This commit is contained in:
2026-11-02 08:08:05 +02:00
parent 13a6cb9922
commit 504b01b244
246 changed files with 24499 additions and 185 deletions
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers;
use App\Models\CostCenter;
use App\Models\SupplyRecord;
use App\Models\User;
use App\Services\AutomaticPurchases;
use App\Services\OrganizationAccess;
use App\Services\SupplyPlanning;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
class AutomaticPurchaseController extends Controller
{
public function index(Request $request, AutomaticPurchases $service)
{
$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);
Inertia::encryptHistory();
return 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():[],
'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)=>in_array('requester',app(OrganizationAccess::class)->permissions($u,'procurement.internal'),true))->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']);
}
public function save(Request $request, AutomaticPurchases $service)
{
$service->save($request->user('web'),$request->all());
return redirect('/stock/reposicao');
}
public function evaluate(Request $request, int $id, AutomaticPurchases $service)
{
$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);
return redirect('/stock/reposicao');
}
}
@@ -9,12 +9,15 @@ use App\Models\Requisition;
use App\Models\StockItem;
use App\Models\StockMovement;
use App\Models\Warehouse;
use App\Services\InventoryLots;
use App\Services\InventoryReversals;
use App\Services\ReceiptWorkflow;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Inertia\Response;
@@ -55,6 +58,8 @@ class GoodsReceiptController extends Controller
'warehouses' => Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']),
'stockItems' => StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']),
'canConfirm' => $request->user()->can('create', [GoodsReceipt::class, $order]),
'lotScopes' => Schema::hasTable('inventory_lot_allocations')
? app(InventoryLots::class)->activeSetups()->get()->map(fn ($r) => ['stock_item_id' => $r->stock_item_id, 'warehouse_id' => $r->warehouse_id, 'quarantine_on_receipt' => $r->data['quarantine_on_receipt'] ?? true])->values()->all() : [],
]);
}
@@ -73,6 +78,7 @@ class GoodsReceiptController extends Controller
Gate::authorize('view', $receipt);
return Inertia::render('receipts/show', [
'correctedReceiptItems' => collect(app(InventoryReversals::class)->corrections('receipt'))->only($receipt->items()->pluck('id')->all())->map(fn ($r) => $r->data['reference']),
'receipt' => $receipt->load('items'),
'movements' => StockMovement::query()->whereIn('goods_receipt_item_id', $receipt->items()->select('id'))->orderBy('id')->get(),
]);
@@ -0,0 +1,75 @@
<?php
namespace App\Http\Controllers;
use App\Models\GoodsReceipt;
use App\Models\GoodsReceiptItem;
use App\Models\StockItem;
use App\Models\SupplyRecord;
use App\Models\User;
use App\Models\Warehouse;
use App\Services\InventoryDispositions;
use App\Services\InventoryLots;
use App\Services\InventoryReversals;
use App\Services\SupplyPlanning;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Inertia\Response;
class InventoryDispositionController extends Controller
{
public function index(Request $request, InventoryDispositions $workflow): Response|RedirectResponse
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$capabilities = [];
foreach (['read', 'prepare', 'review', 'execute'] as $capability) {
$capabilities[$capability] = $workflow->allows($actor, $capability);
}
$canViewReversals = collect(['read', 'prepare', 'review', 'execute'])->contains(fn ($a) => app(InventoryReversals::class)->allows($actor, $a));
if (! collect($capabilities)->contains(fn (bool $allow) => $allow) && $canViewReversals) {
return redirect('/stock/estornos');
}
abort_unless(collect($capabilities)->contains(fn (bool $allow) => $allow), 403);
$ready = Schema::hasTable('supply_records');
$records = $ready ? app(SupplyPlanning::class)->latest('stock_event')->latest('id')->limit(100)->get() : collect();
$receipts = [];
$lots = [];
if ($ready && $capabilities['prepare']) {
foreach (GoodsReceiptItem::query()->whereNotNull('stock_item_id')->where('accepted_milli', '>', 0)->latest('id')->limit(200)->get() as $line) {
$receipt = GoodsReceipt::query()->whereKey($line->goods_receipt_id)->firstOrFail();
$receipts[] = ['id' => $line->id, 'stock_item_id' => $line->stock_item_id, 'warehouse_id' => $receipt->warehouse_id, 'accepted_milli' => $line->accepted_milli,
'returned_milli' => $workflow->returnedForReceipt($line->id), 'label' => $receipt->number.' · '.$receipt->snapshot['supplier']['name'].' · '.$line->snapshot['stock_item']['name']];
}
foreach (app(InventoryLots::class)->activeSetups()->get() as $setup) {
$context = app(InventoryLots::class)->context($setup->stock_item_id, $setup->warehouse_id);
foreach ($context['lots'] ?? [] as $lot) {
if ($lot['quantity_milli'] > 0) {
$lots[] = ['id' => $lot['id'], 'stock_item_id' => $setup->stock_item_id, 'warehouse_id' => $setup->warehouse_id, 'code' => $lot['data']['code'], 'quantity_milli' => $lot['quantity_milli'],
'receipt_item_id' => $lot['origin']['receipt_item_id'] ?? null, 'reconciled' => $context['reconciled']];
}
}
}
}
return Inertia::render('stock/dispositions', ['ready' => $ready, 'actorId' => $actor->id, 'today' => today()->toDateString(), 'capabilities' => $capabilities, 'records' => $records,
'canViewOperations' => $actor->hasFunctions(['admin', 'warehouse', 'procurement', 'finance', 'inventory_adjust_review']),
'canViewReversals' => $canViewReversals,
'correctedOperations' => collect(app(InventoryReversals::class)->corrections('operation'))->only($records->pluck('data.stock_operation_id')->filter()->all())->map(fn ($r) => $r->data['reference']),
'items' => $capabilities['prepare'] ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [],
'warehouses' => $capabilities['prepare'] ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [], 'receipts' => $receipts, 'lots' => $lots,
'history' => $ready ? SupplyRecord::query()->where('kind', 'stock_event')->whereIn('record_key', $records->pluck('record_key'))->latest('id')->get()->groupBy('record_key') : []]);
}
public function save(Request $request, InventoryDispositions $workflow): RedirectResponse
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
abort_unless(Schema::hasTable('supply_records'), 503, 'Instalação do diário de stock pendente.');
$workflow->save($actor, $request->all());
return back()->with('success', 'Processo de stock registado.');
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers;
use App\Models\StockItem;
use App\Models\SupplyRecord;
use App\Models\User;
use App\Models\Warehouse;
use App\Services\InventoryLots;
use App\Services\SupplyPlanning;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Inertia\Response;
class InventoryLotController extends Controller
{
public function index(Request $request, InventoryLots $lots): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$capabilities = [];
foreach (['read', 'prepare', 'review', 'hold', 'release'] as $capability) {
$capabilities[$capability] = $lots->allows($actor, 'inventory_lot_'.$capability);
}
abort_unless(collect($capabilities)->contains(fn (bool $allowed) => $allowed), 403);
$ready = Schema::hasTable('inventory_lot_allocations');
$records = $ready ? app(SupplyPlanning::class)->latest('lot_setup')->latest('id')->limit(100)->get() : collect();
if ($ready) {
$records = $records->concat($lots->activeSetups()->latest('id')->limit(100)->get())->unique('id')->sortByDesc('id')->values();
}
$contexts = [];
foreach ($records->where('state', 'active') as $record) {
$contexts[$record->record_key] = $lots->context($record->stock_item_id, $record->warehouse_id);
}
return Inertia::render('stock/lots', ['ready' => $ready, 'actorId' => $actor->id, 'capabilities' => $capabilities, 'records' => $records, 'contexts' => $contexts,
'items' => $capabilities['prepare'] ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [],
'warehouses' => $capabilities['prepare'] ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [],
'history' => $ready ? SupplyRecord::query()->whereIn('kind', ['lot_setup', 'lot', 'lot_state'])->latest('id')->limit(200)->get() : []]);
}
public function save(Request $request, InventoryLots $lots): RedirectResponse
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
abort_unless(Schema::hasTable('inventory_lot_allocations'), 503, 'Instalação do controlo por lotes pendente.');
$lots->save($actor, $request->all());
return back()->with('success', 'Conferência de lotes registada.');
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers;
use App\Models\GoodsReceipt;
use App\Models\GoodsReceiptItem;
use App\Models\StockOperation;
use App\Models\SupplyRecord;
use App\Models\User;
use App\Services\InventoryDispositions;
use App\Services\InventoryReversals;
use App\Services\SupplyPlanning;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Inertia\Response;
class InventoryReversalController extends Controller
{
public function index(Request $request, InventoryReversals $workflow): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$capabilities = [];
foreach (['read', 'prepare', 'review', 'execute'] as $capability) {
$capabilities[$capability] = $workflow->allows($actor, $capability);
}
abort_unless(collect($capabilities)->contains(fn (bool $allow) => $allow), 403);
$ready = Schema::hasTable('supply_records');
$records = $ready ? app(SupplyPlanning::class)->latest('stock_reversal')->latest('id')->limit(100)->get() : collect();
$operations = [];
$receipts = [];
if ($ready && $capabilities['prepare']) {
$corrected = $workflow->corrections('operation');
$operations = StockOperation::query()->whereIn('kind', ['issue', 'transfer', 'adjustment', 'supplier_return', 'write_off'])->latest('id')->limit(200)->get()
->reject(fn ($op) => isset($corrected[$op->id]))->map(fn ($op) => ['id' => $op->id, 'label' => $op->reference.' · '.$op->snapshot['stock_item']['name'].' · '.$op->snapshot['warehouse']['name']])->values()->all();
$correctedReceipts = $workflow->corrections('receipt');
foreach (GoodsReceiptItem::query()->whereNotNull('stock_item_id')->where('accepted_milli', '>', 0)->latest('id')->limit(200)->get() as $line) {
if (isset($correctedReceipts[$line->id])) {
continue;
}
$receipt = GoodsReceipt::query()->whereKey($line->goods_receipt_id)->firstOrFail();
$receipts[] = ['id' => $line->id, 'label' => $receipt->number.' · '.$line->snapshot['stock_item']['name'].' · '.$line->snapshot['warehouse']['name']];
}
}
$other = app(InventoryDispositions::class);
return Inertia::render('stock/reversals', ['ready' => $ready, 'actorId' => $actor->id, 'today' => today()->toDateString(), 'capabilities' => $capabilities,
'canViewDispositions' => collect(['read', 'prepare', 'review', 'execute'])->contains(fn ($a) => $other->allows($actor, $a)),
'records' => $records, 'operations' => $operations, 'receipts' => $receipts,
'history' => $ready ? SupplyRecord::query()->where('kind', 'stock_reversal')->whereIn('record_key', $records->pluck('record_key'))->latest('id')->get()->groupBy('record_key') : []]);
}
public function save(Request $request, InventoryReversals $workflow): RedirectResponse
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
abort_unless(Schema::hasTable('supply_records'), 503, 'Instalação do diário pendente.');
$workflow->save($actor, $request->all());
return back()->with('success', 'Estorno registado com a origem preservada.');
}
}
+3 -1
View File
@@ -5,6 +5,8 @@ namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\Invoice;
use App\Models\PurchaseOrder;
use App\Services\InventoryDispositions;
use App\Services\InventoryReversals;
use App\Services\InvoiceWorkflow;
use App\Services\SupplierPayables;
use Illuminate\Http\RedirectResponse;
@@ -18,7 +20,7 @@ class InvoiceController extends Controller
{
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
return Inertia::render('invoices/index', ['invoices' => Invoice::query()->with('items')->latest('id')->paginate(15), 'orders' => PurchaseOrder::query()->with('items')->latest('id')->get()->map(fn ($order) => ['id' => $order->id, 'number' => $order->number, 'supplier' => $order->snapshot['supplier']['name'], 'items' => $order->items]), 'payablesAvailable' => SupplierPayables::enabled() && app(SupplierPayables::class)->can($request->user(), 'view'), 'canManage' => $request->user()->hasFunctions([PurchasingRole::Finance])]);
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])]);
}
public function store(Request $request, PurchaseOrder $order, InvoiceWorkflow $workflow): RedirectResponse
@@ -8,6 +8,8 @@ use App\Services\ComparisonPolicy;
use App\Services\IntelligencePolicy;
use App\Services\OrganizationAccess;
use App\Services\OrganizationConfiguration;
use App\Services\WorkContext;
use App\Services\WorkflowTasks;
use Brick\Math\BigDecimal;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -25,6 +27,7 @@ class OrganizationConfigurationController extends Controller
$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)),
@@ -32,7 +35,9 @@ class OrganizationConfigurationController extends Controller
'aiPlan' => app(AiPreparationPlan::class)->company(Tenant::query()->findOrFail($actor->tenant_id)),
'aiDomains' => AiPreparationPlan::DOMAINS, 'aiDataScopes' => AiPreparationPlan::SCOPES,
'permissions' => OrganizationAccess::PERMISSIONS,
'profiles' => $ready ? DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->orderBy('name')->get()->map(fn ($p) => ['id' => $p->id, 'name' => $p->name, 'permissions' => json_decode($p->permissions, true, 32, JSON_THROW_ON_ERROR)]) : [],
'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']),
@@ -49,8 +54,11 @@ class OrganizationConfigurationController extends Controller
abort_unless(Schema::hasTable('organization_access'), 503, 'A instalação da parametrização ainda não está concluída.');
$rules = ['version' => 'required|integer|min:1', 'tenant_id' => 'prohibited', 'purchasing_role' => 'prohibited'];
if ($command === 'profile') {
$rules += ['work_contexts' => 'sometimes|array', 'work_contexts.*' => ['string', 'distinct', Rule::in(array_keys(WorkContext::OPTIONS))]];
$rules += ['id' => 'nullable|integer|min:1', 'name' => ['required', 'string', 'max:120', Rule::unique('organization_profiles')->where('tenant_id', $actor->tenant_id)->ignore($request->integer('id'))],
'permissions' => 'present|array', 'permissions.*' => ['string', 'distinct', Rule::in(array_keys(OrganizationAccess::PERMISSIONS))]];
} elseif ($command === 'work_contexts') {
$rules += ['enabled' => 'present|array', 'enabled.*' => ['string', 'distinct', Rule::in(array_keys(WorkContext::OPTIONS))], 'reason' => 'required|string|min:10|max:4000', 'confirm' => 'accepted'];
} elseif ($command === 'user') {
$rules += ['name' => 'required|string|max:100', 'email' => 'required|email|max:255|unique:users', 'password' => 'required|string|min:12|confirmed',
'department_id' => 'required|integer|min:1', 'profile_ids' => 'present|array', 'profile_ids.*' => 'integer|distinct|min:1', 'reason' => 'required|string|min:10|max:4000'];
@@ -0,0 +1,156 @@
<?php
namespace App\Http\Controllers;
use App\Models\PeopleProcess;
use App\Models\User;
use App\Services\PeopleAccess;
use App\Services\PeopleApplicationReport;
use App\Services\PeopleDeadlineReport;
use App\Services\PeopleHiring;
use App\Services\PeopleSelectionReport;
use App\Services\PeopleWorkdesk;
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 PeopleReportController extends Controller
{
public function index(Request $request): Response
{
return DB::transaction(function () use ($request) {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$kinds = app(PeopleHiring::class)->kinds($actor);
abort_unless($kinds !== [], 403);
$filters = $request->validate([
'view' => 'nullable|in:processes,applications,selections,deadlines',
'deadline_window' => 'nullable|in:all,overdue,today,future,undated',
'deadline_page' => 'nullable|integer|min:1|max:100000',
'kind' => ['nullable', Rule::in($kinds)], 'status' => ['nullable', Rule::in(PeopleWorkdesk::STATES)],
'search' => 'nullable|string|max:100', 'from' => 'nullable|date_format:Y-m-d',
'to' => ['nullable', 'date_format:Y-m-d', Rule::when($request->filled('from'), 'after_or_equal:from')],
]);
$query = app(PeopleAccess::class)->visible(PeopleProcess::query()->whereIn('kind', $kinds), $actor);
foreach (['kind', 'status'] as $field) {
if (! empty($filters[$field])) {
$query->where($field, $filters[$field]);
}
}
if (! empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->whereLike('title', '%'.$search.'%');
if (preg_match('/^(?:PES-)?([1-9][0-9]*)$/D', $search, $matches)) {
$id = filter_var($matches[1], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if ($id !== false) {
$q->orWhere('id', $id);
}
}
});
}
if (! empty($filters['from'])) {
$query->whereDate('created_at', '>=', $filters['from']);
}
if (! empty($filters['to'])) {
$query->whereDate('created_at', '<=', $filters['to']);
}
$columns = ['id', 'title', 'kind', 'status', 'closes_on', 'created_at'];
$page = $request->routeIs('people.reports.csv') ? null : (clone $query)->latest('id')->paginate(25, $columns)->withQueryString();
$applications = ($filters['view'] ?? 'processes') === 'applications';
$selections = ($filters['view'] ?? 'processes') === 'selections';
$deadlines = ($filters['view'] ?? 'processes') === 'deadlines';
$deadlineReport = $deadlines ? app(PeopleDeadlineReport::class)->build(clone $query, $actor, $filters['deadline_window'] ?? 'all') : null;
if ($deadlineReport !== null && ! $request->routeIs('people.reports.csv')) {
$number = (int) ($filters['deadline_page'] ?? 1);
$pager = (new LengthAwarePaginator(array_slice($deadlineReport['items'], ($number - 1) * 25, 25), $deadlineReport['total_items'], 25, $number,
['path' => $request->url(), 'pageName' => 'deadline_page']))->appends($request->except('deadline_page'));
$deadlineReport['items'] = $pager->items();
$deadlineReport['pagination'] = ['current_page' => $pager->currentPage(), 'last_page' => $pager->lastPage(), 'prev_page_url' => $pager->previousPageUrl(), 'next_page_url' => $pager->nextPageUrl()];
}
$metrics = [];
if ($applications || $selections) {
$metricQuery = clone $query;
if ($page !== null) {
$metricQuery->whereIn('id', $page->getCollection()->modelKeys());
}
foreach ($metricQuery->orderBy('id')->lazyById(100) as $process) {
$metrics[$process->id] = $applications ? app(PeopleApplicationReport::class)->counts($process, $actor) : app(PeopleSelectionReport::class)->facts($process, $actor);
}
}
if ($request->routeIs('people.reports.csv') && $deadlineReport !== null) {
$response = $this->deadlineCsv($deadlineReport);
} elseif ($request->routeIs('people.reports.csv')) {
// Materialise within the read-only snapshot; streaming runs after the transaction.
$rows = (clone $query)->orderBy('id')->get($columns);
$response = response()->streamDownload(function () use ($rows, $applications, $selections, $metrics) {
$file = fopen('php://output', 'w');
if ($file === false) {
throw new \RuntimeException('Não foi possível abrir a exportação.');
}
fwrite($file, "\xEF\xBB\xBF");
$extra = ['Registadas para avaliação', 'Avaliação completa', 'Por concluir avaliação', 'Sem avaliação', 'Avaliação em curso', 'Base da avaliação', 'Recebidas no portal', 'Admitidas para análise no portal', 'Não admitidas no portal', 'Triagem pendente'];
$selectionColumns = ['Situação da selecção', 'Proposta em', 'Decisão em', 'Pendência', 'Acesso à minuta', 'Referência da minuta', 'Minuta preparada em', 'Início previsto na minuta'];
fputcsv($file, ['Referência', 'Título', 'Percurso', 'Estado', 'Fecho de candidaturas', 'Criado em', ...($applications ? $extra : ($selections ? $selectionColumns : []))], ';', '"', '');
foreach ($rows as $row) {
$title = preg_match('/^[\s]*[=+@-]/u', $row->title) ? "'".$row->title : $row->title;
$counts = $metrics[$row->id] ?? [];
$values = $applications ? array_values(array_map(fn ($value) => $value === null ? 'Sem acesso' : ($value === 'selection_snapshot' ? 'Selecção preservada' : ($value === 'current_eligibility' ? 'Elegibilidade actual' : $value)), $counts)) : [];
if ($selections) {
$values = array_values(array_map(fn ($value) => $value === null ? 'Não registado' : (is_string($value) && preg_match('/^[\s]*[=+@-]/u', $value) ? "'".$value : $value), $counts));
}
fputcsv($file, ['PES-'.$row->id, $title, $row->kind === 'service' ? 'Serviços individuais' : 'Recrutamento e selecção', self::states()[$row->status] ?? $row->status, $row->closes_on, $row->created_at?->toDateString(), ...$values], ';', '"', '');
}
fclose($file);
}, 'relatorio-pessoas.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
} else {
$summary = (clone $query)->selectRaw('kind, status, count(*) as total')->groupBy('kind', 'status')->get();
Inertia::encryptHistory();
$response = Inertia::render('people/reports', [
'filters' => $filters, 'kinds' => $kinds, 'summary' => $summary,
'applicationMetrics' => $applications ? $metrics : [],
'selectionMetrics' => $selections ? $metrics : [],
'deadlineReport' => $deadlineReport,
'generatedAt' => now()->toIso8601String(),
'processes' => $page,
])->toResponse($request);
}
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
});
}
/** @param array<string,mixed> $report */
private function deadlineCsv(array $report): Response
{
return response()->streamDownload(function () use ($report) {
$file = fopen('php://output', 'w');
if ($file === false) {
throw new \RuntimeException('Não foi possível abrir a exportação.');
}
fwrite($file, "\xEF\xBB\xBF");
fputcsv($file, ['Referência', 'Processo', 'Categoria', 'Intervenção', 'Responsabilidade', 'Prazo', 'Dias de atraso', 'Consulta em', 'Filtro de prazo'], ';', '"', '');
$windows = ['all' => 'Todos', 'overdue' => 'Vencidos', 'today' => 'Hoje', 'future' => 'Futuros', 'undated' => 'Sem prazo definido'];
foreach ($report['items'] as $item) {
$values = [$item['reference'], $item['title'], $item['category'], $item['label'], $item['responsibility'], $item['due_on'] ?? 'Sem prazo definido', $item['days_overdue'] ?? 'Não determinável', $report['as_of'], $windows[$report['window']]];
$values = array_map(fn ($value) => is_string($value) && preg_match('/^[\s]*[=+@-]/u', $value) ? "'".$value : $value, $values);
fputcsv($file, $values, ';', '"', '');
}
fclose($file);
}, 'relatorio-prazos-pessoas.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
}
/** @return array<string,string> */
private static function states(): array
{
return ['draft' => 'Abertura por conferir', 'open' => 'Recepção de candidaturas', 'assessment' => 'Avaliação',
'selection_review' => 'Selecção por conferir', 'selected' => 'Selecção aprovada', 'contract_draft' => 'Minuta contratual preparada'];
}
}
+18 -2
View File
@@ -12,14 +12,19 @@ use App\Models\SparePart;
use App\Models\StockItem;
use App\Models\TechnicalReview;
use App\Models\Tenant;
use App\Models\User;
use App\Services\ApprovalRouting;
use App\Services\ComparisonPolicy;
use App\Services\ConflictGuard;
use App\Services\EquipmentWorkflow;
use App\Services\OrganizationAccess;
use App\Services\RequisitionCatalogue;
use App\Services\RequisitionWorkflow;
use App\Services\SupplySuggestions;
use App\Services\WorkContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
@@ -79,6 +84,9 @@ class RequisitionController extends Controller
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'),
'replenishmentSource' => app(SupplySuggestions::class)->origin($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()),
@@ -140,7 +148,8 @@ class RequisitionController extends Controller
public function submit(Request $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
{
$workflow->submit($request->user(), $requisition);
$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);
return back();
}
@@ -150,14 +159,21 @@ class RequisitionController extends Controller
$data = $request->validate([
'decision' => ['required', Rule::in(['approved', 'rejected'])],
'comment' => ['nullable', 'required_if:decision,rejected', 'string', 'max:2000'],
'operation_key' => 'nullable|uuid', 'process_version' => 'nullable|integer|min:1',
]);
$workflow->decide($request->user(), $requisition, $data['decision'], $data['comment'] ?? null);
$workflow->decide($request->user(), $requisition, $data['decision'], $data['comment'] ?? null, $data['operation_key'] ?? null, $data['process_version'] ?? null);
return back();
}
public function dashboard(Request $request): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$contexts = app(WorkContext::class);
if ($contexts->enforced($actor)) {
return Inertia::render('work-context', ['context' => $contexts->shared($request)]);
}
$visible = Requisition::query()->visibleTo($request->user());
$pending = (clone $visible)->where('status', 'pending')->with('steps')->get();
+2
View File
@@ -6,6 +6,7 @@ use App\Enums\PurchasingRole;
use App\Models\StockItem;
use App\Models\StockMovement;
use App\Models\Warehouse;
use App\Services\InventoryLots;
use App\Services\RequisitionWorkflow;
use Brick\Math\BigDecimal;
use Illuminate\Http\RedirectResponse;
@@ -44,6 +45,7 @@ class StockController extends Controller
$balances = (clone $movements)->selectRaw('stock_item_id, warehouse_id, SUM(quantity_milli) as quantity_milli')->groupBy('stock_item_id', 'warehouse_id')->get()->map(fn ($balance) => [
'stock_item_id' => $balance->stock_item_id, 'warehouse_id' => $balance->warehouse_id,
'quantity_milli' => BigDecimal::of($balance->quantity_milli)->toInt(),
'lot_control' => app(InventoryLots::class)->summary($balance->stock_item_id, $balance->warehouse_id),
]);
return Inertia::render('stock/index', [
@@ -8,6 +8,9 @@ use App\Models\StockItem;
use App\Models\StockOperation;
use App\Models\User;
use App\Models\Warehouse;
use App\Services\InventoryCounts;
use App\Services\InventoryLots;
use App\Services\InventoryReversals;
use App\Services\StockWorkflow;
use Brick\Math\BigDecimal;
use Illuminate\Http\JsonResponse;
@@ -15,6 +18,7 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
@@ -22,31 +26,47 @@ class StockOperationController extends Controller
{
public function index(Request $request, StockWorkflow $workflow): Response
{
abort_unless($request->user()->hasFunctions([PurchasingRole::Warehouse, PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement]), 403);
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
abort_unless($actor->hasFunctions([PurchasingRole::Warehouse, PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement, 'inventory_adjust_review']), 403);
$items = StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']);
$warehouses = Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']);
$balances = [];
$lotCounts = [];
foreach ($items as $item) {
foreach ($warehouses as $warehouse) {
$balances[] = ['stock_item_id' => $item->id, 'warehouse_id' => $warehouse->id, ...$workflow->balance($item->id, $warehouse->id)];
$balances[] = ['stock_item_id' => $item->id, 'warehouse_id' => $warehouse->id, ...$workflow->balance($item->id, $warehouse->id),
'lot_control' => app(InventoryLots::class)->summary($item->id, $warehouse->id)];
if ($actor->hasFunctions([PurchasingRole::Warehouse]) && app(InventoryLots::class)->setup($item->id, $warehouse->id) !== null) {
try {
$source = app(InventoryCounts::class)->source($item->id, $warehouse->id);
$lotCounts[] = ['stock_item_id' => $item->id, 'warehouse_id' => $warehouse->id, ...$source];
} catch (ValidationException) {
// The summary still reports the divergence; no count can use an invented baseline.
}
}
}
}
return Inertia::render('stock/operations', [
'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', $request->user()->tenant_id)->orderBy('name')->get(['id', 'name']),
'users' => User::query()->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'name']),
'operations' => StockOperation::query()->latest('id')->paginate(20),
'counts' => StockOperation::query()->where('kind', 'count')->whereNotIn('id', StockOperation::query()->whereNotNull('count_id')->select('count_id'))->latest('id')->get(),
'canOperate' => $request->user()->hasFunctions([PurchasingRole::Warehouse]),
'canOperate' => $actor->hasFunctions([PurchasingRole::Warehouse]),
'canAdjust' => app(InventoryLots::class)->allows($actor, 'inventory_adjust_review'), 'lotCounts' => $lotCounts,
'equipment' => Equipment::query()->orderBy('code')->get(['id', 'code', 'description']),
]);
}
public function store(Request $request, StockWorkflow $workflow): JsonResponse|RedirectResponse
{
abort_unless($request->user()->hasFunctions([PurchasingRole::Warehouse]), 403);
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
abort_unless($actor->hasFunctions($request->input('kind') === 'adjustment' ? [PurchasingRole::Warehouse, 'inventory_adjust_review'] : [PurchasingRole::Warehouse]), 403);
$request->merge(['reference' => Str::upper(Str::squish($request->string('reference')->toString())), 'reason' => trim($request->string('reason')->toString()), 'destination' => trim($request->string('destination')->toString())]);
$tenant = $request->user()->tenant_id;
$tenant = $actor->tenant_id;
$data = $request->validate([
'kind' => ['required', Rule::in(['issue', 'transfer', 'count', 'adjustment'])],
'reference' => 'required|string|max:100', 'idempotency_key' => 'required|uuid', 'reason' => 'required|string|max:4000',
@@ -59,10 +79,31 @@ class StockOperationController extends Controller
'quantity' => ['exclude_if:kind,adjustment', 'required', 'regex:/^\d{1,9}(\.\d{1,3})?$/', $request->input('kind') === 'count' ? 'gte:0' : 'gt:0'],
'ledger_version' => 'exclude_unless:kind,count|required|integer|min:0',
'count_id' => ['exclude_unless:kind,adjustment', 'required', 'integer', Rule::exists('stock_operations', 'id')->where('tenant_id', $tenant)->where('kind', 'count')],
'lot_version' => 'exclude_unless:kind,count|nullable|string|size:64',
'lot_counts' => 'exclude_unless:kind,count|sometimes|array|max:500',
'lot_counts.*.lot_id' => 'nullable|integer|min:1', 'lot_counts.*.new_key' => 'nullable|uuid',
'lot_counts.*.quantity' => ['required', 'string', 'regex:/^\d{1,9}(\.\d{1,3})?$/'],
'lot_counts.*.code' => 'nullable|string|max:100', 'lot_counts.*.expires_on' => 'nullable|date_format:Y-m-d', 'lot_counts.*.no_expiry' => 'sometimes|boolean',
]);
if (empty($data['equipment_id'])) {
unset($data['equipment_id']);
}
if (empty($data['lot_version'])) {
unset($data['lot_version']);
}
if (empty($data['lot_counts']) && empty($data['lot_version'])) {
unset($data['lot_counts']);
}
if (isset($data['lot_counts'])) {
$data['lot_counts'] = array_map(function (array $row) {
if (! empty($row['lot_id'])) {
$row['lot_id'] = (int) $row['lot_id'];
}
$row['quantity'] = (string) BigDecimal::of($row['quantity'])->toScale(3);
return $row;
}, $data['lot_counts']);
}
foreach (['responsible_id', 'warehouse_id', 'stock_item_id', 'destination_warehouse_id', 'ledger_version', 'count_id', 'equipment_id'] as $key) {
if (isset($data[$key])) {
$data[$key] = (int) $data[$key];
@@ -70,7 +111,7 @@ class StockOperationController extends Controller
}
$data['quantity'] = isset($data['quantity']) ? (string) BigDecimal::of($data['quantity'])->toScale(3) : '0.000';
ksort($data);
$operation = $workflow->record($request->user(), $data);
$operation = $workflow->record($actor, $data);
return $request->expectsJson() ? response()->json(['id' => $operation->id, 'created' => $operation->wasRecentlyCreated], $operation->wasRecentlyCreated ? 201 : 200) : back();
}
@@ -0,0 +1,170 @@
<?php
namespace App\Http\Controllers;
use App\Models\CostCenter;
use App\Models\PurchaseOrder;
use App\Models\PurchaseOrderItem;
use App\Models\Requisition;
use App\Models\RequisitionItem;
use App\Models\StockItem;
use App\Models\StockOperation;
use App\Models\Supplier;
use App\Models\SupplyRecord;
use App\Models\User;
use App\Models\Warehouse;
use App\Services\InventoryReversals;
use App\Services\OrganizationAccess;
use App\Services\ReceiptWorkflow;
use App\Services\SupplyCoordination;
use App\Services\SupplyPlanning;
use App\Services\SupplySuggestions;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class SupplyPlanningController extends Controller
{
public function index(Request $request, SupplyPlanning $planning): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$view = $request->validate(['view' => 'nullable|in:agreements,calendar,imports,alternatives,reservations,deliveries,suggestions,history'])['view'] ?? 'agreements';
if ($view === 'history') {
return $this->history($actor, $planning);
}
if ($view === 'suggestions') {
return $this->suggestions($actor, $planning);
}
if (in_array($view, ['alternatives', 'reservations', 'deliveries'], true)) {
return $this->coordination($actor, $view, $planning);
}
$import = $view === 'imports';
$capabilities = $import ? ['import_read', 'import_prepare', 'import_customs'] : ['supply_read', 'supply_prepare', 'supply_review'];
abort_unless(collect($capabilities)->contains(fn ($p) => $planning->allows($actor, $p)), 403);
$ready = Schema::hasTable('supply_records');
$kind = $import ? 'import' : ($view === 'calendar' ? 'policy' : 'agreement');
$records = $ready ? $planning->latest($kind)->latest('id')->limit(100)->get() : collect();
$projections = [];
if ($view === 'calendar' && $ready) {
foreach ($records->where('state', 'approved') as $record) {
$projections[$record->record_key] = $planning->calendar($record, today()->toDateString());
}
}
$prepare = $planning->allows($actor, $import ? 'import_prepare' : 'supply_prepare');
return Inertia::render('supply/index', ['view' => $view, 'kind' => $kind, 'ready' => $ready, 'records' => $records, 'projections' => $projections,
'asOf' => today()->toDateString(),
'histories' => $ready && $prepare && $kind === 'policy' ? $planning->latest('history')->where('state', 'approved')->latest('id')->limit(100)->get()->map(fn ($r) => [
'key' => $r->record_key, 'version' => $r->version, 'stock_item_id' => $r->stock_item_id, 'warehouse_id' => $r->warehouse_id, 'purpose' => $r->data['purpose'],
'period_from' => $r->data['period_from'], 'period_until' => $r->data['period_until'], 'quality' => $r->data['quality'], 'pattern' => $r->data['pattern'], 'days' => $r->snapshot['analysis']['days'], 'daily_mean_milli' => $r->snapshot['analysis']['daily_mean_milli'],
]) : [],
'agreements' => $ready && $prepare && $kind === 'policy' ? $planning->latest('agreement')->where('state', 'approved')->get(['record_key', 'version', 'stock_item_id', 'warehouse_id', 'data', 'snapshot']) : [],
'history' => $ready ? SupplyRecord::query()->where('kind', $kind)->whereIn('record_key', $records->pluck('record_key'))->orderByDesc('id')->get()->groupBy('record_key') : [],
'canPrepare' => $prepare, 'canReview' => $planning->allows($actor, 'supply_review'), 'canCustoms' => $planning->allows($actor, 'import_customs'),
'items' => $prepare && ! $import ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [],
'warehouses' => $prepare && ! $import ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [],
'suppliers' => $prepare && $kind === 'agreement' ? Supplier::query()->where('status', 'active')->orderBy('name')->get(['id', 'name']) : [],
'orders' => $prepare && $import ? PurchaseOrder::query()->latest('id')->limit(100)->get(['id', 'number']) : [],
]);
}
private function history(User $actor, SupplyPlanning $planning): Response
{
$prepare = $planning->allows($actor, 'supply_history_prepare');
$review = $planning->allows($actor, 'supply_history_review');
abort_unless($prepare || $review || $planning->allows($actor, 'supply_read'), 403);
$ready = Schema::hasTable('supply_records');
$records = $ready ? $planning->latest('history')->latest('id')->limit(100)->get() : collect();
return Inertia::render('supply/history', ['ready' => $ready, 'actorId' => $actor->id, 'records' => $records, 'canPrepare' => $prepare, 'canReview' => $review,
'defaultPeriod' => ['from' => today()->subDays(30)->toDateString(), 'until' => today()->subDay()->toDateString()],
'items' => $prepare ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [], 'warehouses' => $prepare ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [],
'history' => $ready ? SupplyRecord::query()->where('kind', 'history')->whereIn('record_key', $records->pluck('record_key'))->orderByDesc('id')->get()->groupBy('record_key') : [],
]);
}
private function suggestions(User $actor, SupplyPlanning $planning): Response
{
$prepare = $planning->allows($actor, 'supply_suggest_prepare');
$review = $planning->allows($actor, 'supply_suggest_review');
$convertPermission = $planning->allows($actor, 'supply_suggest_convert');
$convert = $convertPermission && app(OrganizationAccess::class)->canRequest($actor);
abort_unless($planning->allows($actor, 'supply_read') || $prepare || $review || $convertPermission, 403);
$ready = Schema::hasTable('supply_records');
$records = $ready ? $planning->latest('suggestion')->latest('id')->limit(100)->get() : collect();
$policies = [];
if ($ready && $prepare) {
foreach ($planning->latest('policy')->where('state', 'approved')->latest('id')->limit(100)->get() as $policy) {
$option = ['key' => $policy->record_key, 'version' => $policy->version, 'label' => $policy->snapshot['item']['code'].' · '.$policy->snapshot['item']['name'].' · '.$policy->snapshot['warehouse']['name'], 'issue' => null];
try {
$basis = app(SupplySuggestions::class)->basis($policy->record_key, today()->toDateString());
$option += ['unit_price' => $basis['unit_price'], 'tax_rate' => $basis['tax_rate'], 'quantity_milli' => $basis['projection']['quantity_milli'] ?? 0,
'delivery_on' => $basis['projection']['risk_date'] ?? null, 'unit' => $basis['item']['unit']];
} catch (ValidationException $e) {
$option['issue'] = $e->getMessage();
}
$policies[] = $option;
}
}
$requests = Requisition::query()->visibleTo($actor)->whereIn('id', $records->pluck('data.requisition_id')->filter())->get(['id', 'number'])->keyBy('id');
return Inertia::render('supply/suggestions', ['ready' => $ready, 'actorId' => $actor->id, 'records' => $records, 'policies' => $policies, 'canPrepare' => $prepare, 'canReview' => $review, 'canConvert' => $convert,
'costCenters' => $convert ? CostCenter::query()->where('department_id', $actor->department_id)->orderBy('code')->get(['id', 'code', 'name']) : [],
'requestLinks' => $requests,
'excess' => $records->mapWithKeys(fn ($r) => [$r->record_key => app(SupplySuggestions::class)->hasExcess($r)]),
'history' => $ready ? SupplyRecord::query()->where('kind', 'suggestion')->whereIn('record_key', $records->pluck('record_key'))->orderByDesc('id')->get()->groupBy('record_key') : [],
]);
}
private function coordination(User $actor, string $view, SupplyPlanning $planning): Response
{
[$kind, $prepare, $review] = match ($view) {
'alternatives' => ['alternative', 'supply_technical_prepare', 'supply_technical_review'],
'reservations' => ['reservation', 'supply_reserve', 'supply_reserve'],
'deliveries' => ['delivery', 'supply_delivery', 'supply_delivery'],
default => abort(404),
};
abort_unless($planning->allows($actor, 'supply_read') || $planning->allows($actor, $prepare) || $planning->allows($actor, $review), 403);
$ready = Schema::hasTable('supply_records');
$records = $ready ? $planning->latest($kind)->latest('id')->limit(100)->get() : collect();
$canPrepare = $planning->allows($actor, $prepare);
$reversedIds = $ready && $kind === 'reservation' ? array_keys(app(InventoryReversals::class)->corrections('operation')) : [];
$orders = $canPrepare && $kind === 'delivery' ? PurchaseOrder::query()->latest('id')->limit(100)->get()->keyBy('id') : collect();
$orderLines = PurchaseOrderItem::query()->whereIn('purchase_order_id', $orders->keys())->get()->map(fn ($line) => [
'id' => $line->id, 'label' => $orders[$line->purchase_order_id]->number.' · '.$line->description.' · '.$line->quantity.' '.$line->unit,
'stock_item_id' => $orders[$line->purchase_order_id]->snapshot['catalogue_sources'][$line->requisition_item_id]['stock_item_id'] ?? null,
'quantity_milli' => ReceiptWorkflow::milli($line->quantity),
]);
$requests = $canPrepare && $kind === 'reservation' ? Requisition::query()->where('status', 'approved')->latest('id')->limit(100)->get()->keyBy('id') : collect();
$requestLines = RequisitionItem::query()->whereIn('requisition_id', $requests->keys())->get()->filter(fn ($line) => isset($line->catalogue_snapshot['stock_item_id']))->map(fn ($line) => [
'id' => $line->id, 'label' => $requests[$line->requisition_id]->number.' · '.$line->description,
'stock_item_id' => $line->catalogue_snapshot['stock_item_id'], 'quantity_milli' => ReceiptWorkflow::milli($line->quantity),
'reference' => $requests[$line->requisition_id]->number.' / '.$line->id, 'needed_on' => $requests[$line->requisition_id]->needed_by->toDateString(),
])->values();
return Inertia::render('supply/coordination', ['kind' => $kind, 'ready' => $ready, 'records' => $records,
'eligible' => $kind === 'alternative' ? $records->mapWithKeys(fn ($r) => [$r->record_key => app(SupplyCoordination::class)->eligibleAlternative($r)]) : [],
'pending' => $kind === 'delivery' ? $records->mapWithKeys(fn ($r) => [$r->record_key => app(SupplyCoordination::class)->remainingDelivery($r)]) : ($kind === 'reservation' ? $records->mapWithKeys(fn ($r) => [$r->record_key => app(SupplyCoordination::class)->remainingReservation($r)]) : []),
'reversedOperations' => $reversedIds,
'canPrepare' => $canPrepare, 'canReview' => $planning->allows($actor, $review),
'items' => $canPrepare ? StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']) : [],
'warehouses' => $canPrepare ? Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']) : [],
'orderLines' => $orderLines, 'requestLines' => $requestLines,
'operations' => $canPrepare && $kind === 'reservation' ? StockOperation::query()->where('kind', 'issue')->whereNotIn('id', $reversedIds)->latest('id')->limit(100)->get(['id', 'reference', 'stock_item_id', 'warehouse_id', 'quantity_milli']) : [],
'history' => $ready ? SupplyRecord::query()->where('kind', $kind)->whereIn('record_key', $records->pluck('record_key'))->orderByDesc('id')->get()->groupBy('record_key') : [],
]);
}
public function save(Request $request, string $kind, SupplyPlanning $planning): RedirectResponse
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$planning->save($actor, $kind, $request->all());
return back();
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers;
use App\Models\StockItem;
use App\Models\User;
use App\Models\Warehouse;
use App\Services\WarehouseAvailability;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class WarehouseAvailabilityController extends Controller
{
public function index(Request $request, WarehouseAvailability $availability): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User && $actor->hasFunctions(['admin', 'warehouse', 'procurement', 'finance', 'warehouse_availability_read']), 403);
return DB::transaction(function () use ($request, $actor, $availability) {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
$filters = $request->validate(['warehouse_id' => ['nullable', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $actor->tenant_id)],
'search' => 'nullable|string|max:100', 'page' => 'nullable|integer|min:1|max:100000']);
$warehouse = empty($filters['warehouse_id']) ? null : Warehouse::query()->whereKey($filters['warehouse_id'])->firstOrFail();
$planning = $actor->hasFunctions(['admin', 'supply_read', 'supply_reserve', 'warehouse_availability_read']);
$query = StockItem::query();
if (! empty($filters['search'])) {
$query->where(fn ($q) => $q->whereLike('code', '%'.$filters['search'].'%')->orWhereLike('name', '%'.$filters['search'].'%'));
}
$page = $warehouse === null ? null : $query->orderBy('code')->paginate(25)->withQueryString()->through(fn ($item) => $availability->row($item, $warehouse, $planning));
$response = Inertia::render('stock/availability', ['filters' => $filters, 'planningVisible' => $planning, 'rows' => $page,
'warehouse' => $warehouse?->only(['id', 'code', 'name']), 'checkedAt' => now()->toIso8601String(),
'warehouses' => Warehouse::query()->orderBy('code')->get(['id', 'code', 'name'])])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
});
}
}
@@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers;
use App\Models\CostCenter;
use App\Models\Equipment;
use App\Models\StockItem;
use App\Models\User;
use App\Models\Warehouse;
use App\Models\WarehouseRequest;
use App\Services\OrganizationAccess;
use App\Services\WarehouseFulfilment;
use App\Services\WarehouseRequests;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class WarehouseRequestController extends Controller
{
public function index(Request $request): 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;
}
public function store(Request $request, WarehouseRequests $service): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$service->draft($actor, $request->all());
return redirect('/stock/requisicoes');
}
public function show(Request $request, int $id, WarehouseFulfilment $service): Response
{
$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;
}
public function command(Request $request, int $id, WarehouseFulfilment $service): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$service->command($actor, $id, $request->all());
return redirect('/stock/requisicoes/'.$id);
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\RequisitionWorkflow;
use App\Services\WorkContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class WorkContextController extends Controller
{
public function select(Request $request, WorkContext $contexts): RedirectResponse
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$data = $request->validate(['area' => ['required', Rule::in(['procurement', 'people'])], 'option' => ['nullable', Rule::in(array_keys(WorkContext::OPTIONS))]]);
$available = array_values(array_filter($contexts->available($actor), fn ($key) => WorkContext::OPTIONS[$key]['area'] === $data['area']));
$key = $data['option'] ?? (count($available) === 1 ? $available[0] : null);
abort_unless($key !== null && in_array($key, $available, true), 403, 'Escolha uma opção configurada, contratada e autorizada.');
$state = ['user_id' => $actor->id, 'tenant_id' => $actor->tenant_id, 'key' => $key];
app(RequisitionWorkflow::class)->audit($actor, 'work_context.selected', null, $request->session()->get('work_context'), $state);
$request->session()->put('work_context', $state);
return redirect('/dashboard');
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\WorkflowNotices;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class WorkflowNoticeController extends Controller
{
public function index(Request $request, WorkflowNotices $notices): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$filter = $request->validate(['view' => 'nullable|in:all,unread,tasks,outcomes', 'page' => 'nullable|integer|min:1|max:100000']);
$visible = collect($notices->visible($actor));
$unread = $visible->whereNull('read_at')->count();
$items = ($filter['view'] ?? 'all') === 'unread' ? $visible->whereNull('read_at')->values() : $visible;
if (in_array($filter['view'] ?? '', ['tasks', 'outcomes'], true)) {
$items = $visible->where('kind', $filter['view'] === 'tasks' ? 'task' : 'outcome')->values();
}
$page = (int) ($filter['page'] ?? 1);
$response = Inertia::render('workflow/notices', ['ready' => Schema::hasTable('workflow_notices'), 'unread' => $unread, 'view' => $filter['view'] ?? 'all',
'notices' => (new LengthAwarePaginator($items->slice(($page - 1) * 25, 25)->values(), $items->count(), 25, $page, ['path' => $request->url()]))->withQueryString()])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function read(Request $request, int $notice, WorkflowNotices $notices): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$notices->read($actor, $notice);
return redirect('/requisicoes/avisos');
}
public function refresh(Request $request, int $requisition, WorkflowNotices $notices): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$notices->refresh($actor, $requisition, $request->all());
return redirect('/requisicoes/'.$requisition);
}
}