This commit is contained in:
2026-11-02 20:27:11 +02:00
parent b44378b899
commit badaada043
300 changed files with 17458 additions and 1056 deletions
@@ -3,50 +3,82 @@
namespace App\Http\Controllers;
use App\Models\CostCenter;
use App\Models\Equipment;
use App\Models\SupplyRecord;
use App\Models\User;
use App\Services\AutomaticPurchases;
use App\Services\OrganizationAccess;
use App\Services\ReplenishmentQueue;
use App\Services\SupplyPlanning;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class AutomaticPurchaseController extends Controller
{
public function index(Request $request, AutomaticPurchases $service)
public function index(Request $request, AutomaticPurchases $service): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User && $actor->hasFunctions(['admin', 'supply_read', 'supply_prepare', 'supply_review']), 403);
$prepare = app(SupplyPlanning::class)->allows($actor, 'supply_prepare');
$ready = $service->ready();
$planning = app(SupplyPlanning::class);
$filters = $request->validate(['need_state' => 'nullable|in:all,pending,created,covered,satisfied', 'rules_page' => 'nullable|integer|min:1|max:1000000', 'needs_page' => 'nullable|integer|min:1|max:1000000', 'history_page' => 'nullable|integer|min:1|max:1000000', 'work_page' => 'nullable|integer|min:1|max:1000000']);
$needs = $planning->latest('auto_need');
if (($filters['need_state'] ?? 'all') !== 'all') {
$needs->where('state', $filters['need_state']);
}
Inertia::encryptHistory();
return Inertia::render('stock/replenishment', [
$response = Inertia::render('stock/replenishment', [
'ready' => $ready, 'canPrepare' => $prepare, 'canReview' => $planning->allows($actor, 'supply_review'),
'rules' => $ready ? $planning->latest('auto_rule')->latest('id')->limit(100)->get() : [],
'needs' => $ready ? $planning->latest('auto_need')->latest('id')->limit(100)->get() : [],
'history' => $ready ? SupplyRecord::query()->whereIn('kind', ['auto_rule', 'auto_need'])->latest('id')->limit(100)->get() : [],
'filters' => ['need_state' => $filters['need_state'] ?? 'all'],
'rules' => $ready ? $planning->latest('auto_rule')->latest('id')->paginate(20, ['*'], 'rules_page')->withQueryString() : null,
'needs' => $ready ? $needs->orderByRaw("CASE WHEN state = 'pending' THEN 0 ELSE 1 END")->oldest('id')->paginate(20, ['*'], 'needs_page')->withQueryString() : null,
'history' => $ready ? SupplyRecord::query()->whereIn('kind', ['auto_rule', 'auto_need'])->latest('id')->paginate(30, ['*'], 'history_page')->withQueryString() : null,
...app(ReplenishmentQueue::class)->status($actor),
'workItems' => app(ReplenishmentQueue::class)->ready() ? DB::table('replenishment_work as w')->join('stock_items as i', 'i.id', '=', 'w.stock_item_id')->join('warehouses as a', 'a.id', '=', 'w.warehouse_id')->where('w.tenant_id', $actor->tenant_id)->where('i.tenant_id', $actor->tenant_id)->where('a.tenant_id', $actor->tenant_id)->where(fn ($q) => $q->whereColumn('w.generation', '>', 'w.processed_generation')->orWhere('w.failures', '>', 0))->orderBy('w.due_at')->orderBy('w.id')->paginate(20, ['w.id', 'i.code', 'i.name', 'a.name as warehouse', 'w.last_error', 'w.due_at', 'w.failures'], 'work_page')->withQueryString() : null,
'policies' => Schema::hasTable('supply_records') && $prepare ? $planning->latest('policy')->where('state', 'approved')->where('data->purpose', 'internal')->get() : [],
'requesters' => $prepare ? User::query()->where('tenant_id', $actor->tenant_id)->get(['id', 'name', 'department_id', 'purchasing_role', 'tenant_id'])->filter(fn ($u) => $service->eligibleRequester($u))->map(fn ($u) => $u->only(['id', 'name', 'department_id']))->values() : [],
'centers' => $prepare ? CostCenter::query()->orderBy('code')->get(['id', 'department_id', 'code', 'name']) : [],
])->toResponse($request)->withHeaders(['Cache-Control' => 'private, no-store, max-age=0']);
'equipment' => $prepare ? Equipment::query()->orderBy('code')->get(['id', 'code', 'description', 'department_id']) : [],
'catalogueTypes' => $prepare && Schema::hasTable('requisition_catalogue') ? DB::table('requisition_catalogue')->where('tenant_id', $actor->tenant_id)->where('published', true)->pluck('purchase_type', 'stock_item_id') : [],
])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function save(Request $request, AutomaticPurchases $service)
public function save(Request $request, AutomaticPurchases $service): RedirectResponse
{
$service->save($request->user('web'), $request->all());
return redirect('/stock/reposicao');
}
public function evaluate(Request $request, int $id, AutomaticPurchases $service)
public function evaluate(Request $request, int $id, AutomaticPurchases $service): RedirectResponse
{
$actor = $request->user('web');
abort_unless($actor instanceof User && ($actor->hasFunctions(['supply_prepare', 'supply_review'])) && ! app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
$need = SupplyRecord::query()->where('kind', 'auto_need')->findOrFail($id);
$service->evaluate($actor, $need->stock_item_id, $need->warehouse_id);
if (app(ReplenishmentQueue::class)->ready()) {
$service->signal($actor, $need->stock_item_id, $need->warehouse_id);
} else {
$service->evaluate($actor, $need->stock_item_id, $need->warehouse_id);
}
return redirect('/stock/reposicao');
}
public function recover(Request $request, int $id, AutomaticPurchases $service): RedirectResponse
{
$actor = $request->user('web');
abort_unless($actor instanceof User && $actor->hasFunctions(['supply_prepare', 'supply_review']) && ! app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
$work = DB::table('replenishment_work')->where('tenant_id', $actor->tenant_id)->where('id', $id)->firstOrFail();
$service->signal($actor, $work->stock_item_id, $work->warehouse_id);
return redirect('/stock/reposicao');
}
@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\User;
use App\Services\OrganizationAccess;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class CommonStockReadController extends Controller
{
public function index(Request $request)
{
// Native authority, reloaded on every request; no SGF grants or identity assumptions.
$user=User::query()->findOrFail($request->user()->id);
abort_unless($user->tenant_id!==null && $user->hasFunctions([PurchasingRole::Admin,PurchasingRole::Warehouse,PurchasingRole::Procurement,PurchasingRole::Finance])
&& !app(OrganizationAccess::class)->maintenanceOnly($user),403);
$d=$request->validate(['view'=>'sometimes|in:items,warehouses,balances,movements','search'=>'nullable|string|max:100',
'warehouse_id'=>'sometimes|integer|min:1','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|min:1|max:100','snapshot'=>'sometimes|string|max:8192']);
abort_if(array_diff(array_keys($request->query()),array_keys($d))!==[],422);
$filters=['view'=>$d['view']??'balances','search'=>$d['search']??'','warehouse_id'=>isset($d['warehouse_id'])?(int)$d['warehouse_id']:null,
'page'=>(int)($d['page']??1),'per_page'=>(int)($d['per_page']??25)];
$binding=config('stock_read.synthetic_sources.'.$user->tenant_id.':'.$user->id);
if(!$binding)return response()->json(['status'=>'unavailable','message'=>'Fonte autorizada de stock ainda não ligada a este contexto.'],503)->header('Cache-Control','no-store');
abort_unless(app()->environment('testing')&&DB::connection()->getDriverName()==='pgsql',403);
$identity=DB::selectOne("SELECT current_database() AS name,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
abort_unless(preg_match('/^stock_read_compras_([a-f0-9]{12})$/D',$identity->name,$m)
&& $identity->role==='stock_read_qa_'.$m[1] && (int)$identity->port===55439
&& preg_match('/^[a-f0-9]{12}$/D',(string)config('stock_read.qa_run'))
&& $identity->cluster==='sgf_modular_regression_'.config('stock_read.qa_run'),403);
abort_unless(preg_match('/^sgf_stock_read_[a-f0-9]{12}$/D',$binding['schema']??'')&&($binding['append_only']??false)===true
&& ($binding['source_tenant_id']??null)===(int)$user->tenant_id,503,'Fonte de stock incompatível.');
if($filters['warehouse_id']!==null)abort_unless(in_array($filters['warehouse_id'],$binding['warehouse_ids'],true),403);
$root=base_path('packages/inventory-read/releases/0.1.0');
abort_unless(is_file($root.'/manifest.json')&&hash_equals(config('stock_read.package_manifest_sha256'),hash_file('sha256',$root.'/manifest.json')),503,'Pacote de consulta incompatível.');
$manifest=json_decode(file_get_contents($root.'/manifest.json'),true,512,JSON_THROW_ON_ERROR);
foreach(['bootstrap.php','src/InventoryReader.php','src/InventoryReadSession.php'] as $file)
abort_unless(is_file($root.'/'.$file)&&isset($manifest['files'][$file])&&hash_equals($manifest['files'][$file],hash_file('sha256',$root.'/'.$file)),503,'Pacote de consulta incompatível.');
require_once $root.'/bootstrap.php';
$fingerprint=hash('sha256',json_encode(['compras',$user->tenant_id,$user->id,$request->session()->get('work_context'),$binding,array_diff_key($filters,['page'=>true])],JSON_THROW_ON_ERROR));
try { $result=(new \Tempo\Inventory\InventoryReadSession(DB::connection()))->read($binding,$fingerprint,$filters,$d['snapshot']??null); }
catch(\Illuminate\Database\QueryException $e){
if(!in_array($e->errorInfo[0]??null,['42P01','42703','42883','57014'],true))throw $e;
return response()->json(['status'=>'unavailable','message'=>'A fonte de stock está indisponível ou incompatível. Actualize a consulta mais tarde.'],503)->header('Cache-Control','no-store');
}
return response()->json($result)->header('Cache-Control','no-store');
}
}
@@ -7,22 +7,30 @@ use App\Models\PurchaseAward;
use App\Models\QualificationAssessment;
use App\Models\QualificationSet;
use App\Models\Requisition;
use App\Services\ConflictGuard;
use App\Models\User;
use App\Services\DocumentaryQualificationWorkflow;
use App\Services\ProposalComparison;
use App\Services\ProposalOpeningWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class DocumentaryQualificationController extends Controller
{
private function actor(Request $request): User
{
$actor = $request->user('web');
abort_unless($actor instanceof User && $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
return $actor;
}
private function access(Request $request, int $id): Requisition
{
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
abort_unless($this->actor($request)->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
return Requisition::query()->visibleTo($request->user())->findOrFail($id);
return Requisition::query()->visibleTo($this->actor($request))->findOrFail($id);
}
public function show(Request $request, Requisition $requisition, ProposalOpeningWorkflow $opening): Response
@@ -32,11 +40,13 @@ class DocumentaryQualificationController extends Controller
$sets = QualificationSet::query()->where('requisition_id', $requisition->id)->latest('revision')->get(['id', 'revision', 'reason', 'created_at']);
$latest = $sets->first() ? QualificationSet::query()->findOrFail($sets->first()->id) : null;
$selected = isset($filters['version']) ? QualificationSet::query()->where('requisition_id', $requisition->id)->findOrFail((int) $filters['version']) : $latest;
$focusInput = $request->validate(['quotation_id' => ['nullable', 'integer', 'min:1', Rule::exists('quotations', 'id')->where('tenant_id', $requisition->tenant_id)->where('requisition_id', $requisition->id), Rule::in(array_column($selected->snapshot['proposals'] ?? [], 'id'))]]);
$focus = isset($focusInput['quotation_id']) ? (int) $focusInput['quotation_id'] : null;
$proposals = $opening->proposals($requisition);
$basis = $opening->basis($proposals);
$current = $selected && $selected->id === $latest?->id && $selected->basis_hash === $basis;
$workflow = app(DocumentaryQualificationWorkflow::class);
$current = $selected !== null && $workflow->currentSet($requisition, $selected);
$closed = $requisition->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $requisition->id)->exists();
$open = ! $closed && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition);
$query = QualificationAssessment::query()->where('qualification_set_id', $selected->id ?? 0);
$recordIds = (clone $query)->selectRaw('MAX(id)')->groupBy('quotation_id', 'analyst_id');
$records = QualificationAssessment::query()->whereIn('id', $recordIds)->orderBy('quotation_id')->orderBy('analyst_id')->get();
@@ -46,9 +56,10 @@ class DocumentaryQualificationController extends Controller
'latestId' => $latest->id ?? 0, 'latestCriteria' => $latest->criteria ?? [], 'basisHash' => $basis, 'isCurrent' => (bool) $current,
'proposals' => $proposals,
'closedForChanges' => $closed,
'records' => $records, 'history' => $query->latest('id')->paginate(20)->withQueryString(), 'actorId' => $request->user()->id,
'canDefine' => $open && $proposals !== [] && $request->user()->hasFunctions([PurchasingRole::Procurement]),
'canAssess' => $open && $current && $request->user()->id !== $requisition->requester_id && app(ProposalComparison::class)->eligibleRole($request->user(), $requisition),
'records' => $records, 'history' => $query->latest('id')->paginate(20)->withQueryString(), 'actorId' => $this->actor($request)->id,
'focusQuotationId' => $focus,
'canDefine' => $workflow->canDefine($this->actor($request), $requisition),
'canAssess' => $selected !== null && $workflow->canAssess($this->actor($request), $requisition, $selected),
])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
@@ -64,7 +75,7 @@ class DocumentaryQualificationController extends Controller
'criteria.*.label' => 'required|string|min:3|max:500', 'criteria.*.category' => 'required|in:legal,financial,fiscal,technical,administrative', 'criteria.*.required' => 'required|boolean',
'snapshot' => 'prohibited', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited',
]);
$workflow->define($request->user(), $requisition, $data);
$workflow->define($this->actor($request), $requisition, $data);
return to_route('qualifications.show', $requisition);
}
@@ -80,7 +91,7 @@ class DocumentaryQualificationController extends Controller
'snapshot' => 'prohibited', 'analyst_id' => 'prohibited', 'tenant_id' => 'prohibited', 'revision' => 'prohibited',
]);
$data['answers'] = array_map(fn ($answer) => [...$answer, 'criterion_id' => (int) $answer['criterion_id']], $data['answers']);
$workflow->assess($request->user(), $set, $data);
$workflow->assess($this->actor($request), $set, $data);
return to_route('qualifications.show', $set->requisition_id);
}
@@ -5,13 +5,17 @@ namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\ExternalPayment;
use App\Models\Invoice;
use App\Models\PurchaseOrder;
use App\Models\Supplier;
use App\Models\SupplierBankDetail;
use App\Models\User;
use App\Services\ExternalPaymentWorkflow;
use App\Services\OrganizationAccess;
use App\Services\RequisitionWorkflow;
use App\Services\SupplierBankWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
@@ -19,20 +23,27 @@ class ExternalPaymentController extends Controller
{
public function index(Request $request, ExternalPaymentWorkflow $workflow): Response
{
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]);
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
abort_unless($actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
$filter = $request->validate(['payment_id' => ['nullable', 'integer', 'min:1', Rule::exists('external_payments', 'id')->where('tenant_id', $actor->tenant_id)], 'invoice_id' => ['nullable', 'integer', 'min:1', Rule::exists('invoices', 'id')->where('tenant_id', $actor->tenant_id)]]);
$focus = isset($filter['payment_id']) ? (int) $filter['payment_id'] : null;
$focusInvoice = isset($filter['invoice_id']) ? (int) $filter['invoice_id'] : null;
$canManage = $actor->hasFunctions([PurchasingRole::Finance]) && ! app(OrganizationAccess::class)->maintenanceOnly($actor);
$visibleInvoices = Invoice::query()->whereIn('purchase_order_id', PurchaseOrder::query()->visibleTo($actor)->select('id'));
$payments = ExternalPayment::query()->whereIn('invoice_id', (clone $visibleInvoices)->select('id'))->when($focus !== null, fn ($q) => $q->whereKey($focus))->when($focusInvoice !== null, fn ($q) => $q->where('invoice_id', $focusInvoice))->latest('id')->paginate(15)->withQueryString()
->through(fn (ExternalPayment $payment) => [...$payment->toArray(), 'canReview' => $workflow->reviewerEligible($actor, $payment), '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()]);
app(RequisitionWorkflow::class)->audit($actor, 'payment.viewed', null, null, ['payment_ids' => $payments->pluck('id')->all()]);
}
$invoices = Invoice::query()->where('status', 'validated')->latest('id')->get()->map(function (Invoice $invoice) use ($workflow) {
$invoices = $visibleInvoices->where('status', 'validated')->when($focus !== null, fn ($q) => $q->whereIn('id', $payments->pluck('invoice_id')))->when($focusInvoice !== null, fn ($q) => $q->whereKey($focusInvoice))->latest('id')->get()->map(function (Invoice $invoice) use ($workflow) {
$supplier = Supplier::query()->findOrFail($invoice->supplier_id);
$bank = SupplierBankDetail::query()->where('supplier_id', $supplier->id)->latest('revision')->first();
return ['id' => $invoice->id, 'number' => $invoice->number, 'supplier_id' => $supplier->id, 'supplier_name' => $supplier->name, 'total_minor' => $invoice->total_minor, 'outstanding_minor' => $workflow->outstanding($invoice), 'bank_id' => $bank->id ?? 0, 'bank_revision' => $bank->revision ?? 0, 'bank_state' => app(SupplierBankWorkflow::class)->state($supplier)];
});
Inertia::encryptHistory();
$response = Inertia::render('payments/index', ['invoices' => $invoices, 'payments' => $payments, 'canManage' => $canManage])->toResponse($request);
$response = Inertia::render('payments/index', ['focusPaymentId' => $focus, 'focusInvoiceId' => $focusInvoice, 'invoices' => $invoices, 'payments' => $payments, 'canManage' => $canManage])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
@@ -40,17 +51,20 @@ class ExternalPaymentController extends Controller
public function store(Request $request, Invoice $invoice, ExternalPaymentWorkflow $workflow): RedirectResponse
{
abort_unless($request->user()->hasFunctions([PurchasingRole::Finance]), 403);
$actor = $request->user('web');
abort_unless($actor instanceof User && $actor->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);
$workflow->register($actor, $invoice, $data);
return to_route('payments.index');
}
public function review(Request $request, ExternalPayment $payment, ExternalPaymentWorkflow $workflow): RedirectResponse
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$data = $request->validate(['decision' => 'required|in:confirmed,rejected', 'reason' => 'required|string|max:2000', 'independently_verified' => 'accepted']);
$workflow->review($request->user(), $payment, $data['decision'], $data['reason']);
$workflow->review($actor, $payment, $data['decision'], $data['reason']);
return back();
}
@@ -9,26 +9,35 @@ use App\Models\AdmissionPolicy;
use App\Models\AdmissionReview;
use App\Models\PurchaseAward;
use App\Models\Requisition;
use App\Services\ConflictGuard;
use App\Models\User;
use App\Services\FormalAdmission;
use App\Services\ProposalOpeningWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class FormalAdmissionController extends Controller
{
private function actor(Request $request): User
{
$actor = $request->user('web');
abort_unless($actor instanceof User && $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
return $actor;
}
private function access(Request $request, int $id): Requisition
{
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
abort_unless($this->actor($request)->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
return Requisition::query()->visibleTo($request->user())->findOrFail($id);
return Requisition::query()->visibleTo($this->actor($request))->findOrFail($id);
}
public function policy(Request $request): Response
{
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin]), 403);
abort_unless($this->actor($request)->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');
@@ -40,7 +49,7 @@ class FormalAdmissionController extends Controller
{
$data = $request->validate(['previous_id' => 'required|integer|min:0', 'enabled' => 'required|boolean', 'roles' => 'required|array|min:1|max:2', 'roles.*' => 'required|distinct|in:finance,department_head', 'required_count' => 'required|integer|min:1|max:50', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'snapshot' => 'prohibited']);
$data['enabled'] = (bool) $data['enabled'];
$workflow->policy($request->user(), $data);
$workflow->policy($this->actor($request), $data);
return to_route('admissions.policy');
}
@@ -51,27 +60,27 @@ class FormalAdmissionController extends Controller
$filter = $request->validate(['version' => 'nullable|integer|min:1']);
$adoption = $workflow->adoption($requisition);
$selected = isset($filter['version']) ? AdmissionAdoption::query()->where('requisition_id', $requisition->id)->findOrFail((int) $filter['version']) : $adoption;
$focusInput = $request->validate(['decision_id' => ['nullable', 'integer', 'min:1', Rule::exists('admission_decisions', 'id')->where('tenant_id', $requisition->tenant_id)->where('admission_adoption_id', $selected->id ?? 0)]]);
$focus = isset($focusInput['decision_id']) ? (int) $focusInput['decision_id'] : null;
$current = $selected && $selected->id === $adoption?->id;
$closed = $requisition->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $requisition->id)->exists();
$open = ! $closed && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition);
$open = $workflow->canParticipate($this->actor($request), $requisition);
$decisions = AdmissionDecision::query()->where('admission_adoption_id', $selected->id ?? 0)->latest('id')->get();
$latestIds = $decisions->unique('quotation_id')->pluck('id');
$proposals = collect($opening->proposals($requisition))->map(function ($quote) use ($workflow, $requisition, $decisions) {
$basis = $workflow->basis($requisition, $quote['id']);
return [...$quote, 'basisHash' => $basis['hash'] ?? null, 'clear' => $basis['clear'] ?? false, 'previousId' => $decisions->firstWhere('quotation_id', $quote['id'])->id ?? 0];
});
$history = AdmissionDecision::query()->where('admission_adoption_id', $selected->id ?? 0)->latest('id')->paginate(15)->withQueryString();
$history->through(function ($decision) use ($workflow, $selected, $current, $latestIds, $requisition, $open, $request) {
$basis = $workflow->basis($requisition, $decision->quotation_id);
$fresh = $current && $latestIds->contains($decision->id) && $basis && $basis['hash'] === $decision->basis_hash;
$history = AdmissionDecision::query()->where('admission_adoption_id', $selected->id ?? 0)->when($focus !== null, fn ($query) => $query->whereKey($focus))->latest('id')->paginate(15)->withQueryString();
$history->through(function ($decision) use ($workflow, $selected, $current, $requisition, $request) {
$fresh = $current && $workflow->currentReviewBasis($requisition, $selected, $decision);
return [...$decision->toArray(), 'status' => $workflow->status($decision, $selected), 'current' => (bool) $fresh,
'reviews' => AdmissionReview::query()->where('admission_decision_id', $decision->id)->orderBy('id')->get(),
'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()];
'canReview' => $fresh && $workflow->canReview($this->actor($request), $requisition, $decision)];
});
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()->hasFunctions([PurchasingRole::Procurement]), 'canPropose' => $open && $current && $request->user()->hasFunctions([PurchasingRole::Procurement]) && $request->user()->id !== $requisition->requester_id, 'closed' => $closed])->toResponse($request);
$response = Inertia::render('purchases/admission', ['focusDecisionId' => $focus, '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 && $this->actor($request)->hasFunctions([PurchasingRole::Procurement]), 'canPropose' => $open && $current && $this->actor($request)->hasFunctions([PurchasingRole::Procurement]) && $this->actor($request)->id !== $requisition->requester_id, 'closed' => $closed])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
@@ -81,7 +90,7 @@ class FormalAdmissionController extends Controller
{
$this->access($request, $requisition->id);
$data = $request->validate(['policy_id' => 'required|integer', 'previous_id' => 'required|integer|min:0', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'snapshot' => 'prohibited', 'created_by' => 'prohibited']);
$workflow->adopt($request->user(), $requisition, (int) $data['policy_id'], (int) $data['previous_id'], $data['reason']);
$workflow->adopt($this->actor($request), $requisition, (int) $data['policy_id'], (int) $data['previous_id'], $data['reason']);
return to_route('admissions.show', $requisition);
}
@@ -90,7 +99,7 @@ class FormalAdmissionController extends Controller
{
$this->access($request, $requisition->id);
$data = $request->validate(['quotation_id' => 'required|integer', 'previous_id' => 'required|integer|min:0', 'basis_hash' => 'required|string|size:64', 'outcome' => 'required|in:admit,exclude,clarify', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'snapshot' => 'prohibited', 'status' => 'prohibited']);
$workflow->propose($request->user(), $requisition, $data);
$workflow->propose($this->actor($request), $requisition, $data);
return to_route('admissions.show', $requisition);
}
@@ -100,7 +109,7 @@ class FormalAdmissionController extends Controller
$adoption = AdmissionAdoption::query()->findOrFail($decision->admission_adoption_id);
$this->access($request, $adoption->requisition_id);
$data = $request->validate(['verdict' => 'required|in:approved,returned', 'reason' => 'required|string|min:10|max:4000', 'reviewed_by' => 'prohibited', 'snapshot' => 'prohibited', 'tenant_id' => 'prohibited']);
$workflow->review($request->user(), $decision, $data['verdict'], $data['reason']);
$workflow->review($this->actor($request), $decision, $data['verdict'], $data['reason']);
return to_route('admissions.show', $adoption->requisition_id);
}
@@ -10,27 +10,35 @@ use App\Models\RecommendationPolicy;
use App\Models\RecommendationReview;
use App\Models\Requisition;
use App\Models\User;
use App\Services\ConflictGuard;
use App\Services\FormalAdmission;
use App\Services\FormalRecommendation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class FormalRecommendationController extends Controller
{
private function actor(Request $request): User
{
$actor = $request->user('web');
abort_unless($actor instanceof User && $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
return $actor;
}
private function access(Request $request, int $id): Requisition
{
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
abort_unless($this->actor($request)->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
return Requisition::query()->visibleTo($request->user())->findOrFail($id);
return Requisition::query()->visibleTo($this->actor($request))->findOrFail($id);
}
public function policy(Request $request): Response
{
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin]), 403);
abort_unless($this->actor($request)->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');
@@ -42,7 +50,7 @@ class FormalRecommendationController extends Controller
{
$data = $request->validate(['previous_id' => 'required|integer|min:0', 'enabled' => 'required|boolean', 'roles' => 'required|array|min:1|max:2', 'roles.*' => 'required|distinct|in:finance,department_head', 'required_count' => 'required|integer|min:1|max:50', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'snapshot' => 'prohibited']);
$data['enabled'] = (bool) $data['enabled'];
$workflow->policy($request->user(), $data);
$workflow->policy($this->actor($request), $data);
return to_route('recommendations.policy');
}
@@ -53,22 +61,24 @@ class FormalRecommendationController extends Controller
$filter = $request->validate(['version' => 'nullable|integer|min:1']);
$adoption = $workflow->adoption($requisition);
$selected = isset($filter['version']) ? RecommendationAdoption::query()->where('requisition_id', $requisition->id)->findOrFail((int) $filter['version']) : $adoption;
$focusInput = $request->validate(['attempt_id' => ['nullable', 'integer', 'min:1', Rule::exists('recommendation_attempts', 'id')->where('tenant_id', $requisition->tenant_id)->where('recommendation_adoption_id', $selected->id ?? 0)]]);
$focus = isset($focusInput['attempt_id']) ? (int) $focusInput['attempt_id'] : null;
$current = $selected && $selected->id === $adoption?->id;
$basis = $workflow->basis($requisition);
$latest = RecommendationAttempt::query()->where('recommendation_adoption_id', $selected->id ?? 0)->latest('revision')->first();
$closed = $requisition->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $requisition->id)->exists();
$open = ! $closed && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition);
$history = RecommendationAttempt::query()->where('recommendation_adoption_id', $selected->id ?? 0)->latest('revision')->paginate(10)->withQueryString();
$history->through(function ($attempt) use ($workflow, $selected, $current, $latest, $basis, $requisition, $open, $request) {
$fresh = $current && $latest?->id === $attempt->id && $basis['ready'] && $basis['hash'] === $attempt->basis_hash;
$open = $workflow->canParticipate($this->actor($request), $requisition);
$history = RecommendationAttempt::query()->where('recommendation_adoption_id', $selected->id ?? 0)->when($focus !== null, fn ($query) => $query->whereKey($focus))->latest('revision')->paginate(10)->withQueryString();
$history->through(function ($attempt) use ($workflow, $selected, $current, $requisition, $request) {
$fresh = $current && $workflow->currentReviewBasis($requisition, $selected, $attempt);
return [...$attempt->toArray(), 'status' => $workflow->status($attempt, $selected), 'current' => (bool) $fresh, 'reviews' => RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->orderBy('id')->get(),
'canReview' => $open && $fresh && $workflow->status($attempt, $selected) === 'pending' && $workflow->eligible($request->user(), $requisition, $selected, $attempt) && ! RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->where('reviewed_by', $request->user()->id)->exists()];
'canReview' => $fresh && $workflow->canReview($this->actor($request), $requisition, $attempt)];
});
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,
$response = Inertia::render('purchases/recommendation', ['focusAttemptId' => $focus, '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()->hasFunctions([PurchasingRole::Procurement]), 'canPropose' => $open && $current && $basis['ready'] && $request->user()->hasFunctions([PurchasingRole::Procurement]) && $request->user()->id !== $requisition->requester_id])->toResponse($request);
'history' => $history, 'closed' => $closed, 'canAdopt' => $open && $this->actor($request)->hasFunctions([PurchasingRole::Procurement]), 'canPropose' => $open && $current && $basis['ready'] && $this->actor($request)->hasFunctions([PurchasingRole::Procurement]) && $this->actor($request)->id !== $requisition->requester_id])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
@@ -78,7 +88,7 @@ class FormalRecommendationController extends Controller
{
$this->access($request, $requisition->id);
$data = $request->validate(['policy_id' => 'required|integer', 'previous_id' => 'required|integer|min:0', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'snapshot' => 'prohibited', 'created_by' => 'prohibited']);
$workflow->adopt($request->user(), $requisition, (int) $data['policy_id'], (int) $data['previous_id'], $data['reason']);
$workflow->adopt($this->actor($request), $requisition, (int) $data['policy_id'], (int) $data['previous_id'], $data['reason']);
return to_route('recommendations.show', $requisition);
}
@@ -87,7 +97,7 @@ class FormalRecommendationController extends Controller
{
$this->access($request, $requisition->id);
$data = $request->validate(['quotation_id' => 'required|integer', 'previous_id' => 'required|integer|min:0', 'basis_hash' => 'required|string|size:64', 'reason' => 'required|string|min:10|max:4000', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'snapshot' => 'prohibited', 'status' => 'prohibited']);
$workflow->propose($request->user(), $requisition, $data);
$workflow->propose($this->actor($request), $requisition, $data);
return to_route('recommendations.show', $requisition);
}
@@ -97,7 +107,7 @@ class FormalRecommendationController extends Controller
$adoption = RecommendationAdoption::query()->findOrFail($attempt->recommendation_adoption_id);
$this->access($request, $adoption->requisition_id);
$data = $request->validate(['verdict' => 'required|in:approved,returned', 'reason' => 'required|string|min:10|max:4000', 'reviewed_by' => 'prohibited', 'snapshot' => 'prohibited', 'tenant_id' => 'prohibited']);
$workflow->review($request->user(), $attempt, $data['verdict'], $data['reason']);
$workflow->review($this->actor($request), $attempt, $data['verdict'], $data['reason']);
return to_route('recommendations.show', $adoption->requisition_id);
}
@@ -108,7 +118,7 @@ class FormalRecommendationController extends Controller
if (DB::connection()->getDriverName() === 'pgsql') {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
}
$actor = User::query()->where('tenant_id', $request->user()->tenant_id)->findOrFail($request->user()->id);
$actor = User::query()->where('tenant_id', $this->actor($request)->tenant_id)->findOrFail($this->actor($request)->id);
$request->setUserResolver(fn () => $actor);
$adoption = RecommendationAdoption::query()->findOrFail($attempt->recommendation_adoption_id);
$requisition = $this->access($request, $adoption->requisition_id);
@@ -118,7 +128,7 @@ class FormalRecommendationController extends Controller
$history = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->where('id', '<', $attempt->id)->orderBy('revision')->get()->map(fn ($previous) => ['attempt' => $previous, 'state' => $workflow->status($previous, $adoption), 'reviews' => RecommendationReview::query()->where('recommendation_attempt_id', $previous->id)->orderBy('id')->get()]);
return response()->view('purchases.recommendation-print', ['requisition' => $requisition, 'attempt' => $attempt, 'adoption' => $adoption, 'history' => $history, 'reviews' => RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->orderBy('id')->get(), 'state' => $workflow->status($attempt, $adoption), 'current' => $currentAdoption?->id === $adoption->id && $latest->id === $attempt->id && $basis['ready'] && $basis['hash'] === $attempt->basis_hash, 'generatedAt' => now()->timezone('Africa/Johannesburg'), 'generatedBy' => $request->user()->name], 200, ['Cache-Control' => 'private, no-store, max-age=0']);
return response()->view('purchases.recommendation-print', ['requisition' => $requisition, 'attempt' => $attempt, 'adoption' => $adoption, 'history' => $history, 'reviews' => RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->orderBy('id')->get(), 'state' => $workflow->status($attempt, $adoption), 'current' => $currentAdoption?->id === $adoption->id && $latest->id === $attempt->id && $basis['ready'] && $basis['hash'] === $attempt->basis_hash, 'generatedAt' => now()->timezone('Africa/Johannesburg'), 'generatedBy' => $this->actor($request)->name], 200, ['Cache-Control' => 'private, no-store, max-age=0']);
});
}
}
+27 -7
View File
@@ -5,37 +5,57 @@ namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\Invoice;
use App\Models\PurchaseOrder;
use App\Models\User;
use App\Services\InventoryDispositions;
use App\Services\InventoryReversals;
use App\Services\InvoiceWorkflow;
use App\Services\OrganizationAccess;
use App\Services\SupplierPayables;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response;
class InvoiceController extends Controller
{
public function index(Request $request): Response
{
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
abort_unless($actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
$filters = $request->validate(['invoice_id' => ['nullable', 'integer', 'min:1', Rule::exists('invoices', 'id')->where('tenant_id', $actor->tenant_id)],
'purchase_order_id' => ['nullable', 'integer', 'min:1', Rule::exists('purchase_orders', 'id')->where('tenant_id', $actor->tenant_id)]]);
$focus = isset($filters['invoice_id']) ? (int) $filters['invoice_id'] : null;
$focusOrder = isset($filters['purchase_order_id']) ? (int) $filters['purchase_order_id'] : null;
Inertia::encryptHistory();
return Inertia::render('invoices/index', ['stockReversals' => app(InventoryReversals::class)->financialNotices(), 'stockReturns' => app(InventoryDispositions::class)->financialNotices(), '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])]);
$response = Inertia::render('invoices/index', ['focusInvoiceId' => $focus, 'focusOrderId' => $focusOrder, 'stockReversals' => app(InventoryReversals::class)->financialNotices(), 'stockReturns' => app(InventoryDispositions::class)->financialNotices(),
'invoices' => Invoice::query()->whereIn('purchase_order_id', PurchaseOrder::query()->visibleTo($actor)->select('id'))->when($focus !== null, fn ($q) => $q->whereKey($focus))->when($focusOrder !== null, fn ($q) => $q->where('purchase_order_id', $focusOrder))->with('items')->latest('id')->paginate(15)->withQueryString()
->through(fn (Invoice $invoice) => [...$invoice->toArray(), 'canValidate' => app(InvoiceWorkflow::class)->reviewerEligible($actor, $invoice)]),
'orders' => PurchaseOrder::query()->visibleTo($actor)->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($actor, 'view'), 'canManage' => $actor->hasFunctions([PurchasingRole::Finance]) && ! app(OrganizationAccess::class)->maintenanceOnly($actor)])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function store(Request $request, PurchaseOrder $order, InvoiceWorkflow $workflow): RedirectResponse
{
abort_unless($request->user()->hasFunctions([PurchasingRole::Finance]), 403);
$actor = $request->user('web');
abort_unless($actor instanceof User && $actor->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);
$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' => 'sometimes|required|string|max:30', 'items.*.unit_price' => ['sometimes', ...$decimal], 'items.*.tax_rate' => ['sometimes', 'required', 'regex:/^\d{1,3}(\.\d{1,2})?$/']]);
$workflow->register($actor, $order, $data);
return to_route('invoices.index');
}
public function validateInvoice(Request $request, Invoice $invoice, InvoiceWorkflow $workflow): RedirectResponse
{
$workflow->validate($request->user(), $invoice);
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$workflow->validate($actor, $invoice);
return back();
}
@@ -3,10 +3,12 @@
namespace App\Http\Controllers;
use App\Models\Tenant;
use App\Models\User;
use App\Services\AiPreparationPlan;
use App\Services\ComparisonPolicy;
use App\Services\IntelligencePolicy;
use App\Services\OrganizationAccess;
use App\Services\OrganizationActivationPreview;
use App\Services\OrganizationConfiguration;
use App\Services\WorkContext;
use App\Services\WorkflowTasks;
@@ -22,35 +24,45 @@ 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,
'workflowTasks' => app(WorkflowTasks::class)->catalogue(),
'companyRules' => $this->companyRules((int) $actor->tenant_id),
'comparisonPolicy' => app(ComparisonPolicy::class)->company(Tenant::query()->findOrFail($actor->tenant_id)),
'intelligencePolicy' => app(IntelligencePolicy::class)->company(Tenant::query()->findOrFail($actor->tenant_id)),
'intelligenceReady' => Schema::hasColumn('tenants', 'intelligence_policy'),
'aiPlan' => app(AiPreparationPlan::class)->company(Tenant::query()->findOrFail($actor->tenant_id)),
'aiDomains' => AiPreparationPlan::DOMAINS, 'aiDataScopes' => AiPreparationPlan::SCOPES,
'permissions' => OrganizationAccess::PERMISSIONS,
'workContextConfiguration' => Schema::hasColumn('tenants', 'work_context_policy') ? app(WorkContext::class)->shared($request) : null,
'enabledWorkContexts' => app(WorkContext::class)->policy($actor)['enabled'] ?? [],
'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), 'work_contexts' => json_decode($p->work_contexts ?? '[]', 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 DB::transaction(function () use ($request, $service) {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
$user = $request->user('web');
abort_unless($user instanceof User, 403);
$actor = $service->administrator($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,
'workflowTasks' => app(WorkflowTasks::class)->catalogue(),
'activationPreview' => app(OrganizationActivationPreview::class)->build($actor),
'companyRules' => $this->companyRules((int) $actor->tenant_id),
'comparisonPolicy' => app(ComparisonPolicy::class)->company(Tenant::query()->findOrFail($actor->tenant_id)),
'intelligencePolicy' => app(IntelligencePolicy::class)->company(Tenant::query()->findOrFail($actor->tenant_id)),
'intelligenceReady' => Schema::hasColumn('tenants', 'intelligence_policy'),
'aiPlan' => app(AiPreparationPlan::class)->company(Tenant::query()->findOrFail($actor->tenant_id)),
'aiDomains' => AiPreparationPlan::DOMAINS, 'aiDataScopes' => AiPreparationPlan::SCOPES,
'permissions' => OrganizationAccess::PERMISSIONS,
'workContextConfiguration' => Schema::hasColumn('tenants', 'work_context_policy') ? app(WorkContext::class)->shared($request) : null,
'enabledWorkContexts' => app(WorkContext::class)->policy($actor)['enabled'] ?? [],
'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), 'work_contexts' => json_decode($p->work_contexts ?? '[]', 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;
return $response;
});
}
public function command(Request $request, string $command, OrganizationConfiguration $service): Response
{
$actor = $service->administrator($request->user());
$user = $request->user('web');
abort_unless($user instanceof User, 403);
$actor = $service->administrator($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') {
+26 -20
View File
@@ -9,7 +9,9 @@ use App\Models\PurchaseAward;
use App\Models\PurchaseOrder;
use App\Models\RecommendationAttempt;
use App\Models\Requisition;
use App\Models\User;
use App\Services\FormalRecommendation;
use App\Services\OrganizationAccess;
use App\Services\ProposalComparison;
use App\Services\PurchaseWorkflow;
use Illuminate\Contracts\View\View;
@@ -22,15 +24,19 @@ use Inertia\Response;
class PurchaseController extends Controller
{
private function access(Request $request): void
private function access(Request $request): User
{
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
abort_unless($actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
return $actor;
}
public function award(Request $request, Requisition $requisition, ProposalComparison $comparison, PurchaseWorkflow $workflow): Response
{
$this->access($request);
Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id);
$actor = $this->access($request);
Requisition::query()->visibleTo($actor)->findOrFail($requisition->id);
$award = PurchaseAward::query()->where('requisition_id', $requisition->id)->with('approvals')->first();
$frozen = $award && AwardCancellation::query()->where('purchase_award_id', $award->id)->where('status', 'pending')->exists();
$recommendation = app(FormalRecommendation::class);
@@ -50,17 +56,17 @@ class PurchaseController extends Controller
return Inertia::render('purchases/award', [
'requisition' => $requisition, 'comparison' => $comparison->inspect($requisition), 'award' => $award,
'canSelect' => $request->user()->hasFunctions([PurchasingRole::Procurement]) && $award === null && $request->user()->id !== $requisition->requester_id && (! $adoption || $candidate !== null),
'canSelect' => ! app(OrganizationAccess::class)->maintenanceOnly($actor) && $actor->hasFunctions([PurchasingRole::Procurement]) && $award === null && $actor->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()->hasFunctions([PurchasingRole::Procurement]),
'canDecide' => ! $frozen && $award !== null && $workflow->canDecide($actor, $award, $requisition),
'canIssue' => $award !== null && $workflow->canIssue($actor, $award, $requisition),
'order' => PurchaseOrder::query()->where('requisition_id', $requisition->id)->first(['id', 'number']),
]);
}
public function select(Request $request, Requisition $requisition, PurchaseWorkflow $workflow): RedirectResponse
{
$this->access($request);
$actor = $this->access($request);
$data = $request->validate([
'quotation_id' => 'required|integer', 'justification' => 'required|string|min:10|max:5000',
'delivery_date' => 'required|date_format:Y-m-d|after_or_equal:today', 'delivery_location' => 'required|string|min:3|max:500',
@@ -68,46 +74,46 @@ class PurchaseController extends Controller
'approval_snapshot' => 'prohibited', 'decision_matrix' => 'prohibited', 'created_by' => 'prohibited', 'tenant_id' => 'prohibited',
'override' => 'prohibited', 'exception' => 'prohibited',
]);
$workflow->select($request->user(), $requisition, $data);
$workflow->select($actor, $requisition, $data);
return to_route('purchases.award', $requisition);
}
public function decide(Request $request, PurchaseAward $award, PurchaseWorkflow $workflow): RedirectResponse
{
$this->access($request);
$actor = $this->access($request);
$data = $request->validate(['decision' => ['required', Rule::in(['approved', 'rejected'])], 'comment' => 'nullable|required_if:decision,rejected|string|max:2000']);
$workflow->decide($request->user(), $award, $data['decision'], $data['comment'] ?? null);
$workflow->decide($actor, $award, $data['decision'], $data['comment'] ?? null);
return to_route('purchases.award', $award->requisition_id);
}
public function issue(Request $request, PurchaseAward $award, PurchaseWorkflow $workflow): RedirectResponse
{
$this->access($request);
$actor = $this->access($request);
$request->validate([
'status' => 'prohibited', 'snapshot' => 'prohibited', 'total_minor' => 'prohibited', 'subtotal_minor' => 'prohibited', 'tax_minor' => 'prohibited',
'issued_by' => 'prohibited', 'tenant_id' => 'prohibited', 'quotation_id' => 'prohibited', 'override' => 'prohibited', 'exception' => 'prohibited',
]);
$order = $workflow->issue($request->user(), $award);
$order = $workflow->issue($actor, $award);
return to_route('orders.show', $order);
}
public function index(Request $request): Response
{
$this->access($request);
$actor = $this->access($request);
return Inertia::render('purchases/index', [
'orders' => PurchaseOrder::query()->visibleTo($request->user())->latest('id')->paginate(15),
'awards' => PurchaseAward::query()->whereIn('requisition_id', Requisition::query()->visibleTo($request->user())->select('id'))->latest()->paginate(15, ['*'], 'awards_page'),
'orders' => PurchaseOrder::query()->visibleTo($actor)->latest('id')->paginate(15),
'awards' => PurchaseAward::query()->whereIn('requisition_id', Requisition::query()->visibleTo($actor)->select('id'))->latest()->paginate(15, ['*'], 'awards_page'),
]);
}
public function show(Request $request, PurchaseOrder $order): Response
{
$this->access($request);
PurchaseOrder::query()->visibleTo($request->user())->findOrFail($order->id);
$actor = $this->access($request);
PurchaseOrder::query()->visibleTo($actor)->findOrFail($order->id);
return Inertia::render('purchases/show', [
'order' => $order->load('items'),
@@ -117,8 +123,8 @@ class PurchaseController extends Controller
public function print(Request $request, PurchaseOrder $order): View
{
$this->access($request);
PurchaseOrder::query()->visibleTo($request->user())->findOrFail($order->id);
$actor = $this->access($request);
PurchaseOrder::query()->visibleTo($actor)->findOrFail($order->id);
return view('purchases.print', ['order' => $order->load('items'), 'snapshot' => $order->snapshot]);
}
@@ -27,7 +27,8 @@ class PurchaseProcessController extends Controller
{
public function show(Request $request, Requisition $requisition): Response
{
$actor = $request->user();
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
Requisition::query()->visibleTo($actor)->findOrFail($requisition->id);
$requisition->load(['requester:id,name', 'department:id,name', 'steps']);
$purchases = $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
@@ -90,10 +91,13 @@ class PurchaseProcessController extends Controller
}
$invoices = $finance && $order ? Invoice::query()->where('purchase_order_id', $order->id)->get(['id', 'number', 'status']) : collect();
$payments = $finance ? ExternalPayment::query()->whereIn('invoice_id', $invoices->pluck('id'))->get(['id', 'status']) : collect();
$stages[] = ['label' => 'Facturas', 'state' => $finance ? ($invoices->isNotEmpty() ? 'recorded' : 'not_started') : 'restricted', 'owner' => 'Finanças e conferente independente', 'detail' => $finance ? $invoices->count().' registadas; '.$invoices->where('status', 'validated')->count().' validadas. O registo não equivale a validação.' : 'Disponível a Finanças e Administração.', 'href' => $finance ? '/facturas' : null];
$stages[] = ['label' => 'Facturas', 'state' => $finance ? ($invoices->isNotEmpty() ? 'recorded' : 'not_started') : 'restricted', 'owner' => 'Finanças e conferente independente', 'detail' => $finance ? $invoices->count().' registadas; '.$invoices->where('status', 'validated')->count().' validadas. O registo não equivale a validação.' : 'Disponível a Finanças e Administração.', 'href' => $finance && $order ? '/facturas?purchase_order_id='.$order->id : null];
$stages[] = ['label' => 'Pagamentos externos', 'state' => $finance ? ($payments->isNotEmpty() ? 'recorded' : 'not_started') : 'restricted', 'owner' => 'Finanças e conferente independente', 'detail' => $finance ? $payments->count().' registos; '.$payments->where('status', 'confirmed')->count().' confirmados. Não comprova liquidação integral e não executa transferências.' : 'Disponível a Finanças e Administração.', 'href' => $finance ? '/pagamentos' : null];
foreach ($invoices as $invoice) {
$documents[] = ['label' => $invoice->number, 'kind' => 'Factura', 'href' => '/facturas'];
$documents[] = ['label' => $invoice->number, 'kind' => 'Factura', 'href' => '/facturas?invoice_id='.$invoice->id.'#invoice-'.$invoice->id];
}
foreach ($payments as $payment) {
$documents[] = ['label' => 'Registo externo #'.$payment->id, 'kind' => 'Pagamento externo', 'href' => '/pagamentos?payment_id='.$payment->id.'#payment-'.$payment->id];
}
$cancellation = $award ? AwardCancellation::query()->where('purchase_award_id', $award->id)->first(['id', 'status']) : null;
$actors = User::query()->where('tenant_id', $actor->tenant_id)->whereIn('id', array_column($decisions, 'actor_id'))->pluck('name', 'id');
+52 -45
View File
@@ -4,13 +4,15 @@ namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\ProposalEvaluation;
use App\Models\PurchaseAward;
use App\Models\Quotation;
use App\Models\Requisition;
use App\Models\Supplier;
use App\Models\User;
use App\Services\ComparisonPolicy;
use App\Services\ConflictGuard;
use App\Services\CurrentActor;
use App\Services\ProposalComparison;
use App\Services\QuotationParticipation;
use App\Services\QuotationPricing;
use App\Services\RequisitionWorkflow;
use Brick\Math\BigDecimal;
@@ -18,47 +20,65 @@ use Brick\Math\RoundingMode;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response as HttpResponse;
class QuotationController extends Controller
{
private function access(Request $request): void
private function access(Request $request): User
{
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
$actor = $request->user('web');
abort_unless($actor instanceof User && $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
return $actor;
}
public function index(Request $request): Response
{
$this->access($request);
$actor = $this->access($request);
return Inertia::render('quotations/index', ['requisitions' => Requisition::query()->visibleTo($request->user())->where('status', 'approved')->with('department:id,name')->latest()->paginate(15)]);
return Inertia::render('quotations/index', ['requisitions' => Requisition::query()->visibleTo($actor)->where('status', 'approved')->with('department:id,name')->latest()->paginate(15)]);
}
public function show(Request $request, Requisition $requisition): Response
public function show(Request $request, Requisition $requisition): HttpResponse
{
$this->access($request);
Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id);
$actor = $this->access($request);
Requisition::query()->visibleTo($actor)->findOrFail($requisition->id);
$filters = $request->validate(['quotation_id' => ['nullable', 'integer', Rule::exists('quotations', 'id')->where('tenant_id', $actor->tenant_id)->where('requisition_id', $requisition->id)], 'prepare' => 'nullable|boolean']);
$access = app(QuotationParticipation::class);
$quotes = Quotation::query()->where('requisition_id', $requisition->id)->with('supplier')->get()->keyBy('id');
$guard = app(ConflictGuard::class);
$result = app(ProposalComparison::class)->inspect($requisition);
$scores = array_map(fn ($row) => [...$row, 'conflict_blocked' => $guard->processBlocked($requisition, $row['supplier_id'], $request->user())], $result['rows']);
$closed = PurchaseAward::query()->where('requisition_id', $requisition->id)->exists();
$scores = array_map(function ($row) use ($quotes, $actor, $access, $requisition, $guard) {
$quote = $quotes->get($row['id']);
return Inertia::render('quotations/show', [
return [...$row, 'conflict_blocked' => $guard->processBlocked($requisition, $row['supplier_id'], $actor), 'canEvaluate' => $row['eligible'] && $quote !== null && $access->canEvaluate($actor, $requisition, $quote)];
}, $result['rows']);
Inertia::encryptHistory();
$response = 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()->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),
'canCreate' => $access->canPrepare($actor, $requisition),
'canEvaluate' => $access->canEvaluateProcess($actor, $requisition),
'focusQuotationId' => isset($filters['quotation_id']) ? (int) $filters['quotation_id'] : null,
'prepare' => (bool) ($filters['prepare'] ?? false),
'ready' => $result['ready'],
'topIds' => $result['top_ids'],
'comparisonPolicy' => $result['policy'],
]);
])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function store(Request $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
{
abort_unless($request->user()->hasFunctions([PurchasingRole::Procurement]), 403);
$actor = $this->access($request);
abort_unless($actor->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',
@@ -68,17 +88,14 @@ class QuotationController extends Controller
'items.*.discount_type' => 'sometimes|required|in:none,percent,amount',
'items.*.discount_value' => 'required_with:items.*.discount_type|numeric|decimal:0,2|min:0|max:1000000000',
]);
DB::transaction(function () use ($request, $requisition, $data, $workflow) {
DB::transaction(function () use ($actor, $requisition, $data, $workflow) {
$guard = app(ConflictGuard::class);
$guard->lockCompany($request->user());
$guard->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement]);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
abort_unless($requisition->status === 'approved', 403);
if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
throw ValidationException::withMessages(['award' => 'As propostas estão fechadas pela adjudicação deste processo.']);
}
$guard->assertParticipation($request->user(), $requisition);
app(QuotationParticipation::class)->assertPrepare($actor, $requisition);
$supplier = Supplier::query()->whereKey($data['supplier_id'])->lockForUpdate()->firstOrFail();
$guard->assertProcess($requisition, $supplier->id, $request->user());
$guard->assertProcess($requisition, $supplier->id, $actor);
if (! $supplier->eligible()) {
throw ValidationException::withMessages(['supplier_id' => 'O fornecedor precisa de estar activo, sem impedimento e com revisão válida.']);
}
@@ -108,20 +125,20 @@ class QuotationController extends Controller
if ($subtotal + $tax > 100000000000000) {
throw ValidationException::withMessages(['items' => 'Valor total acima do limite suportado.']);
}
$items[] = ['tenant_id' => $request->user()->tenant_id, 'requisition_item_id' => $line->id, 'description' => $line->description, 'quantity' => $line->quantity, 'unit_price_minor' => $price, 'tax_rate' => $line->tax_rate, 'subtotal_minor' => $amount, 'tax_minor' => $vat, 'total_minor' => $amount + $vat];
$items[] = ['tenant_id' => $actor->tenant_id, 'requisition_item_id' => $line->id, 'description' => $line->description, 'quantity' => $line->quantity, 'unit_price_minor' => $price, 'tax_rate' => $line->tax_rate, 'subtotal_minor' => $amount, 'tax_minor' => $vat, 'total_minor' => $amount + $vat];
}
if ($subtotal <= 0) {
throw ValidationException::withMessages(['items' => 'O total arredondado deve ser pelo menos 0,01 MZN.']);
}
$quote = Quotation::query()->create([
'tenant_id' => $request->user()->tenant_id, 'requisition_id' => $requisition->id, 'supplier_id' => $supplier->id, 'created_by' => $request->user()->id,
'tenant_id' => $actor->tenant_id, 'requisition_id' => $requisition->id, 'supplier_id' => $supplier->id, 'created_by' => $actor->id,
'reference' => $data['reference'], 'delivery_days' => $data['delivery_days'], 'valid_until' => $data['valid_until'], 'payment_terms' => $data['payment_terms'],
'subtotal_minor' => $subtotal, 'tax_minor' => $tax, 'total_minor' => $subtotal + $tax, 'currency' => 'MZN',
'weights' => app(ComparisonPolicy::class)->forRequest($requisition)['weights'],
'commercial_terms' => array_filter($terms, fn ($term) => $term['discount_type'] !== 'none') === [] ? null : ['version' => 1, 'discount_basis' => 'unit_before_vat', 'rounding' => 'net_unit_half_up_cent', 'lines' => $terms],
]);
$quote->items()->createMany($items);
$workflow->audit($request->user(), 'quotation.created', $requisition, null, [...$quote->load('items')->toArray(), 'no_conflict' => true]);
$workflow->audit($actor, 'quotation.created', $requisition, null, [...$quote->load('items')->toArray(), 'no_conflict' => true]);
});
return back();
@@ -129,28 +146,18 @@ class QuotationController extends Controller
public function evaluate(Request $request, Quotation $quotation, RequisitionWorkflow $workflow): RedirectResponse
{
$this->access($request);
abort_unless($request->user()->hasFunctions([PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
$actor = $this->access($request);
abort_unless($actor->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) {
DB::transaction(function () use ($actor, $quotation, $data, $workflow) {
$guard = app(ConflictGuard::class);
$guard->lockCompany($request->user());
$requisition = Requisition::query()->visibleTo($request->user())->lockForUpdate()->findOrFail($quotation->requisition_id);
abort_unless($requisition->status === 'approved' && $requisition->requester_id !== $request->user()->id, 403);
if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
throw ValidationException::withMessages(['award' => 'As avaliações estão fechadas pela adjudicação deste processo.']);
}
$guard->assertParticipation($request->user(), $requisition);
$guard->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
$requisition = Requisition::query()->visibleTo($actor)->lockForUpdate()->findOrFail($quotation->requisition_id);
$quotation = Quotation::query()->lockForUpdate()->findOrFail($quotation->id);
$guard->assertProcess($requisition, $quotation->supplier_id, $request->user());
if ($quotation->valid_until < today() || ! $quotation->supplier?->eligible()) {
throw ValidationException::withMessages(['evaluation' => 'Proposta expirada ou fornecedor não elegível.']);
}
if ($quotation->evaluations()->where('evaluator_id', $request->user()->id)->exists()) {
throw ValidationException::withMessages(['evaluation' => 'Já avaliou esta proposta.']);
}
$evaluation = ProposalEvaluation::query()->create([...$data, 'no_conflict' => true, 'tenant_id' => $request->user()->tenant_id, 'quotation_id' => $quotation->id, 'evaluator_id' => $request->user()->id]);
$workflow->audit($request->user(), 'quotation.evaluated', $requisition, null, $evaluation->toArray());
app(QuotationParticipation::class)->assertEvaluate($actor, $requisition, $quotation);
$evaluation = ProposalEvaluation::query()->create([...$data, 'no_conflict' => true, 'tenant_id' => $actor->tenant_id, 'quotation_id' => $quotation->id, 'evaluator_id' => $actor->id]);
$workflow->audit($actor, 'quotation.evaluated', $requisition, null, $evaluation->toArray());
});
return back();
+40 -30
View File
@@ -17,6 +17,7 @@ use App\Services\ApprovalRouting;
use App\Services\ComparisonPolicy;
use App\Services\ConflictGuard;
use App\Services\EquipmentWorkflow;
use App\Services\InternalSupplyTrace;
use App\Services\OrganizationAccess;
use App\Services\RequisitionCatalogue;
use App\Services\RequisitionWorkflow;
@@ -31,18 +32,26 @@ use Inertia\Response;
class RequisitionController extends Controller
{
private function actor(Request $request): User
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
return $actor;
}
public function index(Request $request): Response
{
$filters = $request->validate(['search' => 'nullable|string|max:100', 'status' => ['nullable', Rule::in(['draft', 'pending', 'approved', 'rejected', 'cancelled'])], 'view' => ['nullable', Rule::in(['all', 'mine', 'approvals', 'decided'])]]);
$query = Requisition::query()->visibleTo($request->user())->with(['requester:id,name', 'department:id,name', 'steps']);
$query = Requisition::query()->visibleTo($this->actor($request))->with(['requester:id,name', 'department:id,name', 'steps']);
if (($filters['view'] ?? 'all') === 'mine') {
$query->where('requester_id', $request->user()->id);
$query->where('requester_id', $this->actor($request)->id);
} elseif (($filters['view'] ?? 'all') === 'approvals') {
$eligible = (clone $query)->where('status', 'pending')->get()
->filter(fn (Requisition $requisition) => $requisition->canDecide($request->user()))->modelKeys();
->filter(fn (Requisition $requisition) => $requisition->canDecide($this->actor($request)))->modelKeys();
$query->whereIn('id', $eligible);
} elseif (($filters['view'] ?? 'all') === 'decided') {
$query->whereHas('steps', fn ($steps) => $steps->where('approver_id', $request->user()->id)->whereIn('decision', ['approved', 'rejected']));
$query->whereHas('steps', fn ($steps) => $steps->where('approver_id', $this->actor($request)->id)->whereIn('decision', ['approved', 'rejected']));
}
if (! empty($filters['search'])) {
$query->where('number', 'like', '%'.$filters['search'].'%');
@@ -59,12 +68,12 @@ class RequisitionController extends Controller
public function create(Request $request): Response
{
abort_unless(app(OrganizationAccess::class)->canRequest($request->user()), 403);
abort_unless(app(OrganizationAccess::class)->canRequest($this->actor($request)), 403);
return Inertia::render('requisitions/form', [
'costCenters' => CostCenter::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(),
'costCenters' => CostCenter::query()->where('department_id', $this->actor($request)->department_id)->orderBy('code')->get(),
'requisition' => null,
'defaultTaxRate' => (string) (Tenant::query()->findOrFail($request->user()->tenant_id)->default_tax_rate ?? '16.00'),
'defaultTaxRate' => (string) (Tenant::query()->findOrFail($this->actor($request)->tenant_id)->default_tax_rate ?? '16.00'),
...$this->equipmentOptions($request),
'catalogue' => $this->catalogueOptions($request),
]);
@@ -72,28 +81,29 @@ class RequisitionController extends Controller
public function store(StoreRequisitionRequest $request, RequisitionWorkflow $workflow): RedirectResponse
{
$requisition = $workflow->save($request->user(), $request->validated());
$requisition = $workflow->save($this->actor($request), $request->validated());
return to_route('requisitions.show', $requisition);
}
public function show(Request $request, Requisition $requisition): Response
{
Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id);
Requisition::query()->visibleTo($this->actor($request))->findOrFail($requisition->id);
$requisition->load(['items', 'steps', 'requester:id,name', 'department:id,name', 'costCenter']);
return Inertia::render('requisitions/show', [
'requisition' => $requisition,
'approvalRouting' => app(ApprovalRouting::class)->current($requisition, $request->user()),
'canRefreshRouting' => $requisition->status === 'pending' && $request->user()->hasFunctions(['admin', 'procurement']) && ! app(OrganizationAccess::class)->maintenanceOnly($request->user()) && Schema::hasTable('workflow_notices'),
'approvalRouting' => app(ApprovalRouting::class)->current($requisition, $this->actor($request)),
'canRefreshRouting' => $requisition->status === 'pending' && $this->actor($request)->hasFunctions(['admin', 'procurement']) && ! app(OrganizationAccess::class)->maintenanceOnly($this->actor($request)) && Schema::hasTable('workflow_notices'),
'replenishmentSource' => app(SupplySuggestions::class)->origin($requisition),
'automaticSource' => app(InternalSupplyTrace::class)->purchaseOrigin($this->actor($request), $requisition),
'comparisonPolicy' => app(ComparisonPolicy::class)->forRequest($requisition),
'canEdit' => app(OrganizationAccess::class)->canRequest($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']),
'canEdit' => app(OrganizationAccess::class)->canRequest($this->actor($request)) && $requisition->requester_id === $this->actor($request)->id && $requisition->status === 'draft',
'canDecide' => $requisition->canDecide($this->actor($request)),
'conflictBlocked' => app(ConflictGuard::class)->participationBlocked($this->actor($request), $requisition),
'audit' => AuditLog::query()->where('requisition_id', $requisition->id)->orderBy('id')->get(['id', 'action', 'user_id', 'created_at', 'after'])->map(fn ($a) => ['id' => $a->id, 'action' => $a->action, 'user_id' => $a->user_id, 'created_at' => $a->created_at, 'execution' => $a->after['execution']['mode'] ?? null]),
'technical' => [
'canReview' => app(EquipmentWorkflow::class)->canReview($request->user(), $requisition),
'canReview' => app(EquipmentWorkflow::class)->canReview($this->actor($request), $requisition),
'basis_hash' => app(EquipmentWorkflow::class)->basis($requisition),
'reviews' => TechnicalReview::query()->where('requisition_id', $requisition->id)->orderBy('id')->get(),
],
@@ -102,13 +112,13 @@ 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);
abort_unless(app(OrganizationAccess::class)->canRequest($this->actor($request)), 403);
abort_unless($requisition->requester_id === $this->actor($request)->id && $requisition->status === 'draft', 403);
return Inertia::render('requisitions/form', [
'requisition' => $requisition->load('items'),
'defaultTaxRate' => (string) (Tenant::query()->findOrFail($request->user()->tenant_id)->default_tax_rate ?? '16.00'),
'costCenters' => CostCenter::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(),
'defaultTaxRate' => (string) (Tenant::query()->findOrFail($this->actor($request)->tenant_id)->default_tax_rate ?? '16.00'),
'costCenters' => CostCenter::query()->where('department_id', $this->actor($request)->department_id)->orderBy('code')->get(),
...$this->equipmentOptions($request),
'catalogue' => $this->catalogueOptions($request),
]);
@@ -117,7 +127,7 @@ class RequisitionController extends Controller
/** @return array<string,mixed> */
private function equipmentOptions(Request $request): array
{
$equipment = Equipment::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(['id', 'code', 'description']);
$equipment = Equipment::query()->where('department_id', $this->actor($request)->department_id)->orderBy('code')->get(['id', 'code', 'description']);
$parts = SparePart::query()->whereIn('id', EquipmentSparePart::query()->whereIn('equipment_id', $equipment->pluck('id'))->select('spare_part_id'))->get(['id', 'stock_item_id']);
return ['equipment' => $equipment, 'spareParts' => $parts, 'stockItems' => StockItem::query()->whereIn('id', $parts->pluck('stock_item_id'))->get(['id', 'code', 'name', 'unit'])];
@@ -128,20 +138,20 @@ class RequisitionController extends Controller
{
$search = $request->validate(['catalogo' => 'nullable|string|max:100'])['catalogo'] ?? '';
return app(RequisitionCatalogue::class)->options($request->user(), $search);
return app(RequisitionCatalogue::class)->options($this->actor($request), $search);
}
public function technical(Request $request, Requisition $requisition): RedirectResponse
{
$data = $request->validate(['basis_hash' => 'required|string|size:64', 'decision' => ['required', Rule::in(['approved', 'rejected'])], 'reason' => 'required|string|max:4000']);
app(EquipmentWorkflow::class)->review($request->user(), $requisition, $data['basis_hash'], $data['decision'], $data['reason']);
app(EquipmentWorkflow::class)->review($this->actor($request), $requisition, $data['basis_hash'], $data['decision'], $data['reason']);
return back();
}
public function update(StoreRequisitionRequest $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
{
$workflow->save($request->user(), $request->validated(), $requisition);
$workflow->save($this->actor($request), $request->validated(), $requisition);
return to_route('requisitions.show', $requisition);
}
@@ -149,7 +159,7 @@ class RequisitionController extends Controller
public function submit(Request $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
{
$command = $request->validate(['operation_key' => 'nullable|uuid', 'process_version' => 'nullable|integer|min:1']);
$workflow->submit($request->user(), $requisition, $command['operation_key'] ?? null, $command['process_version'] ?? null);
$workflow->submit($this->actor($request), $requisition, $command['operation_key'] ?? null, $command['process_version'] ?? null);
return back();
}
@@ -161,7 +171,7 @@ class RequisitionController extends Controller
'comment' => ['nullable', 'required_if:decision,rejected', 'string', 'max:2000'],
'operation_key' => 'nullable|uuid', 'process_version' => 'nullable|integer|min:1',
]);
$workflow->decide($request->user(), $requisition, $data['decision'], $data['comment'] ?? null, $data['operation_key'] ?? null, $data['process_version'] ?? null);
$workflow->decide($this->actor($request), $requisition, $data['decision'], $data['comment'] ?? null, $data['operation_key'] ?? null, $data['process_version'] ?? null);
return back();
}
@@ -174,20 +184,20 @@ class RequisitionController extends Controller
if ($contexts->enforced($actor)) {
return Inertia::render('work-context', ['context' => $contexts->shared($request)]);
}
$visible = Requisition::query()->visibleTo($request->user());
$visible = Requisition::query()->visibleTo($this->actor($request));
$pending = (clone $visible)->where('status', 'pending')->with('steps')->get();
return Inertia::render('dashboard', [
'company' => $request->user()->tenant_id ? Tenant::query()->find($request->user()->tenant_id) : null,
'company' => $this->actor($request)->tenant_id ? Tenant::query()->find($this->actor($request)->tenant_id) : null,
'stats' => [
'total' => (clone $visible)->count(),
'pending' => $pending->count(),
'approved' => (clone $visible)->where('status', 'approved')->count(),
'approvedMinor' => (clone $visible)->where('status', 'approved')->sum('total_minor'),
'awaitingMe' => $pending->filter(fn (Requisition $r) => $r->canDecide($request->user()))->count(),
'awaitingMe' => $pending->filter(fn (Requisition $r) => $r->canDecide($this->actor($request)))->count(),
],
'recent' => (clone $visible)->with(['requester:id,name', 'department:id,name'])->latest()->limit(6)->get(),
'approvals' => $pending->filter(fn (Requisition $r) => $r->canDecide($request->user()))->take(5)->values(),
'approvals' => $pending->filter(fn (Requisition $r) => $r->canDecide($this->actor($request)))->take(5)->values(),
]);
}
}
@@ -49,6 +49,7 @@ class StockOperationController extends Controller
}
return Inertia::render('stock/operations', [
'canViewReturns' => config('stock_returns.enabled') && $actor->hasFunctions(['warehouse_return_read']) && ! app(\App\Services\OrganizationAccess::class)->maintenanceOnly($actor),
'correctedOperations' => collect(app(InventoryReversals::class)->corrections('operation'))->map(fn ($r) => $r->data['reference']),
'items' => $items, 'warehouses' => $warehouses, 'balances' => $balances,
'users' => User::query()->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'name']),
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\WarehouseExchange;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class WarehouseExchangeController extends Controller
{
public function index(Request $request, WarehouseExchange $exchange): JsonResponse
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$input = $request->validate(['cursor' => 'nullable|string|max:4096', 'limit' => 'sometimes|integer|min:1|max:100']);
return response()->json($exchange->page($actor, $input['cursor'] ?? null, (int) ($input['limit'] ?? 50)))
->header('Cache-Control', 'private, no-store, max-age=0');
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\WarehousePilotReadiness;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class WarehousePilotController extends Controller
{
public function export(Request $request, WarehousePilotReadiness $service): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User && $actor->hasFunctions(['admin']), 403);
$report = DB::transaction(function () use ($actor, $service) {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
return $service->inspect($actor);
});
$rows = [['Conferido em', 'Âmbito', 'Conferência', 'Resultado', 'Observação', 'Onde configurar']];
foreach ($report['checks'] as $check) {
$rows[] = [$report['checked_at'], 'SGC autónomo · diagnóstico de configuração', $check['label'], $check['ok'] ? 'Conferido' : 'A preparar', $check['detail'], $check['path'] ?? ''];
}
return response()->streamDownload(static function () use ($rows) {
$stream = fopen('php://output', 'wb');
if ($stream === false) {
throw new \RuntimeException('Não foi possível preparar a exportação.');
}
fwrite($stream, "\xEF\xBB\xBF");
foreach ($rows as $row) {
$safe = array_map(static fn (string $value) => preg_match('/^[\s\x{FEFF}]*[=+@-]/u', $value) ? "'".$value : $value, $row);
fputcsv($stream, $safe, ';', '"', '');
}
fclose($stream);
}, 'preparacao-piloto-sgc-'.now()->format('Ymd-His').'.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store, max-age=0']);
}
public function index(Request $request, WarehousePilotReadiness $service): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User && $actor->hasFunctions(['admin']), 403);
return DB::transaction(function () use ($actor, $request, $service) {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
Inertia::encryptHistory();
$response = Inertia::render('stock/pilot', ['report' => $service->inspect($actor), 'syntheticEvaluation' => app()->environment(['testing', 'payable-testing'])])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
});
}
}
@@ -8,41 +8,66 @@ use App\Models\StockItem;
use App\Models\User;
use App\Models\Warehouse;
use App\Models\WarehouseRequest;
use App\Services\InternalSupplyTrace;
use App\Services\OrganizationAccess;
use App\Services\WarehouseFulfilment;
use App\Services\WarehouseRequests;
use App\Services\WarehouseWorkdesk;
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 WarehouseRequestController extends Controller
{
public function index(Request $request): Response
public function index(Request $request, WarehouseWorkdesk $desk): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User && $actor->hasFunctions(['admin', 'warehouse_request_read', 'warehouse_request_prepare', 'warehouse_request_review', 'warehouse_request_deliver']), 403);
$prepare = $actor->hasFunctions(['warehouse_request_prepare']) && ! app(OrganizationAccess::class)->maintenanceOnly($actor);
$ready = Schema::hasTable('warehouse_requests');
$query = WarehouseRequest::query();
if (! $actor->hasFunctions(['admin', 'warehouse_request_read']) && ! ($actor->hasFunctions(['warehouse_request_deliver']) && $actor->hasFunctions(['warehouse']))) {
$query->where(function ($q) use ($actor) {
$q->where('created_by', $actor->id);
if ($actor->hasFunctions(['warehouse_request_review']) && $actor->department_id !== null) {
$q->orWhere('snapshot->requester->department_id', $actor->department_id);
}
});
}
Inertia::encryptHistory();
$response = Inertia::render('stock/requests', ['ready' => $ready, 'canPrepare' => $prepare, 'fulfilmentReady' => Schema::hasTable('warehouse_request_events'),
'records' => $ready ? $query->latest('id')->paginate(15, ['id', 'status', 'snapshot', 'created_at'])->withQueryString()->through(fn ($r) => ['id' => $r->id, 'status' => $r->status, 'snapshot' => $r->snapshot, 'progress' => app(WarehouseFulfilment::class)->progress($r)]) : null,
'warehouses' => $prepare ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [],
'items' => $prepare ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [],
'costCenters' => $prepare ? CostCenter::query()->where('department_id', $actor->department_id)->orderBy('code')->get(['id', 'code', 'name']) : [],
'equipment' => $prepare ? Equipment::query()->where('department_id', $actor->department_id)->orderBy('code')->get(['id', 'code', 'description']) : []])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
return DB::transaction(function () use ($request, $actor, $desk) {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
$filters = $request->validate(['view' => 'nullable|in:all,review,reserve,delivery,receipt', 'state' => 'nullable|in:all,draft,submitted,authorized,partial,delivered,closed,rejected,cancelled', 'search' => 'nullable|string|max:100', 'warehouse_id' => ['nullable', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $actor->tenant_id)], 'page' => 'nullable|integer|min:1|max:1000000']);
$prepare = $actor->hasFunctions(['warehouse_request_prepare']) && ! app(OrganizationAccess::class)->maintenanceOnly($actor);
$ready = Schema::hasTable('warehouse_requests');
$base = $desk->visible($actor);
if (! empty($filters['warehouse_id'])) {
$base->where('warehouse_id', $filters['warehouse_id']);
}
if (! empty($filters['search'])) {
$search = trim($filters['search']);
if (preg_match('/^(?:RA-)?([1-9][0-9]{0,9})$/iD', $search, $match)) {
$base->whereKey($match[1]);
} else {
$base->where(fn ($q) => $q->whereLike('snapshot->requester->name', '%'.$search.'%')->orWhereLike('snapshot->destination', '%'.$search.'%'));
}
}
$views = $ready ? $desk->views(clone $base, $actor) : [];
$query = $desk->forView(clone $base, $actor, $filters['view'] ?? 'all');
$states = $ready ? DB::query()->fromSub((clone $query)->selectRaw($desk->stateExpression().' as projected_state')->toBase(), 'request_states')->selectRaw('projected_state, COUNT(*) as total')->groupBy('projected_state')->pluck('total', 'projected_state') : [];
if (($filters['state'] ?? 'all') !== 'all') {
$query->whereRaw($desk->stateExpression().' = ?', [$filters['state']]);
}
Inertia::encryptHistory();
$response = Inertia::render('stock/requests', ['ready' => $ready, 'canPrepare' => $prepare, 'fulfilmentReady' => Schema::hasTable('warehouse_request_events'),
'filters' => ['view' => $filters['view'] ?? 'all', 'state' => $filters['state'] ?? 'all', 'search' => $filters['search'] ?? '', 'warehouse_id' => $filters['warehouse_id'] ?? null], 'views' => $views, 'states' => $states, 'checkedAt' => now()->toIso8601String(),
'records' => $ready ? $query->oldest('id')->paginate(15, ['id', 'tenant_id', 'warehouse_id', 'created_by', 'status', 'snapshot', 'created_at'])->withQueryString()->through(function ($r) use ($desk, $actor) {
$p = app(WarehouseFulfilment::class)->progress($r);
return ['id' => $r->id, 'status' => $r->status, 'snapshot' => $r->snapshot, 'progress' => $p, 'health' => $desk->health($r, $p), 'actions' => $desk->actions($actor, $r, $p)];
}) : null,
'warehouses' => Warehouse::query()->when(! $prepare, fn ($q) => $q->whereIn('id', $ready ? $desk->visible($actor)->select('warehouse_id') : []))->orderBy('code')->get(['id', 'code', 'name']),
'items' => $prepare ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [],
'costCenters' => $prepare ? CostCenter::query()->where('department_id', $actor->department_id)->orderBy('code')->get(['id', 'code', 'name']) : [],
'equipment' => $prepare ? Equipment::query()->where('department_id', $actor->department_id)->orderBy('code')->get(['id', 'code', 'description']) : []])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
});
}
public function store(Request $request, WarehouseRequests $service): Response
@@ -58,22 +83,24 @@ class WarehouseRequestController extends Controller
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$r = WarehouseRequest::query()->findOrFail($id);
$owner = $r->created_by === $actor->id && $actor->hasFunctions(['warehouse_request_prepare']);
$review = $actor->hasFunctions(['warehouse_request_review']) && $actor->department_id !== null && $actor->department_id === $r->snapshot['requester']['department_id'];
$deliver = $actor->hasFunctions(['warehouse_request_deliver']) && $actor->hasFunctions(['warehouse']);
abort_unless($owner || $review || $deliver || $actor->hasFunctions(['admin', 'warehouse_request_read']), 403);
$p = $service->progress($r);
$maintenance = app(OrganizationAccess::class)->maintenanceOnly($actor);
Inertia::encryptHistory();
$response = Inertia::render('stock/request', ['record' => $r->only(['id', 'snapshot', 'created_at']), 'progress' => $p, 'ready' => Schema::hasTable('warehouse_request_events'),
'canSubmit' => ! $maintenance && $owner && $p['state'] === 'draft', 'canReview' => ! $maintenance && $review && ! $owner && $r->created_by !== $actor->id && $p['state'] === 'submitted',
'canDeliver' => ! $maintenance && $deliver && $r->created_by !== $actor->id && $p['reviewer_id'] !== $actor->id && in_array($p['state'], ['authorized', 'partial'], true),
'canManageReservation' => ! $maintenance && $review && ! $owner && in_array($p['state'], ['authorized', 'partial'], true),
'canReceive' => ! $maintenance && $owner])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
return DB::transaction(function () use ($request, $id, $service, $actor) {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
$r = WarehouseRequest::query()->findOrFail($id);
$desk = app(WarehouseWorkdesk::class);
abort_unless($desk->canView($actor, $r), 403);
$p = $service->progress($r);
Inertia::encryptHistory();
$response = Inertia::render('stock/request', ['record' => $r->only(['id', 'snapshot', 'created_at']), 'progress' => $p, 'ready' => Schema::hasTable('warehouse_request_events'),
'canViewReturns' => config('stock_returns.enabled') && $actor->hasFunctions(['warehouse_return_read']) && !app(OrganizationAccess::class)->maintenanceOnly($actor),
'health' => $desk->health($r, $p), 'people' => $desk->people($r, $p),
'supplyTrace' => app(InternalSupplyTrace::class)->forRequest($actor, $r, $p), ...$desk->actions($actor, $r, $p)])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
});
}
public function command(Request $request, int $id, WarehouseFulfilment $service): Response
@@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\ReceiptWorkflow;
use App\Services\WarehouseReturns;
use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
final class WarehouseReturnController extends Controller
{
private function actor(Request $r): User
{
$actor = $r->user('web');
abort_unless($actor instanceof User, 403);
return $actor;
}
public function page(Request $r, WarehouseReturns $flow): Response
{
$flow->authorize($this->actor($r), 'read');
$d = $r->validate(['request_id' => 'sometimes|integer|min:1']);
$actor = $this->actor($r);
$source = isset($d['request_id']) ? $flow->availableDeliveries($actor, (int) $d['request_id'], 1) : null;
$response = Inertia::render('stock/returns', ['requestId' => isset($d['request_id']) ? (int) $d['request_id'] : null, 'canPrepare' => $source['can_prepare'] ?? false, 'canWithdraw' => $flow->allows($actor, 'prepare'), 'canConfirm' => $flow->allows($actor, 'confirm'), 'actorId' => $actor->id])->toResponse($r);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function sources(Request $r, WarehouseReturns $flow): JsonResponse
{
$d = $r->validate(['request_id' => 'required|integer|min:1', 'source_page' => 'sometimes|integer|min:1|max:100000']);
abort_if(array_diff(array_keys($r->query()), array_keys($d)) !== [], 422);
return $this->response(fn () => $flow->availableDeliveries($this->actor($r), (int) $d['request_id'], (int) ($d['source_page'] ?? 1)), true);
}
/** @param callable():array<string,mixed> $run */
private function response(callable $run,bool $readOnly=false): JsonResponse
{
try {
$result=$readOnly?DB::transaction(function()use($run){if(DB::getDriverName()==='pgsql'&&DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');return $run();}):$run();
return response()->json($result)->header('Cache-Control', 'no-store');
} catch (QueryException $e) {
if (($e->errorInfo[0] ?? null) === '55P03') {
return response()->json(['code'=>'warehouse_return_busy','message' => 'Devolução ocupada. Aguarde e repita manualmente a conferência.'], 423)->header('Cache-Control', 'no-store');
}
throw $e;
}
}
public function index(Request $r, WarehouseReturns $flow): JsonResponse
{
$d = $r->validate(['page' => 'sometimes|integer|min:1|max:100000', 'state' => 'sometimes|in:all,prepared,confirmed,withdrawn,rejected']);
abort_if(array_diff(array_keys($r->query()), array_keys($d)) !== [], 422);
return $this->response(fn () => $flow->listing($this->actor($r), (int) ($d['page'] ?? 1), $d['state'] ?? 'all'), true);
}
public function prepare(Request $r, WarehouseReturns $flow): JsonResponse
{
$d = $r->validate(['operation_key' => 'required|uuid', 'delivery_event_id' => 'required|integer|min:1', 'line_index' => 'required|integer|min:0|max:49', 'quantity' => 'required|string|regex:/^[0-9]{1,6}(\.[0-9]{1,3})?$/D', 'declared_unused' => 'required|accepted', 'evidence' => 'required|string|min:10|max:1000', 'reason' => 'required|string|min:10|max:1000']);
abort_if(array_diff(array_keys($r->all()), array_keys($d)) !== [], 422);
$d['quantity_milli'] = ReceiptWorkflow::milli($d['quantity']);
$d['line_index'] = (int) $d['line_index'];
$d['delivery_event_id'] = (int) $d['delivery_event_id'];
$d['declared_unused'] = true;
return $this->response(fn () => $flow->prepare($this->actor($r), $d));
}
public function confirm(Request $r, int $id, WarehouseReturns $flow): JsonResponse
{
$d = $r->validate(['version' => 'required|integer|min:1', 'received_in_warehouse' => 'required|accepted', 'reason' => 'required|string|min:10|max:1000']);
abort_if(array_diff(array_keys($r->all()), array_keys($d)) !== [], 422);
$d['version'] = (int) $d['version'];
$d['received_in_warehouse'] = true;
return $this->response(fn () => $flow->confirm($this->actor($r),$id,$d));
}
public function withdraw(Request $r,int $id,WarehouseReturns $flow): JsonResponse {return $this->close($r,$id,$flow,'withdrawn');}
public function reject(Request $r,int $id,WarehouseReturns $flow): JsonResponse {return $this->close($r,$id,$flow,'rejected');}
private function close(Request $r,int $id,WarehouseReturns $flow,string $state): JsonResponse
{
$d=$r->validate(['version'=>'required|integer|min:1','reason'=>'required|string|min:10|max:1000']);abort_if(array_diff(array_keys($r->all()),array_keys($d))!==[],422);$d['version']=(int)$d['version'];return $this->response(fn()=>$flow->close($this->actor($r),$id,$d,$state));
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\WorkflowTaskInbox;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class WorkflowTaskInboxController extends Controller
{
public function procurement(Request $request, WorkflowTaskInbox $inbox): Response
{
return $this->page($request, $inbox, 'procurement');
}
public function people(Request $request, WorkflowTaskInbox $inbox): Response
{
return $this->page($request, $inbox, 'people');
}
private function page(Request $request, WorkflowTaskInbox $inbox, string $area): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$modules = $area === 'people' ? ['people' => 'Pessoas e contratações'] : ['purchases' => 'Compras', 'warehouse' => 'Armazém', 'supply' => 'Fornecimentos e reposição', 'finance' => 'Finanças'];
$filters = $request->validate(['module' => ['nullable', Rule::in(['all', ...array_keys($modules)])],
'search' => 'nullable|string|max:100', 'page' => 'nullable|integer|min:1|max:1000000']);
return DB::transaction(function () use ($request, $actor, $area, $inbox, $modules, $filters) {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
$all = collect($inbox->build($actor, $area));
$counts = [];
foreach ($modules as $key => $label) {
$counts[$key] = $all->where('module', $key)->count();
}
$rows = $all->filter(fn ($row) => (($filters['module'] ?? 'all') === 'all' || $row['module'] === $filters['module'])
&& (($filters['search'] ?? '') === '' || mb_stripos($row['reference'].' '.$row['label'], trim($filters['search'])) !== false))->values();
$page = (int) ($filters['page'] ?? 1);
Inertia::encryptHistory();
$response = Inertia::render('workflow/tasks', ['area' => $area, 'modules' => $modules, 'counts' => $counts,
'checkedAt' => now()->toIso8601String(), 'filters' => ['module' => $filters['module'] ?? 'all', 'search' => $filters['search'] ?? ''],
'tasks' => (new LengthAwarePaginator($rows->slice(($page - 1) * 25, 25)->values(), $rows->count(), 25, $page, ['path' => $request->url()]))->withQueryString()])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
});
}
}