commit de armazens
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-11-04 09:40:26 +02:00
parent e753f7a0a4
commit 63d20b10fc
245 changed files with 10847 additions and 230 deletions
@@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers;
use App\Models\Tenant;
use App\Models\User;
use App\Services\AdministrativeGeography;
use App\Services\ConflictGuard;
use App\Services\OrganizationAccess;
use App\Services\OrganizationConfiguration;
use App\Services\RequisitionWorkflow;
use Illuminate\Http\JsonResponse;
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 Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AdministrativeGeographyController extends Controller
{
/** @return array<string,mixed> */
private function reviewFilters(Request $request, AdministrativeGeography $geography): array
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
app(OrganizationConfiguration::class)->administrator($actor);
abort_unless($geography->enabled(), 503);
$filters = $request->validate(['search' => 'nullable|string|max:120', 'issue' => ['nullable', Rule::in(array_keys(AdministrativeGeography::ISSUES))], 'page' => 'sometimes|integer|min:1|max:10000']);
return ['search' => trim($filters['search'] ?? ''), 'issue' => $filters['issue'] ?? '', 'page' => (int) ($filters['page'] ?? 1)];
}
public function review(Request $request, AdministrativeGeography $geography): Response
{
$filters = $this->reviewFilters($request, $geography);
$rows = $geography->inconsistencies($filters);
Inertia::encryptHistory();
$response = Inertia::render('company/geography-review', ['filters' => $filters, 'records' => array_slice($rows, ($filters['page'] - 1) * 50, 50), 'total' => count($rows), 'manifest' => $geography->catalogue()['manifest'], 'issues' => AdministrativeGeography::ISSUES])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function exportReview(Request $request, AdministrativeGeography $geography): StreamedResponse
{
$filters = $this->reviewFilters($request, $geography);
$rows = $geography->inconsistencies($filters);
return response()->streamDownload(function () use ($rows) {
$output = fopen('php://output', 'w');
if ($output === false) {
throw new \RuntimeException('Não foi possível preparar a exportação.');
}
$safe = fn ($value) => preg_match('/^[\s]*[=+@-]|^[\t\r\n]/u', $value) ? "'".$value : $value;
fputcsv($output, ['Linha', 'ID', 'Provincia', 'Distrito', 'Posto_Administrativo', 'Localidade', 'Bairro_Urbano', 'Aldeia_Quarteirao', 'Designacao', 'Diagnosticos'], ',', '"', '');
foreach ($rows as $record) {
$raw = $record['raw'];
fputcsv($output, array_map($safe, [(string) $record['source_row'], $record['id'], ...array_values(array_diff_key($raw, ['ID' => true])), implode('; ', array_map(fn ($issue) => AdministrativeGeography::ISSUES[$issue], $record['issues']))]), ',', '"', '');
}
fclose($output);
}, 'sgc-cartografia-pendencias.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store, max-age=0', 'X-Content-Type-Options' => 'nosniff']);
}
public function index(Request $request, AdministrativeGeography $geography): JsonResponse
{
abort_unless($request->user('web') instanceof User && $request->user('web')->tenant_id !== null, 403);
abort_unless($geography->enabled(), 503);
$data = $request->validate(['parent' => 'nullable|string|regex:/^[0-9]+$/D|max:20', 'page' => 'sometimes|integer|min:1|max:10000']);
return response()->json($geography->children($data['parent'] ?? null, (int) ($data['page'] ?? 1)));
}
public function company(Request $request, AdministrativeGeography $geography): RedirectResponse
{
abort_unless($geography->enabled(), 503);
$data = $request->validate(['geography_reference' => 'present|nullable|array:country,catalogue,record_id', 'geography_reference.catalogue' => 'required_with:geography_reference|string|size:64', 'geography_reference.record_id' => 'required_with:geography_reference|string|regex:/^[0-9]+$/D|max:20', 'expected_reference' => 'present|nullable|array:country,catalogue,record_id']);
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
DB::transaction(function () use ($actor, $data, $geography) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(OrganizationConfiguration::class)->administrator($actor);
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
$company = Tenant::query()->lockForUpdate()->findOrFail($actor->tenant_id);
if ($company->geography_reference != $data['expected_reference']) {
throw ValidationException::withMessages(['geography_reference' => 'A localização foi alterada. Reabra a parametrização.']);
}
$before = $company->geography_reference;
$after = $geography->canonical($data['geography_reference']);
if ($before != $after) {
$company->update(['geography_reference' => $after]);
app(RequisitionWorkflow::class)->audit($actor, 'company.geography_updated', null, ['geography_reference' => $before], ['geography_reference' => $after]);
}
});
return back();
}
}
@@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\OrganizationAccess;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class AuditMetadataController extends Controller
{
private function actor(Request $request): User
{
$user = $request->user('web');
abort_unless($user instanceof User && $user->tenant_id !== null, 403);
$user = User::query()->where('tenant_id', $user->tenant_id)->findOrFail($user->id);
$access = app(OrganizationAccess::class);
abort_unless(! $access->maintenanceOnly($user) && in_array('audit_metadata_read', $access->permissions($user), true), 403);
return $user;
}
/** @return array<string,mixed> */
private function filters(Request $request): array
{
return $request->validate(['action' => 'nullable|string|max:100', 'actor_id' => 'nullable|integer|min:1',
'from' => 'nullable|date_format:Y-m-d', 'to' => ['nullable', 'date_format:Y-m-d', Rule::when($request->filled('from'), 'after_or_equal:from')], 'page' => 'nullable|integer|min:1|max:1000000']);
}
/** @param array<string,mixed> $filters */
private function query(User $actor, array $filters): Builder
{
return DB::table('audit_logs')->where('tenant_id', $actor->tenant_id)
->when(! empty($filters['action']), fn ($q) => $q->where('action', $filters['action']))
->when(! empty($filters['actor_id']), fn ($q) => $q->where('user_id', $filters['actor_id']))
->when(! empty($filters['from']), fn ($q) => $q->whereDate('created_at', '>=', $filters['from']))
->when(! empty($filters['to']), fn ($q) => $q->whereDate('created_at', '<=', $filters['to']))
->select(['id', 'created_at', 'action', 'user_id']);
}
public function index(Request $request): Response
{
$this->actor($request);
$filters = $this->filters($request);
return DB::transaction(function () use ($request, $filters) {
$this->readOnly();
$actor = $this->actor($request);
Inertia::encryptHistory();
$response = Inertia::render('audit/metadata', ['filters' => $filters,
'events' => $this->query($actor, $filters)->orderBy('id')->paginate(25)->withQueryString()])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
});
}
public function csv(Request $request): Response
{
$this->actor($request);
$filters = $this->filters($request);
return response()->streamDownload(function () use ($request, $filters) {
DB::transaction(function () use ($request, $filters) {
$this->readOnly();
$actor = $this->actor($request);
$out = fopen('php://output', 'w');
if ($out === false) {
throw new \RuntimeException('Não foi possível preparar a exportação.');
}
$text = static function (mixed $value): string {
$value = (string) ($value ?? '');
return preg_match('/^[\s\x00-\x1F]*[=+\-@]/u', $value) ? "'".$value : $value;
};
try {
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, ['Metadados de eventos da empresa', 'Consulta', now()->toIso8601String()], ';', '"', '');
fputcsv($out, ['Registo', 'Data', 'Acção', 'Utilizador (id)'], ';', '"', '');
foreach ($this->query($actor, $filters)->lazyById(100) as $event) {
fputcsv($out, array_map($text, [$event->id, $event->created_at, $event->action, $event->user_id]), ';', '"', '');
}
} finally {
fclose($out);
}
});
}, 'eventos-metadados-'.now()->format('Ymd-His').'.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store, max-age=0']);
}
private function readOnly(): void
{
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
}
}
@@ -6,10 +6,12 @@ use App\Models\CandidateAccount;
use App\Models\PeopleProcess;
use App\Models\Tenant;
use App\Models\User;
use App\Services\AdministrativeGeography;
use App\Services\CandidateAccess;
use App\Services\CandidateDocuments;
use App\Services\CandidateProfiles;
use App\Services\CandidateSubmissions;
use App\Services\CandidateTerritory;
use App\Services\CandidateTriage;
use App\Services\ConflictGuard;
use App\Services\CurrentActor;
@@ -17,6 +19,7 @@ use App\Services\OrganizationConfiguration;
use App\Services\RequisitionWorkflow;
use App\Services\SubmissionForms;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
@@ -117,12 +120,25 @@ class CandidatePortalController extends Controller
$scopedSelection = is_array($selection) && ($selection['company'] ?? null) === $company && ($selection['account'] ?? null) === $account->id && is_int($selection['id'] ?? null) && $selection['id'] > 0;
$selected = $this->rememberOpportunity($request, $account, $scopedSelection ? (int) $selection['id'] : null);
return $this->page($request, 'candidate/profile', ['companyId' => $company, 'email' => $account->email, 'profile' => $profiles->profile($account),
$profile = $profiles->profile($account);
return $this->page($request, 'candidate/profile', ['companyId' => $company, 'email' => $account->email, 'profile' => $profile,
'geographyEnabled' => app(AdministrativeGeography::class)->enabled(),
'availabilityGeography' => app(AdministrativeGeography::class)->describe($profile['data']['availability_reference'] ?? null),
'emailVerified' => $account->email_verified_at !== null, 'accessStatus' => $scopedSelection && $selected === null ? 'opportunity_unavailable' : $request->session()->get('candidate_access_status'), 'emailDeliveryAvailable' => app(CandidateAccess::class)->deliveryAvailable(),
'opportunities' => $opportunities->items(), 'opportunityPagination' => ['prev' => $opportunities->previousPageUrl(), 'next' => $opportunities->nextPageUrl()], 'selectedOpportunity' => $selected,
'submissions' => $receipts->items(), 'submissionPagination' => ['prev' => $receipts->previousPageUrl(), 'next' => $receipts->nextPageUrl(), 'total' => $receipts->total()]]);
}
public function geography(Request $request, int $company, AdministrativeGeography $geography): JsonResponse
{
abort_unless($this->account($request, $company) !== null, 401);
abort_unless($geography->enabled(), 503);
$data = $request->validate(['parent' => 'nullable|string|regex:/^[0-9]+$/D|max:20', 'page' => 'sometimes|integer|min:1|max:10000']);
return response()->json($geography->children($data['parent'] ?? null, (int) ($data['page'] ?? 1)), 200, ['Cache-Control' => 'private, no-store, max-age=0']);
}
public function save(Request $request, int $company, CandidateProfiles $profiles): Response
{
$account = $this->account($request, $company);
@@ -293,21 +309,42 @@ class CandidatePortalController extends Controller
$record = PeopleProcess::query()->where('tenant_id', $actor->tenant_id)->where('id', $process)->firstOrFail();
$triage = app(CandidateTriage::class);
abort_unless($triage->canRead($actor, $record), 403);
$filter = $request->validate(['submission' => 'nullable|integer|min:1']);
$rows = DB::table('candidate_submissions')->where('tenant_id', $actor->tenant_id)->where('people_process_id', $process)->orderBy('id')->paginate(20);
$filter = $request->validate(['submission' => 'nullable|integer|min:1', 'location' => 'nullable|string|regex:/^[0-9]+$/D|max:20', 'page' => 'sometimes|integer|min:1|max:1000000']);
$query = DB::table('candidate_submissions')->where('tenant_id', $actor->tenant_id)->where('people_process_id', $process);
if (isset($filter['submission'])) {
$rows = DB::table('candidate_submissions')->where('tenant_id', $actor->tenant_id)->where('people_process_id', $process)->where('id', $filter['submission'])->paginate(20)->withQueryString();
$query->where('id', $filter['submission']);
}
if (! empty($filter['location'])) {
app(CandidateTerritory::class)->apply($query, $filter['location']);
}
$rows = $query->orderBy('id')->paginate(20)->withQueryString();
$decisions = Schema::hasTable('candidate_triage') ? DB::table('candidate_triage')->where('tenant_id', $actor->tenant_id)->where('people_process_id', $process)->whereIn('candidate_submission_id', $rows->pluck('id'))->get(['candidate_submission_id', 'outcome', 'reason', 'public_message', 'created_at', 'people_application_id'])->keyBy('candidate_submission_id') : collect();
$rows->through(fn ($row) => ['id' => $row->id, 'reference' => $row->reference, 'received_at' => $row->created_at, 'snapshot' => json_decode($row->snapshot, true, 32, JSON_THROW_ON_ERROR), 'decision' => $decisions->get($row->id)]);
app(RequisitionWorkflow::class)->audit($actor, 'candidate_submissions.consulted', null, null, ['process_id' => $process]);
return $this->page($request, 'people/submissions', ['process' => ['id' => $record->id, 'title' => $record->title, 'version' => $record->version], 'publication' => $submissions->publication($record), 'submissions' => $rows,
'filters' => $filter, 'geographyEnabled' => app(AdministrativeGeography::class)->enabled() && in_array(DB::getDriverName(), ['sqlite', 'pgsql'], true),
'geographyFilter' => empty($filter['location']) ? null : app(AdministrativeGeography::class)->describe(['country' => 'MZ', 'catalogue' => config('geography.source_sha256'), 'record_id' => $filter['location']]),
'canPrepare' => $triage->canPrepare($actor, $record), 'canTriage' => Schema::hasTable('candidate_triage') && $triage->canPrepare($actor, $record) && in_array($record->status, ['open', 'assessment'], true),
'publicUrl' => '/candidaturas/'.$actor->tenant_id.'/oportunidades/'.$record->id]);
});
}
public function processGeography(Request $request, int $process, AdministrativeGeography $geography): JsonResponse
{
return DB::transaction(function () use ($request, $process, $geography) {
$actor = $this->staff($request);
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor);
$record = PeopleProcess::query()->where('tenant_id', $actor->tenant_id)->where('id', $process)->firstOrFail();
abort_unless(app(CandidateTriage::class)->canRead($actor, $record), 403);
abort_unless($geography->enabled(), 503);
$data = $request->validate(['parent' => 'nullable|string|regex:/^[0-9]+$/D|max:20', 'page' => 'sometimes|integer|min:1|max:10000']);
return response()->json($geography->children($data['parent'] ?? null, (int) ($data['page'] ?? 1)), 200, ['Cache-Control' => 'private, no-store, max-age=0']);
});
}
public function publish(Request $request, int $process, CandidateSubmissions $submissions): Response
{
$submissions->publish($this->staff($request), $process, $request->all());
+76 -9
View File
@@ -7,11 +7,17 @@ use App\Models\CriticalityLevel;
use App\Models\Department;
use App\Models\Equipment;
use App\Models\EquipmentSparePart;
use App\Models\Requisition;
use App\Models\SparePart;
use App\Models\StockItem;
use App\Models\StockMovement;
use App\Models\StockOperation;
use App\Models\User;
use App\Services\AdministrativeGeography;
use App\Services\ConflictGuard;
use App\Services\CurrentActor;
use App\Services\EquipmentWarehouseRequests;
use App\Services\OrganizationAccess;
use App\Services\ReceiptWorkflow;
use App\Services\RequisitionWorkflow;
use Brick\Math\BigDecimal;
@@ -26,17 +32,66 @@ use Inertia\Response;
class EquipmentController extends Controller
{
private function actor(Request $request): User
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
return $actor;
}
public function show(Request $request, int $equipment): \Symfony\Component\HttpFoundation\Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$actor = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($actor->id);
abort_unless($actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
$request->validate(['purchases_page' => 'nullable|integer|min:1|max:1000000', 'movements_page' => 'nullable|integer|min:1|max:1000000', 'requests_page' => 'nullable|integer|min:1|max:1000000']);
return DB::transaction(function () use ($request, $actor, $equipment) {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
$asset = Equipment::query()->when($actor->departmentScoped(), fn ($q) => $q->where('department_id', $actor->department_id))->findOrFail($equipment);
$purchases = Requisition::query()->visibleTo($actor)->whereHas('items', fn ($q) => $q->where('equipment_id', $asset->id))
->with(['items' => fn ($q) => $q->where('equipment_id', $asset->id)->select(['id', 'requisition_id', 'description', 'quantity', 'unit'])])
->orderBy('id')->paginate(15, ['id', 'number', 'status', 'needed_by'], 'purchases_page')->withQueryString();
$movements = StockOperation::query()->where('equipment_id', $asset->id)->orderBy('id')
->paginate(15, ['id', 'kind', 'reference', 'quantity_milli', 'recorded_at', 'snapshot'], 'movements_page')->withQueryString()
->through(fn (StockOperation $operation) => [
'id' => $operation->id, 'kind' => $operation->kind, 'reference' => $operation->reference,
'quantity_milli' => $operation->quantity_milli, 'recorded_at' => $operation->recorded_at,
'item' => $operation->snapshot['stock_item'] ?? null, 'warehouse' => $operation->snapshot['warehouse'] ?? null,
]);
Inertia::encryptHistory();
$response = Inertia::render('equipment/show', ['equipment' => $asset->only(['id', 'code', 'description', 'area', 'status']),
'geography' => app(AdministrativeGeography::class)->describe($asset->geography_reference),
'warehouseRequests' => app(EquipmentWarehouseRequests::class)->page($actor, $asset),
'purchases' => $purchases, 'movements' => $movements])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
});
}
public function index(Request $request): Response
{
abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
abort_unless($this->actor($request)->hasFunctions([PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
$filters = $request->validate(['location' => 'nullable|string|regex:/^[0-9]+$/D|max:20']);
$geography = app(AdministrativeGeography::class);
$assets = Equipment::query();
if ($request->user()->departmentScoped()) {
$assets->where('department_id', $request->user()->department_id);
if (! empty($filters['location'])) {
abort_unless($geography->availableFor('equipment'), 503);
$assets->where('geography_reference->catalogue', config('geography.source_sha256'))->whereIn('geography_reference->record_id', $geography->descendants($filters['location']));
}
if ($this->actor($request)->departmentScoped()) {
$assets->where('department_id', $this->actor($request)->department_id);
}
$equipment = $assets->orderBy('code')->get();
$links = EquipmentSparePart::query()->whereIn('equipment_id', $equipment->pluck('id'))->get();
$parts = SparePart::query();
if ($request->user()->departmentScoped()) {
if ($this->actor($request)->departmentScoped() || ! empty($filters['location'])) {
$parts->whereIn('id', $links->pluck('spare_part_id'));
}
$parts = $parts->get()->map(function (SparePart $part) {
@@ -47,24 +102,28 @@ class EquipmentController extends Controller
});
$items = StockItem::query();
$departments = Department::query();
if ($request->user()->departmentScoped()) {
if ($this->actor($request)->departmentScoped() || ! empty($filters['location'])) {
$items->whereIn('id', $parts->pluck('stock_item_id')->merge($equipment->pluck('stock_item_id')->filter()));
$departments->whereKey($request->user()->department_id);
if ($this->actor($request)->departmentScoped()) {
$departments->whereKey($this->actor($request)->department_id);
}
}
return Inertia::render('equipment/index', [
'geographyEnabled' => $geography->availableFor('equipment'), 'equipmentGeography' => $equipment->mapWithKeys(fn (Equipment $asset) => [$asset->id => $geography->describe($asset->geography_reference)])->all(),
'filters' => $filters, 'geographyFilter' => empty($filters['location']) ? null : $geography->describe(['catalogue' => config('geography.source_sha256'), 'record_id' => $filters['location'], 'country' => 'MZ']),
'equipment' => $equipment, 'parts' => $parts, 'links' => $links, 'levels' => CriticalityLevel::query()->orderBy('code')->get(),
'items' => $items->orderBy('code')->get(['id', 'code', 'name', 'unit']),
'departments' => $departments->orderBy('name')->get(['id', 'name']),
'history' => StockOperation::query()->whereNotNull('equipment_id')->whereIn('equipment_id', $equipment->pluck('id'))->latest('id')->paginate(15),
'canManage' => $request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement]),
'canConfigure' => $request->user()->hasFunctions([PurchasingRole::Admin]),
'canManage' => $this->actor($request)->hasFunctions([PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement]),
'canConfigure' => $this->actor($request)->hasFunctions([PurchasingRole::Admin]),
]);
}
public function store(Request $request, string $kind): RedirectResponse
{
$user = $request->user();
$user = $this->actor($request);
abort_unless(in_array($kind, ['categorias', 'activos', 'pecas', 'associacoes']), 404);
abort_unless($kind === 'categorias' ? $user->hasFunctions([PurchasingRole::Admin]) : $user->hasFunctions([PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement]), 403);
$tenant = $user->tenant_id;
@@ -76,9 +135,17 @@ class EquipmentController extends Controller
'pecas' => ['id' => ['nullable', 'integer', $exists('spare_parts')], 'version' => 'nullable|required_with:id|integer|min:1', 'stock_item_id' => ['required', 'integer', $exists('stock_items'), Rule::unique('spare_parts')->ignore($request->input('id'))], 'criticality_level_id' => ['required', 'integer', $exists('criticality_levels')], 'minimum_quantity' => ['required', 'regex:/^\d{1,9}(\.\d{1,3})?$/']],
'associacoes' => ['equipment_id' => ['required', 'integer', $exists('equipment')], 'spare_part_id' => ['required', 'integer', $exists('spare_parts')]],
};
if ($kind === 'activos') {
$rules += app(AdministrativeGeography::class)->referenceRules('equipment');
}
$data = $request->validate($rules);
DB::transaction(function () use ($data, $kind, $user) {
app(ConflictGuard::class)->lockCompany($user);
$user = app(CurrentActor::class)->lock($user, $kind === 'categorias' ? [PurchasingRole::Admin] : [PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement]);
abort_if(app(OrganizationAccess::class)->maintenanceOnly($user), 403);
if ($kind === 'activos' && array_key_exists('geography_reference', $data)) {
$data['geography_reference'] = app(AdministrativeGeography::class)->canonicalFor('equipment', $data['geography_reference']);
}
$model = match ($kind) {
'categorias' => new CriticalityLevel,'activos' => new Equipment,'pecas' => new SparePart,'associacoes' => new EquipmentSparePart
};
@@ -38,7 +38,7 @@ class GoodsReceiptController extends Controller
private function visibleOrders(Request $request): Builder
{
Gate::authorize('viewAny', GoodsReceipt::class);
$orders = PurchaseOrder::query();
$orders = PurchaseOrder::query()->operational();
if ($this->actor($request)->departmentScoped()) {
$orders->whereIn('requisition_id', Requisition::query()->visibleTo($this->actor($request))->select('id'));
}
+1 -1
View File
@@ -33,7 +33,7 @@ class InvoiceController extends Controller
$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]),
'orders' => PurchaseOrder::query()->visibleTo($actor)->operational()->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');
@@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers;
use App\Models\OrderCancellation;
use App\Models\OrderCancellationPolicy;
use App\Models\PurchaseOrder;
use App\Models\Requisition;
use App\Models\User;
use App\Services\OrderCancellationPolicyWorkflow;
use App\Services\OrderCancellationWorkflow;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class OrderCancellationController extends Controller
{
private function actor(Request $request): User
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
abort_unless(config('order_cancellation.enabled') && Schema::hasTable('order_cancellations'), 503, 'Cancelamento de ordens não activado.');
return $actor;
}
public function index(Request $request, OrderCancellationWorkflow $workflow): Response
{
$actor = $this->actor($request);
$focus = $request->validate(['cancellation_id' => 'nullable|integer|min:1'])['cancellation_id'] ?? null;
abort_unless($actor->hasFunctions(['admin', 'procurement', 'finance', 'requester', 'department_head']), 403);
return DB::transaction(function () use ($request, $actor, $workflow, $focus) {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
Inertia::encryptHistory();
$records = OrderCancellation::query()->whereIn('purchase_order_id', PurchaseOrder::query()->visibleTo($actor)->select('id'))
->when($focus !== null, fn ($q) => $q->whereKey($focus))
->orderByDesc('id')->paginate(15)->withQueryString()->through(function ($record) use ($actor, $workflow) {
$order = PurchaseOrder::query()->findOrFail($record->purchase_order_id);
$source = Requisition::query()->findOrFail($order->requisition_id);
return [...$record->only(['id', 'purchase_order_id', 'created_by', 'reason', 'evidence', 'status', 'review_reason', 'reviewed_at', 'revised_requisition_id']),
'order_number' => $order->number, 'canDecide' => $workflow->canDecide($actor, $record),
'source_href' => $actor->hasFunctions(['admin', 'procurement', 'finance', 'department_head']) ? '/ordens/'.$order->id : '/requisicoes/'.$source->id.'/processo',
'canRevise' => $workflow->canRevise($actor, $record)];
});
$response = Inertia::render('purchase-controls/orders', ['records' => $records,
'policies' => $actor->hasFunctions(['admin']) ? OrderCancellationPolicy::query()->latest('revision')->get()->map(fn ($p) => [...$p->toArray(),
'canReview' => app(OrderCancellationPolicyWorkflow::class)->canReview($actor, $p)]) : [],
'canAdminister' => $actor->hasFunctions(['admin']), 'canDecide' => $actor->hasFunctions(['finance'])])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
});
}
public function request(Request $request, PurchaseOrder $order, OrderCancellationWorkflow $workflow): Response
{
$workflow->request($this->actor($request), $order, $request->all());
return redirect('/ordens/cancelamentos');
}
public function decide(Request $request, int $cancellation, OrderCancellationWorkflow $workflow): Response
{
$actor = $this->actor($request);
$data = $request->validate(['decision' => 'required|in:approved,rejected', 'reason' => 'required|string|min:10|max:4000']);
$workflow->decide($actor, OrderCancellation::query()->findOrFail($cancellation), $data['decision'], $data['reason']);
return back();
}
public function revise(Request $request, int $cancellation, OrderCancellationWorkflow $workflow): Response
{
$actor = $this->actor($request);
$data = $request->validate(['needed_by' => 'required|date_format:Y-m-d|after_or_equal:today', 'reason' => 'required|string|min:10|max:4000']);
$record = $workflow->revise($actor, OrderCancellation::query()->findOrFail($cancellation), $data['needed_by'], $data['reason']);
return redirect('/requisicoes/'.$record->id);
}
public function policy(Request $request, OrderCancellationPolicyWorkflow $workflow): Response
{
$workflow->propose($this->actor($request), $request->all());
return back();
}
public function policyReview(Request $request, int $policy, OrderCancellationPolicyWorkflow $workflow): Response
{
$actor = $this->actor($request);
$data = $request->validate(['decision' => 'required|in:approved,rejected', 'reason' => 'required|string|min:10|max:4000']);
$workflow->review($actor, OrderCancellationPolicy::query()->findOrFail($policy), $data['decision'], $data['reason']);
return back();
}
}
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Models\Tenant;
use App\Models\User;
use App\Services\AdministrativeGeography;
use App\Services\AiPreparationPlan;
use App\Services\ComparisonPolicy;
use App\Services\IntelligencePolicy;
@@ -37,6 +38,8 @@ class OrganizationConfigurationController extends Controller
$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),
'geographyEnabled' => app(AdministrativeGeography::class)->enabled(),
'geography' => app(AdministrativeGeography::class)->describe(Tenant::query()->findOrFail($actor->tenant_id)->geography_reference),
'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)),
+23 -15
View File
@@ -15,9 +15,11 @@ use App\Services\PeopleEvidenceBrief;
use App\Services\PeopleForms;
use App\Services\PeopleHiring;
use App\Services\PeopleOperationalInsights;
use App\Services\PeopleProcessProjection;
use App\Services\PeopleRegistry;
use App\Services\PeopleWorkdesk;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
@@ -89,21 +91,9 @@ class PeopleHiringController extends Controller
$access = app(PeopleAccess::class);
abort_unless($access->can($this->actor($request), $record, 'view'), 403);
$registrySearch = $request->validate(['cadastro' => 'nullable|string|max:100'])['cadastro'] ?? '';
$snapshot = $workflow->snapshot($record);
$rows = $workflow->matrix($record);
if (! $access->can($this->actor($request), $record, 'financial')) {
unset($snapshot['rules']['annex']['fields']['financial']);
unset($snapshot['selection']['rules']['annex']['fields']['financial']);
}
if (! $access->can($this->actor($request), $record, 'interviews')) {
$rows = array_map(fn ($row) => [...$row, 'reviews' => []], $rows);
if (isset($snapshot['selection']['rows'])) {
$snapshot['selection']['rows'] = array_map(fn ($row) => [...$row, 'reviews' => []], $snapshot['selection']['rows']);
}
}
if (! $access->can($this->actor($request), $record, 'contract') && ! $access->can($this->actor($request), $record, 'contract_view')) {
$snapshot['contract'] = null;
}
$projection = app(PeopleProcessProjection::class)->read($this->actor($request), $record);
$snapshot = $projection['process'];
$rows = $projection['candidates'];
$clarifications = app(PeopleClarifications::class)->page($this->actor($request), $record);
$company = Tenant::query()->findOrFail($this->actor($request)->tenant_id);
$policy = app(IntelligencePolicy::class)->company($company);
@@ -127,6 +117,24 @@ class PeopleHiringController extends Controller
});
}
public function dossier(Request $request, int $process, PeopleProcessProjection $projection): Response
{
return DB::transaction(function () use ($request, $process, $projection) {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
$record = PeopleProcess::query()->findOrFail($process);
$data = $projection->read($this->actor($request), $record);
return response()->json(['schema' => 'sgc.people.process-dossier.v1', 'exported_at' => now()->toIso8601String(),
'effect' => 'preparation_only',
'process' => Arr::only($data['process'], ['id', 'kind', 'title', 'purpose', 'closes_on', 'status', 'version', 'selected_application_id', 'rules', 'selection', 'contract']),
'candidates' => $data['candidates']])
->header('Content-Disposition', 'attachment; filename="PES-'.$record->id.'-dossier-v'.$record->version.'.json"')
->header('Cache-Control', 'private, no-store, max-age=0')->header('X-Content-Type-Options', 'nosniff');
});
}
public function access(Request $request, int $process, PeopleAccess $access): Response
{
$record = PeopleProcess::query()->findOrFail($process);
+7 -1
View File
@@ -11,12 +11,14 @@ use App\Models\RecommendationAttempt;
use App\Models\Requisition;
use App\Models\User;
use App\Services\FormalRecommendation;
use App\Services\OrderCancellationWorkflow;
use App\Services\OrganizationAccess;
use App\Services\ProposalComparison;
use App\Services\PurchaseWorkflow;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
@@ -117,6 +119,9 @@ class PurchaseController extends Controller
return Inertia::render('purchases/show', [
'order' => $order->load('items'),
'orderCancellation' => ['enabled' => (bool) config('order_cancellation.enabled') && Schema::hasTable('order_cancellations'),
'state' => app(OrderCancellationWorkflow::class)->state($order),
'canRequest' => app(OrderCancellationWorkflow::class)->canRequest($actor, $order)],
'history' => AuditLog::query()->where('requisition_id', $order->requisition_id)->whereIn('action', ['award.created', 'award.approved', 'award.rejected', 'purchase_order.issued'])->orderBy('id')->get(['id', 'user_id', 'action', 'created_at']),
]);
}
@@ -126,6 +131,7 @@ class PurchaseController extends Controller
$actor = $this->access($request);
PurchaseOrder::query()->visibleTo($actor)->findOrFail($order->id);
return view('purchases.print', ['order' => $order->load('items'), 'snapshot' => $order->snapshot]);
return view('purchases.print', ['order' => $order->load('items'), 'snapshot' => $order->snapshot,
'operationalState' => app(OrderCancellationWorkflow::class)->state($order)]);
}
}
+74 -10
View File
@@ -5,24 +5,39 @@ namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\StockItem;
use App\Models\StockMovement;
use App\Models\User;
use App\Models\Warehouse;
use App\Services\AdministrativeGeography;
use App\Services\ConflictGuard;
use App\Services\CurrentActor;
use App\Services\InventoryLots;
use App\Services\OrganizationAccess;
use App\Services\RequisitionWorkflow;
use Brick\Math\BigDecimal;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class StockController extends Controller
{
private function actor(Request $request): User
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
return $actor;
}
private function access(Request $request, bool $manage = false): void
{
$roles = $manage ? [PurchasingRole::Admin, PurchasingRole::Warehouse] : [PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Finance];
abort_unless($request->user()->hasFunctions($roles), 403);
abort_unless($this->actor($request)->hasFunctions($roles), 403);
}
public function index(Request $request): Response
@@ -30,15 +45,30 @@ class StockController extends Controller
$this->access($request);
$filters = $request->validate([
'search' => 'nullable|string|max:100',
'warehouse_id' => ['nullable', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $request->user()->tenant_id)],
'location' => 'nullable|string|regex:/^[0-9]+$/D|max:20',
'warehouse_id' => ['nullable', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $this->actor($request)->tenant_id)],
]);
$items = StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']);
$warehouses = Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']);
$geography = app(AdministrativeGeography::class);
$warehouseQuery = Warehouse::query();
if (! empty($filters['location'])) {
abort_unless($geography->availableFor('warehouses'), 503);
$warehouseQuery->where('geography_reference->catalogue', config('geography.source_sha256'))->whereIn('geography_reference->record_id', $geography->descendants($filters['location']));
}
$columns = ['id', 'code', 'name'];
if (Schema::hasColumn('warehouses', 'geography_reference')) {
$columns[] = 'geography_reference';
}
$warehouses = $warehouseQuery->orderBy('code')->get($columns);
$warehouseGeography = $warehouses->mapWithKeys(fn (Warehouse $warehouse) => [$warehouse->id => $geography->describe($warehouse->geography_reference)])->all();
$selected = StockItem::query();
if (! empty($filters['search'])) {
$selected->where(fn ($q) => $q->where('code', 'like', '%'.$filters['search'].'%')->orWhere('name', 'like', '%'.$filters['search'].'%'));
}
$movements = StockMovement::query()->whereIn('stock_item_id', $selected->select('id'));
if (! empty($filters['location'])) {
$movements->whereIn('warehouse_id', $warehouses->pluck('id'));
}
if (! empty($filters['warehouse_id'])) {
$movements->where('warehouse_id', $filters['warehouse_id']);
}
@@ -50,8 +80,10 @@ class StockController extends Controller
return Inertia::render('stock/index', [
'items' => $items, 'warehouses' => $warehouses, 'balances' => $balances,
'geographyEnabled' => $geography->availableFor('warehouses'), 'warehouseGeography' => $warehouseGeography,
'geographyFilter' => empty($filters['location']) ? null : $geography->describe(['catalogue' => config('geography.source_sha256'), 'record_id' => $filters['location'], 'country' => 'MZ']),
'movements' => $movements->latest('id')->paginate(20)->withQueryString(), 'filters' => $filters,
'canManage' => $request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Warehouse]),
'canManage' => $this->actor($request)->hasFunctions([PurchasingRole::Admin, PurchasingRole::Warehouse]),
]);
}
@@ -60,12 +92,12 @@ class StockController extends Controller
$this->access($request, true);
$request->merge(['code' => Str::upper(trim($request->string('code')->toString())), 'unit' => Str::lower(trim($request->string('unit')->toString()))]);
$data = $request->validate([
'code' => ['required', 'regex:/^[A-Z0-9][A-Z0-9._-]{0,29}$/', Rule::unique('stock_items')->where('tenant_id', $request->user()->tenant_id)],
'code' => ['required', 'regex:/^[A-Z0-9][A-Z0-9._-]{0,29}$/', Rule::unique('stock_items')->where('tenant_id', $this->actor($request)->tenant_id)],
'name' => 'required|string|max:200', 'unit' => 'required|string|max:30',
]);
DB::transaction(function () use ($request, $audit, $data) {
$item = StockItem::query()->create([...$data, 'tenant_id' => $request->user()->tenant_id, 'created_by' => $request->user()->id]);
$audit->audit($request->user(), 'stock.item_created', null, null, $item->toArray());
$item = StockItem::query()->create([...$data, 'tenant_id' => $this->actor($request)->tenant_id, 'created_by' => $this->actor($request)->id]);
$audit->audit($this->actor($request), 'stock.item_created', null, null, $item->toArray());
});
return back();
@@ -76,12 +108,44 @@ class StockController extends Controller
$this->access($request, true);
$request->merge(['code' => Str::upper(trim($request->string('code')->toString()))]);
$data = $request->validate([
'code' => ['required', 'regex:/^[A-Z0-9][A-Z0-9._-]{0,29}$/', Rule::unique('warehouses')->where('tenant_id', $request->user()->tenant_id)],
'code' => ['required', 'regex:/^[A-Z0-9][A-Z0-9._-]{0,29}$/', Rule::unique('warehouses')->where('tenant_id', $this->actor($request)->tenant_id)],
'name' => 'required|string|max:200',
...app(AdministrativeGeography::class)->referenceRules('warehouses'),
]);
DB::transaction(function () use ($request, $audit, $data) {
$warehouse = Warehouse::query()->create([...$data, 'tenant_id' => $request->user()->tenant_id, 'created_by' => $request->user()->id]);
$audit->audit($request->user(), 'stock.warehouse_created', null, null, $warehouse->toArray());
app(ConflictGuard::class)->lockCompany($this->actor($request));
$actor = app(CurrentActor::class)->lock($this->actor($request), [PurchasingRole::Admin, PurchasingRole::Warehouse]);
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
if (array_key_exists('geography_reference', $data)) {
$data['geography_reference'] = app(AdministrativeGeography::class)->canonicalFor('warehouses', $data['geography_reference']);
}
$warehouse = Warehouse::query()->create([...$data, 'tenant_id' => $this->actor($request)->tenant_id, 'created_by' => $this->actor($request)->id]);
$audit->audit($this->actor($request), 'stock.warehouse_created', null, null, $warehouse->toArray());
});
return back();
}
public function warehouseGeography(Request $request, int $warehouse, RequisitionWorkflow $audit): RedirectResponse
{
$this->access($request, true);
$geography = app(AdministrativeGeography::class);
abort_unless($geography->availableFor('warehouses'), 503);
$data = $request->validate([...$geography->referenceRules('warehouses'), 'geography_reference' => 'present|nullable|array:country,catalogue,record_id', 'expected_reference' => 'present|nullable|array:country,catalogue,record_id']);
DB::transaction(function () use ($request, $warehouse, $audit, $data, $geography) {
app(ConflictGuard::class)->lockCompany($this->actor($request));
$actor = app(CurrentActor::class)->lock($this->actor($request), [PurchasingRole::Admin, PurchasingRole::Warehouse]);
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
$record = Warehouse::query()->lockForUpdate()->findOrFail($warehouse);
if ($record->geography_reference != $data['expected_reference']) {
throw ValidationException::withMessages(['geography_reference' => 'A localização mudou. Actualize antes de guardar.']);
}
$before = $record->toArray();
$after = $geography->canonicalFor('warehouses', $data['geography_reference']);
if ($record->geography_reference != $after) {
$record->update(['geography_reference' => $after]);
$audit->audit($actor, 'stock.warehouse_geography_updated', null, $before, $record->toArray());
}
});
return back();
+15 -3
View File
@@ -6,9 +6,11 @@ use App\Enums\PurchasingRole;
use App\Http\Requests\SaveSupplierRequest;
use App\Models\Supplier;
use App\Models\User;
use App\Services\AdministrativeGeography;
use App\Services\ConflictGuard;
use App\Services\CurrentActor;
use App\Services\RequisitionWorkflow;
use App\Services\SupplierGeography;
use App\Services\SupplierParticipation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -41,13 +43,16 @@ class SupplierController extends Controller
public function index(Request $request): Response
{
abort_unless($this->actor($request)->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
$filters = $request->validate(['search' => 'nullable|string|max:100']);
$filters = $request->validate(['search' => 'nullable|string|max:100', 'location' => 'nullable|string|regex:/^[0-9]+$/D|max:20']);
$query = Supplier::query();
if (! empty($filters['location'])) {
$query = app(SupplierGeography::class)->matching($filters['location']);
}
if (! empty($filters['search'])) {
$query->where(fn ($q) => $q->where('name', 'like', '%'.$filters['search'].'%')->orWhere('nuit', 'like', '%'.$filters['search'].'%'));
}
return Inertia::render('suppliers/index', ['suppliers' => $query->orderBy('name')->paginate(15)->withQueryString(), 'filters' => $filters, 'canCreate' => app(SupplierParticipation::class)->canPrepare($this->actor($request)), 'canReview' => app(SupplierParticipation::class)->canReviewRole($this->actor($request))]);
return Inertia::render('suppliers/index', ['suppliers' => $query->orderBy('name')->paginate(15)->withQueryString(), 'filters' => $filters, 'geographyFilter' => empty($filters['location']) ? null : app(AdministrativeGeography::class)->describe(['catalogue' => config('geography.source_sha256'), 'record_id' => $filters['location'], 'country' => 'MZ']), 'geographyEnabled' => app(AdministrativeGeography::class)->enabled(), 'canCreate' => app(SupplierParticipation::class)->canPrepare($this->actor($request)), 'canReview' => app(SupplierParticipation::class)->canReviewRole($this->actor($request))]);
}
public function store(SaveSupplierRequest $request, RequisitionWorkflow $workflow): RedirectResponse
@@ -56,6 +61,9 @@ class SupplierController extends Controller
DB::transaction(function () use ($request, $data, $workflow) {
$actor = $this->currentActor($request, PurchasingRole::Procurement);
abort_unless(app(SupplierParticipation::class)->canPrepare($actor), 403);
if (array_key_exists('geography_reference', $data)) {
$data['geography_reference'] = app(AdministrativeGeography::class)->canonical($data['geography_reference']);
}
$supplier = Supplier::query()->create([...$data, 'tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'last_changed_by' => $actor->id, 'status' => 'pending']);
$workflow->audit($actor, 'supplier.created', null, null, $supplier->toArray());
});
@@ -71,6 +79,7 @@ class SupplierController extends Controller
Inertia::encryptHistory();
$response = Inertia::render('suppliers/show', [
'supplier' => $supplier,
'geography' => app(AdministrativeGeography::class)->describe($supplier->geography_reference),
'documents' => $canDocuments ? $supplier->documents()->latest('id')->get() : [],
'canEdit' => $this->actor($request)->can('update', $supplier),
'canDocuments' => $canDocuments,
@@ -85,7 +94,7 @@ class SupplierController extends Controller
{
Gate::authorize('update', $supplier);
return Inertia::render('suppliers/edit', ['supplier' => $supplier]);
return Inertia::render('suppliers/edit', ['supplier' => $supplier, 'geographyEnabled' => app(AdministrativeGeography::class)->enabled(), 'geography' => app(AdministrativeGeography::class)->describe($supplier->geography_reference)]);
}
public function update(SaveSupplierRequest $request, Supplier $supplier, RequisitionWorkflow $workflow): RedirectResponse
@@ -95,6 +104,9 @@ class SupplierController extends Controller
DB::transaction(function () use ($request, $supplier, $workflow, $data) {
$actor = $this->currentActor($request, PurchasingRole::Procurement);
abort_unless(app(SupplierParticipation::class)->canPrepare($actor), 403);
if (array_key_exists('geography_reference', $data)) {
$data['geography_reference'] = app(AdministrativeGeography::class)->canonical($data['geography_reference']);
}
$supplier = Supplier::query()->lockForUpdate()->findOrFail($supplier->id);
if ($supplier->version !== (int) $data['version']) {
throw ValidationException::withMessages(['version' => 'A ficha foi alterada por outro utilizador. Reabra a edição para carregar os dados actuais.']);
@@ -5,7 +5,9 @@ namespace App\Http\Controllers;
use App\Models\SupplierEvaluation;
use App\Models\SupplierImprovement;
use App\Models\User;
use App\Services\AdministrativeGeography;
use App\Services\SupplierEvaluationWorkflow;
use App\Services\SupplierGeography;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -16,6 +18,22 @@ use Symfony\Component\HttpFoundation\Response;
class SupplierImprovementReportController extends Controller
{
private function actor(Request $request): User
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
return $actor;
}
/** @param array<string,mixed> $filters
* @return array<string,mixed>|null
*/
private function territory(array $filters): ?array
{
return empty($filters['location']) ? null : app(AdministrativeGeography::class)->describe(['country' => 'MZ', 'catalogue' => config('geography.source_sha256'), 'record_id' => $filters['location']]);
}
private function snapshot(\Closure $read): mixed
{
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() !== 0) {
@@ -34,12 +52,12 @@ class SupplierImprovementReportController extends Controller
/** @return array<string,mixed> */
private function filters(Request $request): array
{
$actor = app(SupplierEvaluationWorkflow::class)->reader($request->user());
$actor = app(SupplierEvaluationWorkflow::class)->reader($this->actor($request));
$request->setUserResolver(fn () => $actor);
abort_unless(Schema::hasTable('supplier_improvements'), 503, 'A instalação das medidas de melhoria ainda não está concluída.');
$filters = $request->validate(['supplier_id' => 'nullable|integer|min:1', 'responsible_id' => 'nullable|integer|min:1',
'status' => 'nullable|in:open,review,returned,closed', 'from' => 'nullable|date_format:Y-m-d', 'to' => 'nullable|date_format:Y-m-d',
'overdue' => 'nullable|in:1', 'search' => 'nullable|string|max:200']);
'overdue' => 'nullable|in:1', 'search' => 'nullable|string|max:200', 'location' => 'nullable|string|regex:/^[0-9]+$/D|max:20']);
if (! empty($filters['from']) && ! empty($filters['to']) && $filters['to'] < $filters['from']) {
throw ValidationException::withMessages(['to' => 'O fim do intervalo deve ser igual ou posterior ao início.']);
}
@@ -52,7 +70,11 @@ class SupplierImprovementReportController extends Controller
*/
private function query(Request $request, array $filters, string $today): Builder
{
$query = SupplierImprovement::query()->visibleTo($request->user());
$query = SupplierImprovement::query()->visibleTo($this->actor($request));
if (! empty($filters['location'])) {
$suppliers = app(SupplierGeography::class)->matching($filters['location'])->select('id');
$query->whereHas('evaluation', fn ($evaluation) => $evaluation->whereIn('supplier_id', $suppliers));
}
if (! empty($filters['supplier_id'])) {
$query->whereHas('evaluation', fn ($q) => $q->where('supplier_id', $filters['supplier_id']));
}
@@ -81,7 +103,7 @@ class SupplierImprovementReportController extends Controller
*/
private function records(Builder $query, Request $request): Builder
{
return $query->with(['evaluation:id,supplier_id,supplier_snapshot,revision', 'responsible' => fn ($q) => $q->where('tenant_id', $request->user()->tenant_id)->select(['id', 'name'])]);
return $query->with(['evaluation:id,supplier_id,supplier_snapshot,revision', 'responsible' => fn ($q) => $q->where('tenant_id', $this->actor($request)->tenant_id)->select(['id', 'name'])]);
}
/** @return array<string,mixed> */
@@ -99,17 +121,17 @@ class SupplierImprovementReportController extends Controller
$props = $this->snapshot(function () use ($request): array {
$filters = $this->filters($request);
$today = today()->toDateString();
$all = SupplierImprovement::query()->visibleTo($request->user());
$all = SupplierImprovement::query()->visibleTo($this->actor($request));
$query = $this->query($request, $filters, $today);
$counts = (clone $query)->selectRaw('status, COUNT(*) AS count')->groupBy('status')->pluck('count', 'status')->map(fn ($n) => (int) $n);
$overdue = (clone $query)->where('status', '!=', 'closed')->where('due_on', '<', $today)->count();
// Choices derive only from visible measures, including prior revisions and former assignees.
$evaluations = SupplierEvaluation::query()->visibleTo($request->user())->whereIn('id', (clone $all)->select('supplier_evaluation_id'))->orderByDesc('id')->get(['supplier_id', 'supplier_snapshot']);
$evaluations = SupplierEvaluation::query()->visibleTo($this->actor($request))->whereIn('id', (clone $all)->select('supplier_evaluation_id'))->orderByDesc('id')->get(['supplier_id', 'supplier_snapshot']);
return ['filters' => $filters, 'today' => $today, 'generatedAt' => now()->toIso8601String(), 'counts' => $counts, 'overdue' => $overdue,
return ['filters' => $filters, 'geographyEnabled' => app(AdministrativeGeography::class)->enabled(), 'geographyFilter' => $this->territory($filters), 'today' => $today, 'generatedAt' => now()->toIso8601String(), 'counts' => $counts, 'overdue' => $overdue,
'records' => $this->records($query, $request)->orderBy('due_on')->orderBy('id')->paginate(25)->withQueryString()->through(fn (SupplierImprovement $m) => $this->row($m, $today)),
'suppliers' => $evaluations->map(fn (SupplierEvaluation $e) => ['id' => $e->supplier_id, 'name' => $e->supplier_snapshot['name']])->unique('id')->values(),
'responsibles' => User::query()->where('tenant_id', $request->user()->tenant_id)->whereIn('id', (clone $all)->select('responsible_id'))->orderBy('name')->get(['id', 'name'])];
'responsibles' => User::query()->where('tenant_id', $this->actor($request)->tenant_id)->whereIn('id', (clone $all)->select('responsible_id'))->orderBy('name')->get(['id', 'name'])];
});
Inertia::encryptHistory();
$response = Inertia::render('reports/supplier-improvements', $props)->toResponse($request);
@@ -121,6 +143,9 @@ class SupplierImprovementReportController extends Controller
public function csv(Request $request): Response
{
$filters = $this->filters($request);
if (! empty($filters['location'])) {
app(SupplierGeography::class)->matching($filters['location']);
}
$text = fn (?string $value) => preg_match('/^[\s]*[=+\-@]|^[\t\r\n]/u', $value ?? '') ? "'".$value : ($value ?? '');
return response()->streamDownload(fn () => $this->snapshot(function () use ($request, $filters, $text) {
@@ -133,7 +158,12 @@ class SupplierImprovementReportController extends Controller
}
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, ['Medidas de melhoria de fornecedores', 'Consulta', now()->toIso8601String(), 'Data de referência', $today], ';', '"', '');
fputcsv($out, ['Filtros', $text(json_encode($filters, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR))], ';', '"', '');
$exportFilters = $filters;
if (! empty($filters['location'])) {
$territory = $this->territory($filters);
$exportFilters['localizacao_actual_da_ficha'] = implode(' / ', array_column($territory['path'] ?? [], 'name'));
}
fputcsv($out, ['Filtros', $text(json_encode($exportFilters, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR))], ';', '"', '');
fputcsv($out, ['Medida', 'Fornecedor preservado', 'Avaliação', 'Revisão', 'Título', 'Objectivo', 'Responsável', 'Prazo', 'Estado', 'Prazo ultrapassado', 'Versão', 'Resultado', 'Evidência', 'Última conferência', 'Conferida em', 'Avaliação e acompanhamento', 'Relatório individual'], ';', '"', '');
foreach ($this->records($this->query($request, $filters, $today), $request)->lazyById(200) as $m) {
$row = $this->row($m, $today);
@@ -8,6 +8,7 @@ use App\Models\StockItem;
use App\Models\User;
use App\Models\Warehouse;
use App\Models\WarehouseRequest;
use App\Models\WarehouseRequestEvent;
use App\Services\InternalSupplyTrace;
use App\Services\OrganizationAccess;
use App\Services\WarehouseFulfilment;
@@ -22,6 +23,12 @@ use Symfony\Component\HttpFoundation\Response;
class WarehouseRequestController extends Controller
{
/** @return array<string,mixed> */
private function filters(Request $request, User $actor): array
{
return $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']);
}
public function index(Request $request, WarehouseWorkdesk $desk): Response
{
$actor = $request->user('web');
@@ -31,21 +38,10 @@ class WarehouseRequestController extends Controller
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']);
$filters = $this->filters($request, $actor);
$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.'%'));
}
}
$base = $desk->filteredBase($actor, $filters);
$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') : [];
@@ -70,6 +66,49 @@ class WarehouseRequestController extends Controller
});
}
public function csv(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);
$filters = $this->filters($request, $actor);
abort_unless(Schema::hasTable('warehouse_requests'), 503);
return response()->streamDownload(function () use ($actor, $filters, $desk) {
DB::transaction(function () use ($actor, $filters, $desk) {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
$actor = User::query()->findOrFail($actor->id);
abort_unless($actor->hasFunctions(['admin', 'warehouse_request_read', 'warehouse_request_prepare', 'warehouse_request_review', 'warehouse_request_deliver']), 403);
$query = $desk->forView($desk->filteredBase($actor, $filters), $actor, $filters['view'] ?? 'all');
if (($filters['state'] ?? 'all') !== 'all') {
$query->whereRaw($desk->stateExpression().' = ?', [$filters['state']]);
}
$out = fopen('php://output', 'w');
if ($out === false) {
throw new \RuntimeException('Não foi possível gerar o ficheiro.');
}
$safe = fn (string $value): string => preg_match('/^[\s]*[=+\-@]|^[\t\r\n]/u', $value) ? "'".$value : $value;
try {
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, ['Requisições internas', now()->toIso8601String(), 'Sem factura; entregas acumuladas não descontam devoluções ou correcções.'], ';', '"', '');
fputcsv($out, ['Filtros', json_encode(array_diff_key($filters, ['page' => true]), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)], ';', '"', '');
fputcsv($out, ['Requisição', 'Estado', 'Versão', 'Requisitante', 'Armazém', 'Centro de custo', 'Destino', 'Finalidade', 'Linha', 'Artigo', 'Descrição', 'Unidade', 'Utilização', 'Equipamento', 'Pedida', 'Reservada agora', 'Entregue acumulada', 'Por entregar', 'Cancelada', 'Reservas disponíveis', 'Consulta'], ';', '"', '');
foreach ($query->lazyById(100) as $record) {
$p = app(WarehouseFulfilment::class)->progress($record);
$s = $record->snapshot;
foreach ($s['lines'] as $index => $line) {
$row = ['RA-'.$record->id, $p['state'], (string) $p['version'], $s['requester']['name'], $s['warehouse']['code'], $s['cost_center']['code'], $s['destination'], $s['reason'], (string) ($index + 1), $line['item']['code'], $line['item']['name'], $line['item']['unit'], $line['destination'], $line['equipment']['code'] ?? '', ...array_map(fn ($quantity) => number_format($quantity / 1000, 3, '.', ''), [$line['quantity_milli'], $p['reserved'][$index], $p['delivered'][$index], $p['remaining'][$index], $p['cancelled'][$index]]), $p['reservation_ready'] ? 'Sim' : 'Não', url('/stock/requisicoes/'.$record->id)];
fputcsv($out, array_map($safe, $row), ';', '"', '');
}
}
} finally {
fclose($out);
}
});
}, 'requisicoes-internas-'.now()->format('Ymd-His').'.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store, max-age=0']);
}
public function store(Request $request, WarehouseRequests $service): Response
{
$actor = $request->user('web');
@@ -94,7 +133,7 @@ class WarehouseRequestController extends Controller
$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),
'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');
@@ -111,4 +150,44 @@ class WarehouseRequestController extends Controller
return redirect('/stock/requisicoes/'.$id);
}
public function printRequest(Request $request, int $id, WarehouseFulfilment $service): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
return DB::transaction(function () use ($actor, $id, $service) {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
$record = WarehouseRequest::query()->findOrFail($id);
abort_unless(app(WarehouseWorkdesk::class)->canView($actor, $record), 403);
return response()->view('stock.request-print', ['record' => $record, 'progress' => $service->progress($record), 'generatedAt' => now()])
->header('Cache-Control', 'private, no-store, max-age=0')->header('X-Content-Type-Options', 'nosniff');
});
}
public function printDelivery(Request $request, int $id, int $delivery): Response
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
return DB::transaction(function () use ($actor, $id, $delivery) {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
$record = WarehouseRequest::query()->findOrFail($id);
abort_unless(app(WarehouseWorkdesk::class)->canView($actor, $record), 403);
abort_unless(Schema::hasTable('warehouse_request_events'), 503, 'Registo de entregas indisponível.');
$event = WarehouseRequestEvent::query()->where('warehouse_request_id', $record->id)->where('action', 'deliver')->findOrFail($delivery);
$confirmation = WarehouseRequestEvent::query()->where('warehouse_request_id', $record->id)->where('action', 'receive')
->where('data->delivery_event_id', $event->id)->orderBy('version')->first();
$authorization = WarehouseRequestEvent::query()->where('warehouse_request_id', $record->id)->where('action', 'authorize')
->where('version', '<', $event->version)->orderByDesc('version')->first();
return response()->view('stock.delivery-print', ['record' => $record, 'delivery' => $event, 'confirmation' => $confirmation, 'authorization' => $authorization])
->header('Cache-Control', 'private, no-store, max-age=0')->header('X-Content-Type-Options', 'nosniff');
});
}
}