@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\OperationalHealth;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class OperationsCheck extends Command
|
||||
{
|
||||
protected $signature = 'sgc:operations-check {--json : Resultado agregado para monitorização} {--document-limit=1000 : Máximo de documentos e ficheiros a inspeccionar, de 1 a 10000}';
|
||||
|
||||
protected $description = 'Consultar reposição e integridade documental sem processar compras, corrigir ficheiros ou alterar configurações';
|
||||
|
||||
public function handle(OperationalHealth $health): int
|
||||
{
|
||||
$limit = filter_var($this->option('document-limit'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 10000]]);
|
||||
if ($limit === false) {
|
||||
$this->error('O limite deve ser um inteiro entre 1 e 10000.');
|
||||
|
||||
return self::INVALID;
|
||||
}
|
||||
$result = $health->inspect($limit);
|
||||
if ($this->option('json')) {
|
||||
$this->line(json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
} else {
|
||||
$this->line($result['ok'] ? 'Operação: verificações concluídas sem divergências observadas.' : 'Operação: verificações requerem atenção.');
|
||||
$labels = ['replenishment' => 'Reposição', 'receipt_documents' => 'Documentos da recepção'];
|
||||
$states = ['passed' => 'sem divergências observadas', 'attention' => 'requer atenção', 'incomplete' => 'verificação incompleta', 'unavailable' => 'consulta indisponível'];
|
||||
foreach ($result['checks'] as $key => $check) {
|
||||
$this->line($labels[$key].': '.$states[$check['status']].'.');
|
||||
foreach ($check['result']['reasons'] as $reason) {
|
||||
$this->line(' '.$reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result['ok'] ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ReceiptDocumentHealth;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class ReceiptDocumentsCheck extends Command
|
||||
{
|
||||
protected $signature = 'sgc:receipt-documents-check {--json : Resultado agregado para operação} {--limit=1000 : Máximo de registos e ficheiros, de 1 a 10000}';
|
||||
|
||||
protected $description = 'Verificar documentos privados da recepção sem modificar registos ou ficheiros';
|
||||
|
||||
public function handle(ReceiptDocumentHealth $health): int
|
||||
{
|
||||
$limit = filter_var($this->option('limit'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 10000]]);
|
||||
if ($limit === false) {
|
||||
$this->error('O limite deve ser um inteiro entre 1 e 10000.');
|
||||
|
||||
return self::INVALID;
|
||||
}
|
||||
try {
|
||||
$result = $health->inspect($limit);
|
||||
} catch (Throwable $error) {
|
||||
report($error);
|
||||
$result = ['checked_at' => now()->toIso8601String(), 'ok' => false, 'complete' => false, 'reasons' => ['unavailable']];
|
||||
}
|
||||
if ($this->option('json')) {
|
||||
$this->line(json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
|
||||
} else {
|
||||
$this->line($result['ok'] ? 'Documentos da recepção: verificação concluída sem divergências observadas.' : 'Documentos da recepção: verificação requer atenção.');
|
||||
$labels = ['not_installed' => 'Estrutura documental não instalada.', 'storage_unavailable' => 'Armazenamento privado indisponível.',
|
||||
'missing' => 'Existem ficheiros em falta.', 'invalid_reference' => 'Existem referências inválidas.', 'altered' => 'Existem divergências de tamanho ou conteúdo.',
|
||||
'unreferenced_recent' => 'Existem ficheiros recentes sem referência; podem estar em processamento.', 'unreferenced_older' => 'Existem ficheiros sem referência há pelo menos uma hora.',
|
||||
'limit_reached' => 'Verificação incompleta: atingido o limite configurado.', 'unavailable' => 'Consulta indisponível; verificar o registo técnico.'];
|
||||
foreach ($result['reasons'] as $reason) {
|
||||
$this->line($labels[$reason]);
|
||||
}
|
||||
}
|
||||
|
||||
return $result['ok'] ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,20 @@ use App\Services\RequisitionWorkflow;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Laravel\Fortify\Features;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnterLocalEvaluation
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (Features::enabled(Features::twoFactorAuthentication())) {
|
||||
if ($request->is('avaliar', 'avaliar/*', 'demo', 'demo/*')) {
|
||||
return redirect('/login')->header('Cache-Control', 'private, no-store, max-age=0');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
$access = app(EvaluationAccess::class);
|
||||
if (app(PublicNavigation::class)->enabled()) {
|
||||
return $next($request);
|
||||
|
||||
@@ -63,6 +63,7 @@ class HandleInertiaRequests extends Middleware
|
||||
'profileLabel' => app(OrganizationAccess::class)->profileLabel($actor),
|
||||
],
|
||||
'payablesAvailable' => $actor !== null && app(SupplierPayables::class)->can($actor, 'view'),
|
||||
'orderCancellationAvailable' => $actor !== null && (bool) config('order_cancellation.enabled') && Schema::hasTable('order_cancellations'),
|
||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Services\PublicNavigation;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Fortify\Features;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PublicDevelopmentNavigation
|
||||
@@ -13,7 +14,7 @@ class PublicDevelopmentNavigation
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$navigation = app(PublicNavigation::class);
|
||||
if (! $navigation->enabled() || $request->user('web') !== null || ! in_array($request->method(), ['GET', 'HEAD'], true)) {
|
||||
if (Features::enabled(Features::twoFactorAuthentication()) || ! $navigation->enabled() || $request->user('web') !== null || ! in_array($request->method(), ['GET', 'HEAD'], true)) {
|
||||
return $next($request);
|
||||
}
|
||||
$path = '/'.$request->path();
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Requests;
|
||||
|
||||
use App\Models\Supplier;
|
||||
use App\Models\User;
|
||||
use App\Services\AdministrativeGeography;
|
||||
use App\Services\SupplierParticipation;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
@@ -27,6 +28,9 @@ class SaveSupplierRequest extends FormRequest
|
||||
}
|
||||
|
||||
return [
|
||||
'geography_reference' => app(AdministrativeGeography::class)->enabled() ? 'sometimes|nullable|array:country,catalogue,record_id' : 'prohibited',
|
||||
'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',
|
||||
'name' => 'required|string|max:200',
|
||||
'nuit' => ['required', 'regex:/^\d{9}$/', $uniqueNuit],
|
||||
'contact' => 'required|string|max:200', 'email' => 'required|email|max:200',
|
||||
|
||||
@@ -5,9 +5,15 @@ namespace App\Models;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/** @property array{country:string,catalogue:string,record_id:string}|null $geography_reference */
|
||||
class Equipment extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['geography_reference' => 'array'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use LogicException;
|
||||
|
||||
/** @property array<string,mixed> $snapshot */
|
||||
class OrderCancellation extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $attributes = ['status' => 'pending'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['snapshot' => 'array'];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::updating(function (self $record) {
|
||||
if ($record->getRawOriginal('status') === 'pending' && (! in_array($record->status, ['approved', 'rejected'], true)
|
||||
|| $record->reviewed_by === null || $record->reviewed_at === null || trim((string) $record->review_reason) === '')) {
|
||||
throw new LogicException('Decisão completa e definitiva obrigatória.');
|
||||
}
|
||||
$allowed = $record->getRawOriginal('status') === 'pending' ? ['status', 'reviewed_by', 'review_reason', 'reviewed_at', 'updated_at']
|
||||
: ($record->getRawOriginal('status') === 'approved' && $record->getRawOriginal('revised_requisition_id') === null ? ['revised_requisition_id', 'updated_at'] : []);
|
||||
if (array_diff(array_keys($record->getDirty()), $allowed) !== []) {
|
||||
throw new LogicException('Preservar pedido e decisão de cancelamento.');
|
||||
}
|
||||
});
|
||||
static::deleting(fn () => throw new LogicException('Preservar cancelamento.'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class OrderCancellationPolicy extends PurchaseControlPolicy
|
||||
{
|
||||
protected $table = 'order_cancellation_policies';
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class OrderCancellationPolicyReview extends PurchaseControlReview
|
||||
{
|
||||
protected $table = 'order_cancellation_policy_reviews';
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Models\Concerns\ImmutablePurchaseRecord;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
@@ -39,4 +40,15 @@ class PurchaseOrder extends Model
|
||||
{
|
||||
$query->whereIn('requisition_id', Requisition::query()->visibleTo($user)->select('id'));
|
||||
}
|
||||
|
||||
/** @param Builder<PurchaseOrder> $query */
|
||||
public function scopeOperational(Builder $query): void
|
||||
{
|
||||
$query->where('purchase_orders.status', 'issued');
|
||||
if (Schema::hasTable('order_cancellations')) {
|
||||
$query->whereNotExists(fn ($q) => $q->selectRaw('1')->from('order_cancellations as oc')
|
||||
->whereColumn('oc.purchase_order_id', 'purchase_orders.id')->whereColumn('oc.tenant_id', 'purchase_orders.tenant_id')
|
||||
->whereIn('oc.status', ['pending', 'approved']));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property array{country:string,catalogue:string,record_id:string}|null $geography_reference
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property int $created_by
|
||||
@@ -23,7 +24,7 @@ class Supplier extends Model
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['blocked' => 'boolean', 'next_review' => 'date:Y-m-d'];
|
||||
return ['blocked' => 'boolean', 'next_review' => 'date:Y-m-d', 'geography_reference' => 'array'];
|
||||
}
|
||||
|
||||
public function eligible(): bool
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property array{country:string,catalogue:string,record_id:string}|null $geography_reference
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property int $first_limit_minor
|
||||
@@ -22,6 +23,6 @@ class Tenant extends Model
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['work_context_policy' => 'array', 'approval_includes_tax' => 'boolean', 'rules_provisional' => 'boolean', 'default_tax_rate' => 'decimal:2', 'comparison_policy' => 'array', 'intelligence_policy' => 'array'];
|
||||
return ['geography_reference' => 'array', 'work_context_policy' => 'array', 'approval_includes_tax' => 'boolean', 'rules_provisional' => 'boolean', 'default_tax_rate' => 'decimal:2', 'comparison_policy' => 'array', 'intelligence_policy' => 'array'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,15 @@ namespace App\Models;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/** @property array{country:string,catalogue:string,record_id:string}|null $geography_reference */
|
||||
class Warehouse extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['geography_reference' => 'array'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\AdministrativeGeography;
|
||||
use App\Services\FinanceBundle;
|
||||
use App\Services\ReplenishmentRun;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Date;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -15,11 +18,12 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(\App\Services\ReplenishmentRun::class);
|
||||
$this->app->singleton(ReplenishmentRun::class);
|
||||
$this->app->scoped(AdministrativeGeography::class);
|
||||
require_once base_path('packages/purchasing/bootstrap.php');
|
||||
$package = config('common-budget.path');
|
||||
require_once $package.'/bootstrap.php';
|
||||
\App\Services\FinanceBundle::configure();
|
||||
FinanceBundle::configure();
|
||||
$payables = config('common-payables.path');
|
||||
require_once $payables.'/bootstrap.php';
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
*/
|
||||
private function configureViews(): void
|
||||
{
|
||||
Fortify::loginView(fn (Request $request) => EvaluationNavigation::local($request) ? redirect('/avaliar') : Inertia::render('auth/login', [
|
||||
Fortify::loginView(fn (Request $request) => EvaluationNavigation::local($request) && ! Features::enabled(Features::twoFactorAuthentication()) ? redirect('/avaliar') : Inertia::render('auth/login', [
|
||||
'canResetPassword' => Features::enabled(Features::resetPasswords()),
|
||||
'status' => $request->session()->get('status'),
|
||||
]));
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AdministrativeGeography
|
||||
{
|
||||
public const ISSUES = [
|
||||
'blank_designation' => 'Designação vazia',
|
||||
'incomplete_code_path' => 'Percurso de códigos incompleto',
|
||||
'duplicate_code_path' => 'Código repetido',
|
||||
'missing_parent' => 'Nível superior ausente',
|
||||
'ambiguous_parent' => 'Nível superior ambíguo',
|
||||
'ancestor_requires_review' => 'Ascendente por rever',
|
||||
];
|
||||
|
||||
/** @var array<string,mixed>|null */
|
||||
private ?array $data = null;
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function catalogue(): array
|
||||
{
|
||||
if ($this->data === null) {
|
||||
$content = file_get_contents(resource_path('data/geography/cartografia-moz.json'));
|
||||
abort_unless(is_string($content) && hash_equals((string) config('geography.catalogue_sha256'), hash('sha256', $content)), 503, 'A integridade do catálogo precisa de verificação.');
|
||||
$data = json_decode($content, true, 32, JSON_THROW_ON_ERROR);
|
||||
abort_unless(is_array($data) && ($data['manifest']['source_sha256'] ?? null) === config('geography.source_sha256'), 503, 'Catálogo geográfico indisponível.');
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return config('geography.enabled') === true
|
||||
&& Schema::hasColumn('suppliers', 'geography_reference')
|
||||
&& Schema::hasColumn('tenants', 'geography_reference');
|
||||
}
|
||||
|
||||
public function availableFor(string $table): bool
|
||||
{
|
||||
return in_array($table, ['tenants', 'suppliers', 'warehouses', 'equipment'], true)
|
||||
&& (! in_array($table, ['warehouses', 'equipment'], true) || config('geography.operational_enabled') === true)
|
||||
&& $this->enabled() && Schema::hasColumn($table, 'geography_reference');
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
public function referenceRules(string $table): array
|
||||
{
|
||||
return ['geography_reference' => $this->availableFor($table) ? 'sometimes|nullable|array:country,catalogue,record_id' : 'prohibited',
|
||||
'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'];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed>|null $reference
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function canonicalFor(string $table, ?array $reference): ?array
|
||||
{
|
||||
abort_unless($this->availableFor($table), 503, 'A localização deste cadastro ainda não está instalada.');
|
||||
|
||||
return $this->canonical($reference);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function record(string $id): array
|
||||
{
|
||||
foreach ($this->catalogue()['records'] as $record) {
|
||||
if ($record['id'] === $id) {
|
||||
return $record;
|
||||
}
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages(['geography_reference' => 'Localização não encontrada no catálogo.']);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed>|null $reference
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function canonical(?array $reference): ?array
|
||||
{
|
||||
abort_unless($this->enabled(), 503, 'A localização administrativa ainda não está disponível.');
|
||||
if ($reference === null) {
|
||||
return null;
|
||||
}
|
||||
if (($reference['catalogue'] ?? null) !== $this->catalogue()['manifest']['source_sha256']) {
|
||||
throw ValidationException::withMessages(['geography_reference' => 'O catálogo foi alterado. Reabra a ficha antes de guardar.']);
|
||||
}
|
||||
$record = $this->record((string) ($reference['record_id'] ?? ''));
|
||||
if (! $record['selectable']) {
|
||||
throw ValidationException::withMessages(['geography_reference' => 'Este registo ou a sua hierarquia precisa de revisão. Escolha um nível superior confirmado.']);
|
||||
}
|
||||
|
||||
return ['country' => 'MZ', 'catalogue' => $reference['catalogue'], 'record_id' => $record['id']];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed>|null $reference
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function describe(?array $reference): ?array
|
||||
{
|
||||
if ($reference === null) {
|
||||
return null;
|
||||
}
|
||||
if (($reference['catalogue'] ?? null) !== $this->catalogue()['manifest']['source_sha256']) {
|
||||
return ['reference' => $reference, 'path' => [], 'requires_review' => true];
|
||||
}
|
||||
$record = $this->record((string) $reference['record_id']);
|
||||
$path = [$record];
|
||||
while ($record['parent_id'] !== null) {
|
||||
$record = $this->record($record['parent_id']);
|
||||
array_unshift($path, $record);
|
||||
}
|
||||
|
||||
return ['reference' => $reference, 'path' => array_map(fn ($node) => $this->summary($node), $path), 'requires_review' => ! $path[count($path) - 1]['selectable']];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $record
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function summary(array $record): array
|
||||
{
|
||||
return array_intersect_key($record, array_flip(['id', 'name', 'level', 'parent_id']));
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function children(?string $parent, int $page = 1): array
|
||||
{
|
||||
if ($parent !== null && ! $this->record($parent)['selectable']) {
|
||||
throw ValidationException::withMessages(['parent' => 'Hierarquia pendente de revisão.']);
|
||||
}
|
||||
$records = array_values(array_filter($this->catalogue()['records'], fn ($record) => $record['selectable'] && $record['parent_id'] === $parent));
|
||||
usort($records, fn ($a, $b) => strcmp($a['name'], $b['name']) ?: strcmp($a['id'], $b['id']));
|
||||
|
||||
return ['data' => array_map(fn ($record) => $this->summary($record), array_slice($records, ($page - 1) * 50, 50)), 'page' => $page, 'has_more' => count($records) > $page * 50, 'catalogue' => $this->catalogue()['manifest']['source_sha256']];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function descendants(string $id): array
|
||||
{
|
||||
$root = $this->record($id);
|
||||
if (! $root['selectable']) {
|
||||
throw ValidationException::withMessages(['location' => 'Localização pendente de revisão.']);
|
||||
}
|
||||
$prefix = array_slice($root['codes'], 0, $root['level']);
|
||||
|
||||
return array_values(array_map(fn ($record) => $record['id'], array_filter($this->catalogue()['records'], fn ($record) => $record['selectable'] && array_slice($record['codes'], 0, count($prefix)) === $prefix)));
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $filters
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function inconsistencies(array $filters): array
|
||||
{
|
||||
$search = trim($filters['search'] ?? '');
|
||||
$issue = $filters['issue'] ?? '';
|
||||
|
||||
return array_values(array_filter($this->catalogue()['records'], fn ($record) => ! $record['selectable']
|
||||
&& ($issue === '' || in_array($issue, $record['issues'], true))
|
||||
&& ($search === '' || $record['id'] === $search || mb_stripos($record['name'], $search) !== false)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class CandidateGeography
|
||||
{
|
||||
/** @param array<string,mixed> $input
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function resolve(array $input): array
|
||||
{
|
||||
if (($input['availability_reference'] ?? null) !== null) {
|
||||
$data = Validator::make($input, ['availability_reference' => 'required|array:country,catalogue,record_id', 'availability_reference.catalogue' => 'required|string|size:64', 'availability_reference.record_id' => 'required|string|regex:/^[0-9]+$/D|max:20'])->validate();
|
||||
$resolved = $this->locality($data['availability_reference'], 'availability_reference');
|
||||
$input['availability_reference'] = $resolved['reference'];
|
||||
$input['availability'] = $resolved['location'];
|
||||
}
|
||||
if (array_key_exists('additional_availability_references', $input)) {
|
||||
$data = Validator::make($input, ['additional_availability' => 'present|array|list|max:20', 'additional_availability.*' => 'array',
|
||||
'additional_availability_references' => 'present|array|list|max:20', 'additional_availability_references.*' => 'nullable|array:country,catalogue,record_id',
|
||||
'additional_availability_references.*.catalogue' => 'required_with:additional_availability_references.*|string|size:64',
|
||||
'additional_availability_references.*.record_id' => 'required_with:additional_availability_references.*|string|regex:/^[0-9]+$/D|max:20'])->validate();
|
||||
if (count($data['additional_availability']) !== count($data['additional_availability_references'])) {
|
||||
throw ValidationException::withMessages(['additional_availability_references' => 'Cada local adicional deve ter uma referência correspondente ou vazia.']);
|
||||
}
|
||||
foreach ($data['additional_availability_references'] as $index => $reference) {
|
||||
if ($reference !== null) {
|
||||
$resolved = $this->locality($reference, 'additional_availability_references.'.$index);
|
||||
$input['additional_availability'][$index] = $resolved['location'];
|
||||
$input['additional_availability_references'][$index] = $resolved['reference'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $input;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $value
|
||||
* @return array{reference:array<string,mixed>,location:array<string,string>}
|
||||
*/
|
||||
private function locality(array $value, string $field): array
|
||||
{
|
||||
$geography = app(AdministrativeGeography::class);
|
||||
try {
|
||||
$reference = $geography->canonical($value);
|
||||
} catch (ValidationException $error) {
|
||||
throw ValidationException::withMessages([$field => array_merge(...array_values($error->errors()))]);
|
||||
}
|
||||
abort_unless($reference !== null, 422);
|
||||
$record = $geography->record($reference['record_id']);
|
||||
if ($record['level'] !== 4) {
|
||||
throw ValidationException::withMessages([$field => 'Seleccione um percurso completo até à localidade. Não se presume um distrito ou localidade em falta.']);
|
||||
}
|
||||
$description = $geography->describe($reference);
|
||||
abort_unless($description !== null, 422);
|
||||
$names = array_column($description['path'], 'name', 'level');
|
||||
|
||||
return ['reference' => $reference, 'location' => ['province' => $names[1], 'district' => $names[2], 'locality' => $names[4]]];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data
|
||||
* @param array<string,mixed> $previous
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function preserve(array $data, array $previous): array
|
||||
{
|
||||
if (! array_key_exists('availability_reference', $data) && isset($previous['availability_reference']) && array_map(fn ($key) => $data['availability'][$key], ['province', 'district', 'locality']) === array_map(fn ($key) => $previous['availability'][$key], ['province', 'district', 'locality'])) {
|
||||
$data['availability_reference'] = $previous['availability_reference'];
|
||||
}
|
||||
|
||||
if (! array_key_exists('additional_availability_references', $data) && array_key_exists('additional_availability_references', $previous)) {
|
||||
$known = [];
|
||||
foreach ($previous['additional_availability'] ?? [] as $index => $location) {
|
||||
$key = json_encode(array_map(fn ($part) => $location[$part], ['province', 'district', 'locality']), JSON_THROW_ON_ERROR);
|
||||
$known[$key] = $previous['additional_availability_references'][$index] ?? null;
|
||||
}
|
||||
$data['additional_availability_references'] = array_map(function ($location) use ($known) {
|
||||
$key = json_encode(array_map(fn ($part) => $location[$part], ['province', 'district', 'locality']), JSON_THROW_ON_ERROR);
|
||||
|
||||
return $known[$key] ?? null;
|
||||
}, $data['additional_availability']);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -76,9 +76,12 @@ class CandidateProfiles
|
||||
$sessionVersion = $account->session_version;
|
||||
$account = CandidateAccount::query()->where('tenant_id', $account->tenant_id)->where('id', $account->id)->lockForUpdate()->firstOrFail();
|
||||
abort_unless($account->session_version === $sessionVersion, 401);
|
||||
$input = app(CandidateGeography::class)->resolve($input);
|
||||
$data = Validator::make($input, ['version' => 'required|integer|min:0', 'name' => 'required|string|min:3|max:200', 'phone' => 'nullable|string|max:50',
|
||||
'availability_reference' => 'sometimes|nullable|array:country,catalogue,record_id',
|
||||
'summary' => 'required|string|min:10|max:5000', 'availability' => 'required|array:province,district,locality',
|
||||
'availability.province' => 'required|string|max:150', 'availability.district' => 'required|string|max:150', 'availability.locality' => 'required|string|max:150',
|
||||
'additional_availability_references' => 'sometimes|array|list|max:20',
|
||||
'additional_availability' => 'sometimes|array|list|max:20',
|
||||
'additional_availability.*' => 'required|array:province,district,locality',
|
||||
'additional_availability.*.province' => 'required|string|max:150',
|
||||
@@ -87,6 +90,7 @@ class CandidateProfiles
|
||||
'reason' => 'required|string|min:10|max:4000'])->validate();
|
||||
$previous = $this->profile($account);
|
||||
$data['additional_availability'] ??= $previous['data']['additional_availability'] ?? [];
|
||||
$data = app(CandidateGeography::class)->preserve($data, $previous['data']);
|
||||
$seen = [];
|
||||
foreach ([$data['availability'], ...$data['additional_availability']] as $location) {
|
||||
$key = json_encode(array_map(fn ($part) => mb_strtolower(trim($location[$part])), ['province', 'district', 'locality']), JSON_THROW_ON_ERROR);
|
||||
@@ -99,7 +103,7 @@ class CandidateProfiles
|
||||
throw ValidationException::withMessages(['version' => 'O perfil mudou. Actualize e confira antes de guardar.']);
|
||||
}
|
||||
$id = DB::table('candidate_profile_versions')->insertGetId(['tenant_id' => $account->tenant_id, 'candidate_account_id' => $account->id,
|
||||
'version' => $previous['version'] + 1, 'data' => json_encode(array_intersect_key($data, array_flip(['name', 'phone', 'summary', 'availability', 'additional_availability'])), JSON_THROW_ON_ERROR),
|
||||
'version' => $previous['version'] + 1, 'data' => json_encode(array_intersect_key($data, array_flip(['name', 'phone', 'summary', 'availability', 'additional_availability', 'availability_reference', 'additional_availability_references'])), JSON_THROW_ON_ERROR),
|
||||
'reason' => $data['reason'], 'created_at' => now()]);
|
||||
$this->event($account, 'profile.version_created', ['profile_version_id' => $id, 'version' => $previous['version'] + 1]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Database\Connection;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
|
||||
class CandidateTerritory
|
||||
{
|
||||
/** Filter only immutable received snapshots; never consult the current candidate profile. */
|
||||
public function apply(Builder $query, string $location): void
|
||||
{
|
||||
$geography = app(AdministrativeGeography::class);
|
||||
abort_unless($geography->enabled(), 503);
|
||||
$ids = $geography->descendants($location);
|
||||
$edition = $geography->catalogue()['manifest']['source_sha256'];
|
||||
$connection = $query->getConnection();
|
||||
abort_unless($connection instanceof Connection, 503);
|
||||
$driver = $connection->getDriverName();
|
||||
abort_unless(in_array($driver, ['sqlite', 'pgsql'], true), 503, 'Filtro territorial ainda não disponível nesta base.');
|
||||
$parameters = implode(',', array_fill(0, count($ids), '?'));
|
||||
if ($driver === 'pgsql') {
|
||||
$source = "candidate_submissions.snapshot::jsonb #> '{profile,data,additional_availability_references}'";
|
||||
$array = "CASE WHEN jsonb_typeof($source) = 'array' THEN $source ELSE '[]'::jsonb END";
|
||||
$additional = "EXISTS (SELECT 1 FROM jsonb_array_elements($array) AS territory(value) WHERE territory.value->>'catalogue' = ? AND territory.value->>'country' = 'MZ' AND territory.value->>'record_id' IN ($parameters))";
|
||||
} else {
|
||||
$source = "json_extract(candidate_submissions.snapshot, '$.profile.data.additional_availability_references')";
|
||||
$array = "CASE WHEN json_type(candidate_submissions.snapshot, '$.profile.data.additional_availability_references') = 'array' THEN $source ELSE '[]' END";
|
||||
$object = "CASE WHEN territory.type = 'object' THEN territory.value ELSE '{}' END";
|
||||
$additional = "EXISTS (SELECT 1 FROM json_each($array) AS territory WHERE json_extract($object, '$.catalogue') = ? AND json_extract($object, '$.country') = 'MZ' AND json_extract($object, '$.record_id') IN ($parameters))";
|
||||
}
|
||||
$query->where(function (Builder $group) use ($ids, $edition, $additional) {
|
||||
$group->where(fn (Builder $main) => $main->where('candidate_submissions.snapshot->profile->data->availability_reference->catalogue', $edition)
|
||||
->where('candidate_submissions.snapshot->profile->data->availability_reference->country', 'MZ')
|
||||
->whereIn('candidate_submissions.snapshot->profile->data->availability_reference->record_id', $ids))
|
||||
->orWhereRaw($additional, [$edition, ...$ids]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Equipment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class EquipmentWarehouseRequests
|
||||
{
|
||||
/** Read-only projection; consumers must not insert rows into the paginator.
|
||||
* @return LengthAwarePaginator<int,covariant array<string,mixed>>|null
|
||||
*/
|
||||
public function page(User $actor, Equipment $equipment): ?LengthAwarePaginator
|
||||
{
|
||||
$canRead = $actor->hasFunctions(['admin', 'warehouse_request_read', 'warehouse_request_prepare'])
|
||||
|| ($actor->hasFunctions(['warehouse_request_review']) && $actor->department_id !== null)
|
||||
|| ($actor->hasFunctions(['warehouse_request_deliver']) && $actor->hasFunctions(['warehouse']));
|
||||
if (! $canRead || ! Schema::hasTable('warehouse_requests')) {
|
||||
return null;
|
||||
}
|
||||
$query = app(WarehouseWorkdesk::class)->visible($actor);
|
||||
if (DB::getDriverName() === 'pgsql') {
|
||||
$query->whereRaw("EXISTS (SELECT 1 FROM json_array_elements(warehouse_requests.snapshot->'lines') AS line(value) WHERE line.value->'equipment'->>'id' = ?)", [(string) $equipment->id]);
|
||||
} elseif (DB::getDriverName() === 'sqlite') {
|
||||
$query->whereRaw("EXISTS (SELECT 1 FROM json_each(json_extract(warehouse_requests.snapshot, '$.lines')) AS line WHERE json_extract(line.value, '$.equipment.id') = ?)", [$equipment->id]);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $query->oldest('id')->paginate(15, ['id', 'snapshot'], 'requests_page')->withQueryString()->through(function ($record) use ($equipment) {
|
||||
$p = app(WarehouseFulfilment::class)->progress($record);
|
||||
$lines = [];
|
||||
foreach ($record->snapshot['lines'] as $index => $line) {
|
||||
if (($line['equipment']['id'] ?? null) !== $equipment->id) {
|
||||
continue;
|
||||
}
|
||||
$lines[] = ['line' => $index + 1, 'item' => $line['item'], 'destination' => $line['destination'],
|
||||
'requested_milli' => $line['quantity_milli'], 'reserved_milli' => $p['reserved'][$index],
|
||||
'delivered_milli' => $p['delivered'][$index], 'remaining_milli' => $p['remaining'][$index], 'cancelled_milli' => $p['cancelled'][$index]];
|
||||
}
|
||||
|
||||
return ['id' => $record->id, 'state' => $p['state'], 'version' => $p['version'],
|
||||
'reservation_ready' => $p['reservation_ready'], 'lines' => $lines];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,7 @@ class InvoiceWorkflow
|
||||
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Finance]);
|
||||
$this->access($user);
|
||||
$order = PurchaseOrder::query()->lockForUpdate()->findOrFail($order->id);
|
||||
app(OrderCancellationWorkflow::class)->assertOperational($order);
|
||||
$request = Requisition::query()->findOrFail($order->requisition_id);
|
||||
app(ConflictGuard::class)->assertProcess($request, $order->supplier_id, $user);
|
||||
$number = Str::upper(Str::squish($data['number']));
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use LogicException;
|
||||
use Throwable;
|
||||
|
||||
/** Read-only composition of existing technical inspections; never runs recovery or repairs. */
|
||||
class OperationalHealth
|
||||
{
|
||||
/** @return array<string,mixed> */
|
||||
public function inspect(int $documentLimit = 1000): array
|
||||
{
|
||||
abort_unless(app()->runningInConsole() && ! auth()->check() && ! request()->hasSession(), 403);
|
||||
if ($documentLimit < 1 || $documentLimit > 10000) {
|
||||
throw new LogicException('Limite fora do intervalo permitido.');
|
||||
}
|
||||
$checks = [];
|
||||
$startedAt = now()->toIso8601String();
|
||||
foreach (['replenishment' => fn () => app(ReplenishmentQueue::class)->health(),
|
||||
'receipt_documents' => fn () => app(ReceiptDocumentHealth::class)->inspect($documentLimit)] as $key => $read) {
|
||||
try {
|
||||
$result = $read();
|
||||
$complete = $result['complete'] ?? true;
|
||||
$checks[$key] = ['status' => ! $complete ? 'incomplete' : ($result['ok'] ? 'passed' : 'attention'), 'result' => $result];
|
||||
} catch (Throwable $error) {
|
||||
report($error);
|
||||
$checks[$key] = ['status' => 'unavailable', 'result' => ['checked_at' => now()->toIso8601String(), 'ok' => false, 'complete' => false, 'reasons' => ['unavailable']]];
|
||||
}
|
||||
}
|
||||
|
||||
return ['schema_version' => 1, 'started_at' => $startedAt, 'checked_at' => now()->toIso8601String(),
|
||||
'ok' => collect($checks)->every(fn ($check) => $check['status'] === 'passed'),
|
||||
'complete' => collect($checks)->every(fn ($check) => in_array($check['status'], ['passed', 'attention'], true)),
|
||||
'checks' => $checks];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\PurchaseControlPolicy;
|
||||
use App\Models\PurchaseControlReview;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class OrderCancellationPolicyWorkflow extends PurchaseControlPolicyWorkflow
|
||||
{
|
||||
public function canReview(User $actor, PurchaseControlPolicy $policy): bool
|
||||
{
|
||||
return (bool) config('order_cancellation.enabled') && $actor->tenant_id === $policy->tenant_id && $actor->hasFunctions(['admin'])
|
||||
&& ! app(OrganizationAccess::class)->maintenanceOnly($actor) && $policy->getTable() === 'order_cancellation_policies'
|
||||
&& $policy->status === 'pending' && $policy->created_by !== $actor->id && $policy->revision === $this->policies()->max('revision')
|
||||
&& ! $this->reviews()->where('purchase_control_policy_id', $policy->id)->where('reviewed_by', $actor->id)->exists();
|
||||
}
|
||||
|
||||
/** @return Builder<PurchaseControlPolicy> */
|
||||
protected function policies(): Builder
|
||||
{
|
||||
return (new PurchaseControlPolicy)->setTable('order_cancellation_policies')->newQuery();
|
||||
}
|
||||
|
||||
/** @return Builder<PurchaseControlReview> */
|
||||
protected function reviews(): Builder
|
||||
{
|
||||
return (new PurchaseControlReview)->setTable('order_cancellation_policy_reviews')->newQuery();
|
||||
}
|
||||
|
||||
protected function mode(): string
|
||||
{
|
||||
return 'issued_order';
|
||||
}
|
||||
|
||||
protected function auditPrefix(): string
|
||||
{
|
||||
return 'order_cancellation_policy';
|
||||
}
|
||||
|
||||
private function enabled(): void
|
||||
{
|
||||
abort_unless(config('order_cancellation.enabled') && Schema::hasTable('order_cancellation_policies'), 503, 'Cancelamento de ordens não activado.');
|
||||
}
|
||||
|
||||
public function propose(User $user, array $data): PurchaseControlPolicy
|
||||
{
|
||||
$this->enabled();
|
||||
abort_if(app(OrganizationAccess::class)->maintenanceOnly($user), 403);
|
||||
validator($data, ['previous_id' => 'required|integer|min:0', 'mode' => 'required|in:issued_order', 'enabled' => 'required|boolean',
|
||||
'valid_from' => 'required|date_format:Y-m-d', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:valid_from', 'reason' => 'required|string|min:10|max:4000'])->validate();
|
||||
|
||||
return parent::propose($user, $data);
|
||||
}
|
||||
|
||||
public function review(User $user, PurchaseControlPolicy $policy, string $decision, string $reason): void
|
||||
{
|
||||
$this->enabled();
|
||||
abort_if(app(OrganizationAccess::class)->maintenanceOnly($user), 403);
|
||||
parent::review($user, $policy, $decision, $reason);
|
||||
}
|
||||
|
||||
public function requireCurrent(): PurchaseControlPolicy
|
||||
{
|
||||
$this->enabled();
|
||||
$policy = $this->current();
|
||||
abort_unless($policy && $policy->enabled && $policy->mode === 'issued_order' && $policy->valid_until >= today()->format('Y-m-d'), 422, 'É necessária política vigente de cancelamento de ordens, com duas aprovações administrativas independentes.');
|
||||
|
||||
return $policy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\GoodsReceipt;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\OrderCancellation;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\User;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Tempo\Finance\Budget\BudgetContext;
|
||||
use Tempo\Finance\OrderCancellation\CommitmentReversal;
|
||||
|
||||
class OrderCancellationWorkflow
|
||||
{
|
||||
public function canRevise(User $actor, OrderCancellation $record): bool
|
||||
{
|
||||
if (! config('order_cancellation.enabled') || $record->tenant_id !== $actor->tenant_id || $record->status !== 'approved' || $record->revised_requisition_id
|
||||
|| app(OrganizationAccess::class)->maintenanceOnly($actor) || ! app(OrganizationAccess::class)->canRequest($actor)) {
|
||||
return false;
|
||||
}
|
||||
$order = PurchaseOrder::query()->visibleTo($actor)->find($record->purchase_order_id);
|
||||
|
||||
return $order && Requisition::query()->whereKey($order->requisition_id)->where('requester_id', $actor->id)->where('department_id', $actor->department_id)->exists();
|
||||
}
|
||||
|
||||
public function canRequest(User $actor, PurchaseOrder $order): bool
|
||||
{
|
||||
if (! config('order_cancellation.enabled') || ! Schema::hasTable('order_cancellations') || ! $actor->hasFunctions([PurchasingRole::Procurement])
|
||||
|| app(OrganizationAccess::class)->maintenanceOnly($actor) || $actor->tenant_id !== $order->tenant_id || $this->state($order) !== 'issued') {
|
||||
return false;
|
||||
}
|
||||
$request = Requisition::query()->visibleTo($actor)->find($order->requisition_id);
|
||||
$policy = app(OrderCancellationPolicyWorkflow::class)->current();
|
||||
|
||||
return $request && $request->requester_id !== $actor->id && $policy && $policy->enabled && $policy->mode === 'issued_order'
|
||||
&& $policy->valid_until >= today()->format('Y-m-d')
|
||||
&& ! GoodsReceipt::query()->where('purchase_order_id', $order->id)->exists() && ! Invoice::query()->where('purchase_order_id', $order->id)->exists()
|
||||
&& ! app(ConflictGuard::class)->processBlocked($request, $order->supplier_id, $actor);
|
||||
}
|
||||
|
||||
public function canDecide(User $actor, OrderCancellation $record): bool
|
||||
{
|
||||
if (! config('order_cancellation.enabled') || $record->tenant_id !== $actor->tenant_id || $record->status !== 'pending' || ! $actor->hasFunctions([PurchasingRole::Finance]) || app(OrganizationAccess::class)->maintenanceOnly($actor)) {
|
||||
return false;
|
||||
}
|
||||
$order = PurchaseOrder::query()->visibleTo($actor)->find($record->purchase_order_id);
|
||||
$request = $order ? Requisition::query()->find($order->requisition_id) : null;
|
||||
$award = $order ? PurchaseAward::query()->find($order->purchase_award_id) : null;
|
||||
|
||||
return $order && $request && $award && ! in_array($actor->id, [$record->created_by, $request->requester_id, $award->created_by, $order->issued_by], true);
|
||||
}
|
||||
|
||||
private function enabled(): void
|
||||
{
|
||||
abort_unless(config('order_cancellation.enabled') && Schema::hasTable('order_cancellations'), 503, 'Cancelamento de ordens não activado.');
|
||||
}
|
||||
|
||||
public function state(PurchaseOrder $order): string
|
||||
{
|
||||
if (! Schema::hasTable('order_cancellations')) {
|
||||
return $order->status;
|
||||
}
|
||||
$states = OrderCancellation::query()->where('purchase_order_id', $order->id)->pluck('status');
|
||||
|
||||
return $states->contains('approved') ? 'cancelled' : ($states->contains('pending') ? 'cancellation_pending' : $order->status);
|
||||
}
|
||||
|
||||
/** Holds survive disabling the feature; hiding a command must not unblock an order. */
|
||||
public function assertOperational(PurchaseOrder $order): void
|
||||
{
|
||||
if (in_array($this->state($order), ['cancelled', 'cancellation_pending'], true)) {
|
||||
throw ValidationException::withMessages(['order' => 'Ordem cancelada ou com pedido de cancelamento pendente. Não é permitida nova execução.']);
|
||||
}
|
||||
}
|
||||
|
||||
private function source(PurchaseOrder $order): void
|
||||
{
|
||||
if ($order->getRawOriginal('status') !== 'issued' || $order->currency !== 'MZN' || GoodsReceipt::query()->where('purchase_order_id', $order->id)->exists()
|
||||
|| Invoice::query()->where('purchase_order_id', $order->id)->exists()) {
|
||||
throw ValidationException::withMessages(['order' => 'Este circuito exige ordem emitida em MZN sem qualquer recepção, factura ou pagamento.']);
|
||||
}
|
||||
}
|
||||
|
||||
private function basis(PurchaseOrder $order): string
|
||||
{
|
||||
return hash('sha256', json_encode(['order' => $order->getAttributes(), 'items' => $order->items()->orderBy('id')->get()->toArray(),
|
||||
'budget' => DB::table('budget_entries')->where('tenant_id', $order->tenant_id)->where('requisition_id', $order->requisition_id)->orderBy('id')->get()->toArray()], JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input */
|
||||
public function request(User $actor, PurchaseOrder $order, array $input): OrderCancellation
|
||||
{
|
||||
$this->enabled();
|
||||
$data = validator($input, ['operation_key' => 'required|uuid', 'reason' => 'required|string|min:10|max:4000', 'evidence' => 'required|string|min:10|max:4000'])->validate();
|
||||
$data['operation_key'] = strtolower($data['operation_key']);
|
||||
$hash = hash('sha256', json_encode([$order->id, $data], JSON_THROW_ON_ERROR));
|
||||
|
||||
return DB::transaction(function () use ($actor, $order, $data, $hash) {
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement]);
|
||||
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
|
||||
$order = PurchaseOrder::query()->visibleTo($actor)->lockForUpdate()->findOrFail($order->id);
|
||||
$request = Requisition::query()->lockForUpdate()->findOrFail($order->requisition_id);
|
||||
abort_if($actor->id === $request->requester_id, 403);
|
||||
app(ConflictGuard::class)->assertProcess($request, $order->supplier_id, $actor);
|
||||
$old = OrderCancellation::query()->where('created_by', $actor->id)->where('operation_key', $data['operation_key'])->first();
|
||||
if ($old) {
|
||||
abort_unless($old->input_hash === $hash, 422, 'Chave utilizada com outro conteúdo.');
|
||||
|
||||
return $old;
|
||||
}
|
||||
$this->assertOperational($order);
|
||||
$this->source($order);
|
||||
$policy = app(OrderCancellationPolicyWorkflow::class)->requireCurrent();
|
||||
$record = OrderCancellation::query()->create(['tenant_id' => $actor->tenant_id, 'purchase_order_id' => $order->id, 'policy_id' => $policy->id,
|
||||
'created_by' => $actor->id, ...$data, 'input_hash' => $hash, 'basis_hash' => $this->basis($order),
|
||||
'snapshot' => ['order_number' => $order->number, 'policy' => $policy->toArray(), 'original_order_status' => $order->status]]);
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'order_cancellation.requested', $request, null, ['cancellation_id' => $record->id, 'order_id' => $order->id]);
|
||||
|
||||
return $record;
|
||||
});
|
||||
}
|
||||
|
||||
public function decide(User $actor, OrderCancellation $record, string $decision, string $reason): void
|
||||
{
|
||||
$this->enabled();
|
||||
validator(['decision' => $decision, 'reason' => $reason], ['decision' => 'required|in:approved,rejected', 'reason' => 'required|string|min:10|max:4000'])->validate();
|
||||
DB::transaction(function () use ($actor, $record, $decision, $reason) {
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Finance]);
|
||||
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
|
||||
$record = OrderCancellation::query()->lockForUpdate()->findOrFail($record->id);
|
||||
$order = PurchaseOrder::query()->visibleTo($actor)->lockForUpdate()->findOrFail($record->purchase_order_id);
|
||||
$request = Requisition::query()->lockForUpdate()->findOrFail($order->requisition_id);
|
||||
$award = PurchaseAward::query()->findOrFail($order->purchase_award_id);
|
||||
abort_if(in_array($actor->id, [$record->created_by, $request->requester_id, $award->created_by, $order->issued_by], true), 403);
|
||||
app(ConflictGuard::class)->assertProcess($request, $order->supplier_id, $actor);
|
||||
if ($record->status !== 'pending') {
|
||||
abort_unless($record->status === $decision && $record->review_reason === $reason && $record->reviewed_by === $actor->id, 409, 'Decisão imutável.');
|
||||
|
||||
return;
|
||||
}
|
||||
$entry = null;
|
||||
if ($decision === 'approved') {
|
||||
$this->source($order);
|
||||
abort_unless($record->basis_hash === $this->basis($order), 409, 'A base da ordem ou do compromisso mudou.');
|
||||
$policy = app(OrderCancellationPolicyWorkflow::class)->requireCurrent();
|
||||
abort_unless($policy->id === $record->policy_id, 409, 'Política alterada; rejeite o pedido para retomar o circuito.');
|
||||
abort_unless(DB::table('tenants')->where('id', $actor->tenant_id)->value('currency') === 'MZN', 422);
|
||||
if (! empty($order->snapshot['budget_control']) && ! DB::table('budget_entries')->where('tenant_id', $actor->tenant_id)->where('requisition_id', $request->id)->exists()) {
|
||||
throw ValidationException::withMessages(['budget' => 'A ordem indica controlo orçamental, mas o compromisso não está disponível.']);
|
||||
}
|
||||
$path = base_path('packages/order-cancellation/src/CommitmentReversal.php');
|
||||
abort_unless(is_file($path) && hash_file('sha256', $path) === config('order_cancellation.reversal_sha256'), 503, 'Capacidade de reversão indisponível.');
|
||||
require_once $path;
|
||||
try {
|
||||
$entry = (new CommitmentReversal)->reverse(DB::connection(), new BudgetContext($actor->id, ['tenant_id' => (int) $actor->tenant_id], (int) now()->year, 'MZN'), $request->id, $order->id);
|
||||
} catch (\LogicException $error) {
|
||||
throw ValidationException::withMessages(['budget' => 'O compromisso não está integralmente disponível para reversão ou a sua base é inconsistente. Reveja o controlo orçamental.']);
|
||||
}
|
||||
$request->update(['status' => 'cancelled']);
|
||||
}
|
||||
$record->update(['status' => $decision, 'reviewed_by' => $actor->id, 'review_reason' => $reason, 'reviewed_at' => now()]);
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'order_cancellation.'.$decision, $request, null, ['cancellation_id' => $record->id, 'order_id' => $order->id, 'budget_reversal_entry_id' => $entry]);
|
||||
});
|
||||
}
|
||||
|
||||
public function revise(User $actor, OrderCancellation $record, string $neededBy, string $reason): Requisition
|
||||
{
|
||||
$this->enabled();
|
||||
validator(['needed_by' => $neededBy, 'reason' => $reason], ['needed_by' => 'required|date_format:Y-m-d|after_or_equal:today', 'reason' => 'required|string|min:10|max:4000'])->validate();
|
||||
|
||||
return DB::transaction(function () use ($actor, $record, $neededBy, $reason) {
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor);
|
||||
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
|
||||
$record = OrderCancellation::query()->lockForUpdate()->findOrFail($record->id);
|
||||
$order = PurchaseOrder::query()->findOrFail($record->purchase_order_id);
|
||||
$source = Requisition::query()->lockForUpdate()->findOrFail($order->requisition_id);
|
||||
abort_unless($actor->id === $source->requester_id && $actor->department_id === $source->department_id && $record->status === 'approved', 403);
|
||||
if ($record->revised_requisition_id) {
|
||||
return Requisition::query()->findOrFail($record->revised_requisition_id);
|
||||
}
|
||||
$items = $source->items()->get()->map(fn ($item) => [...$item->only(['description', 'category', 'purchase_type', 'quantity', 'unit', 'tax_rate', 'production_area', 'equipment_id', 'spare_part_id']), 'unit_price' => BigDecimal::of($item->unit_price_minor)->dividedBy(100, 2)->__toString()])->all();
|
||||
$revised = app(RequisitionWorkflow::class)->save($actor, ['cost_center_id' => $source->cost_center_id, 'priority' => $source->priority, 'needed_by' => $neededBy, 'justification' => $reason, 'items' => $items]);
|
||||
$record->update(['revised_requisition_id' => $revised->id]);
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'order_cancellation.revision_created', $revised, null, ['source_requisition_id' => $source->id, 'cancellation_id' => $record->id]);
|
||||
|
||||
return $revised;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Schema;
|
||||
class OrganizationAccess
|
||||
{
|
||||
public const PERMISSIONS = [
|
||||
'audit_metadata_read' => 'Consultar e exportar metadados de eventos de todos os módulos da empresa, sem conteúdos privados',
|
||||
'warehouse_return_read' => 'Consultar devoluções de excedentes de entregas internas',
|
||||
'warehouse_return_prepare' => 'Declarar excedente não consumido de entrega interna própria',
|
||||
'warehouse_return_confirm' => 'Conferir ou recusar recepção independente do excedente no armazém (exige função de armazém)',
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\PeopleProcess;
|
||||
use App\Models\User;
|
||||
|
||||
/** One permission-aware projection shared by consultation and portable process dossiers. */
|
||||
class PeopleProcessProjection
|
||||
{
|
||||
/** @return array{process:array<string,mixed>,candidates:list<array<string,mixed>>} */
|
||||
public function read(User $actor, PeopleProcess $record): array
|
||||
{
|
||||
$access = app(PeopleAccess::class);
|
||||
abort_unless($access->can($actor, $record, 'view'), 403);
|
||||
$workflow = app(PeopleHiring::class);
|
||||
$snapshot = $workflow->snapshot($record);
|
||||
$rows = $workflow->matrix($record);
|
||||
if (! $access->can($actor, $record, 'financial')) {
|
||||
unset($snapshot['rules']['annex']['fields']['financial']);
|
||||
unset($snapshot['selection']['rules']['annex']['fields']['financial']);
|
||||
}
|
||||
if (! $access->can($actor, $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($actor, $record, 'contract') && ! $access->can($actor, $record, 'contract_view')) {
|
||||
$snapshot['contract'] = null;
|
||||
}
|
||||
|
||||
$rows = app(ReceivedCandidateAvailability::class)->attach($actor, $record, $rows);
|
||||
|
||||
return ['process' => $snapshot, 'candidates' => $rows];
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,34 @@ use App\Enums\PurchasingRole;
|
||||
use App\Models\PurchaseControlPolicy;
|
||||
use App\Models\PurchaseControlReview;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PurchaseControlPolicyWorkflow
|
||||
{
|
||||
/** @return Builder<PurchaseControlPolicy> */
|
||||
protected function policies(): Builder
|
||||
{
|
||||
return PurchaseControlPolicy::query();
|
||||
}
|
||||
|
||||
/** @return Builder<PurchaseControlReview> */
|
||||
protected function reviews(): Builder
|
||||
{
|
||||
return PurchaseControlReview::query();
|
||||
}
|
||||
|
||||
protected function mode(): string
|
||||
{
|
||||
return 'unissued_award';
|
||||
}
|
||||
|
||||
protected function auditPrefix(): string
|
||||
{
|
||||
return 'purchase_control';
|
||||
}
|
||||
|
||||
private function admin(User $user): void
|
||||
{
|
||||
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Admin]), 403);
|
||||
@@ -25,12 +48,12 @@ class PurchaseControlPolicyWorkflow
|
||||
app(ConflictGuard::class)->lockCompany($user);
|
||||
$user = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->findOrFail($user->id);
|
||||
$this->admin($user);
|
||||
$latest = PurchaseControlPolicy::query()->latest('revision')->first();
|
||||
if ((int) $data['previous_id'] !== ($latest->id ?? 0) || $data['mode'] !== 'unissued_award') {
|
||||
$latest = $this->policies()->latest('revision')->first();
|
||||
if ((int) $data['previous_id'] !== ($latest->id ?? 0) || $data['mode'] !== $this->mode()) {
|
||||
throw ValidationException::withMessages(['cancellation' => 'Versão desactualizada ou regra não suportada. Não se presume um circuito de excepção.']);
|
||||
}
|
||||
$policy = PurchaseControlPolicy::query()->create(['tenant_id' => $user->tenant_id, 'created_by' => $user->id, 'revision' => ($latest->revision ?? 0) + 1, 'mode' => $data['mode'], 'enabled' => $data['enabled'], 'valid_from' => $data['valid_from'], 'valid_until' => $data['valid_until'], 'reason' => $data['reason']]);
|
||||
app(RequisitionWorkflow::class)->audit($user, 'purchase_control.proposed', null, null, $policy->toArray());
|
||||
$policy = $this->policies()->create(['tenant_id' => $user->tenant_id, 'created_by' => $user->id, 'revision' => ($latest->revision ?? 0) + 1, 'mode' => $data['mode'], 'enabled' => $data['enabled'], 'valid_from' => $data['valid_from'], 'valid_until' => $data['valid_until'], 'reason' => $data['reason']]);
|
||||
app(RequisitionWorkflow::class)->audit($user, $this->auditPrefix().'.proposed', null, null, $policy->toArray());
|
||||
|
||||
return $policy;
|
||||
});
|
||||
@@ -39,6 +62,7 @@ class PurchaseControlPolicyWorkflow
|
||||
public function review(User $user, PurchaseControlPolicy $policy, string $decision, string $reason): void
|
||||
{
|
||||
$this->admin($user);
|
||||
abort_unless($policy->getTable() === $this->policies()->getModel()->getTable(), 403);
|
||||
abort_unless($policy->tenant_id === $user->tenant_id && $policy->created_by !== $user->id, 403);
|
||||
if (! in_array($decision, ['approved', 'rejected']) || trim($reason) === '') {
|
||||
throw ValidationException::withMessages(['cancellation' => 'Decisão e fundamento obrigatórios.']);
|
||||
@@ -47,8 +71,8 @@ class PurchaseControlPolicyWorkflow
|
||||
app(ConflictGuard::class)->lockCompany($user);
|
||||
$user = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->findOrFail($user->id);
|
||||
$this->admin($user);
|
||||
$policy = PurchaseControlPolicy::query()->lockForUpdate()->findOrFail($policy->id);
|
||||
$existing = PurchaseControlReview::query()->where('purchase_control_policy_id', $policy->id)->where('reviewed_by', $user->id)->first();
|
||||
$policy = $this->policies()->lockForUpdate()->findOrFail($policy->id);
|
||||
$existing = $this->reviews()->where('purchase_control_policy_id', $policy->id)->where('reviewed_by', $user->id)->first();
|
||||
if ($existing) {
|
||||
if ($existing->decision !== $decision || $existing->reason !== $reason) {
|
||||
throw ValidationException::withMessages(['cancellation' => 'Decisão anterior imutável.']);
|
||||
@@ -56,35 +80,35 @@ class PurchaseControlPolicyWorkflow
|
||||
|
||||
return;
|
||||
}
|
||||
if ($policy->status !== 'pending' || PurchaseControlPolicy::query()->max('revision') !== $policy->revision) {
|
||||
if ($policy->status !== 'pending' || $this->policies()->max('revision') !== $policy->revision) {
|
||||
throw ValidationException::withMessages(['cancellation' => 'Versão decidida ou substituída por proposta mais recente.']);
|
||||
}
|
||||
if ($decision === 'approved') {
|
||||
$ids = PurchaseControlReview::query()->where('purchase_control_policy_id', $policy->id)->where('decision', 'approved')->pluck('reviewed_by')->push($policy->created_by)->push($user->id)->unique();
|
||||
$ids = $this->reviews()->where('purchase_control_policy_id', $policy->id)->where('decision', 'approved')->pluck('reviewed_by')->push($policy->created_by)->push($user->id)->unique();
|
||||
$actors = User::query()->where('tenant_id', $user->tenant_id)->whereIn('id', $ids)->orderBy('id')->lockForUpdate()->get();
|
||||
if ($actors->count() !== $ids->count() || $actors->contains(fn (User $actor) => ! $actor->hasFunctions([PurchasingRole::Admin]))) {
|
||||
throw ValidationException::withMessages(['cancellation' => 'Proponente e validadores devem continuar elegíveis até à publicação.']);
|
||||
}
|
||||
}
|
||||
PurchaseControlReview::query()->create(['tenant_id' => $user->tenant_id, 'purchase_control_policy_id' => $policy->id, 'reviewed_by' => $user->id, 'decision' => $decision, 'reason' => $reason, 'created_at' => now()]);
|
||||
$this->reviews()->create(['tenant_id' => $user->tenant_id, 'purchase_control_policy_id' => $policy->id, 'reviewed_by' => $user->id, 'decision' => $decision, 'reason' => $reason, 'created_at' => now()]);
|
||||
if ($decision === 'rejected') {
|
||||
$policy->update(['status' => 'rejected', 'decided_at' => now()]);
|
||||
} elseif (PurchaseControlReview::query()->where('purchase_control_policy_id', $policy->id)->where('decision', 'approved')->count() === 2) {
|
||||
} elseif ($this->reviews()->where('purchase_control_policy_id', $policy->id)->where('decision', 'approved')->count() === 2) {
|
||||
$policy->update(['status' => 'approved', 'decided_at' => now()]);
|
||||
}
|
||||
app(RequisitionWorkflow::class)->audit($user, 'purchase_control.'.$decision, null, null, ['policy_id' => $policy->id, 'status' => $policy->status]);
|
||||
app(RequisitionWorkflow::class)->audit($user, $this->auditPrefix().'.'.$decision, null, null, ['policy_id' => $policy->id, 'status' => $policy->status]);
|
||||
});
|
||||
}
|
||||
|
||||
public function current(): ?PurchaseControlPolicy
|
||||
{
|
||||
return PurchaseControlPolicy::query()->where('status', 'approved')->where('valid_from', '<=', today()->format('Y-m-d'))->latest('revision')->first();
|
||||
return $this->policies()->where('status', 'approved')->where('valid_from', '<=', today()->format('Y-m-d'))->latest('revision')->first();
|
||||
}
|
||||
|
||||
public function requireCurrent(): PurchaseControlPolicy
|
||||
{
|
||||
$policy = $this->current();
|
||||
if (! $policy || $policy->valid_until < today()->format('Y-m-d') || $policy->mode !== 'unissued_award' || ! $policy->enabled) {
|
||||
if (! $policy || $policy->valid_until < today()->format('Y-m-d') || $policy->mode !== $this->mode() || ! $policy->enabled) {
|
||||
throw ValidationException::withMessages(['cancellation' => 'A empresa deve activar o cancelamento de adjudicações sem ordem emitida numa política vigente, com duas aprovações administrativas independentes.']);
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,8 @@ class PurchaseWorkflow
|
||||
// An already committed issue is returned, never re-created or re-snapshotted.
|
||||
$existing = PurchaseOrder::query()->where('requisition_id', $requisition->id)->first();
|
||||
if ($existing) {
|
||||
app(OrderCancellationWorkflow::class)->assertOperational($existing);
|
||||
|
||||
return $existing;
|
||||
}
|
||||
$this->assertNoPendingCancellation($award);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use LogicException;
|
||||
|
||||
/** Global technical inspection, with aggregate output only. Never repairs or removes files. */
|
||||
class ReceiptDocumentHealth
|
||||
{
|
||||
/** @return array<string,mixed> */
|
||||
public function inspect(int $limit = 1000): array
|
||||
{
|
||||
abort_unless(app()->runningInConsole() && ! auth()->check(), 403, 'Verificação reservada à operação técnica.');
|
||||
if ($limit < 1 || $limit > 10000) {
|
||||
throw new LogicException('Limite fora do intervalo permitido.');
|
||||
}
|
||||
$result = ['checked_at' => now()->toIso8601String(), 'ok' => false, 'complete' => false, 'reasons' => [],
|
||||
'documents_checked' => 0, 'files_checked' => 0, 'missing' => 0, 'invalid_reference' => 0, 'altered' => 0,
|
||||
'unreferenced_recent' => 0, 'unreferenced_older' => 0];
|
||||
if (! Schema::hasTable('receipt_documents')) {
|
||||
return [...$result, 'reasons' => ['not_installed']];
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($limit, $result) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
$disk = Storage::disk('receipt_documents');
|
||||
$root = realpath($disk->path(''));
|
||||
if ($root === false) {
|
||||
return [...$result, 'reasons' => ['storage_unavailable']];
|
||||
}
|
||||
$records = DB::table('receipt_documents')->orderBy('id')->limit($limit + 1)->get();
|
||||
$limited = $records->count() > $limit;
|
||||
foreach ($records->take($limit) as $document) {
|
||||
$result['documents_checked']++;
|
||||
$prefix = $document->tenant_id.'/'.$document->goods_receipt_id.'/';
|
||||
$name = substr($document->path, strlen($prefix));
|
||||
if (! str_starts_with($document->path, $prefix) || ! preg_match('/\A[A-Za-z0-9_-]+\.[A-Za-z0-9]+\z/D', $name)
|
||||
|| ! DB::table('goods_receipts')->where('id', $document->goods_receipt_id)->where('tenant_id', $document->tenant_id)->exists()) {
|
||||
$result['invalid_reference']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
$path = realpath($disk->path($document->path));
|
||||
if ($path === false || ! is_file($path)) {
|
||||
$result['missing']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
$normalizedPath = str_replace('\\', '/', $path);
|
||||
$normalizedRoot = str_replace('\\', '/', $root).'/';
|
||||
$withinRoot = PHP_OS_FAMILY === 'Windows'
|
||||
? str_starts_with(strtolower($normalizedPath), strtolower($normalizedRoot))
|
||||
: str_starts_with($normalizedPath, $normalizedRoot);
|
||||
if (! $withinRoot) {
|
||||
$result['invalid_reference']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
$size = filesize($path);
|
||||
if ($size !== (int) $document->size || $size < 1 || $size > 2097152 || hash_file('sha256', $path) !== $document->sha256) {
|
||||
$result['altered']++;
|
||||
}
|
||||
}
|
||||
// Lazy listing and an independent ceiling also bound unreferenced-file inspection.
|
||||
foreach ($disk->getDriver()->listContents('', true) as $entry) {
|
||||
if (! $entry->isFile()) {
|
||||
continue;
|
||||
}
|
||||
if ($result['files_checked'] >= $limit) {
|
||||
$limited = true;
|
||||
break;
|
||||
}
|
||||
$result['files_checked']++;
|
||||
if (! DB::table('receipt_documents')->where('path', $entry->path())->exists()) {
|
||||
$age = now()->getTimestamp() - $disk->lastModified($entry->path());
|
||||
$result[$age >= 3600 ? 'unreferenced_older' : 'unreferenced_recent']++;
|
||||
}
|
||||
}
|
||||
$result['complete'] = ! $limited;
|
||||
foreach (['missing', 'invalid_reference', 'altered', 'unreferenced_recent', 'unreferenced_older'] as $reason) {
|
||||
if ($result[$reason] > 0) {
|
||||
$result['reasons'][] = $reason;
|
||||
}
|
||||
}
|
||||
if ($limited) {
|
||||
$result['reasons'][] = 'limit_reached';
|
||||
}
|
||||
$result['ok'] = $result['reasons'] === [];
|
||||
|
||||
return $result;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,7 @@ class ReceiptWorkflow
|
||||
$this->conflicts->lockCompany($user);
|
||||
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Warehouse]);
|
||||
$order = PurchaseOrder::query()->lockForUpdate()->findOrFail($order->id);
|
||||
app(OrderCancellationWorkflow::class)->assertOperational($order);
|
||||
Gate::forUser($user)->authorize('create', [GoodsReceipt::class, $order]);
|
||||
$deliveryNote = Str::squish(Str::upper($data['delivery_note']));
|
||||
if ($deliveryNote === '') {
|
||||
@@ -219,7 +220,11 @@ class ReceiptWorkflow
|
||||
$writer = app(ReceiptStockMovement::class)->for($user);
|
||||
$basis = $writer->preview($item->id);
|
||||
$result = $writer->write($item->id, $basis['source_hash']);
|
||||
$movement = StockMovement::query()->findOrFail($result['movement_id']);
|
||||
$movementId = $result['movement_id'] ?? null;
|
||||
if (! is_int($movementId)) {
|
||||
throw new \LogicException('Resposta inválida do escritor de stock da recepção.');
|
||||
}
|
||||
$movement = StockMovement::query()->findOrFail($movementId);
|
||||
$sourceInput = collect($inputs)->firstWhere('purchase_order_item_id', $line['purchase_order_item_id']);
|
||||
app(InventoryLots::class)->receive($user, $movement, $sourceInput['lots'] ?? [], $data['received_on']);
|
||||
app(AutomaticPurchases::class)->signal($user, $line['stock_item_id'], $warehouse->id);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\PeopleProcess;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class ReceivedCandidateAvailability
|
||||
{
|
||||
/** Add received availability only to the authorized read projection, never to decision matrices.
|
||||
* @param list<array<string,mixed>> $rows
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function attach(User $actor, PeopleProcess $record, array $rows): array
|
||||
{
|
||||
if ($rows === [] || ! Schema::hasTable('candidate_triage') || ! Schema::hasTable('candidate_submissions')) {
|
||||
return $rows;
|
||||
}
|
||||
$current = User::query()->where('tenant_id', $record->tenant_id)->whereKey($actor->id)->first();
|
||||
if ($current === null || ! app(CandidateTriage::class)->canRead($current, $record)) {
|
||||
return $rows;
|
||||
}
|
||||
$sources = DB::table('candidate_triage as t')->join('candidate_submissions as s', function (JoinClause $join) {
|
||||
$join->on('s.id', '=', 't.candidate_submission_id')->on('s.tenant_id', '=', 't.tenant_id')->on('s.people_process_id', '=', 't.people_process_id');
|
||||
})->where('t.tenant_id', $record->tenant_id)->where('t.people_process_id', $record->id)->where('t.outcome', 'accepted_for_analysis')
|
||||
->whereIn('t.people_application_id', array_column($rows, 'id'))
|
||||
->get(['t.people_application_id', 't.profile_version', 's.id', 's.reference', 's.snapshot'])->keyBy('people_application_id');
|
||||
|
||||
return array_map(function (array $row) use ($sources) {
|
||||
$source = $sources->get($row['id']);
|
||||
if ($source === null) {
|
||||
return $row;
|
||||
}
|
||||
$snapshot = json_decode($source->snapshot, true, 32, JSON_THROW_ON_ERROR);
|
||||
$data = $snapshot['profile']['data'] ?? [];
|
||||
$main = $this->location($data['availability'] ?? null);
|
||||
if ($main === null || ($snapshot['profile']['version'] ?? null) !== $source->profile_version) {
|
||||
return $row;
|
||||
}
|
||||
$additional = [];
|
||||
$references = [];
|
||||
foreach ($data['additional_availability'] ?? [] as $index => $location) {
|
||||
$value = $this->location($location);
|
||||
if ($value !== null) {
|
||||
$additional[] = $value;
|
||||
$references[] = $this->reference($data['additional_availability_references'][$index] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
return [...$row, 'received_availability' => ['submission_id' => $source->id, 'reference' => $source->reference, 'profile_version' => $source->profile_version,
|
||||
'availability' => $main, 'additional_availability' => $additional,
|
||||
'availability_reference' => $this->reference($data['availability_reference'] ?? null), 'additional_availability_references' => $references]];
|
||||
}, $rows);
|
||||
}
|
||||
|
||||
/** @return array{province:string,district:string,locality:string}|null */
|
||||
private function location(mixed $value): ?array
|
||||
{
|
||||
if (! is_array($value) || ! is_string($value['province'] ?? null) || ! is_string($value['district'] ?? null) || ! is_string($value['locality'] ?? null)
|
||||
|| trim($value['province']) === '' || trim($value['district']) === '' || trim($value['locality']) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['province' => $value['province'], 'district' => $value['district'], 'locality' => $value['locality']];
|
||||
}
|
||||
|
||||
/** @return array{country:string,catalogue:string,record_id:string}|null */
|
||||
private function reference(mixed $value): ?array
|
||||
{
|
||||
if (! is_array($value) || ($value['country'] ?? null) !== 'MZ' || ! is_string($value['catalogue'] ?? null) || ! preg_match('/^[a-f0-9]{64}$/D', $value['catalogue'])
|
||||
|| ! is_string($value['record_id'] ?? null) || ! preg_match('/^[0-9]{1,20}$/D', $value['record_id'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['country' => 'MZ', 'catalogue' => $value['catalogue'], 'record_id' => $value['record_id']];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Supplier;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class SupplierGeography
|
||||
{
|
||||
/** Current supplier registration only; never infer geography from address text or evaluation snapshots.
|
||||
* @return Builder<Supplier>
|
||||
*/
|
||||
public function matching(string $location): Builder
|
||||
{
|
||||
$geography = app(AdministrativeGeography::class);
|
||||
abort_unless($geography->enabled(), 503);
|
||||
|
||||
return Supplier::query()->where('geography_reference->country', 'MZ')
|
||||
->where('geography_reference->catalogue', $geography->catalogue()['manifest']['source_sha256'])
|
||||
->whereIn('geography_reference->record_id', $geography->descendants($location));
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,7 @@ class SupplyCoordination
|
||||
if ($kind === 'delivery') {
|
||||
$line = PurchaseOrderItem::query()->whereKey($data['purchase_order_item_id'])->firstOrFail();
|
||||
$order = PurchaseOrder::query()->whereKey($line->purchase_order_id)->firstOrFail();
|
||||
app(OrderCancellationWorkflow::class)->assertOperational($order);
|
||||
$snapshot['order'] = $order->only(['id', 'number']);
|
||||
$snapshot['order_line'] = $line->only(['id', 'quantity', 'unit', 'description']);
|
||||
}
|
||||
@@ -204,6 +205,7 @@ class SupplyCoordination
|
||||
if ($kind === 'delivery') {
|
||||
$line = PurchaseOrderItem::query()->whereKey($data['purchase_order_item_id'])->firstOrFail();
|
||||
$order = PurchaseOrder::query()->whereKey($line->purchase_order_id)->firstOrFail();
|
||||
app(OrderCancellationWorkflow::class)->assertOperational($order);
|
||||
$stock = StockItem::query()->whereKey($data['stock_item_id'])->firstOrFail();
|
||||
$linked = $order->snapshot['catalogue_sources'][$line->requisition_item_id]['stock_item_id'] ?? null;
|
||||
$this->require($order->status === 'issued' && $line->unit === $stock->unit && ($linked === null || $linked === $stock->id), 'data.stock_item_id', 'A confirmação deve respeitar o artigo e unidade da ordem emitida.');
|
||||
|
||||
@@ -153,6 +153,9 @@ class SupplyPlanning
|
||||
$warehouse = isset($data['warehouse_id']) ? Warehouse::query()->whereKey($data['warehouse_id'])->firstOrFail() : null;
|
||||
$supplier = isset($data['supplier_id']) ? Supplier::query()->whereKey($data['supplier_id'])->lockForUpdate()->firstOrFail() : null;
|
||||
$order = isset($data['purchase_order_id']) ? PurchaseOrder::query()->whereKey($data['purchase_order_id'])->firstOrFail() : null;
|
||||
if ($order !== null) {
|
||||
app(OrderCancellationWorkflow::class)->assertOperational($order);
|
||||
}
|
||||
if ($kind === 'agreement' && $state === 'approved') {
|
||||
$this->check($supplier !== null && $supplier->eligible(), 'supplier_id', 'O fornecedor deve estar activo e com revisão documental vigente.');
|
||||
$this->check(! app(ConflictGuard::class)->hasConflict($actor->id, $supplier->id, null), 'supplier_id', 'Existe conflito declarado com este fornecedor.');
|
||||
|
||||
@@ -15,6 +15,27 @@ use Illuminate\Support\Facades\Schema;
|
||||
/** Read-only workdesk over the existing request, event and reservation journals. */
|
||||
class WarehouseWorkdesk
|
||||
{
|
||||
/** @param array<string,mixed> $filters
|
||||
* @return Builder<WarehouseRequest>
|
||||
*/
|
||||
public function filteredBase(User $actor, array $filters): Builder
|
||||
{
|
||||
$base = $this->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.'%'));
|
||||
}
|
||||
}
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
public function canView(User $actor, WarehouseRequest $request): bool
|
||||
{
|
||||
return $actor->tenant_id === $request->tenant_id && (
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace App\Services;
|
||||
use App\Models\AdmissionDecision;
|
||||
use App\Models\ExternalPayment;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\OrderCancellation;
|
||||
use App\Models\OrderCancellationPolicy;
|
||||
use App\Models\PeopleProcess;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\QualificationAssessment;
|
||||
@@ -35,6 +37,29 @@ class WorkflowTaskInbox
|
||||
$rows = $area === 'people' ? $this->people($actor) : [...$this->purchases($actor), ...$this->suppliers($actor), ...$this->quotations($actor), ...$this->documentary($actor), ...$this->formalReviews($actor), ...$this->supply($actor), ...$this->suggestions($actor), ...$this->awards($actor), ...$this->warehouse($actor), ...$this->inventoryCorrections($actor), ...$this->lotSetups($actor), ...$this->finance($actor)];
|
||||
if ($area === 'procurement') {
|
||||
$rows = [...$rows, ...$this->supplierImprovements($actor), ...$this->supplierQuality($actor), ...$this->supplyFollowUp($actor), ...$this->lotQuality($actor)];
|
||||
if (config('order_cancellation.enabled') && Schema::hasTable('order_cancellations')) {
|
||||
foreach (OrderCancellation::query()->where('status', 'pending')->lazyById(100) as $record) {
|
||||
if (app(OrderCancellationWorkflow::class)->canDecide($actor, $record)) {
|
||||
$rows[] = ['id' => 'order-cancellation:'.$record->id, 'module' => 'finance', 'reference' => $record->snapshot['order_number'],
|
||||
'label' => 'Conferir cancelamento de ordem emitida', 'task' => 'order_cancellation_review', 'status' => 'pending', 'blocked' => false,
|
||||
'created_at' => $this->date($record->created_at), 'due_on' => null, 'href' => '/ordens/cancelamentos?cancellation_id='.$record->id.'#cancellation-'.$record->id];
|
||||
}
|
||||
}
|
||||
foreach (OrderCancellationPolicy::query()->where('status', 'pending')->lazyById(100) as $policy) {
|
||||
if (app(OrderCancellationPolicyWorkflow::class)->canReview($actor, $policy)) {
|
||||
$rows[] = ['id' => 'order-cancellation-policy:'.$policy->id, 'module' => 'finance', 'reference' => 'Política de cancelamento v'.$policy->revision,
|
||||
'label' => 'Conferir política de cancelamento de ordens', 'task' => 'order_cancellation_policy_review', 'status' => 'pending', 'blocked' => false,
|
||||
'created_at' => $this->date($policy->created_at), 'due_on' => null, 'href' => '/ordens/cancelamentos#policy-'.$policy->id];
|
||||
}
|
||||
}
|
||||
foreach (OrderCancellation::query()->where('status', 'approved')->whereNull('revised_requisition_id')->lazyById(100) as $record) {
|
||||
if (app(OrderCancellationWorkflow::class)->canRevise($actor, $record)) {
|
||||
$rows[] = ['id' => 'order-cancellation-revision:'.$record->id, 'module' => 'purchases', 'reference' => $record->snapshot['order_number'],
|
||||
'label' => 'Preparar nova requisição após cancelamento de ordem', 'task' => 'order_cancellation_revision', 'status' => 'approved', 'blocked' => false,
|
||||
'created_at' => $this->date($record->reviewed_at), 'due_on' => null, 'href' => '/ordens/cancelamentos?cancellation_id='.$record->id.'#cancellation-'.$record->id];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
usort($rows, fn ($a, $b) => strcmp($a['created_at'] ?? '', $b['created_at'] ?? '') ?: strnatcmp($a['id'], $b['id']));
|
||||
|
||||
|
||||
+2
-1
@@ -12,6 +12,7 @@ use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
|
||||
use Illuminate\Http\Request;
|
||||
use Laravel\Fortify\Features;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
@@ -20,7 +21,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->redirectGuestsTo(fn (Request $request) => EvaluationNavigation::local($request) ? '/avaliar' : route('login'));
|
||||
$middleware->redirectGuestsTo(fn (Request $request) => EvaluationNavigation::local($request) && ! Features::enabled(Features::twoFactorAuthentication()) ? '/avaliar' : route('login'));
|
||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
||||
$middleware->prependToPriorityList(AuthenticatesRequests::class, EnterLocalEvaluation::class);
|
||||
$middleware->prependToPriorityList(EnterLocalEvaluation::class, PublicDevelopmentNavigation::class);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'enabled' => filter_var(env('SGC_GEOGRAPHY_ENABLED', false), FILTER_VALIDATE_BOOLEAN),
|
||||
'operational_enabled' => filter_var(env('SGC_OPERATIONAL_GEOGRAPHY_ENABLED', false), FILTER_VALIDATE_BOOLEAN),
|
||||
'catalogue_sha256' => '28530f11f528e6f715396991e1294872c7e6a8b260d08a84e7d8fe17e5920457',
|
||||
'source_sha256' => '70996d9a9133785a848de90bedb4bf685e24d8f823cd153c0adc43eac642a292',
|
||||
];
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
|
||||
return ['enabled' => false, 'reversal_sha256' => '5471d9f826f47012415bf9a7086d4d7316ab8550b58e497f7432b94514a18bfa'];
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('order_cancellation_policies', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('tenant_id')->constrained();
|
||||
$t->foreignId('created_by')->constrained('users');
|
||||
$t->unsignedInteger('revision');
|
||||
$t->string('status')->default('pending');
|
||||
$t->string('mode');
|
||||
$t->boolean('enabled');
|
||||
$t->date('valid_from');
|
||||
$t->date('valid_until');
|
||||
$t->text('reason');
|
||||
$t->timestamp('decided_at')->nullable();
|
||||
$t->timestamps();
|
||||
$t->unique(['tenant_id', 'revision']);
|
||||
});
|
||||
Schema::create('order_cancellation_policy_reviews', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('tenant_id')->constrained();
|
||||
$t->foreignId('purchase_control_policy_id')->constrained('order_cancellation_policies');
|
||||
$t->foreignId('reviewed_by')->constrained('users');
|
||||
$t->string('decision');
|
||||
$t->text('reason');
|
||||
$t->timestamp('created_at');
|
||||
$t->unique(['purchase_control_policy_id', 'reviewed_by'], 'order_cancel_policy_reviewer_unique');
|
||||
});
|
||||
Schema::create('order_cancellations', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('tenant_id')->constrained();
|
||||
$t->foreignId('purchase_order_id')->constrained();
|
||||
$t->foreignId('policy_id')->constrained('order_cancellation_policies');
|
||||
$t->foreignId('created_by')->constrained('users');
|
||||
$t->uuid('operation_key');
|
||||
$t->string('input_hash', 64);
|
||||
$t->string('basis_hash', 64);
|
||||
$t->json('snapshot');
|
||||
$t->string('status')->default('pending');
|
||||
$t->text('reason');
|
||||
$t->text('evidence');
|
||||
$t->foreignId('reviewed_by')->nullable()->constrained('users');
|
||||
$t->text('review_reason')->nullable();
|
||||
$t->timestamp('reviewed_at')->nullable();
|
||||
$t->foreignId('revised_requisition_id')->nullable()->constrained('requisitions');
|
||||
$t->timestamps();
|
||||
$t->unique(['tenant_id', 'created_by', 'operation_key'], 'order_cancel_operation_unique');
|
||||
});
|
||||
foreach (['pending', 'approved'] as $state) {
|
||||
DB::statement("CREATE UNIQUE INDEX order_cancel_{$state}_unique ON order_cancellations (purchase_order_id) WHERE status = '{$state}'");
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
throw new LogicException('Preservar políticas, pedidos e decisões de cancelamento.');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
foreach (['tenants', 'suppliers'] as $table) {
|
||||
Schema::table($table, function (Blueprint $blueprint) {
|
||||
$blueprint->json('geography_reference')->nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
throw new LogicException('Preservar referências geográficas: reversão exige plano explícito.');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
foreach (['warehouses', 'equipment'] as $table) {
|
||||
Schema::table($table, function (Blueprint $blueprint) {
|
||||
$blueprint->json('geography_reference')->nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
throw new LogicException('Preservar localizações operacionais; reversão exige plano explícito.');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
# A1 — documento individual de entrega interna
|
||||
|
||||
A requisição interna é o documento de saída, sem factura. A impressão conjunta da página continua disponível. O bloco A1 acrescenta «Imprimir apenas esta entrega» junto de cada entrega registada, para emitir uma reprodução A4 ou guardar PDF pelo navegador. Não há novos campos ou cadastros.
|
||||
|
||||
Cada reprodução identifica RA e entrega, apenas as quantidades efectivamente saídas nesse evento, artigo/unidade, destino, equipamento quando aplicável, centro de custo, requisitante, responsável da entrega e operação de stock de origem. A autorização anterior à entrega é reutilizada com responsável, data e referência. A confirmação do requisitante é apresentada quando existe, ou indicada como pendente à data da consulta.
|
||||
|
||||
O documento não mistura saldo pedido, reservado, cancelado ou outras entregas. Conserva a saída original; devoluções/correcções posteriores continuam nos processos respectivos e não são abatidas silenciosamente nesta reprodução. A confirmação é uma consulta actual, não uma alteração do evento original. Não equivale a factura, assinatura digital ou saldo actual.
|
||||
|
||||
A rota aplica a visibilidade nativa da requisição e o isolamento por empresa antes de consultar o evento. Eventos de outra requisição ou de outra acção não são documentos de entrega e devolvem404. Sem a instalação de eventos, devolve503. A resposta é privada sem cache; texto introduzido pelos utilizadores é escapado. A impressão não executa comandos, reserva, saída, recepção, facturação, reposição ou decisões de compra.
|
||||
|
||||
Não existe migração nem activação adicional. Não foram emitidas entregas de negócio no piloto para ensaiar o bloco. Percursos com stock e confirmações são validados em bases descartáveis; consulta HTTP do piloto permanece de leitura. A revisão visual completa e impressão física permanecem separadas.
|
||||
|
||||
## Provas de fecho
|
||||
|
||||
Bateria integrada WarehouseFulfilmentTest e WarehouseRequestsTest: 20 testes aprovados/363 verificações. Inclui submissão, autorização, reservas, entregas parciais, confirmação, isolamento e ausência de mutações pela impressão. PHPStan, TypeScript, compilação de produção e formatação aprovados. Provas warehouse-delivery-print.json, warehouse-delivery-print-pg.json e warehouse-delivery-print-http.json. A consulta HTTP confirmou os índices por Administração e Armazém; o piloto não tinha requisições internas, pelo que não prova emissão documental com dados nessa instalação. Não somar a bateria dirigida PostgreSQL como cobertura empresarial adicional.
|
||||
@@ -0,0 +1,13 @@
|
||||
# A2 — reprodução imprimível da requisição interna
|
||||
|
||||
03/11/2026. A ficha já permitia imprimir o ecrã. O botão passa a abrir uma versão dedicada, sem formulários de comando, reutilizando WarehouseFulfilment.progress e o snapshot original da requisição.
|
||||
|
||||
GET /stock/requisicoes/{id}/imprimir conserva o mesmo controlo de leitura de WarehouseWorkdesk.canView e o isolamento por empresa. A consulta é privada, sem cache e sem mutações. PostgreSQL usa a mesma transacção de leitura repetível do detalhe existente.
|
||||
|
||||
O documento reúne requisitante, armazém, centro de custo, finalidade, destino e equipamento por linha. Separa pedido, reserva actual, entrega acumulada, saldo por entregar e quantidade cancelada. Apresenta estado, versão e data da consulta, intervenientes preservados nos eventos, autorização e confirmação de recepção. Cada entrega remete para o seu documento individual A1; receber uma entrega parcial não confirma as restantes.
|
||||
|
||||
Rascunho, reserva e pedido não são apresentados como saída física. A requisição continua a ser o documento interno, sem factura. Entregas acumuladas não descontam devoluções ou correcções posteriores. Se a estrutura de reservas não existir, a indisponibilidade fica explícita. HTML escapa textos registados. Nenhuma assinatura ou nova aprovação é criada.
|
||||
|
||||
Sem migração, configuração nova ou alteração de perfis. MFA permanece desligado. Os testes usam apenas bases descartáveis e verificam impressão repetida sem alteração de pedidos, reservas, eventos, stock, orçamento ou auditoria; acessos entre empresas e perfis; rascunho, entrega parcial, cancelamento do saldo e confirmação. Resultados e limites em docs/proofs/warehouse-request-print.json e warehouse-request-print-pg.json. Não constitui revisão visual completa nem aceitação comercial.
|
||||
|
||||
Validação A2: bateria conjunta WarehouseFulfilmentTest + WarehouseRequestsTest aprovada, 23 testes/408 verificações; PostgreSQL descartável 3 testes/45 verificações, base removida. PHPStan, tipos, formatação, rotas e compilação aprovados. Sem revisão visual completa. Contagens não aditivas às provas anteriores.
|
||||
@@ -0,0 +1,15 @@
|
||||
# A3 — exportação do acompanhamento das requisições internas
|
||||
|
||||
03/11/2026. O acompanhamento passa a disponibilizar CSV integral com os filtros aplicados no ecrã: tarefa, estado, pesquisa por RA/requisitante/destino e armazém. A página seleccionada não limita a exportação. O botão usa os filtros efectivamente aplicados, não alterações ainda por submeter.
|
||||
|
||||
O cadastro e a exportação partilham WarehouseWorkdesk.filteredBase, visible e forView; estados usam a projecção nativa. A exportação exige os mesmos perfis da lista, relê o utilizador antes de gerar o conteúdo e respeita o âmbito da empresa, departamento e tarefa. Não concede direitos nem inclui requisições alheias ao âmbito. PostgreSQL usa transacção de leitura repetível, como a lista existente.
|
||||
|
||||
Cada linha do CSV representa um artigo de uma requisição, com unidade, destino e equipamento preservados. Quantidades pedida, reservada actualmente, entregue acumulada, por entregar e cancelada vêm de WarehouseFulfilment.progress. Não se somam artigos com unidades diferentes. A disponibilidade da estrutura de reservas é explícita; zeros com estrutura indisponível não certificam ausência de compromissos. Entregas acumuladas não descontam devoluções/correcções posteriores. Inclui ligação à ficha e metadados da data/filtros; não é factura nem nova saída.
|
||||
|
||||
Exportação em blocos de 100 requisições, sem carregar todas as linhas simultaneamente. UTF-8 com BOM, separador ponto e vírgula e protecção de células interpretáveis como fórmulas. Texto com separadores é escapado pelo escritor CSV. Repetir a consulta não cria eventos, reservas, compras ou movimentos.
|
||||
|
||||
Sem migrações, activação de políticas, alteração de aprovações ou de MFA. Dados e contas do piloto preservados. Validação dirigida documentada em docs/proofs/warehouse-request-export.json e warehouse-request-export-pg.json; as contagens não se somam a provas históricas. Não constitui teste de carga nem revisão visual completa.
|
||||
|
||||
Validação A3: WarehouseFulfilmentTest + WarehouseRequestsTest, 26 testes/437 verificações aprovados. PostgreSQL descartável, 3 testes/29 verificações aprovados e base removida. PHPStan, tipos, formatação e diff aprovados. Contagens não aditivas às provas anteriores.
|
||||
|
||||
Compilação de produção concluída com êxito; rota de exportação conferida. Nenhum ensaio visual completo foi declarado.
|
||||
@@ -0,0 +1,11 @@
|
||||
# D1 — consulta de metadados dos módulos
|
||||
|
||||
O menu Auditoria / Eventos dos módulos reúne eventos registados na empresa. A competência própria `audit_metadata_read` permite consulta e CSV e não é atribuída automaticamente, inclusive a administradores. O cadastro de perfis passa a apresentar esta opção para uma atribuição empresarial explícita pelos percursos nativos. Manutenção, atribuições expiradas ou revogadas não dão acesso.
|
||||
|
||||
O âmbito é a empresa inteira e pode incluir eventos de Pessoas, Compras, Armazém e configuração. Cada linha contém apenas identificador do evento,data,acção e identificador do utilizador. Não são carregados/exportados os campos before/after,nomes,documentos,condições financeiras ou dados privados do processo. A nova competência não autoriza consultar os respectivos processos ou executar decisões.
|
||||
|
||||
Paginação de25eventos pela ordem de registo,começando pelos mais antigos. Filtros opcionais de acção exacta,utilizador e datas inclusivas. Consulta e CSV usam a mesma pesquisa por empresa; CSV inclui todos os resultados filtrados,independentemente da página. A exportação percorre lotes de100 e protege células interpretáveis como fórmulas. Respostas privadas semcache e históricoInertia cifrado. PostgreSQL utiliza leitura consistente por transacção; o acesso é conferido novamente no início da transmissão.
|
||||
|
||||
Esta consulta não é exportação integral de auditoria,arquivo inviolável,retenção,assinatura ou monitorização de acessos. Não escreve eventos pela mera consulta nem certifica protecção contra administradoresSQL. Os campos omitidos não são reconstruídos por esta funcionalidade. Volume elevado e revisão visual têm validação própria pendente.
|
||||
|
||||
Sem migração ou novas permissões no piloto. A inclusão do menu é condicionada à competência; a recusaHTTP do piloto é esperada enquanto a atribuição não for autorizada. Provas audit-metadata.json,audit-metadata-pg.json e audit-metadata-http.json.
|
||||
@@ -0,0 +1,37 @@
|
||||
# C2 — cancelamento integral de ordens emitidas
|
||||
|
||||
Âmbito autorizado em03/11/2026 exclusivamente para implementação. `config/order_cancellation.php` mantém `enabled=false`. A migração000003 não foi aplicada ao piloto,não existem novas políticas publicadas nem ordens canceladas por esta entrega. MFA e acessos existentes permanecem inalterados.
|
||||
|
||||
## Percurso e responsabilidades
|
||||
|
||||
1. Uma política própria da empresa define permissão e vigência deste circuito. Reutiliza o mesmo motor de publicação das políticas de controlo de compras,com tabelas próprias:proponente administrativo e dois validadores administrativos distintos,que devem continuar elegíveis até à publicação. São três identidades administrativas;não há atribuição automática de perfis. A política anterior à emissão permanece separada.
|
||||
2. Compras pede cancelamento integral,com fundamento e referência/descrição da documentação de suporte. Não pode ser o requisitante original. Reutiliza ordem,fornecedor,artigos,valores e compromisso;chave de operação oculta permite repetição sem duplicar o pedido. Suporte é uma referência textual,não upload de ficheiro ou validação de autenticidade.
|
||||
3. Só é admitida ordem emitida sem qualquer recepção ou factura,mesmo que rejeitada/estornada. Os pagamentos nativos dependem de factura;existência desta já bloqueia o circuito. Não inclui transferências bancárias,liquidações,reembolsos,notas de crédito ou documentos posteriores.
|
||||
4. O pedido pendente bloqueia nova execução:recepção,registo de factura,reemissão e preparação/alteração das ligações de importação/entrega. Oculta as opções operacionais aplicáveis,sem apagar os documentos. A suspensão persiste se a capacidade for desligada;não desligar como forma de contornar um pedido pendente.
|
||||
5. Finanças decide com identidade distinta do requisitante,autor da adjudicação,emissor da ordem e autor do pedido. Função,empresa,conflitos,base e política são novamente conferidos sob bloqueio. A rejeição preserva ordem e compromisso e permite retomar execução. Pode rejeitar um pedido cuja política expirou/mudou;aprovação exige a política publicada preservada ainda vigente.
|
||||
6. Aprovação e reversão do compromisso são uma transacção única,com auditoria. Replicar a mesma decisão pela mesma identidade não volta a lançar valores;alterar decisão/fundamento é recusado. Pedidos rejeitados são preservados;outro pedido exige nova chave e todas as verificações novamente.
|
||||
7. Só o requisitante original,na mesma área e com autoridade nativa para requisitar,pode preparar o rascunho ligado,com nova data e justificação. Reutiliza os artigos/estimativa originais,sem copiar propostas,avaliações,validações técnicas,aprovações ou reserva. A ligação evita duplicar rascunhos após repetição e o novo processo segue os percursos existentes.
|
||||
|
||||
## Documento original e reversão
|
||||
|
||||
A ordem emitida é imutável. O seu estado original permanece `issued`;o estado operacional passa a «cancelamento pendente» ou «cancelada» pela projecção dos registos de cancelamento. A requisição é marcada cancelada na aprovação. Número,linhas,snapshot e aprovações originais não são reescritos. A consulta e impressão identificam o estado operacional;reprodução do original não autoriza execução.
|
||||
|
||||
A capacidade aditiva `packages/order-cancellation/src/CommitmentReversal.php` escreve no mesmo livro canónico `budget_entries`,sem substituir o pacote orçamental instalado. O seu SHA-256 é conferido antes de executar. Reverte exactamente o compromisso original,na dotação e base originais,por novo lançamento `order-cancel-ID`,sem recalcular IVA,câmbio ou estimativa. Exige compromisso integral ainda disponível,reserva zero e dotação aprovada. Divergências bloqueiam a decisão. Documentos legados explicitamente sem controlo e sem lançamentos não geram uma reversão fictícia;se o snapshot declara controlo mas faltam lançamentos,o circuito recusa.
|
||||
|
||||
Os18hashes dos artefactos existentes são conferidos separadamente;isto não representa nova instalação de pacotes noSGF ou publicação comercial da capacidade. ApenasMZN,como o controlo instalado. A função de reversão exige a transacção e o bloqueio da empresa fornecidos pelo adaptador;outros alojamentos precisam de integração própria,não devem chamar o escritor isoladamente.
|
||||
|
||||
## Integração e limites
|
||||
|
||||
Pedidos e políticas aparecem nas tarefas elegíveis;após cancelamento,o requisitante vê a preparação do rascunho. Ligações focadas conservam acessíveis pedidos pendentes antigos. Registos são paginados e respeitam visibilidade nativa por processo/empresa. A origem abre a ordem ou o processo conforme a autoridade existente,sem conceder acesso adicional.
|
||||
|
||||
Desactivar tecnicamente a capacidade não resolve pendências;estas conservam a suspensão. A resolução exige capacidade disponível e decisão autorizada. Publicar política desactivada impede novas aprovações,mas permite rejeitar pendências enquanto a capacidade técnica estiver disponível. Migração,activação e políticas reais exigem preparação e autorização próprias. Nenhuma decisão é tomada automaticamente,nem são enviados avisos externos.
|
||||
|
||||
Não inclui cancelamento parcial,recepções/facturas/pagamentos existentes,devoluções,desalfandegamento,compensações,reembolsos ou assinatura digital. Revisão visual,carga e instalação comercial permanecem separadas. Os testes usamSQLite emmemória ePostgreSQL descartável;outros drivers não foram certificados. Provas issued-order-cancellation.json e issued-order-cancellation-pg.json.
|
||||
|
||||
## Evidências de fecho
|
||||
|
||||
Regressão dos percursos afectados:159testes/2014verificações de compras,ordens,recepção,facturas e orçamento. Fecho dirigido após as ligações finais:43testes/284verificações de cancelamento e recepção. Estas baterias sobrepõem-se e não devem ser somadas. PostgreSQL descartável12testes/91verificações,mais3ensaios de processos concorrentes com espera no bloqueio da empresa observada:pedido antes da recepção,recepção antes do pedido e dupla aprovação com uma só reversão. Bases descartáveis removidas.
|
||||
|
||||
PHPStan sem erros nos ficheiros afectados,Pint,formataçãofrontend,TypeScript,compilação e18hashes originais aprovados. Capacidade nova de reversão tem hash próprio conferido. A análise passou a reconhecer também o escritor de recepção já instalado,com validação do identificador devolvido,sem alterar o artefacto selado.
|
||||
|
||||
Inspecção transaccional só de leitura do piloto confirmou estrutura000003 ausente,zero políticas/cancelamentos,camposMFA vazios e nenhuma atribuição da competência deAuditoria. HTTP porAdmin,Compras eFinanças confirmou circuito indisponível503 e ordens existentes consultáveis,sem comandos de cancelamento/política. Fonte:issued-order-cancellation-pilot.json e issued-order-cancellation-http.json. Sem revisão visual completa ou implantação comercial declaradas.
|
||||
@@ -2596,3 +2596,162 @@ Matriz e docs/operacao-reposicao.md actualizados. Sem migração,contas/grants,d
|
||||
Adoptado o plano docs/plano-entregas-sgc.md: construir blocos funcionais e validar em conjunto no fecho, mantendo verificações críticas dirigidas durante a implementação. R1 acrescenta evidências privadas à recepção confirmada, reutilizando origem e responsabilidades nativas, com repetição segura, integridade na descarga, auditoria e navegação para o processo. Não repete stock, aprovação ou recepção física.
|
||||
|
||||
Validação final: ReceiptDocumentBlockTest + ReceiptStockTest, 36 testes/271 verificações; PostgreSQL descartável 5/78, base removida. PHPStan sem erros, TypeScript, compilação de produção, Pint, diff e 18 hashes de execução conferidos. Prova docs/proofs/receipt-block.json e limites em docs/recepcao-documental.md. Sem revisão visual completa, carga ou instalação no piloto. Migração documental 2026_11_03_000002 pendente de autorização específica; não aplicar incidentalmente MFA. Sem novas contas, permissões ou decisões de negócio. R1 não declara todo o módulo Recepção concluído.
|
||||
|
||||
## 03/11/2026 — instalação R1 autorizada e bloco R2 entregue
|
||||
|
||||
O proprietário autorizou instalar documentos da recepção no piloto e continuar os blocos seguintes. Aplicada exclusivamente a migração documental000002; cópia restaurada/conferida em113tabelas,112tabelas de negócio preservadas e base de restauro removida. Armazenamento privado conferido,sem novos documentos de negócio,contas,grants ou decisões. MFA não activado. Provas receipt-block-installation[-http].json; não repetir a instalação ou solicitar novamente esta autorização.
|
||||
|
||||
R2 acrescenta impressão A4/guardar PDF pelo navegador,sem campos adicionais: confirmação original,quantidades,qualidade,referências de estornos executados e anexos consultáveis comSHA256,sem incorporar ficheiros privados. Consulta actual claramente distinguida do original,nativepolicy/tenant e resposta privada semcache. Não substitui assinatura digital,saldo efectivo ou aceitação visual. Bateria integrada39testes/295verificações;PostgreSQL3/24 descartável;PHPStan,TypeScript,compilação,Pint,diff e18hashes aprovados. HTTP porAdmin,Armazém,Compras,sem comandos de negócio. Provas receipt-print[-pg/-http].json; docs/recepcao-impressao.md. Sem migração adicional,alteração de aprovações/adjudicação/encomenda/SGF. Restantes blocos da matriz continuam pendentes; não confundir esta entrega com conclusão comercial integral.
|
||||
|
||||
## 03/11/2026 — A1: documento individual de entrega interna
|
||||
|
||||
Acrescentado documento imprimível por entrega,sem duplicar a impressão conjunta já existente. Reutiliza a requisição como documento de saída sem factura;inclui só as quantidades desse evento,artigo/unidade,destino/equipamento,centro de custo,responsáveis,autorização anterior e operação de stock. Confirmação actual do requisitante identificada ou pendente;original não é apresentado como saldo actual e devoluções/correcções conservam processos próprios.
|
||||
|
||||
Acesso nativo por requisição/empresa,acção deliver obrigatória,resposta privada semcache e texto escapado. Nenhum comando de negócio na emissão. Bateria integrada20testes/363verificações;PostgreSQL descartável2/37,base removida. PHPStan,TypeScript,compilação,Pint,formataçãofrontend,diff e18hashes aprovados. HTTP conferiu índicesAdmin/Armazém,ambos sem requisições internas;documentos positivos exercitados apenas nas bases descartáveis. Sem nova migração,grants,contas,entregas,facturas,decisões no piloto ou mudançaSGF. Prova docs/proofs/warehouse-delivery-print.json e variantes;docs/armazem-documento-entrega.md. Restantes conversões,activação de devoluções e aceitação operacional continuam separadas na matriz.
|
||||
|
||||
## 03/11/2026 — O1: integridade operacional dos documentos da recepção
|
||||
|
||||
Entregue sgc:receipt-documents-check,consulta técnica global de consola sem identidade interactiva. Cruza registos/empresa/recepção e armazenamento privado,detecta referências inválidas,ausência,divergência de tamanho/SHA256 e ficheiros sem registo,recentes ou de pelo menos1hora. Não interpreta idade como autorização de eliminação,não repara/apaga/nem altera auditoria ou aprovações. Saída só agregada e erros genéricos,limite independente de registos/ficheiros com incompletude explícita;sem cursor/capacidade geral certificada nem snapshot conjunto base/ficheiros.
|
||||
|
||||
Validação final14testes/135verificações integradas com upload,descarga e impressão;PostgreSQL descartável6/33,base removida. Duas empresas,sem mutações,inválidos/ausentes/alterados/sem referência,limites,estrutura/armazenamento indisponíveis e privacidade/recusa interactiva cobertos. PHPStan sem erros,Pint,diff e18hashes conferidos. Sem novo frontend,compilação,revisão visual,monitor recorrente,migração,comando no piloto,contas/grants,decisão de negócio ou alteraçãoSGF. Provas receipt-document-health[-pg].json e docs/operacao-documentos-recepcao.md. Retenção,limpeza autorizada,backup/restauro conjunto,serviço,reinício/capacidade e demais blocos mantêm estado próprio.
|
||||
|
||||
## 03/11/2026 — S1: integração de MFA com as entradas de avaliação
|
||||
|
||||
Quando MFA activo,o middleware SGC impede atalhos /avaliar e /demo,autoentrada do proprietário e navegação pública de desenvolvimento como alternativa à autenticação;login por palavra-passe e desafio Fortify nativo permanecem. MFA desligado conserva o piloto. Sem modificar o código do alojamentoSGF ou decisões/contas/grants.
|
||||
|
||||
Regressão36testes/230verificações;PostgreSQL descartável8/61,base removida;PHPStan,Pint,diff e18hashes aprovados. EntradasHTTP e desafio comCSRF;identidade autenticada testada directamente no middleware. Sem novo frontend,compilação ou revisão visual. Provas mfa-evaluation-boundary[-pg].json. ConsultaHTTP confirma piloto desligado,sem inscrições.
|
||||
|
||||
Preparada proposta concreta de instalar exclusivamente migração000001,três camposMFA anuláveis,vazios. Cópia consistente restaurada114tabelas;ensaio preservou112tabelas protegidas e colunas existentes de12contas,sem inscrições;base removida. Provas mfa-pilot-schema-preparation/proposal.json e docs/proposta-instalacao-mfa-piloto.md. Instalação real continua PENDENTE DE AUTORIZAÇÃO;activação e eventuais credenciais/recuperação exigem plano e autorização próprios. Não activar MFA nas contas sintéticas dependentes de atalhos sem preparar acesso e recuperação,ou aplicar uma migração geral por inferência.
|
||||
|
||||
## 03/11/2026 — estrutura MFA instalada e blocoP1 Pessoas entregue
|
||||
|
||||
Autorização específica do proprietário aplicada só à migraçãoMFA000001,três campos vazios. Nova cópia consistente restaurada114tabelas,112tabelas protegidas e colunas antigas de12contas preservadas,sem inscrições,palavras-passe,perfis ou aprovações alterados. Provas mfa-pilot-schema-authorization/installation.json;consultaHTTP confirma capacidade desligada. Não repetir instalação ou solicitar novamente autorização;activação/credenciais/recuperação permanecem fora do recorte.
|
||||
|
||||
P1 acrescenta dossiêJSON de preparação,schema sgc.people.process-dossier.v1,com versão e dados reutilizados do processo e matriz. Projecção única entre página/descarga,com acessos separados ao financeiro,entrevistas e minuta e autorização nativa por processo/empresa/validade. Sem anexos copiados,auditoria integral,comandos,envioRH ou formalização;fronteira PeopleContractHandoff continua a recusar minutas. Downloadprivado semcache;não é arquivo assinado e acesso revogado não elimina cópia já descarregada.
|
||||
|
||||
Validação final56testes/1684verificações dePeopleHiring/PeopleRegistry;PostgreSQL3/72,base descartável removida. PHPStan,Pint,formataçãofrontend,TypeScript,compilação,diff e18hashes aprovados. HTTP confirmouPessoas sem processos visíveis nessa conta;dossiês positivos apenas em bases descartáveis. Sem dados/decisões/grants dePessoas no piloto,nova migração paraP1,revisão visual completa ou mudançaSGF. Prova people-dossier[-pg/-http].json e docs/pessoas-dossie-preparacao.md. Matriz actualizada;formalização contratual e restantes blocos conservam critérios próprios.
|
||||
|
||||
## 03/11/2026 — D1: metadados de auditoria dos módulos
|
||||
|
||||
Consulta paginada25eventos pela ordem de registo e CSV de todos os resultados filtrados,com mesma pesquisa/âmbito por empresa e apenasid,data,acção,user_id. Competência audit_metadata_read nova,não atribuída automaticamente nem aAdmin,navegação condicionada e configurável pelos percursos nativos. Sem before/after,anexos,dados privados ou links que contornem acesso a processos;sem mutações por consulta. Filtros opcionais deacção/utilizador/datas,CSV emlotes100 com protecção de fórmulas,privacidade e snapshot de leitura. Não é exportação integral/retenção/protecção contraSQLadmin.
|
||||
|
||||
Validação41testes/410verificações comOrganização/Contexto;PostgreSQL7/85,base descartável removida;PHPStan,Pint,formataçãofrontend,TypeScript,compilação,diff e18hashes aprovados. HTTP recusaAdmin/Armazém no piloto sem a nova competência;MFA continua desligado. Sem migração,grants ou comandos de negócio no piloto. Provas audit-metadata[-pg/-http].json e docs/auditoria-metadados.md.
|
||||
|
||||
Preparada proposta concreta audit-access-proposal.json,documentada em docs/proposta-acesso-auditoria-piloto.md:perfil sóaudit_metadata_read emprocurement.internal,acrescido apenas a6/8,conservando perfil7 e competências existentes. Ensaio nativo emcópia restaurada114tabelas,permissões preservadas e tabelas protegidas intactas;base removida,nenhumgrantreal. Versão actual27,proposta30 por3comandos auditados;atribuições6/8 são reemitidas pelo motor com novo fundamento/IDs,estado anterior conservado nohistórico. PENDENTE DE AUTORIZAÇÃO por novo acesso a metadados de todos os módulos daempresa;sem palavras-passe,contas,MFA,aprovações,decisões ouSGF. Não activar por inferência de continuação genérica.
|
||||
|
||||
## 03/11/2026 — E1: Equipamentos ligados a Compras e Armazém
|
||||
|
||||
A continuação genérica do proprietário manteve a proposta D1 pendente;nenhum novo acesso deAuditoria foi atribuído. MFA permanece desligado e aprovações preservadas.
|
||||
|
||||
Ficha por equipamento reutiliza dados existentes e separa requisições de compra visíveis de operações físicas. Listas independentes15/página pela ordem de registo,sem preços ou snapshots integrais. Âmbito nativo por função/empresa/departamento e visibilidade própria deRequisition.visibleTo. Quantidade pedida não equivale a recepção/saída;estornos conservam documento próprio,sem saldo ou consumo líquido inventado. Entrada pela ficha existente deEquipamentos e ligação ao processo de compra autorizado. Tipagem do actorweb explicitada no controlador,sem conceder funções adicionais.
|
||||
|
||||
Validação48testes/571verificações Equipamentos+WarehouseFulfilment;PostgreSQL4/99,base removida. Entrega interna nativa reutiliza operação única,sem duplicação;isolamento,restrições de compras,leitura sem mutação e páginas independentes16/16 cobertos. PHPStan,Pint,formataçãofrontend,TypeScript,compilação,diff e18hashes aprovados. HTTP apenas equipamentos existentes,sem comandos de negócio;sem migração,grants,contas,decisões ou alteraçãoSGF. Provas equipment-integration[-pg/-http].json e docs/equipamentos-acompanhamento.md. Revisão visual,capacidade,projecto/fraccionamento e restantes módulos mantêm critérios próprios.
|
||||
|
||||
## 03/11/2026 — C2: cancelamento de ordens implementado,sem activação
|
||||
|
||||
Autorização específica do proprietário aplicada exclusivamente ao desenvolvimento do âmbito proposto. Política própria com publicação pelo motor reutilizado,duas validações administrativas independentes,Compras solicita eFinanças decide com exclusão do requisitante,autor da adjudicação,emissor e autor do pedido. Vigência,roles actuais,empresa,conflitos,base da ordem ecompromisso revalidados. Pendência suspende execução mesmo seflag for desligado. Recepção,facturação,reemissão e fontes deimportação/entrega integradas;selecções operacionais excluem ordens suspensas/canceladas. Tarefas depolítica,decisão e novo rascunho comligações focadas e autoridade nativa,sem novosgrants.
|
||||
|
||||
Original emitido,snapshot,linhas eaprovações conservados;estadooperacional derivado,requisição marcada cancelada apenas naaprovação. Capacidade financeira aditiva,pinSHA próprio,no livro canónico,sem alterar os18artefactos instalados:reversão única do compromisso original integralmente disponível,sem recalcular moeda/IVA,sem bancos,reembolsos ou apagamentos. SóMZN. Rejeição preserva compromisso;rollback conjunto verificado. Requisitante original prepara umrascunho ligado sem copiar decisões,propostas,validadores ou reserva,repetição semduplicação.
|
||||
|
||||
Regressão159testes/2014verificações dospercursos afectados;fecho dirigido43/284 após ligações finais. Baterias sobrepostas,não somar. PostgreSQL descartável12/91 e3ensaios concorrentes com espera real no bloqueio daempresa:pedido/recepção nasduasordens e duplaaprovação com um só lançamento. Bases removidas. PHPStan0erros,Pint,formataçãofrontend,TypeScript,compilação,diff,18hashes originais ehash novo aprovados. Semrevisão visual completa,carga oulibertação comercial.
|
||||
|
||||
Piloto inspeccionado sóemleitura:estrutura000003 ausente,zero políticas/cancelamentos,camposMFA vazios,zero perfisAuditMetadata eacessos27. HTTPAdmin/Compras/Finanças:circuito503desligado,ordens consultáveis,sem comandos de negócio. MFA continua desligado;Auditoria6/8 continua pendente. Semcontas,palavras-passe,grants,aprovações,migração oucancelamento nopiloto;SGF não alterado. Provas issued-order-cancellation[-pg/-http/-pilot/-authorization].json;docs/cancelamento-ordens-emitidas.md.
|
||||
|
||||
Preparada instalação APENAS de3tabelas vazias,semactivar:ordem de migração exacta000003,hash eproposta issued-order-schema-proposal.json;ensaio emrestauro114tabelas,113tabelas protegidas intactas,newtablesvazias,um só registo de migração,base removida. Backup/hashes em issued-order-schema-preparation.json. PENDENTE DE AUTORIZAÇÃO PARA APLICAÇÃO NO PILOTO;docs/proposta-instalacao-cancelamento-ordens.md. Não aplicar migração geral,publicar política,conceder acessos ou cancelar por inferência desta entrega.
|
||||
|
||||
## 03/11/2026 — G1: divisão administrativa comum preparada e integrada
|
||||
|
||||
Fonte cartografia_moz.xlsx fornecida pelo proprietário, identificada por hash, apenas lida. Catálogo derivado reproduzível conserva as 20.407 linhas, oito colunas originais e zeros iniciais. Origem INE conforme indicação do proprietário; edição não confirmada. Há 9.678 referências seleccionáveis e 10.729 pendentes: 48 grupos de códigos repetidos, 9 nomes vazios, 8.610 linhas sem pai imediato, 377 com pai ambíguo e 1.688 com ancestral inconsistente (diagnósticos sobrepostos). Não fundir, apagar, inventar nomes ou mapear por semelhança. As 8.585 ausências da análise inicial eram percursos distintos, não linhas.
|
||||
|
||||
Serviço comum, API autenticada e selector progressivo paginado integrados na sede da empresa e criação/edição de fornecedores. Descrição na ficha e filtro territorial com descendentes confirmados, sempre no âmbito da empresa. Campo opcional, sem substituir morada ou pressupor país para fichas sem classificação. Manutenção, administrador actual, versão nativa do fornecedor, revisão independente por Finanças, auditoria e rollback preservados. Clientes não têm cadastro autónomo neste SGC; não criar facturação/SGF. Outros cadastros e disponibilidades em texto continuam preservados, sem conversão automática.
|
||||
|
||||
Fecho: 58 testes/591 verificações integradas e PostgreSQL descartável 12/83, com bases removidas. Baterias sobrepostas, não somar. PHPStan zero erros, TypeScript, lint/formatação frontend, Pint e compilação aprovados. Fonte original intacta e reprodução determinística conferidas. Sem revisão visual completa ou aceitação comercial.
|
||||
|
||||
Piloto inspeccionado em transacção só de leitura: ambas as colunas ausentes, migração000004 não aplicada, opção geográfica desligada, acessos versão27 e zero inscrições MFA. Sem comandos de negócio, alterações de aprovação, contas, grants, ordens, políticas ou SGF. Provas administrative-geography[-source/-pg/-pilot].json. Instalação de duas colunas JSON anuláveis e activação só da selecção opcional dependem de autorização em docs/proposta-instalacao-geografia.md; não executar migração geral nem classificar fichas existentes. MFA desligado; propostas de cancelamento e Auditoria6/8 continuam pendentes.
|
||||
|
||||
## 03/11/2026 — G1 instalado/activo com autorização; G2 revisão cartográfica entregue
|
||||
|
||||
O proprietário autorizou instalar os dois campos vazios e activar a selecção geográfica no piloto, preservando moradas, aprovações e MFA desligado. Autorização literal e hashes em administrative-geography-installation-authorization.json. Aplicada apenas migração000004: backup consistente restaurado e114tabelas conferidas,111tabelas protegidas intactas, todas as colunas anteriores de empresas/fornecedores preservadas e referências novasNULL. Base de restauro removida. Activação apenas SGC_GEOGRAPHY_ENABLED=true na configuração privada8005, demais opções preservadas; por defeito a capacidade continua desligada noutros ambientes. Provas installation/activation/pilot/http separadas. Não repetir instalação nem pedir novamente autorização. Estado pendente da proposta/preparação original é histórico; ficheiros originais preservados por hash.
|
||||
|
||||
HTTP comAdmin/Compras/Finanças confirmou campos e catálogo disponíveis, fichas existentes sem classificação e segurança MFA desligada. Não foram submetidas localizações, aprovações ou decisões de negócio. Versão de acessos27. Propostas de cancelamento e Auditoria6/8 continuam pendentes; nenhuma política/grant/conta/ordem/SGF alterada.
|
||||
|
||||
G2 acrescenta consulta administrativa de10.729pendências,50por página, filtros por diagnóstico e designação/ID, códigos/linhas originais e CSV de todos os resultados filtrados. Administração actual revalidada; outros perfis recusados, respostas privadas sem cache. Projecção de fonte comum, sem dados de empresas/clientes/fornecedores no relatório. Não há comandos de correcção ou invenção de localidades. CSV neutraliza fórmulas apenas na representação exportada; importar códigos como texto nas folhas de cálculo. Catálogo original e hashes preservados.
|
||||
|
||||
Validação G2:17testes/150verificações e PostgreSQL descartável17/150, baterias sobrepostas (não somar), base removida. PHPStan zero erros, Pint, lint/formatação, TypeScript e compilação aprovados. HTTPAdmin conferiu pesquisa e exportação;Compras recusado. Prova administrative-geography-review.json. Sem revisão visual completa, certificação da edição ou resolução humana das inconsistências.
|
||||
|
||||
## 03/11/2026 — G3: geografia operacional em armazéns e equipamentos
|
||||
|
||||
Reutilizados catálogo/IDs/edição e selector comum na criação/edição dos dois cadastros, sem converter áreas, copiar localidades ou pressupor classificação. Armazém altera apenas referência geográfica por comparação com o valor anterior, sem auditoria duplicada quando igual; equipamento conserva comando e versão nativos. Utilizador/competência actuais revalidados após bloqueio da empresa, manutenção recusada e auditoria transaccional. Guard web explícito no Stock evita confundir contas de candidatos com operadores.
|
||||
|
||||
Filtros territoriais incluem descendentes confirmados e conservam âmbito de empresa/departamento. Em Stock, os armazéns seleccionados limitam movimentos e saldos derivados. Em Equipamentos, limitam activos, associações/peças, artigos associados e histórico. Ficha mostra localização actual, sem reescrever requisições, aprovações, validações ou snapshots de movimentos. Omitir conserva referência; retirar explicitamente remove só a classificação.
|
||||
|
||||
Validação integrada67testes/481verificações de OperationalGeography,EquipmentSpareParts eStockOperations; fecho14/149 com suspensão independente, revogação de competência, manutenção e falha de auditoria; PostgreSQL descartável14/149, base removida. Baterias sobrepostas, não somar. PHPStan zero erros, Pint, lint/formatação, TypeScript, compilação e diff aprovados. Fonte/hash intactos, sem revisão visual completa.
|
||||
|
||||
Nova opção SGC_OPERATIONAL_GEOGRAPHY_ENABLED desligada por defeito e no piloto. Suspensão não afecta geografia já activa da sede/fornecedores. Migração000005 acrescenta só warehouses/equipment.geography_reference anulável. Preparação em backup/restauro114tabelas,111protegidas e colunas anteriores dos dois cadastros intactas,novos camposNULL,cópia removida. Apenas cópia recebeu migração; piloto inalterado. HTTPAdmin/Armazém/Compras/Finanças confirmou stock/equipamentos consultáveis,novos controlos ocultos e catálogo anterior disponível. MFA desligado,acessos27;sem comandos de negócio,aprovações,grants,políticas,ordens ouSGF.
|
||||
|
||||
Provas operational-geography[-pg/-http/-pilot].json e operational-geography-schema-preparation/proposal.json; docs/geografia-operacional-sgc.md. Instalação dos dois campos vazios e activação apenas da opção operacional dependem de autorização específica em docs/proposta-instalacao-geografia-operacional.md. Não aplicar migração geral, reutilizar autorização de G1 para G3 ou classificar cadastros por inferência. Outras propostas pendentes continuam separadas.
|
||||
|
||||
## 03/11/2026 — G4: geografia na disponibilidade principal do candidato
|
||||
|
||||
Integração sem migração, reutilizando JSON versionado do perfil, catálogo e selector comum. Escolha explícita até à localidade completa, botão de aplicação antes de guardar e designações canónicas resolvidas no servidor. availability_reference fica separado do objecto de três campos availability, mantendo compatibilidade dos formulários publicados. Nomes constituem fotografia da fonte na versão do perfil, sem segundo preenchimento. Disponibilidades adicionais continuam manuais; limite e duplicados nativos conservados.
|
||||
|
||||
API específica de candidato exige conta da empresa e versão de sessão actual, sem converter conta interna em candidato; paginação50,limite60/minuto,respostas privadas sem cache,sem dados de pessoas. Texto livre existente não recebe referências por semelhança. Pedidos antigos conservam referência conhecida apenas com textos inalterados; mudança manual elimina ligação na nova versão e remoção explícita conserva o texto indicado. Versões anteriores intactas. Desligar catálogo mantém perfil manual e referências históricas. Preparação da candidatura reutiliza disponibilidade nativa; snapshot/repetição de submissão conservam a versão mesmo após nova edição do perfil. Não há decisões automáticas de triagem ou aprovação.
|
||||
|
||||
Validação51testes/679verificações de CandidateGeography,CandidatePortal,CandidateSubmissions eAdministrativeGeography;PostgreSQL32/496,base descartável removida,baterias sobrepostas (não somar). PHPStan zero erros,Pint,lint/formatação,TypeScript e compilação aprovados. Provas candidate-geography[-pg/-http/-pilot].json e docs/geografia-candidatos-sgc.md. Fonte/hash intactos;sem revisão visual completa.
|
||||
|
||||
Piloto inspeccionado só em leitura:zero portais configurados,contas e versões de perfil de candidatos;nenhuma inscrição ou identidade criada para demonstrar G4. HTTP recusa visitantes e sessões apenas internas;catálogo interno actual disponível após selecção do contexto nativo. Percursos positivos apenas nas bases descartáveis. Geografia da sede/fornecedores continua activa;G3 ainda sem colunas/activação e aguarda autorização específica. Acessos27,MFA desligado;sem migrações,perfis,aprovações,políticas,ordens ouSGF alterados. Não abrir portal/criar candidato ou instalar G3 por inferência desta continuação.
|
||||
|
||||
## 03/11/2026 — G5: cartografia nas disponibilidades adicionais
|
||||
|
||||
Estendido o selector e o resolvedor de G4 aos até 20 locais adicionais do candidato, sem migração. Objectos de localização mantêm os três campos antigos; referências ficam em lista separada e alinhada no JSON da versão, com null para entrada manual. Servidor valida fonte/ID/localidade completa e resolve nomes; limite, duplicados entre todos os locais, motivo, versão e sessão continuam nativos. Não criar localidades nem inferir códigos a partir de textos livres.
|
||||
|
||||
Remover uma linha remove a referência correspondente. Chaves estáveis de interface impedem que estado de selecção migre para outra linha após remoção; não são dados de negócio. Os pedidos antigos que omitem dados conservam-nos; ao reordenar locais sem referências, só são reutilizadas associações já conhecidas para os mesmos textos exactos. Texto modificado passa a manual; referências null e listas vazias permitem remoção explícita. Fonte desligada preserva histórico e pedidos antigos inalterados.
|
||||
|
||||
Formulários publicados conservam o formato de três campos, reutilizando os locais do perfil. Snapshot da candidatura guarda a versão com referências adicionais; retirada posterior no perfil não altera a submissão e repetição conserva o mesmo recibo. Não há triagem, aprovação ou selecção territorial automática. Mantida a recarga nativa após gravação, sem actualizar a versão de uma edição local por uma consulta concorrente.
|
||||
|
||||
Validação integrada: 46 testes/649 verificações de CandidateGeography, CandidatePortal e CandidateSubmissions; PostgreSQL descartável 39/549, base removida. Baterias sobrepostas, não somar. PHPStan zero erros, Pint, lint/formatação, TypeScript e compilação aprovados. Provas candidate-additional-geography[-pg/-http/-pilot].json; docs/geografia-disponibilidades-adicionais-sgc.md. Fonte/hash preservados; sem revisão visual completa.
|
||||
|
||||
Piloto consultado só em leitura: zero portais, contas e perfis de candidatos; sem criação de identidades ou abertura de inscrições para demonstrar G5. HTTP recusa visitantes e sessões apenas internas; catálogo de operadores preservado. Percursos positivos só descartáveis. Geografia da sede/fornecedores continua activa, G3 aguarda autorização e permanece sem estrutura/activação. Acessos versão27, MFA desligado; sem alterações de stock, aprovações, políticas, permissões, ordens ou SGF.
|
||||
|
||||
## 03/11/2026 — G6: consulta territorial de candidaturas recebidas
|
||||
|
||||
Filtro opcional na caixa de Pessoas por referências principal e adicionais do snapshot submetido, incluindo descendentes do território escolhido. Não consulta o perfil actual nem associa códigos a nomes manuais. Referências de outra edição e listas adicionais ausentes/antigas são tratadas sem remapear dados; retirar filtro conserva a caixa completa. SQL JSON parametrizado em SQLite/PostgreSQL; restantes motores mantêm caixa sem este filtro.
|
||||
|
||||
Autorização nativa CandidateTriage.canRead, empresa/processo, utilizador e competências actuais e vigência mantidos. Novo catálogo por processo no contexto Pessoas, sem exigir entrada em Compras ou conceder acesso ao catálogo operacional desse contexto. Resultados em ordem de recepção, vinte por página; paginação conserva filtros e foco submission. Ramo principal/adicionais agrupado dentro do âmbito de empresa/processo. Pendências de triagem continuam globais ao processo; nenhuma decisão, pontuação, selecção ou aprovação automática. Auditoria nativa de consulta conservada, sem exportação pessoal nova.
|
||||
|
||||
Validação integrada 45 testes/682 verificações de CandidateSubmissions e WorkContext; PostgreSQL descartável 8/135, base removida; baterias sobrepostas, não somar. Inclui principal/adicionais, nomes manuais iguais sem associação, perfil actual divergente, vinte+uma candidaturas/paginação, foco, isolamento, revogação, contexto, suspensão e outras edições. Fixtures respeitam chaves estrangeiras e não alteram submissões imutáveis existentes. PHPStan zero erros, Pint, lint/formatação, TypeScript, compilação e diff aprovados. Fonte/hash preservados; sem revisão visual completa.
|
||||
|
||||
Provas candidate-territory[-pg/-http/-pilot].json; docs/territorio-candidaturas-recebidas-sgc.md. HTTP do piloto confirmou Pessoas acessível, processo inexistente404 e catálogo de Compras403 no contexto Pessoas, sem caixas visíveis nessa conta. Percursos positivos de filtro só em bases descartáveis. Zero portais/contas/perfis de candidatos no piloto, acessos27, MFA desligado. Sem processos, migrações, classificações, permissões, triagem, aprovações, políticas, ordens ou SGF alterados. Geografia da sede/fornecedores continua activa; G3 permanece sem estrutura/activação e aguarda autorização própria.
|
||||
|
||||
## 03/11/2026 — G7: disponibilidade recebida na ficha e dossiê de Pessoas
|
||||
|
||||
ReceivedCandidateAvailability reutiliza a origem electrónica da triagem aceite por empresa/processo, acrescentando received_availability apenas à projecção de leitura PeopleProcessProjection. Página e dossiê partilham o campo e a redacção. Revalida utilizador actual e CandidateTriage.canRead; consultar ou avaliar o processo, por si só, não concede as fontes. Whitelist: referência de submissão, versão, disponibilidade principal/adicionais e respectivas referências; sem email, telefone, documentos, respostas ou conta de candidato.
|
||||
|
||||
Usa a fotografia da submissão e confere a versão registada na triagem. Não consulta perfil actual nem resolve nomes no catálogo novo. Texto manual mantém-se sem código inventado; desligar catálogo conserva o histórico. Candidatura interna manual sem origem e submissão recusada não recebem localização por inferência. Consulta em lote, sem novas gravações de cadastros ou triagem.
|
||||
|
||||
O campo NÃO integra PeopleHiring.matrix nem as linhas congeladas da selecção. Critérios, pontuações, contribuintes, selecção e aprovações preservados. Testes confirmaram equivalência página/dossiê, leitor sem fontes, revogação de âmbito e identidade actual, isolamento entre processos, referência principal/adicionais/manual, perfil posteriormente alterado, catálogo desligado, base de decisão intacta e tabelas de negócio/auditoria inalteradas pela consulta.
|
||||
|
||||
Validação integrada: 89 testes/2237 verificações de CandidateSubmissions e PeopleHiring; PostgreSQL descartável 7/97, base removida; baterias sobrepostas, não somar. PHPStan zero erros, Pint, lint/formatação, TypeScript, compilação e diff aprovados. Fonte/hash intactos. Provas received-availability[-pg/-http/-pilot].json e docs/disponibilidade-recebida-processo-sgc.md. Sem revisão visual completa ou aceitação empresarial.
|
||||
|
||||
Piloto consultado só em leitura, sem processos visíveis para comparar positivamente nessa conta e zero portais/contas/perfis de candidato; os casos positivos de disponibilidade electrónica ocorreram apenas em bases descartáveis. Acessos versão27, MFA desligado; sem migração, conta, perfil, aprovação, triagem, política, ordem ou SGF alterados. Geografia da sede/fornecedores continua activa e G3 permanece pendente da autorização própria, sem instalar estrutura ou activar a opção operacional por inferência.
|
||||
|
||||
|
||||
G8 — 03/11/2026: relatório de medidas de melhoria segmentado pelo território actual da ficha do fornecedor, partilhando SupplierGeography com o cadastro. Totais, paginação e CSV respeitam os mesmos filtros e acessos. Sem migração, alteração de snapshots, decisões, perfis ou aprovações. MFA desligado; G3 operacional continua pendente. Documentação: docs/territorio-melhorias-fornecedores-sgc.md; prova: docs/proofs/supplier-territory.json. Validação dirigida SQLite: 50 testes/700 verificações; sem nova execução PostgreSQL ou revisão visual completa.
|
||||
|
||||
|
||||
A2 — 03/11/2026: reprodução dedicada da requisição interna, reutilizando snapshot e WarehouseFulfilment.progress. Separação de pedido/reserva/entrega/saldo/cancelamento, histórico de autorização e recepção, ligações aos documentos individuais A1. Mesmos acessos da ficha, sem mutações ou factura. Sem migração; aprovações preservadas e MFA desligado. Docs/armazem-requisicao-impressao.md; provas warehouse-request-print.json e warehouse-request-print-pg.json. Não declara revisão visual ou aceitação comercial.
|
||||
|
||||
Validação A2: bateria conjunta WarehouseFulfilmentTest + WarehouseRequestsTest aprovada, 23 testes/408 verificações; PostgreSQL descartável 3 testes/45 verificações, base removida. PHPStan, tipos, formatação, rotas e compilação aprovados. Sem revisão visual completa. Contagens não aditivas às provas anteriores.
|
||||
|
||||
|
||||
A3 — 03/11/2026: CSV integral de acompanhamento das requisições internas, com filtros aplicados e âmbito partilhado com a lista. Uma linha por artigo/unidade; quantidades e estados reutilizam progress nativo. Paginação não limita a exportação; protecção de células de folha de cálculo. Sem novas aprovações, campos, migrações ou movimentos; MFA desligado. Docs/armazem-requisicoes-exportacao.md; provas warehouse-request-export.json e warehouse-request-export-pg.json. Sem teste de carga ou revisão visual completa.
|
||||
|
||||
Validação A3: WarehouseFulfilmentTest + WarehouseRequestsTest, 26 testes/437 verificações aprovados. PostgreSQL descartável, 3 testes/29 verificações aprovados e base removida. PHPStan, tipos, formatação e diff aprovados. Contagens não aditivas às provas anteriores.
|
||||
|
||||
|
||||
O2 — 03/11/2026: diagnóstico integrado de reposição e integridade documental pelo comando sgc:operations-check. Reutiliza verificadores nativos, consulta global só em consola sem identidade/sessão, saída agregada sem detalhes privados e continuação em falha parcial. Estado incompleto/indisponível nunca equivale a sucesso. Não instala serviços, recupera compras, corrige ficheiros ou grava telemetria. Sem migrações ou interface nova; aprovações preservadas e MFA desligado. Docs/operacao-diagnostico-integrado.md; provas operational-health.json e operational-health-pg.json. Instalação recorrente, restauro, capacidade e aceitação mantêm-se pendentes.
|
||||
|
||||
Validação O2: 17 testes/112 verificações aprovados em OperationalHealthTest, ReplenishmentHealthTest e ReceiptDocumentHealthTest; PostgreSQL descartável 5 testes/36 verificações aprovados e base removida. Contagens não aditivas às provas anteriores. Interface inalterada, sem repetição da compilação nesta etapa.
|
||||
|
||||
|
||||
E2 — 03/11/2026: ficha de equipamento reúne também requisições internas ao armazém ainda pendentes, por ID nas linhas preservadas, sem associação por texto. Projecção só das linhas do equipamento, estado/reserva/entrega/saldo nativos, ligações aos processos e paginação independente de15. Acesso ao equipamento não concede acesso ao armazém; empresa/departamento/capacidades mantidos. Sem novos registos, movimentos, migração ou aprovações; MFA desligado. Docs/equipamentos-requisicoes-internas.md; provas equipment-warehouse.json e equipment-warehouse-pg.json. SQLite57testes/737verificações e PostgreSQL4/107 aprovados, sem somar a provas históricas; não declara revisão visual/carga. G3 continua pendente.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# G7 — Disponibilidade recebida na ficha e no dossiê de Pessoas
|
||||
|
||||
Reutiliza a ligação da triagem aceite à candidatura electrónica original. Sem novos campos de cadastro, migrações ou cópia de localizações para a ficha interna da pessoa. Dados introduzidos no perfil e congelados na submissão passam a ser consultáveis no acompanhamento interno, conforme os acessos existentes.
|
||||
|
||||
## Origem e campos apresentados
|
||||
|
||||
`ReceivedCandidateAvailability` acrescenta `received_availability` apenas à projecção de leitura partilhada pela página e pelo dossiê de preparação. A origem é o snapshot da submissão aceite para análise, associado à candidatura interna pelo registo nativo de triagem, dentro da mesma empresa e processo. A versão do perfil recebida é conferida contra a versão registada na triagem.
|
||||
|
||||
A projecção inclui referência da submissão, versão do perfil, disponibilidade principal e locais adicionais, com as referências geográficas correspondentes. Os textos são os da versão submetida, incluindo os preenchidos manualmente; nomes não são procurados no catálogo actual para atribuir códigos. As referências identificam a edição recebida. Desligar o catálogo não apaga ou actualiza essa fotografia histórica.
|
||||
|
||||
Não se acrescentam email, telefone, anexos, documentos, respostas do formulário ou identificação da conta de candidato. Os campos são seleccionados explicitamente. Uma candidatura interna manual sem ligação electrónica ou uma submissão recusada não recebe uma localização inventada ou associada por nome.
|
||||
|
||||
## Acessos e responsabilidades
|
||||
|
||||
A leitura continua a exigir acesso ao processo. O novo campo só é acrescentado quando `CandidateTriage.canRead` também permite consultar as fontes electrónicas. A identidade interna actual é relida na empresa do processo antes dessa verificação. Só consultar/avaliar/aprovar um processo não atribui automaticamente acesso a estes dados. Um revisor precisa dos âmbitos de fonte já exigidos pelo circuito; não são concedidos novos perfis ou direitos.
|
||||
|
||||
O mesmo controlo aplica-se à resposta da página e ao ficheiro JSON do dossiê, no servidor. Ocultar o bloco na interface não constitui o controlo de acesso. A revogação do âmbito elimina o campo das consultas seguintes. Não são criados endpoints de exportação ou competências novos.
|
||||
|
||||
## Preservar decisões e documentos
|
||||
|
||||
`PeopleHiring.matrix` continua a ser a matriz do workflow. O novo campo não é acrescentado a essa matriz ou às linhas congeladas de uma proposta/selecção. Critérios, avaliações, pontuações, contribuintes, versões, selecções e aprovações não são recalculados ou reescritos. A interface identifica a disponibilidade como informação do perfil recebido, não como critério de avaliação.
|
||||
|
||||
A projecção é resolvida por consulta em lote das origens autorizadas, sem nova triagem, criação de candidatos internos ou escrita de localizações. Editar o perfil depois da submissão não altera os dados apresentados. A matriz actual de leitura pode mostrar a proveniência, enquanto a base de decisão conservada mantém exactamente os dados com que foi produzida.
|
||||
|
||||
## Evidência e limites
|
||||
|
||||
Validações em `docs/proofs/received-availability.json` e `received-availability-pg.json`: página/dossiê equivalentes; fotografia recebida face ao perfil actualizado; disponibilidade principal/adicionais/manual; exclusão de dados privados desnecessários; ausência do campo para leitores sem fontes; revogação e identidade actual; isolamento entre processos; suspensão do catálogo; matriz/base de selecção intactas e ausência de mutações por consulta.
|
||||
|
||||
O piloto é consultado apenas em leitura. Sem criar processos, portais, contas ou perfis para demonstrar o bloco. Os casos positivos ocorreram em bases descartáveis. Sem revisão visual completa ou aceitação por pessoas reais. MFA desligado, aprovações e perfis existentes preservados. G3 continua pendente de autorização para a estrutura e activação geográfica de armazéns/equipamentos; SGF não é alterado.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Divisão administrativa comum do SGC
|
||||
|
||||
Fonte fornecida pelo proprietário: `\\mac\Home\Documents\TEMPO\SEGI\SEGI NOVO\cartografia_moz.xlsx`, folha `Sheet1`. O proprietário identifica o documento como oficial e validado pelo INE. O ficheiro não fornece uma edição confirmada nesta análise. Não se presume que representa a divisão vigente em qualquer data futura.
|
||||
|
||||
SHA-256 da fonte: `70996d9a9133785a848de90bedb4bf685e24d8f823cd153c0adc43eac642a292`. Original apenas lido, sem alterações. Derivação em `resources/data/geography/cartografia-moz.json`, com todas as oito colunas originais, número da linha, códigos como texto (incluindo zeros iniciais), designação original e diagnóstico. O ID da linha identifica a referência nesta edição; não é tratado como código nacional universal entre edições.
|
||||
|
||||
## Qualidade e selecção
|
||||
|
||||
São 20.407 registos: 11 registos no nível Provincia, 148 distritos, 437 postos administrativos, 1.334 localidades, 1.870 bairros urbanos e 16.607 aldeias/quarteirões, classificados pela última coluna de código preenchida. São contagens do ficheiro, não uma confirmação independente da divisão administrativa actual.
|
||||
|
||||
Há 48 grupos de percursos de códigos repetidos, envolvendo 96 linhas. Existem 9 designações vazias, 8.610 linhas sem pai imediato explícito e 377 com pai imediato ambíguo. Estes números referem-se a linhas e podem sobrepor-se. A análise inicial de 8.585 referia-se a percursos distintos sem pai, não ao número de linhas.
|
||||
|
||||
Os diagnósticos não corrigem a fonte por inferência. A relação pai/filho só é estabelecida quando existe exactamente uma linha com o prefixo imediato. São bloqueados para selecção os registos inconsistentes e os descendentes de ancestrais inconsistentes (1.688 linhas com esse diagnóstico). Resultado: 9.678 seleccionáveis e 10.729 pendentes de revisão. Nenhuma linha é apagada, fundida ou fabricada. É permitido seleccionar apenas a província ou outro nível superior confirmado, sem exigir todos os níveis.
|
||||
|
||||
O preparador `scripts/prepare-geography-catalogue.py` lê XLSX com a biblioteca padrão, valida cabeçalhos e IDs e rejeita substituir um catálogo por uma fonte com hash diferente. Uma nova edição deve usar outro destino, ser conferida e preservar a edição anterior. Não reutilizar IDs de outra edição nem remapear associações por semelhança de nomes. A configuração fixa os hashes da fonte e do catálogo derivado; divergência impede a leitura funcional.
|
||||
|
||||
## Integração por cadastro
|
||||
|
||||
- **Fornecedores:** campo opcional na criação/edição, descrição na ficha e filtro territorial no directório, incluindo níveis inferiores confirmados. A referência contém apenas país MZ, hash da edição e ID da fonte. Designações e ascendentes são lidos do catálogo comum. Morada, NUIT e demais regras originais mantêm-se. Omitir o campo conserva a referência existente; retirar explicitamente remove só a referência. Endereços estrangeiros ou não classificados podem continuar sem referência. Isto não altera a validação fiscal nativa nem representa um novo regime de cadastro internacional.
|
||||
- **Empresa:** sede na parametrização, só por administrador actual e fora de manutenção. Compara a referência anterior para rejeitar sobrescrita concorrente; apenas actualiza a localização, sem mudar alçadas, perfis, configurações ou aprovações.
|
||||
- **Outros módulos:** serviço e selector comuns preparados para reutilização. Clientes não possuem um cadastro autónomo implementado no SGC inspeccionado; não foi criado um módulo de clientes/facturação ou feita integração SGF por inferência. G4 acrescenta escolha explícita da disponibilidade principal do candidato no catálogo, com referência e fotografia das designações na versão do perfil; texto antigo e locais adicionais continuam preservados sem correspondência automática. Ver `docs/geografia-candidatos-sgc.md`.
|
||||
|
||||
Alterações de localização de fornecedores usam o mesmo comando, bloqueio de empresa, versão da ficha e auditoria existentes. Tal como qualquer alteração material da ficha, retiram a validação actual e exigem nova revisão independente por Finanças. Uma submissão sem alterações não incrementa a versão nem cria ruído de auditoria. A instalação, por si só, não altera nenhum fornecedor ou aprovação.
|
||||
|
||||
API autenticada `/api/divisao-administrativa`: filhos confirmados de um registo, 50 por página. Sem dados pessoais nem endereços de outras empresas. Filtro de fornecedores mantém o âmbito nativo de empresa e valida edição/ID; não inclui resultados sem classificação por adivinhação.
|
||||
|
||||
## Instalação e limites
|
||||
|
||||
Instalado e activado no piloto8005 com autorização específica do proprietário. Aplicada apenas a migração `2026_11_03_000004_add_administrative_geography_references`, com duas colunas JSON anuláveis e vazias, sem preencher referências ou converter endereços. A cópia consistente foi restaurada e as114tabelas conferidas;111tabelas protegidas e colunas anteriores de empresas/fornecedores permaneceram intactas. O restauro descartável foi removido. Provas: `administrative-geography-installation-authorization.json`, `administrative-geography-installation.json` e `administrative-geography-activation.json`.
|
||||
|
||||
`config/geography.php` lê `SGC_GEOGRAPHY_ENABLED`, desligado por defeito. Apenas a configuração privada do piloto recebeu o valor `true`; restantes opções preservadas, incluindo MFA desligado. O circuito recusa operações se faltar qualquer coluna, mesmo com a opção ligada. Históricos classificados permanecem legíveis se posteriormente se desligar a selecção. ConsultasHTTP confirmaram campos disponíveis e cadastros ainda sem classificação; não se submeteram decisões ou alterações de ficha.
|
||||
|
||||
Não executar migração geral: há outras migrações e autorizações pendentes. A proposta original `docs/proposta-instalacao-geografia.md` fica preservada pelo hash; o seu estado pendente é histórico e a autorização/aplicação efectiva estão nas provas separadas. Não foram autorizadas nem executadas alterações de MFA, políticas, contas, alçadas, decisões, ordens ou SGF. A revisão humana das inconsistências e a identificação da edição continuam pendentes. Sem certificação oficial independente, mapas, coordenadas ou revisão visual completa nesta entrega.
|
||||
|
||||
## G2: revisão administrativa das inconsistências
|
||||
|
||||
A parametrização liga à consulta `/administracao/divisao-administrativa`, limitada aos administradores actuais pelos mesmos controlos da parametrização. Mostra50linhas por página, pela ordem original, e permite filtrar por diagnóstico e pesquisar designação ou ID exacto. São visíveis linha, ID, nome e códigos originais, além dos diagnósticos. Registos de empresas e fornecedores não integram este relatório, comum à fonte geográfica.
|
||||
|
||||
A exportação CSV inclui todos os resultados filtrados, independentemente da página. Mantém os códigos como texto no ficheiro; ao abrir no Excel, importar essas colunas como texto para conservar zeros. Campos que poderiam ser interpretados como fórmulas recebem uma aspa inicial apenas no CSV. O JSON original e a fonte não são alterados. Consulta e exportação usam o mesmo conjunto filtrado, respostas privadas sem cache e não têm comandos de correcção, classificação ou aprovação. É necessária fonte esclarecida para uma futura edição; nunca resolver códigos repetidos escolhendo a primeira linha.
|
||||
|
||||
Validação G2 em `docs/proofs/administrative-geography-review.json`: testes de serviço/HTTP, paginação, exportação completa, permissões actuais, neutralização de fórmulas e bases descartáveis. Não equivale a revisão humana concluída das10.729pendências nem revisão visual completa.
|
||||
@@ -0,0 +1,15 @@
|
||||
# E1 — acompanhamento por equipamento
|
||||
|
||||
O cadastro de Equipamentos e peças liga à ficha «Acompanhar compras e movimentos». A ficha reutiliza o equipamento existente e reúne dois percursos distintos: requisições de compra com linhas associadas ao equipamento e documentos de movimentos físicos com equipment_id. Não exige novo registo de artigos,equipamentos,quantidades ou responsáveis.
|
||||
|
||||
Compras mostra só as requisições que a conta já pode consultar pelo motor nativo,com referência,estado,prazo e linhas desse equipamento,sem preços. Cada referência abre o processo de compra existente;nenhuma aprovação é executada pela ficha. Uma quantidade pedida não é apresentada como recebida,entregue ou consumida.
|
||||
|
||||
Movimentos reutiliza as operações físicas já ligadas ao equipamento,incluindo a operação única produzida pela entrega de uma requisição interna. Mostra referência,tipo,data,artigo,unidade,armazém e quantidade do documento. Estornos continuam documentos próprios;sem somar saídas/estornos como se fossem consumo líquido. Não calcula saldo,consumo técnico,custo ou amortização. Não é uma agregação por projecto nem controlo de fraccionamento.
|
||||
|
||||
As duas listas têm páginas independentes de15documentos pela ordem de registo,para manter acessíveis os mais antigos. Acesso segue as funções já usadas no cadastro e o mesmo âmbito departamental. Empresa diferente é recusada;as requisições preservam ainda a sua visibilidade própria. A ficha não envia snapshots integrais das operações,preços ou campos adicionais da requisição. Resposta privada semcache e históricoInertia cifrado.
|
||||
|
||||
Sem migração,competências novas,grants,MFA ou decisões de negócio. Testes positivos usam dados descartáveis e os motores nativos de compra,recepção,stock e requisição interna. Revisão visual,carga,consumo técnico e requisitos de projecto continuam separados. Provas equipment-integration.json e equipment-integration-pg.json. O acesso de Auditoria D1 continua pendente:nova instrução genérica de continuação não autoriza a atribuição às contas6/8.
|
||||
|
||||
Fecho:48testes/571verificações de Equipamentos e WarehouseFulfilment aprovados;PostgreSQL descartável4/99,base removida. Cobertura da entrega interna nativa sem duplicação,compras e saídas separadas,visibilidade própria das requisições,departamento/empresa,consulta sem mutações e paginação independente de16compras/16movimentos. PHPStan,Pint,formataçãofrontend,TypeScript,compilação,diff e18hashes aprovados. HTTP consultou apenas equipamentos existentes no piloto,sem compras/movimentos novos;prova equipment-integration-http.json. Não constitui revisão visual completa ou aceitação comercial.
|
||||
|
||||
Evolução E2: a ficha acrescenta uma terceira lista, de requisições internas ao armazém por equipamento, preservando os acessos específicos e o progresso nativo. Âmbito e limites em docs/equipamentos-requisicoes-internas.md; prova equipment-warehouse.json. As contagens E1 acima conservam a sua composição histórica.
|
||||
@@ -0,0 +1,15 @@
|
||||
# E2 — requisições internas na ficha do equipamento
|
||||
|
||||
03/11/2026. O acompanhamento do equipamento passa a incluir pedidos internos ao armazém antes da saída física, além das compras e movimentos E1. Não exige voltar a indicar artigos, quantidades, destino ou equipamento.
|
||||
|
||||
A associação usa o identificador do equipamento nas linhas preservadas do snapshot da requisição, sem correspondências por nome ou morada. PostgreSQL e SQLite usam pesquisa nas linhas JSON. Requisições com várias linhas surgem uma vez e a projecção inclui apenas as linhas do equipamento consultado, conservando o índice original para consultar o progresso.
|
||||
|
||||
O estado, versão, reserva actual, entrega acumulada, quantidade cancelada e saldo por entregar reutilizam WarehouseFulfilment.progress. Reserva ou quantidade pedida não comprova saída. Entregas acumuladas não descontam devoluções/correcções posteriores. Não se somam unidades de artigos diferentes. Indisponibilidade da estrutura de reservas é explícita.
|
||||
|
||||
EquipmentWarehouseRequests usa WarehouseWorkdesk.visible e exige capacidade efectiva de consulta/preparação/revisão/entrega do armazém. Acesso à ficha de equipamento por si só não concede acesso a requisições internas. Os limites de empresa e departamento do equipamento continuam aplicados antes da consulta; as ligações abrem o processo nativo com os seus próprios controlos.
|
||||
|
||||
Sem capacidade de consulta ou estrutura instalada, a secção indica indisponibilidade e não divulga contagens. Com acesso e sem resultados, indica ausência de requisições visíveis. As páginas de 15 pedidos mantêm a ordem dos mais antigos e são independentes de compras e movimentos; as três listas conservam as páginas seleccionadas na navegação. Texto e quantidades pertencentes a outras linhas não integram a projecção.
|
||||
|
||||
Sem migração, novas permissões, aprovações, reservas ou saídas. MFA desligado; geografia operacional G3 continua pendente de instalação autorizada. Os testes usam apenas bases descartáveis e cobrem pedido ainda sem entrega, linhas mistas, leitura sem mutações, acesso próprio/departamental, empresa diferente, paginação e entrega nativa com operação única. Provas equipment-warehouse.json e equipment-warehouse-pg.json. Não constitui aceitação visual, teste de carga ou consumo técnico líquido.
|
||||
|
||||
Validação final: SQLite 57 testes/737 verificações; PostgreSQL descartável 4 testes/107 verificações e base removida. Análise PHPStan sem erros, formatação, tipos, diff e compilação de produção aprovados. Sem revisão visual completa.
|
||||
@@ -0,0 +1,29 @@
|
||||
# G4 — Cartografia na disponibilidade principal do candidato
|
||||
|
||||
Bloco sem migrações: reutiliza `candidate_profile_versions.data`, a divisão administrativa comum e a sessão de candidato existente. Não abre inscrições, configura portais, cria contas no piloto ou altera o circuito de Pessoas.
|
||||
|
||||
## Preencher e reutilizar
|
||||
|
||||
No perfil, a opção «Escolher a disponibilidade principal no catálogo» permite percorrer província, distrito, posto administrativo e localidade. Só um percurso confirmado completo até à localidade pode preencher a disponibilidade, pois os formulários existentes exigem província, distrito e localidade. Um nível superior incompleto não é transformado numa localidade fictícia. Os registos isolados para revisão continuam indisponíveis.
|
||||
|
||||
Explorar o catálogo não altera os campos. O candidato deve escolher «Usar esta localidade», conferir as designações e guardar o perfil com o motivo e a versão nativos. Pode retirar a associação para voltar ao preenchimento manual. Endereços estrangeiros, locais ausentes ou pendentes continuam a poder ser escritos manualmente. Disponibilidade continua distinta da morada.
|
||||
|
||||
O servidor resolve o ID e a edição e obtém as três designações da fonte, sem confiar nos nomes enviados pelo navegador. O JSON guarda `availability_reference` junto à disponibilidade principal. A referência não é acrescentada dentro de `availability`, para conservar o formato de três campos que os formulários publicados já utilizam. As designações são uma fotografia da fonte na versão do perfil, necessária para conservar candidaturas históricas; o utilizador não volta a escrevê-las.
|
||||
|
||||
A preparação da candidatura já reutiliza a disponibilidade principal do perfil. A submissão mantém a versão e a referência no seu snapshot, além das respostas próprias do formulário. Actualizar o perfil posteriormente não altera a candidatura recebida. Repetir a submissão com a mesma operação reutiliza o recibo, seguindo a regra nativa.
|
||||
|
||||
## Compatibilidade e autoridade
|
||||
|
||||
Pedidos antigos sem `availability_reference` conservam a associação anteriormente validada somente se os três textos continuarem exactamente iguais (independentemente da ordem das chaves). Se o texto mudou manualmente, a nova versão não herda a referência antiga. Nunca se atribui um código a textos livres por semelhança. Remover explicitamente a referência mantém os textos indicados nessa gravação. G5 estende o selector aos locais adicionais, mantendo o formato original e guardando referências em lista separada no JSON. O preenchimento manual, o limite e a recusa de duplicados mantêm-se; ver `docs/geografia-disponibilidades-adicionais-sgc.md`.
|
||||
|
||||
O selector comum admite um endereço de consulta e um nível final específicos, mantendo os valores por defeito dos selectores da sede, fornecedores e geografia operacional. O endpoint `/candidaturas/{company}/divisao-administrativa` exige conta de candidato da empresa e versão de sessão actual; uma sessão interna de funcionário não é suficiente. Respostas paginadas de50registos, privadas e sem cache, sem perfis, empresas, fornecedores ou dados de outros candidatos. Acesso limitado a60consultas/minuto. Não se utiliza a identidade interna para dar acesso ao candidato.
|
||||
|
||||
Catálogo desligado oculta o selector e recusa novas associações. O preenchimento manual e as referências históricas inalteradas continuam preservados. As mesmas regras de versão do perfil, versão de sessão, duplicados, publicação da oportunidade, confirmação de email, triagem e aprovações continuam em vigor. Auditoria de eventos e criação da versão são transaccionais.
|
||||
|
||||
## Evidência e limites
|
||||
|
||||
Provas em `docs/proofs/candidate-geography.json`, `candidate-geography-pg.json`, `candidate-geography-pilot.json` e `candidate-geography-http.json`. Testes cobrem autenticação separada, empresa, revogação de sessão, designações canónicas, fonte inconsistente/obsoleta, versões, duplicados, escrita manual, remoção, suspensão, falha de evento e preservação de snapshots/repetição de submissões. As baterias SQLite e PostgreSQL sobrepõem-se e não se somam.
|
||||
|
||||
Consulta do piloto confirmou zero portais de candidatos configurados, zero contas de candidatos e zero versões de perfil. Não foi aberta inscrição nem criado candidato para demonstrar o bloco. HTTP confirmou recusa a visitantes e a sessões apenas internas; os percursos positivos ocorreram somente em bases descartáveis. MFA desligado e aprovações/perfis/contas existentes preservados. Sem revisão visual completa ou aceitação por pessoas reais.
|
||||
|
||||
G3 (armazéns e equipamentos) continua com instalação e activação pendentes de autorização específica. A geografia da sede e fornecedores mantém a instalação já autorizada. Este bloco não altera SGF, decisões de Pessoas, dados originais da cartografia ou as propostas de acesso de auditoria/cancelamento de ordens.
|
||||
@@ -0,0 +1,27 @@
|
||||
# G5 — Cartografia nos locais adicionais de disponibilidade
|
||||
|
||||
Estende G4 sem migrações ou novos acessos. Cada um dos até 20 locais adicionais pode ser escolhido explicitamente no mesmo catálogo, ou continuar preenchido manualmente. A disponibilidade principal mantém o percurso existente.
|
||||
|
||||
## Dados e reutilização
|
||||
|
||||
`additional_availability` conserva a lista original de objectos com apenas província, distrito e localidade. O JSON da versão guarda, separadamente, `additional_availability_references`: uma referência por posição, ou `null` para um local manual. As listas têm sempre o mesmo tamanho quando são submetidas em conjunto. Valores sem correspondência, formatos que não são listas e mais de 20 entradas são recusados antes de criar a versão.
|
||||
|
||||
Para cada referência escolhida, o servidor valida edição, ID, hierarquia e nível de localidade e obtém as designações da fonte. Os nomes enviados pelo navegador não determinam os nomes oficiais. Não se criam localidades, distritos ou níveis superiores para completar o percurso. As referências de locais pendentes de revisão não são aceites.
|
||||
|
||||
O selector só aplica os dados quando o candidato confirma a localidade. Os três campos ficam de leitura até retirar a associação para preencher manualmente ou escolher outra referência. A remoção de uma linha remove também a sua referência; identificadores locais de interface mantêm o estado de cada selector associado à linha correcta. Esses identificadores não são enviados ou guardados como dados de negócio.
|
||||
|
||||
## Pedidos antigos e versões
|
||||
|
||||
Pedidos que omitem locais adicionais conservam os dados anteriores. Pedidos que enviam os locais mas omitem a nova lista só conservam referências já conhecidas para os mesmos textos exactos da versão anterior, independentemente da ordem das chaves. Isso permite reordenar/remover locais sem associar a referência da posição antiga a outra linha. Texto alterado passa a manual. Não se procuram códigos novos no catálogo por semelhança de nomes.
|
||||
|
||||
Uma lista explícita de referências com `null` retira as associações correspondentes, mantendo os textos fornecidos; listas vazias em conjunto retiram todos os locais adicionais da nova versão. As versões anteriores não são alteradas. A suspensão do catálogo preserva pedidos antigos e referências históricas inalteradas. O formulário mantém a recarga após gravação confirmada e o controlo de versão nativos, sem actualizar a versão de uma edição local apenas porque outra consulta trouxe dados mais recentes.
|
||||
|
||||
O limite e a recusa de locais duplicados, entre principal e adicionais e entre os próprios adicionais, continuam a pertencer ao comando nativo do perfil. A validação acontece depois de resolver as designações, evitando que nomes falsos enviados pelo cliente contornem a recusa de duplicados. Motivo, versão de perfil e sessão continuam obrigatórios.
|
||||
|
||||
## Integração com candidaturas
|
||||
|
||||
O formulário da oportunidade já oferece os locais registados no perfil e continua a receber o objecto de três campos, compatível com a definição publicada. A submissão conserva a fotografia do perfil com as referências e as respostas da oportunidade. Remover locais do perfil não altera uma candidatura recebida; a repetição da mesma submissão reutiliza o recibo. Não há triagem, classificação, aprovação ou selecção automática por território.
|
||||
|
||||
Validação em `docs/proofs/candidate-additional-geography.json` e prova PostgreSQL própria, sem substituir a evidência histórica de G4. Inclui localidades canónicas, mistura manual/catálogo, correspondência das listas, limites, ordenação/remoção, compatibilidade, duplicados, edição obsoleta, suspensão e fotografia/repetição da candidatura.
|
||||
|
||||
No piloto, as verificações são apenas de leitura: não foram configurados portais, criados candidatos ou gravados perfis. Os percursos positivos foram exercitados em bases descartáveis. MFA desligado, aprovações e acessos existentes preservados. G3 conserva a instalação/activação pendentes de autorização. Sem revisão visual completa ou aceitação por utilizadores reais.
|
||||
@@ -0,0 +1,33 @@
|
||||
# G3 — Divisão administrativa em armazéns e equipamentos
|
||||
|
||||
Implementação autorizada pela continuação de desenvolvimento. Instalação no piloto **pendente de autorização específica**; o âmbito anterior instalado abrangia apenas sede da empresa e fornecedores. A cartografia e os seus diagnósticos não foram alterados.
|
||||
|
||||
## Registar uma vez e reutilizar
|
||||
|
||||
Armazéns e equipamentos guardam apenas `geography_reference`: país MZ, hash da edição e ID do registo. Nomes, códigos e percurso são lidos de `AdministrativeGeography`, com o mesmo selector opcional e as mesmas recusas de códigos ambíguos, níveis superiores ausentes ou edição divergente. Não se copiam tabelas de localidades por empresa ou módulo.
|
||||
|
||||
A migração isolada `2026_11_03_000005_add_operational_geography_references` acrescenta duas colunas JSON anuláveis, em `warehouses` e `equipment`, sem preencher qualquer referência. Cada cadastro verifica a existência da sua coluna e a opção específica `SGC_OPERATIONAL_GEOGRAPHY_ENABLED` (desligada por defeito) antes de disponibilizar o campo. Suspender esta opção conserva a geografia da sede/fornecedores. A ausência desta estrutura não desliga a localização já instalada na sede/fornecedores nem impede os cadastros antigos sem classificação.
|
||||
|
||||
## Armazém
|
||||
|
||||
Administração e Armazém podem indicar uma referência na criação e alterar somente a localização de um armazém existente. A alteração compara a referência anterior, sob o bloqueio da empresa, para recusar sobrescrita concorrente. Repetir a mesma referência não cria alteração ou nova auditoria; retirar a referência não altera código, nome, reposição, centro de custo ou responsável.
|
||||
|
||||
O comando revalida o utilizador actual e a sua competência no servidor, recusa manutenção e restringe o armazém à mesma empresa. Auditoria e alteração são transaccionais. Compras e Finanças mantêm consulta, sem ganhar competência para classificar armazéns.
|
||||
|
||||
A consulta de existências pode filtrar pela localização do armazém, incluindo descendentes confirmados. Os mesmos armazéns filtram movimentos e saldos derivados; não se grava saldo territorial paralelo. A pesquisa de artigo e o filtro por armazém continuam combináveis. A localização apresentada é a referência actual, não uma afirmação sobre onde ocorreu um movimento histórico.
|
||||
|
||||
## Equipamento
|
||||
|
||||
Administração, Armazém e Compras mantêm a competência nativa de cadastro. A localização é opcional na criação/edição, no mesmo comando, versão e auditoria da ficha. O comando revalida a competência actual após bloquear a empresa e recusa manutenção. Omitir o campo conserva a classificação; retirar explicitamente elimina só a referência. A área textual, departamento, série, criticidade e restantes regras de cadastro não são substituídos por uma localidade.
|
||||
|
||||
O filtro territorial limita os equipamentos, ligações a peças, artigos associados e histórico aos equipamentos confirmados nesse território, conservando a separação entre empresas e o âmbito departamental do leitor. Criticidade mantém o catálogo próprio. A ficha individual mostra a localização actual, enquanto pedidos de compra e movimentos continuam a consultar os respectivos dados de origem.
|
||||
|
||||
Este bloco não representa transferência física de equipamento, mudança de responsável, redistribuição de stock ou alteração de destino de uma requisição. Documentos, decisões, validações técnicas e movimentos anteriores não são reescritos. Não se infere a localização de um equipamento a partir da sede, do armazém ou da semelhança do nome da área.
|
||||
|
||||
## Verificação e piloto
|
||||
|
||||
Provas em `docs/proofs/operational-geography.json` e `operational-geography-pg.json`. Cobertura de criação, edição, remoção, concorrência lógica, versão nativa, âmbito empresarial/departamental, recusas de dados inconsistentes, permissões revogadas, manutenção e rollback de auditoria. A regressão integrada de Equipamentos/Stock é distinta do fecho dirigido; baterias sobrepostas não se somam.
|
||||
|
||||
A preparação `scripts/prepare-operational-geography-schema.php --prepare-operational-geography` exporta uma cópia consistente do piloto em transacção só de leitura, restaura uma base descartável e aplica a migração **apenas nessa cópia**. Confere todas as tabelas, as colunas antigas dos dois cadastros e que as novas referências estão vazias; remove a cópia. Não executa comandos de negócio ou migrações no piloto.
|
||||
|
||||
No piloto8005, a estrutura000005 continua ausente e os novos controlos permanecem ocultos. A cartografia existente continua activa para sede e fornecedores. MFA desligado; sem alterações a contas, perfis, aprovações, políticas, ordens ou SGF. A proposta concreta de instalação está em `docs/proposta-instalacao-geografia-operacional.md`. Sem revisão visual completa ou aceitação empresarial nesta entrega.
|
||||
@@ -8,17 +8,18 @@ O âmbito é o SGC autónomo em `C:/laragon/www/compras`, com o piloto sintétic
|
||||
|
||||
| Área da matriz | Código/prova consultados | Estado reconciliado e pendência |
|
||||
| --- | --- | --- |
|
||||
| Divisão administrativa e cadastros | `AdministrativeGeography`; `divisao-administrativa-sgc.md`; provas `administrative-geography-installation.json` e `administrative-geography-review.json` | G1 instalado e selecção activa com autorização: duas colunas vazias, backup/restauro114tabelas e dados anteriores intactos. G2: consulta/CSV das inconsistências, sem correcção automática. G3 reutiliza geografia em armazéns/equipamentos, com opção independente desligada e migração000005 não instalada; docs/geografia-operacional-sgc.md. G4/G5 reutilizam catálogo nas disponibilidades principal/adicionais e snapshot; G6 filtra a caixa por referências submetidas; G7 reutiliza a disponibilidade na ficha/dossiê apenas com acesso às fontes, sem integrar a base de decisão e sem migração; docs/geografia-candidatos-sgc.md. Catálogo preserva20.407linhas,9.678seleccionáveis e10.729pendentes. MFA desligado; edição/revisão humana pendentes. |
|
||||
| Acesso e organização | `config/fortify.php`, `OrganizationAccess`, `CurrentActor`; `proofs/pilot-replenishment-activation-http.json` | Perfis, tarefas, contexto e vigência existem. Fortify activa registo e recuperação de palavra-passe; os campos MFA no modelo não provam MFA activo. MFA e validação comercial do ciclo de acessos continuam pendentes. Não impor regras novas às contas existentes por inferência. |
|
||||
| Requisições e aprovações | `RequisitionWorkflow`; `InternalRequisitionCandidateTest`; `proofs/pilot-approval-activation.json` | Circuito versionado e recibos de comandos implementados; aprovação sintética aplicada uma vez. Valores e segregação continuam a pertencer à configuração da empresa. Não significa aceitação empresarial nem autorização de adjudicação. |
|
||||
| Fornecedores | `SupplierParticipation`, `SupplierBankWorkflow`, `SupplierEvaluationWorkflow`; `proofs/supplier-quality-task-integration.json` | Cadastro, validação, avaliação qualitativa, contraditório e tarefas têm implementação posterior à matriz. Múltiplas contas bancárias e verificação externa não ficam comprovadas pelas provas aqui consultadas. |
|
||||
| Cotações | `QuotationController`, `QuotationPricing`; `SupplierQuotationTest`; `proofs/pilot-evaluation-activation.json` | Preços/descontos reutilizam núcleo comum e as seis avaliações fictícias estão registadas. O controlador ainda grava moeda MZN: multimoeda de propostas continua uma lacuna real, distinta do registo de acordos de importação. Não inventar câmbios ou aprovar propostas novas. |
|
||||
| Conflitos RF-AF-01 | `ConflictGuard`; `compatibilidade.md` | Há controlo no servidor. Não foi comprovado nesta revisão o ciclo completo de periodicidade/validade/levantamento; manter pendente, sem desactivar impedimentos. |
|
||||
| Adjudicação e ordens | `routes/web.php`, `PurchaseAwardOrderTest`; `proposta-cancelamento-ordens.md` | Emissão e aprovações existentes preservadas. Cancelamento de ordem emitida continua descrito como proposta não activada; é distinto da revisão anterior à emissão e do estorno físico. Não cancelar nem libertar compromisso por analogia com uma correcção de stock. |
|
||||
| Recepção | `ReceiptWorkflow`; provas de recepção e `proofs/inventory-corrections.json` | Recepção e correcções físicas têm implementação. Substituições/anexos e percurso operacional integral ainda exigem cobertura própria; correcção física não equivale a nota de crédito. |
|
||||
| Adjudicação e ordens | `PurchaseAwardOrderTest`; `cancelamento-ordens-emitidas.md`; `proofs/issued-order-cancellation.json` | C2 autorizado para desenvolvimento,implementado com política própria e decisão independente de Finanças,sem recepção/factura/pagamento. Original imutável,reversão canónica auditada única e revisão ligada. Desligado e sem migração/políticas/cancelamentos no piloto;antes da emissão e estornos físicos mantêm circuitos separados. |
|
||||
| Recepção | `ReceiptWorkflow`; provas de recepção e `proofs/inventory-corrections.json`; `recepcao-documental.md` | Recepção e correcções físicas implementadas; anexos privados R1 instalados com autorização, backup/restauro e consulta por perfis confirmados (`proofs/receipt-block-installation.json`). R2 acrescenta reprodução imprimível com referências dos estornos e anexos. Substituições e aceitação operacional integral mantêm cobertura própria; correcção física não equivale a nota de crédito. |
|
||||
| Stock | `StockWorkflow`, `InventoryLots`, `InventoryReversals`, `InventoryDispositions`; `proofs/inventory-task-integration.json` | Lotes, qualidade, correcções e tarefas já existem; «estornos ausentes» seria uma conclusão incorrecta para stock. Conversões gerais de unidade e revisão visual não foram encerradas. |
|
||||
| Requisições internas | `WarehouseRequests`, `WarehouseReservations`, `WarehouseFulfilment`; `proofs/warehouse-task-pilot-status-http.json` | Autorização, reserva e entrega usam a requisição como documento de saída, sem factura. Venda a terceiros pertence à composição SGF; não desenvolver facturação paralela no SGC. |
|
||||
| Devolução interna de excedente | `WarehouseReturns`; `warehouse-unused-return.md` | Implementação sintética própria, desactivada por defeito. Não confundir existência de código/prova com funcionalidade activa no piloto; não habilitar por esta revisão. |
|
||||
| Equipamentos e peças | `EquipmentWorkflow`; `EquipmentSparePartsTest`, `InvestmentPolicyTest` | Cadastros e regras técnicas existem. Configuração empresarial, agregação por projecto e fraccionamento continuam sem encerramento comprovado nesta revisão. |
|
||||
| Equipamentos e peças | `EquipmentWorkflow`; `EquipmentSparePartsTest`, `InvestmentPolicyTest`; `equipamentos-acompanhamento.md` | E1 liga ficha do equipamento às compras visíveis e movimentos físicos,com paginação independente e operação única da entrega interna (`proofs/equipment-integration.json`). Configuração empresarial,agregação por projecto e fraccionamento continuam separados,sem encerramento por esta ficha. |
|
||||
| Orçamento | `CommonBudget`, `BudgetWorkflow`, `BudgetReinforcementWorkflow`; `compatibilidade.md` e provas orçamentais | Núcleo comum, dotações, reforços, reservas e compromissos têm provas históricas. Reduções/transferências e aceitação operacional não devem ser inferidas dessas provas. |
|
||||
| Facturas e pagamentos | `SupplierPayables`, `SupplierPayablePage`; `financeiro-consulta-paginada.md`, `contabilidade-ciclo-sintetico.md` | Há adaptadores e entregas comuns posteriores. Algumas capacidades continuam restritas a QA ou desligadas; não declarar estornos/notas universalmente disponíveis no piloto nem instalar pacote por inferência. |
|
||||
| Planeamento e formalização | `PurchasePlanningWorkflow`, `ProposalOpeningWorkflow`, `FormalAdmission`, `FormalRecommendation`; `validacao-tecnica.md` | Preservar adopções e políticas independentes. Não há prova nesta revisão de assinaturas digitais verificadas, propostas seladas ou aprovação financeira de planos. |
|
||||
@@ -59,3 +60,66 @@ Não foi declarado o SGC completo. Esta tabela actualiza estados sustentados pel
|
||||
Adoptado o plano docs/plano-entregas-sgc.md: construir blocos funcionais e validar em conjunto no fecho, mantendo verificações críticas dirigidas durante a implementação. R1 acrescenta evidências privadas à recepção confirmada, reutilizando origem e responsabilidades nativas, com repetição segura, integridade na descarga, auditoria e navegação para o processo. Não repete stock, aprovação ou recepção física.
|
||||
|
||||
Validação final: ReceiptDocumentBlockTest + ReceiptStockTest, 36 testes/271 verificações; PostgreSQL descartável 5/78, base removida. PHPStan sem erros, TypeScript, compilação de produção, Pint, diff e 18 hashes de execução conferidos. Prova docs/proofs/receipt-block.json e limites em docs/recepcao-documental.md. Sem revisão visual completa, carga ou instalação no piloto. Migração documental 2026_11_03_000002 pendente de autorização específica; não aplicar incidentalmente MFA. Sem novas contas, permissões ou decisões de negócio. R1 não declara todo o módulo Recepção concluído.
|
||||
|
||||
## 03/11/2026 — instalação R1 autorizada e bloco R2 entregue
|
||||
|
||||
O proprietário autorizou instalar documentos da recepção no piloto e continuar os blocos seguintes. Aplicada exclusivamente a migração documental000002; cópia restaurada/conferida em113tabelas,112tabelas de negócio preservadas e base de restauro removida. Armazenamento privado conferido,sem novos documentos de negócio,contas,grants ou decisões. MFA não activado. Provas receipt-block-installation[-http].json; não repetir a instalação ou solicitar novamente esta autorização.
|
||||
|
||||
R2 acrescenta impressão A4/guardar PDF pelo navegador,sem campos adicionais: confirmação original,quantidades,qualidade,referências de estornos executados e anexos consultáveis comSHA256,sem incorporar ficheiros privados. Consulta actual claramente distinguida do original,nativepolicy/tenant e resposta privada semcache. Não substitui assinatura digital,saldo efectivo ou aceitação visual. Bateria integrada39testes/295verificações;PostgreSQL3/24 descartável;PHPStan,TypeScript,compilação,Pint,diff e18hashes aprovados. HTTP porAdmin,Armazém,Compras,sem comandos de negócio. Provas receipt-print[-pg/-http].json; docs/recepcao-impressao.md. Sem migração adicional,alteração de aprovações/adjudicação/encomenda/SGF. Restantes blocos da matriz continuam pendentes; não confundir esta entrega com conclusão comercial integral.
|
||||
|
||||
## 03/11/2026 — A1: documento individual de entrega interna
|
||||
|
||||
Acrescentado documento imprimível por entrega,sem duplicar a impressão conjunta já existente. Reutiliza a requisição como documento de saída sem factura;inclui só as quantidades desse evento,artigo/unidade,destino/equipamento,centro de custo,responsáveis,autorização anterior e operação de stock. Confirmação actual do requisitante identificada ou pendente;original não é apresentado como saldo actual e devoluções/correcções conservam processos próprios.
|
||||
|
||||
Acesso nativo por requisição/empresa,acção deliver obrigatória,resposta privada semcache e texto escapado. Nenhum comando de negócio na emissão. Bateria integrada20testes/363verificações;PostgreSQL descartável2/37,base removida. PHPStan,TypeScript,compilação,Pint,formataçãofrontend,diff e18hashes aprovados. HTTP conferiu índicesAdmin/Armazém,ambos sem requisições internas;documentos positivos exercitados apenas nas bases descartáveis. Sem nova migração,grants,contas,entregas,facturas,decisões no piloto ou mudançaSGF. Prova docs/proofs/warehouse-delivery-print.json e variantes;docs/armazem-documento-entrega.md. Restantes conversões,activação de devoluções e aceitação operacional continuam separadas na matriz.
|
||||
|
||||
## 03/11/2026 — O1: integridade operacional dos documentos da recepção
|
||||
|
||||
Entregue sgc:receipt-documents-check,consulta técnica global de consola sem identidade interactiva. Cruza registos/empresa/recepção e armazenamento privado,detecta referências inválidas,ausência,divergência de tamanho/SHA256 e ficheiros sem registo,recentes ou de pelo menos1hora. Não interpreta idade como autorização de eliminação,não repara/apaga/nem altera auditoria ou aprovações. Saída só agregada e erros genéricos,limite independente de registos/ficheiros com incompletude explícita;sem cursor/capacidade geral certificada nem snapshot conjunto base/ficheiros.
|
||||
|
||||
Validação final14testes/135verificações integradas com upload,descarga e impressão;PostgreSQL descartável6/33,base removida. Duas empresas,sem mutações,inválidos/ausentes/alterados/sem referência,limites,estrutura/armazenamento indisponíveis e privacidade/recusa interactiva cobertos. PHPStan sem erros,Pint,diff e18hashes conferidos. Sem novo frontend,compilação,revisão visual,monitor recorrente,migração,comando no piloto,contas/grants,decisão de negócio ou alteraçãoSGF. Provas receipt-document-health[-pg].json e docs/operacao-documentos-recepcao.md. Retenção,limpeza autorizada,backup/restauro conjunto,serviço,reinício/capacidade e demais blocos mantêm estado próprio.
|
||||
|
||||
## 03/11/2026 — S1: integração de MFA com as entradas de avaliação
|
||||
|
||||
Quando MFA activo,o middleware SGC impede atalhos /avaliar e /demo,autoentrada do proprietário e navegação pública de desenvolvimento como alternativa à autenticação;login por palavra-passe e desafio Fortify nativo permanecem. MFA desligado conserva o piloto. Sem modificar o código do alojamentoSGF ou decisões/contas/grants.
|
||||
|
||||
Regressão36testes/230verificações;PostgreSQL descartável8/61,base removida;PHPStan,Pint,diff e18hashes aprovados. EntradasHTTP e desafio comCSRF;identidade autenticada testada directamente no middleware. Sem novo frontend,compilação ou revisão visual. Provas mfa-evaluation-boundary[-pg].json. ConsultaHTTP confirma piloto desligado,sem inscrições.
|
||||
|
||||
Preparada proposta concreta de instalar exclusivamente migração000001,três camposMFA anuláveis,vazios. Cópia consistente restaurada114tabelas;ensaio preservou112tabelas protegidas e colunas existentes de12contas,sem inscrições;base removida. Provas mfa-pilot-schema-preparation/proposal.json e docs/proposta-instalacao-mfa-piloto.md. Instalação real continua PENDENTE DE AUTORIZAÇÃO;activação e eventuais credenciais/recuperação exigem plano e autorização próprios. Não activar MFA nas contas sintéticas dependentes de atalhos sem preparar acesso e recuperação,ou aplicar uma migração geral por inferência.
|
||||
|
||||
## 03/11/2026 — estrutura MFA instalada e blocoP1 Pessoas entregue
|
||||
|
||||
Autorização específica do proprietário aplicada só à migraçãoMFA000001,três campos vazios. Nova cópia consistente restaurada114tabelas,112tabelas protegidas e colunas antigas de12contas preservadas,sem inscrições,palavras-passe,perfis ou aprovações alterados. Provas mfa-pilot-schema-authorization/installation.json;consultaHTTP confirma capacidade desligada. Não repetir instalação ou solicitar novamente autorização;activação/credenciais/recuperação permanecem fora do recorte.
|
||||
|
||||
P1 acrescenta dossiêJSON de preparação,schema sgc.people.process-dossier.v1,com versão e dados reutilizados do processo e matriz. Projecção única entre página/descarga,com acessos separados ao financeiro,entrevistas e minuta e autorização nativa por processo/empresa/validade. Sem anexos copiados,auditoria integral,comandos,envioRH ou formalização;fronteira PeopleContractHandoff continua a recusar minutas. Downloadprivado semcache;não é arquivo assinado e acesso revogado não elimina cópia já descarregada.
|
||||
|
||||
Validação final56testes/1684verificações dePeopleHiring/PeopleRegistry;PostgreSQL3/72,base descartável removida. PHPStan,Pint,formataçãofrontend,TypeScript,compilação,diff e18hashes aprovados. HTTP confirmouPessoas sem processos visíveis nessa conta;dossiês positivos apenas em bases descartáveis. Sem dados/decisões/grants dePessoas no piloto,nova migração paraP1,revisão visual completa ou mudançaSGF. Prova people-dossier[-pg/-http].json e docs/pessoas-dossie-preparacao.md. Matriz actualizada;formalização contratual e restantes blocos conservam critérios próprios.
|
||||
|
||||
## 03/11/2026 — D1: metadados de auditoria dos módulos
|
||||
|
||||
Consulta paginada25eventos pela ordem de registo e CSV de todos os resultados filtrados,com mesma pesquisa/âmbito por empresa e apenasid,data,acção,user_id. Competência audit_metadata_read nova,não atribuída automaticamente nem aAdmin,navegação condicionada e configurável pelos percursos nativos. Sem before/after,anexos,dados privados ou links que contornem acesso a processos;sem mutações por consulta. Filtros opcionais deacção/utilizador/datas,CSV emlotes100 com protecção de fórmulas,privacidade e snapshot de leitura. Não é exportação integral/retenção/protecção contraSQLadmin.
|
||||
|
||||
Validação41testes/410verificações comOrganização/Contexto;PostgreSQL7/85,base descartável removida;PHPStan,Pint,formataçãofrontend,TypeScript,compilação,diff e18hashes aprovados. HTTP recusaAdmin/Armazém no piloto sem a nova competência;MFA continua desligado. Sem migração,grants ou comandos de negócio no piloto. Provas audit-metadata[-pg/-http].json e docs/auditoria-metadados.md.
|
||||
|
||||
Preparada proposta concreta audit-access-proposal.json,documentada em docs/proposta-acesso-auditoria-piloto.md:perfil sóaudit_metadata_read emprocurement.internal,acrescido apenas a6/8,conservando perfil7 e competências existentes. Ensaio nativo emcópia restaurada114tabelas,permissões preservadas e tabelas protegidas intactas;base removida,nenhumgrantreal. Versão actual27,proposta30 por3comandos auditados;atribuições6/8 são reemitidas pelo motor com novo fundamento/IDs,estado anterior conservado nohistórico. PENDENTE DE AUTORIZAÇÃO por novo acesso a metadados de todos os módulos daempresa;sem palavras-passe,contas,MFA,aprovações,decisões ouSGF. Não activar por inferência de continuação genérica.
|
||||
|
||||
|
||||
G8 — 03/11/2026: relatório de medidas de melhoria segmentado pelo território actual da ficha do fornecedor, partilhando SupplierGeography com o cadastro. Totais, paginação e CSV respeitam os mesmos filtros e acessos. Sem migração, alteração de snapshots, decisões, perfis ou aprovações. MFA desligado; G3 operacional continua pendente. Documentação: docs/territorio-melhorias-fornecedores-sgc.md; prova: docs/proofs/supplier-territory.json. Validação dirigida SQLite: 50 testes/700 verificações; sem nova execução PostgreSQL ou revisão visual completa.
|
||||
|
||||
|
||||
A2 — 03/11/2026: reprodução dedicada da requisição interna, reutilizando snapshot e WarehouseFulfilment.progress. Separação de pedido/reserva/entrega/saldo/cancelamento, histórico de autorização e recepção, ligações aos documentos individuais A1. Mesmos acessos da ficha, sem mutações ou factura. Sem migração; aprovações preservadas e MFA desligado. Docs/armazem-requisicao-impressao.md; provas warehouse-request-print.json e warehouse-request-print-pg.json. Não declara revisão visual ou aceitação comercial.
|
||||
|
||||
Validação A2: bateria conjunta WarehouseFulfilmentTest + WarehouseRequestsTest aprovada, 23 testes/408 verificações; PostgreSQL descartável 3 testes/45 verificações, base removida. PHPStan, tipos, formatação, rotas e compilação aprovados. Sem revisão visual completa. Contagens não aditivas às provas anteriores.
|
||||
|
||||
|
||||
A3 — 03/11/2026: CSV integral de acompanhamento das requisições internas, com filtros aplicados e âmbito partilhado com a lista. Uma linha por artigo/unidade; quantidades e estados reutilizam progress nativo. Paginação não limita a exportação; protecção de células de folha de cálculo. Sem novas aprovações, campos, migrações ou movimentos; MFA desligado. Docs/armazem-requisicoes-exportacao.md; provas warehouse-request-export.json e warehouse-request-export-pg.json. Sem teste de carga ou revisão visual completa.
|
||||
|
||||
Validação A3: WarehouseFulfilmentTest + WarehouseRequestsTest, 26 testes/437 verificações aprovados. PostgreSQL descartável, 3 testes/29 verificações aprovados e base removida. PHPStan, tipos, formatação e diff aprovados. Contagens não aditivas às provas anteriores.
|
||||
|
||||
|
||||
O2 — 03/11/2026: diagnóstico integrado de reposição e integridade documental pelo comando sgc:operations-check. Reutiliza verificadores nativos, consulta global só em consola sem identidade/sessão, saída agregada sem detalhes privados e continuação em falha parcial. Estado incompleto/indisponível nunca equivale a sucesso. Não instala serviços, recupera compras, corrige ficheiros ou grava telemetria. Sem migrações ou interface nova; aprovações preservadas e MFA desligado. Docs/operacao-diagnostico-integrado.md; provas operational-health.json e operational-health-pg.json. Instalação recorrente, restauro, capacidade e aceitação mantêm-se pendentes.
|
||||
|
||||
Validação O2: 17 testes/112 verificações aprovados em OperationalHealthTest, ReplenishmentHealthTest e ReceiptDocumentHealthTest; PostgreSQL descartável 5 testes/36 verificações aprovados e base removida. Contagens não aditivas às provas anteriores. Interface inalterada, sem repetição da compilação nesta etapa.
|
||||
|
||||
|
||||
E2 — 03/11/2026: ficha de equipamento reúne também requisições internas ao armazém ainda pendentes, por ID nas linhas preservadas, sem associação por texto. Projecção só das linhas do equipamento, estado/reserva/entrega/saldo nativos, ligações aos processos e paginação independente de15. Acesso ao equipamento não concede acesso ao armazém; empresa/departamento/capacidades mantidos. Sem novos registos, movimentos, migração ou aprovações; MFA desligado. Docs/equipamentos-requisicoes-internas.md; provas equipment-warehouse.json e equipment-warehouse-pg.json. SQLite57testes/737verificações e PostgreSQL4/107 aprovados, sem somar a provas históricas; não declara revisão visual/carga. G3 continua pendente.
|
||||
|
||||
+9
-1
@@ -1,6 +1,6 @@
|
||||
# Adesão voluntária MFA no SGC
|
||||
|
||||
03/11/2026. Código preparado e validado em bases descartáveis; piloto permanece com a funcionalidade desligada e sem a migração MFA aplicada. Não há inscrição automática, MFA obrigatório por perfil ou mudança de competências.
|
||||
03/11/2026. Código preparado e validado em bases descartáveis; piloto permanece com a funcionalidade desligada. Posteriormente autorizada e instalada exclusivamente a estrutura de três campos vazios,com backup/restauro114tabelas e preservação das12contas;prova mfa-pilot-schema-installation.json. Não há inscrição automática, MFA obrigatório por perfil ou mudança de competências. Referências de preparação abaixo preservam a sequência histórica; a pendência actual é activação/recuperação autorizada, não repetir a migração.
|
||||
|
||||
## Percurso implementado
|
||||
|
||||
@@ -23,3 +23,11 @@ Antes de propor activação no destino, preparar a operação concreta com estad
|
||||
`proofs/mfa-management.json` e variantes `-pg`/`-http` registam os resultados finais. A regressão inclui os cenários anteriores de desafio TOTP/recuperação e os novos de adesão, consulta sem mutação, renovação, desactivação, isolamento do titular, palavra-passe expirada, privacidade e CSRF com validação efectivamente ligada no teste. O piloto foi consultado apenas com a capacidade desligada: não foram feitas adesões, trocas de palavra-passe, migrações ou grants.
|
||||
|
||||
Esta entrega não altera decisões, alçadas, reservas, stock ou aprovação de compras. A autenticação confirma a identidade; as competências continuam nos motores existentes.
|
||||
|
||||
## S1 — integração com a avaliação local
|
||||
|
||||
Foi corrigida a fronteira entre MFA e os atalhos sintéticos: quando a capacidade MFA está activa, middleware SGC impede entrada/troca de perfis por /avaliar e /demo, não executa autoentrada do proprietário e deixa a autenticação normal responder. A navegação pública de desenvolvimento também não substitui a autenticação nesse modo. A protecção abrange as rotas web de demonstração registadas pelo alojamento, sem modificar o código SGF. Com a capacidade desligada, o piloto conserva o comportamento actual.
|
||||
|
||||
Regressão final36testes/230verificações de navegação,avaliação,desafio e gestãoMFA;PostgreSQL descartável8testes/61verificações. CoberturaHTTP das entradas sem palavra-passe e do desafio nativo comCSRF;preservação da identidade já autenticada também verificada directamente no middleware. PHPStan,Pint,diff e18hashes aprovados. Sem frontend novo,compilação ou revisão visual. Provas mfa-evaluation-boundary[-pg].json.
|
||||
|
||||
Preparada proposta-instalacao-mfa-piloto.md e prova mfa-pilot-schema-preparation.json:backup restaurado,114tabelas conferidas e migração ensaiada só na cópia descartável,sem inscrever contas. A instalação real dos três campos continua pendente de autorização. Activação futura depende de acesso por palavra-passe e recuperação preparados,sem desligar MFA globalmente como solução de recuperação individual.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# O2 — diagnóstico operacional integrado
|
||||
|
||||
03/11/2026. O comando `php artisan sgc:operations-check --json` reúne as verificações existentes de reposição durável e integridade documental da recepção. Não instala serviços nem executa recuperação, compras, aprovações ou reparações. Os comandos individuais continuam disponíveis.
|
||||
|
||||
`--document-limit=1000` limita a verificação documental de 1 a 10000 registos/ficheiros, segundo o verificador nativo. Limites inválidos devolvem código 2 antes de inspeccionar. O comando só pode executar em consola, sem identidade autenticada ou sessão interactiva, pois agrega observações de várias empresas.
|
||||
|
||||
O resultado JSON tem `schema_version=1`, início e fim da consulta, `ok`, `complete` e `checks`. Cada verificação conserva o seu resultado e data nativos. As consultas são sequenciais e não representam uma fotografia atómica entre módulos. Não se cria um segundo motor de avaliação.
|
||||
|
||||
Estados por verificação:
|
||||
|
||||
- `passed`: nenhuma divergência observada pela verificação.
|
||||
- `attention`: consulta concluída com pendências ou divergências, incluindo ausência de passagem recente da reposição.
|
||||
- `incomplete`: a verificação não cobriu todo o âmbito, por exemplo limite documental atingido ou estrutura indisponível.
|
||||
- `unavailable`: erro na execução; o outro verificador continua e o resultado apresenta apenas motivo genérico.
|
||||
|
||||
`ok` só é verdadeiro se ambas passarem. `complete` indica apenas que ambas as consultas terminaram com cobertura completa; pode ser verdadeiro com `ok=false`. Código de saída 0 indica ausência de divergências observadas e 1 indica atenção/incompletude/indisponibilidade. Não certifica prontidão comercial, cópias de segurança, restauro, capacidade, serviço instalado ou aprovação humana.
|
||||
|
||||
O comando não inclui nomes, identificadores de empresas, caminhos privados ou credenciais no resultado. Excepções conservam o registo técnico existente; a saída não inclui a mensagem privada. Não agenda tarefas, publica notificações nem grava telemetria. Quem monitoriza pode integrar o código de saída e o JSON conforme a instalação autorizada.
|
||||
|
||||
Sem alterações na interface ou migrações. Perfis, aprovações e dados do piloto preservados; MFA desligado. Testes em bases descartáveis cobrem composição nativa, ausência de mutações e de alteração do heartbeat, falha parcial com continuação, limite incompleto, pendências e rejeição de execução interactiva. Provas em docs/proofs/operational-health.json e operational-health-pg.json; sem nova revisão visual ou teste de carga.
|
||||
|
||||
Validação O2: 17 testes/112 verificações aprovados em OperationalHealthTest, ReplenishmentHealthTest e ReceiptDocumentHealthTest; PostgreSQL descartável 5 testes/36 verificações aprovados e base removida. Contagens não aditivas às provas anteriores. Interface inalterada, sem repetição da compilação nesta etapa.
|
||||
|
||||
Análise PHPStan sem erros, formatação e verificação do diff aprovadas.
|
||||
@@ -0,0 +1,15 @@
|
||||
# O1 — verificação operacional dos documentos da recepção
|
||||
|
||||
O comando técnico `php artisan sgc:receipt-documents-check --json` cruza os registos da recepção com o armazenamento privado. É uma consulta global da instalação, com saída apenas agregada, destinada à consola sem identidade interactiva. Não existe rota HTTP, acesso adicional ou inscrição automática em monitorização.
|
||||
|
||||
Verifica a ligação do documento à recepção/empresa, o caminho dentro do armazenamento privado, a existência do ficheiro, o tamanho e o SHA-256. Só calcula o hash quando o tamanho coincide e respeita o máximo de2MB do upload. A listagem distingue ficheiros sem referência recentes e com pelo menos uma hora. Esta idade é uma indicação técnica; não prova abandono nem autoriza apagar ficheiros. Um upload em curso pode surgir como ficheiro recente sem referência.
|
||||
|
||||
Não escreve registos, ficheiros, auditoria ou estado de saúde persistente; não elimina, reanexa ou repara conteúdo. Nomes, caminhos, empresas, pessoas e conteúdo não integram a saída. Falhas inesperadas devolvem indicação genérica e ficam no registo técnico da instalação. A recusa de identidade interactiva impede confundir este agregado com uma consulta por empresa.
|
||||
|
||||
`--limit=1000` limita independentemente documentos e ficheiros; aceita de1a10000. Ao atingir o limite, a saída indica `complete=false`, motivo `limit_reached` e código1, sem declarar a instalação saudável. Este bloco não oferece paginação/cursor para grandes volumes nem substitui ensaio de capacidade. A enumeração de directórios e as consultas técnicas podem consumir recursos; não foi instalado um serviço periódico.
|
||||
|
||||
Código0 significa consulta completa sem divergências observadas; código1 significa divergências, estrutura/armazenamento indisponível, consulta incompleta ou falha; código2 indica limite inválido. A consulta PostgreSQL usa snapshot de leitura, mas ficheiros e base não têm snapshot conjunto: o resultado é uma observação técnica, não uma certificação de backup ou recuperação.
|
||||
|
||||
Não foram executados comandos no piloto nem alteradas contas, aprovações ou configuração por esta entrega. Ensaios de ficheiros, bases e erros são descartáveis. Não há novo frontend, migração, activação de MFA ou alteração SGF. Provas receipt-document-health.json e receipt-document-health-pg.json. Revisão visual, retenção, limpeza de ficheiros, backup/restauro conjunto e instalação comercial continuam separados.
|
||||
|
||||
Fecho: 14 testes/135 verificações integradas de saúde documental, upload/descarga e impressão. PostgreSQL descartável6testes/33verificações,base removida. Cobertura de duas empresas,ausência de mutações,conteúdo alterado,ficheiros ausentes/sem referência,caminho inválido,limite,estrutura/armazenamento indisponível,ocultação de erros e recusa de identidade interactiva. PHPStan sem erros,Pint,diff e18hashes de execução conferidos. Sem alteração de frontend,nova compilação ou revisão visual nesta entrega.
|
||||
@@ -1071,3 +1071,156 @@ Matriz e docs/operacao-reposicao.md actualizados. Sem migração,contas/grants,d
|
||||
Adoptado o plano docs/plano-entregas-sgc.md: construir blocos funcionais e validar em conjunto no fecho, mantendo verificações críticas dirigidas durante a implementação. R1 acrescenta evidências privadas à recepção confirmada, reutilizando origem e responsabilidades nativas, com repetição segura, integridade na descarga, auditoria e navegação para o processo. Não repete stock, aprovação ou recepção física.
|
||||
|
||||
Validação final: ReceiptDocumentBlockTest + ReceiptStockTest, 36 testes/271 verificações; PostgreSQL descartável 5/78, base removida. PHPStan sem erros, TypeScript, compilação de produção, Pint, diff e 18 hashes de execução conferidos. Prova docs/proofs/receipt-block.json e limites em docs/recepcao-documental.md. Sem revisão visual completa, carga ou instalação no piloto. Migração documental 2026_11_03_000002 pendente de autorização específica; não aplicar incidentalmente MFA. Sem novas contas, permissões ou decisões de negócio. R1 não declara todo o módulo Recepção concluído.
|
||||
|
||||
## 03/11/2026 — instalação R1 autorizada e bloco R2 entregue
|
||||
|
||||
O proprietário autorizou instalar documentos da recepção no piloto e continuar os blocos seguintes. Aplicada exclusivamente a migração documental000002; cópia restaurada/conferida em113tabelas,112tabelas de negócio preservadas e base de restauro removida. Armazenamento privado conferido,sem novos documentos de negócio,contas,grants ou decisões. MFA não activado. Provas receipt-block-installation[-http].json; não repetir a instalação ou solicitar novamente esta autorização.
|
||||
|
||||
R2 acrescenta impressão A4/guardar PDF pelo navegador,sem campos adicionais: confirmação original,quantidades,qualidade,referências de estornos executados e anexos consultáveis comSHA256,sem incorporar ficheiros privados. Consulta actual claramente distinguida do original,nativepolicy/tenant e resposta privada semcache. Não substitui assinatura digital,saldo efectivo ou aceitação visual. Bateria integrada39testes/295verificações;PostgreSQL3/24 descartável;PHPStan,TypeScript,compilação,Pint,diff e18hashes aprovados. HTTP porAdmin,Armazém,Compras,sem comandos de negócio. Provas receipt-print[-pg/-http].json; docs/recepcao-impressao.md. Sem migração adicional,alteração de aprovações/adjudicação/encomenda/SGF. Restantes blocos da matriz continuam pendentes; não confundir esta entrega com conclusão comercial integral.
|
||||
|
||||
## 03/11/2026 — A1: documento individual de entrega interna
|
||||
|
||||
Acrescentado documento imprimível por entrega,sem duplicar a impressão conjunta já existente. Reutiliza a requisição como documento de saída sem factura;inclui só as quantidades desse evento,artigo/unidade,destino/equipamento,centro de custo,responsáveis,autorização anterior e operação de stock. Confirmação actual do requisitante identificada ou pendente;original não é apresentado como saldo actual e devoluções/correcções conservam processos próprios.
|
||||
|
||||
Acesso nativo por requisição/empresa,acção deliver obrigatória,resposta privada semcache e texto escapado. Nenhum comando de negócio na emissão. Bateria integrada20testes/363verificações;PostgreSQL descartável2/37,base removida. PHPStan,TypeScript,compilação,Pint,formataçãofrontend,diff e18hashes aprovados. HTTP conferiu índicesAdmin/Armazém,ambos sem requisições internas;documentos positivos exercitados apenas nas bases descartáveis. Sem nova migração,grants,contas,entregas,facturas,decisões no piloto ou mudançaSGF. Prova docs/proofs/warehouse-delivery-print.json e variantes;docs/armazem-documento-entrega.md. Restantes conversões,activação de devoluções e aceitação operacional continuam separadas na matriz.
|
||||
|
||||
## 03/11/2026 — O1: integridade operacional dos documentos da recepção
|
||||
|
||||
Entregue sgc:receipt-documents-check,consulta técnica global de consola sem identidade interactiva. Cruza registos/empresa/recepção e armazenamento privado,detecta referências inválidas,ausência,divergência de tamanho/SHA256 e ficheiros sem registo,recentes ou de pelo menos1hora. Não interpreta idade como autorização de eliminação,não repara/apaga/nem altera auditoria ou aprovações. Saída só agregada e erros genéricos,limite independente de registos/ficheiros com incompletude explícita;sem cursor/capacidade geral certificada nem snapshot conjunto base/ficheiros.
|
||||
|
||||
Validação final14testes/135verificações integradas com upload,descarga e impressão;PostgreSQL descartável6/33,base removida. Duas empresas,sem mutações,inválidos/ausentes/alterados/sem referência,limites,estrutura/armazenamento indisponíveis e privacidade/recusa interactiva cobertos. PHPStan sem erros,Pint,diff e18hashes conferidos. Sem novo frontend,compilação,revisão visual,monitor recorrente,migração,comando no piloto,contas/grants,decisão de negócio ou alteraçãoSGF. Provas receipt-document-health[-pg].json e docs/operacao-documentos-recepcao.md. Retenção,limpeza autorizada,backup/restauro conjunto,serviço,reinício/capacidade e demais blocos mantêm estado próprio.
|
||||
|
||||
## 03/11/2026 — S1: integração de MFA com as entradas de avaliação
|
||||
|
||||
Quando MFA activo,o middleware SGC impede atalhos /avaliar e /demo,autoentrada do proprietário e navegação pública de desenvolvimento como alternativa à autenticação;login por palavra-passe e desafio Fortify nativo permanecem. MFA desligado conserva o piloto. Sem modificar o código do alojamentoSGF ou decisões/contas/grants.
|
||||
|
||||
Regressão36testes/230verificações;PostgreSQL descartável8/61,base removida;PHPStan,Pint,diff e18hashes aprovados. EntradasHTTP e desafio comCSRF;identidade autenticada testada directamente no middleware. Sem novo frontend,compilação ou revisão visual. Provas mfa-evaluation-boundary[-pg].json. ConsultaHTTP confirma piloto desligado,sem inscrições.
|
||||
|
||||
Preparada proposta concreta de instalar exclusivamente migração000001,três camposMFA anuláveis,vazios. Cópia consistente restaurada114tabelas;ensaio preservou112tabelas protegidas e colunas existentes de12contas,sem inscrições;base removida. Provas mfa-pilot-schema-preparation/proposal.json e docs/proposta-instalacao-mfa-piloto.md. Instalação real continua PENDENTE DE AUTORIZAÇÃO;activação e eventuais credenciais/recuperação exigem plano e autorização próprios. Não activar MFA nas contas sintéticas dependentes de atalhos sem preparar acesso e recuperação,ou aplicar uma migração geral por inferência.
|
||||
|
||||
## 03/11/2026 — estrutura MFA instalada e blocoP1 Pessoas entregue
|
||||
|
||||
Autorização específica do proprietário aplicada só à migraçãoMFA000001,três campos vazios. Nova cópia consistente restaurada114tabelas,112tabelas protegidas e colunas antigas de12contas preservadas,sem inscrições,palavras-passe,perfis ou aprovações alterados. Provas mfa-pilot-schema-authorization/installation.json;consultaHTTP confirma capacidade desligada. Não repetir instalação ou solicitar novamente autorização;activação/credenciais/recuperação permanecem fora do recorte.
|
||||
|
||||
P1 acrescenta dossiêJSON de preparação,schema sgc.people.process-dossier.v1,com versão e dados reutilizados do processo e matriz. Projecção única entre página/descarga,com acessos separados ao financeiro,entrevistas e minuta e autorização nativa por processo/empresa/validade. Sem anexos copiados,auditoria integral,comandos,envioRH ou formalização;fronteira PeopleContractHandoff continua a recusar minutas. Downloadprivado semcache;não é arquivo assinado e acesso revogado não elimina cópia já descarregada.
|
||||
|
||||
Validação final56testes/1684verificações dePeopleHiring/PeopleRegistry;PostgreSQL3/72,base descartável removida. PHPStan,Pint,formataçãofrontend,TypeScript,compilação,diff e18hashes aprovados. HTTP confirmouPessoas sem processos visíveis nessa conta;dossiês positivos apenas em bases descartáveis. Sem dados/decisões/grants dePessoas no piloto,nova migração paraP1,revisão visual completa ou mudançaSGF. Prova people-dossier[-pg/-http].json e docs/pessoas-dossie-preparacao.md. Matriz actualizada;formalização contratual e restantes blocos conservam critérios próprios.
|
||||
|
||||
## 03/11/2026 — D1: metadados de auditoria dos módulos
|
||||
|
||||
Consulta paginada25eventos pela ordem de registo e CSV de todos os resultados filtrados,com mesma pesquisa/âmbito por empresa e apenasid,data,acção,user_id. Competência audit_metadata_read nova,não atribuída automaticamente nem aAdmin,navegação condicionada e configurável pelos percursos nativos. Sem before/after,anexos,dados privados ou links que contornem acesso a processos;sem mutações por consulta. Filtros opcionais deacção/utilizador/datas,CSV emlotes100 com protecção de fórmulas,privacidade e snapshot de leitura. Não é exportação integral/retenção/protecção contraSQLadmin.
|
||||
|
||||
Validação41testes/410verificações comOrganização/Contexto;PostgreSQL7/85,base descartável removida;PHPStan,Pint,formataçãofrontend,TypeScript,compilação,diff e18hashes aprovados. HTTP recusaAdmin/Armazém no piloto sem a nova competência;MFA continua desligado. Sem migração,grants ou comandos de negócio no piloto. Provas audit-metadata[-pg/-http].json e docs/auditoria-metadados.md.
|
||||
|
||||
Preparada proposta concreta audit-access-proposal.json,documentada em docs/proposta-acesso-auditoria-piloto.md:perfil sóaudit_metadata_read emprocurement.internal,acrescido apenas a6/8,conservando perfil7 e competências existentes. Ensaio nativo emcópia restaurada114tabelas,permissões preservadas e tabelas protegidas intactas;base removida,nenhumgrantreal. Versão actual27,proposta30 por3comandos auditados;atribuições6/8 são reemitidas pelo motor com novo fundamento/IDs,estado anterior conservado nohistórico. PENDENTE DE AUTORIZAÇÃO por novo acesso a metadados de todos os módulos daempresa;sem palavras-passe,contas,MFA,aprovações,decisões ouSGF. Não activar por inferência de continuação genérica.
|
||||
|
||||
## 03/11/2026 — E1: Equipamentos ligados a Compras e Armazém
|
||||
|
||||
A continuação genérica do proprietário manteve a proposta D1 pendente;nenhum novo acesso deAuditoria foi atribuído. MFA permanece desligado e aprovações preservadas.
|
||||
|
||||
Ficha por equipamento reutiliza dados existentes e separa requisições de compra visíveis de operações físicas. Listas independentes15/página pela ordem de registo,sem preços ou snapshots integrais. Âmbito nativo por função/empresa/departamento e visibilidade própria deRequisition.visibleTo. Quantidade pedida não equivale a recepção/saída;estornos conservam documento próprio,sem saldo ou consumo líquido inventado. Entrada pela ficha existente deEquipamentos e ligação ao processo de compra autorizado. Tipagem do actorweb explicitada no controlador,sem conceder funções adicionais.
|
||||
|
||||
Validação48testes/571verificações Equipamentos+WarehouseFulfilment;PostgreSQL4/99,base removida. Entrega interna nativa reutiliza operação única,sem duplicação;isolamento,restrições de compras,leitura sem mutação e páginas independentes16/16 cobertos. PHPStan,Pint,formataçãofrontend,TypeScript,compilação,diff e18hashes aprovados. HTTP apenas equipamentos existentes,sem comandos de negócio;sem migração,grants,contas,decisões ou alteraçãoSGF. Provas equipment-integration[-pg/-http].json e docs/equipamentos-acompanhamento.md. Revisão visual,capacidade,projecto/fraccionamento e restantes módulos mantêm critérios próprios.
|
||||
|
||||
## 03/11/2026 — C2: cancelamento de ordens implementado,sem activação
|
||||
|
||||
Autorização específica do proprietário aplicada exclusivamente ao desenvolvimento do âmbito proposto. Política própria com publicação pelo motor reutilizado,duas validações administrativas independentes,Compras solicita eFinanças decide com exclusão do requisitante,autor da adjudicação,emissor e autor do pedido. Vigência,roles actuais,empresa,conflitos,base da ordem ecompromisso revalidados. Pendência suspende execução mesmo seflag for desligado. Recepção,facturação,reemissão e fontes deimportação/entrega integradas;selecções operacionais excluem ordens suspensas/canceladas. Tarefas depolítica,decisão e novo rascunho comligações focadas e autoridade nativa,sem novosgrants.
|
||||
|
||||
Original emitido,snapshot,linhas eaprovações conservados;estadooperacional derivado,requisição marcada cancelada apenas naaprovação. Capacidade financeira aditiva,pinSHA próprio,no livro canónico,sem alterar os18artefactos instalados:reversão única do compromisso original integralmente disponível,sem recalcular moeda/IVA,sem bancos,reembolsos ou apagamentos. SóMZN. Rejeição preserva compromisso;rollback conjunto verificado. Requisitante original prepara umrascunho ligado sem copiar decisões,propostas,validadores ou reserva,repetição semduplicação.
|
||||
|
||||
Regressão159testes/2014verificações dospercursos afectados;fecho dirigido43/284 após ligações finais. Baterias sobrepostas,não somar. PostgreSQL descartável12/91 e3ensaios concorrentes com espera real no bloqueio daempresa:pedido/recepção nasduasordens e duplaaprovação com um só lançamento. Bases removidas. PHPStan0erros,Pint,formataçãofrontend,TypeScript,compilação,diff,18hashes originais ehash novo aprovados. Semrevisão visual completa,carga oulibertação comercial.
|
||||
|
||||
Piloto inspeccionado sóemleitura:estrutura000003 ausente,zero políticas/cancelamentos,camposMFA vazios,zero perfisAuditMetadata eacessos27. HTTPAdmin/Compras/Finanças:circuito503desligado,ordens consultáveis,sem comandos de negócio. MFA continua desligado;Auditoria6/8 continua pendente. Semcontas,palavras-passe,grants,aprovações,migração oucancelamento nopiloto;SGF não alterado. Provas issued-order-cancellation[-pg/-http/-pilot/-authorization].json;docs/cancelamento-ordens-emitidas.md.
|
||||
|
||||
Preparada instalação APENAS de3tabelas vazias,semactivar:ordem de migração exacta000003,hash eproposta issued-order-schema-proposal.json;ensaio emrestauro114tabelas,113tabelas protegidas intactas,newtablesvazias,um só registo de migração,base removida. Backup/hashes em issued-order-schema-preparation.json. PENDENTE DE AUTORIZAÇÃO PARA APLICAÇÃO NO PILOTO;docs/proposta-instalacao-cancelamento-ordens.md. Não aplicar migração geral,publicar política,conceder acessos ou cancelar por inferência desta entrega.
|
||||
|
||||
## 03/11/2026 — G1: divisão administrativa comum preparada e integrada
|
||||
|
||||
Fonte cartografia_moz.xlsx fornecida pelo proprietário, identificada por hash, apenas lida. Catálogo derivado reproduzível conserva as 20.407 linhas, oito colunas originais e zeros iniciais. Origem INE conforme indicação do proprietário; edição não confirmada. Há 9.678 referências seleccionáveis e 10.729 pendentes: 48 grupos de códigos repetidos, 9 nomes vazios, 8.610 linhas sem pai imediato, 377 com pai ambíguo e 1.688 com ancestral inconsistente (diagnósticos sobrepostos). Não fundir, apagar, inventar nomes ou mapear por semelhança. As 8.585 ausências da análise inicial eram percursos distintos, não linhas.
|
||||
|
||||
Serviço comum, API autenticada e selector progressivo paginado integrados na sede da empresa e criação/edição de fornecedores. Descrição na ficha e filtro territorial com descendentes confirmados, sempre no âmbito da empresa. Campo opcional, sem substituir morada ou pressupor país para fichas sem classificação. Manutenção, administrador actual, versão nativa do fornecedor, revisão independente por Finanças, auditoria e rollback preservados. Clientes não têm cadastro autónomo neste SGC; não criar facturação/SGF. Outros cadastros e disponibilidades em texto continuam preservados, sem conversão automática.
|
||||
|
||||
Fecho: 58 testes/591 verificações integradas e PostgreSQL descartável 12/83, com bases removidas. Baterias sobrepostas, não somar. PHPStan zero erros, TypeScript, lint/formatação frontend, Pint e compilação aprovados. Fonte original intacta e reprodução determinística conferidas. Sem revisão visual completa ou aceitação comercial.
|
||||
|
||||
Piloto inspeccionado em transacção só de leitura: ambas as colunas ausentes, migração000004 não aplicada, opção geográfica desligada, acessos versão27 e zero inscrições MFA. Sem comandos de negócio, alterações de aprovação, contas, grants, ordens, políticas ou SGF. Provas administrative-geography[-source/-pg/-pilot].json. Instalação de duas colunas JSON anuláveis e activação só da selecção opcional dependem de autorização em docs/proposta-instalacao-geografia.md; não executar migração geral nem classificar fichas existentes. MFA desligado; propostas de cancelamento e Auditoria6/8 continuam pendentes.
|
||||
|
||||
## 03/11/2026 — G1 instalado/activo com autorização; G2 revisão cartográfica entregue
|
||||
|
||||
O proprietário autorizou instalar os dois campos vazios e activar a selecção geográfica no piloto, preservando moradas, aprovações e MFA desligado. Autorização literal e hashes em administrative-geography-installation-authorization.json. Aplicada apenas migração000004: backup consistente restaurado e114tabelas conferidas,111tabelas protegidas intactas, todas as colunas anteriores de empresas/fornecedores preservadas e referências novasNULL. Base de restauro removida. Activação apenas SGC_GEOGRAPHY_ENABLED=true na configuração privada8005, demais opções preservadas; por defeito a capacidade continua desligada noutros ambientes. Provas installation/activation/pilot/http separadas. Não repetir instalação nem pedir novamente autorização. Estado pendente da proposta/preparação original é histórico; ficheiros originais preservados por hash.
|
||||
|
||||
HTTP comAdmin/Compras/Finanças confirmou campos e catálogo disponíveis, fichas existentes sem classificação e segurança MFA desligada. Não foram submetidas localizações, aprovações ou decisões de negócio. Versão de acessos27. Propostas de cancelamento e Auditoria6/8 continuam pendentes; nenhuma política/grant/conta/ordem/SGF alterada.
|
||||
|
||||
G2 acrescenta consulta administrativa de10.729pendências,50por página, filtros por diagnóstico e designação/ID, códigos/linhas originais e CSV de todos os resultados filtrados. Administração actual revalidada; outros perfis recusados, respostas privadas sem cache. Projecção de fonte comum, sem dados de empresas/clientes/fornecedores no relatório. Não há comandos de correcção ou invenção de localidades. CSV neutraliza fórmulas apenas na representação exportada; importar códigos como texto nas folhas de cálculo. Catálogo original e hashes preservados.
|
||||
|
||||
Validação G2:17testes/150verificações e PostgreSQL descartável17/150, baterias sobrepostas (não somar), base removida. PHPStan zero erros, Pint, lint/formatação, TypeScript e compilação aprovados. HTTPAdmin conferiu pesquisa e exportação;Compras recusado. Prova administrative-geography-review.json. Sem revisão visual completa, certificação da edição ou resolução humana das inconsistências.
|
||||
|
||||
## 03/11/2026 — G3: geografia operacional em armazéns e equipamentos
|
||||
|
||||
Reutilizados catálogo/IDs/edição e selector comum na criação/edição dos dois cadastros, sem converter áreas, copiar localidades ou pressupor classificação. Armazém altera apenas referência geográfica por comparação com o valor anterior, sem auditoria duplicada quando igual; equipamento conserva comando e versão nativos. Utilizador/competência actuais revalidados após bloqueio da empresa, manutenção recusada e auditoria transaccional. Guard web explícito no Stock evita confundir contas de candidatos com operadores.
|
||||
|
||||
Filtros territoriais incluem descendentes confirmados e conservam âmbito de empresa/departamento. Em Stock, os armazéns seleccionados limitam movimentos e saldos derivados. Em Equipamentos, limitam activos, associações/peças, artigos associados e histórico. Ficha mostra localização actual, sem reescrever requisições, aprovações, validações ou snapshots de movimentos. Omitir conserva referência; retirar explicitamente remove só a classificação.
|
||||
|
||||
Validação integrada67testes/481verificações de OperationalGeography,EquipmentSpareParts eStockOperations; fecho14/149 com suspensão independente, revogação de competência, manutenção e falha de auditoria; PostgreSQL descartável14/149, base removida. Baterias sobrepostas, não somar. PHPStan zero erros, Pint, lint/formatação, TypeScript, compilação e diff aprovados. Fonte/hash intactos, sem revisão visual completa.
|
||||
|
||||
Nova opção SGC_OPERATIONAL_GEOGRAPHY_ENABLED desligada por defeito e no piloto. Suspensão não afecta geografia já activa da sede/fornecedores. Migração000005 acrescenta só warehouses/equipment.geography_reference anulável. Preparação em backup/restauro114tabelas,111protegidas e colunas anteriores dos dois cadastros intactas,novos camposNULL,cópia removida. Apenas cópia recebeu migração; piloto inalterado. HTTPAdmin/Armazém/Compras/Finanças confirmou stock/equipamentos consultáveis,novos controlos ocultos e catálogo anterior disponível. MFA desligado,acessos27;sem comandos de negócio,aprovações,grants,políticas,ordens ouSGF.
|
||||
|
||||
Provas operational-geography[-pg/-http/-pilot].json e operational-geography-schema-preparation/proposal.json; docs/geografia-operacional-sgc.md. Instalação dos dois campos vazios e activação apenas da opção operacional dependem de autorização específica em docs/proposta-instalacao-geografia-operacional.md. Não aplicar migração geral, reutilizar autorização de G1 para G3 ou classificar cadastros por inferência. Outras propostas pendentes continuam separadas.
|
||||
|
||||
## 03/11/2026 — G4: geografia na disponibilidade principal do candidato
|
||||
|
||||
Integração sem migração, reutilizando JSON versionado do perfil, catálogo e selector comum. Escolha explícita até à localidade completa, botão de aplicação antes de guardar e designações canónicas resolvidas no servidor. availability_reference fica separado do objecto de três campos availability, mantendo compatibilidade dos formulários publicados. Nomes constituem fotografia da fonte na versão do perfil, sem segundo preenchimento. Disponibilidades adicionais continuam manuais; limite e duplicados nativos conservados.
|
||||
|
||||
API específica de candidato exige conta da empresa e versão de sessão actual, sem converter conta interna em candidato; paginação50,limite60/minuto,respostas privadas sem cache,sem dados de pessoas. Texto livre existente não recebe referências por semelhança. Pedidos antigos conservam referência conhecida apenas com textos inalterados; mudança manual elimina ligação na nova versão e remoção explícita conserva o texto indicado. Versões anteriores intactas. Desligar catálogo mantém perfil manual e referências históricas. Preparação da candidatura reutiliza disponibilidade nativa; snapshot/repetição de submissão conservam a versão mesmo após nova edição do perfil. Não há decisões automáticas de triagem ou aprovação.
|
||||
|
||||
Validação51testes/679verificações de CandidateGeography,CandidatePortal,CandidateSubmissions eAdministrativeGeography;PostgreSQL32/496,base descartável removida,baterias sobrepostas (não somar). PHPStan zero erros,Pint,lint/formatação,TypeScript e compilação aprovados. Provas candidate-geography[-pg/-http/-pilot].json e docs/geografia-candidatos-sgc.md. Fonte/hash intactos;sem revisão visual completa.
|
||||
|
||||
Piloto inspeccionado só em leitura:zero portais configurados,contas e versões de perfil de candidatos;nenhuma inscrição ou identidade criada para demonstrar G4. HTTP recusa visitantes e sessões apenas internas;catálogo interno actual disponível após selecção do contexto nativo. Percursos positivos apenas nas bases descartáveis. Geografia da sede/fornecedores continua activa;G3 ainda sem colunas/activação e aguarda autorização específica. Acessos27,MFA desligado;sem migrações,perfis,aprovações,políticas,ordens ouSGF alterados. Não abrir portal/criar candidato ou instalar G3 por inferência desta continuação.
|
||||
|
||||
## 03/11/2026 — G5: cartografia nas disponibilidades adicionais
|
||||
|
||||
Estendido o selector e o resolvedor de G4 aos até 20 locais adicionais do candidato, sem migração. Objectos de localização mantêm os três campos antigos; referências ficam em lista separada e alinhada no JSON da versão, com null para entrada manual. Servidor valida fonte/ID/localidade completa e resolve nomes; limite, duplicados entre todos os locais, motivo, versão e sessão continuam nativos. Não criar localidades nem inferir códigos a partir de textos livres.
|
||||
|
||||
Remover uma linha remove a referência correspondente. Chaves estáveis de interface impedem que estado de selecção migre para outra linha após remoção; não são dados de negócio. Os pedidos antigos que omitem dados conservam-nos; ao reordenar locais sem referências, só são reutilizadas associações já conhecidas para os mesmos textos exactos. Texto modificado passa a manual; referências null e listas vazias permitem remoção explícita. Fonte desligada preserva histórico e pedidos antigos inalterados.
|
||||
|
||||
Formulários publicados conservam o formato de três campos, reutilizando os locais do perfil. Snapshot da candidatura guarda a versão com referências adicionais; retirada posterior no perfil não altera a submissão e repetição conserva o mesmo recibo. Não há triagem, aprovação ou selecção territorial automática. Mantida a recarga nativa após gravação, sem actualizar a versão de uma edição local por uma consulta concorrente.
|
||||
|
||||
Validação integrada: 46 testes/649 verificações de CandidateGeography, CandidatePortal e CandidateSubmissions; PostgreSQL descartável 39/549, base removida. Baterias sobrepostas, não somar. PHPStan zero erros, Pint, lint/formatação, TypeScript e compilação aprovados. Provas candidate-additional-geography[-pg/-http/-pilot].json; docs/geografia-disponibilidades-adicionais-sgc.md. Fonte/hash preservados; sem revisão visual completa.
|
||||
|
||||
Piloto consultado só em leitura: zero portais, contas e perfis de candidatos; sem criação de identidades ou abertura de inscrições para demonstrar G5. HTTP recusa visitantes e sessões apenas internas; catálogo de operadores preservado. Percursos positivos só descartáveis. Geografia da sede/fornecedores continua activa, G3 aguarda autorização e permanece sem estrutura/activação. Acessos versão27, MFA desligado; sem alterações de stock, aprovações, políticas, permissões, ordens ou SGF.
|
||||
|
||||
## 03/11/2026 — G6: consulta territorial de candidaturas recebidas
|
||||
|
||||
Filtro opcional na caixa de Pessoas por referências principal e adicionais do snapshot submetido, incluindo descendentes do território escolhido. Não consulta o perfil actual nem associa códigos a nomes manuais. Referências de outra edição e listas adicionais ausentes/antigas são tratadas sem remapear dados; retirar filtro conserva a caixa completa. SQL JSON parametrizado em SQLite/PostgreSQL; restantes motores mantêm caixa sem este filtro.
|
||||
|
||||
Autorização nativa CandidateTriage.canRead, empresa/processo, utilizador e competências actuais e vigência mantidos. Novo catálogo por processo no contexto Pessoas, sem exigir entrada em Compras ou conceder acesso ao catálogo operacional desse contexto. Resultados em ordem de recepção, vinte por página; paginação conserva filtros e foco submission. Ramo principal/adicionais agrupado dentro do âmbito de empresa/processo. Pendências de triagem continuam globais ao processo; nenhuma decisão, pontuação, selecção ou aprovação automática. Auditoria nativa de consulta conservada, sem exportação pessoal nova.
|
||||
|
||||
Validação integrada 45 testes/682 verificações de CandidateSubmissions e WorkContext; PostgreSQL descartável 8/135, base removida; baterias sobrepostas, não somar. Inclui principal/adicionais, nomes manuais iguais sem associação, perfil actual divergente, vinte+uma candidaturas/paginação, foco, isolamento, revogação, contexto, suspensão e outras edições. Fixtures respeitam chaves estrangeiras e não alteram submissões imutáveis existentes. PHPStan zero erros, Pint, lint/formatação, TypeScript, compilação e diff aprovados. Fonte/hash preservados; sem revisão visual completa.
|
||||
|
||||
Provas candidate-territory[-pg/-http/-pilot].json; docs/territorio-candidaturas-recebidas-sgc.md. HTTP do piloto confirmou Pessoas acessível, processo inexistente404 e catálogo de Compras403 no contexto Pessoas, sem caixas visíveis nessa conta. Percursos positivos de filtro só em bases descartáveis. Zero portais/contas/perfis de candidatos no piloto, acessos27, MFA desligado. Sem processos, migrações, classificações, permissões, triagem, aprovações, políticas, ordens ou SGF alterados. Geografia da sede/fornecedores continua activa; G3 permanece sem estrutura/activação e aguarda autorização própria.
|
||||
|
||||
## 03/11/2026 — G7: disponibilidade recebida na ficha e dossiê de Pessoas
|
||||
|
||||
ReceivedCandidateAvailability reutiliza a origem electrónica da triagem aceite por empresa/processo, acrescentando received_availability apenas à projecção de leitura PeopleProcessProjection. Página e dossiê partilham o campo e a redacção. Revalida utilizador actual e CandidateTriage.canRead; consultar ou avaliar o processo, por si só, não concede as fontes. Whitelist: referência de submissão, versão, disponibilidade principal/adicionais e respectivas referências; sem email, telefone, documentos, respostas ou conta de candidato.
|
||||
|
||||
Usa a fotografia da submissão e confere a versão registada na triagem. Não consulta perfil actual nem resolve nomes no catálogo novo. Texto manual mantém-se sem código inventado; desligar catálogo conserva o histórico. Candidatura interna manual sem origem e submissão recusada não recebem localização por inferência. Consulta em lote, sem novas gravações de cadastros ou triagem.
|
||||
|
||||
O campo NÃO integra PeopleHiring.matrix nem as linhas congeladas da selecção. Critérios, pontuações, contribuintes, selecção e aprovações preservados. Testes confirmaram equivalência página/dossiê, leitor sem fontes, revogação de âmbito e identidade actual, isolamento entre processos, referência principal/adicionais/manual, perfil posteriormente alterado, catálogo desligado, base de decisão intacta e tabelas de negócio/auditoria inalteradas pela consulta.
|
||||
|
||||
Validação integrada: 89 testes/2237 verificações de CandidateSubmissions e PeopleHiring; PostgreSQL descartável 7/97, base removida; baterias sobrepostas, não somar. PHPStan zero erros, Pint, lint/formatação, TypeScript, compilação e diff aprovados. Fonte/hash intactos. Provas received-availability[-pg/-http/-pilot].json e docs/disponibilidade-recebida-processo-sgc.md. Sem revisão visual completa ou aceitação empresarial.
|
||||
|
||||
Piloto consultado só em leitura, sem processos visíveis para comparar positivamente nessa conta e zero portais/contas/perfis de candidato; os casos positivos de disponibilidade electrónica ocorreram apenas em bases descartáveis. Acessos versão27, MFA desligado; sem migração, conta, perfil, aprovação, triagem, política, ordem ou SGF alterados. Geografia da sede/fornecedores continua activa e G3 permanece pendente da autorização própria, sem instalar estrutura ou activar a opção operacional por inferência.
|
||||
|
||||
|
||||
G8 — 03/11/2026: relatório de medidas de melhoria segmentado pelo território actual da ficha do fornecedor, partilhando SupplierGeography com o cadastro. Totais, paginação e CSV respeitam os mesmos filtros e acessos. Sem migração, alteração de snapshots, decisões, perfis ou aprovações. MFA desligado; G3 operacional continua pendente. Documentação: docs/territorio-melhorias-fornecedores-sgc.md; prova: docs/proofs/supplier-territory.json. Validação dirigida SQLite: 50 testes/700 verificações; sem nova execução PostgreSQL ou revisão visual completa.
|
||||
|
||||
|
||||
A2 — 03/11/2026: reprodução dedicada da requisição interna, reutilizando snapshot e WarehouseFulfilment.progress. Separação de pedido/reserva/entrega/saldo/cancelamento, histórico de autorização e recepção, ligações aos documentos individuais A1. Mesmos acessos da ficha, sem mutações ou factura. Sem migração; aprovações preservadas e MFA desligado. Docs/armazem-requisicao-impressao.md; provas warehouse-request-print.json e warehouse-request-print-pg.json. Não declara revisão visual ou aceitação comercial.
|
||||
|
||||
|
||||
A3 — 03/11/2026: CSV integral de acompanhamento das requisições internas, com filtros aplicados e âmbito partilhado com a lista. Uma linha por artigo/unidade; quantidades e estados reutilizam progress nativo. Paginação não limita a exportação; protecção de células de folha de cálculo. Sem novas aprovações, campos, migrações ou movimentos; MFA desligado. Docs/armazem-requisicoes-exportacao.md; provas warehouse-request-export.json e warehouse-request-export-pg.json. Sem teste de carga ou revisão visual completa.
|
||||
|
||||
|
||||
O2 — 03/11/2026: diagnóstico integrado de reposição e integridade documental pelo comando sgc:operations-check. Reutiliza verificadores nativos, consulta global só em consola sem identidade/sessão, saída agregada sem detalhes privados e continuação em falha parcial. Estado incompleto/indisponível nunca equivale a sucesso. Não instala serviços, recupera compras, corrige ficheiros ou grava telemetria. Sem migrações ou interface nova; aprovações preservadas e MFA desligado. Docs/operacao-diagnostico-integrado.md; provas operational-health.json e operational-health-pg.json. Instalação recorrente, restauro, capacidade e aceitação mantêm-se pendentes.
|
||||
|
||||
|
||||
E2 — 03/11/2026: ficha de equipamento reúne também requisições internas ao armazém ainda pendentes, por ID nas linhas preservadas, sem associação por texto. Projecção só das linhas do equipamento, estado/reserva/entrega/saldo nativos, ligações aos processos e paginação independente de15. Acesso ao equipamento não concede acesso ao armazém; empresa/departamento/capacidades mantidos. Sem novos registos, movimentos, migração ou aprovações; MFA desligado. Docs/equipamentos-requisicoes-internas.md; provas equipment-warehouse.json e equipment-warehouse-pg.json. SQLite57testes/737verificações e PostgreSQL4/107 aprovados, sem somar a provas históricas; não declara revisão visual/carga. G3 continua pendente.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# P1 — dossiê portátil de preparação de Pessoas
|
||||
|
||||
A consulta do processo oferece a descarga de um dossiê de preparação em JSON, com esquema `sgc.people.process-dossier.v1`, data da consulta, versão do processo e indicação `preparation_only`. Serve para reutilizar dados existentes na preparação interna e integrar ferramentas que leiam o esquema. Não existe importação, despacho, envio externo ou execução de comandos a partir do ficheiro.
|
||||
|
||||
Inclui os dados autorizados do processo, critérios, selecção, minuta quando consultável e matriz actual de candidaturas. A projecção é partilhada pela página e pela descarga, evitando regras de ocultação divergentes: acesso ao processo é obrigatório; dados financeiros do anexo, evidências de entrevistas e minuta conservam os seus âmbitos independentes. Acesso expirado ou de outra empresa é recusado pelos motores existentes. Não são exportados identificadores técnicos de criação ou a chave interna de empresa no cabeçalho do processo.
|
||||
|
||||
Os ficheiros anexos, conteúdos da auditoria integral e todo o cadastro de pessoas não são incorporados. Referências de origem electrónica/cadastro já visíveis na matriz podem acompanhar os dados,sem duplicar os documentos. O dossiê contém os dados pessoais que a conta pode consultar; o ficheiro descarregado conserva esses dados e não recebe revogação remota quando um acesso ao sistema expira.
|
||||
|
||||
A descarga usa uma transacção de leitura PostgreSQL e resposta privada sem cache, como a consulta. Não é um arquivo imutável ou assinado: data e matriz representam o momento da leitura. A selecção aprovada continua distinta de minuta e formalização. A fronteira PeopleContractHandoff continua a recusar minutas; não são criados contrato formalizado,admissão,empregado,aprovação ou registoRH.
|
||||
|
||||
Sem migração ou novas competências. Os testes abrangem serviço e trabalhador, aprovação/minuta nativas, repetição de consulta sem mutações, ocultação por âmbito e recusa por expiração/empresa. Revisão visual,volume elevado e formalização empresarial permanecem separados. Provas people-dossier.json e people-dossier-pg.json.
|
||||
|
||||
Fecho:56testes/1684verificações de Pessoas e cadastro aprovados;PostgreSQL descartável3testes/72verificações,base removida. PHPStan,Pint,formataçãofrontend,TypeScript,compilação,diff e18hashes aprovados. HTTP confirmou a consulta no piloto,sem processos visíveis para descarregar nessa conta;os dossiês positivos foram exercitados apenas nas bases descartáveis. Sem novas contas,grants,decisões ou dados de contratação no piloto.
|
||||
@@ -4,14 +4,16 @@
|
||||
|
||||
| Módulo | Base existente | Bloco a concluir | Critério de fecho |
|
||||
| --- | --- | --- | --- |
|
||||
| Recepção | Ordem emitida, recepção/qualidade, stock e histórico nativos | **R1 implementado e validado em bases descartáveis; instalação no piloto pendente** — evidências privadas ligadas à recepção, ordem e requisição | 36 testes/271 verificações de recepção e stock; PostgreSQL 5/78. Prova receipt-block.json; restantes funções de Recepção mantêm âmbito próprio |
|
||||
| Compras | Requisição, aprovações, propostas, avaliação, adjudicação e ordens | Multimoeda e revisão comercial de propostas; cancelamento de ordens conforme âmbito a validar | Regras e efeitos financeiros definidos, origem/versionamento preservados e percurso fechado; não inventar câmbios ou novas alçadas |
|
||||
| Armazém e reposição | Requisições internas, reserva, entrega, mínimos, recuperação e qualidade | Conversões gerais e fecho operacional das devoluções preparadas | Unidade/origem inequívocas e escritor único; activação/configuração separadas |
|
||||
| Cadastros e território | Fonte cartográfica fornecida pelo proprietário | **G1 instalado/activo; G2 revisão/CSV entregue; G3 armazéns/equipamentos pendente de instalação; G4/G5 disponibilidades, G6 consulta territorial e G7 disponibilidade recebida na ficha/dossiê integrados sem migração** | Backup/restauro114tabelas,111protegidas e colunas anteriores intactas. Referências ainda vazias; MFA desligado. Diagnósticos apenas em leitura, dados originais preservados; provas administrative-geography-installation/review.json |
|
||||
| Recepção | Ordem emitida, recepção/qualidade, stock e histórico nativos | **R1 instalado com autorização; R2 implementado e validado: reprodução imprimível da recepção** | R1: receipt-block-installation.json. R2: receipt-print.json, bateria integrada 39/295 e PostgreSQL 3/24; confirmação original, qualidade, estornos e referências documentais, sem novos movimentos ou decisões |
|
||||
| Compras | Requisição, aprovações, propostas, avaliação, adjudicação e ordens | **C2 entregue: cancelamento integral de ordens emitidas,desligado no piloto**;multimoeda e revisão comercial continuam pendentes | Política própria,Finanças independente,reversão única e original preservado;prova issued-order-cancellation.json. Não inventar câmbios ou novas alçadas |
|
||||
| Armazém e reposição | Requisições internas, reserva, entrega, mínimos, recuperação e qualidade | **A1 entregue: documento individual por entrega interna**, reutilizando requisição, autorização e operação de stock; depois conversões gerais e fecho operacional das devoluções preparadas | 20 testes/363 verificações de requisições e entregas; prova warehouse-delivery-print.json. Conversões e activação mantêm critérios próprios |
|
||||
| Equipamentos e peças | Cadastro, associações, validação técnica e saídas ligadas | **E1/E2 entregues: ficha de compras, requisições internas pendentes e movimentos por equipamento** | 48testes/571verificações integradas,PostgreSQL4/99;prova equipment-integration.json. Âmbito nativo,operação única da entrega interna;projecto/fraccionamento conservam âmbito próprio |
|
||||
| Financeiro | Núcleo comum, orçamento, facturas, pagamentos e entregas sintéticas | Consolidar cobertura efectiva da composição e dos percursos financeiros pendentes | Reconciliar funcionalidades activas/QA, sem duplicar motores ou instalar pacotes por inferência; preservar trabalho paralelo |
|
||||
| Pessoas | Cadastro, candidaturas, workflow e relatórios | Conferência de cobertura do percurso completo e passagem contratual | Dados reutilizados entre etapas e responsabilidades próprias, sem misturar permissões de Compras |
|
||||
| Segurança | Adesão MFA voluntária implementada e desligada no piloto | Preparação de activação/recuperação e política empresarial | Operação concreta, salvaguarda e autorização antes de migração/activação; não inscrever contas automaticamente |
|
||||
| Auditoria e documentos | Histórico por processo, metadados/CSV e paginação | Cobertura documental e retenção/exportação gerais conforme requisitos | Regras explícitas de acesso e preservação; não confundir CSV de metadados com auditoria integral |
|
||||
| Operação comercial | Recuperação, exclusão local, heartbeat e healthcheck | Instalação do serviço, reinício, backup/restauro, capacidade e revisão visual | Ensaios de implantação/recuperação e aceitação próprios, com autorização para operações no destino |
|
||||
| Pessoas | Cadastro, candidaturas, workflow e relatórios | **P1 entregue: dossiê portátil de preparação com a mesma projecção autorizada da consulta**; formalização contratual mantém âmbito próprio | 56testes/1684verificações integradas,PostgreSQL3/72. Percursos serviço/trabalhador, selecção e minuta preservadas; sem admissão ou envioRH. Prova people-dossier.json |
|
||||
| Segurança | Adesão MFA voluntária implementada e desligada no piloto | **S1: estrutura dos três campos vazios instalada com autorização**; capacidade continua desligada | mfa-pilot-schema-installation.json:114tabelas restauradas/conferidas,12contas preservadas. Activação posterior exige acesso/recuperação e autorização próprios |
|
||||
| Auditoria e documentos | Histórico por processo, metadados/CSV e paginação | **D1 entregue: consulta/CSV de metadados dos módulos com competência própria**; proposta de acesso no piloto pendente | 41testes/410verificações integradas,PostgreSQL7/85. Prova audit-metadata.json;retenção/exportação integral mantêm âmbito próprio |
|
||||
| Operação comercial | Recuperação, exclusão local, heartbeat e healthcheck | **O1 entregue: integridade documental e ficheiros sem referência**; instalação do serviço, reinício, backup/restauro, capacidade e revisão visual continuam pendentes | 14 testes/135 verificações integradas e PostgreSQL6/33; consulta sem mutações e incompletude explícita. Prova receipt-document-health.json; implantação/recuperação e aceitação exigem ensaios próprios |
|
||||
|
||||
## Execução de R1
|
||||
|
||||
@@ -20,3 +22,18 @@ Reutilizar a recepção confirmada como origem, sem pedir novamente fornecedor,
|
||||
Construir em conjunto armazenamento privado, metadados, repetição/reutilização, descarga, interface no detalhe e referências de navegação. Testes dirigidos de ficheiros, acesso, repetição e rollback acompanham a construção. No fecho, executar uma bateria integrada de recepção documental e recepção/stock afectados; actualizar uma prova e a matriz. Sem baterias gerais ou reverificação de todos os outros módulos entre componentes.
|
||||
|
||||
Substituições de artigos, reabertura de qualidade e cancelamento/estorno financeiro têm regras próprias e não são introduzidos implicitamente por anexar um documento. O fecho de R1 não declara todo o módulo Recepção, instalação ou produto comercial completos. Manter os percursos já aprovados e as autorizações anteriores.
|
||||
|
||||
|
||||
G8 — 03/11/2026: relatório de medidas de melhoria segmentado pelo território actual da ficha do fornecedor, partilhando SupplierGeography com o cadastro. Totais, paginação e CSV respeitam os mesmos filtros e acessos. Sem migração, alteração de snapshots, decisões, perfis ou aprovações. MFA desligado; G3 operacional continua pendente. Documentação: docs/territorio-melhorias-fornecedores-sgc.md; prova: docs/proofs/supplier-territory.json. Validação dirigida SQLite: 50 testes/700 verificações; sem nova execução PostgreSQL ou revisão visual completa.
|
||||
|
||||
|
||||
A2 — 03/11/2026: reprodução dedicada da requisição interna, reutilizando snapshot e WarehouseFulfilment.progress. Separação de pedido/reserva/entrega/saldo/cancelamento, histórico de autorização e recepção, ligações aos documentos individuais A1. Mesmos acessos da ficha, sem mutações ou factura. Sem migração; aprovações preservadas e MFA desligado. Docs/armazem-requisicao-impressao.md; provas warehouse-request-print.json e warehouse-request-print-pg.json. Não declara revisão visual ou aceitação comercial.
|
||||
|
||||
|
||||
A3 — 03/11/2026: CSV integral de acompanhamento das requisições internas, com filtros aplicados e âmbito partilhado com a lista. Uma linha por artigo/unidade; quantidades e estados reutilizam progress nativo. Paginação não limita a exportação; protecção de células de folha de cálculo. Sem novas aprovações, campos, migrações ou movimentos; MFA desligado. Docs/armazem-requisicoes-exportacao.md; provas warehouse-request-export.json e warehouse-request-export-pg.json. Sem teste de carga ou revisão visual completa.
|
||||
|
||||
|
||||
O2 — 03/11/2026: diagnóstico integrado de reposição e integridade documental pelo comando sgc:operations-check. Reutiliza verificadores nativos, consulta global só em consola sem identidade/sessão, saída agregada sem detalhes privados e continuação em falha parcial. Estado incompleto/indisponível nunca equivale a sucesso. Não instala serviços, recupera compras, corrige ficheiros ou grava telemetria. Sem migrações ou interface nova; aprovações preservadas e MFA desligado. Docs/operacao-diagnostico-integrado.md; provas operational-health.json e operational-health-pg.json. Instalação recorrente, restauro, capacidade e aceitação mantêm-se pendentes.
|
||||
|
||||
|
||||
E2 — 03/11/2026: ficha de equipamento reúne também requisições internas ao armazém ainda pendentes, por ID nas linhas preservadas, sem associação por texto. Projecção só das linhas do equipamento, estado/reserva/entrega/saldo nativos, ligações aos processos e paginação independente de15. Acesso ao equipamento não concede acesso ao armazém; empresa/departamento/capacidades mantidos. Sem novos registos, movimentos, migração ou aprovações; MFA desligado. Docs/equipamentos-requisicoes-internas.md; provas equipment-warehouse.json e equipment-warehouse-pg.json. SQLite57testes/737verificações e PostgreSQL4/107 aprovados, sem somar a provas históricas; não declara revisão visual/carga. G3 continua pendente.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"checked_at": "2026-11-03T15:20:24+00:00",
|
||||
"result": "passed",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"authorization": "administrative-geography-installation-authorization.json",
|
||||
"installation": "administrative-geography-installation.json",
|
||||
"changed_setting": "SGC_GEOGRAPHY_ENABLED",
|
||||
"selection_enabled": true,
|
||||
"other_settings_preserved": true,
|
||||
"settings_backup": "C:/laragon/data/sgf-tempo/backups/compras-geography-installation-20261103-151927-bc37f0/settings-before.json",
|
||||
"mfa_enabled": false,
|
||||
"business_commands": false,
|
||||
"automatic_classification": false
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"authorized_at": "2026-11-03T15:19:19.982258+00:00",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"scope": "install_two_nullable_geography_columns_and_enable_selection",
|
||||
"user_instruction": "Autorizo instalar os dois campos vazios e activar a selecção geográfica no piloto, sem alterar moradas ou aprovações existentes e mantendo o MFA desligado. Depois continua a desenvolver e integrar.",
|
||||
"proposal": "docs/proposta-instalacao-geografia.md",
|
||||
"proposal_sha256": "652c0c5b9cf995ef230df93e8cd2d9cd9d9287d1ae88bc9a88594f411906dfdf",
|
||||
"migration_sha256": "cd5ef20b862165f13ceac0542c2f69b9d701f0fbe0af278e5ce3b01c2f60f069",
|
||||
"automatic_entity_classification": false,
|
||||
"mfa_activation": false,
|
||||
"approval_changes": false,
|
||||
"cancel_orders": false,
|
||||
"new_grants": false
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"checked_at": "2026-11-03T15:21:27.037Z",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"result": "passed",
|
||||
"records": [
|
||||
{
|
||||
"role": "admin",
|
||||
"geographicSelectionAvailable": true,
|
||||
"existingSupplierUnclassified": true
|
||||
},
|
||||
{
|
||||
"role": "procurement",
|
||||
"geographicSelectionAvailable": true,
|
||||
"existingSupplierUnclassified": true
|
||||
},
|
||||
{
|
||||
"role": "finance",
|
||||
"geographicSelectionAvailable": true,
|
||||
"existingSupplierUnclassified": true
|
||||
}
|
||||
],
|
||||
"business_commands": false,
|
||||
"classification_commands": false,
|
||||
"approval_changes": false,
|
||||
"mfa_activation": false,
|
||||
"visual_review": false
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"checked_at": "2026-11-03T15:25:28+00:00",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"result": "passed",
|
||||
"supplier_column_installed": true,
|
||||
"company_column_installed": true,
|
||||
"migration_applied": true,
|
||||
"mfa_accounts_enrolled": 0,
|
||||
"access_version": 27,
|
||||
"business_commands": false,
|
||||
"geography_flag": true,
|
||||
"classified_suppliers": 0,
|
||||
"classified_companies": 0
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"checked_at": "2026-11-03T15:19:40+00:00",
|
||||
"result": "passed",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"scope": "install_two_nullable_geography_columns_and_enable_selection",
|
||||
"migration": "database/migrations/2026_11_03_000004_add_administrative_geography_references.php",
|
||||
"migration_sha256": "cd5ef20b862165f13ceac0542c2f69b9d701f0fbe0af278e5ce3b01c2f60f069",
|
||||
"backup": "C:/laragon/data/sgf-tempo/backups/compras-geography-installation-20261103-151927-bc37f0/before.dump",
|
||||
"backup_sha256": "2c2ab95823a089c71cbe7e834fa7a5e29ae8bb9eb3ce0e0471dadc26308cb9f5",
|
||||
"restored_tables_checked": 114,
|
||||
"protected_tables_checked": 111,
|
||||
"existing_company_and_supplier_columns_preserved": true,
|
||||
"new_references_all_null": true,
|
||||
"pilot_migration_applied": true,
|
||||
"feature_activated": false,
|
||||
"mfa_enabled": false,
|
||||
"passwords_or_grants_changed": false,
|
||||
"restore_database_removed": true
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"checked_at": "2026-11-03T15:12:12+00:00",
|
||||
"database": "disposable PostgreSQL",
|
||||
"result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":12,\"passed\":12,\"assertions\":83,\"duration_ms\":49729}",
|
||||
"races": [],
|
||||
"persistent_data_changed": false,
|
||||
"database_removed_in_finally": true
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"checked_at": "2026-11-03T15:09:20+00:00",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"result": "passed",
|
||||
"supplier_column_installed": false,
|
||||
"company_column_installed": false,
|
||||
"migration_applied": false,
|
||||
"mfa_accounts_enrolled": 0,
|
||||
"access_version": 27,
|
||||
"business_commands": false,
|
||||
"geography_flag": false
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"checked_at": "2026-11-03T15:25:27.117Z",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"result": "passed",
|
||||
"records": [
|
||||
{
|
||||
"role": "admin",
|
||||
"pendingRecords": 10729,
|
||||
"firstPage": 50,
|
||||
"blankDesignations": 9,
|
||||
"exportStatus": 200
|
||||
},
|
||||
{
|
||||
"role": "procurement",
|
||||
"reviewStatus": 403
|
||||
}
|
||||
],
|
||||
"business_commands": false,
|
||||
"classification_commands": false,
|
||||
"approval_changes": false,
|
||||
"mfa_activation": false,
|
||||
"visual_review": false
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"checked_at": "2026-11-03T15:24:29+00:00",
|
||||
"database": "disposable PostgreSQL",
|
||||
"result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":17,\"passed\":17,\"assertions\":150,\"duration_ms\":17851}",
|
||||
"races": [],
|
||||
"persistent_data_changed": false,
|
||||
"database_removed_in_finally": true
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"checked_at": "2026-11-03T15:27:19.717801+00:00",
|
||||
"result": "passed",
|
||||
"block": "G2 revisão administrativa de inconsistências",
|
||||
"regression": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 17,
|
||||
"passed": 17,
|
||||
"assertions": 150,
|
||||
"duration_ms": 15154
|
||||
},
|
||||
"postgresql": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 17,
|
||||
"passed": 17,
|
||||
"assertions": 150,
|
||||
"duration_ms": 17851
|
||||
},
|
||||
"regression_and_pg_overlap": true,
|
||||
"postgresql_proof": "administrative-geography-review-pg.json",
|
||||
"phpstan": {
|
||||
"tool": "phpstan",
|
||||
"result": "passed",
|
||||
"errors": 0
|
||||
},
|
||||
"pint": {
|
||||
"tool": "pint",
|
||||
"result": "passed"
|
||||
},
|
||||
"typescript": "passed",
|
||||
"frontend_lint": "passed",
|
||||
"production_build": "passed",
|
||||
"http_proof": "administrative-geography-review-http.json",
|
||||
"source_catalogue_hash_unchanged": true,
|
||||
"review_only_no_corrections": true,
|
||||
"csv_all_filtered_results": true,
|
||||
"csv_formula_neutralization_tested": true,
|
||||
"pilot_geography_installed_and_enabled": true,
|
||||
"pilot_references_all_null": true,
|
||||
"installation_proof": "administrative-geography-installation.json",
|
||||
"activation_proof": "administrative-geography-activation.json",
|
||||
"business_commands": false,
|
||||
"permission_changes": false,
|
||||
"approval_changes": false,
|
||||
"mfa_enabled": false,
|
||||
"sgf_changed": false,
|
||||
"visual_review_complete": false
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"result": "passed",
|
||||
"source_sha256": "70996d9a9133785a848de90bedb4bf685e24d8f823cd153c0adc43eac642a292",
|
||||
"catalogue_sha256": "28530f11f528e6f715396991e1294872c7e6a8b260d08a84e7d8fe17e5920457",
|
||||
"source_unchanged": true,
|
||||
"deterministic_reproduction": true,
|
||||
"original_workbook_written": false,
|
||||
"pilot_database_written": false,
|
||||
"manifest": {
|
||||
"schema_version": 1,
|
||||
"country": "MZ",
|
||||
"source_file": "cartografia_moz.xlsx",
|
||||
"source_sha256": "70996d9a9133785a848de90bedb4bf685e24d8f823cd153c0adc43eac642a292",
|
||||
"source_sheet": "Sheet1",
|
||||
"edition": null,
|
||||
"authority": "INE, conforme indicação do proprietário; edição não verificada",
|
||||
"columns": [
|
||||
"ID",
|
||||
"Provincia",
|
||||
"Distrito",
|
||||
"Posto_Administrativo",
|
||||
"Localidade",
|
||||
"Bairro_Urbano",
|
||||
"Aldeia_Quarteirao",
|
||||
"Designacao"
|
||||
],
|
||||
"records": 20407,
|
||||
"selectable": 9678,
|
||||
"levels": {
|
||||
"1": 11,
|
||||
"2": 148,
|
||||
"3": 437,
|
||||
"4": 1334,
|
||||
"5": 1870,
|
||||
"6": 16607
|
||||
},
|
||||
"issues": {
|
||||
"missing_parent": 8610,
|
||||
"ancestor_requires_review": 1688,
|
||||
"duplicate_code_path": 96,
|
||||
"blank_designation": 9,
|
||||
"ambiguous_parent": 377
|
||||
},
|
||||
"duplicate_code_groups": 48
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"checked_at": "2026-11-03T15:15:17.158145+00:00",
|
||||
"block": "G1 divisão administrativa comum",
|
||||
"result": "passed",
|
||||
"source_proof": "administrative-geography-source.json",
|
||||
"catalogue": {
|
||||
"schema_version": 1,
|
||||
"country": "MZ",
|
||||
"source_file": "cartografia_moz.xlsx",
|
||||
"source_sha256": "70996d9a9133785a848de90bedb4bf685e24d8f823cd153c0adc43eac642a292",
|
||||
"source_sheet": "Sheet1",
|
||||
"edition": null,
|
||||
"authority": "INE, conforme indicação do proprietário; edição não verificada",
|
||||
"columns": [
|
||||
"ID",
|
||||
"Provincia",
|
||||
"Distrito",
|
||||
"Posto_Administrativo",
|
||||
"Localidade",
|
||||
"Bairro_Urbano",
|
||||
"Aldeia_Quarteirao",
|
||||
"Designacao"
|
||||
],
|
||||
"records": 20407,
|
||||
"selectable": 9678,
|
||||
"levels": {
|
||||
"1": 11,
|
||||
"2": 148,
|
||||
"3": 437,
|
||||
"4": 1334,
|
||||
"5": 1870,
|
||||
"6": 16607
|
||||
},
|
||||
"issues": {
|
||||
"missing_parent": 8610,
|
||||
"ancestor_requires_review": 1688,
|
||||
"duplicate_code_path": 96,
|
||||
"blank_designation": 9,
|
||||
"ambiguous_parent": 377
|
||||
},
|
||||
"duplicate_code_groups": 48
|
||||
},
|
||||
"regression": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 58,
|
||||
"passed": 58,
|
||||
"assertions": 591,
|
||||
"duration_ms": 85067
|
||||
},
|
||||
"postgresql": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 12,
|
||||
"passed": 12,
|
||||
"assertions": 83,
|
||||
"duration_ms": 49729
|
||||
},
|
||||
"regression_and_pg_overlap": true,
|
||||
"postgresql_proof": "administrative-geography-pg.json",
|
||||
"phpstan": {
|
||||
"tool": "phpstan",
|
||||
"result": "passed",
|
||||
"errors": 0
|
||||
},
|
||||
"pint": {
|
||||
"tool": "pint",
|
||||
"result": "passed"
|
||||
},
|
||||
"typescript": "passed",
|
||||
"frontend_lint": "passed",
|
||||
"production_build": "passed",
|
||||
"visual_review_complete": false,
|
||||
"pilot_read_only_proof": "administrative-geography-pilot.json",
|
||||
"pilot_schema_installed": false,
|
||||
"pilot_feature_enabled": false,
|
||||
"existing_addresses_rewritten": false,
|
||||
"automatic_entity_classification": false,
|
||||
"permissions_changed": false,
|
||||
"approval_decisions_changed": false,
|
||||
"mfa_enabled": false,
|
||||
"sgf_changed": false,
|
||||
"installation_authorization": "pending",
|
||||
"migration": "database/migrations/2026_11_03_000004_add_administrative_geography_references.php",
|
||||
"migration_sha256": "cd5ef20b862165f13ceac0542c2f69b9d701f0fbe0af278e5ce3b01c2f60f069"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"checked_at": "2026-11-03T11:02:05+00:00",
|
||||
"result": "passed",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"tenant_id": 1,
|
||||
"expected_access_version": 27,
|
||||
"proposed_access_version": 30,
|
||||
"actors": [
|
||||
6,
|
||||
8
|
||||
],
|
||||
"new_profile": {
|
||||
"name": "Auditoria de metadados \u2014 piloto sint\u00e9tico",
|
||||
"permissions": [
|
||||
"audit_metadata_read"
|
||||
],
|
||||
"work_contexts": [
|
||||
"procurement.internal"
|
||||
]
|
||||
},
|
||||
"existing_profile_ids": {
|
||||
"6": [
|
||||
7
|
||||
],
|
||||
"8": [
|
||||
7
|
||||
]
|
||||
},
|
||||
"existing_permissions_preserved": true,
|
||||
"backup": "C:/laragon/data/sgf-tempo/backups/compras-audit-access-preparation-20261103-110158-183e4b/before.dump",
|
||||
"backup_sha256": "c10545b140bf946d26519f9bcb948824f4b06b034bb3cdda062611229e121783",
|
||||
"restored_tables_checked": 114,
|
||||
"protected_tables_checked": 110,
|
||||
"pilot_grants_changed": false,
|
||||
"pilot_mfa_enabled": false,
|
||||
"restore_database_removed": true
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"created_at": "2026-11-03T11:04:17.3589240Z",
|
||||
"authorization_status": "pending",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"tenant_id": 1,
|
||||
"expected_access_version": 27,
|
||||
"proposed_access_version": 30,
|
||||
"actors": [
|
||||
6,
|
||||
8
|
||||
],
|
||||
"new_profile": {
|
||||
"name": "Auditoria de metadados — piloto sintético",
|
||||
"permissions": [
|
||||
"audit_metadata_read"
|
||||
],
|
||||
"work_contexts": [
|
||||
"procurement.internal"
|
||||
]
|
||||
},
|
||||
"existing_profile_ids": {
|
||||
"6": [
|
||||
7
|
||||
],
|
||||
"8": [
|
||||
7
|
||||
]
|
||||
},
|
||||
"preparation_sha256": "a0bbb95ec73a759019297c79cca4f66337626665200759d4fab8bf25cf9f79c8",
|
||||
"documentation": "docs/proposta-acesso-auditoria-piloto.md",
|
||||
"new_accounts": false,
|
||||
"password_changes": false,
|
||||
"existing_permissions_removed": false,
|
||||
"approval_changes": false,
|
||||
"mfa_activation": false,
|
||||
"sgf_changes": false
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"checked_at": "2026-11-03T11:03:35.747Z",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"result": "passed",
|
||||
"records": [
|
||||
{
|
||||
"role": "admin",
|
||||
"path": "/auditoria/eventos",
|
||||
"status": 403
|
||||
},
|
||||
{
|
||||
"role": "admin",
|
||||
"path": "/auditoria/eventos.csv",
|
||||
"status": 403
|
||||
},
|
||||
{
|
||||
"role": "warehouse",
|
||||
"path": "/auditoria/eventos",
|
||||
"status": 403
|
||||
},
|
||||
{
|
||||
"role": "warehouse",
|
||||
"path": "/auditoria/eventos.csv",
|
||||
"status": 403
|
||||
}
|
||||
],
|
||||
"pilot_grants_changed": false,
|
||||
"business_commands": false,
|
||||
"visual_review": false,
|
||||
"coverage": "Denial before explicit permission; positive access exercised in disposable databases."
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"checked_at": "2026-11-03T11:00:38+00:00",
|
||||
"database": "disposable PostgreSQL",
|
||||
"result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":7,\"passed\":7,\"assertions\":85,\"duration_ms\":13938}",
|
||||
"races": [],
|
||||
"persistent_data_changed": false,
|
||||
"database_removed_in_finally": true
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"checked_at": "2026-11-03T11:04:17.3250947Z",
|
||||
"block": "D1 tenant audit metadata console",
|
||||
"regression": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 41,
|
||||
"passed": 41,
|
||||
"assertions": 410,
|
||||
"duration_ms": 31617
|
||||
},
|
||||
"postgres": {
|
||||
"checked_at": "2026-11-03T13:00:38+02:00",
|
||||
"database": "disposable PostgreSQL",
|
||||
"result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":7,\"passed\":7,\"assertions\":85,\"duration_ms\":13938}",
|
||||
"races": [],
|
||||
"persistent_data_changed": false,
|
||||
"database_removed_in_finally": true
|
||||
},
|
||||
"phpstan": {
|
||||
"tool": "phpstan",
|
||||
"result": "passed",
|
||||
"errors": 0
|
||||
},
|
||||
"http": {
|
||||
"checked_at": "2026-11-03T11:03:35.747Z",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"result": "passed",
|
||||
"records": [
|
||||
{
|
||||
"role": "admin",
|
||||
"path": "/auditoria/eventos",
|
||||
"status": 403
|
||||
},
|
||||
{
|
||||
"role": "admin",
|
||||
"path": "/auditoria/eventos.csv",
|
||||
"status": 403
|
||||
},
|
||||
{
|
||||
"role": "warehouse",
|
||||
"path": "/auditoria/eventos",
|
||||
"status": 403
|
||||
},
|
||||
{
|
||||
"role": "warehouse",
|
||||
"path": "/auditoria/eventos.csv",
|
||||
"status": 403
|
||||
}
|
||||
],
|
||||
"pilot_grants_changed": false,
|
||||
"business_commands": false,
|
||||
"visual_review": false,
|
||||
"coverage": "Denial before explicit permission; positive access exercised in disposable databases."
|
||||
},
|
||||
"typescript": "passed; audit-metadata-types.txt",
|
||||
"production_build": "passed; audit-metadata-build.txt",
|
||||
"formatting": "passed",
|
||||
"runtime_packages": 18,
|
||||
"pilot_grants_changed": false,
|
||||
"pilot_mfa_enabled": false,
|
||||
"private_payloads_exported": false,
|
||||
"full_audit_export": false,
|
||||
"visual_review": false
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"checked_at": "2026-11-03T17:09:20.132Z",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"result": "passed",
|
||||
"guest_status": 401,
|
||||
"staff_without_candidate_status": 401,
|
||||
"existing_staff_catalogue_status": 200,
|
||||
"registration_commands": false,
|
||||
"profile_commands": false,
|
||||
"portal_configuration_commands": false,
|
||||
"business_commands": false,
|
||||
"mfa_activation": false,
|
||||
"positive_candidate_session_coverage": "disposable tests only",
|
||||
"visual_review": false
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"checked_at": "2026-11-03T17:08:52+00:00",
|
||||
"database": "disposable PostgreSQL",
|
||||
"result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":39,\"passed\":39,\"assertions\":549,\"duration_ms\":140079}",
|
||||
"races": [],
|
||||
"persistent_data_changed": false,
|
||||
"database_removed_in_finally": true
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"checked_at": "2026-11-03T17:09:13+00:00",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"result": "passed",
|
||||
"candidate_accounts": 0,
|
||||
"candidate_profile_versions": 0,
|
||||
"candidate_portals_configured": 0,
|
||||
"warehouse_column_installed": false,
|
||||
"equipment_column_installed": false,
|
||||
"operational_flag": false,
|
||||
"operational_migration_applied": false,
|
||||
"supplier_column_installed": true,
|
||||
"company_column_installed": true,
|
||||
"migration_applied": true,
|
||||
"mfa_accounts_enrolled": 0,
|
||||
"access_version": 27,
|
||||
"business_commands": false,
|
||||
"geography_flag": true,
|
||||
"classified_suppliers": 0,
|
||||
"classified_companies": 0
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"checked_at": "2026-11-03T17:13:12.522132+00:00",
|
||||
"result": "passed",
|
||||
"block": "G5 disponibilidades adicionais reutilizam cartografia",
|
||||
"regression": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 46,
|
||||
"passed": 46,
|
||||
"assertions": 649,
|
||||
"duration_ms": 57902
|
||||
},
|
||||
"postgresql": {
|
||||
"tool": "pest",
|
||||
"result": "passed",
|
||||
"tests": 39,
|
||||
"passed": 39,
|
||||
"assertions": 549,
|
||||
"duration_ms": 140079
|
||||
},
|
||||
"batteries_overlap": true,
|
||||
"postgresql_proof": "candidate-additional-geography-pg.json",
|
||||
"phpstan": {
|
||||
"tool": "phpstan",
|
||||
"result": "passed",
|
||||
"errors": 0
|
||||
},
|
||||
"pint": {
|
||||
"tool": "pint",
|
||||
"result": "passed"
|
||||
},
|
||||
"typescript": "passed",
|
||||
"frontend_lint": "passed",
|
||||
"production_build": "passed",
|
||||
"http_proof": "candidate-additional-geography-http.json",
|
||||
"pilot_read_only_proof": "candidate-additional-geography-pilot.json",
|
||||
"source_catalogue_hash_unchanged": true,
|
||||
"migrations_required": false,
|
||||
"aligned_reference_lists_tested": true,
|
||||
"manual_and_catalogue_locations_tested": true,
|
||||
"legacy_reorder_removal_and_omission_tested": true,
|
||||
"duplicate_and_limit_checks_preserved": true,
|
||||
"submission_snapshot_and_replay_tested": true,
|
||||
"approved_form_definitions_changed": false,
|
||||
"pilot_candidate_accounts_created": false,
|
||||
"pilot_profiles_created_or_changed": false,
|
||||
"pilot_portal_configured_or_opened": false,
|
||||
"positive_candidate_flow_coverage": "disposable tests only",
|
||||
"operational_geography_installation_authorization": "still pending",
|
||||
"stock_or_approval_commands": false,
|
||||
"mfa_enabled": false,
|
||||
"sgf_changed": false,
|
||||
"visual_review_complete": false
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"checked_at": "2026-11-03T16:56:21.684Z",
|
||||
"environment": "http://127.0.0.1:8005",
|
||||
"result": "passed",
|
||||
"guest_status": 401,
|
||||
"staff_without_candidate_status": 401,
|
||||
"existing_staff_catalogue_status": 200,
|
||||
"registration_commands": false,
|
||||
"profile_commands": false,
|
||||
"portal_configuration_commands": false,
|
||||
"business_commands": false,
|
||||
"mfa_activation": false,
|
||||
"positive_candidate_session_coverage": "disposable tests only",
|
||||
"visual_review": false
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"checked_at": "2026-11-03T16:54:26+00:00",
|
||||
"database": "disposable PostgreSQL",
|
||||
"result": "{\"tool\":\"pest\",\"result\":\"passed\",\"tests\":32,\"passed\":32,\"assertions\":496,\"duration_ms\":103949}",
|
||||
"races": [],
|
||||
"persistent_data_changed": false,
|
||||
"database_removed_in_finally": true
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user