commit5
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-30 10:55:18 +02:00
parent 0235c7609e
commit 48347e2d3a
161 changed files with 11814 additions and 265 deletions
+2
View File
@@ -10,6 +10,7 @@ enum PurchasingRole: string
case Finance = 'finance';
case Warehouse = 'warehouse';
case Admin = 'admin';
case Configured = 'configured';
public function label(): string
{
@@ -20,6 +21,7 @@ enum PurchasingRole: string
self::Finance => 'Finanças',
self::Warehouse => 'Recepção / Armazém',
self::Admin => 'Administrador',
self::Configured => 'Perfil definido pela empresa',
};
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ class BudgetController extends Controller
{
private function access(Request $request): void
{
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance]), 403);
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
}
public function index(Request $request): Response
@@ -13,7 +13,7 @@ class BudgetReinforcementController extends Controller
{
public function store(Request $request, Budget $budget, BudgetReinforcementWorkflow $workflow): RedirectResponse
{
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance]), 403);
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
$data = $request->validate(['reference' => 'required|string|max:100', 'amount' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'], 'reason' => 'required|string|max:4000']);
$workflow->propose($request->user(), $budget, $data);
+19 -7
View File
@@ -7,6 +7,8 @@ use App\Models\CostCenter;
use App\Models\Department;
use App\Models\Tenant;
use App\Models\User;
use App\Services\OrganizationAccess;
use App\Services\OrganizationConfiguration;
use App\Services\RequisitionWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -24,30 +26,39 @@ class CompanyController extends Controller
public function store(Request $request, RequisitionWorkflow $workflow): RedirectResponse
{
$data = $request->validate(['name' => 'required|string|max:150', 'department' => 'required|string|max:100']);
abort_if($request->user()->tenant_id !== null, 403);
$data = $request->validate(['name' => 'required|string|max:150', 'department' => 'required|string|max:100',
'second_name' => 'required|string|max:100', 'second_email' => 'required|email|max:255|unique:users,email', 'second_password' => 'required|string|min:12|confirmed',
'first_limit' => ['required', 'string', 'regex:/^\d{1,12}(\.\d{1,2})?$/D'], 'second_limit' => ['required', 'string', 'regex:/^\d{1,12}(\.\d{1,2})?$/D'], 'includes_tax' => 'required|boolean']);
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']]);
$tenant = Tenant::query()->create(['name' => $data['name'], ...OrganizationConfiguration::rules($data)]);
$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']]);
$user->forceFill(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Configured])->save();
$second = new User;
$second->forceFill(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'name' => $data['second_name'], 'email' => $data['second_email'], 'password' => $data['second_password'], 'purchasing_role' => PurchasingRole::Configured])->save();
app(OrganizationConfiguration::class)->initialise($user, $second);
$workflow->audit($user, 'company.created', null, null, ['name' => $data['name'], 'initial_administrators' => [$user->id, $second->id], 'rules' => $tenant->only(['first_limit_minor', 'second_limit_minor', 'approval_includes_tax', 'rules_provisional'])]);
});
return to_route('dashboard');
}
public function index(Request $request): Response
public function index(Request $request): Response|RedirectResponse
{
$this->admin($request);
if (app(OrganizationAccess::class)->active($request->user()->tenant_id)) {
return to_route('organization.parameters');
}
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()]),
'roles' => collect(PurchasingRole::cases())->reject(fn (PurchasingRole $role) => $role === PurchasingRole::Configured)->values()->map(fn (PurchasingRole $role) => ['value' => $role->value, 'label' => $role->label()]),
]);
}
@@ -82,6 +93,7 @@ class CompanyController extends Controller
public function user(Request $request, RequisitionWorkflow $workflow): RedirectResponse
{
$this->admin($request);
abort_if(app(OrganizationAccess::class)->active($request->user()->tenant_id), 409, 'Crie utilizadores e atribua perfis na parametrização.');
$data = $request->validate([
'name' => 'required|string|max:100', 'email' => 'required|email|max:255|unique:users',
'password' => 'required|string|min:12|confirmed',
@@ -99,6 +111,6 @@ class CompanyController extends Controller
private function admin(Request $request): void
{
abort_unless($request->user()->purchasing_role === PurchasingRole::Admin, 403);
app(OrganizationConfiguration::class)->administrator($request->user());
}
}
@@ -20,7 +20,7 @@ class DocumentaryQualificationController extends Controller
{
private function access(Request $request, int $id): Requisition
{
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
return Requisition::query()->visibleTo($request->user())->findOrFail($id);
}
@@ -47,7 +47,7 @@ class DocumentaryQualificationController extends Controller
'proposals' => $proposals,
'closedForChanges' => $closed,
'records' => $records, 'history' => $query->latest('id')->paginate(20)->withQueryString(), 'actorId' => $request->user()->id,
'canDefine' => $open && $proposals !== [] && $request->user()->purchasing_role === PurchasingRole::Procurement,
'canDefine' => $open && $proposals !== [] && $request->user()->hasFunctions([PurchasingRole::Procurement]),
'canAssess' => $open && $current && $request->user()->id !== $requisition->requester_id && app(ProposalComparison::class)->eligibleRole($request->user(), $requisition),
])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
+7 -7
View File
@@ -28,15 +28,15 @@ class EquipmentController extends Controller
{
public function index(Request $request): Response
{
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
$assets = Equipment::query();
if ($request->user()->purchasing_role === PurchasingRole::DepartmentHead) {
if ($request->user()->departmentScoped()) {
$assets->where('department_id', $request->user()->department_id);
}
$equipment = $assets->orderBy('code')->get();
$links = EquipmentSparePart::query()->whereIn('equipment_id', $equipment->pluck('id'))->get();
$parts = SparePart::query();
if ($request->user()->purchasing_role === PurchasingRole::DepartmentHead) {
if ($request->user()->departmentScoped()) {
$parts->whereIn('id', $links->pluck('spare_part_id'));
}
$parts = $parts->get()->map(function (SparePart $part) {
@@ -47,7 +47,7 @@ class EquipmentController extends Controller
});
$items = StockItem::query();
$departments = Department::query();
if ($request->user()->purchasing_role === PurchasingRole::DepartmentHead) {
if ($request->user()->departmentScoped()) {
$items->whereIn('id', $parts->pluck('stock_item_id')->merge($equipment->pluck('stock_item_id')->filter()));
$departments->whereKey($request->user()->department_id);
}
@@ -57,8 +57,8 @@ class EquipmentController extends Controller
'items' => $items->orderBy('code')->get(['id', 'code', 'name', 'unit']),
'departments' => $departments->orderBy('name')->get(['id', 'name']),
'history' => StockOperation::query()->whereNotNull('equipment_id')->whereIn('equipment_id', $equipment->pluck('id'))->latest('id')->paginate(15),
'canManage' => in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement]),
'canConfigure' => $request->user()->purchasing_role === PurchasingRole::Admin,
'canManage' => $request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement]),
'canConfigure' => $request->user()->hasFunctions([PurchasingRole::Admin]),
]);
}
@@ -66,7 +66,7 @@ class EquipmentController extends Controller
{
$user = $request->user();
abort_unless(in_array($kind, ['categorias', 'activos', 'pecas', 'associacoes']), 404);
abort_unless($kind === 'categorias' ? $user->purchasing_role === PurchasingRole::Admin : in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement]), 403);
abort_unless($kind === 'categorias' ? $user->hasFunctions([PurchasingRole::Admin]) : $user->hasFunctions([PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement]), 403);
$tenant = $user->tenant_id;
$exists = fn (string $table) => Rule::exists($table, 'id')->where('tenant_id', $tenant);
$request->merge(['code' => Str::upper(Str::squish($request->string('code')->toString()))]);
@@ -19,8 +19,8 @@ class ExternalPaymentController extends Controller
{
public function index(Request $request, ExternalPaymentWorkflow $workflow): Response
{
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance]), 403);
$canManage = $request->user()->purchasing_role === PurchasingRole::Finance;
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
$canManage = $request->user()->hasFunctions([PurchasingRole::Finance]);
$payments = ExternalPayment::query()->latest('id')->paginate(15)->through(fn ($payment) => [...$payment->toArray(), 'external_reference' => $canManage ? $payment->external_reference : null, 'evidence_reference' => $canManage ? $payment->evidence_reference : null]);
if ($canManage && $payments->count() > 0) {
app(RequisitionWorkflow::class)->audit($request->user(), 'payment.viewed', null, null, ['payment_ids' => $payments->pluck('id')->all()]);
@@ -40,7 +40,7 @@ class ExternalPaymentController extends Controller
public function store(Request $request, Invoice $invoice, ExternalPaymentWorkflow $workflow): RedirectResponse
{
abort_unless($request->user()->purchasing_role === PurchasingRole::Finance, 403);
abort_unless($request->user()->hasFunctions([PurchasingRole::Finance]), 403);
$data = $request->validate(['supplier_bank_detail_id' => 'required|integer|min:1', 'amount' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'], 'paid_on' => 'required|date_format:Y-m-d', 'external_reference' => 'required|string|max:150', 'evidence_reference' => 'required|string|max:1000']);
$workflow->register($request->user(), $invoice, $data);
@@ -21,14 +21,14 @@ class FormalAdmissionController extends Controller
{
private function access(Request $request, int $id): Requisition
{
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
return Requisition::query()->visibleTo($request->user())->findOrFail($id);
}
public function policy(Request $request): Response
{
abort_unless($request->user()->purchasing_role === PurchasingRole::Admin, 403);
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin]), 403);
Inertia::encryptHistory();
$response = Inertia::render('purchases/admission-policy', ['policies' => AdmissionPolicy::query()->latest('revision')->paginate(20)->withQueryString(), 'latest' => AdmissionPolicy::query()->latest('revision')->first()])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
@@ -71,7 +71,7 @@ class FormalAdmissionController extends Controller
'canReview' => $open && $fresh && $workflow->status($decision, $selected) === 'pending' && $workflow->eligible($request->user(), $requisition, $selected, $decision) && ! AdmissionReview::query()->where('admission_decision_id', $decision->id)->where('reviewed_by', $request->user()->id)->exists()];
});
Inertia::encryptHistory();
$response = Inertia::render('purchases/admission', ['requisition' => $requisition->only(['id', 'number']), 'policy' => AdmissionPolicy::query()->latest('revision')->first(), 'adoptions' => AdmissionAdoption::query()->where('requisition_id', $requisition->id)->latest('revision')->get(), 'adoption' => $adoption, 'selected' => $selected, 'proposals' => $proposals, 'history' => $history, 'canAdopt' => $open && $request->user()->purchasing_role === PurchasingRole::Procurement, 'canPropose' => $open && $current && $request->user()->purchasing_role === PurchasingRole::Procurement && $request->user()->id !== $requisition->requester_id, 'closed' => $closed])->toResponse($request);
$response = Inertia::render('purchases/admission', ['requisition' => $requisition->only(['id', 'number']), 'policy' => AdmissionPolicy::query()->latest('revision')->first(), 'adoptions' => AdmissionAdoption::query()->where('requisition_id', $requisition->id)->latest('revision')->get(), 'adoption' => $adoption, 'selected' => $selected, 'proposals' => $proposals, 'history' => $history, 'canAdopt' => $open && $request->user()->hasFunctions([PurchasingRole::Procurement]), 'canPropose' => $open && $current && $request->user()->hasFunctions([PurchasingRole::Procurement]) && $request->user()->id !== $requisition->requester_id, 'closed' => $closed])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
@@ -23,14 +23,14 @@ class FormalRecommendationController extends Controller
{
private function access(Request $request, int $id): Requisition
{
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
return Requisition::query()->visibleTo($request->user())->findOrFail($id);
}
public function policy(Request $request): Response
{
abort_unless($request->user()->purchasing_role === PurchasingRole::Admin, 403);
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin]), 403);
Inertia::encryptHistory();
$response = Inertia::render('purchases/recommendation-policy', ['policies' => RecommendationPolicy::query()->latest('revision')->paginate(20)->withQueryString(), 'latest' => RecommendationPolicy::query()->latest('revision')->first()])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
@@ -68,7 +68,7 @@ class FormalRecommendationController extends Controller
Inertia::encryptHistory();
$response = Inertia::render('purchases/recommendation', ['requisition' => $requisition->only(['id', 'number']), 'policy' => RecommendationPolicy::query()->latest('revision')->first(), 'adoption' => $adoption, 'selected' => $selected, 'adoptions' => RecommendationAdoption::query()->where('requisition_id', $requisition->id)->latest('revision')->get(), 'hasAdmission' => app(FormalAdmission::class)->adoption($requisition) !== null,
'basis' => ['hash' => $basis['hash'], 'ready' => $basis['ready'], 'top_ids' => $basis['top_ids'], 'rows' => collect($basis['payload']['rows'])->map(fn ($row) => array_intersect_key($row, array_flip(['id', 'reference', 'supplier_name', 'currency', 'total_minor', 'eligible', 'ineligibility_reasons', 'evaluation_count', 'score_minor'])))->all()], 'previousId' => $latest->id ?? 0,
'history' => $history, 'closed' => $closed, 'canAdopt' => $open && $request->user()->purchasing_role === PurchasingRole::Procurement, 'canPropose' => $open && $current && $basis['ready'] && $request->user()->purchasing_role === PurchasingRole::Procurement && $request->user()->id !== $requisition->requester_id])->toResponse($request);
'history' => $history, 'closed' => $closed, 'canAdopt' => $open && $request->user()->hasFunctions([PurchasingRole::Procurement]), 'canPropose' => $open && $current && $basis['ready'] && $request->user()->hasFunctions([PurchasingRole::Procurement]) && $request->user()->id !== $requisition->requester_id])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
@@ -2,7 +2,6 @@
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Http\Requests\ConfirmReceiptRequest;
use App\Models\GoodsReceipt;
use App\Models\PurchaseOrder;
@@ -26,7 +25,7 @@ class GoodsReceiptController extends Controller
{
Gate::authorize('viewAny', GoodsReceipt::class);
$orders = PurchaseOrder::query();
if ($request->user()->purchasing_role === PurchasingRole::DepartmentHead) {
if ($request->user()->departmentScoped()) {
$orders->whereIn('requisition_id', Requisition::query()->visibleTo($request->user())->select('id'));
}
@@ -15,15 +15,15 @@ class InvestmentPolicyController extends Controller
{
public function index(Request $request, InvestmentWorkflow $workflow): Response
{
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement]), 403);
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement]), 403);
$current = $workflow->current();
return Inertia::render('investment/index', ['policies' => InvestmentPolicy::query()->latest('revision')->get(), 'reviews' => InvestmentPolicyReview::query()->orderBy('id')->get(), 'currentPolicyId' => $current && $current->valid_until >= today()->format('Y-m-d') ? $current->id : null, 'canManage' => $request->user()->purchasing_role === PurchasingRole::Admin]);
return Inertia::render('investment/index', ['policies' => InvestmentPolicy::query()->latest('revision')->get(), 'reviews' => InvestmentPolicyReview::query()->orderBy('id')->get(), 'currentPolicyId' => $current && $current->valid_until >= today()->format('Y-m-d') ? $current->id : null, 'canManage' => $request->user()->hasFunctions([PurchasingRole::Admin])]);
}
public function store(Request $request, InvestmentWorkflow $workflow): RedirectResponse
{
abort_unless($request->user()->purchasing_role === PurchasingRole::Admin, 403);
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin]), 403);
$data = $request->validate(['previous_id' => 'required|integer|min:0', 'mode' => 'required|in:ceiling', 'scope' => 'required|in:process_total', 'currency' => 'required|in:MZN', 'ceiling' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'], 'includes_tax' => 'required|boolean', 'valid_from' => 'required|date_format:Y-m-d', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:valid_from', 'reason' => 'required|string|max:4000']);
$workflow->propose($request->user(), $data);
+3 -3
View File
@@ -16,14 +16,14 @@ class InvoiceController extends Controller
{
public function index(Request $request): Response
{
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance]), 403);
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
return Inertia::render('invoices/index', ['invoices' => Invoice::query()->with('items')->latest('id')->paginate(15), 'orders' => PurchaseOrder::query()->with('items')->latest('id')->get()->map(fn ($order) => ['id' => $order->id, 'number' => $order->number, 'supplier' => $order->snapshot['supplier']['name'], 'items' => $order->items]), 'payablesAvailable' => SupplierPayables::enabled() && app(SupplierPayables::class)->can($request->user(), 'view'), 'canManage' => $request->user()->purchasing_role === PurchasingRole::Finance]);
return Inertia::render('invoices/index', ['invoices' => Invoice::query()->with('items')->latest('id')->paginate(15), 'orders' => PurchaseOrder::query()->with('items')->latest('id')->get()->map(fn ($order) => ['id' => $order->id, 'number' => $order->number, 'supplier' => $order->snapshot['supplier']['name'], 'items' => $order->items]), 'payablesAvailable' => SupplierPayables::enabled() && app(SupplierPayables::class)->can($request->user(), 'view'), 'canManage' => $request->user()->hasFunctions([PurchasingRole::Finance])]);
}
public function store(Request $request, PurchaseOrder $order, InvoiceWorkflow $workflow): RedirectResponse
{
abort_unless($request->user()->purchasing_role === PurchasingRole::Finance, 403);
abort_unless($request->user()->hasFunctions([PurchasingRole::Finance]), 403);
$decimal = ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'];
$data = $request->validate(['number' => 'required|string|max:100', 'issued_on' => 'required|date_format:Y-m-d', 'subtotal' => $decimal, 'tax' => $decimal, 'total' => $decimal, 'items' => 'required|array|min:1|max:50', 'items.*.purchase_order_item_id' => 'required|integer|distinct', 'items.*.quantity' => ['required', 'regex:/^\d{1,9}(\.\d{1,3})?$/'], 'items.*.unit' => 'required|string|max:30', 'items.*.unit_price' => $decimal, 'items.*.tax_rate' => ['required', 'regex:/^\d{1,3}(\.\d{1,2})?$/']]);
$workflow->register($request->user(), $order, $data);
@@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers;
use App\Models\Tenant;
use App\Services\OrganizationAccess;
use App\Services\OrganizationConfiguration;
use Brick\Math\BigDecimal;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class OrganizationConfigurationController extends Controller
{
public function index(Request $request, OrganizationConfiguration $service): Response
{
$actor = $service->administrator($request->user());
$ready = Schema::hasTable('organization_access');
$state = $ready ? DB::table('organization_access')->where('tenant_id', $actor->tenant_id)->first() : null;
Inertia::encryptHistory();
$response = Inertia::render('company/parameters', ['ready' => $ready, 'active' => (bool) ($state->active ?? false), 'version' => $state->version ?? 1,
'companyRules' => $this->companyRules((int) $actor->tenant_id),
'permissions' => OrganizationAccess::PERMISSIONS,
'profiles' => $ready ? DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->orderBy('name')->get()->map(fn ($p) => ['id' => $p->id, 'name' => $p->name, 'permissions' => json_decode($p->permissions, true, 32, JSON_THROW_ON_ERROR)]) : [],
'memberships' => $ready ? DB::table('organization_memberships')->where('tenant_id', $actor->tenant_id)->get(['user_id', 'profile_id', 'maintenance', 'expires_at', 'reason']) : [],
'users' => DB::table('users')->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'name', 'email', 'department_id']),
'departments' => DB::table('departments')->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'name']),
'costCenters' => DB::table('cost_centers')->where('tenant_id', $actor->tenant_id)->orderBy('code')->get(['id', 'code', 'name']),
'notice' => $request->session()->get('organization_notice')])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function command(Request $request, string $command, OrganizationConfiguration $service): Response
{
$actor = $service->administrator($request->user());
abort_unless(Schema::hasTable('organization_access'), 503, 'A instalação da parametrização ainda não está concluída.');
$rules = ['version' => 'required|integer|min:1', 'tenant_id' => 'prohibited', 'purchasing_role' => 'prohibited'];
if ($command === 'profile') {
$rules += ['id' => 'nullable|integer|min:1', 'name' => ['required', 'string', 'max:120', Rule::unique('organization_profiles')->where('tenant_id', $actor->tenant_id)->ignore($request->integer('id'))],
'permissions' => 'present|array', 'permissions.*' => ['string', 'distinct', Rule::in(array_keys(OrganizationAccess::PERMISSIONS))]];
} elseif ($command === 'user') {
$rules += ['name' => 'required|string|max:100', 'email' => 'required|email|max:255|unique:users', 'password' => 'required|string|min:12|confirmed',
'department_id' => 'required|integer|min:1', 'profile_ids' => 'present|array', 'profile_ids.*' => 'integer|distinct|min:1', 'reason' => 'required|string|min:10|max:4000'];
} elseif ($command === 'assign') {
$rules += ['user_id' => 'required|integer|min:1', 'profile_ids' => 'present|array', 'profile_ids.*' => 'integer|distinct|min:1', 'maintenance' => 'required|boolean',
'expires_at' => 'nullable|date|after:now', 'reason' => 'required|string|min:10|max:4000'];
} elseif ($command === 'rules') {
$rules += ['first_limit' => ['required', 'string', 'regex:/^\d{1,12}(\.\d{1,2})?$/D'], 'second_limit' => ['required', 'string', 'regex:/^\d{1,12}(\.\d{1,2})?$/D'], 'includes_tax' => 'required|boolean', 'reason' => 'required|string|min:10|max:4000'];
} else {
abort_unless($command === 'activate', 404);
$rules += ['reason' => 'required|string|min:10|max:4000', 'confirm' => 'required|accepted'];
}
$service->execute($actor, $command, $request->validate($rules));
return to_route('organization.parameters')->with('organization_notice', 'Parametrização actualizada e registada na auditoria.');
}
/** @return array<string,mixed> */
private function companyRules(int $tenant): array
{
$company = Tenant::query()->findOrFail($tenant);
return ['first_limit' => (string) BigDecimal::of((string) $company->first_limit_minor)->dividedBy(100, 2),
'second_limit' => (string) BigDecimal::of((string) $company->second_limit_minor)->dividedBy(100, 2),
'includes_tax' => $company->approval_includes_tax, 'provisional' => $company->rules_provisional];
}
}
@@ -0,0 +1,146 @@
<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\Department;
use App\Models\PurchaseOrder;
use App\Models\Quotation;
use App\Models\Requisition;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class ProcurementReportController extends Controller
{
private function snapshot(\Closure $read): mixed
{
if (DB::connection()->getDriverName() === 'pgsql' && DB::transactionLevel() !== 0) {
throw new \LogicException('O relatório exige uma transacção própria de leitura.');
}
return DB::transaction(function () use ($read) {
if (DB::connection()->getDriverName() === 'pgsql') {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
}
return $read();
});
}
/** @return array<string,mixed> */
private function filters(Request $request): array
{
$actor = User::query()->findOrFail($request->user()->id);
abort_unless($actor->tenant_id === $request->user()->tenant_id && $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
$request->setUserResolver(fn () => $actor);
$filters = $request->validate([
'search' => 'nullable|string|max:100',
'status' => 'nullable|in:draft,pending,approved,rejected,cancelled',
'from' => 'nullable|date_format:Y-m-d',
'to' => 'nullable|date_format:Y-m-d',
'department_id' => 'nullable|integer|min:1',
]);
if (! empty($filters['from']) && ! empty($filters['to']) && $filters['from'] > $filters['to']) {
throw ValidationException::withMessages(['to' => 'A data final deve ser igual ou posterior à data inicial.']);
}
return $filters;
}
/** @param array<string,mixed> $filters
* @return Builder<Requisition>
*/
private function query(User $actor, array $filters): Builder
{
$query = Requisition::query()->where('tenant_id', $actor->tenant_id)->visibleTo($actor);
if (isset($filters['search']) && $filters['search'] !== '') {
$query->where('number', 'like', '%'.$filters['search'].'%');
}
foreach (['status', 'department_id'] as $field) {
if (! empty($filters[$field])) {
$query->where($field, $filters[$field]);
}
}
if (! empty($filters['from'])) {
$query->where('created_at', '>=', $filters['from'].' 00:00:00');
}
if (! empty($filters['to'])) {
$query->where('created_at', '<', (new \DateTimeImmutable($filters['to']))->modify('+1 day')->format('Y-m-d').' 00:00:00');
}
return $query;
}
/** @param Builder<Requisition> $query
* @return Builder<Requisition>
*/
private function records(Builder $query): Builder
{
return $query->select('requisitions.*')->with(['department:id,name', 'requester:id,name'])->addSelect([
'quotations_count' => Quotation::query()->selectRaw('COUNT(*)')->whereColumn('requisition_id', 'requisitions.id'),
'order_number' => PurchaseOrder::query()->visibleTo(request()->user())->select('number')->whereColumn('requisition_id', 'requisitions.id')->orderBy('id')->limit(1),
]);
}
/** @return array<string,mixed> */
private function row(Requisition $record): array
{
return [
'id' => $record->id, 'number' => $record->number, 'status' => $record->status,
'created_at' => $record->created_at?->toIso8601String(),
'department' => $record->department?->name, 'requester' => $record->requester?->name,
'total_minor' => (string) $record->total_minor,
'quotations_count' => (int) $record->getAttribute('quotations_count'),
'order_number' => $record->getAttribute('order_number'),
];
}
public function index(Request $request): Response
{
$props = $this->snapshot(function () use ($request): array {
$filters = $this->filters($request);
$query = $this->query($request->user(), $filters);
$counts = (clone $query)->selectRaw('status, COUNT(*) AS count')->groupBy('status')->pluck('count', 'status')->map(fn ($value) => (int) $value);
$records = $this->records(clone $query)->orderByDesc('id')->paginate(25)->withQueryString()->through(fn (Requisition $record) => $this->row($record));
$departments = Department::query()->when($request->user()->departmentScoped(), fn ($q) => $q->whereKey($request->user()->department_id))->orderBy('name')->get(['id', 'name']);
return ['records' => $records, 'counts' => $counts, 'filters' => $filters, 'departments' => $departments, 'generatedAt' => now()->toIso8601String()];
});
Inertia::encryptHistory();
$response = Inertia::render('reports/procurement', $props)->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function export(Request $request): Response
{
$filters = $this->filters($request);
$query = $this->records($this->query($request->user(), $filters));
$text = fn (?string $value) => preg_match('/^[\s]*[=+\-@]|^[\t\r\n]/u', $value ?? '') ? "'".$value : ($value ?? '');
return response()->streamDownload(fn () => $this->snapshot(function () use ($query, $text, $filters) {
$output = fopen('php://output', 'w');
if ($output === false) {
throw new \RuntimeException('Não foi possível gerar o ficheiro.');
}
fwrite($output, "\xEF\xBB\xBF");
fputcsv($output, ['Relatório de compras', 'Criado em', now()->toIso8601String()], ';', '"', '');
fputcsv($output, ['Filtros', $text(json_encode($filters, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR))], ';', '"', '');
fputcsv($output, ['Requisição', 'Criada em', 'Departamento', 'Solicitante', 'Estado da requisição', 'Estimativa total (centavos MZN)', 'Propostas registadas', 'Ordem de compra', 'Processo', 'Relatório de conferência'], ';', '"', '');
foreach ($query->lazyById(200) as $record) {
$row = $this->row($record);
$base = url('/requisicoes/'.$record->id);
fputcsv($output, [$text($record->number), $row['created_at'], $text($row['department']), $text($row['requester']), $record->status, $row['total_minor'], $row['quotations_count'], $text($row['order_number']), $base.'/processo', $base.'/relatorio'], ';', '"', '');
}
fclose($output);
}), 'relatorio-compras-'.now()->format('Ymd-His').'.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store, max-age=0']);
}
}
@@ -19,7 +19,7 @@ class ProposalOpeningController extends Controller
{
private function access(Request $request, int $requisitionId): Requisition
{
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
return Requisition::query()->visibleTo($request->user())->findOrFail($requisitionId);
}
@@ -36,7 +36,7 @@ class ProposalOpeningController extends Controller
'requisition' => $requisition->only(['id', 'number', 'justification']), 'proposals' => $proposals, 'basisHash' => $workflow->basis($proposals), 'openings' => $openings,
'sourceChanged' => $latest && $workflow->basis($proposals) !== $latest->basis_hash,
'latestId' => $latest->id ?? 0,
'canRecord' => $request->user()->purchasing_role === PurchasingRole::Procurement && $requisition->status === 'approved' && ! PurchaseAward::query()->where('requisition_id', $requisition->id)->exists() && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition),
'canRecord' => $request->user()->hasFunctions([PurchasingRole::Procurement]) && $requisition->status === 'approved' && ! PurchaseAward::query()->where('requisition_id', $requisition->id)->exists() && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition),
])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
@@ -10,6 +10,7 @@ use App\Models\PurchaseControlReview;
use App\Models\PurchaseOrder;
use App\Models\PurchaseRevision;
use App\Models\Requisition;
use App\Services\OrganizationAccess;
use App\Services\PurchaseControlPolicyWorkflow;
use App\Services\PurchaseRevisionWorkflow;
use Illuminate\Http\RedirectResponse;
@@ -22,13 +23,13 @@ class PurchaseControlController extends Controller
public function index(Request $request): Response
{
$user = $request->user();
$operational = in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance]);
$operational = $user->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance]);
$sources = Requisition::query()->where('requester_id', $user->id)->select('id');
$ownAwards = PurchaseAward::query()->whereIn('requisition_id', $sources)->select('id');
$cancellations = AwardCancellation::query()->whereIn('purchase_award_id', $ownAwards)->where('status', 'approved')->get(['id', 'purchase_award_id']);
return Inertia::render('purchase-controls/index', [
'role' => $user->purchasing_role->value, 'actorId' => $user->id,
'role' => $user->purchasing_role->value ?? '', 'permissions' => app(OrganizationAccess::class)->permissions($user), 'actorId' => $user->id,
'policies' => $operational ? PurchaseControlPolicy::query()->latest('revision')->get() : [],
'reviews' => $operational ? PurchaseControlReview::query()->get() : [],
'awards' => $operational ? PurchaseAward::query()->whereNotIn('requisition_id', PurchaseOrder::query()->select('requisition_id'))->latest('id')->get(['id', 'requisition_id', 'status']) : [],
@@ -44,7 +45,7 @@ class PurchaseControlController extends Controller
public function propose(Request $request, PurchaseControlPolicyWorkflow $workflow): RedirectResponse
{
abort_unless($request->user()->purchasing_role === PurchasingRole::Admin, 403);
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin]), 403);
$data = $request->validate(['previous_id' => 'required|integer|min:0', 'mode' => 'required|in:unissued_award', 'enabled' => 'required|boolean', 'valid_from' => 'required|date_format:Y-m-d', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:valid_from', 'reason' => 'required|string|min:10|max:4000']);
$workflow->propose($request->user(), $data);
+3 -3
View File
@@ -24,7 +24,7 @@ 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);
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
}
public function award(Request $request, Requisition $requisition, ProposalComparison $comparison, PurchaseWorkflow $workflow): Response
@@ -50,10 +50,10 @@ class PurchaseController extends Controller
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 && (! $adoption || $candidate !== null),
'canSelect' => $request->user()->hasFunctions([PurchasingRole::Procurement]) && $award === null && $request->user()->id !== $requisition->requester_id && (! $adoption || $candidate !== null),
'recommendation' => ['adopted' => $adoption !== null, 'quotation_id' => $candidate],
'canDecide' => ! $frozen && $award !== null && $workflow->canDecide($request->user(), $award, $requisition),
'canIssue' => ! $frozen && $award?->status === 'approved' && $request->user()->purchasing_role === PurchasingRole::Procurement,
'canIssue' => ! $frozen && $award?->status === 'approved' && $request->user()->hasFunctions([PurchasingRole::Procurement]),
'order' => PurchaseOrder::query()->where('requisition_id', $requisition->id)->first(['id', 'number']),
]);
}
@@ -21,7 +21,7 @@ class PurchasePlanningController extends Controller
public function index(Request $request): Response
{
$actor = $request->user();
$canManage = in_array($actor->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement]);
$canManage = $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement]);
$filters = $request->validate(['item' => 'nullable|integer|min:1']);
$items = PurchasePlanItem::query()->visibleTo($actor)->when($filters['item'] ?? null, fn ($query, $id) => $query->whereKey($id))->latest('id')->paginate(15)->withQueryString();
$milestones = PurchasePlanMilestone::query()->whereIn('purchase_plan_item_id', $items->pluck('id'))->orderBy('planned_on')->orderBy('id')->get();
@@ -30,10 +30,9 @@ class PurchaseProcessController extends Controller
$actor = $request->user();
Requisition::query()->visibleTo($actor)->findOrFail($requisition->id);
$requisition->load(['requester:id,name', 'department:id,name', 'steps']);
$role = $actor->purchasing_role;
$purchases = in_array($role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
$finance = in_array($role, [PurchasingRole::Admin, PurchasingRole::Finance]);
$inventory = $purchases || $role === PurchasingRole::Warehouse;
$purchases = $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
$finance = $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]);
$inventory = $purchases || $actor->hasFunctions([PurchasingRole::Warehouse]);
$base = '/requisicoes/'.$requisition->id;
$stages = [];
$documents = [['label' => $requisition->number, 'kind' => 'Requisição', 'href' => $base]];
+5 -5
View File
@@ -24,7 +24,7 @@ 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);
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
}
public function index(Request $request): Response
@@ -46,15 +46,15 @@ class QuotationController extends Controller
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),
'canCreate' => ! $closed && $request->user()->hasFunctions([PurchasingRole::Procurement]) && $requisition->status === 'approved' && ! $guard->participationBlocked($request->user(), $requisition),
'canEvaluate' => ! $closed && $request->user()->hasFunctions([PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]) && $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);
abort_unless($request->user()->hasFunctions([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',
@@ -120,7 +120,7 @@ class QuotationController extends Controller
public function evaluate(Request $request, Quotation $quotation, RequisitionWorkflow $workflow): RedirectResponse
{
$this->access($request);
abort_if($request->user()->purchasing_role === PurchasingRole::Admin, 403);
abort_unless($request->user()->hasFunctions([PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 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);
@@ -14,6 +14,7 @@ use App\Models\TechnicalReview;
use App\Models\Tenant;
use App\Services\ConflictGuard;
use App\Services\EquipmentWorkflow;
use App\Services\OrganizationAccess;
use App\Services\RequisitionWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -51,6 +52,8 @@ class RequisitionController extends Controller
public function create(Request $request): Response
{
abort_unless(app(OrganizationAccess::class)->canRequest($request->user()), 403);
return Inertia::render('requisitions/form', [
'costCenters' => CostCenter::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(),
'requisition' => null,
@@ -72,7 +75,7 @@ class RequisitionController extends Controller
return Inertia::render('requisitions/show', [
'requisition' => $requisition,
'canEdit' => $requisition->requester_id === $request->user()->id && $requisition->status === 'draft',
'canEdit' => app(OrganizationAccess::class)->canRequest($request->user()) && $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']),
@@ -86,6 +89,7 @@ class RequisitionController extends Controller
public function edit(Request $request, Requisition $requisition): Response
{
abort_unless(app(OrganizationAccess::class)->canRequest($request->user()), 403);
abort_unless($requisition->requester_id === $request->user()->id && $requisition->status === 'draft', 403);
return Inertia::render('requisitions/form', [
+2 -2
View File
@@ -21,7 +21,7 @@ class StockController extends Controller
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(in_array($request->user()->purchasing_role, $roles), 403);
abort_unless($request->user()->hasFunctions($roles), 403);
}
public function index(Request $request): Response
@@ -49,7 +49,7 @@ class StockController extends Controller
return Inertia::render('stock/index', [
'items' => $items, 'warehouses' => $warehouses, 'balances' => $balances,
'movements' => $movements->latest('id')->paginate(20)->withQueryString(), 'filters' => $filters,
'canManage' => in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Warehouse]),
'canManage' => $request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Warehouse]),
]);
}
@@ -22,7 +22,7 @@ class StockOperationController extends Controller
{
public function index(Request $request, StockWorkflow $workflow): Response
{
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Warehouse, PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement]), 403);
abort_unless($request->user()->hasFunctions([PurchasingRole::Warehouse, PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement]), 403);
$items = StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']);
$warehouses = Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']);
$balances = [];
@@ -37,14 +37,14 @@ class StockOperationController extends Controller
'users' => User::query()->where('tenant_id', $request->user()->tenant_id)->orderBy('name')->get(['id', 'name']),
'operations' => StockOperation::query()->latest('id')->paginate(20),
'counts' => StockOperation::query()->where('kind', 'count')->whereNotIn('id', StockOperation::query()->whereNotNull('count_id')->select('count_id'))->latest('id')->get(),
'canOperate' => $request->user()->purchasing_role === PurchasingRole::Warehouse,
'canOperate' => $request->user()->hasFunctions([PurchasingRole::Warehouse]),
'equipment' => Equipment::query()->orderBy('code')->get(['id', 'code', 'description']),
]);
}
public function store(Request $request, StockWorkflow $workflow): JsonResponse|RedirectResponse
{
abort_unless($request->user()->purchasing_role === PurchasingRole::Warehouse, 403);
abort_unless($request->user()->hasFunctions([PurchasingRole::Warehouse]), 403);
$request->merge(['reference' => Str::upper(Str::squish($request->string('reference')->toString())), 'reason' => trim($request->string('reason')->toString()), 'destination' => trim($request->string('destination')->toString())]);
$tenant = $request->user()->tenant_id;
$data = $request->validate([
@@ -18,10 +18,10 @@ class SupplierBankController extends Controller
{
public function show(Request $request, Supplier $supplier, SupplierBankWorkflow $workflow): Response
{
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance]), 403);
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance]), 403);
$details = SupplierBankDetail::query()->where('supplier_id', $supplier->id)->latest('revision')->get();
$current = $details->first();
$canRead = in_array($request->user()->purchasing_role, [PurchasingRole::Procurement, PurchasingRole::Finance]);
$canRead = $request->user()->hasFunctions([PurchasingRole::Procurement, PurchasingRole::Finance]);
$data = null;
if ($current && $canRead) {
app(RequisitionWorkflow::class)->audit($request->user(), 'supplier.bank_viewed', null, null, ['supplier_id' => $supplier->id, 'bank_detail_id' => $current->id]);
@@ -29,7 +29,7 @@ class SupplierBankController extends Controller
}
Inertia::encryptHistory();
$response = Inertia::render('suppliers/bank', ['supplier' => $supplier->only(['id', 'name']), 'current' => $data, 'currentId' => $current->id ?? 0, 'state' => $workflow->state($supplier), 'history' => $details->map(fn ($detail) => $detail->only(['id', 'revision', 'created_by', 'created_at'])), 'reviews' => SupplierBankReview::query()->whereIn('supplier_bank_detail_id', $details->pluck('id'))->orderBy('id')->get(), 'canPropose' => $request->user()->purchasing_role === PurchasingRole::Procurement, 'canReview' => $current && $request->user()->purchasing_role === PurchasingRole::Finance && $current->created_by !== $request->user()->id && ! SupplierBankReview::query()->where('supplier_bank_detail_id', $current->id)->where('reviewed_by', $request->user()->id)->exists()])->toResponse($request);
$response = Inertia::render('suppliers/bank', ['supplier' => $supplier->only(['id', 'name']), 'current' => $data, 'currentId' => $current->id ?? 0, 'state' => $workflow->state($supplier), 'history' => $details->map(fn ($detail) => $detail->only(['id', 'revision', 'created_by', 'created_at'])), 'reviews' => SupplierBankReview::query()->whereIn('supplier_bank_detail_id', $details->pluck('id'))->orderBy('id')->get(), 'canPropose' => $request->user()->hasFunctions([PurchasingRole::Procurement]), 'canReview' => $current && $request->user()->hasFunctions([PurchasingRole::Finance]) && $current->created_by !== $request->user()->id && ! SupplierBankReview::query()->where('supplier_bank_detail_id', $current->id)->where('reviewed_by', $request->user()->id)->exists()])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
+4 -4
View File
@@ -19,14 +19,14 @@ 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);
abort_unless($request->user()->hasFunctions([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]);
return Inertia::render('suppliers/index', ['suppliers' => $query->orderBy('name')->paginate(15)->withQueryString(), 'filters' => $filters, 'canCreate' => $request->user()->hasFunctions([PurchasingRole::Procurement]), 'canReview' => $request->user()->hasFunctions([PurchasingRole::Finance])]);
}
public function store(SaveSupplierRequest $request, RequisitionWorkflow $workflow): RedirectResponse
@@ -50,7 +50,7 @@ class SupplierController extends Controller
'documents' => $canDocuments ? $supplier->documents()->latest('id')->get() : [],
'canEdit' => $request->user()->can('update', $supplier),
'canDocuments' => $canDocuments,
'canReview' => $request->user()->purchasing_role === PurchasingRole::Finance,
'canReview' => $request->user()->hasFunctions([PurchasingRole::Finance]),
]);
}
@@ -84,7 +84,7 @@ class SupplierController extends Controller
public function review(Request $request, Supplier $supplier, RequisitionWorkflow $workflow): RedirectResponse
{
abort_unless($request->user()->purchasing_role === PurchasingRole::Finance, 403);
abort_unless($request->user()->hasFunctions([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);
@@ -0,0 +1,159 @@
<?php
namespace App\Http\Controllers;
use App\Models\AuditLog;
use App\Models\PurchaseOrder;
use App\Models\Supplier;
use App\Models\SupplierEvaluation;
use App\Models\SupplierImprovement;
use App\Models\SupplierResponse;
use App\Models\User;
use App\Services\SupplierEvaluationWorkflow;
use App\Services\SupplierImprovementWorkflow;
use App\Services\SupplierResponseWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class SupplierEvaluationController extends Controller
{
private function ready(): void
{
abort_unless(Schema::hasTable('supplier_evaluations'), 503, 'A instalação do módulo de avaliações ainda não foi concluída.');
}
/** @return array<string,mixed> */
private function payload(Request $request): array
{
return $request->validate([
'payload' => 'required|array:title,nature,scope,period_start,period_end,order_ids,criteria,conclusion,critical_occurrences,critical_evidence',
'payload.title' => 'required|string|min:3|max:200', 'payload.nature' => 'required|in:goods,services',
'payload.scope' => 'nullable|string|max:10000', 'payload.period_start' => 'nullable|date_format:Y-m-d', 'payload.period_end' => 'nullable|date_format:Y-m-d',
'payload.order_ids' => 'present|array|max:50', 'payload.order_ids.*' => 'integer|distinct|min:1',
'payload.criteria' => 'present|array|max:30', 'payload.criteria.*' => 'required|array:label,result,reason,evidence',
'payload.criteria.*.label' => 'nullable|string|max:300', 'payload.criteria.*.result' => 'required|in:pending,observed,insufficient,not_applicable',
'payload.criteria.*.reason' => 'nullable|string|max:4000', 'payload.criteria.*.evidence' => 'nullable|string|max:4000',
'payload.conclusion' => 'nullable|string|max:10000', 'payload.critical_occurrences' => 'nullable|string|max:4000', 'payload.critical_evidence' => 'nullable|string|max:4000',
'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'status' => 'prohibited', 'contributors' => 'prohibited', 'supplier_snapshot' => 'prohibited',
])['payload'];
}
public function index(Request $request, SupplierEvaluationWorkflow $workflow): Response
{
$actor = $workflow->reader($request->user());
$filters = $request->validate(['supplier_id' => 'nullable|integer|min:1', 'status' => 'nullable|in:draft,review,returned,approved']);
$ready = Schema::hasTable('supplier_evaluations');
$records = $ready ? SupplierEvaluation::query()->visibleTo($actor)
->when($filters['supplier_id'] ?? null, fn ($q, $id) => $q->where('supplier_id', $id))
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
->latest('id')->paginate(20)->withQueryString()->through(fn (SupplierEvaluation $e) => [
...$e->only(['id', 'status', 'revision', 'version', 'previous_id']), 'supplier' => $e->supplier_snapshot['name'],
'title' => $e->payload['title'], 'period_start' => $e->payload['period_start'], 'period_end' => $e->payload['period_end'],
]) : null;
return $this->render($request, 'suppliers/evaluations', ['ready' => $ready, 'records' => $records, 'filters' => $filters,
'suppliers' => Supplier::query()->orderBy('name')->get(['id', 'name', 'nuit']),
'orders' => PurchaseOrder::query()->visibleTo($actor)->orderByDesc('id')->get(['id', 'number', 'supplier_id'])]);
}
public function show(Request $request, int $evaluation, SupplierEvaluationWorkflow $workflow): Response
{
return $this->view($request, $evaluation, $workflow, false);
}
public function report(Request $request, int $evaluation, SupplierEvaluationWorkflow $workflow): Response
{
return $this->view($request, $evaluation, $workflow, true);
}
private function view(Request $request, int $id, SupplierEvaluationWorkflow $workflow, bool $report): Response
{
$actor = $workflow->reader($request->user());
$this->ready();
$evaluation = SupplierEvaluation::query()->visibleTo($actor)->findOrFail($id);
$improvementWorkflow = app(SupplierImprovementWorkflow::class);
$measuresReady = Schema::hasTable('supplier_improvements');
$measures = $measuresReady ? SupplierImprovement::query()->where('supplier_evaluation_id', $id)->orderBy('due_on')->orderBy('id')->get() : collect();
$measureHistory = $measuresReady ? AuditLog::query()->where('action', 'like', 'supplier_improvement.%')->where('after->supplier_evaluation_id', $id)->latest('id')->paginate(10, ['id', 'action', 'user_id', 'after', 'created_at'], 'measures_history_page')->withQueryString() : null;
$responsesReady = Schema::hasTable('supplier_responses');
$responses = $responsesReady ? SupplierResponse::query()->where('supplier_evaluation_id', $id)->orderBy('id')->get() : collect();
$responseHistory = $responsesReady ? AuditLog::query()->where('action', 'like', 'supplier_response.%')->where('after->supplier_evaluation_id', $id)->latest('id')->paginate(10, ['id', 'action', 'user_id', 'after', 'created_at'], 'responses_history_page')->withQueryString() : null;
$history = AuditLog::query()->where('action', 'like', 'supplier_evaluation.%')->where('after->evaluation_id', $id)->latest('id')->paginate(10, ['id', 'action', 'user_id', 'after', 'created_at'], 'history_page')->withQueryString();
$peopleIds = collect($evaluation->contributors)->merge($history->pluck('user_id'))->merge($measures->flatMap(fn (SupplierImprovement $m) => $m->contributors))->merge($measures->pluck('reviewed_by'))->merge($measureHistory?->pluck('user_id') ?? []);
$peopleIds = $peopleIds->merge($responses->flatMap(fn (SupplierResponse $r) => $r->contributors))->merge($responses->pluck('reviewed_by'))->merge($responseHistory?->pluck('user_id') ?? []);
$people = User::query()->where('tenant_id', $actor->tenant_id)->whereIn('id', $peopleIds)->pluck('name', 'id');
$writable = $improvementWorkflow->canParticipate($actor, $evaluation);
$next = SupplierEvaluation::query()->visibleTo($actor)->where('previous_id', $id)->value('id');
return $this->render($request, 'suppliers/evaluation', ['evaluation' => $workflow->snapshot($evaluation), 'reportOnly' => $report,
'canEdit' => ! $report && $writable && in_array($evaluation->status, ['draft', 'returned'], true),
'canReview' => ! $report && $writable && $evaluation->status === 'review' && ! in_array($actor->id, $evaluation->contributors, true),
'canRevise' => ! $report && $writable && $evaluation->status === 'approved' && $next === null,
'nextRevisionId' => $next, 'history' => $history, 'people' => $people,
'responses' => ['ready' => $responsesReady, 'canCreate' => ! $report && $responsesReady && $writable && $evaluation->status === 'approved',
'history' => $responseHistory,
'records' => $responses->map(fn (SupplierResponse $r) => [...app(SupplierResponseWorkflow::class)->snapshot($r),
'canEdit' => ! $report && $writable && in_array($r->status, ['draft', 'returned'], true),
'canReview' => ! $report && $writable && $r->status === 'review' && ! in_array($actor->id, $r->contributors, true)])],
'improvements' => ['ready' => $measuresReady, 'canCreate' => ! $report && $measuresReady && $writable && $evaluation->status === 'approved',
'today' => today()->toDateString(),
'records' => $measures->map(fn (SupplierImprovement $m) => [...$improvementWorkflow->snapshot($m),
'canPlan' => ! $report && $writable && in_array($m->status, ['open', 'returned'], true),
'canExecute' => ! $report && $writable && $actor->id === $m->responsible_id && in_array($m->status, ['open', 'returned'], true),
'canReview' => ! $report && $writable && $m->status === 'review' && ! in_array($actor->id, $m->contributors, true)]),
'history' => $measureHistory,
'assignees' => $report ? [] : User::query()->where('tenant_id', $actor->tenant_id)->orderBy('name')->get()->filter(fn (User $u) => $u->hasFunctions(SupplierEvaluationWorkflow::ROLES) && (! $u->departmentScoped() || ($u->department_id !== null && $u->department_id === $evaluation->department_id)))->map(fn (User $u) => $u->only(['id', 'name']))->values()],
'orders' => PurchaseOrder::query()->visibleTo($actor)->where('supplier_id', $evaluation->supplier_id)->orderByDesc('id')->get(['id', 'number', 'supplier_id']),
'generatedAt' => now()->toIso8601String()]);
}
/** @param array<string,mixed> $props */
private function render(Request $request, string $component, array $props): Response
{
Inertia::encryptHistory();
$response = Inertia::render($component, [...$props, 'notice' => $request->session()->get('supplier_evaluation_notice')])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function store(Request $request, SupplierEvaluationWorkflow $workflow): RedirectResponse
{
$this->ready();
$data = $request->validate(['supplier_id' => 'required|integer|min:1', 'operation_key' => 'required|uuid']);
$evaluation = $workflow->create($request->user(), (int) $data['supplier_id'], $data['operation_key'], $this->payload($request));
return to_route('supplier-evaluations.show', $evaluation->id)->with('supplier_evaluation_notice', 'Rascunho guardado. Os dados podem ser recuperados nesta avaliação.');
}
public function update(Request $request, int $evaluation, SupplierEvaluationWorkflow $workflow): RedirectResponse
{
$this->ready();
$data = $request->validate(['version' => 'required|integer|min:1']);
$workflow->save($request->user(), $evaluation, (int) $data['version'], $this->payload($request));
return to_route('supplier-evaluations.show', $evaluation)->with('supplier_evaluation_notice', 'Alterações guardadas no histórico.');
}
public function transition(Request $request, int $evaluation, string $action, SupplierEvaluationWorkflow $workflow): RedirectResponse
{
$this->ready();
$data = $request->validate(['version' => 'required|integer|min:1', 'reason' => $action === 'submit' ? 'nullable|string|max:4000' : 'required|string|min:10|max:4000',
'payload' => 'prohibited', 'supplier_id' => 'prohibited', 'tenant_id' => 'prohibited', 'reviewed_by' => 'prohibited']);
$workflow->transition($request->user(), $evaluation, (int) $data['version'], $action, $data['reason'] ?? null);
return to_route('supplier-evaluations.show', $evaluation)->with('supplier_evaluation_notice', 'Estado e decisão registados.');
}
public function revise(Request $request, int $evaluation, SupplierEvaluationWorkflow $workflow): RedirectResponse
{
$this->ready();
$data = $request->validate(['version' => 'required|integer|min:1']);
$revision = $workflow->revise($request->user(), $evaluation, (int) $data['version']);
return to_route('supplier-evaluations.show', $revision->id)->with('supplier_evaluation_notice', 'Nova revisão preparada. A avaliação aprovada anterior mantém-se preservada.');
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers;
use App\Services\SupplierImprovementWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
class SupplierImprovementController extends Controller
{
public function store(Request $request, int $evaluation, SupplierImprovementWorkflow $workflow): RedirectResponse
{
return $this->execute($request, $evaluation, null, 'create', $workflow);
}
public function update(Request $request, int $evaluation, int $measure, string $command, SupplierImprovementWorkflow $workflow): RedirectResponse
{
return $this->execute($request, $evaluation, $measure, $command, $workflow);
}
private function execute(Request $request, int $evaluation, ?int $measure, string $command, SupplierImprovementWorkflow $workflow): RedirectResponse
{
abort_unless(Schema::hasTable('supplier_improvements'), 503, 'A instalação das medidas de melhoria ainda não está concluída.');
$rules = ['tenant_id' => 'prohibited', 'supplier_evaluation_id' => 'prohibited', 'created_by' => 'prohibited', 'status' => 'prohibited', 'contributors' => 'prohibited', 'reviewed_by' => 'prohibited', 'reviewed_at' => 'prohibited'];
if ($command === 'create' || $command === 'plan') {
$rules += ['title' => 'required|string|min:3|max:200', 'description' => 'required|string|min:10|max:10000', 'responsible_id' => 'required|integer|min:1', 'due_on' => 'required|date_format:Y-m-d', 'result' => 'prohibited', 'evidence' => 'prohibited'];
} else {
$rules += ['title' => 'prohibited', 'description' => 'prohibited', 'responsible_id' => 'prohibited', 'due_on' => 'prohibited'];
}
$rules += $command === 'create' ? ['operation_key' => 'required|uuid', 'version' => 'prohibited', 'reason' => 'prohibited'] : ['operation_key' => 'prohibited', 'version' => 'required|integer|min:1'];
if (in_array($command, ['progress', 'submit'], true)) {
$rules += ['result' => 'nullable|string|max:10000', 'evidence' => 'nullable|string|max:10000', 'reason' => 'prohibited'];
} elseif ($command !== 'create') {
$rules += ['reason' => 'required|string|min:10|max:4000'];
if ($command !== 'plan') {
$rules += ['result' => 'prohibited', 'evidence' => 'prohibited'];
}
}
$workflow->execute($request->user(), $evaluation, $measure, $command, $request->validate($rules));
return to_route('supplier-evaluations.show', $evaluation)->with('supplier_evaluation_notice', 'Medida de melhoria e histórico actualizados.');
}
}
@@ -0,0 +1,146 @@
<?php
namespace App\Http\Controllers;
use App\Models\SupplierEvaluation;
use App\Models\SupplierImprovement;
use App\Models\User;
use App\Services\SupplierEvaluationWorkflow;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class SupplierImprovementReportController extends Controller
{
private function snapshot(\Closure $read): mixed
{
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() !== 0) {
throw new \LogicException('O relatório exige uma transacção própria de leitura.');
}
return DB::transaction(function () use ($read) {
if (DB::getDriverName() === 'pgsql') {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
}
return $read();
});
}
/** @return array<string,mixed> */
private function filters(Request $request): array
{
$actor = app(SupplierEvaluationWorkflow::class)->reader($request->user());
$request->setUserResolver(fn () => $actor);
abort_unless(Schema::hasTable('supplier_improvements'), 503, 'A instalação das medidas de melhoria ainda não está concluída.');
$filters = $request->validate(['supplier_id' => 'nullable|integer|min:1', 'responsible_id' => 'nullable|integer|min:1',
'status' => 'nullable|in:open,review,returned,closed', 'from' => 'nullable|date_format:Y-m-d', 'to' => 'nullable|date_format:Y-m-d',
'overdue' => 'nullable|in:1', 'search' => 'nullable|string|max:200']);
if (! empty($filters['from']) && ! empty($filters['to']) && $filters['to'] < $filters['from']) {
throw ValidationException::withMessages(['to' => 'O fim do intervalo deve ser igual ou posterior ao início.']);
}
return $filters;
}
/** @param array<string,mixed> $filters
* @return Builder<SupplierImprovement>
*/
private function query(Request $request, array $filters, string $today): Builder
{
$query = SupplierImprovement::query()->visibleTo($request->user());
if (! empty($filters['supplier_id'])) {
$query->whereHas('evaluation', fn ($q) => $q->where('supplier_id', $filters['supplier_id']));
}
foreach (['responsible_id', 'status'] as $field) {
if (! empty($filters[$field])) {
$query->where($field, $filters[$field]);
}
}
foreach (['from' => '>=', 'to' => '<='] as $field => $operator) {
if (! empty($filters[$field])) {
$query->where('due_on', $operator, $filters[$field]);
}
}
if (! empty($filters['overdue'])) {
$query->where('status', '!=', 'closed')->where('due_on', '<', $today);
}
if (isset($filters['search']) && $filters['search'] !== '') {
$query->whereRaw('LOWER(title) LIKE ?', ['%'.mb_strtolower($filters['search']).'%']);
}
return $query;
}
/** @param Builder<SupplierImprovement> $query
* @return Builder<SupplierImprovement>
*/
private function records(Builder $query, Request $request): Builder
{
return $query->with(['evaluation:id,supplier_id,supplier_snapshot,revision', 'responsible' => fn ($q) => $q->where('tenant_id', $request->user()->tenant_id)->select(['id', 'name'])]);
}
/** @return array<string,mixed> */
private function row(SupplierImprovement $m, string $today): array
{
return ['id' => $m->id, 'evaluation_id' => $m->supplier_evaluation_id, 'revision' => $m->evaluation?->revision,
'supplier' => $m->evaluation?->supplier_snapshot['name'] ?? 'Fornecedor não disponível',
'responsible' => $m->responsible->name ?? '#'.$m->responsible_id,
...$m->only(['title', 'description', 'due_on', 'status', 'version', 'result', 'evidence', 'review_reason', 'reviewed_at']),
'overdue' => $m->status !== 'closed' && $m->due_on < $today];
}
public function index(Request $request): Response
{
$props = $this->snapshot(function () use ($request): array {
$filters = $this->filters($request);
$today = today()->toDateString();
$all = SupplierImprovement::query()->visibleTo($request->user());
$query = $this->query($request, $filters, $today);
$counts = (clone $query)->selectRaw('status, COUNT(*) AS count')->groupBy('status')->pluck('count', 'status')->map(fn ($n) => (int) $n);
$overdue = (clone $query)->where('status', '!=', 'closed')->where('due_on', '<', $today)->count();
// Choices derive only from visible measures, including prior revisions and former assignees.
$evaluations = SupplierEvaluation::query()->visibleTo($request->user())->whereIn('id', (clone $all)->select('supplier_evaluation_id'))->orderByDesc('id')->get(['supplier_id', 'supplier_snapshot']);
return ['filters' => $filters, 'today' => $today, 'generatedAt' => now()->toIso8601String(), 'counts' => $counts, 'overdue' => $overdue,
'records' => $this->records($query, $request)->orderBy('due_on')->orderBy('id')->paginate(25)->withQueryString()->through(fn (SupplierImprovement $m) => $this->row($m, $today)),
'suppliers' => $evaluations->map(fn (SupplierEvaluation $e) => ['id' => $e->supplier_id, 'name' => $e->supplier_snapshot['name']])->unique('id')->values(),
'responsibles' => User::query()->where('tenant_id', $request->user()->tenant_id)->whereIn('id', (clone $all)->select('responsible_id'))->orderBy('name')->get(['id', 'name'])];
});
Inertia::encryptHistory();
$response = Inertia::render('reports/supplier-improvements', $props)->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function csv(Request $request): Response
{
$filters = $this->filters($request);
$text = fn (?string $value) => preg_match('/^[\s]*[=+\-@]|^[\t\r\n]/u', $value ?? '') ? "'".$value : ($value ?? '');
return response()->streamDownload(fn () => $this->snapshot(function () use ($request, $filters, $text) {
// Refresh the profile inside the export snapshot as well.
$this->filters($request);
$today = today()->toDateString();
$out = fopen('php://output', 'w');
if ($out === false) {
throw new \RuntimeException('Não foi possível gerar o ficheiro.');
}
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, ['Medidas de melhoria de fornecedores', 'Consulta', now()->toIso8601String(), 'Data de referência', $today], ';', '"', '');
fputcsv($out, ['Filtros', $text(json_encode($filters, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR))], ';', '"', '');
fputcsv($out, ['Medida', 'Fornecedor preservado', 'Avaliação', 'Revisão', 'Título', 'Objectivo', 'Responsável', 'Prazo', 'Estado', 'Prazo ultrapassado', 'Versão', 'Resultado', 'Evidência', 'Última conferência', 'Conferida em', 'Avaliação e acompanhamento', 'Relatório individual'], ';', '"', '');
foreach ($this->records($this->query($request, $filters, $today), $request)->lazyById(200) as $m) {
$row = $this->row($m, $today);
$url = url('/fornecedores/avaliacoes/'.$m->supplier_evaluation_id);
fputcsv($out, ['MM-'.$m->id, $text($row['supplier']), 'AVF-'.$m->supplier_evaluation_id, $row['revision'], $text($m->title), $text($m->description), $text($row['responsible']), $m->due_on, $m->status, $row['overdue'] ? 'Sim' : 'Não', $m->version, $text($m->result), $text($m->evidence), $text($m->review_reason), $m->reviewed_at?->toIso8601String(), $url.'#medida-'.$m->id, $url.'/relatorio#medida-'.$m->id], ';', '"', '');
}
fclose($out);
}), 'medidas-fornecedores-'.now()->format('Ymd-His').'.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store, max-age=0']);
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers;
use App\Services\SupplierResponseWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
class SupplierResponseController extends Controller
{
public function store(Request $request, int $evaluation, SupplierResponseWorkflow $workflow): RedirectResponse
{
return $this->execute($request, $evaluation, null, 'create', $workflow);
}
public function update(Request $request, int $evaluation, int $record, string $command, SupplierResponseWorkflow $workflow): RedirectResponse
{
return $this->execute($request, $evaluation, $record, $command, $workflow);
}
private function execute(Request $request, int $evaluation, ?int $record, string $command, SupplierResponseWorkflow $workflow): RedirectResponse
{
abort_unless(Schema::hasTable('supplier_responses'), 503, 'A instalação do contraditório ainda não está concluída.');
$rules = ['tenant_id' => 'prohibited', 'supplier_evaluation_id' => 'prohibited', 'created_by' => 'prohibited', 'contributors' => 'prohibited', 'status' => 'prohibited', 'decision' => 'prohibited', 'reviewed_by' => 'prohibited', 'reviewed_at' => 'prohibited'];
$rules += $command === 'create' ? ['operation_key' => 'required|uuid', 'version' => 'prohibited'] : ['operation_key' => 'prohibited', 'version' => 'required|integer|min:1'];
if (in_array($command, ['create', 'save'], true)) {
$rules += ['payload' => 'required|array:title,received_on,source_reference,statement', 'payload.title' => 'required|string|min:3|max:200',
'payload.received_on' => 'nullable|date_format:Y-m-d', 'payload.source_reference' => 'nullable|string|max:4000', 'payload.statement' => 'nullable|string|max:20000'];
} else {
$rules['payload'] = 'prohibited';
}
$rules['reason'] = in_array($command, ['return', 'decide'], true) ? 'required|string|min:10|max:10000' : 'prohibited';
if ($command === 'decide') {
$rules += ['outcome' => 'required|in:accepted,partially_accepted,not_accepted', 'follow_up' => 'nullable|string|max:10000', 'response_reference' => 'nullable|string|max:4000'];
} else {
$rules += ['outcome' => 'prohibited', 'follow_up' => 'prohibited', 'response_reference' => 'prohibited'];
}
$workflow->execute($request->user(), $evaluation, $record, $command, $request->validate($rules));
return to_route('supplier-evaluations.show', $evaluation)->with('supplier_evaluation_notice', 'Contraditório e histórico actualizados.');
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use App\Services\OrganizationAccess;
use App\Services\RequisitionWorkflow;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureOrganizationAccess
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user !== null && $user->tenant_id !== null) {
abort_unless(app(OrganizationAccess::class)->permissions($user) !== [], 403, 'Não existe uma atribuição de acesso válida. Contacte os administradores da empresa.');
abort_if(app(OrganizationAccess::class)->maintenanceOnly($user) && ! $request->isMethodSafe(), 403, 'A manutenção temporária tem acesso de diagnóstico e consulta.');
}
$response = $next($request);
if ($user !== null && app(OrganizationAccess::class)->maintenanceOnly($user)) {
app(RequisitionWorkflow::class)->audit($user, 'maintenance.consulted', null, null, ['path' => $request->path(), 'status' => $response->getStatusCode()]);
}
return $response;
}
}
@@ -2,6 +2,7 @@
namespace App\Http\Middleware;
use App\Services\OrganizationAccess;
use App\Services\SupplierPayables;
use Illuminate\Http\Request;
use Inertia\Middleware;
@@ -39,9 +40,15 @@ class HandleInertiaRequests extends Middleware
return [
...parent::share($request),
'name' => config('app.name'),
'canRequest' => $request->user() !== null && app(OrganizationAccess::class)->canRequest($request->user()),
'auth' => [
'user' => $request->user(),
],
'organizationAccess' => $request->user() === null ? null : [
'permissions' => app(OrganizationAccess::class)->permissions($request->user()),
'active' => app(OrganizationAccess::class)->active($request->user()->tenant_id),
'profileLabel' => app(OrganizationAccess::class)->profileLabel($request->user()),
],
'payablesAvailable' => $request->user() !== null && app(SupplierPayables::class)->can($request->user(), 'view'),
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
];
+1 -1
View File
@@ -11,7 +11,7 @@ class SaveSupplierRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->purchasing_role === PurchasingRole::Procurement;
return $this->user()?->hasFunctions([PurchasingRole::Procurement]) ?? false;
}
/** @return array<string, mixed> */
+1 -1
View File
@@ -41,7 +41,7 @@ class ConflictDeclaration extends Model
/** @param Builder<ConflictDeclaration> $query */
public function scopeVisibleTo(Builder $query, User $user): void
{
if (! in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance])) {
if (! $user->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance])) {
$query->where('user_id', $user->id);
}
}
+2
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -12,6 +13,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property int $tenant_id
* @property int $purchase_order_id
* @property int $received_by
* @property CarbonInterface $received_on
* @property string $payload_hash
* @property array<string, mixed> $snapshot
*/
+5 -5
View File
@@ -18,11 +18,11 @@ class PurchasePlanItem extends Model
/** @param Builder<PurchasePlanItem> $query */
public function scopeVisibleTo(Builder $query, User $user): void
{
match ($user->purchasing_role) {
PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance => null,
PurchasingRole::DepartmentHead => $query->where(fn ($visible) => $visible->where('department_id', $user->department_id)->orWhere('responsible_id', $user->id)),
default => $query->where('responsible_id', $user->id),
};
if ($user->departmentScoped()) {
$query->where(fn ($visible) => $visible->where('department_id', $user->department_id)->orWhere('responsible_id', $user->id));
} elseif (! $user->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance])) {
$query->where('responsible_id', $user->id);
}
}
protected static function booted(): void
+1 -1
View File
@@ -7,7 +7,7 @@ use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/**
* @property array{analyst:array{id:int,name:string,purchasing_role:string},basis_hash:string} $snapshot
* @property array{analyst:array{id:int,name:string,purchasing_role:string,profile_label?:string},basis_hash:string} $snapshot
* @property list<array{criterion_id:int,result:string,evidence:string,reason:string}> $answers
*/
class QualificationAssessment extends Model
+6 -6
View File
@@ -68,11 +68,11 @@ class Requisition extends Model
/** @param Builder<Requisition> $query */
public function scopeVisibleTo(Builder $query, User $user): void
{
match ($user->purchasing_role) {
PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance => null,
PurchasingRole::DepartmentHead => $query->where('department_id', $user->department_id),
default => $query->where('requester_id', $user->id),
};
if ($user->departmentScoped()) {
$query->where('department_id', $user->department_id);
} elseif (! $user->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance])) {
$query->where('requester_id', $user->id);
}
}
public function canDecide(User $user): bool
@@ -87,7 +87,7 @@ class Requisition extends Model
$next = $this->steps->firstWhere('decision', 'pending');
return $next !== null && $next->role === $user->purchasing_role->value
return $next !== null && $user->hasFunctions([$next->role])
&& ($next->role !== 'department_head' || $this->department_id === $user->department_id)
&& ! app(ConflictGuard::class)->participationBlocked($user, $this);
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/** @property int $id
* @property int $tenant_id
* @property int $supplier_id
* @property int|null $department_id
* @property int $created_by
* @property int $version
* @property int $revision
* @property string $status
* @property array<string,mixed> $payload
* @property array<string,mixed> $supplier_snapshot
* @property list<int> $contributors
*/
class SupplierEvaluation extends Model
{
use BelongsToTenant;
protected $guarded = ['id'];
protected $attributes = ['version' => 1, 'revision' => 1, 'status' => 'draft'];
protected function casts(): array
{
return ['payload' => 'array', 'supplier_snapshot' => 'array', 'contributors' => 'array', 'reviewed_at' => 'datetime'];
}
/** @param Builder<SupplierEvaluation> $query */
public function scopeVisibleTo(Builder $query, User $actor): void
{
$query->where('tenant_id', $actor->tenant_id);
if ($actor->departmentScoped()) {
$query->where('department_id', $actor->department_id ?? 0);
}
}
protected static function booted(): void
{
static::updating(function (self $evaluation) {
if ($evaluation->getRawOriginal('status') === 'approved') {
throw new \LogicException('A avaliação aprovada conserva-se; prepare uma nova revisão.');
}
});
static::deleting(fn () => throw new \LogicException('Preservar o histórico de avaliações.'));
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property int $tenant_id
* @property int $supplier_evaluation_id
* @property int $created_by
* @property int $responsible_id
* @property int $version
* @property string $status
* @property list<int> $contributors
* @property CarbonInterface|null $reviewed_at
*/
class SupplierImprovement extends Model
{
use BelongsToTenant;
protected $guarded = ['id'];
protected $attributes = ['version' => 1, 'status' => 'open'];
protected function casts(): array
{
return ['contributors' => 'array', 'reviewed_at' => 'datetime'];
}
/** @param Builder<SupplierImprovement> $query */
public function scopeVisibleTo(Builder $query, User $actor): void
{
$query->where('tenant_id', $actor->tenant_id)->whereIn('supplier_evaluation_id', SupplierEvaluation::query()->visibleTo($actor)->select('id'));
}
/** @return BelongsTo<SupplierEvaluation, $this> */
public function evaluation(): BelongsTo
{
return $this->belongsTo(SupplierEvaluation::class, 'supplier_evaluation_id');
}
/** @return BelongsTo<User, $this> */
public function responsible(): BelongsTo
{
return $this->belongsTo(User::class, 'responsible_id');
}
protected static function booted(): void
{
static::updating(function (self $measure) {
if ($measure->getRawOriginal('status') === 'closed') {
throw new \LogicException('A medida encerrada conserva-se no histórico.');
}
});
static::deleting(fn () => throw new \LogicException('Preservar as medidas de melhoria.'));
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property int $tenant_id
* @property int $supplier_evaluation_id
* @property int $created_by
* @property int $version
* @property string $status
* @property array<string,mixed> $payload
* @property list<int> $contributors
* @property array<string,mixed>|null $decision
*/
class SupplierResponse extends Model
{
use BelongsToTenant;
protected $guarded = ['id'];
protected $attributes = ['status' => 'draft', 'version' => 1];
protected function casts(): array
{
return ['payload' => 'array', 'contributors' => 'array', 'decision' => 'array', 'reviewed_at' => 'datetime'];
}
protected static function booted(): void
{
static::updating(function (self $record) {
if ($record->getRawOriginal('status') === 'decided') {
throw new \LogicException('A conclusão registada conserva-se no histórico.');
}
});
static::deleting(fn () => throw new \LogicException('Preservar o contraditório do fornecedor.'));
}
}
+13 -1
View File
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\PurchasingRole;
use App\Services\OrganizationAccess;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
@@ -15,7 +16,7 @@ use Illuminate\Support\Carbon;
* @property int $id
* @property int|null $tenant_id
* @property int|null $department_id
* @property PurchasingRole $purchasing_role
* @property PurchasingRole|null $purchasing_role
* @property string $name
* @property string $email
* @property Carbon|null $email_verified_at
@@ -31,6 +32,17 @@ use Illuminate\Support\Carbon;
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
class User extends Authenticatable
{
/** @param list<PurchasingRole|string> $functions */
public function hasFunctions(array $functions): bool
{
return app(OrganizationAccess::class)->allows($this, $functions);
}
public function departmentScoped(): bool
{
return app(OrganizationAccess::class)->departmentScoped($this);
}
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
+3 -3
View File
@@ -12,13 +12,13 @@ class GoodsReceiptPolicy
{
public function viewAny(User $user): bool
{
return $user->tenant_id !== null && in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::Warehouse, PurchasingRole::DepartmentHead]);
return $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::Warehouse, PurchasingRole::DepartmentHead]);
}
public function viewOrder(User $user, PurchaseOrder $order): bool
{
return $this->viewAny($user) && $user->tenant_id === $order->tenant_id &&
($user->purchasing_role !== PurchasingRole::DepartmentHead || Requisition::query()->whereKey($order->requisition_id)->where('department_id', $user->department_id)->exists());
(! $user->departmentScoped() || Requisition::query()->whereKey($order->requisition_id)->where('department_id', $user->department_id)->exists());
}
public function view(User $user, GoodsReceipt $receipt): bool
@@ -30,6 +30,6 @@ class GoodsReceiptPolicy
public function create(User $user, PurchaseOrder $order): bool
{
return $user->tenant_id === $order->tenant_id && $user->purchasing_role === PurchasingRole::Warehouse && $user->id !== $order->issued_by;
return $user->tenant_id === $order->tenant_id && $user->hasFunctions([PurchasingRole::Warehouse]) && $user->id !== $order->issued_by;
}
}
+3 -3
View File
@@ -10,7 +10,7 @@ class SupplierPolicy
{
public function viewAny(User $user): bool
{
return $user->tenant_id !== null && in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
return $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
}
public function view(User $user, Supplier $supplier): bool
@@ -20,11 +20,11 @@ class SupplierPolicy
public function update(User $user, Supplier $supplier): bool
{
return $user->purchasing_role === PurchasingRole::Procurement && $user->tenant_id === $supplier->tenant_id;
return $user->hasFunctions([PurchasingRole::Procurement]) && $user->tenant_id === $supplier->tenant_id;
}
public function viewDocuments(User $user, Supplier $supplier): bool
{
return $this->view($user, $supplier) && in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance]);
return $this->view($user, $supplier) && $user->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance]);
}
}
-6
View File
@@ -16,14 +16,8 @@ class AppServiceProvider extends ServiceProvider
public function register(): void
{
$package = config('common-budget.path');
if (! is_file($package.'/bootstrap.php') && ! $this->app->environment('production')) {
$package = base_path('../sgf-tempo/packages/budget-control');
}
require_once $package.'/bootstrap.php';
$payables = config('common-payables.path');
if (! is_file($payables.'/bootstrap.php') && ! $this->app->environment('production')) {
$payables = base_path('../sgf-tempo/packages/payables');
}
require_once $payables.'/bootstrap.php';
}
+2 -1
View File
@@ -4,6 +4,7 @@ namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use App\Services\EvaluationNavigation;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
@@ -48,7 +49,7 @@ class FortifyServiceProvider extends ServiceProvider
*/
private function configureViews(): void
{
Fortify::loginView(fn (Request $request) => Inertia::render('auth/login', [
Fortify::loginView(fn (Request $request) => EvaluationNavigation::local($request) ? redirect('/avaliar') : Inertia::render('auth/login', [
'canResetPassword' => Features::enabled(Features::resetPasswords()),
'status' => $request->session()->get('status'),
]));
+1 -1
View File
@@ -36,7 +36,7 @@ final class CommonBudget
app(RequisitionWorkflow::class)->audit($user, $action, $request, null, $data);
}
},
fn (int $id): bool => User::query()->where('tenant_id', $user->tenant_id)->whereKey($id)->lockForUpdate()->whereIn('purchasing_role', [PurchasingRole::Admin->value, PurchasingRole::Finance->value])->exists(),
fn (int $id): bool => User::query()->where('tenant_id', $user->tenant_id)->whereKey($id)->lockForUpdate()->first()?->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]) ?? false,
);
}
+1 -1
View File
@@ -18,7 +18,7 @@ class CurrentActor
throw new \LogicException('Revalidar o operador dentro da transacção.');
}
$user = User::query()->where('tenant_id', $actor->tenant_id)->lockForUpdate()->findOrFail($actor->id);
abort_unless($roles === null || in_array($user->purchasing_role, $roles, true), 403);
abort_unless($roles === null || $user->hasFunctions($roles), 403);
return $user;
}
@@ -110,7 +110,7 @@ class DocumentaryQualificationWorkflow
}
$answers[] = ['criterion_id' => $answer['criterion_id'], 'result' => $result, 'evidence' => $evidence, 'reason' => $reason];
}
$assessment = QualificationAssessment::query()->create(['tenant_id' => $actor->tenant_id, 'qualification_set_id' => $set->id, 'quotation_id' => $quote->id, 'analyst_id' => $actor->id, 'revision' => ($latest->revision ?? 0) + 1, 'answers' => $answers, 'snapshot' => ['analyst' => $actor->only(['id', 'name', 'purchasing_role']), 'basis_hash' => $set->basis_hash], 'reason' => $data['reason'], 'created_at' => now()]);
$assessment = QualificationAssessment::query()->create(['tenant_id' => $actor->tenant_id, 'qualification_set_id' => $set->id, 'quotation_id' => $quote->id, 'analyst_id' => $actor->id, 'revision' => ($latest->revision ?? 0) + 1, 'answers' => $answers, 'snapshot' => ['analyst' => app(OrganizationAccess::class)->actorSnapshot($actor, ['id', 'name', 'purchasing_role']), 'basis_hash' => $set->basis_hash], 'reason' => $data['reason'], 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'qualification.assessed', $request, null, ['assessment_id' => $assessment->id, 'set_id' => $set->id, 'quotation_id' => $quote->id, 'revision' => $assessment->revision, 'previous_id' => $latest?->id]);
return $assessment;
+1 -1
View File
@@ -59,7 +59,7 @@ class EquipmentWorkflow
private function eligible(User $user, Requisition $requisition): bool
{
return $requisition->tenant_id === $user->tenant_id && $user->purchasing_role === PurchasingRole::DepartmentHead && $user->department_id === $requisition->department_id && $user->id !== $requisition->requester_id && ! app(ConflictGuard::class)->participationBlocked($user, $requisition);
return $requisition->tenant_id === $user->tenant_id && $user->hasFunctions([PurchasingRole::DepartmentHead]) && $user->department_id === $requisition->department_id && $user->id !== $requisition->requester_id && ! app(ConflictGuard::class)->participationBlocked($user, $requisition);
}
public function review(User $user, Requisition $requisition, string $basis, string $decision, string $reason): TechnicalReview
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Services;
use Illuminate\Http\Request;
final class EvaluationNavigation
{
public static function local(Request $request): bool
{
return app()->environment('payable-testing')
&& in_array($request->ip(), ['127.0.0.1', '::1'], true)
&& in_array($request->getHost(), ['127.0.0.1', 'localhost', '::1'], true);
}
public static function returnPath(mixed $intended, Request $request): string
{
$fallback = '/dashboard';
if (! is_string($intended) || preg_match('/[\x00-\x20\\\\]/', $intended)) {
return $fallback;
}
$parts = parse_url($intended);
if ($parts === false) {
return $fallback;
}
if (isset($parts['scheme']) || isset($parts['host'])) {
$port = $parts['port'] ?? (($parts['scheme'] ?? '') === 'https' ? 443 : 80);
if (($parts['scheme'] ?? '') !== $request->getScheme() || ($parts['host'] ?? '') !== $request->getHost() || $port !== $request->getPort()) {
return $fallback;
}
}
$path = $parts['path'] ?? '';
if (! str_starts_with($path, '/') || str_starts_with($path, '//')
|| in_array($path, ['/login', '/logout', '/avaliar', '/avaliar/entrar'], true)) {
return $fallback;
}
return $path.(isset($parts['query']) ? '?'.$parts['query'] : '');
}
}
+2 -2
View File
@@ -20,7 +20,7 @@ class ExternalPaymentWorkflow
{
private function actor(User $user, int $tenantId): void
{
abort_unless(auth()->id() === $user->id && $user->tenant_id === $tenantId && $user->purchasing_role === PurchasingRole::Finance, 403);
abort_unless(auth()->id() === $user->id && $user->tenant_id === $tenantId && $user->hasFunctions([PurchasingRole::Finance]), 403);
}
private function invalid(string $message): never
@@ -127,7 +127,7 @@ class ExternalPaymentWorkflow
}
$this->bank($user, $invoice, $payment->supplier_bank_detail_id, $payment->amount_minor);
$author = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->find($payment->created_by);
if (! $author || $author->purchasing_role !== PurchasingRole::Finance) {
if (! $author || ! $author->hasFunctions([PurchasingRole::Finance])) {
$this->invalid('O autor do registo deixou de ser elegível.');
}
}
+2 -2
View File
@@ -174,7 +174,7 @@ class FormalAdmission
public function eligible(User $actor, Requisition $request, AdmissionAdoption $adoption, AdmissionDecision $decision): bool
{
return $actor->tenant_id === $request->tenant_id && in_array($actor->purchasing_role->value, $adoption->snapshot['roles'], true) && ! in_array($actor->id, [$request->requester_id, $decision->created_by], true) && ($actor->purchasing_role !== PurchasingRole::DepartmentHead || $actor->department_id === $request->department_id);
return $actor->tenant_id === $request->tenant_id && $actor->hasFunctions($adoption->snapshot['roles']) && ! in_array($actor->id, [$request->requester_id, $decision->created_by], true) && (! $actor->departmentScoped() || $actor->department_id === $request->department_id);
}
public function review(User $actor, AdmissionDecision $decision, string $verdict, string $reason): void
@@ -213,7 +213,7 @@ class FormalAdmission
}
}
}
$review = AdmissionReview::query()->create(['tenant_id' => $actor->tenant_id, 'admission_decision_id' => $decision->id, 'reviewed_by' => $actor->id, 'verdict' => $verdict, 'reason' => $reason, 'snapshot' => $actor->only(['id', 'name', 'purchasing_role', 'department_id']), 'created_at' => now()]);
$review = AdmissionReview::query()->create(['tenant_id' => $actor->tenant_id, 'admission_decision_id' => $decision->id, 'reviewed_by' => $actor->id, 'verdict' => $verdict, 'reason' => $reason, 'snapshot' => app(OrganizationAccess::class)->actorSnapshot($actor, ['id', 'name', 'purchasing_role', 'department_id']), 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'admission.reviewed', $request, null, $review->toArray());
});
}
+2 -2
View File
@@ -152,7 +152,7 @@ class FormalRecommendation
public function eligible(User $actor, Requisition $request, RecommendationAdoption $adoption, RecommendationAttempt $attempt): bool
{
return $actor->tenant_id === $request->tenant_id && in_array($actor->purchasing_role->value, $adoption->snapshot['roles'], true) && ! in_array($actor->id, [$request->requester_id, $attempt->created_by], true) && ($actor->purchasing_role !== PurchasingRole::DepartmentHead || $actor->department_id === $request->department_id);
return $actor->tenant_id === $request->tenant_id && $actor->hasFunctions($adoption->snapshot['roles']) && ! in_array($actor->id, [$request->requester_id, $attempt->created_by], true) && (! $actor->departmentScoped() || $actor->department_id === $request->department_id);
}
public function review(User $actor, RecommendationAttempt $attempt, string $verdict, string $reason): void
@@ -191,7 +191,7 @@ class FormalRecommendation
}
}
}
$review = RecommendationReview::query()->create(['tenant_id' => $actor->tenant_id, 'recommendation_attempt_id' => $attempt->id, 'reviewed_by' => $actor->id, 'verdict' => $verdict, 'reason' => $reason, 'snapshot' => $actor->only(['id', 'name', 'purchasing_role', 'department_id']), 'created_at' => now()]);
$review = RecommendationReview::query()->create(['tenant_id' => $actor->tenant_id, 'recommendation_attempt_id' => $attempt->id, 'reviewed_by' => $actor->id, 'verdict' => $verdict, 'reason' => $reason, 'snapshot' => app(OrganizationAccess::class)->actorSnapshot($actor, ['id', 'name', 'purchasing_role', 'department_id']), 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'recommendation.reviewed', $request, null, $review->toArray());
});
}
+2 -2
View File
@@ -19,7 +19,7 @@ class InvestmentWorkflow
{
private function admin(User $user): void
{
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->purchasing_role === PurchasingRole::Admin, 403);
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Admin]), 403);
}
/** @param array<string,mixed> $data */
@@ -68,7 +68,7 @@ class InvestmentWorkflow
if ($decision === 'approved') {
$ids = InvestmentPolicyReview::query()->where('investment_policy_id', $policy->id)->where('decision', 'approved')->pluck('reviewed_by')->push($policy->created_by)->push($user->id)->unique();
$actors = User::query()->where('tenant_id', $user->tenant_id)->whereIn('id', $ids)->orderBy('id')->lockForUpdate()->get();
if ($actors->count() !== $ids->count() || $actors->contains(fn (User $actor) => $actor->purchasing_role !== PurchasingRole::Admin)) {
if ($actors->count() !== $ids->count() || $actors->contains(fn (User $actor) => ! $actor->hasFunctions([PurchasingRole::Admin]))) {
throw ValidationException::withMessages(['investment' => 'Proponente e validadores devem continuar elegíveis até à publicação.']);
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ class InvoiceWorkflow
{
private function access(User $user): void
{
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->purchasing_role === PurchasingRole::Finance, 403);
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Finance]), 403);
}
private function invalid(string $message): never
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class OrganizationAccess
{
public const PERMISSIONS = [
'requester' => 'Solicitar e acompanhar compras próprias',
'department_head' => 'Supervisão e aprovação do departamento',
'procurement' => 'Preparação e gestão operacional de compras',
'finance' => 'Conferência e operações financeiras',
'warehouse' => 'Recepção e operações de armazém',
'admin' => 'Administrar a empresa, utilizadores e parametrização',
];
public function active(?int $tenant): bool
{
return $tenant !== null && Schema::hasTable('organization_access') && DB::table('organization_access')->where('tenant_id', $tenant)->where('active', true)->exists();
}
/** @return list<string> */
public function permissions(User $user): array
{
if ($user->tenant_id === null) {
return [];
}
if (! $this->active($user->tenant_id)) {
return $user->purchasing_role === null || $user->purchasing_role === PurchasingRole::Configured ? [] : [$user->purchasing_role->value];
}
$rows = DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')
->where('m.tenant_id', $user->tenant_id)->where('p.tenant_id', $user->tenant_id)->where('m.user_id', $user->id)
->where(fn ($q) => $q->whereNull('m.expires_at')->orWhere('m.expires_at', '>', now()))->get(['p.permissions']);
$permissions = [];
foreach ($rows as $row) {
$permissions = [...$permissions, ...json_decode($row->permissions, true, 32, JSON_THROW_ON_ERROR)];
}
return array_values(array_unique($permissions));
}
/** @param list<PurchasingRole|string> $functions */
public function allows(User $user, array $functions): bool
{
$requested = array_map(fn ($f) => $f instanceof PurchasingRole ? $f->value : $f, $functions);
return $user->tenant_id !== null && count(array_intersect($this->permissions($user), $requested)) > 0;
}
public function maintenanceOnly(User $user): bool
{
if (! $this->active($user->tenant_id)) {
return false;
}
$valid = DB::table('organization_memberships')->where('tenant_id', $user->tenant_id)->where('user_id', $user->id)->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()));
return (clone $valid)->where('maintenance', true)->exists() && ! (clone $valid)->where('maintenance', false)->exists();
}
public function departmentScoped(User $user): bool
{
$permissions = $this->permissions($user);
return in_array('department_head', $permissions, true) && ! array_intersect($permissions, ['admin', 'procurement', 'finance']);
}
public function canRequest(User $user): bool
{
return $user->tenant_id !== null && ($this->active($user->tenant_id) ? $this->allows($user, ['requester']) : ($user->purchasing_role !== null && $user->purchasing_role !== PurchasingRole::Configured));
}
public function profileLabel(User $user): string
{
if (! $this->active($user->tenant_id)) {
return $user->purchasing_role?->label() ?? 'Utilizador';
}
return DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')->where('m.tenant_id', $user->tenant_id)->where('p.tenant_id', $user->tenant_id)->where('m.user_id', $user->id)
->where(fn ($q) => $q->whereNull('m.expires_at')->orWhere('m.expires_at', '>', now()))->orderBy('p.name')->pluck('p.name')->unique()->implode(' · ');
}
/** @param list<string> $fields
* @return array<string,mixed>
*/
public function actorSnapshot(User $user, array $fields): array
{
$snapshot = $user->only($fields);
if ($this->active($user->tenant_id)) {
if (array_key_exists('purchasing_role', $snapshot)) {
$snapshot['purchasing_role'] = $user->purchasing_role->value ?? '';
}
$snapshot['profile_label'] = $this->profileLabel($user);
}
return $snapshot;
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\Tenant;
use App\Models\User;
use Brick\Math\BigDecimal;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class OrganizationConfiguration
{
/** @param array<string,mixed> $data
* @return array<string,int|bool>
*/
public static function rules(array $data): array
{
$first = BigDecimal::of($data['first_limit'])->multipliedBy(100)->toInt();
$second = BigDecimal::of($data['second_limit'])->multipliedBy(100)->toInt();
if ($second < $first) {
throw ValidationException::withMessages(['second_limit' => 'O segundo limite deve ser igual ou superior ao primeiro.']);
}
return ['first_limit_minor' => $first, 'second_limit_minor' => $second, 'approval_includes_tax' => (bool) $data['includes_tax'], 'rules_provisional' => false];
}
public function administrator(User $actor): User
{
abort_unless(auth()->id() === $actor->id && $actor->tenant_id !== null, 403);
$actor = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($actor->id);
abort_unless($actor->hasFunctions(['admin']), 403);
return $actor;
}
/** @param array<string,mixed> $data */
public function execute(User $actor, string $command, array $data): void
{
DB::transaction(function () use ($actor, $command, $data) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = $this->administrator($actor);
DB::table('organization_access')->insertOrIgnore(['tenant_id' => $actor->tenant_id, 'active' => false, 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
$state = DB::table('organization_access')->where('tenant_id', $actor->tenant_id)->lockForUpdate()->first();
if ((int) $data['version'] !== (int) $state->version) {
throw ValidationException::withMessages(['version' => 'A parametrização foi alterada. Actualize e compare antes de guardar.']);
}
$before = null;
if ($command === 'profile') {
$profile = isset($data['id']) ? DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->where('id', $data['id'])->first() : null;
abort_if(isset($data['id']) && ! $profile, 404);
if ($profile && in_array('admin', $data['permissions'], true) && DB::table('organization_memberships')->where('tenant_id', $actor->tenant_id)->where('profile_id', $profile->id)->where('maintenance', true)->exists()) {
throw ValidationException::withMessages(['permissions' => 'Retire as atribuições de manutenção antes de dar administração a este perfil.']);
}
$before = $profile ? (array) $profile : null;
$values = ['name' => $data['name'], 'permissions' => json_encode(array_values(array_unique($data['permissions'])), JSON_THROW_ON_ERROR), 'updated_at' => now()];
if ($profile) {
DB::table('organization_profiles')->where('id', $profile->id)->update([...$values, 'version' => $profile->version + 1]);
$id = $profile->id;
} else {
$id = DB::table('organization_profiles')->insertGetId([...$values, 'tenant_id' => $actor->tenant_id, 'created_at' => now()]);
}
$after = ['profile_id' => $id, 'name' => $data['name'], 'permissions' => $data['permissions']];
} elseif ($command === 'user') {
$department = DB::table('departments')->where('tenant_id', $actor->tenant_id)->where('id', $data['department_id'])->exists();
abort_unless($department, 422, 'Departamento não autorizado.');
$user = new User;
$user->forceFill(['tenant_id' => $actor->tenant_id, 'department_id' => $data['department_id'], 'name' => $data['name'], 'email' => $data['email'], 'password' => $data['password'], 'purchasing_role' => PurchasingRole::Configured])->save();
$this->assign($actor, $user->id, $data['profile_ids'], false, null, $data['reason']);
$after = [...$user->only(['id', 'name', 'email', 'department_id']), 'profile_ids' => $data['profile_ids'], 'reason' => $data['reason']];
} elseif ($command === 'assign') {
$target = User::query()->where('tenant_id', $actor->tenant_id)->whereKey((int) $data['user_id'])->firstOrFail();
$before = ['user_id' => $target->id, 'memberships' => DB::table('organization_memberships')->where('tenant_id', $actor->tenant_id)->where('user_id', $target->id)->get(['profile_id', 'maintenance', 'expires_at', 'reason'])->map(fn ($m) => (array) $m)->all()];
if (($data['maintenance'] ?? false) && ! $state->active) {
throw ValidationException::withMessages(['maintenance' => 'Active a parametrização antes de atribuir manutenção temporária.']);
}
$this->assign($actor, $target->id, $data['profile_ids'], $data['maintenance'] ?? false, $data['expires_at'] ?? null, $data['reason']);
$after = ['user_id' => $target->id, 'profile_ids' => $data['profile_ids'], 'maintenance' => $data['maintenance'] ?? false, 'expires_at' => $data['expires_at'] ?? null, 'reason' => $data['reason']];
} elseif ($command === 'rules') {
$company = Tenant::query()->findOrFail($actor->tenant_id);
$before = $company->only(['first_limit_minor', 'second_limit_minor', 'approval_includes_tax', 'rules_provisional']);
$after = [...self::rules($data), 'reason' => $data['reason']];
$company->fill(self::rules($data))->save();
} else {
abort_unless($command === 'activate', 404);
if (Tenant::query()->findOrFail($actor->tenant_id)->rules_provisional) {
throw ValidationException::withMessages(['rules' => 'Defina e guarde as regras de aprovação da empresa antes da activação.']);
}
DB::table('organization_access')->where('id', $state->id)->update(['active' => true]);
$after = ['active' => true, 'reason' => $data['reason']];
}
if ($state->active || $command === 'activate') {
$this->twoAdministrators((int) $actor->tenant_id);
}
DB::table('organization_access')->where('id', $state->id)->update(['version' => $state->version + 1, 'updated_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'organization.'.$command, null, $before, $after);
});
}
/** @param list<int> $profiles */
private function assign(User $actor, int $user, array $profiles, bool $maintenance, ?string $expires, string $reason): void
{
$ids = DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->whereIn('id', $profiles)->pluck('id')->all();
if (count($ids) !== count($profiles)) {
throw ValidationException::withMessages(['profile_ids' => 'Escolha apenas perfis desta empresa.']);
}
if ($maintenance && ($expires === null || now()->parse($expires)->isPast())) {
throw ValidationException::withMessages(['expires_at' => 'O acesso de manutenção exige uma data futura de expiração.']);
}
if ($maintenance && DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->whereIn('id', $ids)->pluck('permissions')->contains(fn ($json) => in_array('admin', json_decode($json, true, 32, JSON_THROW_ON_ERROR), true))) {
throw ValidationException::withMessages(['profile_ids' => 'A manutenção temporária não pode administrar os acessos da empresa.']);
}
DB::table('organization_memberships')->where('tenant_id', $actor->tenant_id)->where('user_id', $user)->delete();
foreach ($ids as $id) {
DB::table('organization_memberships')->insert(['tenant_id' => $actor->tenant_id, 'user_id' => $user, 'profile_id' => $id, 'maintenance' => $maintenance, 'expires_at' => $expires === null ? null : CarbonImmutable::parse($expires)->utc()->format('Y-m-d H:i:s'), 'reason' => $reason, 'created_at' => now(), 'updated_at' => now()]);
}
}
private function twoAdministrators(int $tenant): void
{
$count = 0;
foreach (User::query()->where('tenant_id', $tenant)->get() as $user) {
$profiles = DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')->where('m.tenant_id', $tenant)->where('p.tenant_id', $tenant)->where('m.user_id', $user->id)->where('m.maintenance', false)->whereNull('m.expires_at')->pluck('p.permissions');
if ($profiles->contains(fn ($json) => in_array('admin', json_decode($json, true, 32, JSON_THROW_ON_ERROR), true))) {
$count++;
}
}
if ($count < 2) {
throw ValidationException::withMessages(['administrators' => 'Mantenha pelo menos dois administradores permanentes da empresa; manutenção não conta para esse mínimo.']);
}
}
public function initialise(User $first, User $second): void
{
$profile = DB::table('organization_profiles')->insertGetId(['tenant_id' => $first->tenant_id, 'name' => 'Administração da empresa', 'permissions' => json_encode(['admin'], JSON_THROW_ON_ERROR), 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
foreach ([$first, $second] as $user) {
$this->assign($first, $user->id, [$profile], false, null, 'Configuração inicial e entrega do produto.');
}
DB::table('organization_access')->insert(['tenant_id' => $first->tenant_id, 'active' => true, 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
}
}
+17 -1
View File
@@ -3,6 +3,9 @@
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\ExternalPayment;
use App\Models\GoodsReceipt;
use App\Models\Invoice;
use App\Models\ProposalOpening;
use App\Models\PurchaseAward;
use App\Models\PurchaseOrder;
@@ -28,7 +31,7 @@ class ProcurementReviewReport
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
}
$actor = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($actor->id);
abort_unless(in_array($actor->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
abort_unless($actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
$request = Requisition::query()->visibleTo($actor)->with(['department:id,name', 'requester:id,name'])->findOrFail($requisitionId);
$comparison = app(ProposalComparison::class)->inspect($request);
$rows = [];
@@ -59,6 +62,14 @@ class ProcurementReviewReport
$opening = ProposalOpening::query()->where('requisition_id', $request->id)->latest('revision')->first();
$award = PurchaseAward::query()->where('requisition_id', $request->id)->first();
$order = PurchaseOrder::query()->visibleTo($actor)->where('requisition_id', $request->id)->first();
$receipts = $order ? GoodsReceipt::query()->where('purchase_order_id', $order->id)->orderBy('id')->get(['id', 'received_on', 'delivery_note'])->map(fn (GoodsReceipt $receipt) => [
'id' => $receipt->id, 'received_on' => $receipt->received_on->format('Y-m-d'), 'delivery_note' => $receipt->delivery_note,
])->all() : [];
$financeVisible = $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]);
$invoices = $financeVisible && $order ? Invoice::query()->where('purchase_order_id', $order->id)->orderBy('id')->get(['id', 'number', 'status', 'issued_on', 'total_minor', 'snapshot']) : collect();
$payments = $financeVisible ? ExternalPayment::query()->whereIn('invoice_id', $invoices->pluck('id'))->orderBy('id')->get(['id', 'invoice_id', 'status', 'amount_minor', 'currency', 'paid_on'])->map(fn ($payment) => [
'id' => $payment->id, 'invoice_id' => $payment->invoice_id, 'status' => $payment->status, 'amount_minor' => (string) $payment->amount_minor, 'currency' => $payment->currency, 'paid_on' => $payment->paid_on,
])->all() : null;
return [
'generated_at' => now()->toIso8601String(), 'generated_by' => $actor->name,
@@ -71,6 +82,11 @@ class ProcurementReviewReport
'opening' => $opening ? [...$opening->only(['id', 'revision', 'reference']), 'source_changed' => $opening->basis_hash !== $basis] : null,
'award' => $award?->only(['id', 'status', 'total_minor']),
'order' => $order?->only(['id', 'number', 'currency', 'total_minor']),
'fulfilment' => [
'receipts' => $receipts, 'finance_visible' => $financeVisible,
'invoices' => $financeVisible ? $invoices->map(fn (Invoice $invoice) => ['id' => $invoice->id, 'number' => $invoice->number, 'status' => $invoice->status, 'issued_on' => $invoice->issued_on, 'total_minor' => (string) $invoice->total_minor, 'currency' => $invoice->snapshot['currency'] ?? null])->all() : null,
'payments' => $payments,
],
];
});
}
+2 -2
View File
@@ -121,8 +121,8 @@ class ProposalComparison
public function eligibleRole(User $user, Requisition $requisition): bool
{
return $user->tenant_id === $requisition->tenant_id &&
(in_array($user->purchasing_role, [PurchasingRole::Procurement, PurchasingRole::Finance]) ||
($user->purchasing_role === PurchasingRole::DepartmentHead && $user->department_id === $requisition->department_id));
($user->hasFunctions([PurchasingRole::Procurement, PurchasingRole::Finance]) ||
($user->hasFunctions([PurchasingRole::DepartmentHead]) && $user->department_id === $requisition->department_id));
}
/** @param array<string, mixed> $weights */
@@ -13,7 +13,7 @@ class PurchaseControlPolicyWorkflow
{
private function admin(User $user): void
{
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->purchasing_role === PurchasingRole::Admin, 403);
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Admin]), 403);
}
/** @param array<string,mixed> $data */
@@ -62,7 +62,7 @@ class PurchaseControlPolicyWorkflow
if ($decision === 'approved') {
$ids = PurchaseControlReview::query()->where('purchase_control_policy_id', $policy->id)->where('decision', 'approved')->pluck('reviewed_by')->push($policy->created_by)->push($user->id)->unique();
$actors = User::query()->where('tenant_id', $user->tenant_id)->whereIn('id', $ids)->orderBy('id')->lockForUpdate()->get();
if ($actors->count() !== $ids->count() || $actors->contains(fn (User $actor) => $actor->purchasing_role !== PurchasingRole::Admin)) {
if ($actors->count() !== $ids->count() || $actors->contains(fn (User $actor) => ! $actor->hasFunctions([PurchasingRole::Admin]))) {
throw ValidationException::withMessages(['cancellation' => 'Proponente e validadores devem continuar elegíveis até à publicação.']);
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ class PurchasePlanningWorkflow
{
private function manager(User $actor): bool
{
return in_array($actor->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement]);
return $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement]);
}
private function responsible(User $actor, int $id): void
+4 -4
View File
@@ -29,7 +29,7 @@ class PurchaseWorkflow
$this->guard->lockCompany($user);
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
abort_unless($user->purchasing_role === PurchasingRole::Procurement && $user->id !== $requisition->requester_id, 403);
abort_unless($user->hasFunctions([PurchasingRole::Procurement]) && $user->id !== $requisition->requester_id, 403);
if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
throw ValidationException::withMessages(['award' => 'Este processo já tem uma adjudicação. Não é permitida uma segunda selecção.']);
}
@@ -70,7 +70,7 @@ class PurchaseWorkflow
$steps = $award->approvals()->get();
$next = $steps->firstWhere('decision', 'pending');
return $next !== null && $next->role === $user->purchasing_role->value &&
return $next !== null && $user->hasFunctions([$next->role]) &&
($next->role !== 'department_head' || $user->department_id === $requisition->department_id) &&
! $steps->contains('approver_id', $user->id) && ! $this->guard->participationBlocked($user, $requisition);
}
@@ -103,7 +103,7 @@ class PurchaseWorkflow
return DB::transaction(function () use ($user, $award) {
$this->guard->lockCompany($user);
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]);
abort_unless($user->purchasing_role === PurchasingRole::Procurement, 403);
abort_unless($user->hasFunctions([PurchasingRole::Procurement]), 403);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id);
$award = PurchaseAward::query()->lockForUpdate()->findOrFail($award->id);
// An already committed issue is returned, never re-created or re-snapshotted.
@@ -281,7 +281,7 @@ class PurchaseWorkflow
}
foreach ($steps as $index => $step) {
$user = User::query()->where('tenant_id', $requisition->tenant_id)->lockForUpdate()->find($step->approver_id);
if (! $user || $step->decision !== 'approved' || $step->decided_at === null || $step->role !== $roles[$index] || $user->purchasing_role->value !== $step->role ||
if (! $user || $step->decision !== 'approved' || $step->decided_at === null || $step->role !== $roles[$index] || ! $user->hasFunctions([$step->role]) ||
($step->role === 'department_head' && $user->department_id !== $requisition->department_id) || in_array($user->id, $seen, true)) {
throw ValidationException::withMessages(['approvals' => 'As aprovações do valor final devem continuar elegíveis e ser de pessoas distintas.']);
}
+2
View File
@@ -20,6 +20,7 @@ class RequisitionWorkflow
return DB::transaction(function () use ($user, $data, $existing) {
app(ConflictGuard::class)->lockCompany($user);
$user = app(CurrentActor::class)->lock($user);
abort_unless(app(OrganizationAccess::class)->canRequest($user), 403);
if (! $user->department_id || ! CostCenter::query()->whereKey($data['cost_center_id'])->where('department_id', $user->department_id)->exists()) {
throw ValidationException::withMessages(['cost_center_id' => 'O departamento ou centro de custo mudou. Actualize o pedido antes de guardar.']);
}
@@ -76,6 +77,7 @@ class RequisitionWorkflow
DB::transaction(function () use ($user, $requisition) {
app(ConflictGuard::class)->lockCompany($user);
$user = app(CurrentActor::class)->lock($user);
abort_unless(app(OrganizationAccess::class)->canRequest($user), 403);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
abort_unless($requisition->requester_id === $user->id && $requisition->status === 'draft', 403);
if ($user->department_id !== $requisition->department_id) {
+1 -1
View File
@@ -27,7 +27,7 @@ class StockWorkflow
/** @param array{kind: string, reference: string, idempotency_key: string, responsible_id: int, warehouse_id: int, stock_item_id: int, destination_warehouse_id?: int, quantity: string, reason: string, destination?: string, count_id?: int, ledger_version?: int, equipment_id?: int} $data */
public function record(User $actor, array $data): StockOperation
{
abort_unless($actor->tenant_id !== null && $actor->purchasing_role === PurchasingRole::Warehouse && auth()->id() === $actor->id, 403);
abort_unless($actor->tenant_id !== null && $actor->hasFunctions([PurchasingRole::Warehouse]) && auth()->id() === $actor->id, 403);
return DB::transaction(function () use ($actor, $data) {
// Same first lock as receipts: includes empty balances and concurrent receipts.
+3 -3
View File
@@ -24,7 +24,7 @@ class SupplierBankWorkflow
public function propose(User $user, Supplier $supplier, array $data): SupplierBankDetail
{
$this->actor($user, $supplier);
abort_unless($user->purchasing_role === PurchasingRole::Procurement, 403);
abort_unless($user->hasFunctions([PurchasingRole::Procurement]), 403);
return DB::transaction(function () use ($user, $supplier, $data) {
app(ConflictGuard::class)->lockCompany($user);
@@ -44,7 +44,7 @@ class SupplierBankWorkflow
public function review(User $user, SupplierBankDetail $detail, string $decision, string $reason): void
{
abort_unless($user->purchasing_role === PurchasingRole::Finance && $detail->tenant_id === $user->tenant_id && $detail->created_by !== $user->id, 403);
abort_unless($user->hasFunctions([PurchasingRole::Finance]) && $detail->tenant_id === $user->tenant_id && $detail->created_by !== $user->id, 403);
if (! in_array($decision, ['approved', 'rejected']) || trim($reason) === '') {
throw ValidationException::withMessages(['bank' => 'Decisão e fundamento obrigatórios.']);
}
@@ -92,7 +92,7 @@ class SupplierBankWorkflow
}
foreach ($reviews as $review) {
$user = User::query()->where('tenant_id', $supplier->tenant_id)->find($review->reviewed_by);
if (! $user || $user->purchasing_role !== PurchasingRole::Finance || $user->id === $detail->created_by || app(ConflictGuard::class)->hasConflict($user->id, $supplier->id, null)) {
if (! $user || ! $user->hasFunctions([PurchasingRole::Finance]) || $user->id === $detail->created_by || app(ConflictGuard::class)->hasConflict($user->id, $supplier->id, null)) {
return 'blocked';
}
}
+235
View File
@@ -0,0 +1,235 @@
<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\PurchaseOrder;
use App\Models\Requisition;
use App\Models\Supplier;
use App\Models\SupplierEvaluation;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class SupplierEvaluationWorkflow
{
public const ROLES = [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead];
public function reader(User $actor): User
{
abort_unless(auth()->id() === $actor->id && $actor->tenant_id !== null, 403);
$fresh = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($actor->id);
abort_unless($fresh->hasFunctions(self::ROLES), 403);
return $fresh;
}
/**
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
private function normalise(User $actor, Supplier $supplier, array $payload): array
{
$orders = array_values(array_unique(array_map('intval', $payload['order_ids'] ?? [])));
sort($orders);
$payload = ['title' => $payload['title'], 'nature' => $payload['nature'], 'scope' => $payload['scope'] ?? '',
'period_start' => $payload['period_start'] ?? null, 'period_end' => $payload['period_end'] ?? null,
'order_ids' => $orders,
'criteria' => array_map(fn ($row) => ['label' => $row['label'] ?? '', 'result' => $row['result'], 'reason' => $row['reason'] ?? '', 'evidence' => $row['evidence'] ?? ''], $payload['criteria']),
'conclusion' => $payload['conclusion'] ?? '', 'critical_occurrences' => $payload['critical_occurrences'] ?? '', 'critical_evidence' => $payload['critical_evidence'] ?? ''];
if ($payload['period_start'] && $payload['period_end'] && $payload['period_end'] < $payload['period_start']) {
throw ValidationException::withMessages(['payload.period_end' => 'O fim do período deve ser igual ou posterior ao início.']);
}
$orders = PurchaseOrder::query()->visibleTo($actor)->where('supplier_id', $supplier->id)->whereIn('id', $payload['order_ids'])->count();
if ($orders !== count($payload['order_ids'])) {
throw ValidationException::withMessages(['payload.order_ids' => 'Escolha apenas ordens autorizadas deste fornecedor.']);
}
foreach (PurchaseOrder::query()->whereIn('id', $payload['order_ids'])->pluck('requisition_id') as $requisitionId) {
abort_if(app(ConflictGuard::class)->hasConflict($actor->id, $supplier->id, (int) $requisitionId), 403, 'Existe uma declaração de conflito num dos processos abrangidos.');
}
return $payload;
}
/** @param array<string,mixed> $payload */
private function department(User $actor, array $payload): ?int
{
if (! $payload['order_ids']) {
return $actor->department_id;
}
$departments = Requisition::query()->whereIn('id', PurchaseOrder::query()->whereIn('id', $payload['order_ids'])->select('requisition_id'))->distinct()->pluck('department_id');
return $departments->count() === 1 ? (int) $departments->first() : null;
}
private function actor(User $actor): User
{
app(ConflictGuard::class)->lockCompany($actor);
return app(CurrentActor::class)->lock($actor, self::ROLES);
}
private function participating(User $actor, int $supplierId): void
{
abort_if(app(ConflictGuard::class)->hasConflict($actor->id, $supplierId, null), 403, 'Existe uma declaração de conflito com este fornecedor.');
}
/** @return array<string,mixed> */
public function snapshot(SupplierEvaluation $evaluation): array
{
return ['evaluation_id' => $evaluation->id, ...$evaluation->only(['id', 'supplier_id', 'department_id', 'created_by', 'submitted_by', 'reviewed_by', 'previous_id', 'revision', 'version', 'status', 'payload', 'supplier_snapshot', 'contributors', 'review_reason', 'reviewed_at', 'created_at', 'updated_at'])];
}
/** @param array<string,mixed>|null $before */
private function audit(User $actor, string $action, SupplierEvaluation $evaluation, ?array $before): void
{
app(RequisitionWorkflow::class)->audit($actor, 'supplier_evaluation.'.$action, null, $before, $this->snapshot($evaluation));
}
/** @param array<string,mixed> $payload */
public function create(User $actor, int $supplierId, string $operation, array $payload): SupplierEvaluation
{
return DB::transaction(function () use ($actor, $supplierId, $operation, $payload) {
$actor = $this->actor($actor);
abort_if($actor->departmentScoped() && $actor->department_id === null, 403, 'O perfil de chefia precisa do departamento correspondente.');
$supplier = Supplier::query()->findOrFail($supplierId);
$this->participating($actor, $supplier->id);
$payload = $this->normalise($actor, $supplier, $payload);
$hash = hash('sha256', json_encode([$supplierId, $payload], JSON_THROW_ON_ERROR));
$existing = SupplierEvaluation::query()->where('operation_key', $operation)->first();
if ($existing) {
abort_unless($existing->created_by === $actor->id && $existing->creation_hash === $hash, 409, 'Pedido de criação já utilizado com outros dados.');
return $existing;
}
$evaluation = SupplierEvaluation::query()->create([
'tenant_id' => $actor->tenant_id, 'supplier_id' => $supplier->id, 'department_id' => $this->department($actor, $payload),
'created_by' => $actor->id, 'operation_key' => $operation, 'creation_hash' => $hash,
'supplier_snapshot' => $supplier->only(['id', 'name', 'nuit']), 'contributors' => [$actor->id], 'payload' => $payload,
]);
$this->audit($actor, 'created', $evaluation, null);
return $evaluation;
});
}
/** @param array<string,mixed> $payload */
public function save(User $actor, int $id, int $version, array $payload): SupplierEvaluation
{
return DB::transaction(function () use ($actor, $id, $version, $payload) {
$actor = $this->actor($actor);
$evaluation = $this->locked($actor, $id, $version);
$this->participating($actor, $evaluation->supplier_id);
abort_unless(in_array($evaluation->status, ['draft', 'returned'], true), 409, 'A avaliação não está aberta para edição.');
$payload = $this->normalise($actor, Supplier::query()->findOrFail($evaluation->supplier_id), $payload);
if ($payload === $evaluation->payload) {
return $evaluation;
}
$before = $this->snapshot($evaluation);
$evaluation->fill(['payload' => $payload, 'department_id' => $this->department($actor, $payload), 'contributors' => array_values(array_unique([...$evaluation->contributors, $actor->id])),
'version' => $version + 1, 'status' => 'draft', 'submitted_by' => null, 'reviewed_by' => null, 'reviewed_at' => null, 'review_reason' => null])->save();
$this->audit($actor, 'updated', $evaluation, $before);
return $evaluation;
});
}
private function locked(User $actor, int $id, int $version): SupplierEvaluation
{
$evaluation = SupplierEvaluation::query()->visibleTo($actor)->lockForUpdate()->findOrFail($id);
if ($evaluation->version !== $version) {
throw ValidationException::withMessages(['version' => 'A avaliação foi alterada. Os seus dados não foram sobrepostos; consulte a versão actual antes de comparar.']);
}
return $evaluation;
}
/** @param array<string,mixed> $payload */
private function complete(array $payload, bool $approving): void
{
$errors = [];
foreach (['scope' => 'Descreva o âmbito avaliado.', 'period_start' => 'Indique o início do período.', 'period_end' => 'Indique o fim do período.', 'conclusion' => 'Registe a conclusão.'] as $key => $message) {
if (empty($payload[$key])) {
$errors['payload.'.$key] = $message;
}
}
if (! $payload['criteria']) {
$errors['payload.criteria'] = 'Indique pelo menos um critério e a situação da evidência.';
}
foreach ($payload['criteria'] as $index => $row) {
if (trim($row['label']) === '' || trim($row['reason']) === '') {
$errors['payload.criteria.'.$index.'.reason'] = 'Identifique e justifique cada critério.';
}
if ($row['result'] === 'observed' && trim($row['evidence']) === '') {
$errors['payload.criteria.'.$index.'.evidence'] = 'A observação precisa de uma referência de evidência.';
}
if ($approving && in_array($row['result'], ['pending', 'insufficient'], true)) {
$errors['payload.criteria.'.$index.'.result'] = 'Complete as evidências em falta ou devolva a avaliação para correcção.';
}
}
if ($payload['critical_occurrences'] !== '' && trim($payload['critical_evidence']) === '') {
$errors['payload.critical_evidence'] = 'Indique a evidência das ocorrências críticas.';
}
if ($errors) {
throw ValidationException::withMessages($errors);
}
}
public function transition(User $actor, int $id, int $version, string $action, ?string $reason): SupplierEvaluation
{
return DB::transaction(function () use ($actor, $id, $version, $action, $reason) {
$actor = $this->actor($actor);
$evaluation = $this->locked($actor, $id, $version);
$this->participating($actor, $evaluation->supplier_id);
$before = $this->snapshot($evaluation);
$this->normalise($actor, Supplier::query()->findOrFail($evaluation->supplier_id), $evaluation->payload);
if ($action === 'submit') {
abort_unless(in_array($evaluation->status, ['draft', 'returned'], true), 409);
$this->complete($evaluation->payload, false);
$evaluation->fill(['status' => 'review', 'submitted_by' => $actor->id, 'contributors' => array_values(array_unique([...$evaluation->contributors, $actor->id])), 'reviewed_by' => null, 'reviewed_at' => null, 'review_reason' => null]);
} else {
abort_unless(in_array($action, ['approve', 'return'], true) && $evaluation->status === 'review', 409);
if ($reason === null || mb_strlen(trim($reason)) < 10) {
throw ValidationException::withMessages(['reason' => 'Fundamente a decisão de revisão.']);
}
abort_if(in_array($actor->id, $evaluation->contributors, true), 403, 'A conferência deve ser realizada por outra pessoa que não elaborou esta revisão.');
if ($action === 'approve') {
$this->complete($evaluation->payload, true);
}
$evaluation->fill(['status' => $action === 'approve' ? 'approved' : 'returned', 'reviewed_by' => $actor->id, 'reviewed_at' => now(), 'review_reason' => $reason]);
}
$evaluation->version = $version + 1;
$evaluation->save();
$this->audit($actor, $action, $evaluation, $before);
return $evaluation;
});
}
public function revise(User $actor, int $id, int $version): SupplierEvaluation
{
return DB::transaction(function () use ($actor, $id, $version) {
$actor = $this->actor($actor);
$previous = $this->locked($actor, $id, $version);
$this->participating($actor, $previous->supplier_id);
$this->normalise($actor, Supplier::query()->findOrFail($previous->supplier_id), $previous->payload);
abort_unless($previous->status === 'approved', 409);
$child = SupplierEvaluation::query()->visibleTo($actor)->where('previous_id', $id)->first();
if ($child) {
abort_unless($child->created_by === $actor->id, 409, 'Já existe uma revisão posterior; consulte o histórico.');
return $child;
}
$evaluation = SupplierEvaluation::query()->create([
'tenant_id' => $actor->tenant_id, 'supplier_id' => $previous->supplier_id, 'department_id' => $previous->department_id,
'created_by' => $actor->id, 'previous_id' => $id, 'revision' => $previous->revision + 1,
'operation_key' => (string) Str::uuid(), 'creation_hash' => hash('sha256', 'revision:'.$id.':'.$version),
'contributors' => [$actor->id], 'supplier_snapshot' => $previous->supplier_snapshot, 'payload' => $previous->payload,
]);
$this->audit($actor, 'revised', $evaluation, null);
return $evaluation;
});
}
}
@@ -0,0 +1,120 @@
<?php
namespace App\Services;
use App\Models\PurchaseOrder;
use App\Models\SupplierEvaluation;
use App\Models\SupplierImprovement;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class SupplierImprovementWorkflow
{
public function canParticipate(User $actor, SupplierEvaluation $evaluation): bool
{
$guard = app(ConflictGuard::class);
if ($guard->hasConflict($actor->id, $evaluation->supplier_id, null)) {
return false;
}
foreach (PurchaseOrder::query()->whereIn('id', $evaluation->payload['order_ids'])->pluck('requisition_id') as $id) {
if ($guard->hasConflict($actor->id, $evaluation->supplier_id, (int) $id)) {
return false;
}
}
return true;
}
/** @return array<string,mixed> */
public function snapshot(SupplierImprovement $measure): array
{
return $measure->only(['id', 'supplier_evaluation_id', 'created_by', 'responsible_id', 'title', 'description', 'due_on', 'version', 'status', 'contributors', 'result', 'evidence', 'reviewed_by', 'review_reason', 'reviewed_at', 'created_at', 'updated_at']);
}
private function assignee(User $actor, SupplierEvaluation $evaluation, int $id): User
{
$user = User::query()->where('tenant_id', $actor->tenant_id)->lockForUpdate()->find($id);
if (! $user || ! $user->hasFunctions(SupplierEvaluationWorkflow::ROLES)
|| ($user->departmentScoped() && ($user->department_id === null || $user->department_id !== $evaluation->department_id))
|| ! $this->canParticipate($user, $evaluation)) {
throw ValidationException::withMessages(['responsible_id' => 'Escolha um responsável autorizado desta empresa, sem conflito no âmbito avaliado.']);
}
return $user;
}
/** @param array<string,mixed> $data */
public function execute(User $actor, int $evaluationId, ?int $id, string $command, array $data): SupplierImprovement
{
return DB::transaction(function () use ($actor, $evaluationId, $id, $command, $data) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor, SupplierEvaluationWorkflow::ROLES);
$evaluation = SupplierEvaluation::query()->visibleTo($actor)->lockForUpdate()->findOrFail($evaluationId);
abort_unless($evaluation->status === 'approved', 409, 'Associe medidas à revisão aprovada da avaliação.');
abort_unless($this->canParticipate($actor, $evaluation), 403, 'Existe um conflito no âmbito desta avaliação.');
if ($command === 'create') {
$plan = ['title' => $data['title'], 'description' => $data['description'], 'responsible_id' => (int) $data['responsible_id'], 'due_on' => $data['due_on']];
$hash = hash('sha256', json_encode([$evaluationId, $plan], JSON_THROW_ON_ERROR));
$existing = SupplierImprovement::query()->where('operation_key', $data['operation_key'])->first();
if ($existing) {
abort_unless($existing->created_by === $actor->id && $existing->creation_hash === $hash, 409, 'Pedido de criação já utilizado.');
return $existing;
}
$responsible = $this->assignee($actor, $evaluation, $plan['responsible_id']);
$measure = SupplierImprovement::query()->create([...$plan, 'tenant_id' => $actor->tenant_id, 'supplier_evaluation_id' => $evaluationId,
'created_by' => $actor->id, 'operation_key' => $data['operation_key'], 'creation_hash' => $hash,
'contributors' => array_values(array_unique([$actor->id, $responsible->id]))]);
$before = null;
} else {
$measure = SupplierImprovement::query()->where('supplier_evaluation_id', $evaluationId)->lockForUpdate()->findOrFail($id);
if ($measure->version !== (int) $data['version']) {
throw ValidationException::withMessages(['version' => 'A medida foi alterada. Consulte a versão actual antes de comparar; os seus dados não foram sobrepostos.']);
}
abort_if($measure->status === 'closed', 409, 'A medida já está encerrada e preservada.');
$before = $this->snapshot($measure);
if ($command === 'plan') {
abort_unless(in_array($measure->status, ['open', 'returned'], true), 409);
$responsible = $this->assignee($actor, $evaluation, (int) $data['responsible_id']);
$measure->fill(['title' => $data['title'], 'description' => $data['description'], 'responsible_id' => $responsible->id, 'due_on' => $data['due_on'],
'contributors' => array_values(array_unique([...$measure->contributors, $actor->id, $responsible->id]))]);
if (! $measure->isDirty()) {
return $measure;
}
if (mb_strlen(trim($data['reason'] ?? '')) < 10) {
throw ValidationException::withMessages(['reason' => 'Justifique a alteração do plano, prazo ou responsável.']);
}
} elseif (in_array($command, ['progress', 'submit'], true)) {
abort_unless($actor->id === $measure->responsible_id && in_array($measure->status, ['open', 'returned'], true), 403);
$measure->fill(['result' => $data['result'] ?? '', 'evidence' => $data['evidence'] ?? '']);
if ($command === 'submit') {
if (trim($measure->result ?? '') === '' || trim($measure->evidence ?? '') === '') {
throw ValidationException::withMessages(['evidence' => 'Registe resultado e referência da evidência antes de pedir encerramento.']);
}
$measure->status = 'review';
}
if (! $measure->isDirty()) {
return $measure;
}
} else {
abort_unless(in_array($command, ['close', 'return'], true) && $measure->status === 'review', 409);
abort_if(in_array($actor->id, $measure->contributors, true), 403, 'O encerramento exige outra pessoa, independente da preparação e execução.');
if (mb_strlen(trim($data['reason'] ?? '')) < 10) {
throw ValidationException::withMessages(['reason' => 'Fundamente a decisão sobre a execução.']);
}
$measure->fill(['status' => $command === 'close' ? 'closed' : 'returned', 'reviewed_by' => $actor->id, 'reviewed_at' => now(), 'review_reason' => $data['reason']]);
}
$measure->version++;
$measure->save();
}
$after = $this->snapshot($measure);
if ($command === 'plan') {
$after['change_reason'] = $data['reason'];
}
app(RequisitionWorkflow::class)->audit($actor, 'supplier_improvement.'.$command, null, $before, $after);
return $measure;
});
}
}
+4
View File
@@ -29,6 +29,10 @@ final class SupplierPayables
public function can(User $u, string $action): bool
{
if (app(OrganizationAccess::class)->active($u->tenant_id) && ! $u->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance])) {
return false;
}
return self::enabled() && DB::table('pa_access')->where($this->scope($u))->where(['user_id' => $u->id, 'action' => $action])->exists();
}
+100
View File
@@ -0,0 +1,100 @@
<?php
namespace App\Services;
use App\Models\SupplierEvaluation;
use App\Models\SupplierResponse;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class SupplierResponseWorkflow
{
/** @return array<string,mixed> */
public function snapshot(SupplierResponse $record): array
{
return $record->only(['id', 'supplier_evaluation_id', 'created_by', 'version', 'status', 'payload', 'contributors', 'decision', 'reviewed_by', 'review_reason', 'reviewed_at', 'created_at', 'updated_at']);
}
/** @param array<string,mixed> $payload
* @return array<string,mixed>
*/
private function normalise(array $payload): array
{
return ['title' => $payload['title'], 'received_on' => $payload['received_on'] ?? null, 'source_reference' => $payload['source_reference'] ?? '', 'statement' => $payload['statement'] ?? ''];
}
/** @param array<string,mixed> $data */
public function execute(User $actor, int $evaluationId, ?int $id, string $command, array $data): SupplierResponse
{
return DB::transaction(function () use ($actor, $evaluationId, $id, $command, $data) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor, SupplierEvaluationWorkflow::ROLES);
$evaluation = SupplierEvaluation::query()->visibleTo($actor)->lockForUpdate()->findOrFail($evaluationId);
abort_unless($evaluation->status === 'approved', 409, 'O contraditório refere-se à revisão aprovada.');
abort_unless(app(SupplierImprovementWorkflow::class)->canParticipate($actor, $evaluation), 403, 'Existe conflito no âmbito da avaliação.');
if ($command === 'create') {
$payload = $this->normalise($data['payload']);
$hash = hash('sha256', json_encode([$evaluationId, $payload], JSON_THROW_ON_ERROR));
$existing = SupplierResponse::query()->where('operation_key', $data['operation_key'])->first();
if ($existing) {
abort_unless($existing->created_by === $actor->id && $existing->creation_hash === $hash, 409, 'Pedido já utilizado com outro conteúdo.');
return $existing;
}
$record = SupplierResponse::query()->create(['tenant_id' => $actor->tenant_id, 'supplier_evaluation_id' => $evaluationId,
'created_by' => $actor->id, 'operation_key' => $data['operation_key'], 'creation_hash' => $hash,
'payload' => $payload, 'contributors' => [$actor->id]]);
$before = null;
} else {
$record = SupplierResponse::query()->where('supplier_evaluation_id', $evaluationId)->lockForUpdate()->findOrFail($id);
if ($record->version !== (int) $data['version']) {
throw ValidationException::withMessages(['version' => 'O registo foi alterado. Consulte a versão actual antes de comparar; os seus dados não foram sobrepostos.']);
}
abort_if($record->status === 'decided', 409, 'A conclusão já está registada e preservada.');
$before = $this->snapshot($record);
if ($command === 'save') {
abort_unless(in_array($record->status, ['draft', 'returned'], true), 409);
$payload = $this->normalise($data['payload']);
if ($payload === $record->payload) {
return $record;
}
$record->fill(['payload' => $payload, 'contributors' => array_values(array_unique([...$record->contributors, $actor->id]))]);
} elseif ($command === 'submit') {
abort_unless(in_array($record->status, ['draft', 'returned'], true), 409);
$errors = [];
foreach (['received_on', 'source_reference', 'statement'] as $key) {
if (trim($record->payload[$key] ?? '') === '') {
$errors['payload.'.$key] = 'Complete a data, a origem documental e os argumentos recebidos antes de submeter.';
}
}
if (($record->payload['received_on'] ?? '') > today()->toDateString()) {
$errors['payload.received_on'] = 'A data de recepção não pode ser futura.';
}
if ($errors) {
throw ValidationException::withMessages($errors);
}
$record->fill(['status' => 'review', 'contributors' => array_values(array_unique([...$record->contributors, $actor->id]))]);
} else {
abort_unless(in_array($command, ['return', 'decide'], true) && $record->status === 'review', 409);
abort_if(in_array($actor->id, $record->contributors, true), 403, 'A análise deve ser concluída por outra pessoa que não preparou o registo.');
if (mb_strlen(trim($data['reason'] ?? '')) < 10) {
throw ValidationException::withMessages(['reason' => 'Fundamente a análise ou a devolução.']);
}
if ($command === 'decide') {
if (in_array($data['outcome'], ['accepted', 'partially_accepted'], true) && mb_strlen(trim($data['follow_up'] ?? '')) < 10) {
throw ValidationException::withMessages(['follow_up' => 'Descreva como será dado seguimento ao acolhimento dos argumentos.']);
}
$record->decision = ['outcome' => $data['outcome'], 'reason' => $data['reason'], 'follow_up' => $data['follow_up'] ?? '', 'response_reference' => $data['response_reference'] ?? ''];
}
$record->fill(['status' => $command === 'decide' ? 'decided' : 'returned', 'reviewed_by' => $actor->id, 'reviewed_at' => now(), 'review_reason' => $data['reason']]);
}
$record->version++;
$record->save();
}
app(RequisitionWorkflow::class)->audit($actor, 'supplier_response.'.$command, null, $before, $this->snapshot($record));
return $record;
});
}
}