diff --git a/.gitattributes b/.gitattributes
index 4da48a8..781b28c 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,5 +1,9 @@
* text=auto eol=lf
+# Preserve the exact bytes of pinned shared runtime artifacts and their hashes.
+/packages/budget-control/** -text
+/packages/payables/** -text
+
*.blade.php diff=html
*.css diff=css
*.html diff=html
@@ -9,3 +13,6 @@
CHANGELOG.md export-ignore
README.md export-ignore
.github/workflows/browser-tests.yml export-ignore
+
+/packages/budget-control/** -export-ignore
+/packages/payables/** -export-ignore
diff --git a/README.md b/README.md
index 67b5e87..513f261 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,8 @@ Sistema de gestão de compras em Laravel 13, React, TypeScript e Inertia. Esta e
Este é o projecto activo em `C:\laragon\www\compras`. Preserve a conta existente. A implementação Python criada separadamente fica apenas como referência; consulte [o mapa de compatibilidade](docs/compatibilidade.md) antes de adaptar funcionalidades.
+O produto inclui os artefactos dos núcleos comuns em `packages/`, conferidos por SHA-256 com `composer run runtime:check`. A execução da aplicação já não exige a pasta vizinha do SGF. Actualizar esses artefactos a partir da origem comum, conforme `packages/README.md`. Esta distribuição não activa automaticamente capacidades financeiras nem altera dados/configuração de instalações existentes.
+
## Executar localmente
Durante o desenvolvimento, use `composer run dev` e abra **http://127.0.0.1:8005/avaliar**. Este é o ambiente persistente com os menus organizados e os processos em avaliação. Escolha **Entrar como proprietário** ou o perfil operacional pretendido. O arranque reutiliza a avaliação existente e inicia o Vite quando necessário, sem migrações, seeds ou alterações de contas.
@@ -12,7 +14,7 @@ Conferência dos pagamentos pendentes após importação: [entrega e provas](doc
### Explorar a aplicação sem palavra-passe
-Na demonstração local isolada, abrir **http://127.0.0.1:8005/avaliar** e escolher **Entrar como proprietário**. A página «Explorar Compras», acessível também pela ligação acima da aplicação, reúne os 20 módulos/páginas principais. Disponibiliza ainda os seis perfis operacionais sintéticos para experimentar os respectivos passos sem procurar credenciais. Os dados e alterações desta demonstração persistem entre visitas.
+Na demonstração local isolada, abrir **http://127.0.0.1:8005/avaliar** e escolher **Entrar como proprietário**. A página «Explorar Compras», acessível também pela ligação acima da aplicação, reúne os 21 módulos/páginas principais. Links protegidos, como `/relatorios`, encaminham para esta entrada quando a sessão expira e regressam ao destino após a escolha do perfil, sem pedir credenciais. Disponibiliza ainda os seis perfis operacionais sintéticos para experimentar os respectivos passos. Os dados e alterações desta demonstração persistem entre visitas.
A entrada pertence exclusivamente à base QA local existente de obrigações, com empresa e exemplos sintéticos; não altera contas/dados da instalação normal em 8015. As regras de aprovação independente e de integridade permanecem. O proprietário tem o perfil administrativo e as três acções do ensaio de obrigações; os passos reservados a Procurement, Finanças ou Armazém usam os perfis de avaliação disponíveis. Não se declara que todos os percursos já tenham exemplos completos nesta base.
@@ -37,6 +39,8 @@ Crie um pedido, guarde o rascunho, confira os valores e submeta. Entre com as co
## Entrega actual
+- **Relatórios de compras:** menu Relatórios → Relatórios de compras (`/relatorios`), com filtros, resumo, exportação CSV completa e ligações ao processo/relatório individual. O relatório imprimível inclui recepções e, para os perfis financeiros autorizados, facturas e registos de pagamentos. Estados preservados, sem cálculo de saldo por este relatório. Ver o inventário actualizado em `docs/menus-benchmark-20261029.md`.
+
- Passagem explícita de pagamentos históricos para a obrigação comum: principal integral, confirmados deduzidos uma vez, pendentes/rejeitados preservados e validação distinta. Migração 000022 aplicada e activação histórica concluída em `compras_dev`, após backup/restauro e preservação conferida; sem incorporação automática. A demonstração em 8005 mantém o seu cenário anterior. Não executa pagamentos nem confirma pendentes após a passagem. Ver [entrega, aplicação e provas](docs/passagem-historico-pagamentos.md).
- Regras de investimento próprias por empresa: tecto por processo, base de IVA
diff --git a/app/Enums/PurchasingRole.php b/app/Enums/PurchasingRole.php
index 0f4ace3..256b4ff 100644
--- a/app/Enums/PurchasingRole.php
+++ b/app/Enums/PurchasingRole.php
@@ -10,6 +10,7 @@ enum PurchasingRole: string
case Finance = 'finance';
case Warehouse = 'warehouse';
case Admin = 'admin';
+ case Configured = 'configured';
public function label(): string
{
@@ -20,6 +21,7 @@ enum PurchasingRole: string
self::Finance => 'Finanças',
self::Warehouse => 'Recepção / Armazém',
self::Admin => 'Administrador',
+ self::Configured => 'Perfil definido pela empresa',
};
}
}
diff --git a/app/Http/Controllers/BudgetController.php b/app/Http/Controllers/BudgetController.php
index e4b6d9a..9de7d34 100644
--- a/app/Http/Controllers/BudgetController.php
+++ b/app/Http/Controllers/BudgetController.php
@@ -21,7 +21,7 @@ class BudgetController extends Controller
{
private function access(Request $request): void
{
- abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance]), 403);
+ abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
}
public function index(Request $request): Response
diff --git a/app/Http/Controllers/BudgetReinforcementController.php b/app/Http/Controllers/BudgetReinforcementController.php
index cef6e39..7a07514 100644
--- a/app/Http/Controllers/BudgetReinforcementController.php
+++ b/app/Http/Controllers/BudgetReinforcementController.php
@@ -13,7 +13,7 @@ class BudgetReinforcementController extends Controller
{
public function store(Request $request, Budget $budget, BudgetReinforcementWorkflow $workflow): RedirectResponse
{
- abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance]), 403);
+ abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
$data = $request->validate(['reference' => 'required|string|max:100', 'amount' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'], 'reason' => 'required|string|max:4000']);
$workflow->propose($request->user(), $budget, $data);
diff --git a/app/Http/Controllers/CompanyController.php b/app/Http/Controllers/CompanyController.php
index 3d133c8..3feebeb 100644
--- a/app/Http/Controllers/CompanyController.php
+++ b/app/Http/Controllers/CompanyController.php
@@ -7,6 +7,8 @@ use App\Models\CostCenter;
use App\Models\Department;
use App\Models\Tenant;
use App\Models\User;
+use App\Services\OrganizationAccess;
+use App\Services\OrganizationConfiguration;
use App\Services\RequisitionWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -24,30 +26,39 @@ class CompanyController extends Controller
public function store(Request $request, RequisitionWorkflow $workflow): RedirectResponse
{
- $data = $request->validate(['name' => 'required|string|max:150', 'department' => 'required|string|max:100']);
+ abort_if($request->user()->tenant_id !== null, 403);
+ $data = $request->validate(['name' => 'required|string|max:150', 'department' => 'required|string|max:100',
+ 'second_name' => 'required|string|max:100', 'second_email' => 'required|email|max:255|unique:users,email', 'second_password' => 'required|string|min:12|confirmed',
+ 'first_limit' => ['required', 'string', 'regex:/^\d{1,12}(\.\d{1,2})?$/D'], 'second_limit' => ['required', 'string', 'regex:/^\d{1,12}(\.\d{1,2})?$/D'], 'includes_tax' => 'required|boolean']);
DB::transaction(function () use ($request, $data, $workflow) {
$user = User::query()->lockForUpdate()->findOrFail($request->user()->id);
abort_if($user->tenant_id !== null, 403);
- $tenant = Tenant::query()->create(['name' => $data['name']]);
+ $tenant = Tenant::query()->create(['name' => $data['name'], ...OrganizationConfiguration::rules($data)]);
$department = Department::query()->create(['tenant_id' => $tenant->id, 'name' => $data['department']]);
CostCenter::query()->create(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'code' => 'GERAL', 'name' => 'Centro de custo geral']);
- $user->forceFill(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Admin])->save();
- $workflow->audit($user, 'company.created', null, null, ['name' => $data['name']]);
+ $user->forceFill(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Configured])->save();
+ $second = new User;
+ $second->forceFill(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'name' => $data['second_name'], 'email' => $data['second_email'], 'password' => $data['second_password'], 'purchasing_role' => PurchasingRole::Configured])->save();
+ app(OrganizationConfiguration::class)->initialise($user, $second);
+ $workflow->audit($user, 'company.created', null, null, ['name' => $data['name'], 'initial_administrators' => [$user->id, $second->id], 'rules' => $tenant->only(['first_limit_minor', 'second_limit_minor', 'approval_includes_tax', 'rules_provisional'])]);
});
return to_route('dashboard');
}
- public function index(Request $request): Response
+ public function index(Request $request): Response|RedirectResponse
{
$this->admin($request);
+ if (app(OrganizationAccess::class)->active($request->user()->tenant_id)) {
+ return to_route('organization.parameters');
+ }
return Inertia::render('company/index', [
'company' => Tenant::query()->findOrFail($request->user()->tenant_id),
'departments' => Department::query()->orderBy('name')->get(),
'costCenters' => CostCenter::query()->orderBy('code')->get(),
'users' => User::query()->where('tenant_id', $request->user()->tenant_id)->orderBy('name')->get(['id', 'name', 'email', 'department_id', 'purchasing_role']),
- 'roles' => collect(PurchasingRole::cases())->map(fn (PurchasingRole $role) => ['value' => $role->value, 'label' => $role->label()]),
+ 'roles' => collect(PurchasingRole::cases())->reject(fn (PurchasingRole $role) => $role === PurchasingRole::Configured)->values()->map(fn (PurchasingRole $role) => ['value' => $role->value, 'label' => $role->label()]),
]);
}
@@ -82,6 +93,7 @@ class CompanyController extends Controller
public function user(Request $request, RequisitionWorkflow $workflow): RedirectResponse
{
$this->admin($request);
+ abort_if(app(OrganizationAccess::class)->active($request->user()->tenant_id), 409, 'Crie utilizadores e atribua perfis na parametrização.');
$data = $request->validate([
'name' => 'required|string|max:100', 'email' => 'required|email|max:255|unique:users',
'password' => 'required|string|min:12|confirmed',
@@ -99,6 +111,6 @@ class CompanyController extends Controller
private function admin(Request $request): void
{
- abort_unless($request->user()->purchasing_role === PurchasingRole::Admin, 403);
+ app(OrganizationConfiguration::class)->administrator($request->user());
}
}
diff --git a/app/Http/Controllers/DocumentaryQualificationController.php b/app/Http/Controllers/DocumentaryQualificationController.php
index e0d6bab..6a85d11 100644
--- a/app/Http/Controllers/DocumentaryQualificationController.php
+++ b/app/Http/Controllers/DocumentaryQualificationController.php
@@ -20,7 +20,7 @@ class DocumentaryQualificationController extends Controller
{
private function access(Request $request, int $id): Requisition
{
- abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
+ abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
return Requisition::query()->visibleTo($request->user())->findOrFail($id);
}
@@ -47,7 +47,7 @@ class DocumentaryQualificationController extends Controller
'proposals' => $proposals,
'closedForChanges' => $closed,
'records' => $records, 'history' => $query->latest('id')->paginate(20)->withQueryString(), 'actorId' => $request->user()->id,
- 'canDefine' => $open && $proposals !== [] && $request->user()->purchasing_role === PurchasingRole::Procurement,
+ 'canDefine' => $open && $proposals !== [] && $request->user()->hasFunctions([PurchasingRole::Procurement]),
'canAssess' => $open && $current && $request->user()->id !== $requisition->requester_id && app(ProposalComparison::class)->eligibleRole($request->user(), $requisition),
])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
diff --git a/app/Http/Controllers/EquipmentController.php b/app/Http/Controllers/EquipmentController.php
index 4fd0875..ef4e30d 100644
--- a/app/Http/Controllers/EquipmentController.php
+++ b/app/Http/Controllers/EquipmentController.php
@@ -28,15 +28,15 @@ class EquipmentController extends Controller
{
public function index(Request $request): Response
{
- abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
+ abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
$assets = Equipment::query();
- if ($request->user()->purchasing_role === PurchasingRole::DepartmentHead) {
+ if ($request->user()->departmentScoped()) {
$assets->where('department_id', $request->user()->department_id);
}
$equipment = $assets->orderBy('code')->get();
$links = EquipmentSparePart::query()->whereIn('equipment_id', $equipment->pluck('id'))->get();
$parts = SparePart::query();
- if ($request->user()->purchasing_role === PurchasingRole::DepartmentHead) {
+ if ($request->user()->departmentScoped()) {
$parts->whereIn('id', $links->pluck('spare_part_id'));
}
$parts = $parts->get()->map(function (SparePart $part) {
@@ -47,7 +47,7 @@ class EquipmentController extends Controller
});
$items = StockItem::query();
$departments = Department::query();
- if ($request->user()->purchasing_role === PurchasingRole::DepartmentHead) {
+ if ($request->user()->departmentScoped()) {
$items->whereIn('id', $parts->pluck('stock_item_id')->merge($equipment->pluck('stock_item_id')->filter()));
$departments->whereKey($request->user()->department_id);
}
@@ -57,8 +57,8 @@ class EquipmentController extends Controller
'items' => $items->orderBy('code')->get(['id', 'code', 'name', 'unit']),
'departments' => $departments->orderBy('name')->get(['id', 'name']),
'history' => StockOperation::query()->whereNotNull('equipment_id')->whereIn('equipment_id', $equipment->pluck('id'))->latest('id')->paginate(15),
- 'canManage' => in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement]),
- 'canConfigure' => $request->user()->purchasing_role === PurchasingRole::Admin,
+ 'canManage' => $request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement]),
+ 'canConfigure' => $request->user()->hasFunctions([PurchasingRole::Admin]),
]);
}
@@ -66,7 +66,7 @@ class EquipmentController extends Controller
{
$user = $request->user();
abort_unless(in_array($kind, ['categorias', 'activos', 'pecas', 'associacoes']), 404);
- abort_unless($kind === 'categorias' ? $user->purchasing_role === PurchasingRole::Admin : in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement]), 403);
+ abort_unless($kind === 'categorias' ? $user->hasFunctions([PurchasingRole::Admin]) : $user->hasFunctions([PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement]), 403);
$tenant = $user->tenant_id;
$exists = fn (string $table) => Rule::exists($table, 'id')->where('tenant_id', $tenant);
$request->merge(['code' => Str::upper(Str::squish($request->string('code')->toString()))]);
diff --git a/app/Http/Controllers/ExternalPaymentController.php b/app/Http/Controllers/ExternalPaymentController.php
index 6a3e4a8..fa0a9c4 100644
--- a/app/Http/Controllers/ExternalPaymentController.php
+++ b/app/Http/Controllers/ExternalPaymentController.php
@@ -19,8 +19,8 @@ class ExternalPaymentController extends Controller
{
public function index(Request $request, ExternalPaymentWorkflow $workflow): Response
{
- abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance]), 403);
- $canManage = $request->user()->purchasing_role === PurchasingRole::Finance;
+ abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
+ $canManage = $request->user()->hasFunctions([PurchasingRole::Finance]);
$payments = ExternalPayment::query()->latest('id')->paginate(15)->through(fn ($payment) => [...$payment->toArray(), 'external_reference' => $canManage ? $payment->external_reference : null, 'evidence_reference' => $canManage ? $payment->evidence_reference : null]);
if ($canManage && $payments->count() > 0) {
app(RequisitionWorkflow::class)->audit($request->user(), 'payment.viewed', null, null, ['payment_ids' => $payments->pluck('id')->all()]);
@@ -40,7 +40,7 @@ class ExternalPaymentController extends Controller
public function store(Request $request, Invoice $invoice, ExternalPaymentWorkflow $workflow): RedirectResponse
{
- abort_unless($request->user()->purchasing_role === PurchasingRole::Finance, 403);
+ abort_unless($request->user()->hasFunctions([PurchasingRole::Finance]), 403);
$data = $request->validate(['supplier_bank_detail_id' => 'required|integer|min:1', 'amount' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'], 'paid_on' => 'required|date_format:Y-m-d', 'external_reference' => 'required|string|max:150', 'evidence_reference' => 'required|string|max:1000']);
$workflow->register($request->user(), $invoice, $data);
diff --git a/app/Http/Controllers/FormalAdmissionController.php b/app/Http/Controllers/FormalAdmissionController.php
index bf6a038..fc316c0 100644
--- a/app/Http/Controllers/FormalAdmissionController.php
+++ b/app/Http/Controllers/FormalAdmissionController.php
@@ -21,14 +21,14 @@ class FormalAdmissionController extends Controller
{
private function access(Request $request, int $id): Requisition
{
- abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
+ abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
return Requisition::query()->visibleTo($request->user())->findOrFail($id);
}
public function policy(Request $request): Response
{
- abort_unless($request->user()->purchasing_role === PurchasingRole::Admin, 403);
+ abort_unless($request->user()->hasFunctions([PurchasingRole::Admin]), 403);
Inertia::encryptHistory();
$response = Inertia::render('purchases/admission-policy', ['policies' => AdmissionPolicy::query()->latest('revision')->paginate(20)->withQueryString(), 'latest' => AdmissionPolicy::query()->latest('revision')->first()])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
@@ -71,7 +71,7 @@ class FormalAdmissionController extends Controller
'canReview' => $open && $fresh && $workflow->status($decision, $selected) === 'pending' && $workflow->eligible($request->user(), $requisition, $selected, $decision) && ! AdmissionReview::query()->where('admission_decision_id', $decision->id)->where('reviewed_by', $request->user()->id)->exists()];
});
Inertia::encryptHistory();
- $response = Inertia::render('purchases/admission', ['requisition' => $requisition->only(['id', 'number']), 'policy' => AdmissionPolicy::query()->latest('revision')->first(), 'adoptions' => AdmissionAdoption::query()->where('requisition_id', $requisition->id)->latest('revision')->get(), 'adoption' => $adoption, 'selected' => $selected, 'proposals' => $proposals, 'history' => $history, 'canAdopt' => $open && $request->user()->purchasing_role === PurchasingRole::Procurement, 'canPropose' => $open && $current && $request->user()->purchasing_role === PurchasingRole::Procurement && $request->user()->id !== $requisition->requester_id, 'closed' => $closed])->toResponse($request);
+ $response = Inertia::render('purchases/admission', ['requisition' => $requisition->only(['id', 'number']), 'policy' => AdmissionPolicy::query()->latest('revision')->first(), 'adoptions' => AdmissionAdoption::query()->where('requisition_id', $requisition->id)->latest('revision')->get(), 'adoption' => $adoption, 'selected' => $selected, 'proposals' => $proposals, 'history' => $history, 'canAdopt' => $open && $request->user()->hasFunctions([PurchasingRole::Procurement]), 'canPropose' => $open && $current && $request->user()->hasFunctions([PurchasingRole::Procurement]) && $request->user()->id !== $requisition->requester_id, 'closed' => $closed])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
diff --git a/app/Http/Controllers/FormalRecommendationController.php b/app/Http/Controllers/FormalRecommendationController.php
index 84d891f..bc04d3c 100644
--- a/app/Http/Controllers/FormalRecommendationController.php
+++ b/app/Http/Controllers/FormalRecommendationController.php
@@ -23,14 +23,14 @@ class FormalRecommendationController extends Controller
{
private function access(Request $request, int $id): Requisition
{
- abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
+ abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
return Requisition::query()->visibleTo($request->user())->findOrFail($id);
}
public function policy(Request $request): Response
{
- abort_unless($request->user()->purchasing_role === PurchasingRole::Admin, 403);
+ abort_unless($request->user()->hasFunctions([PurchasingRole::Admin]), 403);
Inertia::encryptHistory();
$response = Inertia::render('purchases/recommendation-policy', ['policies' => RecommendationPolicy::query()->latest('revision')->paginate(20)->withQueryString(), 'latest' => RecommendationPolicy::query()->latest('revision')->first()])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
@@ -68,7 +68,7 @@ class FormalRecommendationController extends Controller
Inertia::encryptHistory();
$response = Inertia::render('purchases/recommendation', ['requisition' => $requisition->only(['id', 'number']), 'policy' => RecommendationPolicy::query()->latest('revision')->first(), 'adoption' => $adoption, 'selected' => $selected, 'adoptions' => RecommendationAdoption::query()->where('requisition_id', $requisition->id)->latest('revision')->get(), 'hasAdmission' => app(FormalAdmission::class)->adoption($requisition) !== null,
'basis' => ['hash' => $basis['hash'], 'ready' => $basis['ready'], 'top_ids' => $basis['top_ids'], 'rows' => collect($basis['payload']['rows'])->map(fn ($row) => array_intersect_key($row, array_flip(['id', 'reference', 'supplier_name', 'currency', 'total_minor', 'eligible', 'ineligibility_reasons', 'evaluation_count', 'score_minor'])))->all()], 'previousId' => $latest->id ?? 0,
- 'history' => $history, 'closed' => $closed, 'canAdopt' => $open && $request->user()->purchasing_role === PurchasingRole::Procurement, 'canPropose' => $open && $current && $basis['ready'] && $request->user()->purchasing_role === PurchasingRole::Procurement && $request->user()->id !== $requisition->requester_id])->toResponse($request);
+ 'history' => $history, 'closed' => $closed, 'canAdopt' => $open && $request->user()->hasFunctions([PurchasingRole::Procurement]), 'canPropose' => $open && $current && $basis['ready'] && $request->user()->hasFunctions([PurchasingRole::Procurement]) && $request->user()->id !== $requisition->requester_id])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
diff --git a/app/Http/Controllers/GoodsReceiptController.php b/app/Http/Controllers/GoodsReceiptController.php
index e6ebced..b7bd9a3 100644
--- a/app/Http/Controllers/GoodsReceiptController.php
+++ b/app/Http/Controllers/GoodsReceiptController.php
@@ -2,7 +2,6 @@
namespace App\Http\Controllers;
-use App\Enums\PurchasingRole;
use App\Http\Requests\ConfirmReceiptRequest;
use App\Models\GoodsReceipt;
use App\Models\PurchaseOrder;
@@ -26,7 +25,7 @@ class GoodsReceiptController extends Controller
{
Gate::authorize('viewAny', GoodsReceipt::class);
$orders = PurchaseOrder::query();
- if ($request->user()->purchasing_role === PurchasingRole::DepartmentHead) {
+ if ($request->user()->departmentScoped()) {
$orders->whereIn('requisition_id', Requisition::query()->visibleTo($request->user())->select('id'));
}
diff --git a/app/Http/Controllers/InvestmentPolicyController.php b/app/Http/Controllers/InvestmentPolicyController.php
index 49d4d8a..67d4b83 100644
--- a/app/Http/Controllers/InvestmentPolicyController.php
+++ b/app/Http/Controllers/InvestmentPolicyController.php
@@ -15,15 +15,15 @@ class InvestmentPolicyController extends Controller
{
public function index(Request $request, InvestmentWorkflow $workflow): Response
{
- abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement]), 403);
+ abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement]), 403);
$current = $workflow->current();
- return Inertia::render('investment/index', ['policies' => InvestmentPolicy::query()->latest('revision')->get(), 'reviews' => InvestmentPolicyReview::query()->orderBy('id')->get(), 'currentPolicyId' => $current && $current->valid_until >= today()->format('Y-m-d') ? $current->id : null, 'canManage' => $request->user()->purchasing_role === PurchasingRole::Admin]);
+ return Inertia::render('investment/index', ['policies' => InvestmentPolicy::query()->latest('revision')->get(), 'reviews' => InvestmentPolicyReview::query()->orderBy('id')->get(), 'currentPolicyId' => $current && $current->valid_until >= today()->format('Y-m-d') ? $current->id : null, 'canManage' => $request->user()->hasFunctions([PurchasingRole::Admin])]);
}
public function store(Request $request, InvestmentWorkflow $workflow): RedirectResponse
{
- abort_unless($request->user()->purchasing_role === PurchasingRole::Admin, 403);
+ abort_unless($request->user()->hasFunctions([PurchasingRole::Admin]), 403);
$data = $request->validate(['previous_id' => 'required|integer|min:0', 'mode' => 'required|in:ceiling', 'scope' => 'required|in:process_total', 'currency' => 'required|in:MZN', 'ceiling' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'], 'includes_tax' => 'required|boolean', 'valid_from' => 'required|date_format:Y-m-d', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:valid_from', 'reason' => 'required|string|max:4000']);
$workflow->propose($request->user(), $data);
diff --git a/app/Http/Controllers/InvoiceController.php b/app/Http/Controllers/InvoiceController.php
index 2b49e53..7bc64ee 100644
--- a/app/Http/Controllers/InvoiceController.php
+++ b/app/Http/Controllers/InvoiceController.php
@@ -16,14 +16,14 @@ class InvoiceController extends Controller
{
public function index(Request $request): Response
{
- abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance]), 403);
+ abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]), 403);
- return Inertia::render('invoices/index', ['invoices' => Invoice::query()->with('items')->latest('id')->paginate(15), 'orders' => PurchaseOrder::query()->with('items')->latest('id')->get()->map(fn ($order) => ['id' => $order->id, 'number' => $order->number, 'supplier' => $order->snapshot['supplier']['name'], 'items' => $order->items]), 'payablesAvailable' => SupplierPayables::enabled() && app(SupplierPayables::class)->can($request->user(), 'view'), 'canManage' => $request->user()->purchasing_role === PurchasingRole::Finance]);
+ return Inertia::render('invoices/index', ['invoices' => Invoice::query()->with('items')->latest('id')->paginate(15), 'orders' => PurchaseOrder::query()->with('items')->latest('id')->get()->map(fn ($order) => ['id' => $order->id, 'number' => $order->number, 'supplier' => $order->snapshot['supplier']['name'], 'items' => $order->items]), 'payablesAvailable' => SupplierPayables::enabled() && app(SupplierPayables::class)->can($request->user(), 'view'), 'canManage' => $request->user()->hasFunctions([PurchasingRole::Finance])]);
}
public function store(Request $request, PurchaseOrder $order, InvoiceWorkflow $workflow): RedirectResponse
{
- abort_unless($request->user()->purchasing_role === PurchasingRole::Finance, 403);
+ abort_unless($request->user()->hasFunctions([PurchasingRole::Finance]), 403);
$decimal = ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'];
$data = $request->validate(['number' => 'required|string|max:100', 'issued_on' => 'required|date_format:Y-m-d', 'subtotal' => $decimal, 'tax' => $decimal, 'total' => $decimal, 'items' => 'required|array|min:1|max:50', 'items.*.purchase_order_item_id' => 'required|integer|distinct', 'items.*.quantity' => ['required', 'regex:/^\d{1,9}(\.\d{1,3})?$/'], 'items.*.unit' => 'required|string|max:30', 'items.*.unit_price' => $decimal, 'items.*.tax_rate' => ['required', 'regex:/^\d{1,3}(\.\d{1,2})?$/']]);
$workflow->register($request->user(), $order, $data);
diff --git a/app/Http/Controllers/OrganizationConfigurationController.php b/app/Http/Controllers/OrganizationConfigurationController.php
new file mode 100644
index 0000000..fca23e7
--- /dev/null
+++ b/app/Http/Controllers/OrganizationConfigurationController.php
@@ -0,0 +1,72 @@
+administrator($request->user());
+ $ready = Schema::hasTable('organization_access');
+ $state = $ready ? DB::table('organization_access')->where('tenant_id', $actor->tenant_id)->first() : null;
+ Inertia::encryptHistory();
+ $response = Inertia::render('company/parameters', ['ready' => $ready, 'active' => (bool) ($state->active ?? false), 'version' => $state->version ?? 1,
+ 'companyRules' => $this->companyRules((int) $actor->tenant_id),
+ 'permissions' => OrganizationAccess::PERMISSIONS,
+ 'profiles' => $ready ? DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->orderBy('name')->get()->map(fn ($p) => ['id' => $p->id, 'name' => $p->name, 'permissions' => json_decode($p->permissions, true, 32, JSON_THROW_ON_ERROR)]) : [],
+ 'memberships' => $ready ? DB::table('organization_memberships')->where('tenant_id', $actor->tenant_id)->get(['user_id', 'profile_id', 'maintenance', 'expires_at', 'reason']) : [],
+ 'users' => DB::table('users')->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'name', 'email', 'department_id']),
+ 'departments' => DB::table('departments')->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'name']),
+ 'costCenters' => DB::table('cost_centers')->where('tenant_id', $actor->tenant_id)->orderBy('code')->get(['id', 'code', 'name']),
+ 'notice' => $request->session()->get('organization_notice')])->toResponse($request);
+ $response->headers->set('Cache-Control', 'private, no-store, max-age=0');
+
+ return $response;
+ }
+
+ public function command(Request $request, string $command, OrganizationConfiguration $service): Response
+ {
+ $actor = $service->administrator($request->user());
+ abort_unless(Schema::hasTable('organization_access'), 503, 'A instalação da parametrização ainda não está concluída.');
+ $rules = ['version' => 'required|integer|min:1', 'tenant_id' => 'prohibited', 'purchasing_role' => 'prohibited'];
+ if ($command === 'profile') {
+ $rules += ['id' => 'nullable|integer|min:1', 'name' => ['required', 'string', 'max:120', Rule::unique('organization_profiles')->where('tenant_id', $actor->tenant_id)->ignore($request->integer('id'))],
+ 'permissions' => 'present|array', 'permissions.*' => ['string', 'distinct', Rule::in(array_keys(OrganizationAccess::PERMISSIONS))]];
+ } elseif ($command === 'user') {
+ $rules += ['name' => 'required|string|max:100', 'email' => 'required|email|max:255|unique:users', 'password' => 'required|string|min:12|confirmed',
+ 'department_id' => 'required|integer|min:1', 'profile_ids' => 'present|array', 'profile_ids.*' => 'integer|distinct|min:1', 'reason' => 'required|string|min:10|max:4000'];
+ } elseif ($command === 'assign') {
+ $rules += ['user_id' => 'required|integer|min:1', 'profile_ids' => 'present|array', 'profile_ids.*' => 'integer|distinct|min:1', 'maintenance' => 'required|boolean',
+ 'expires_at' => 'nullable|date|after:now', 'reason' => 'required|string|min:10|max:4000'];
+ } elseif ($command === 'rules') {
+ $rules += ['first_limit' => ['required', 'string', 'regex:/^\d{1,12}(\.\d{1,2})?$/D'], 'second_limit' => ['required', 'string', 'regex:/^\d{1,12}(\.\d{1,2})?$/D'], 'includes_tax' => 'required|boolean', 'reason' => 'required|string|min:10|max:4000'];
+ } else {
+ abort_unless($command === 'activate', 404);
+ $rules += ['reason' => 'required|string|min:10|max:4000', 'confirm' => 'required|accepted'];
+ }
+ $service->execute($actor, $command, $request->validate($rules));
+
+ return to_route('organization.parameters')->with('organization_notice', 'Parametrização actualizada e registada na auditoria.');
+ }
+
+ /** @return array */
+ private function companyRules(int $tenant): array
+ {
+ $company = Tenant::query()->findOrFail($tenant);
+
+ return ['first_limit' => (string) BigDecimal::of((string) $company->first_limit_minor)->dividedBy(100, 2),
+ 'second_limit' => (string) BigDecimal::of((string) $company->second_limit_minor)->dividedBy(100, 2),
+ 'includes_tax' => $company->approval_includes_tax, 'provisional' => $company->rules_provisional];
+ }
+}
diff --git a/app/Http/Controllers/ProcurementReportController.php b/app/Http/Controllers/ProcurementReportController.php
new file mode 100644
index 0000000..9b9cc3f
--- /dev/null
+++ b/app/Http/Controllers/ProcurementReportController.php
@@ -0,0 +1,146 @@
+getDriverName() === 'pgsql' && DB::transactionLevel() !== 0) {
+ throw new \LogicException('O relatório exige uma transacção própria de leitura.');
+ }
+
+ return DB::transaction(function () use ($read) {
+ if (DB::connection()->getDriverName() === 'pgsql') {
+ DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
+ }
+
+ return $read();
+ });
+ }
+
+ /** @return array */
+ private function filters(Request $request): array
+ {
+ $actor = User::query()->findOrFail($request->user()->id);
+ abort_unless($actor->tenant_id === $request->user()->tenant_id && $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
+ $request->setUserResolver(fn () => $actor);
+
+ $filters = $request->validate([
+ 'search' => 'nullable|string|max:100',
+ 'status' => 'nullable|in:draft,pending,approved,rejected,cancelled',
+ 'from' => 'nullable|date_format:Y-m-d',
+ 'to' => 'nullable|date_format:Y-m-d',
+ 'department_id' => 'nullable|integer|min:1',
+ ]);
+ if (! empty($filters['from']) && ! empty($filters['to']) && $filters['from'] > $filters['to']) {
+ throw ValidationException::withMessages(['to' => 'A data final deve ser igual ou posterior à data inicial.']);
+ }
+
+ return $filters;
+ }
+
+ /** @param array $filters
+ * @return Builder
+ */
+ private function query(User $actor, array $filters): Builder
+ {
+ $query = Requisition::query()->where('tenant_id', $actor->tenant_id)->visibleTo($actor);
+ if (isset($filters['search']) && $filters['search'] !== '') {
+ $query->where('number', 'like', '%'.$filters['search'].'%');
+ }
+ foreach (['status', 'department_id'] as $field) {
+ if (! empty($filters[$field])) {
+ $query->where($field, $filters[$field]);
+ }
+ }
+ if (! empty($filters['from'])) {
+ $query->where('created_at', '>=', $filters['from'].' 00:00:00');
+ }
+ if (! empty($filters['to'])) {
+ $query->where('created_at', '<', (new \DateTimeImmutable($filters['to']))->modify('+1 day')->format('Y-m-d').' 00:00:00');
+ }
+
+ return $query;
+ }
+
+ /** @param Builder $query
+ * @return Builder
+ */
+ private function records(Builder $query): Builder
+ {
+ return $query->select('requisitions.*')->with(['department:id,name', 'requester:id,name'])->addSelect([
+ 'quotations_count' => Quotation::query()->selectRaw('COUNT(*)')->whereColumn('requisition_id', 'requisitions.id'),
+ 'order_number' => PurchaseOrder::query()->visibleTo(request()->user())->select('number')->whereColumn('requisition_id', 'requisitions.id')->orderBy('id')->limit(1),
+ ]);
+ }
+
+ /** @return array */
+ private function row(Requisition $record): array
+ {
+ return [
+ 'id' => $record->id, 'number' => $record->number, 'status' => $record->status,
+ 'created_at' => $record->created_at?->toIso8601String(),
+ 'department' => $record->department?->name, 'requester' => $record->requester?->name,
+ 'total_minor' => (string) $record->total_minor,
+ 'quotations_count' => (int) $record->getAttribute('quotations_count'),
+ 'order_number' => $record->getAttribute('order_number'),
+ ];
+ }
+
+ public function index(Request $request): Response
+ {
+ $props = $this->snapshot(function () use ($request): array {
+ $filters = $this->filters($request);
+ $query = $this->query($request->user(), $filters);
+ $counts = (clone $query)->selectRaw('status, COUNT(*) AS count')->groupBy('status')->pluck('count', 'status')->map(fn ($value) => (int) $value);
+ $records = $this->records(clone $query)->orderByDesc('id')->paginate(25)->withQueryString()->through(fn (Requisition $record) => $this->row($record));
+ $departments = Department::query()->when($request->user()->departmentScoped(), fn ($q) => $q->whereKey($request->user()->department_id))->orderBy('name')->get(['id', 'name']);
+
+ return ['records' => $records, 'counts' => $counts, 'filters' => $filters, 'departments' => $departments, 'generatedAt' => now()->toIso8601String()];
+ });
+ Inertia::encryptHistory();
+
+ $response = Inertia::render('reports/procurement', $props)->toResponse($request);
+ $response->headers->set('Cache-Control', 'private, no-store, max-age=0');
+
+ return $response;
+ }
+
+ public function export(Request $request): Response
+ {
+ $filters = $this->filters($request);
+ $query = $this->records($this->query($request->user(), $filters));
+ $text = fn (?string $value) => preg_match('/^[\s]*[=+\-@]|^[\t\r\n]/u', $value ?? '') ? "'".$value : ($value ?? '');
+
+ return response()->streamDownload(fn () => $this->snapshot(function () use ($query, $text, $filters) {
+ $output = fopen('php://output', 'w');
+ if ($output === false) {
+ throw new \RuntimeException('Não foi possível gerar o ficheiro.');
+ }
+ fwrite($output, "\xEF\xBB\xBF");
+ fputcsv($output, ['Relatório de compras', 'Criado em', now()->toIso8601String()], ';', '"', '');
+ fputcsv($output, ['Filtros', $text(json_encode($filters, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR))], ';', '"', '');
+ fputcsv($output, ['Requisição', 'Criada em', 'Departamento', 'Solicitante', 'Estado da requisição', 'Estimativa total (centavos MZN)', 'Propostas registadas', 'Ordem de compra', 'Processo', 'Relatório de conferência'], ';', '"', '');
+ foreach ($query->lazyById(200) as $record) {
+ $row = $this->row($record);
+ $base = url('/requisicoes/'.$record->id);
+ fputcsv($output, [$text($record->number), $row['created_at'], $text($row['department']), $text($row['requester']), $record->status, $row['total_minor'], $row['quotations_count'], $text($row['order_number']), $base.'/processo', $base.'/relatorio'], ';', '"', '');
+ }
+ fclose($output);
+ }), 'relatorio-compras-'.now()->format('Ymd-His').'.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store, max-age=0']);
+ }
+}
diff --git a/app/Http/Controllers/ProposalOpeningController.php b/app/Http/Controllers/ProposalOpeningController.php
index f615514..5de6379 100644
--- a/app/Http/Controllers/ProposalOpeningController.php
+++ b/app/Http/Controllers/ProposalOpeningController.php
@@ -19,7 +19,7 @@ class ProposalOpeningController extends Controller
{
private function access(Request $request, int $requisitionId): Requisition
{
- abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
+ abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
return Requisition::query()->visibleTo($request->user())->findOrFail($requisitionId);
}
@@ -36,7 +36,7 @@ class ProposalOpeningController extends Controller
'requisition' => $requisition->only(['id', 'number', 'justification']), 'proposals' => $proposals, 'basisHash' => $workflow->basis($proposals), 'openings' => $openings,
'sourceChanged' => $latest && $workflow->basis($proposals) !== $latest->basis_hash,
'latestId' => $latest->id ?? 0,
- 'canRecord' => $request->user()->purchasing_role === PurchasingRole::Procurement && $requisition->status === 'approved' && ! PurchaseAward::query()->where('requisition_id', $requisition->id)->exists() && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition),
+ 'canRecord' => $request->user()->hasFunctions([PurchasingRole::Procurement]) && $requisition->status === 'approved' && ! PurchaseAward::query()->where('requisition_id', $requisition->id)->exists() && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition),
])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
diff --git a/app/Http/Controllers/PurchaseControlController.php b/app/Http/Controllers/PurchaseControlController.php
index 232bcc9..fcce31d 100644
--- a/app/Http/Controllers/PurchaseControlController.php
+++ b/app/Http/Controllers/PurchaseControlController.php
@@ -10,6 +10,7 @@ use App\Models\PurchaseControlReview;
use App\Models\PurchaseOrder;
use App\Models\PurchaseRevision;
use App\Models\Requisition;
+use App\Services\OrganizationAccess;
use App\Services\PurchaseControlPolicyWorkflow;
use App\Services\PurchaseRevisionWorkflow;
use Illuminate\Http\RedirectResponse;
@@ -22,13 +23,13 @@ class PurchaseControlController extends Controller
public function index(Request $request): Response
{
$user = $request->user();
- $operational = in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance]);
+ $operational = $user->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance]);
$sources = Requisition::query()->where('requester_id', $user->id)->select('id');
$ownAwards = PurchaseAward::query()->whereIn('requisition_id', $sources)->select('id');
$cancellations = AwardCancellation::query()->whereIn('purchase_award_id', $ownAwards)->where('status', 'approved')->get(['id', 'purchase_award_id']);
return Inertia::render('purchase-controls/index', [
- 'role' => $user->purchasing_role->value, 'actorId' => $user->id,
+ 'role' => $user->purchasing_role->value ?? '', 'permissions' => app(OrganizationAccess::class)->permissions($user), 'actorId' => $user->id,
'policies' => $operational ? PurchaseControlPolicy::query()->latest('revision')->get() : [],
'reviews' => $operational ? PurchaseControlReview::query()->get() : [],
'awards' => $operational ? PurchaseAward::query()->whereNotIn('requisition_id', PurchaseOrder::query()->select('requisition_id'))->latest('id')->get(['id', 'requisition_id', 'status']) : [],
@@ -44,7 +45,7 @@ class PurchaseControlController extends Controller
public function propose(Request $request, PurchaseControlPolicyWorkflow $workflow): RedirectResponse
{
- abort_unless($request->user()->purchasing_role === PurchasingRole::Admin, 403);
+ abort_unless($request->user()->hasFunctions([PurchasingRole::Admin]), 403);
$data = $request->validate(['previous_id' => 'required|integer|min:0', 'mode' => 'required|in:unissued_award', 'enabled' => 'required|boolean', 'valid_from' => 'required|date_format:Y-m-d', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:valid_from', 'reason' => 'required|string|min:10|max:4000']);
$workflow->propose($request->user(), $data);
diff --git a/app/Http/Controllers/PurchaseController.php b/app/Http/Controllers/PurchaseController.php
index f61e4d3..7ce7fe3 100644
--- a/app/Http/Controllers/PurchaseController.php
+++ b/app/Http/Controllers/PurchaseController.php
@@ -24,7 +24,7 @@ class PurchaseController extends Controller
{
private function access(Request $request): void
{
- abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
+ abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
}
public function award(Request $request, Requisition $requisition, ProposalComparison $comparison, PurchaseWorkflow $workflow): Response
@@ -50,10 +50,10 @@ class PurchaseController extends Controller
return Inertia::render('purchases/award', [
'requisition' => $requisition, 'comparison' => $comparison->inspect($requisition), 'award' => $award,
- 'canSelect' => $request->user()->purchasing_role === PurchasingRole::Procurement && $award === null && $request->user()->id !== $requisition->requester_id && (! $adoption || $candidate !== null),
+ 'canSelect' => $request->user()->hasFunctions([PurchasingRole::Procurement]) && $award === null && $request->user()->id !== $requisition->requester_id && (! $adoption || $candidate !== null),
'recommendation' => ['adopted' => $adoption !== null, 'quotation_id' => $candidate],
'canDecide' => ! $frozen && $award !== null && $workflow->canDecide($request->user(), $award, $requisition),
- 'canIssue' => ! $frozen && $award?->status === 'approved' && $request->user()->purchasing_role === PurchasingRole::Procurement,
+ 'canIssue' => ! $frozen && $award?->status === 'approved' && $request->user()->hasFunctions([PurchasingRole::Procurement]),
'order' => PurchaseOrder::query()->where('requisition_id', $requisition->id)->first(['id', 'number']),
]);
}
diff --git a/app/Http/Controllers/PurchasePlanningController.php b/app/Http/Controllers/PurchasePlanningController.php
index bf39308..a985b96 100644
--- a/app/Http/Controllers/PurchasePlanningController.php
+++ b/app/Http/Controllers/PurchasePlanningController.php
@@ -21,7 +21,7 @@ class PurchasePlanningController extends Controller
public function index(Request $request): Response
{
$actor = $request->user();
- $canManage = in_array($actor->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement]);
+ $canManage = $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement]);
$filters = $request->validate(['item' => 'nullable|integer|min:1']);
$items = PurchasePlanItem::query()->visibleTo($actor)->when($filters['item'] ?? null, fn ($query, $id) => $query->whereKey($id))->latest('id')->paginate(15)->withQueryString();
$milestones = PurchasePlanMilestone::query()->whereIn('purchase_plan_item_id', $items->pluck('id'))->orderBy('planned_on')->orderBy('id')->get();
diff --git a/app/Http/Controllers/PurchaseProcessController.php b/app/Http/Controllers/PurchaseProcessController.php
index 9743fee..1ef4a59 100644
--- a/app/Http/Controllers/PurchaseProcessController.php
+++ b/app/Http/Controllers/PurchaseProcessController.php
@@ -30,10 +30,9 @@ class PurchaseProcessController extends Controller
$actor = $request->user();
Requisition::query()->visibleTo($actor)->findOrFail($requisition->id);
$requisition->load(['requester:id,name', 'department:id,name', 'steps']);
- $role = $actor->purchasing_role;
- $purchases = in_array($role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
- $finance = in_array($role, [PurchasingRole::Admin, PurchasingRole::Finance]);
- $inventory = $purchases || $role === PurchasingRole::Warehouse;
+ $purchases = $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
+ $finance = $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]);
+ $inventory = $purchases || $actor->hasFunctions([PurchasingRole::Warehouse]);
$base = '/requisicoes/'.$requisition->id;
$stages = [];
$documents = [['label' => $requisition->number, 'kind' => 'Requisição', 'href' => $base]];
diff --git a/app/Http/Controllers/QuotationController.php b/app/Http/Controllers/QuotationController.php
index bc0f105..dcd9e24 100644
--- a/app/Http/Controllers/QuotationController.php
+++ b/app/Http/Controllers/QuotationController.php
@@ -24,7 +24,7 @@ class QuotationController extends Controller
{
private function access(Request $request): void
{
- abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
+ abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
}
public function index(Request $request): Response
@@ -46,15 +46,15 @@ class QuotationController extends Controller
return Inertia::render('quotations/show', [
'requisition' => $requisition->load('items'), 'quotations' => $scores,
'suppliers' => Supplier::query()->where('status', 'active')->where('blocked', false)->whereDate('next_review', '>=', today())->orderBy('name')->get(['id', 'name']),
- 'canCreate' => ! $closed && $request->user()->purchasing_role === PurchasingRole::Procurement && $requisition->status === 'approved' && ! $guard->participationBlocked($request->user(), $requisition),
- 'canEvaluate' => ! $closed && $request->user()->purchasing_role !== PurchasingRole::Admin && $request->user()->id !== $requisition->requester_id && $requisition->status === 'approved' && ! $guard->participationBlocked($request->user(), $requisition),
+ 'canCreate' => ! $closed && $request->user()->hasFunctions([PurchasingRole::Procurement]) && $requisition->status === 'approved' && ! $guard->participationBlocked($request->user(), $requisition),
+ 'canEvaluate' => ! $closed && $request->user()->hasFunctions([PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]) && $request->user()->id !== $requisition->requester_id && $requisition->status === 'approved' && ! $guard->participationBlocked($request->user(), $requisition),
'ready' => $result['ready'],
]);
}
public function store(Request $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
{
- abort_unless($request->user()->purchasing_role === PurchasingRole::Procurement, 403);
+ abort_unless($request->user()->hasFunctions([PurchasingRole::Procurement]), 403);
$data = $request->validate([
'supplier_id' => 'required|integer', 'reference' => 'required|string|max:100',
'delivery_days' => 'required|integer|min:1|max:3650', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:today',
@@ -120,7 +120,7 @@ class QuotationController extends Controller
public function evaluate(Request $request, Quotation $quotation, RequisitionWorkflow $workflow): RedirectResponse
{
$this->access($request);
- abort_if($request->user()->purchasing_role === PurchasingRole::Admin, 403);
+ abort_unless($request->user()->hasFunctions([PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
$data = $request->validate(['quality' => 'required|numeric|decimal:0,2|between:0,10', 'history' => 'required|numeric|decimal:0,2|between:0,10', 'payment' => 'required|numeric|decimal:0,2|between:0,10', 'comment' => 'required|string|min:10|max:2000', 'no_conflict' => 'accepted']);
DB::transaction(function () use ($request, $quotation, $data, $workflow) {
$guard = app(ConflictGuard::class);
diff --git a/app/Http/Controllers/RequisitionController.php b/app/Http/Controllers/RequisitionController.php
index dbe9133..a1b9563 100644
--- a/app/Http/Controllers/RequisitionController.php
+++ b/app/Http/Controllers/RequisitionController.php
@@ -14,6 +14,7 @@ use App\Models\TechnicalReview;
use App\Models\Tenant;
use App\Services\ConflictGuard;
use App\Services\EquipmentWorkflow;
+use App\Services\OrganizationAccess;
use App\Services\RequisitionWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -51,6 +52,8 @@ class RequisitionController extends Controller
public function create(Request $request): Response
{
+ abort_unless(app(OrganizationAccess::class)->canRequest($request->user()), 403);
+
return Inertia::render('requisitions/form', [
'costCenters' => CostCenter::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(),
'requisition' => null,
@@ -72,7 +75,7 @@ class RequisitionController extends Controller
return Inertia::render('requisitions/show', [
'requisition' => $requisition,
- 'canEdit' => $requisition->requester_id === $request->user()->id && $requisition->status === 'draft',
+ 'canEdit' => app(OrganizationAccess::class)->canRequest($request->user()) && $requisition->requester_id === $request->user()->id && $requisition->status === 'draft',
'canDecide' => $requisition->canDecide($request->user()),
'conflictBlocked' => app(ConflictGuard::class)->participationBlocked($request->user(), $requisition),
'audit' => AuditLog::query()->where('requisition_id', $requisition->id)->orderBy('id')->get(['id', 'action', 'user_id', 'created_at']),
@@ -86,6 +89,7 @@ class RequisitionController extends Controller
public function edit(Request $request, Requisition $requisition): Response
{
+ abort_unless(app(OrganizationAccess::class)->canRequest($request->user()), 403);
abort_unless($requisition->requester_id === $request->user()->id && $requisition->status === 'draft', 403);
return Inertia::render('requisitions/form', [
diff --git a/app/Http/Controllers/StockController.php b/app/Http/Controllers/StockController.php
index e65f4f6..174b496 100644
--- a/app/Http/Controllers/StockController.php
+++ b/app/Http/Controllers/StockController.php
@@ -21,7 +21,7 @@ class StockController extends Controller
private function access(Request $request, bool $manage = false): void
{
$roles = $manage ? [PurchasingRole::Admin, PurchasingRole::Warehouse] : [PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Finance];
- abort_unless(in_array($request->user()->purchasing_role, $roles), 403);
+ abort_unless($request->user()->hasFunctions($roles), 403);
}
public function index(Request $request): Response
@@ -49,7 +49,7 @@ class StockController extends Controller
return Inertia::render('stock/index', [
'items' => $items, 'warehouses' => $warehouses, 'balances' => $balances,
'movements' => $movements->latest('id')->paginate(20)->withQueryString(), 'filters' => $filters,
- 'canManage' => in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Warehouse]),
+ 'canManage' => $request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Warehouse]),
]);
}
diff --git a/app/Http/Controllers/StockOperationController.php b/app/Http/Controllers/StockOperationController.php
index 439f5ad..96f2c27 100644
--- a/app/Http/Controllers/StockOperationController.php
+++ b/app/Http/Controllers/StockOperationController.php
@@ -22,7 +22,7 @@ class StockOperationController extends Controller
{
public function index(Request $request, StockWorkflow $workflow): Response
{
- abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Warehouse, PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement]), 403);
+ abort_unless($request->user()->hasFunctions([PurchasingRole::Warehouse, PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement]), 403);
$items = StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']);
$warehouses = Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']);
$balances = [];
@@ -37,14 +37,14 @@ class StockOperationController extends Controller
'users' => User::query()->where('tenant_id', $request->user()->tenant_id)->orderBy('name')->get(['id', 'name']),
'operations' => StockOperation::query()->latest('id')->paginate(20),
'counts' => StockOperation::query()->where('kind', 'count')->whereNotIn('id', StockOperation::query()->whereNotNull('count_id')->select('count_id'))->latest('id')->get(),
- 'canOperate' => $request->user()->purchasing_role === PurchasingRole::Warehouse,
+ 'canOperate' => $request->user()->hasFunctions([PurchasingRole::Warehouse]),
'equipment' => Equipment::query()->orderBy('code')->get(['id', 'code', 'description']),
]);
}
public function store(Request $request, StockWorkflow $workflow): JsonResponse|RedirectResponse
{
- abort_unless($request->user()->purchasing_role === PurchasingRole::Warehouse, 403);
+ abort_unless($request->user()->hasFunctions([PurchasingRole::Warehouse]), 403);
$request->merge(['reference' => Str::upper(Str::squish($request->string('reference')->toString())), 'reason' => trim($request->string('reason')->toString()), 'destination' => trim($request->string('destination')->toString())]);
$tenant = $request->user()->tenant_id;
$data = $request->validate([
diff --git a/app/Http/Controllers/SupplierBankController.php b/app/Http/Controllers/SupplierBankController.php
index f88702b..28cefeb 100644
--- a/app/Http/Controllers/SupplierBankController.php
+++ b/app/Http/Controllers/SupplierBankController.php
@@ -18,10 +18,10 @@ class SupplierBankController extends Controller
{
public function show(Request $request, Supplier $supplier, SupplierBankWorkflow $workflow): Response
{
- abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance]), 403);
+ abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance]), 403);
$details = SupplierBankDetail::query()->where('supplier_id', $supplier->id)->latest('revision')->get();
$current = $details->first();
- $canRead = in_array($request->user()->purchasing_role, [PurchasingRole::Procurement, PurchasingRole::Finance]);
+ $canRead = $request->user()->hasFunctions([PurchasingRole::Procurement, PurchasingRole::Finance]);
$data = null;
if ($current && $canRead) {
app(RequisitionWorkflow::class)->audit($request->user(), 'supplier.bank_viewed', null, null, ['supplier_id' => $supplier->id, 'bank_detail_id' => $current->id]);
@@ -29,7 +29,7 @@ class SupplierBankController extends Controller
}
Inertia::encryptHistory();
- $response = Inertia::render('suppliers/bank', ['supplier' => $supplier->only(['id', 'name']), 'current' => $data, 'currentId' => $current->id ?? 0, 'state' => $workflow->state($supplier), 'history' => $details->map(fn ($detail) => $detail->only(['id', 'revision', 'created_by', 'created_at'])), 'reviews' => SupplierBankReview::query()->whereIn('supplier_bank_detail_id', $details->pluck('id'))->orderBy('id')->get(), 'canPropose' => $request->user()->purchasing_role === PurchasingRole::Procurement, 'canReview' => $current && $request->user()->purchasing_role === PurchasingRole::Finance && $current->created_by !== $request->user()->id && ! SupplierBankReview::query()->where('supplier_bank_detail_id', $current->id)->where('reviewed_by', $request->user()->id)->exists()])->toResponse($request);
+ $response = Inertia::render('suppliers/bank', ['supplier' => $supplier->only(['id', 'name']), 'current' => $data, 'currentId' => $current->id ?? 0, 'state' => $workflow->state($supplier), 'history' => $details->map(fn ($detail) => $detail->only(['id', 'revision', 'created_by', 'created_at'])), 'reviews' => SupplierBankReview::query()->whereIn('supplier_bank_detail_id', $details->pluck('id'))->orderBy('id')->get(), 'canPropose' => $request->user()->hasFunctions([PurchasingRole::Procurement]), 'canReview' => $current && $request->user()->hasFunctions([PurchasingRole::Finance]) && $current->created_by !== $request->user()->id && ! SupplierBankReview::query()->where('supplier_bank_detail_id', $current->id)->where('reviewed_by', $request->user()->id)->exists()])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
diff --git a/app/Http/Controllers/SupplierController.php b/app/Http/Controllers/SupplierController.php
index a4e8e3d..a4a92e7 100644
--- a/app/Http/Controllers/SupplierController.php
+++ b/app/Http/Controllers/SupplierController.php
@@ -19,14 +19,14 @@ class SupplierController extends Controller
{
public function index(Request $request): Response
{
- abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
+ abort_unless($request->user()->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
$filters = $request->validate(['search' => 'nullable|string|max:100']);
$query = Supplier::query();
if (! empty($filters['search'])) {
$query->where(fn ($q) => $q->where('name', 'like', '%'.$filters['search'].'%')->orWhere('nuit', 'like', '%'.$filters['search'].'%'));
}
- return Inertia::render('suppliers/index', ['suppliers' => $query->orderBy('name')->paginate(15)->withQueryString(), 'filters' => $filters, 'canCreate' => $request->user()->purchasing_role === PurchasingRole::Procurement, 'canReview' => $request->user()->purchasing_role === PurchasingRole::Finance]);
+ return Inertia::render('suppliers/index', ['suppliers' => $query->orderBy('name')->paginate(15)->withQueryString(), 'filters' => $filters, 'canCreate' => $request->user()->hasFunctions([PurchasingRole::Procurement]), 'canReview' => $request->user()->hasFunctions([PurchasingRole::Finance])]);
}
public function store(SaveSupplierRequest $request, RequisitionWorkflow $workflow): RedirectResponse
@@ -50,7 +50,7 @@ class SupplierController extends Controller
'documents' => $canDocuments ? $supplier->documents()->latest('id')->get() : [],
'canEdit' => $request->user()->can('update', $supplier),
'canDocuments' => $canDocuments,
- 'canReview' => $request->user()->purchasing_role === PurchasingRole::Finance,
+ 'canReview' => $request->user()->hasFunctions([PurchasingRole::Finance]),
]);
}
@@ -84,7 +84,7 @@ class SupplierController extends Controller
public function review(Request $request, Supplier $supplier, RequisitionWorkflow $workflow): RedirectResponse
{
- abort_unless($request->user()->purchasing_role === PurchasingRole::Finance, 403);
+ abort_unless($request->user()->hasFunctions([PurchasingRole::Finance]), 403);
$data = $request->validate(['status' => ['required', Rule::in(['active', 'suspended'])], 'comment' => 'required|string|min:10|max:2000', 'version' => 'required|integer|min:1']);
DB::transaction(function () use ($request, $supplier, $data, $workflow) {
$supplier = Supplier::query()->lockForUpdate()->findOrFail($supplier->id);
diff --git a/app/Http/Controllers/SupplierEvaluationController.php b/app/Http/Controllers/SupplierEvaluationController.php
new file mode 100644
index 0000000..a44c446
--- /dev/null
+++ b/app/Http/Controllers/SupplierEvaluationController.php
@@ -0,0 +1,159 @@
+ */
+ private function payload(Request $request): array
+ {
+ return $request->validate([
+ 'payload' => 'required|array:title,nature,scope,period_start,period_end,order_ids,criteria,conclusion,critical_occurrences,critical_evidence',
+ 'payload.title' => 'required|string|min:3|max:200', 'payload.nature' => 'required|in:goods,services',
+ 'payload.scope' => 'nullable|string|max:10000', 'payload.period_start' => 'nullable|date_format:Y-m-d', 'payload.period_end' => 'nullable|date_format:Y-m-d',
+ 'payload.order_ids' => 'present|array|max:50', 'payload.order_ids.*' => 'integer|distinct|min:1',
+ 'payload.criteria' => 'present|array|max:30', 'payload.criteria.*' => 'required|array:label,result,reason,evidence',
+ 'payload.criteria.*.label' => 'nullable|string|max:300', 'payload.criteria.*.result' => 'required|in:pending,observed,insufficient,not_applicable',
+ 'payload.criteria.*.reason' => 'nullable|string|max:4000', 'payload.criteria.*.evidence' => 'nullable|string|max:4000',
+ 'payload.conclusion' => 'nullable|string|max:10000', 'payload.critical_occurrences' => 'nullable|string|max:4000', 'payload.critical_evidence' => 'nullable|string|max:4000',
+ 'tenant_id' => 'prohibited', 'created_by' => 'prohibited', 'status' => 'prohibited', 'contributors' => 'prohibited', 'supplier_snapshot' => 'prohibited',
+ ])['payload'];
+ }
+
+ public function index(Request $request, SupplierEvaluationWorkflow $workflow): Response
+ {
+ $actor = $workflow->reader($request->user());
+ $filters = $request->validate(['supplier_id' => 'nullable|integer|min:1', 'status' => 'nullable|in:draft,review,returned,approved']);
+ $ready = Schema::hasTable('supplier_evaluations');
+ $records = $ready ? SupplierEvaluation::query()->visibleTo($actor)
+ ->when($filters['supplier_id'] ?? null, fn ($q, $id) => $q->where('supplier_id', $id))
+ ->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
+ ->latest('id')->paginate(20)->withQueryString()->through(fn (SupplierEvaluation $e) => [
+ ...$e->only(['id', 'status', 'revision', 'version', 'previous_id']), 'supplier' => $e->supplier_snapshot['name'],
+ 'title' => $e->payload['title'], 'period_start' => $e->payload['period_start'], 'period_end' => $e->payload['period_end'],
+ ]) : null;
+
+ return $this->render($request, 'suppliers/evaluations', ['ready' => $ready, 'records' => $records, 'filters' => $filters,
+ 'suppliers' => Supplier::query()->orderBy('name')->get(['id', 'name', 'nuit']),
+ 'orders' => PurchaseOrder::query()->visibleTo($actor)->orderByDesc('id')->get(['id', 'number', 'supplier_id'])]);
+ }
+
+ public function show(Request $request, int $evaluation, SupplierEvaluationWorkflow $workflow): Response
+ {
+ return $this->view($request, $evaluation, $workflow, false);
+ }
+
+ public function report(Request $request, int $evaluation, SupplierEvaluationWorkflow $workflow): Response
+ {
+ return $this->view($request, $evaluation, $workflow, true);
+ }
+
+ private function view(Request $request, int $id, SupplierEvaluationWorkflow $workflow, bool $report): Response
+ {
+ $actor = $workflow->reader($request->user());
+ $this->ready();
+ $evaluation = SupplierEvaluation::query()->visibleTo($actor)->findOrFail($id);
+ $improvementWorkflow = app(SupplierImprovementWorkflow::class);
+ $measuresReady = Schema::hasTable('supplier_improvements');
+ $measures = $measuresReady ? SupplierImprovement::query()->where('supplier_evaluation_id', $id)->orderBy('due_on')->orderBy('id')->get() : collect();
+ $measureHistory = $measuresReady ? AuditLog::query()->where('action', 'like', 'supplier_improvement.%')->where('after->supplier_evaluation_id', $id)->latest('id')->paginate(10, ['id', 'action', 'user_id', 'after', 'created_at'], 'measures_history_page')->withQueryString() : null;
+ $responsesReady = Schema::hasTable('supplier_responses');
+ $responses = $responsesReady ? SupplierResponse::query()->where('supplier_evaluation_id', $id)->orderBy('id')->get() : collect();
+ $responseHistory = $responsesReady ? AuditLog::query()->where('action', 'like', 'supplier_response.%')->where('after->supplier_evaluation_id', $id)->latest('id')->paginate(10, ['id', 'action', 'user_id', 'after', 'created_at'], 'responses_history_page')->withQueryString() : null;
+ $history = AuditLog::query()->where('action', 'like', 'supplier_evaluation.%')->where('after->evaluation_id', $id)->latest('id')->paginate(10, ['id', 'action', 'user_id', 'after', 'created_at'], 'history_page')->withQueryString();
+ $peopleIds = collect($evaluation->contributors)->merge($history->pluck('user_id'))->merge($measures->flatMap(fn (SupplierImprovement $m) => $m->contributors))->merge($measures->pluck('reviewed_by'))->merge($measureHistory?->pluck('user_id') ?? []);
+ $peopleIds = $peopleIds->merge($responses->flatMap(fn (SupplierResponse $r) => $r->contributors))->merge($responses->pluck('reviewed_by'))->merge($responseHistory?->pluck('user_id') ?? []);
+ $people = User::query()->where('tenant_id', $actor->tenant_id)->whereIn('id', $peopleIds)->pluck('name', 'id');
+ $writable = $improvementWorkflow->canParticipate($actor, $evaluation);
+ $next = SupplierEvaluation::query()->visibleTo($actor)->where('previous_id', $id)->value('id');
+
+ return $this->render($request, 'suppliers/evaluation', ['evaluation' => $workflow->snapshot($evaluation), 'reportOnly' => $report,
+ 'canEdit' => ! $report && $writable && in_array($evaluation->status, ['draft', 'returned'], true),
+ 'canReview' => ! $report && $writable && $evaluation->status === 'review' && ! in_array($actor->id, $evaluation->contributors, true),
+ 'canRevise' => ! $report && $writable && $evaluation->status === 'approved' && $next === null,
+ 'nextRevisionId' => $next, 'history' => $history, 'people' => $people,
+ 'responses' => ['ready' => $responsesReady, 'canCreate' => ! $report && $responsesReady && $writable && $evaluation->status === 'approved',
+ 'history' => $responseHistory,
+ 'records' => $responses->map(fn (SupplierResponse $r) => [...app(SupplierResponseWorkflow::class)->snapshot($r),
+ 'canEdit' => ! $report && $writable && in_array($r->status, ['draft', 'returned'], true),
+ 'canReview' => ! $report && $writable && $r->status === 'review' && ! in_array($actor->id, $r->contributors, true)])],
+ 'improvements' => ['ready' => $measuresReady, 'canCreate' => ! $report && $measuresReady && $writable && $evaluation->status === 'approved',
+ 'today' => today()->toDateString(),
+ 'records' => $measures->map(fn (SupplierImprovement $m) => [...$improvementWorkflow->snapshot($m),
+ 'canPlan' => ! $report && $writable && in_array($m->status, ['open', 'returned'], true),
+ 'canExecute' => ! $report && $writable && $actor->id === $m->responsible_id && in_array($m->status, ['open', 'returned'], true),
+ 'canReview' => ! $report && $writable && $m->status === 'review' && ! in_array($actor->id, $m->contributors, true)]),
+ 'history' => $measureHistory,
+ 'assignees' => $report ? [] : User::query()->where('tenant_id', $actor->tenant_id)->orderBy('name')->get()->filter(fn (User $u) => $u->hasFunctions(SupplierEvaluationWorkflow::ROLES) && (! $u->departmentScoped() || ($u->department_id !== null && $u->department_id === $evaluation->department_id)))->map(fn (User $u) => $u->only(['id', 'name']))->values()],
+ 'orders' => PurchaseOrder::query()->visibleTo($actor)->where('supplier_id', $evaluation->supplier_id)->orderByDesc('id')->get(['id', 'number', 'supplier_id']),
+ 'generatedAt' => now()->toIso8601String()]);
+ }
+
+ /** @param array $props */
+ private function render(Request $request, string $component, array $props): Response
+ {
+ Inertia::encryptHistory();
+ $response = Inertia::render($component, [...$props, 'notice' => $request->session()->get('supplier_evaluation_notice')])->toResponse($request);
+ $response->headers->set('Cache-Control', 'private, no-store, max-age=0');
+
+ return $response;
+ }
+
+ public function store(Request $request, SupplierEvaluationWorkflow $workflow): RedirectResponse
+ {
+ $this->ready();
+ $data = $request->validate(['supplier_id' => 'required|integer|min:1', 'operation_key' => 'required|uuid']);
+ $evaluation = $workflow->create($request->user(), (int) $data['supplier_id'], $data['operation_key'], $this->payload($request));
+
+ return to_route('supplier-evaluations.show', $evaluation->id)->with('supplier_evaluation_notice', 'Rascunho guardado. Os dados podem ser recuperados nesta avaliação.');
+ }
+
+ public function update(Request $request, int $evaluation, SupplierEvaluationWorkflow $workflow): RedirectResponse
+ {
+ $this->ready();
+ $data = $request->validate(['version' => 'required|integer|min:1']);
+ $workflow->save($request->user(), $evaluation, (int) $data['version'], $this->payload($request));
+
+ return to_route('supplier-evaluations.show', $evaluation)->with('supplier_evaluation_notice', 'Alterações guardadas no histórico.');
+ }
+
+ public function transition(Request $request, int $evaluation, string $action, SupplierEvaluationWorkflow $workflow): RedirectResponse
+ {
+ $this->ready();
+ $data = $request->validate(['version' => 'required|integer|min:1', 'reason' => $action === 'submit' ? 'nullable|string|max:4000' : 'required|string|min:10|max:4000',
+ 'payload' => 'prohibited', 'supplier_id' => 'prohibited', 'tenant_id' => 'prohibited', 'reviewed_by' => 'prohibited']);
+ $workflow->transition($request->user(), $evaluation, (int) $data['version'], $action, $data['reason'] ?? null);
+
+ return to_route('supplier-evaluations.show', $evaluation)->with('supplier_evaluation_notice', 'Estado e decisão registados.');
+ }
+
+ public function revise(Request $request, int $evaluation, SupplierEvaluationWorkflow $workflow): RedirectResponse
+ {
+ $this->ready();
+ $data = $request->validate(['version' => 'required|integer|min:1']);
+ $revision = $workflow->revise($request->user(), $evaluation, (int) $data['version']);
+
+ return to_route('supplier-evaluations.show', $revision->id)->with('supplier_evaluation_notice', 'Nova revisão preparada. A avaliação aprovada anterior mantém-se preservada.');
+ }
+}
diff --git a/app/Http/Controllers/SupplierImprovementController.php b/app/Http/Controllers/SupplierImprovementController.php
new file mode 100644
index 0000000..a1a897e
--- /dev/null
+++ b/app/Http/Controllers/SupplierImprovementController.php
@@ -0,0 +1,44 @@
+execute($request, $evaluation, null, 'create', $workflow);
+ }
+
+ public function update(Request $request, int $evaluation, int $measure, string $command, SupplierImprovementWorkflow $workflow): RedirectResponse
+ {
+ return $this->execute($request, $evaluation, $measure, $command, $workflow);
+ }
+
+ private function execute(Request $request, int $evaluation, ?int $measure, string $command, SupplierImprovementWorkflow $workflow): RedirectResponse
+ {
+ abort_unless(Schema::hasTable('supplier_improvements'), 503, 'A instalação das medidas de melhoria ainda não está concluída.');
+ $rules = ['tenant_id' => 'prohibited', 'supplier_evaluation_id' => 'prohibited', 'created_by' => 'prohibited', 'status' => 'prohibited', 'contributors' => 'prohibited', 'reviewed_by' => 'prohibited', 'reviewed_at' => 'prohibited'];
+ if ($command === 'create' || $command === 'plan') {
+ $rules += ['title' => 'required|string|min:3|max:200', 'description' => 'required|string|min:10|max:10000', 'responsible_id' => 'required|integer|min:1', 'due_on' => 'required|date_format:Y-m-d', 'result' => 'prohibited', 'evidence' => 'prohibited'];
+ } else {
+ $rules += ['title' => 'prohibited', 'description' => 'prohibited', 'responsible_id' => 'prohibited', 'due_on' => 'prohibited'];
+ }
+ $rules += $command === 'create' ? ['operation_key' => 'required|uuid', 'version' => 'prohibited', 'reason' => 'prohibited'] : ['operation_key' => 'prohibited', 'version' => 'required|integer|min:1'];
+ if (in_array($command, ['progress', 'submit'], true)) {
+ $rules += ['result' => 'nullable|string|max:10000', 'evidence' => 'nullable|string|max:10000', 'reason' => 'prohibited'];
+ } elseif ($command !== 'create') {
+ $rules += ['reason' => 'required|string|min:10|max:4000'];
+ if ($command !== 'plan') {
+ $rules += ['result' => 'prohibited', 'evidence' => 'prohibited'];
+ }
+ }
+ $workflow->execute($request->user(), $evaluation, $measure, $command, $request->validate($rules));
+
+ return to_route('supplier-evaluations.show', $evaluation)->with('supplier_evaluation_notice', 'Medida de melhoria e histórico actualizados.');
+ }
+}
diff --git a/app/Http/Controllers/SupplierImprovementReportController.php b/app/Http/Controllers/SupplierImprovementReportController.php
new file mode 100644
index 0000000..7a35767
--- /dev/null
+++ b/app/Http/Controllers/SupplierImprovementReportController.php
@@ -0,0 +1,146 @@
+ */
+ private function filters(Request $request): array
+ {
+ $actor = app(SupplierEvaluationWorkflow::class)->reader($request->user());
+ $request->setUserResolver(fn () => $actor);
+ abort_unless(Schema::hasTable('supplier_improvements'), 503, 'A instalação das medidas de melhoria ainda não está concluída.');
+ $filters = $request->validate(['supplier_id' => 'nullable|integer|min:1', 'responsible_id' => 'nullable|integer|min:1',
+ 'status' => 'nullable|in:open,review,returned,closed', 'from' => 'nullable|date_format:Y-m-d', 'to' => 'nullable|date_format:Y-m-d',
+ 'overdue' => 'nullable|in:1', 'search' => 'nullable|string|max:200']);
+ if (! empty($filters['from']) && ! empty($filters['to']) && $filters['to'] < $filters['from']) {
+ throw ValidationException::withMessages(['to' => 'O fim do intervalo deve ser igual ou posterior ao início.']);
+ }
+
+ return $filters;
+ }
+
+ /** @param array $filters
+ * @return Builder
+ */
+ private function query(Request $request, array $filters, string $today): Builder
+ {
+ $query = SupplierImprovement::query()->visibleTo($request->user());
+ if (! empty($filters['supplier_id'])) {
+ $query->whereHas('evaluation', fn ($q) => $q->where('supplier_id', $filters['supplier_id']));
+ }
+ foreach (['responsible_id', 'status'] as $field) {
+ if (! empty($filters[$field])) {
+ $query->where($field, $filters[$field]);
+ }
+ }
+ foreach (['from' => '>=', 'to' => '<='] as $field => $operator) {
+ if (! empty($filters[$field])) {
+ $query->where('due_on', $operator, $filters[$field]);
+ }
+ }
+ if (! empty($filters['overdue'])) {
+ $query->where('status', '!=', 'closed')->where('due_on', '<', $today);
+ }
+ if (isset($filters['search']) && $filters['search'] !== '') {
+ $query->whereRaw('LOWER(title) LIKE ?', ['%'.mb_strtolower($filters['search']).'%']);
+ }
+
+ return $query;
+ }
+
+ /** @param Builder $query
+ * @return Builder
+ */
+ private function records(Builder $query, Request $request): Builder
+ {
+ return $query->with(['evaluation:id,supplier_id,supplier_snapshot,revision', 'responsible' => fn ($q) => $q->where('tenant_id', $request->user()->tenant_id)->select(['id', 'name'])]);
+ }
+
+ /** @return array */
+ private function row(SupplierImprovement $m, string $today): array
+ {
+ return ['id' => $m->id, 'evaluation_id' => $m->supplier_evaluation_id, 'revision' => $m->evaluation?->revision,
+ 'supplier' => $m->evaluation?->supplier_snapshot['name'] ?? 'Fornecedor não disponível',
+ 'responsible' => $m->responsible->name ?? '#'.$m->responsible_id,
+ ...$m->only(['title', 'description', 'due_on', 'status', 'version', 'result', 'evidence', 'review_reason', 'reviewed_at']),
+ 'overdue' => $m->status !== 'closed' && $m->due_on < $today];
+ }
+
+ public function index(Request $request): Response
+ {
+ $props = $this->snapshot(function () use ($request): array {
+ $filters = $this->filters($request);
+ $today = today()->toDateString();
+ $all = SupplierImprovement::query()->visibleTo($request->user());
+ $query = $this->query($request, $filters, $today);
+ $counts = (clone $query)->selectRaw('status, COUNT(*) AS count')->groupBy('status')->pluck('count', 'status')->map(fn ($n) => (int) $n);
+ $overdue = (clone $query)->where('status', '!=', 'closed')->where('due_on', '<', $today)->count();
+ // Choices derive only from visible measures, including prior revisions and former assignees.
+ $evaluations = SupplierEvaluation::query()->visibleTo($request->user())->whereIn('id', (clone $all)->select('supplier_evaluation_id'))->orderByDesc('id')->get(['supplier_id', 'supplier_snapshot']);
+
+ return ['filters' => $filters, 'today' => $today, 'generatedAt' => now()->toIso8601String(), 'counts' => $counts, 'overdue' => $overdue,
+ 'records' => $this->records($query, $request)->orderBy('due_on')->orderBy('id')->paginate(25)->withQueryString()->through(fn (SupplierImprovement $m) => $this->row($m, $today)),
+ 'suppliers' => $evaluations->map(fn (SupplierEvaluation $e) => ['id' => $e->supplier_id, 'name' => $e->supplier_snapshot['name']])->unique('id')->values(),
+ 'responsibles' => User::query()->where('tenant_id', $request->user()->tenant_id)->whereIn('id', (clone $all)->select('responsible_id'))->orderBy('name')->get(['id', 'name'])];
+ });
+ Inertia::encryptHistory();
+ $response = Inertia::render('reports/supplier-improvements', $props)->toResponse($request);
+ $response->headers->set('Cache-Control', 'private, no-store, max-age=0');
+
+ return $response;
+ }
+
+ public function csv(Request $request): Response
+ {
+ $filters = $this->filters($request);
+ $text = fn (?string $value) => preg_match('/^[\s]*[=+\-@]|^[\t\r\n]/u', $value ?? '') ? "'".$value : ($value ?? '');
+
+ return response()->streamDownload(fn () => $this->snapshot(function () use ($request, $filters, $text) {
+ // Refresh the profile inside the export snapshot as well.
+ $this->filters($request);
+ $today = today()->toDateString();
+ $out = fopen('php://output', 'w');
+ if ($out === false) {
+ throw new \RuntimeException('Não foi possível gerar o ficheiro.');
+ }
+ fwrite($out, "\xEF\xBB\xBF");
+ fputcsv($out, ['Medidas de melhoria de fornecedores', 'Consulta', now()->toIso8601String(), 'Data de referência', $today], ';', '"', '');
+ fputcsv($out, ['Filtros', $text(json_encode($filters, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR))], ';', '"', '');
+ fputcsv($out, ['Medida', 'Fornecedor preservado', 'Avaliação', 'Revisão', 'Título', 'Objectivo', 'Responsável', 'Prazo', 'Estado', 'Prazo ultrapassado', 'Versão', 'Resultado', 'Evidência', 'Última conferência', 'Conferida em', 'Avaliação e acompanhamento', 'Relatório individual'], ';', '"', '');
+ foreach ($this->records($this->query($request, $filters, $today), $request)->lazyById(200) as $m) {
+ $row = $this->row($m, $today);
+ $url = url('/fornecedores/avaliacoes/'.$m->supplier_evaluation_id);
+ fputcsv($out, ['MM-'.$m->id, $text($row['supplier']), 'AVF-'.$m->supplier_evaluation_id, $row['revision'], $text($m->title), $text($m->description), $text($row['responsible']), $m->due_on, $m->status, $row['overdue'] ? 'Sim' : 'Não', $m->version, $text($m->result), $text($m->evidence), $text($m->review_reason), $m->reviewed_at?->toIso8601String(), $url.'#medida-'.$m->id, $url.'/relatorio#medida-'.$m->id], ';', '"', '');
+ }
+ fclose($out);
+ }), 'medidas-fornecedores-'.now()->format('Ymd-His').'.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store, max-age=0']);
+ }
+}
diff --git a/app/Http/Controllers/SupplierResponseController.php b/app/Http/Controllers/SupplierResponseController.php
new file mode 100644
index 0000000..af74ba0
--- /dev/null
+++ b/app/Http/Controllers/SupplierResponseController.php
@@ -0,0 +1,43 @@
+execute($request, $evaluation, null, 'create', $workflow);
+ }
+
+ public function update(Request $request, int $evaluation, int $record, string $command, SupplierResponseWorkflow $workflow): RedirectResponse
+ {
+ return $this->execute($request, $evaluation, $record, $command, $workflow);
+ }
+
+ private function execute(Request $request, int $evaluation, ?int $record, string $command, SupplierResponseWorkflow $workflow): RedirectResponse
+ {
+ abort_unless(Schema::hasTable('supplier_responses'), 503, 'A instalação do contraditório ainda não está concluída.');
+ $rules = ['tenant_id' => 'prohibited', 'supplier_evaluation_id' => 'prohibited', 'created_by' => 'prohibited', 'contributors' => 'prohibited', 'status' => 'prohibited', 'decision' => 'prohibited', 'reviewed_by' => 'prohibited', 'reviewed_at' => 'prohibited'];
+ $rules += $command === 'create' ? ['operation_key' => 'required|uuid', 'version' => 'prohibited'] : ['operation_key' => 'prohibited', 'version' => 'required|integer|min:1'];
+ if (in_array($command, ['create', 'save'], true)) {
+ $rules += ['payload' => 'required|array:title,received_on,source_reference,statement', 'payload.title' => 'required|string|min:3|max:200',
+ 'payload.received_on' => 'nullable|date_format:Y-m-d', 'payload.source_reference' => 'nullable|string|max:4000', 'payload.statement' => 'nullable|string|max:20000'];
+ } else {
+ $rules['payload'] = 'prohibited';
+ }
+ $rules['reason'] = in_array($command, ['return', 'decide'], true) ? 'required|string|min:10|max:10000' : 'prohibited';
+ if ($command === 'decide') {
+ $rules += ['outcome' => 'required|in:accepted,partially_accepted,not_accepted', 'follow_up' => 'nullable|string|max:10000', 'response_reference' => 'nullable|string|max:4000'];
+ } else {
+ $rules += ['outcome' => 'prohibited', 'follow_up' => 'prohibited', 'response_reference' => 'prohibited'];
+ }
+ $workflow->execute($request->user(), $evaluation, $record, $command, $request->validate($rules));
+
+ return to_route('supplier-evaluations.show', $evaluation)->with('supplier_evaluation_notice', 'Contraditório e histórico actualizados.');
+ }
+}
diff --git a/app/Http/Middleware/EnsureOrganizationAccess.php b/app/Http/Middleware/EnsureOrganizationAccess.php
new file mode 100644
index 0000000..399ad9e
--- /dev/null
+++ b/app/Http/Middleware/EnsureOrganizationAccess.php
@@ -0,0 +1,28 @@
+user();
+ if ($user !== null && $user->tenant_id !== null) {
+ abort_unless(app(OrganizationAccess::class)->permissions($user) !== [], 403, 'Não existe uma atribuição de acesso válida. Contacte os administradores da empresa.');
+ abort_if(app(OrganizationAccess::class)->maintenanceOnly($user) && ! $request->isMethodSafe(), 403, 'A manutenção temporária tem acesso de diagnóstico e consulta.');
+ }
+
+ $response = $next($request);
+ if ($user !== null && app(OrganizationAccess::class)->maintenanceOnly($user)) {
+ app(RequisitionWorkflow::class)->audit($user, 'maintenance.consulted', null, null, ['path' => $request->path(), 'status' => $response->getStatusCode()]);
+ }
+
+ return $response;
+ }
+}
diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php
index 618c5b6..eeb7218 100644
--- a/app/Http/Middleware/HandleInertiaRequests.php
+++ b/app/Http/Middleware/HandleInertiaRequests.php
@@ -2,6 +2,7 @@
namespace App\Http\Middleware;
+use App\Services\OrganizationAccess;
use App\Services\SupplierPayables;
use Illuminate\Http\Request;
use Inertia\Middleware;
@@ -39,9 +40,15 @@ class HandleInertiaRequests extends Middleware
return [
...parent::share($request),
'name' => config('app.name'),
+ 'canRequest' => $request->user() !== null && app(OrganizationAccess::class)->canRequest($request->user()),
'auth' => [
'user' => $request->user(),
],
+ 'organizationAccess' => $request->user() === null ? null : [
+ 'permissions' => app(OrganizationAccess::class)->permissions($request->user()),
+ 'active' => app(OrganizationAccess::class)->active($request->user()->tenant_id),
+ 'profileLabel' => app(OrganizationAccess::class)->profileLabel($request->user()),
+ ],
'payablesAvailable' => $request->user() !== null && app(SupplierPayables::class)->can($request->user(), 'view'),
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
];
diff --git a/app/Http/Requests/SaveSupplierRequest.php b/app/Http/Requests/SaveSupplierRequest.php
index e8c0e64..bad83da 100644
--- a/app/Http/Requests/SaveSupplierRequest.php
+++ b/app/Http/Requests/SaveSupplierRequest.php
@@ -11,7 +11,7 @@ class SaveSupplierRequest extends FormRequest
{
public function authorize(): bool
{
- return $this->user()?->purchasing_role === PurchasingRole::Procurement;
+ return $this->user()?->hasFunctions([PurchasingRole::Procurement]) ?? false;
}
/** @return array */
diff --git a/app/Models/ConflictDeclaration.php b/app/Models/ConflictDeclaration.php
index 95f3170..13c39d2 100644
--- a/app/Models/ConflictDeclaration.php
+++ b/app/Models/ConflictDeclaration.php
@@ -41,7 +41,7 @@ class ConflictDeclaration extends Model
/** @param Builder $query */
public function scopeVisibleTo(Builder $query, User $user): void
{
- if (! in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance])) {
+ if (! $user->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance])) {
$query->where('user_id', $user->id);
}
}
diff --git a/app/Models/GoodsReceipt.php b/app/Models/GoodsReceipt.php
index 6bab516..4ede68e 100644
--- a/app/Models/GoodsReceipt.php
+++ b/app/Models/GoodsReceipt.php
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
+use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -12,6 +13,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property int $tenant_id
* @property int $purchase_order_id
* @property int $received_by
+ * @property CarbonInterface $received_on
* @property string $payload_hash
* @property array $snapshot
*/
diff --git a/app/Models/PurchasePlanItem.php b/app/Models/PurchasePlanItem.php
index 99b37eb..e8036de 100644
--- a/app/Models/PurchasePlanItem.php
+++ b/app/Models/PurchasePlanItem.php
@@ -18,11 +18,11 @@ class PurchasePlanItem extends Model
/** @param Builder $query */
public function scopeVisibleTo(Builder $query, User $user): void
{
- match ($user->purchasing_role) {
- PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance => null,
- PurchasingRole::DepartmentHead => $query->where(fn ($visible) => $visible->where('department_id', $user->department_id)->orWhere('responsible_id', $user->id)),
- default => $query->where('responsible_id', $user->id),
- };
+ if ($user->departmentScoped()) {
+ $query->where(fn ($visible) => $visible->where('department_id', $user->department_id)->orWhere('responsible_id', $user->id));
+ } elseif (! $user->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance])) {
+ $query->where('responsible_id', $user->id);
+ }
}
protected static function booted(): void
diff --git a/app/Models/QualificationAssessment.php b/app/Models/QualificationAssessment.php
index 6e0961e..80af5d0 100644
--- a/app/Models/QualificationAssessment.php
+++ b/app/Models/QualificationAssessment.php
@@ -7,7 +7,7 @@ use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/**
- * @property array{analyst:array{id:int,name:string,purchasing_role:string},basis_hash:string} $snapshot
+ * @property array{analyst:array{id:int,name:string,purchasing_role:string,profile_label?:string},basis_hash:string} $snapshot
* @property list $answers
*/
class QualificationAssessment extends Model
diff --git a/app/Models/Requisition.php b/app/Models/Requisition.php
index 75aa67f..60d5e95 100644
--- a/app/Models/Requisition.php
+++ b/app/Models/Requisition.php
@@ -68,11 +68,11 @@ class Requisition extends Model
/** @param Builder $query */
public function scopeVisibleTo(Builder $query, User $user): void
{
- match ($user->purchasing_role) {
- PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance => null,
- PurchasingRole::DepartmentHead => $query->where('department_id', $user->department_id),
- default => $query->where('requester_id', $user->id),
- };
+ if ($user->departmentScoped()) {
+ $query->where('department_id', $user->department_id);
+ } elseif (! $user->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance])) {
+ $query->where('requester_id', $user->id);
+ }
}
public function canDecide(User $user): bool
@@ -87,7 +87,7 @@ class Requisition extends Model
$next = $this->steps->firstWhere('decision', 'pending');
- return $next !== null && $next->role === $user->purchasing_role->value
+ return $next !== null && $user->hasFunctions([$next->role])
&& ($next->role !== 'department_head' || $this->department_id === $user->department_id)
&& ! app(ConflictGuard::class)->participationBlocked($user, $this);
}
diff --git a/app/Models/SupplierEvaluation.php b/app/Models/SupplierEvaluation.php
new file mode 100644
index 0000000..56f81c2
--- /dev/null
+++ b/app/Models/SupplierEvaluation.php
@@ -0,0 +1,52 @@
+ $payload
+ * @property array $supplier_snapshot
+ * @property list $contributors
+ */
+class SupplierEvaluation extends Model
+{
+ use BelongsToTenant;
+
+ protected $guarded = ['id'];
+
+ protected $attributes = ['version' => 1, 'revision' => 1, 'status' => 'draft'];
+
+ protected function casts(): array
+ {
+ return ['payload' => 'array', 'supplier_snapshot' => 'array', 'contributors' => 'array', 'reviewed_at' => 'datetime'];
+ }
+
+ /** @param Builder $query */
+ public function scopeVisibleTo(Builder $query, User $actor): void
+ {
+ $query->where('tenant_id', $actor->tenant_id);
+ if ($actor->departmentScoped()) {
+ $query->where('department_id', $actor->department_id ?? 0);
+ }
+ }
+
+ protected static function booted(): void
+ {
+ static::updating(function (self $evaluation) {
+ if ($evaluation->getRawOriginal('status') === 'approved') {
+ throw new \LogicException('A avaliação aprovada conserva-se; prepare uma nova revisão.');
+ }
+ });
+ static::deleting(fn () => throw new \LogicException('Preservar o histórico de avaliações.'));
+ }
+}
diff --git a/app/Models/SupplierImprovement.php b/app/Models/SupplierImprovement.php
new file mode 100644
index 0000000..26aa980
--- /dev/null
+++ b/app/Models/SupplierImprovement.php
@@ -0,0 +1,62 @@
+ $contributors
+ * @property CarbonInterface|null $reviewed_at
+ */
+class SupplierImprovement extends Model
+{
+ use BelongsToTenant;
+
+ protected $guarded = ['id'];
+
+ protected $attributes = ['version' => 1, 'status' => 'open'];
+
+ protected function casts(): array
+ {
+ return ['contributors' => 'array', 'reviewed_at' => 'datetime'];
+ }
+
+ /** @param Builder $query */
+ public function scopeVisibleTo(Builder $query, User $actor): void
+ {
+ $query->where('tenant_id', $actor->tenant_id)->whereIn('supplier_evaluation_id', SupplierEvaluation::query()->visibleTo($actor)->select('id'));
+ }
+
+ /** @return BelongsTo */
+ public function evaluation(): BelongsTo
+ {
+ return $this->belongsTo(SupplierEvaluation::class, 'supplier_evaluation_id');
+ }
+
+ /** @return BelongsTo */
+ public function responsible(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'responsible_id');
+ }
+
+ protected static function booted(): void
+ {
+ static::updating(function (self $measure) {
+ if ($measure->getRawOriginal('status') === 'closed') {
+ throw new \LogicException('A medida encerrada conserva-se no histórico.');
+ }
+ });
+ static::deleting(fn () => throw new \LogicException('Preservar as medidas de melhoria.'));
+ }
+}
diff --git a/app/Models/SupplierResponse.php b/app/Models/SupplierResponse.php
new file mode 100644
index 0000000..dd0466b
--- /dev/null
+++ b/app/Models/SupplierResponse.php
@@ -0,0 +1,41 @@
+ $payload
+ * @property list $contributors
+ * @property array|null $decision
+ */
+class SupplierResponse extends Model
+{
+ use BelongsToTenant;
+
+ protected $guarded = ['id'];
+
+ protected $attributes = ['status' => 'draft', 'version' => 1];
+
+ protected function casts(): array
+ {
+ return ['payload' => 'array', 'contributors' => 'array', 'decision' => 'array', 'reviewed_at' => 'datetime'];
+ }
+
+ protected static function booted(): void
+ {
+ static::updating(function (self $record) {
+ if ($record->getRawOriginal('status') === 'decided') {
+ throw new \LogicException('A conclusão registada conserva-se no histórico.');
+ }
+ });
+ static::deleting(fn () => throw new \LogicException('Preservar o contraditório do fornecedor.'));
+ }
+}
diff --git a/app/Models/User.php b/app/Models/User.php
index e96ca40..b1ec8de 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\PurchasingRole;
+use App\Services\OrganizationAccess;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
@@ -15,7 +16,7 @@ use Illuminate\Support\Carbon;
* @property int $id
* @property int|null $tenant_id
* @property int|null $department_id
- * @property PurchasingRole $purchasing_role
+ * @property PurchasingRole|null $purchasing_role
* @property string $name
* @property string $email
* @property Carbon|null $email_verified_at
@@ -31,6 +32,17 @@ use Illuminate\Support\Carbon;
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
class User extends Authenticatable
{
+ /** @param list $functions */
+ public function hasFunctions(array $functions): bool
+ {
+ return app(OrganizationAccess::class)->allows($this, $functions);
+ }
+
+ public function departmentScoped(): bool
+ {
+ return app(OrganizationAccess::class)->departmentScoped($this);
+ }
+
/** @use HasFactory */
use HasFactory, Notifiable;
diff --git a/app/Policies/GoodsReceiptPolicy.php b/app/Policies/GoodsReceiptPolicy.php
index b43a339..27de40c 100644
--- a/app/Policies/GoodsReceiptPolicy.php
+++ b/app/Policies/GoodsReceiptPolicy.php
@@ -12,13 +12,13 @@ class GoodsReceiptPolicy
{
public function viewAny(User $user): bool
{
- return $user->tenant_id !== null && in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::Warehouse, PurchasingRole::DepartmentHead]);
+ return $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::Warehouse, PurchasingRole::DepartmentHead]);
}
public function viewOrder(User $user, PurchaseOrder $order): bool
{
return $this->viewAny($user) && $user->tenant_id === $order->tenant_id &&
- ($user->purchasing_role !== PurchasingRole::DepartmentHead || Requisition::query()->whereKey($order->requisition_id)->where('department_id', $user->department_id)->exists());
+ (! $user->departmentScoped() || Requisition::query()->whereKey($order->requisition_id)->where('department_id', $user->department_id)->exists());
}
public function view(User $user, GoodsReceipt $receipt): bool
@@ -30,6 +30,6 @@ class GoodsReceiptPolicy
public function create(User $user, PurchaseOrder $order): bool
{
- return $user->tenant_id === $order->tenant_id && $user->purchasing_role === PurchasingRole::Warehouse && $user->id !== $order->issued_by;
+ return $user->tenant_id === $order->tenant_id && $user->hasFunctions([PurchasingRole::Warehouse]) && $user->id !== $order->issued_by;
}
}
diff --git a/app/Policies/SupplierPolicy.php b/app/Policies/SupplierPolicy.php
index 1d84ee4..b005067 100644
--- a/app/Policies/SupplierPolicy.php
+++ b/app/Policies/SupplierPolicy.php
@@ -10,7 +10,7 @@ class SupplierPolicy
{
public function viewAny(User $user): bool
{
- return $user->tenant_id !== null && in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
+ return $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
}
public function view(User $user, Supplier $supplier): bool
@@ -20,11 +20,11 @@ class SupplierPolicy
public function update(User $user, Supplier $supplier): bool
{
- return $user->purchasing_role === PurchasingRole::Procurement && $user->tenant_id === $supplier->tenant_id;
+ return $user->hasFunctions([PurchasingRole::Procurement]) && $user->tenant_id === $supplier->tenant_id;
}
public function viewDocuments(User $user, Supplier $supplier): bool
{
- return $this->view($user, $supplier) && in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance]);
+ return $this->view($user, $supplier) && $user->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance]);
}
}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index f008a02..9ff329b 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -16,14 +16,8 @@ class AppServiceProvider extends ServiceProvider
public function register(): void
{
$package = config('common-budget.path');
- if (! is_file($package.'/bootstrap.php') && ! $this->app->environment('production')) {
- $package = base_path('../sgf-tempo/packages/budget-control');
- }
require_once $package.'/bootstrap.php';
$payables = config('common-payables.path');
- if (! is_file($payables.'/bootstrap.php') && ! $this->app->environment('production')) {
- $payables = base_path('../sgf-tempo/packages/payables');
- }
require_once $payables.'/bootstrap.php';
}
diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php
index 019298d..71181a9 100644
--- a/app/Providers/FortifyServiceProvider.php
+++ b/app/Providers/FortifyServiceProvider.php
@@ -4,6 +4,7 @@ namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
+use App\Services\EvaluationNavigation;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
@@ -48,7 +49,7 @@ class FortifyServiceProvider extends ServiceProvider
*/
private function configureViews(): void
{
- Fortify::loginView(fn (Request $request) => Inertia::render('auth/login', [
+ Fortify::loginView(fn (Request $request) => EvaluationNavigation::local($request) ? redirect('/avaliar') : Inertia::render('auth/login', [
'canResetPassword' => Features::enabled(Features::resetPasswords()),
'status' => $request->session()->get('status'),
]));
diff --git a/app/Services/CommonBudget.php b/app/Services/CommonBudget.php
index ee99ec4..9be0fcc 100644
--- a/app/Services/CommonBudget.php
+++ b/app/Services/CommonBudget.php
@@ -36,7 +36,7 @@ final class CommonBudget
app(RequisitionWorkflow::class)->audit($user, $action, $request, null, $data);
}
},
- fn (int $id): bool => User::query()->where('tenant_id', $user->tenant_id)->whereKey($id)->lockForUpdate()->whereIn('purchasing_role', [PurchasingRole::Admin->value, PurchasingRole::Finance->value])->exists(),
+ fn (int $id): bool => User::query()->where('tenant_id', $user->tenant_id)->whereKey($id)->lockForUpdate()->first()?->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]) ?? false,
);
}
diff --git a/app/Services/CurrentActor.php b/app/Services/CurrentActor.php
index 7651ff5..4e1f79b 100644
--- a/app/Services/CurrentActor.php
+++ b/app/Services/CurrentActor.php
@@ -18,7 +18,7 @@ class CurrentActor
throw new \LogicException('Revalidar o operador dentro da transacção.');
}
$user = User::query()->where('tenant_id', $actor->tenant_id)->lockForUpdate()->findOrFail($actor->id);
- abort_unless($roles === null || in_array($user->purchasing_role, $roles, true), 403);
+ abort_unless($roles === null || $user->hasFunctions($roles), 403);
return $user;
}
diff --git a/app/Services/DocumentaryQualificationWorkflow.php b/app/Services/DocumentaryQualificationWorkflow.php
index 563936a..8f1ebf5 100644
--- a/app/Services/DocumentaryQualificationWorkflow.php
+++ b/app/Services/DocumentaryQualificationWorkflow.php
@@ -110,7 +110,7 @@ class DocumentaryQualificationWorkflow
}
$answers[] = ['criterion_id' => $answer['criterion_id'], 'result' => $result, 'evidence' => $evidence, 'reason' => $reason];
}
- $assessment = QualificationAssessment::query()->create(['tenant_id' => $actor->tenant_id, 'qualification_set_id' => $set->id, 'quotation_id' => $quote->id, 'analyst_id' => $actor->id, 'revision' => ($latest->revision ?? 0) + 1, 'answers' => $answers, 'snapshot' => ['analyst' => $actor->only(['id', 'name', 'purchasing_role']), 'basis_hash' => $set->basis_hash], 'reason' => $data['reason'], 'created_at' => now()]);
+ $assessment = QualificationAssessment::query()->create(['tenant_id' => $actor->tenant_id, 'qualification_set_id' => $set->id, 'quotation_id' => $quote->id, 'analyst_id' => $actor->id, 'revision' => ($latest->revision ?? 0) + 1, 'answers' => $answers, 'snapshot' => ['analyst' => app(OrganizationAccess::class)->actorSnapshot($actor, ['id', 'name', 'purchasing_role']), 'basis_hash' => $set->basis_hash], 'reason' => $data['reason'], 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'qualification.assessed', $request, null, ['assessment_id' => $assessment->id, 'set_id' => $set->id, 'quotation_id' => $quote->id, 'revision' => $assessment->revision, 'previous_id' => $latest?->id]);
return $assessment;
diff --git a/app/Services/EquipmentWorkflow.php b/app/Services/EquipmentWorkflow.php
index 91ad74c..70e652d 100644
--- a/app/Services/EquipmentWorkflow.php
+++ b/app/Services/EquipmentWorkflow.php
@@ -59,7 +59,7 @@ class EquipmentWorkflow
private function eligible(User $user, Requisition $requisition): bool
{
- return $requisition->tenant_id === $user->tenant_id && $user->purchasing_role === PurchasingRole::DepartmentHead && $user->department_id === $requisition->department_id && $user->id !== $requisition->requester_id && ! app(ConflictGuard::class)->participationBlocked($user, $requisition);
+ return $requisition->tenant_id === $user->tenant_id && $user->hasFunctions([PurchasingRole::DepartmentHead]) && $user->department_id === $requisition->department_id && $user->id !== $requisition->requester_id && ! app(ConflictGuard::class)->participationBlocked($user, $requisition);
}
public function review(User $user, Requisition $requisition, string $basis, string $decision, string $reason): TechnicalReview
diff --git a/app/Services/EvaluationNavigation.php b/app/Services/EvaluationNavigation.php
new file mode 100644
index 0000000..9b783bc
--- /dev/null
+++ b/app/Services/EvaluationNavigation.php
@@ -0,0 +1,40 @@
+environment('payable-testing')
+ && in_array($request->ip(), ['127.0.0.1', '::1'], true)
+ && in_array($request->getHost(), ['127.0.0.1', 'localhost', '::1'], true);
+ }
+
+ public static function returnPath(mixed $intended, Request $request): string
+ {
+ $fallback = '/dashboard';
+ if (! is_string($intended) || preg_match('/[\x00-\x20\\\\]/', $intended)) {
+ return $fallback;
+ }
+ $parts = parse_url($intended);
+ if ($parts === false) {
+ return $fallback;
+ }
+ if (isset($parts['scheme']) || isset($parts['host'])) {
+ $port = $parts['port'] ?? (($parts['scheme'] ?? '') === 'https' ? 443 : 80);
+ if (($parts['scheme'] ?? '') !== $request->getScheme() || ($parts['host'] ?? '') !== $request->getHost() || $port !== $request->getPort()) {
+ return $fallback;
+ }
+ }
+ $path = $parts['path'] ?? '';
+ if (! str_starts_with($path, '/') || str_starts_with($path, '//')
+ || in_array($path, ['/login', '/logout', '/avaliar', '/avaliar/entrar'], true)) {
+ return $fallback;
+ }
+
+ return $path.(isset($parts['query']) ? '?'.$parts['query'] : '');
+ }
+}
diff --git a/app/Services/ExternalPaymentWorkflow.php b/app/Services/ExternalPaymentWorkflow.php
index 4fa99db..617c678 100644
--- a/app/Services/ExternalPaymentWorkflow.php
+++ b/app/Services/ExternalPaymentWorkflow.php
@@ -20,7 +20,7 @@ class ExternalPaymentWorkflow
{
private function actor(User $user, int $tenantId): void
{
- abort_unless(auth()->id() === $user->id && $user->tenant_id === $tenantId && $user->purchasing_role === PurchasingRole::Finance, 403);
+ abort_unless(auth()->id() === $user->id && $user->tenant_id === $tenantId && $user->hasFunctions([PurchasingRole::Finance]), 403);
}
private function invalid(string $message): never
@@ -127,7 +127,7 @@ class ExternalPaymentWorkflow
}
$this->bank($user, $invoice, $payment->supplier_bank_detail_id, $payment->amount_minor);
$author = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->find($payment->created_by);
- if (! $author || $author->purchasing_role !== PurchasingRole::Finance) {
+ if (! $author || ! $author->hasFunctions([PurchasingRole::Finance])) {
$this->invalid('O autor do registo deixou de ser elegível.');
}
}
diff --git a/app/Services/FormalAdmission.php b/app/Services/FormalAdmission.php
index 4240726..5cbc825 100644
--- a/app/Services/FormalAdmission.php
+++ b/app/Services/FormalAdmission.php
@@ -174,7 +174,7 @@ class FormalAdmission
public function eligible(User $actor, Requisition $request, AdmissionAdoption $adoption, AdmissionDecision $decision): bool
{
- return $actor->tenant_id === $request->tenant_id && in_array($actor->purchasing_role->value, $adoption->snapshot['roles'], true) && ! in_array($actor->id, [$request->requester_id, $decision->created_by], true) && ($actor->purchasing_role !== PurchasingRole::DepartmentHead || $actor->department_id === $request->department_id);
+ return $actor->tenant_id === $request->tenant_id && $actor->hasFunctions($adoption->snapshot['roles']) && ! in_array($actor->id, [$request->requester_id, $decision->created_by], true) && (! $actor->departmentScoped() || $actor->department_id === $request->department_id);
}
public function review(User $actor, AdmissionDecision $decision, string $verdict, string $reason): void
@@ -213,7 +213,7 @@ class FormalAdmission
}
}
}
- $review = AdmissionReview::query()->create(['tenant_id' => $actor->tenant_id, 'admission_decision_id' => $decision->id, 'reviewed_by' => $actor->id, 'verdict' => $verdict, 'reason' => $reason, 'snapshot' => $actor->only(['id', 'name', 'purchasing_role', 'department_id']), 'created_at' => now()]);
+ $review = AdmissionReview::query()->create(['tenant_id' => $actor->tenant_id, 'admission_decision_id' => $decision->id, 'reviewed_by' => $actor->id, 'verdict' => $verdict, 'reason' => $reason, 'snapshot' => app(OrganizationAccess::class)->actorSnapshot($actor, ['id', 'name', 'purchasing_role', 'department_id']), 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'admission.reviewed', $request, null, $review->toArray());
});
}
diff --git a/app/Services/FormalRecommendation.php b/app/Services/FormalRecommendation.php
index c624cc2..cb1d42e 100644
--- a/app/Services/FormalRecommendation.php
+++ b/app/Services/FormalRecommendation.php
@@ -152,7 +152,7 @@ class FormalRecommendation
public function eligible(User $actor, Requisition $request, RecommendationAdoption $adoption, RecommendationAttempt $attempt): bool
{
- return $actor->tenant_id === $request->tenant_id && in_array($actor->purchasing_role->value, $adoption->snapshot['roles'], true) && ! in_array($actor->id, [$request->requester_id, $attempt->created_by], true) && ($actor->purchasing_role !== PurchasingRole::DepartmentHead || $actor->department_id === $request->department_id);
+ return $actor->tenant_id === $request->tenant_id && $actor->hasFunctions($adoption->snapshot['roles']) && ! in_array($actor->id, [$request->requester_id, $attempt->created_by], true) && (! $actor->departmentScoped() || $actor->department_id === $request->department_id);
}
public function review(User $actor, RecommendationAttempt $attempt, string $verdict, string $reason): void
@@ -191,7 +191,7 @@ class FormalRecommendation
}
}
}
- $review = RecommendationReview::query()->create(['tenant_id' => $actor->tenant_id, 'recommendation_attempt_id' => $attempt->id, 'reviewed_by' => $actor->id, 'verdict' => $verdict, 'reason' => $reason, 'snapshot' => $actor->only(['id', 'name', 'purchasing_role', 'department_id']), 'created_at' => now()]);
+ $review = RecommendationReview::query()->create(['tenant_id' => $actor->tenant_id, 'recommendation_attempt_id' => $attempt->id, 'reviewed_by' => $actor->id, 'verdict' => $verdict, 'reason' => $reason, 'snapshot' => app(OrganizationAccess::class)->actorSnapshot($actor, ['id', 'name', 'purchasing_role', 'department_id']), 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'recommendation.reviewed', $request, null, $review->toArray());
});
}
diff --git a/app/Services/InvestmentWorkflow.php b/app/Services/InvestmentWorkflow.php
index 8abbfef..7700cc6 100644
--- a/app/Services/InvestmentWorkflow.php
+++ b/app/Services/InvestmentWorkflow.php
@@ -19,7 +19,7 @@ class InvestmentWorkflow
{
private function admin(User $user): void
{
- abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->purchasing_role === PurchasingRole::Admin, 403);
+ abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Admin]), 403);
}
/** @param array $data */
@@ -68,7 +68,7 @@ class InvestmentWorkflow
if ($decision === 'approved') {
$ids = InvestmentPolicyReview::query()->where('investment_policy_id', $policy->id)->where('decision', 'approved')->pluck('reviewed_by')->push($policy->created_by)->push($user->id)->unique();
$actors = User::query()->where('tenant_id', $user->tenant_id)->whereIn('id', $ids)->orderBy('id')->lockForUpdate()->get();
- if ($actors->count() !== $ids->count() || $actors->contains(fn (User $actor) => $actor->purchasing_role !== PurchasingRole::Admin)) {
+ if ($actors->count() !== $ids->count() || $actors->contains(fn (User $actor) => ! $actor->hasFunctions([PurchasingRole::Admin]))) {
throw ValidationException::withMessages(['investment' => 'Proponente e validadores devem continuar elegíveis até à publicação.']);
}
}
diff --git a/app/Services/InvoiceWorkflow.php b/app/Services/InvoiceWorkflow.php
index 98b730e..a655eee 100644
--- a/app/Services/InvoiceWorkflow.php
+++ b/app/Services/InvoiceWorkflow.php
@@ -19,7 +19,7 @@ class InvoiceWorkflow
{
private function access(User $user): void
{
- abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->purchasing_role === PurchasingRole::Finance, 403);
+ abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Finance]), 403);
}
private function invalid(string $message): never
diff --git a/app/Services/OrganizationAccess.php b/app/Services/OrganizationAccess.php
new file mode 100644
index 0000000..6ef9f17
--- /dev/null
+++ b/app/Services/OrganizationAccess.php
@@ -0,0 +1,101 @@
+ 'Solicitar e acompanhar compras próprias',
+ 'department_head' => 'Supervisão e aprovação do departamento',
+ 'procurement' => 'Preparação e gestão operacional de compras',
+ 'finance' => 'Conferência e operações financeiras',
+ 'warehouse' => 'Recepção e operações de armazém',
+ 'admin' => 'Administrar a empresa, utilizadores e parametrização',
+ ];
+
+ public function active(?int $tenant): bool
+ {
+ return $tenant !== null && Schema::hasTable('organization_access') && DB::table('organization_access')->where('tenant_id', $tenant)->where('active', true)->exists();
+ }
+
+ /** @return list */
+ public function permissions(User $user): array
+ {
+ if ($user->tenant_id === null) {
+ return [];
+ }
+ if (! $this->active($user->tenant_id)) {
+ return $user->purchasing_role === null || $user->purchasing_role === PurchasingRole::Configured ? [] : [$user->purchasing_role->value];
+ }
+ $rows = DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')
+ ->where('m.tenant_id', $user->tenant_id)->where('p.tenant_id', $user->tenant_id)->where('m.user_id', $user->id)
+ ->where(fn ($q) => $q->whereNull('m.expires_at')->orWhere('m.expires_at', '>', now()))->get(['p.permissions']);
+ $permissions = [];
+ foreach ($rows as $row) {
+ $permissions = [...$permissions, ...json_decode($row->permissions, true, 32, JSON_THROW_ON_ERROR)];
+ }
+
+ return array_values(array_unique($permissions));
+ }
+
+ /** @param list $functions */
+ public function allows(User $user, array $functions): bool
+ {
+ $requested = array_map(fn ($f) => $f instanceof PurchasingRole ? $f->value : $f, $functions);
+
+ return $user->tenant_id !== null && count(array_intersect($this->permissions($user), $requested)) > 0;
+ }
+
+ public function maintenanceOnly(User $user): bool
+ {
+ if (! $this->active($user->tenant_id)) {
+ return false;
+ }
+ $valid = DB::table('organization_memberships')->where('tenant_id', $user->tenant_id)->where('user_id', $user->id)->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()));
+
+ return (clone $valid)->where('maintenance', true)->exists() && ! (clone $valid)->where('maintenance', false)->exists();
+ }
+
+ public function departmentScoped(User $user): bool
+ {
+ $permissions = $this->permissions($user);
+
+ return in_array('department_head', $permissions, true) && ! array_intersect($permissions, ['admin', 'procurement', 'finance']);
+ }
+
+ public function canRequest(User $user): bool
+ {
+ return $user->tenant_id !== null && ($this->active($user->tenant_id) ? $this->allows($user, ['requester']) : ($user->purchasing_role !== null && $user->purchasing_role !== PurchasingRole::Configured));
+ }
+
+ public function profileLabel(User $user): string
+ {
+ if (! $this->active($user->tenant_id)) {
+ return $user->purchasing_role?->label() ?? 'Utilizador';
+ }
+
+ return DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')->where('m.tenant_id', $user->tenant_id)->where('p.tenant_id', $user->tenant_id)->where('m.user_id', $user->id)
+ ->where(fn ($q) => $q->whereNull('m.expires_at')->orWhere('m.expires_at', '>', now()))->orderBy('p.name')->pluck('p.name')->unique()->implode(' · ');
+ }
+
+ /** @param list $fields
+ * @return array
+ */
+ public function actorSnapshot(User $user, array $fields): array
+ {
+ $snapshot = $user->only($fields);
+ if ($this->active($user->tenant_id)) {
+ if (array_key_exists('purchasing_role', $snapshot)) {
+ $snapshot['purchasing_role'] = $user->purchasing_role->value ?? '';
+ }
+ $snapshot['profile_label'] = $this->profileLabel($user);
+ }
+
+ return $snapshot;
+ }
+}
diff --git a/app/Services/OrganizationConfiguration.php b/app/Services/OrganizationConfiguration.php
new file mode 100644
index 0000000..5e14008
--- /dev/null
+++ b/app/Services/OrganizationConfiguration.php
@@ -0,0 +1,142 @@
+ $data
+ * @return array
+ */
+ public static function rules(array $data): array
+ {
+ $first = BigDecimal::of($data['first_limit'])->multipliedBy(100)->toInt();
+ $second = BigDecimal::of($data['second_limit'])->multipliedBy(100)->toInt();
+ if ($second < $first) {
+ throw ValidationException::withMessages(['second_limit' => 'O segundo limite deve ser igual ou superior ao primeiro.']);
+ }
+
+ return ['first_limit_minor' => $first, 'second_limit_minor' => $second, 'approval_includes_tax' => (bool) $data['includes_tax'], 'rules_provisional' => false];
+ }
+
+ public function administrator(User $actor): User
+ {
+ abort_unless(auth()->id() === $actor->id && $actor->tenant_id !== null, 403);
+ $actor = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($actor->id);
+ abort_unless($actor->hasFunctions(['admin']), 403);
+
+ return $actor;
+ }
+
+ /** @param array $data */
+ public function execute(User $actor, string $command, array $data): void
+ {
+ DB::transaction(function () use ($actor, $command, $data) {
+ app(ConflictGuard::class)->lockCompany($actor);
+ $actor = $this->administrator($actor);
+ DB::table('organization_access')->insertOrIgnore(['tenant_id' => $actor->tenant_id, 'active' => false, 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
+ $state = DB::table('organization_access')->where('tenant_id', $actor->tenant_id)->lockForUpdate()->first();
+ if ((int) $data['version'] !== (int) $state->version) {
+ throw ValidationException::withMessages(['version' => 'A parametrização foi alterada. Actualize e compare antes de guardar.']);
+ }
+ $before = null;
+ if ($command === 'profile') {
+ $profile = isset($data['id']) ? DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->where('id', $data['id'])->first() : null;
+ abort_if(isset($data['id']) && ! $profile, 404);
+ if ($profile && in_array('admin', $data['permissions'], true) && DB::table('organization_memberships')->where('tenant_id', $actor->tenant_id)->where('profile_id', $profile->id)->where('maintenance', true)->exists()) {
+ throw ValidationException::withMessages(['permissions' => 'Retire as atribuições de manutenção antes de dar administração a este perfil.']);
+ }
+ $before = $profile ? (array) $profile : null;
+ $values = ['name' => $data['name'], 'permissions' => json_encode(array_values(array_unique($data['permissions'])), JSON_THROW_ON_ERROR), 'updated_at' => now()];
+ if ($profile) {
+ DB::table('organization_profiles')->where('id', $profile->id)->update([...$values, 'version' => $profile->version + 1]);
+ $id = $profile->id;
+ } else {
+ $id = DB::table('organization_profiles')->insertGetId([...$values, 'tenant_id' => $actor->tenant_id, 'created_at' => now()]);
+ }
+ $after = ['profile_id' => $id, 'name' => $data['name'], 'permissions' => $data['permissions']];
+ } elseif ($command === 'user') {
+ $department = DB::table('departments')->where('tenant_id', $actor->tenant_id)->where('id', $data['department_id'])->exists();
+ abort_unless($department, 422, 'Departamento não autorizado.');
+ $user = new User;
+ $user->forceFill(['tenant_id' => $actor->tenant_id, 'department_id' => $data['department_id'], 'name' => $data['name'], 'email' => $data['email'], 'password' => $data['password'], 'purchasing_role' => PurchasingRole::Configured])->save();
+ $this->assign($actor, $user->id, $data['profile_ids'], false, null, $data['reason']);
+ $after = [...$user->only(['id', 'name', 'email', 'department_id']), 'profile_ids' => $data['profile_ids'], 'reason' => $data['reason']];
+ } elseif ($command === 'assign') {
+ $target = User::query()->where('tenant_id', $actor->tenant_id)->whereKey((int) $data['user_id'])->firstOrFail();
+ $before = ['user_id' => $target->id, 'memberships' => DB::table('organization_memberships')->where('tenant_id', $actor->tenant_id)->where('user_id', $target->id)->get(['profile_id', 'maintenance', 'expires_at', 'reason'])->map(fn ($m) => (array) $m)->all()];
+ if (($data['maintenance'] ?? false) && ! $state->active) {
+ throw ValidationException::withMessages(['maintenance' => 'Active a parametrização antes de atribuir manutenção temporária.']);
+ }
+ $this->assign($actor, $target->id, $data['profile_ids'], $data['maintenance'] ?? false, $data['expires_at'] ?? null, $data['reason']);
+ $after = ['user_id' => $target->id, 'profile_ids' => $data['profile_ids'], 'maintenance' => $data['maintenance'] ?? false, 'expires_at' => $data['expires_at'] ?? null, 'reason' => $data['reason']];
+ } elseif ($command === 'rules') {
+ $company = Tenant::query()->findOrFail($actor->tenant_id);
+ $before = $company->only(['first_limit_minor', 'second_limit_minor', 'approval_includes_tax', 'rules_provisional']);
+ $after = [...self::rules($data), 'reason' => $data['reason']];
+ $company->fill(self::rules($data))->save();
+ } else {
+ abort_unless($command === 'activate', 404);
+ if (Tenant::query()->findOrFail($actor->tenant_id)->rules_provisional) {
+ throw ValidationException::withMessages(['rules' => 'Defina e guarde as regras de aprovação da empresa antes da activação.']);
+ }
+ DB::table('organization_access')->where('id', $state->id)->update(['active' => true]);
+ $after = ['active' => true, 'reason' => $data['reason']];
+ }
+ if ($state->active || $command === 'activate') {
+ $this->twoAdministrators((int) $actor->tenant_id);
+ }
+ DB::table('organization_access')->where('id', $state->id)->update(['version' => $state->version + 1, 'updated_at' => now()]);
+ app(RequisitionWorkflow::class)->audit($actor, 'organization.'.$command, null, $before, $after);
+ });
+ }
+
+ /** @param list $profiles */
+ private function assign(User $actor, int $user, array $profiles, bool $maintenance, ?string $expires, string $reason): void
+ {
+ $ids = DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->whereIn('id', $profiles)->pluck('id')->all();
+ if (count($ids) !== count($profiles)) {
+ throw ValidationException::withMessages(['profile_ids' => 'Escolha apenas perfis desta empresa.']);
+ }
+ if ($maintenance && ($expires === null || now()->parse($expires)->isPast())) {
+ throw ValidationException::withMessages(['expires_at' => 'O acesso de manutenção exige uma data futura de expiração.']);
+ }
+ if ($maintenance && DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->whereIn('id', $ids)->pluck('permissions')->contains(fn ($json) => in_array('admin', json_decode($json, true, 32, JSON_THROW_ON_ERROR), true))) {
+ throw ValidationException::withMessages(['profile_ids' => 'A manutenção temporária não pode administrar os acessos da empresa.']);
+ }
+ DB::table('organization_memberships')->where('tenant_id', $actor->tenant_id)->where('user_id', $user)->delete();
+ foreach ($ids as $id) {
+ DB::table('organization_memberships')->insert(['tenant_id' => $actor->tenant_id, 'user_id' => $user, 'profile_id' => $id, 'maintenance' => $maintenance, 'expires_at' => $expires === null ? null : CarbonImmutable::parse($expires)->utc()->format('Y-m-d H:i:s'), 'reason' => $reason, 'created_at' => now(), 'updated_at' => now()]);
+ }
+ }
+
+ private function twoAdministrators(int $tenant): void
+ {
+ $count = 0;
+ foreach (User::query()->where('tenant_id', $tenant)->get() as $user) {
+ $profiles = DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')->where('m.tenant_id', $tenant)->where('p.tenant_id', $tenant)->where('m.user_id', $user->id)->where('m.maintenance', false)->whereNull('m.expires_at')->pluck('p.permissions');
+ if ($profiles->contains(fn ($json) => in_array('admin', json_decode($json, true, 32, JSON_THROW_ON_ERROR), true))) {
+ $count++;
+ }
+ }
+ if ($count < 2) {
+ throw ValidationException::withMessages(['administrators' => 'Mantenha pelo menos dois administradores permanentes da empresa; manutenção não conta para esse mínimo.']);
+ }
+ }
+
+ public function initialise(User $first, User $second): void
+ {
+ $profile = DB::table('organization_profiles')->insertGetId(['tenant_id' => $first->tenant_id, 'name' => 'Administração da empresa', 'permissions' => json_encode(['admin'], JSON_THROW_ON_ERROR), 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
+ foreach ([$first, $second] as $user) {
+ $this->assign($first, $user->id, [$profile], false, null, 'Configuração inicial e entrega do produto.');
+ }
+ DB::table('organization_access')->insert(['tenant_id' => $first->tenant_id, 'active' => true, 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
+ }
+}
diff --git a/app/Services/ProcurementReviewReport.php b/app/Services/ProcurementReviewReport.php
index aa8b8fa..f636ad9 100644
--- a/app/Services/ProcurementReviewReport.php
+++ b/app/Services/ProcurementReviewReport.php
@@ -3,6 +3,9 @@
namespace App\Services;
use App\Enums\PurchasingRole;
+use App\Models\ExternalPayment;
+use App\Models\GoodsReceipt;
+use App\Models\Invoice;
use App\Models\ProposalOpening;
use App\Models\PurchaseAward;
use App\Models\PurchaseOrder;
@@ -28,7 +31,7 @@ class ProcurementReviewReport
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
}
$actor = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($actor->id);
- abort_unless(in_array($actor->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
+ abort_unless($actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
$request = Requisition::query()->visibleTo($actor)->with(['department:id,name', 'requester:id,name'])->findOrFail($requisitionId);
$comparison = app(ProposalComparison::class)->inspect($request);
$rows = [];
@@ -59,6 +62,14 @@ class ProcurementReviewReport
$opening = ProposalOpening::query()->where('requisition_id', $request->id)->latest('revision')->first();
$award = PurchaseAward::query()->where('requisition_id', $request->id)->first();
$order = PurchaseOrder::query()->visibleTo($actor)->where('requisition_id', $request->id)->first();
+ $receipts = $order ? GoodsReceipt::query()->where('purchase_order_id', $order->id)->orderBy('id')->get(['id', 'received_on', 'delivery_note'])->map(fn (GoodsReceipt $receipt) => [
+ 'id' => $receipt->id, 'received_on' => $receipt->received_on->format('Y-m-d'), 'delivery_note' => $receipt->delivery_note,
+ ])->all() : [];
+ $financeVisible = $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance]);
+ $invoices = $financeVisible && $order ? Invoice::query()->where('purchase_order_id', $order->id)->orderBy('id')->get(['id', 'number', 'status', 'issued_on', 'total_minor', 'snapshot']) : collect();
+ $payments = $financeVisible ? ExternalPayment::query()->whereIn('invoice_id', $invoices->pluck('id'))->orderBy('id')->get(['id', 'invoice_id', 'status', 'amount_minor', 'currency', 'paid_on'])->map(fn ($payment) => [
+ 'id' => $payment->id, 'invoice_id' => $payment->invoice_id, 'status' => $payment->status, 'amount_minor' => (string) $payment->amount_minor, 'currency' => $payment->currency, 'paid_on' => $payment->paid_on,
+ ])->all() : null;
return [
'generated_at' => now()->toIso8601String(), 'generated_by' => $actor->name,
@@ -71,6 +82,11 @@ class ProcurementReviewReport
'opening' => $opening ? [...$opening->only(['id', 'revision', 'reference']), 'source_changed' => $opening->basis_hash !== $basis] : null,
'award' => $award?->only(['id', 'status', 'total_minor']),
'order' => $order?->only(['id', 'number', 'currency', 'total_minor']),
+ 'fulfilment' => [
+ 'receipts' => $receipts, 'finance_visible' => $financeVisible,
+ 'invoices' => $financeVisible ? $invoices->map(fn (Invoice $invoice) => ['id' => $invoice->id, 'number' => $invoice->number, 'status' => $invoice->status, 'issued_on' => $invoice->issued_on, 'total_minor' => (string) $invoice->total_minor, 'currency' => $invoice->snapshot['currency'] ?? null])->all() : null,
+ 'payments' => $payments,
+ ],
];
});
}
diff --git a/app/Services/ProposalComparison.php b/app/Services/ProposalComparison.php
index 1278305..5088c81 100644
--- a/app/Services/ProposalComparison.php
+++ b/app/Services/ProposalComparison.php
@@ -121,8 +121,8 @@ class ProposalComparison
public function eligibleRole(User $user, Requisition $requisition): bool
{
return $user->tenant_id === $requisition->tenant_id &&
- (in_array($user->purchasing_role, [PurchasingRole::Procurement, PurchasingRole::Finance]) ||
- ($user->purchasing_role === PurchasingRole::DepartmentHead && $user->department_id === $requisition->department_id));
+ ($user->hasFunctions([PurchasingRole::Procurement, PurchasingRole::Finance]) ||
+ ($user->hasFunctions([PurchasingRole::DepartmentHead]) && $user->department_id === $requisition->department_id));
}
/** @param array $weights */
diff --git a/app/Services/PurchaseControlPolicyWorkflow.php b/app/Services/PurchaseControlPolicyWorkflow.php
index 67e945a..c28510e 100644
--- a/app/Services/PurchaseControlPolicyWorkflow.php
+++ b/app/Services/PurchaseControlPolicyWorkflow.php
@@ -13,7 +13,7 @@ class PurchaseControlPolicyWorkflow
{
private function admin(User $user): void
{
- abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->purchasing_role === PurchasingRole::Admin, 403);
+ abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Admin]), 403);
}
/** @param array $data */
@@ -62,7 +62,7 @@ class PurchaseControlPolicyWorkflow
if ($decision === 'approved') {
$ids = PurchaseControlReview::query()->where('purchase_control_policy_id', $policy->id)->where('decision', 'approved')->pluck('reviewed_by')->push($policy->created_by)->push($user->id)->unique();
$actors = User::query()->where('tenant_id', $user->tenant_id)->whereIn('id', $ids)->orderBy('id')->lockForUpdate()->get();
- if ($actors->count() !== $ids->count() || $actors->contains(fn (User $actor) => $actor->purchasing_role !== PurchasingRole::Admin)) {
+ if ($actors->count() !== $ids->count() || $actors->contains(fn (User $actor) => ! $actor->hasFunctions([PurchasingRole::Admin]))) {
throw ValidationException::withMessages(['cancellation' => 'Proponente e validadores devem continuar elegíveis até à publicação.']);
}
}
diff --git a/app/Services/PurchasePlanningWorkflow.php b/app/Services/PurchasePlanningWorkflow.php
index 4df3f2d..970ba5e 100644
--- a/app/Services/PurchasePlanningWorkflow.php
+++ b/app/Services/PurchasePlanningWorkflow.php
@@ -15,7 +15,7 @@ class PurchasePlanningWorkflow
{
private function manager(User $actor): bool
{
- return in_array($actor->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement]);
+ return $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement]);
}
private function responsible(User $actor, int $id): void
diff --git a/app/Services/PurchaseWorkflow.php b/app/Services/PurchaseWorkflow.php
index 587ba3b..e9c55b5 100644
--- a/app/Services/PurchaseWorkflow.php
+++ b/app/Services/PurchaseWorkflow.php
@@ -29,7 +29,7 @@ class PurchaseWorkflow
$this->guard->lockCompany($user);
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
- abort_unless($user->purchasing_role === PurchasingRole::Procurement && $user->id !== $requisition->requester_id, 403);
+ abort_unless($user->hasFunctions([PurchasingRole::Procurement]) && $user->id !== $requisition->requester_id, 403);
if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
throw ValidationException::withMessages(['award' => 'Este processo já tem uma adjudicação. Não é permitida uma segunda selecção.']);
}
@@ -70,7 +70,7 @@ class PurchaseWorkflow
$steps = $award->approvals()->get();
$next = $steps->firstWhere('decision', 'pending');
- return $next !== null && $next->role === $user->purchasing_role->value &&
+ return $next !== null && $user->hasFunctions([$next->role]) &&
($next->role !== 'department_head' || $user->department_id === $requisition->department_id) &&
! $steps->contains('approver_id', $user->id) && ! $this->guard->participationBlocked($user, $requisition);
}
@@ -103,7 +103,7 @@ class PurchaseWorkflow
return DB::transaction(function () use ($user, $award) {
$this->guard->lockCompany($user);
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]);
- abort_unless($user->purchasing_role === PurchasingRole::Procurement, 403);
+ abort_unless($user->hasFunctions([PurchasingRole::Procurement]), 403);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id);
$award = PurchaseAward::query()->lockForUpdate()->findOrFail($award->id);
// An already committed issue is returned, never re-created or re-snapshotted.
@@ -281,7 +281,7 @@ class PurchaseWorkflow
}
foreach ($steps as $index => $step) {
$user = User::query()->where('tenant_id', $requisition->tenant_id)->lockForUpdate()->find($step->approver_id);
- if (! $user || $step->decision !== 'approved' || $step->decided_at === null || $step->role !== $roles[$index] || $user->purchasing_role->value !== $step->role ||
+ if (! $user || $step->decision !== 'approved' || $step->decided_at === null || $step->role !== $roles[$index] || ! $user->hasFunctions([$step->role]) ||
($step->role === 'department_head' && $user->department_id !== $requisition->department_id) || in_array($user->id, $seen, true)) {
throw ValidationException::withMessages(['approvals' => 'As aprovações do valor final devem continuar elegíveis e ser de pessoas distintas.']);
}
diff --git a/app/Services/RequisitionWorkflow.php b/app/Services/RequisitionWorkflow.php
index 70537e6..cad6a71 100644
--- a/app/Services/RequisitionWorkflow.php
+++ b/app/Services/RequisitionWorkflow.php
@@ -20,6 +20,7 @@ class RequisitionWorkflow
return DB::transaction(function () use ($user, $data, $existing) {
app(ConflictGuard::class)->lockCompany($user);
$user = app(CurrentActor::class)->lock($user);
+ abort_unless(app(OrganizationAccess::class)->canRequest($user), 403);
if (! $user->department_id || ! CostCenter::query()->whereKey($data['cost_center_id'])->where('department_id', $user->department_id)->exists()) {
throw ValidationException::withMessages(['cost_center_id' => 'O departamento ou centro de custo mudou. Actualize o pedido antes de guardar.']);
}
@@ -76,6 +77,7 @@ class RequisitionWorkflow
DB::transaction(function () use ($user, $requisition) {
app(ConflictGuard::class)->lockCompany($user);
$user = app(CurrentActor::class)->lock($user);
+ abort_unless(app(OrganizationAccess::class)->canRequest($user), 403);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
abort_unless($requisition->requester_id === $user->id && $requisition->status === 'draft', 403);
if ($user->department_id !== $requisition->department_id) {
diff --git a/app/Services/StockWorkflow.php b/app/Services/StockWorkflow.php
index e2195d9..001791c 100644
--- a/app/Services/StockWorkflow.php
+++ b/app/Services/StockWorkflow.php
@@ -27,7 +27,7 @@ class StockWorkflow
/** @param array{kind: string, reference: string, idempotency_key: string, responsible_id: int, warehouse_id: int, stock_item_id: int, destination_warehouse_id?: int, quantity: string, reason: string, destination?: string, count_id?: int, ledger_version?: int, equipment_id?: int} $data */
public function record(User $actor, array $data): StockOperation
{
- abort_unless($actor->tenant_id !== null && $actor->purchasing_role === PurchasingRole::Warehouse && auth()->id() === $actor->id, 403);
+ abort_unless($actor->tenant_id !== null && $actor->hasFunctions([PurchasingRole::Warehouse]) && auth()->id() === $actor->id, 403);
return DB::transaction(function () use ($actor, $data) {
// Same first lock as receipts: includes empty balances and concurrent receipts.
diff --git a/app/Services/SupplierBankWorkflow.php b/app/Services/SupplierBankWorkflow.php
index a88bbef..9bd964f 100644
--- a/app/Services/SupplierBankWorkflow.php
+++ b/app/Services/SupplierBankWorkflow.php
@@ -24,7 +24,7 @@ class SupplierBankWorkflow
public function propose(User $user, Supplier $supplier, array $data): SupplierBankDetail
{
$this->actor($user, $supplier);
- abort_unless($user->purchasing_role === PurchasingRole::Procurement, 403);
+ abort_unless($user->hasFunctions([PurchasingRole::Procurement]), 403);
return DB::transaction(function () use ($user, $supplier, $data) {
app(ConflictGuard::class)->lockCompany($user);
@@ -44,7 +44,7 @@ class SupplierBankWorkflow
public function review(User $user, SupplierBankDetail $detail, string $decision, string $reason): void
{
- abort_unless($user->purchasing_role === PurchasingRole::Finance && $detail->tenant_id === $user->tenant_id && $detail->created_by !== $user->id, 403);
+ abort_unless($user->hasFunctions([PurchasingRole::Finance]) && $detail->tenant_id === $user->tenant_id && $detail->created_by !== $user->id, 403);
if (! in_array($decision, ['approved', 'rejected']) || trim($reason) === '') {
throw ValidationException::withMessages(['bank' => 'Decisão e fundamento obrigatórios.']);
}
@@ -92,7 +92,7 @@ class SupplierBankWorkflow
}
foreach ($reviews as $review) {
$user = User::query()->where('tenant_id', $supplier->tenant_id)->find($review->reviewed_by);
- if (! $user || $user->purchasing_role !== PurchasingRole::Finance || $user->id === $detail->created_by || app(ConflictGuard::class)->hasConflict($user->id, $supplier->id, null)) {
+ if (! $user || ! $user->hasFunctions([PurchasingRole::Finance]) || $user->id === $detail->created_by || app(ConflictGuard::class)->hasConflict($user->id, $supplier->id, null)) {
return 'blocked';
}
}
diff --git a/app/Services/SupplierEvaluationWorkflow.php b/app/Services/SupplierEvaluationWorkflow.php
new file mode 100644
index 0000000..95b8a86
--- /dev/null
+++ b/app/Services/SupplierEvaluationWorkflow.php
@@ -0,0 +1,235 @@
+id() === $actor->id && $actor->tenant_id !== null, 403);
+ $fresh = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($actor->id);
+ abort_unless($fresh->hasFunctions(self::ROLES), 403);
+
+ return $fresh;
+ }
+
+ /**
+ * @param array $payload
+ * @return array
+ */
+ private function normalise(User $actor, Supplier $supplier, array $payload): array
+ {
+ $orders = array_values(array_unique(array_map('intval', $payload['order_ids'] ?? [])));
+ sort($orders);
+ $payload = ['title' => $payload['title'], 'nature' => $payload['nature'], 'scope' => $payload['scope'] ?? '',
+ 'period_start' => $payload['period_start'] ?? null, 'period_end' => $payload['period_end'] ?? null,
+ 'order_ids' => $orders,
+ 'criteria' => array_map(fn ($row) => ['label' => $row['label'] ?? '', 'result' => $row['result'], 'reason' => $row['reason'] ?? '', 'evidence' => $row['evidence'] ?? ''], $payload['criteria']),
+ 'conclusion' => $payload['conclusion'] ?? '', 'critical_occurrences' => $payload['critical_occurrences'] ?? '', 'critical_evidence' => $payload['critical_evidence'] ?? ''];
+ if ($payload['period_start'] && $payload['period_end'] && $payload['period_end'] < $payload['period_start']) {
+ throw ValidationException::withMessages(['payload.period_end' => 'O fim do período deve ser igual ou posterior ao início.']);
+ }
+ $orders = PurchaseOrder::query()->visibleTo($actor)->where('supplier_id', $supplier->id)->whereIn('id', $payload['order_ids'])->count();
+ if ($orders !== count($payload['order_ids'])) {
+ throw ValidationException::withMessages(['payload.order_ids' => 'Escolha apenas ordens autorizadas deste fornecedor.']);
+ }
+ foreach (PurchaseOrder::query()->whereIn('id', $payload['order_ids'])->pluck('requisition_id') as $requisitionId) {
+ abort_if(app(ConflictGuard::class)->hasConflict($actor->id, $supplier->id, (int) $requisitionId), 403, 'Existe uma declaração de conflito num dos processos abrangidos.');
+ }
+
+ return $payload;
+ }
+
+ /** @param array $payload */
+ private function department(User $actor, array $payload): ?int
+ {
+ if (! $payload['order_ids']) {
+ return $actor->department_id;
+ }
+ $departments = Requisition::query()->whereIn('id', PurchaseOrder::query()->whereIn('id', $payload['order_ids'])->select('requisition_id'))->distinct()->pluck('department_id');
+
+ return $departments->count() === 1 ? (int) $departments->first() : null;
+ }
+
+ private function actor(User $actor): User
+ {
+ app(ConflictGuard::class)->lockCompany($actor);
+
+ return app(CurrentActor::class)->lock($actor, self::ROLES);
+ }
+
+ private function participating(User $actor, int $supplierId): void
+ {
+ abort_if(app(ConflictGuard::class)->hasConflict($actor->id, $supplierId, null), 403, 'Existe uma declaração de conflito com este fornecedor.');
+ }
+
+ /** @return array */
+ public function snapshot(SupplierEvaluation $evaluation): array
+ {
+ return ['evaluation_id' => $evaluation->id, ...$evaluation->only(['id', 'supplier_id', 'department_id', 'created_by', 'submitted_by', 'reviewed_by', 'previous_id', 'revision', 'version', 'status', 'payload', 'supplier_snapshot', 'contributors', 'review_reason', 'reviewed_at', 'created_at', 'updated_at'])];
+ }
+
+ /** @param array|null $before */
+ private function audit(User $actor, string $action, SupplierEvaluation $evaluation, ?array $before): void
+ {
+ app(RequisitionWorkflow::class)->audit($actor, 'supplier_evaluation.'.$action, null, $before, $this->snapshot($evaluation));
+ }
+
+ /** @param array $payload */
+ public function create(User $actor, int $supplierId, string $operation, array $payload): SupplierEvaluation
+ {
+ return DB::transaction(function () use ($actor, $supplierId, $operation, $payload) {
+ $actor = $this->actor($actor);
+ abort_if($actor->departmentScoped() && $actor->department_id === null, 403, 'O perfil de chefia precisa do departamento correspondente.');
+ $supplier = Supplier::query()->findOrFail($supplierId);
+ $this->participating($actor, $supplier->id);
+ $payload = $this->normalise($actor, $supplier, $payload);
+ $hash = hash('sha256', json_encode([$supplierId, $payload], JSON_THROW_ON_ERROR));
+ $existing = SupplierEvaluation::query()->where('operation_key', $operation)->first();
+ if ($existing) {
+ abort_unless($existing->created_by === $actor->id && $existing->creation_hash === $hash, 409, 'Pedido de criação já utilizado com outros dados.');
+
+ return $existing;
+ }
+ $evaluation = SupplierEvaluation::query()->create([
+ 'tenant_id' => $actor->tenant_id, 'supplier_id' => $supplier->id, 'department_id' => $this->department($actor, $payload),
+ 'created_by' => $actor->id, 'operation_key' => $operation, 'creation_hash' => $hash,
+ 'supplier_snapshot' => $supplier->only(['id', 'name', 'nuit']), 'contributors' => [$actor->id], 'payload' => $payload,
+ ]);
+ $this->audit($actor, 'created', $evaluation, null);
+
+ return $evaluation;
+ });
+ }
+
+ /** @param array $payload */
+ public function save(User $actor, int $id, int $version, array $payload): SupplierEvaluation
+ {
+ return DB::transaction(function () use ($actor, $id, $version, $payload) {
+ $actor = $this->actor($actor);
+ $evaluation = $this->locked($actor, $id, $version);
+ $this->participating($actor, $evaluation->supplier_id);
+ abort_unless(in_array($evaluation->status, ['draft', 'returned'], true), 409, 'A avaliação não está aberta para edição.');
+ $payload = $this->normalise($actor, Supplier::query()->findOrFail($evaluation->supplier_id), $payload);
+ if ($payload === $evaluation->payload) {
+ return $evaluation;
+ }
+ $before = $this->snapshot($evaluation);
+ $evaluation->fill(['payload' => $payload, 'department_id' => $this->department($actor, $payload), 'contributors' => array_values(array_unique([...$evaluation->contributors, $actor->id])),
+ 'version' => $version + 1, 'status' => 'draft', 'submitted_by' => null, 'reviewed_by' => null, 'reviewed_at' => null, 'review_reason' => null])->save();
+ $this->audit($actor, 'updated', $evaluation, $before);
+
+ return $evaluation;
+ });
+ }
+
+ private function locked(User $actor, int $id, int $version): SupplierEvaluation
+ {
+ $evaluation = SupplierEvaluation::query()->visibleTo($actor)->lockForUpdate()->findOrFail($id);
+ if ($evaluation->version !== $version) {
+ throw ValidationException::withMessages(['version' => 'A avaliação foi alterada. Os seus dados não foram sobrepostos; consulte a versão actual antes de comparar.']);
+ }
+
+ return $evaluation;
+ }
+
+ /** @param array $payload */
+ private function complete(array $payload, bool $approving): void
+ {
+ $errors = [];
+ foreach (['scope' => 'Descreva o âmbito avaliado.', 'period_start' => 'Indique o início do período.', 'period_end' => 'Indique o fim do período.', 'conclusion' => 'Registe a conclusão.'] as $key => $message) {
+ if (empty($payload[$key])) {
+ $errors['payload.'.$key] = $message;
+ }
+ }
+ if (! $payload['criteria']) {
+ $errors['payload.criteria'] = 'Indique pelo menos um critério e a situação da evidência.';
+ }
+ foreach ($payload['criteria'] as $index => $row) {
+ if (trim($row['label']) === '' || trim($row['reason']) === '') {
+ $errors['payload.criteria.'.$index.'.reason'] = 'Identifique e justifique cada critério.';
+ }
+ if ($row['result'] === 'observed' && trim($row['evidence']) === '') {
+ $errors['payload.criteria.'.$index.'.evidence'] = 'A observação precisa de uma referência de evidência.';
+ }
+ if ($approving && in_array($row['result'], ['pending', 'insufficient'], true)) {
+ $errors['payload.criteria.'.$index.'.result'] = 'Complete as evidências em falta ou devolva a avaliação para correcção.';
+ }
+ }
+ if ($payload['critical_occurrences'] !== '' && trim($payload['critical_evidence']) === '') {
+ $errors['payload.critical_evidence'] = 'Indique a evidência das ocorrências críticas.';
+ }
+ if ($errors) {
+ throw ValidationException::withMessages($errors);
+ }
+ }
+
+ public function transition(User $actor, int $id, int $version, string $action, ?string $reason): SupplierEvaluation
+ {
+ return DB::transaction(function () use ($actor, $id, $version, $action, $reason) {
+ $actor = $this->actor($actor);
+ $evaluation = $this->locked($actor, $id, $version);
+ $this->participating($actor, $evaluation->supplier_id);
+ $before = $this->snapshot($evaluation);
+ $this->normalise($actor, Supplier::query()->findOrFail($evaluation->supplier_id), $evaluation->payload);
+ if ($action === 'submit') {
+ abort_unless(in_array($evaluation->status, ['draft', 'returned'], true), 409);
+ $this->complete($evaluation->payload, false);
+ $evaluation->fill(['status' => 'review', 'submitted_by' => $actor->id, 'contributors' => array_values(array_unique([...$evaluation->contributors, $actor->id])), 'reviewed_by' => null, 'reviewed_at' => null, 'review_reason' => null]);
+ } else {
+ abort_unless(in_array($action, ['approve', 'return'], true) && $evaluation->status === 'review', 409);
+ if ($reason === null || mb_strlen(trim($reason)) < 10) {
+ throw ValidationException::withMessages(['reason' => 'Fundamente a decisão de revisão.']);
+ }
+ abort_if(in_array($actor->id, $evaluation->contributors, true), 403, 'A conferência deve ser realizada por outra pessoa que não elaborou esta revisão.');
+ if ($action === 'approve') {
+ $this->complete($evaluation->payload, true);
+ }
+ $evaluation->fill(['status' => $action === 'approve' ? 'approved' : 'returned', 'reviewed_by' => $actor->id, 'reviewed_at' => now(), 'review_reason' => $reason]);
+ }
+ $evaluation->version = $version + 1;
+ $evaluation->save();
+ $this->audit($actor, $action, $evaluation, $before);
+
+ return $evaluation;
+ });
+ }
+
+ public function revise(User $actor, int $id, int $version): SupplierEvaluation
+ {
+ return DB::transaction(function () use ($actor, $id, $version) {
+ $actor = $this->actor($actor);
+ $previous = $this->locked($actor, $id, $version);
+ $this->participating($actor, $previous->supplier_id);
+ $this->normalise($actor, Supplier::query()->findOrFail($previous->supplier_id), $previous->payload);
+ abort_unless($previous->status === 'approved', 409);
+ $child = SupplierEvaluation::query()->visibleTo($actor)->where('previous_id', $id)->first();
+ if ($child) {
+ abort_unless($child->created_by === $actor->id, 409, 'Já existe uma revisão posterior; consulte o histórico.');
+
+ return $child;
+ }
+ $evaluation = SupplierEvaluation::query()->create([
+ 'tenant_id' => $actor->tenant_id, 'supplier_id' => $previous->supplier_id, 'department_id' => $previous->department_id,
+ 'created_by' => $actor->id, 'previous_id' => $id, 'revision' => $previous->revision + 1,
+ 'operation_key' => (string) Str::uuid(), 'creation_hash' => hash('sha256', 'revision:'.$id.':'.$version),
+ 'contributors' => [$actor->id], 'supplier_snapshot' => $previous->supplier_snapshot, 'payload' => $previous->payload,
+ ]);
+ $this->audit($actor, 'revised', $evaluation, null);
+
+ return $evaluation;
+ });
+ }
+}
diff --git a/app/Services/SupplierImprovementWorkflow.php b/app/Services/SupplierImprovementWorkflow.php
new file mode 100644
index 0000000..bc877fa
--- /dev/null
+++ b/app/Services/SupplierImprovementWorkflow.php
@@ -0,0 +1,120 @@
+hasConflict($actor->id, $evaluation->supplier_id, null)) {
+ return false;
+ }
+ foreach (PurchaseOrder::query()->whereIn('id', $evaluation->payload['order_ids'])->pluck('requisition_id') as $id) {
+ if ($guard->hasConflict($actor->id, $evaluation->supplier_id, (int) $id)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /** @return array */
+ public function snapshot(SupplierImprovement $measure): array
+ {
+ return $measure->only(['id', 'supplier_evaluation_id', 'created_by', 'responsible_id', 'title', 'description', 'due_on', 'version', 'status', 'contributors', 'result', 'evidence', 'reviewed_by', 'review_reason', 'reviewed_at', 'created_at', 'updated_at']);
+ }
+
+ private function assignee(User $actor, SupplierEvaluation $evaluation, int $id): User
+ {
+ $user = User::query()->where('tenant_id', $actor->tenant_id)->lockForUpdate()->find($id);
+ if (! $user || ! $user->hasFunctions(SupplierEvaluationWorkflow::ROLES)
+ || ($user->departmentScoped() && ($user->department_id === null || $user->department_id !== $evaluation->department_id))
+ || ! $this->canParticipate($user, $evaluation)) {
+ throw ValidationException::withMessages(['responsible_id' => 'Escolha um responsável autorizado desta empresa, sem conflito no âmbito avaliado.']);
+ }
+
+ return $user;
+ }
+
+ /** @param array $data */
+ public function execute(User $actor, int $evaluationId, ?int $id, string $command, array $data): SupplierImprovement
+ {
+ return DB::transaction(function () use ($actor, $evaluationId, $id, $command, $data) {
+ app(ConflictGuard::class)->lockCompany($actor);
+ $actor = app(CurrentActor::class)->lock($actor, SupplierEvaluationWorkflow::ROLES);
+ $evaluation = SupplierEvaluation::query()->visibleTo($actor)->lockForUpdate()->findOrFail($evaluationId);
+ abort_unless($evaluation->status === 'approved', 409, 'Associe medidas à revisão aprovada da avaliação.');
+ abort_unless($this->canParticipate($actor, $evaluation), 403, 'Existe um conflito no âmbito desta avaliação.');
+ if ($command === 'create') {
+ $plan = ['title' => $data['title'], 'description' => $data['description'], 'responsible_id' => (int) $data['responsible_id'], 'due_on' => $data['due_on']];
+ $hash = hash('sha256', json_encode([$evaluationId, $plan], JSON_THROW_ON_ERROR));
+ $existing = SupplierImprovement::query()->where('operation_key', $data['operation_key'])->first();
+ if ($existing) {
+ abort_unless($existing->created_by === $actor->id && $existing->creation_hash === $hash, 409, 'Pedido de criação já utilizado.');
+
+ return $existing;
+ }
+ $responsible = $this->assignee($actor, $evaluation, $plan['responsible_id']);
+ $measure = SupplierImprovement::query()->create([...$plan, 'tenant_id' => $actor->tenant_id, 'supplier_evaluation_id' => $evaluationId,
+ 'created_by' => $actor->id, 'operation_key' => $data['operation_key'], 'creation_hash' => $hash,
+ 'contributors' => array_values(array_unique([$actor->id, $responsible->id]))]);
+ $before = null;
+ } else {
+ $measure = SupplierImprovement::query()->where('supplier_evaluation_id', $evaluationId)->lockForUpdate()->findOrFail($id);
+ if ($measure->version !== (int) $data['version']) {
+ throw ValidationException::withMessages(['version' => 'A medida foi alterada. Consulte a versão actual antes de comparar; os seus dados não foram sobrepostos.']);
+ }
+ abort_if($measure->status === 'closed', 409, 'A medida já está encerrada e preservada.');
+ $before = $this->snapshot($measure);
+ if ($command === 'plan') {
+ abort_unless(in_array($measure->status, ['open', 'returned'], true), 409);
+ $responsible = $this->assignee($actor, $evaluation, (int) $data['responsible_id']);
+ $measure->fill(['title' => $data['title'], 'description' => $data['description'], 'responsible_id' => $responsible->id, 'due_on' => $data['due_on'],
+ 'contributors' => array_values(array_unique([...$measure->contributors, $actor->id, $responsible->id]))]);
+ if (! $measure->isDirty()) {
+ return $measure;
+ }
+ if (mb_strlen(trim($data['reason'] ?? '')) < 10) {
+ throw ValidationException::withMessages(['reason' => 'Justifique a alteração do plano, prazo ou responsável.']);
+ }
+ } elseif (in_array($command, ['progress', 'submit'], true)) {
+ abort_unless($actor->id === $measure->responsible_id && in_array($measure->status, ['open', 'returned'], true), 403);
+ $measure->fill(['result' => $data['result'] ?? '', 'evidence' => $data['evidence'] ?? '']);
+ if ($command === 'submit') {
+ if (trim($measure->result ?? '') === '' || trim($measure->evidence ?? '') === '') {
+ throw ValidationException::withMessages(['evidence' => 'Registe resultado e referência da evidência antes de pedir encerramento.']);
+ }
+ $measure->status = 'review';
+ }
+ if (! $measure->isDirty()) {
+ return $measure;
+ }
+ } else {
+ abort_unless(in_array($command, ['close', 'return'], true) && $measure->status === 'review', 409);
+ abort_if(in_array($actor->id, $measure->contributors, true), 403, 'O encerramento exige outra pessoa, independente da preparação e execução.');
+ if (mb_strlen(trim($data['reason'] ?? '')) < 10) {
+ throw ValidationException::withMessages(['reason' => 'Fundamente a decisão sobre a execução.']);
+ }
+ $measure->fill(['status' => $command === 'close' ? 'closed' : 'returned', 'reviewed_by' => $actor->id, 'reviewed_at' => now(), 'review_reason' => $data['reason']]);
+ }
+ $measure->version++;
+ $measure->save();
+ }
+ $after = $this->snapshot($measure);
+ if ($command === 'plan') {
+ $after['change_reason'] = $data['reason'];
+ }
+ app(RequisitionWorkflow::class)->audit($actor, 'supplier_improvement.'.$command, null, $before, $after);
+
+ return $measure;
+ });
+ }
+}
diff --git a/app/Services/SupplierPayables.php b/app/Services/SupplierPayables.php
index db698a0..4d830ae 100644
--- a/app/Services/SupplierPayables.php
+++ b/app/Services/SupplierPayables.php
@@ -29,6 +29,10 @@ final class SupplierPayables
public function can(User $u, string $action): bool
{
+ if (app(OrganizationAccess::class)->active($u->tenant_id) && ! $u->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance])) {
+ return false;
+ }
+
return self::enabled() && DB::table('pa_access')->where($this->scope($u))->where(['user_id' => $u->id, 'action' => $action])->exists();
}
diff --git a/app/Services/SupplierResponseWorkflow.php b/app/Services/SupplierResponseWorkflow.php
new file mode 100644
index 0000000..1cd88f4
--- /dev/null
+++ b/app/Services/SupplierResponseWorkflow.php
@@ -0,0 +1,100 @@
+ */
+ public function snapshot(SupplierResponse $record): array
+ {
+ return $record->only(['id', 'supplier_evaluation_id', 'created_by', 'version', 'status', 'payload', 'contributors', 'decision', 'reviewed_by', 'review_reason', 'reviewed_at', 'created_at', 'updated_at']);
+ }
+
+ /** @param array $payload
+ * @return array
+ */
+ private function normalise(array $payload): array
+ {
+ return ['title' => $payload['title'], 'received_on' => $payload['received_on'] ?? null, 'source_reference' => $payload['source_reference'] ?? '', 'statement' => $payload['statement'] ?? ''];
+ }
+
+ /** @param array $data */
+ public function execute(User $actor, int $evaluationId, ?int $id, string $command, array $data): SupplierResponse
+ {
+ return DB::transaction(function () use ($actor, $evaluationId, $id, $command, $data) {
+ app(ConflictGuard::class)->lockCompany($actor);
+ $actor = app(CurrentActor::class)->lock($actor, SupplierEvaluationWorkflow::ROLES);
+ $evaluation = SupplierEvaluation::query()->visibleTo($actor)->lockForUpdate()->findOrFail($evaluationId);
+ abort_unless($evaluation->status === 'approved', 409, 'O contraditório refere-se à revisão aprovada.');
+ abort_unless(app(SupplierImprovementWorkflow::class)->canParticipate($actor, $evaluation), 403, 'Existe conflito no âmbito da avaliação.');
+ if ($command === 'create') {
+ $payload = $this->normalise($data['payload']);
+ $hash = hash('sha256', json_encode([$evaluationId, $payload], JSON_THROW_ON_ERROR));
+ $existing = SupplierResponse::query()->where('operation_key', $data['operation_key'])->first();
+ if ($existing) {
+ abort_unless($existing->created_by === $actor->id && $existing->creation_hash === $hash, 409, 'Pedido já utilizado com outro conteúdo.');
+
+ return $existing;
+ }
+ $record = SupplierResponse::query()->create(['tenant_id' => $actor->tenant_id, 'supplier_evaluation_id' => $evaluationId,
+ 'created_by' => $actor->id, 'operation_key' => $data['operation_key'], 'creation_hash' => $hash,
+ 'payload' => $payload, 'contributors' => [$actor->id]]);
+ $before = null;
+ } else {
+ $record = SupplierResponse::query()->where('supplier_evaluation_id', $evaluationId)->lockForUpdate()->findOrFail($id);
+ if ($record->version !== (int) $data['version']) {
+ throw ValidationException::withMessages(['version' => 'O registo foi alterado. Consulte a versão actual antes de comparar; os seus dados não foram sobrepostos.']);
+ }
+ abort_if($record->status === 'decided', 409, 'A conclusão já está registada e preservada.');
+ $before = $this->snapshot($record);
+ if ($command === 'save') {
+ abort_unless(in_array($record->status, ['draft', 'returned'], true), 409);
+ $payload = $this->normalise($data['payload']);
+ if ($payload === $record->payload) {
+ return $record;
+ }
+ $record->fill(['payload' => $payload, 'contributors' => array_values(array_unique([...$record->contributors, $actor->id]))]);
+ } elseif ($command === 'submit') {
+ abort_unless(in_array($record->status, ['draft', 'returned'], true), 409);
+ $errors = [];
+ foreach (['received_on', 'source_reference', 'statement'] as $key) {
+ if (trim($record->payload[$key] ?? '') === '') {
+ $errors['payload.'.$key] = 'Complete a data, a origem documental e os argumentos recebidos antes de submeter.';
+ }
+ }
+ if (($record->payload['received_on'] ?? '') > today()->toDateString()) {
+ $errors['payload.received_on'] = 'A data de recepção não pode ser futura.';
+ }
+ if ($errors) {
+ throw ValidationException::withMessages($errors);
+ }
+ $record->fill(['status' => 'review', 'contributors' => array_values(array_unique([...$record->contributors, $actor->id]))]);
+ } else {
+ abort_unless(in_array($command, ['return', 'decide'], true) && $record->status === 'review', 409);
+ abort_if(in_array($actor->id, $record->contributors, true), 403, 'A análise deve ser concluída por outra pessoa que não preparou o registo.');
+ if (mb_strlen(trim($data['reason'] ?? '')) < 10) {
+ throw ValidationException::withMessages(['reason' => 'Fundamente a análise ou a devolução.']);
+ }
+ if ($command === 'decide') {
+ if (in_array($data['outcome'], ['accepted', 'partially_accepted'], true) && mb_strlen(trim($data['follow_up'] ?? '')) < 10) {
+ throw ValidationException::withMessages(['follow_up' => 'Descreva como será dado seguimento ao acolhimento dos argumentos.']);
+ }
+ $record->decision = ['outcome' => $data['outcome'], 'reason' => $data['reason'], 'follow_up' => $data['follow_up'] ?? '', 'response_reference' => $data['response_reference'] ?? ''];
+ }
+ $record->fill(['status' => $command === 'decide' ? 'decided' : 'returned', 'reviewed_by' => $actor->id, 'reviewed_at' => now(), 'review_reason' => $data['reason']]);
+ }
+ $record->version++;
+ $record->save();
+ }
+ app(RequisitionWorkflow::class)->audit($actor, 'supplier_response.'.$command, null, $before, $this->snapshot($record));
+
+ return $record;
+ });
+ }
+}
diff --git a/bootstrap/app.php b/bootstrap/app.php
index 2bfa639..8230a7d 100644
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -2,6 +2,7 @@
use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests;
+use App\Services\EvaluationNavigation;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
@@ -15,6 +16,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->encryptCookies(except: ['appearance', 'sidebar_state']);
$middleware->web(append: [
@@ -24,7 +26,7 @@ return Application::configure(basePath: dirname(__DIR__))
]);
})
->withExceptions(function (Exceptions $exceptions): void {
- $exceptions->dontFlash(['account_identifier', 'beneficiary', 'external_reference', 'evidence_reference', 'reason']);
+ $exceptions->dontFlash(['account_identifier', 'beneficiary', 'external_reference', 'evidence_reference', 'reason', 'second_password', 'second_password_confirmation']);
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
);
diff --git a/composer.json b/composer.json
index 4e7400e..bd586d5 100644
--- a/composer.json
+++ b/composer.json
@@ -43,6 +43,7 @@
}
},
"scripts": {
+ "runtime:check": "@php scripts/check-runtime-packages.php",
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
@@ -67,6 +68,7 @@
],
"ci:check": [
"Composer\\Config::disableProcessTimeout",
+ "@runtime:check",
"npm run check",
"npm run types:check",
"@test"
diff --git a/database/migrations/2026_10_30_000024_create_supplier_evaluations.php b/database/migrations/2026_10_30_000024_create_supplier_evaluations.php
new file mode 100644
index 0000000..162d2bb
--- /dev/null
+++ b/database/migrations/2026_10_30_000024_create_supplier_evaluations.php
@@ -0,0 +1,50 @@
+id();
+ $table->foreignId('tenant_id')->constrained()->restrictOnDelete();
+ $table->foreignId('supplier_id')->constrained()->restrictOnDelete();
+ $table->foreignId('department_id')->nullable()->constrained()->restrictOnDelete();
+ $table->foreignId('created_by')->constrained('users')->restrictOnDelete();
+ $table->foreignId('submitted_by')->nullable()->constrained('users')->restrictOnDelete();
+ $table->foreignId('reviewed_by')->nullable()->constrained('users')->restrictOnDelete();
+ $table->foreignId('previous_id')->nullable()->unique()->constrained('supplier_evaluations')->restrictOnDelete();
+ $table->uuid('operation_key');
+ $table->string('creation_hash', 64);
+ $table->unsignedInteger('revision')->default(1);
+ $table->unsignedInteger('version')->default(1);
+ $table->string('status')->default('draft');
+ $table->json('contributors');
+ $table->json('supplier_snapshot');
+ $table->json('payload');
+ $table->text('review_reason')->nullable();
+ $table->timestamp('reviewed_at')->nullable();
+ $table->timestamps();
+ $table->unique(['tenant_id', 'operation_key']);
+ $table->index(['tenant_id', 'supplier_id', 'status']);
+ });
+ if (DB::getDriverName() === 'pgsql') {
+ DB::unprepared("CREATE OR REPLACE FUNCTION sgc_preserve_supplier_evaluation() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
+ IF TG_OP = 'DELETE' OR OLD.status = 'approved' THEN RAISE EXCEPTION 'Preservar avaliação aprovada e histórico'; END IF;
+ RETURN NEW; END $$;
+ CREATE TRIGGER preserve_supplier_evaluation BEFORE UPDATE OR DELETE ON supplier_evaluations FOR EACH ROW EXECUTE FUNCTION sgc_preserve_supplier_evaluation();");
+ } elseif (DB::getDriverName() === 'sqlite') {
+ DB::unprepared("CREATE TRIGGER preserve_supplier_evaluation_delete BEFORE DELETE ON supplier_evaluations BEGIN SELECT RAISE(ABORT, 'Preservar histórico de avaliação'); END;");
+ DB::unprepared("CREATE TRIGGER preserve_supplier_evaluation_approved BEFORE UPDATE ON supplier_evaluations WHEN OLD.status = 'approved' BEGIN SELECT RAISE(ABORT, 'Preservar avaliação aprovada'); END;");
+ }
+ }
+
+ public function down(): void
+ {
+ throw new LogicException('Preservar avaliações, decisões e histórico.');
+ }
+};
diff --git a/database/migrations/2026_10_30_000025_create_supplier_improvements.php b/database/migrations/2026_10_30_000025_create_supplier_improvements.php
new file mode 100644
index 0000000..d5309a8
--- /dev/null
+++ b/database/migrations/2026_10_30_000025_create_supplier_improvements.php
@@ -0,0 +1,50 @@
+id();
+ $table->foreignId('tenant_id')->constrained()->restrictOnDelete();
+ $table->foreignId('supplier_evaluation_id')->constrained()->restrictOnDelete();
+ $table->foreignId('created_by')->constrained('users')->restrictOnDelete();
+ $table->foreignId('responsible_id')->constrained('users')->restrictOnDelete();
+ $table->uuid('operation_key');
+ $table->string('creation_hash', 64);
+ $table->string('title', 200);
+ $table->text('description');
+ $table->date('due_on');
+ $table->unsignedInteger('version')->default(1);
+ $table->string('status')->default('open');
+ $table->json('contributors');
+ $table->text('result')->nullable();
+ $table->text('evidence')->nullable();
+ $table->foreignId('reviewed_by')->nullable()->constrained('users')->restrictOnDelete();
+ $table->text('review_reason')->nullable();
+ $table->timestamp('reviewed_at')->nullable();
+ $table->timestamps();
+ $table->unique(['tenant_id', 'operation_key']);
+ $table->index(['supplier_evaluation_id', 'status']);
+ });
+ if (DB::getDriverName() === 'pgsql') {
+ DB::unprepared("CREATE FUNCTION sgc_preserve_supplier_improvement() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
+ IF TG_OP = 'DELETE' OR OLD.status = 'closed' THEN RAISE EXCEPTION 'Preservar medida encerrada e histórico'; END IF;
+ RETURN NEW; END $$;
+ CREATE TRIGGER preserve_supplier_improvement BEFORE UPDATE OR DELETE ON supplier_improvements FOR EACH ROW EXECUTE FUNCTION sgc_preserve_supplier_improvement();");
+ } elseif (DB::getDriverName() === 'sqlite') {
+ DB::unprepared("CREATE TRIGGER preserve_supplier_improvement_delete BEFORE DELETE ON supplier_improvements BEGIN SELECT RAISE(ABORT, 'Preservar histórico da medida'); END;");
+ DB::unprepared("CREATE TRIGGER preserve_supplier_improvement_closed BEFORE UPDATE ON supplier_improvements WHEN OLD.status = 'closed' BEGIN SELECT RAISE(ABORT, 'Preservar medida encerrada'); END;");
+ }
+ }
+
+ public function down(): void
+ {
+ throw new LogicException('Preservar medidas e decisões.');
+ }
+};
diff --git a/database/migrations/2026_10_30_000026_create_supplier_responses.php b/database/migrations/2026_10_30_000026_create_supplier_responses.php
new file mode 100644
index 0000000..a102c13
--- /dev/null
+++ b/database/migrations/2026_10_30_000026_create_supplier_responses.php
@@ -0,0 +1,46 @@
+id();
+ $table->foreignId('tenant_id')->constrained()->restrictOnDelete();
+ $table->foreignId('supplier_evaluation_id')->constrained()->restrictOnDelete();
+ $table->foreignId('created_by')->constrained('users')->restrictOnDelete();
+ $table->uuid('operation_key');
+ $table->string('creation_hash', 64);
+ $table->unsignedInteger('version')->default(1);
+ $table->string('status')->default('draft');
+ $table->json('payload');
+ $table->json('contributors');
+ $table->json('decision')->nullable();
+ $table->foreignId('reviewed_by')->nullable()->constrained('users')->restrictOnDelete();
+ $table->text('review_reason')->nullable();
+ $table->timestamp('reviewed_at')->nullable();
+ $table->timestamps();
+ $table->unique(['tenant_id', 'operation_key']);
+ $table->index(['supplier_evaluation_id', 'status']);
+ });
+ if (DB::getDriverName() === 'pgsql') {
+ DB::unprepared("CREATE FUNCTION sgc_preserve_supplier_response() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
+ IF TG_OP = 'DELETE' OR OLD.status = 'decided' THEN RAISE EXCEPTION 'Preservar contraditorio e conclusao'; END IF;
+ RETURN NEW; END $$;
+ CREATE TRIGGER preserve_supplier_response BEFORE UPDATE OR DELETE ON supplier_responses FOR EACH ROW EXECUTE FUNCTION sgc_preserve_supplier_response();");
+ } elseif (DB::getDriverName() === 'sqlite') {
+ DB::unprepared("CREATE TRIGGER preserve_supplier_response_delete BEFORE DELETE ON supplier_responses BEGIN SELECT RAISE(ABORT, 'Preservar contraditorio'); END;");
+ DB::unprepared("CREATE TRIGGER preserve_supplier_response_decided BEFORE UPDATE ON supplier_responses WHEN OLD.status = 'decided' BEGIN SELECT RAISE(ABORT, 'Preservar conclusao'); END;");
+ }
+ }
+
+ public function down(): void
+ {
+ throw new LogicException('Preservar contraditórios e conclusões.');
+ }
+};
diff --git a/database/migrations/2026_10_30_000027_create_organization_access.php b/database/migrations/2026_10_30_000027_create_organization_access.php
new file mode 100644
index 0000000..7876fbe
--- /dev/null
+++ b/database/migrations/2026_10_30_000027_create_organization_access.php
@@ -0,0 +1,44 @@
+id();
+ $t->foreignId('tenant_id')->unique()->constrained()->restrictOnDelete();
+ $t->boolean('active')->default(false);
+ $t->unsignedInteger('version')->default(1);
+ $t->timestamps();
+ });
+ Schema::create('organization_profiles', function (Blueprint $t) {
+ $t->id();
+ $t->foreignId('tenant_id')->constrained()->restrictOnDelete();
+ $t->string('name', 120);
+ $t->json('permissions');
+ $t->unsignedInteger('version')->default(1);
+ $t->timestamps();
+ $t->unique(['tenant_id', 'name']);
+ });
+ Schema::create('organization_memberships', function (Blueprint $t) {
+ $t->id();
+ $t->foreignId('tenant_id')->constrained()->restrictOnDelete();
+ $t->foreignId('user_id')->constrained()->restrictOnDelete();
+ $t->foreignId('profile_id')->constrained('organization_profiles')->restrictOnDelete();
+ $t->boolean('maintenance')->default(false);
+ $t->timestamp('expires_at')->nullable();
+ $t->text('reason')->nullable();
+ $t->timestamps();
+ $t->unique(['tenant_id', 'user_id', 'profile_id']);
+ });
+ }
+
+ public function down(): void
+ {
+ throw new LogicException('Preservar perfis, atribuições e auditoria.');
+ }
+};
diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php
index 6b901f8..a223974 100644
--- a/database/seeders/DatabaseSeeder.php
+++ b/database/seeders/DatabaseSeeder.php
@@ -2,7 +2,6 @@
namespace Database\Seeders;
-use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
@@ -15,11 +14,7 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
- // User::factory(10)->create();
-
- User::factory()->create([
- 'name' => 'Test User',
- 'email' => 'test@example.com',
- ]);
+ // Product installation creates its named administrators through company setup.
+ // Synthetic accounts belong only to explicitly isolated test/evaluation fixtures.
}
}
diff --git a/docs/desenvolvimento.md b/docs/desenvolvimento.md
index b412eb4..ffc41c8 100644
--- a/docs/desenvolvimento.md
+++ b/docs/desenvolvimento.md
@@ -1,5 +1,109 @@
# Desenvolvimento do SGC
+## Administração, dois administradores e perfis da empresa — 30 de Outubro de 2026
+
+Orientação do proprietário: Administração deve incluir parametrização; dois administradores serão definidos na configuração/entrega e poderão criar utilizadores e outorgar perfis/permissões escolhidos pela empresa. Substituir regras/perfis de desenvolvimento, conservando manutenção TEMPO. Implementado menu **Administração → Parametrização da aplicação**, `/administracao/parametrizacao`, com perfis de nomes próprios, combinação de permissões funcionais, múltiplos perfis por utilizador, criação de contas nominativas e atribuição/revogação justificada. Configuração versionada, isolada por empresa e auditada; alterações concorrentes antigas recusadas. Uma alteração não pode reduzir o conjunto a menos de dois administradores permanentes, incluindo por edição de perfil ou expiração de uma atribuição.
+
+Novas empresas: a conta que configura a empresa e uma segunda pessoa identificada formam os dois administradores iniciais, com contas distintas e palavras-passe normais. Limites de aprovação e aplicação sobre valor com/sem imposto são expressamente preenchidos pela empresa, em valores decimais MZN convertidos exactamente para centavos; não entram como regras provisórias. O seeder normal deixa de criar `Test User`/`test@example.com` ou outra conta de exemplo. Os dois administradores iniciais recebem um perfil configurável da empresa. O campo antigo de perfil fica com marcador `configured`, sem autoridade própria; a validação usa as atribuições efectivas. O marcador não é um perfil concedível nem uma permissão funcional. Contas preparadas antes da activação não recebem poderes implícitos de desenvolvimento.
+
+Empresas existentes: configuração em preparação conserva o funcionamento anterior até activação explícita, após definir as regras e associar pelo menos dois administradores. A página explica o efeito sobre utilizadores sem atribuições. Após activação, os perfis antigos deixam de autorizar; nomes/permissões dos perfis da empresa e respectivas vigências são a fonte efectiva. Não há comando de retorno silencioso ao modo anterior. Não foram apagadas contas, decisões, documentos ou referências históricas. Os processos já submetidos conservam as regras monetárias registadas; novos limites aplicam-se a novas submissões.
+
+Integração transversal: verificações de perfil em controladores, políticas e serviços passam pela resolução comum `OrganizationAccess`/`User::hasFunctions`, incluindo revalidação transaccional de operadores, elegibilidade de aprovação, leitura por departamento, preparação/conferência, stock, equipamentos, bancos, facturas, pagamentos e relatórios. Menus e botões usam as permissões efectivas, incluindo múltiplos perfis; preparar requisições exige a respectiva função no modo configurado. Novas snapshots de qualificação/admissão/recomendação registam a designação do perfil da empresa; snapshots anteriores permanecem intactas. As permissões específicas/identidade de pessoa do núcleo financeiro comum continuam a ser conferidas pelo núcleo, com a autorização configurada como requisito adicional; não foram inventadas identidades financeiras ou concessões desse núcleo.
+
+Manutenção TEMPO: implementada como atribuição a conta nominativa existente da empresa, autenticação normal, perfis sem administração, expiração obrigatória, revogação e auditoria das consultas. O modo temporário é somente diagnóstico/consulta; comandos de negócio e administração de acessos são recusados. Não existe rota secreta, conta global, palavra-passe universal ou autenticação contornada. O prazo termina no servidor; editar um perfil de manutenção para o transformar em administrador também é recusado. Esta é a concretização técnica do objectivo de manutenção referido como «backdoor», não uma confirmação de que foi criada uma entrada oculta. A entrega de credenciais de pessoas reais permanece no fluxo da empresa, sem palavras-passe nesta conversa.
+
+Instalação local: somente migração aditiva **000027**, com três tabelas de configuração inicialmente vazias, depois de backup fresco/restauro descartável por hashes iguais e preservação das **84 tabelas** anteriores. Avaliação 8005 disponível, ainda sem activação da nova configuração, contas criadas ou permissões alteradas. As identidades dos dois administradores da entrega serão introduzidas na configuração; não foram inferidas de perfis sintéticos. A instalação habitual/domínio não foi migrada nem publicada. Provas e resultados finais em `proofs/organization-configuration.json`, `organization-install.json`, `organization-pg.json`, `organization-access-http.json` e `menu-code-contract.json`.
+
+Verificação final: suíte integral com **603 testes: 601 aprovados, dois ignorados já existentes e 5 157 asserções**, sem falhas; ensaio próprio de parametrização em PostgreSQL descartável com **13 testes/96 asserções**, incluindo criação dos dois administradores, perfis efectivos, ausência de direitos implícitos antes da activação, mínimo de dois administradores, revogação/expiração da manutenção, isolamento, concorrência, rollback de auditoria, regras monetárias exactas e seeder sem contas sintéticas. A base descartável foi removida, sem alteração de privilégios do role reutilizado. PHPStan sem erros, TypeScript, lint/formatação dirigidos, Pint, build e `git diff --check` aprovados. Runtime: 18 ficheiros conferidos por SHA-256; pacotes fixados preservados. HTTP em 8005 conferiu os seis perfis de avaliação e a parametrização acessível apenas ao proprietário, sem retornar passwords e sem comandos de configuração. Menu: 107 entradas na matriz de compatibilidade e casos adicionais de permissões combinadas; catálogo: 23 páginas. Visual/impressão continuam sem execução nesta entrega.
+
+Âmbito e evolução: esta entrega introduz a gestão efectiva de perfis por empresa e a configuração dos limites existentes. O catálogo inicial agrupa as funções operacionais actuais; o editor universal de etapas/condições, a decomposição das permissões por operação e a gestão das associações específicas do núcleo financeiro comum continuam incrementos próprios. Portanto não se declara toda a aplicação integralmente parametrizável. Fluxos de independência, integridade, histórico e isolamento continuam activos. Visual/impressão manual pendentes pela limitação do controlo de URL Windows já registada. A prioridade seguinte é ampliar a parametrização dos fluxos e das permissões mantendo esta fonte comum, em vez de voltar a listas de perfis dispersas.
+
+## Instrução permanente: aplicação integralmente parametrizável — 30 de Outubro de 2026
+
+O proprietário determinou que os utilizadores possam definir quem é quem, quando usar cada função/regra e como fazer as associações em toda a aplicação. Registado em `AGENTS.md` e na continuidade como requisito transversal dos módulos existentes e futuros. A configuração deve permitir funções/cargos e designações próprias, capacidades/permissões, pessoas/equipas/unidades, múltiplas responsabilidades, delegações/substituições, encaminhamentos, etapas, prazos, condições/limites, vigência e ligações a processos/documentos/fornecedores/relatórios, pela interface e sem alterações de código por cliente. A competência de configurar também deve poder ser atribuída/delegada de forma autorizada, evitando dependência permanente de um único perfil fixo.
+
+Conferência inicial do estado actual: `PurchasingRole` contém seis perfis fixos; `CompanyController` permite cadastrar departamentos, centros e pessoas escolhendo esse enum e restringe a configuração ao perfil Admin; `SupplierEvaluationWorkflow::ROLES` e os fluxos de medidas/contraditório reutilizam uma lista fixa de quatro perfis; os menus também declaram listas de perfis. A requisição já regista parâmetros empresariais numa snapshot, uma prática a preservar e generalizar. Portanto, a aplicação ainda não satisfaz a parametrização integral; a existência de alguns parâmetros de empresa não equivale ao requisito transversal agora definido.
+
+Sequência de implementação actualizada: estabelecer catálogo comum de capacidades, funções/perfis configuráveis por empresa e associações de pessoas/unidades com vigência; fornecer gestão na interface e migrar os perfis actuais como configuração inicial compatível. Depois substituir gradualmente as verificações organizacionais fixas nos controladores/serviços, menus, formulários e relatórios pela mesma resolução de capacidades/âmbito. Parametrizar condições e encaminhamentos com versões, publicação/activação validada e simulação do efeito sobre processos novos/em curso. Preservar as versões aplicadas aos processos e as decisões históricas, validar falta de responsáveis, incompatibilidades e dependências, e manter isolamento, autorização e auditoria efectivos. Não remover verificações dispersas antes de existir a substituição comum testada.
+
+Esta passagem regista a decisão e o levantamento inicial; não altera permissões, contas, processos, schema ou dados. Não declara já disponível um editor de perfis/fluxos. A base de parametrização passa à frente da extensão isolada do consolidado de contraditórios indicado no registo anterior; essas funcionalidades permanecem no âmbito do produto. Critério de entrega: a organização consegue configurar os seus intervenientes, responsabilidades, condições e associações pela interface, com aplicação coerente no servidor e nos percursos/relatórios, sem intervenção do programador para essas adaptações organizacionais.
+
+## Contraditório documentado do fornecedor — 30 de Outubro de 2026
+
+Na revisão aprovada da avaliação, implementado o registo pela equipa dos documentos/argumentos recebidos: assunto, data de recepção, referência da origem e transcrição dos argumentos. Rascunhos recuperáveis, gravação idempotente e versionada, submissão para análise, devolução para completar e conclusão por pessoa independente de todos os editores/submetentes do registo. Recepção futura ou dados incompletos impedem a submissão, sem impedir guardar o rascunho. A conclusão fundamentada pode acolher, acolher parcialmente ou não acolher os argumentos; acolhimento exige seguimento documentado. Referência da resposta preparada é opcional. Não há envio automático, comunicação externa ou autenticação do fornecedor neste incremento; a equipa regista o recebido e prepara a comunicação.
+
+Empresa/departamento, perfil recarregado e conflitos de fornecedor/processo são conferidos no servidor. Cada contraditório pertence à revisão exacta. Conteúdo anterior/posterior e decisões ficam no histórico próprio paginado, com autores e datas. Conclusões finais imutáveis no modelo e por trigger SQL; não há apagamento. O relatório individual inclui o contraditório como acompanhamento datado, sem reescrever a avaliação aprovada. Argumentos acolhidos não rectificam automaticamente avaliações, medidas, elegibilidade ou saldos; eventual rectificação usa o mecanismo existente de nova revisão. Fontes documentais são referências textuais; upload de anexos e prova de comunicação não estão incluídos.
+
+Verificações: **39 testes/505 asserções**, incluindo 11 novos do contraditório e regressões das avaliações, medidas e consolidado. Cobertos dados incompletos/futuros, idempotência/canonicalização, autores/editores impedidos de concluir, devolução/correcção, seguimento obrigatório, preservação das versões, concorrência, perfis/empresa/departamento, conflitos novos, metadados forjados, imutabilidade SQL e rollback de falha da auditoria. PHPStan sem erros, tipos, lint/formatação dirigidos, Pint, build e `git diff --check` aprovados. HTTP com dois perfis existentes na base PostgreSQL conferiu o ciclo completo e o relatório; teste SQL recusou UPDATE/DELETE da conclusão final em transacções revertidas. Menu permanece com 106 entradas/seis perfis. Provas `proofs/supplier-responses-http.json`, `supplier-responses-pg.json`, `supplier-responses-install.json` e `supplier-responses.json`.
+
+Aplicada apenas a migração aditiva **000026** na avaliação persistente 8005, com backup fresco e restauro descartável por hashes iguais, preservando **83 tabelas** anteriores e deixando a nova tabela inicialmente vazia. Exemplo fictício CTR-1 concluído na AVF-1, com argumentos parcialmente acolhidos, sem modificar essa avaliação. Nenhuma conta/concessão ou migração adicional; sem instalação habitual ou publicação no domínio. Visual/impressão manual continuam pendentes pela limitação do controlo de URL Windows anteriormente registada; sem nova aceitação visual presumida. Este incremento conclui o registo do contraditório proposto abaixo. Próxima evolução: consulta consolidada dos contraditórios por fornecedor/estado/recepção/conclusão, com ligação aos relatórios; anexos e registo da comunicação exigem o seu incremento próprio.
+
+## Consulta consolidada e CSV das medidas — 30 de Outubro de 2026
+
+Entregue `/relatorios/medidas-fornecedores`, em Relatórios → Medidas de melhoria e por atalho nas avaliações. Filtros combináveis por fornecedor, responsável, estado, título e intervalo de prazo, mais «só prazos ultrapassados». Inclui medidas de revisões anteriores: uma rectificação da avaliação não faz desaparecer uma medida pendente. Resumo calculado sobre todos os resultados autorizados dos filtros, paginação de 25 medidas, ligações directas à medida na avaliação e no relatório individual e impressão explicitamente da página visível.
+
+Exportação `/relatorios/medidas-fornecedores.csv` inclui todos os resultados dos mesmos filtros, independentemente da página, em lotes de 200. Conserva título/objectivo, fornecedor preservado na avaliação, revisão, responsável, prazo, estado, resultado/evidência, última conferência, versão e ligações. Textos que podem ser interpretados como fórmula são protegidos. Inclui filtros e data de referência; a flag de atraso exclui medidas encerradas e prazos da própria data de referência, sem inferir atraso histórico à data do encerramento. Sem cálculo de elegibilidade ou efeitos financeiros.
+
+Consulta e CSV reutilizam o âmbito da avaliação por empresa/departamento e recarregam o perfil no servidor; opções de fornecedor/responsável derivam apenas de medidas visíveis. Leituras PostgreSQL usam transacção própria repeatable-read/read-only; data de referência única por leitura. Consulta conserva histórico Inertia cifrado e resposta privada sem cache. A listagem envia apenas campos do relatório, sem chaves de idempotência ou hashes internos. Nenhuma migração, nova conta/concessão ou comando de negócio; a avaliação local 8005 já disponibiliza o código e os assets.
+
+Verificações: **36 testes/542 asserções**, incluindo seis testes novos do consolidado e regressões de avaliações, medidas e relatórios. Exportação com 207 medidas conferida em dois lotes, filtros, limites de datas, versões anteriores, estados/atrasos, perfis actuais, isolamento por empresa/departamento, opções não expostas, texto CSV seguro e ausência de escritas sobre avaliação/fornecedor/auditoria. PHPStan sem erros, TypeScript, lint/formatação dirigidos, Pint, build e `git diff --check` aprovados. Prova HTTP na base de avaliação com seis perfis: quatro leitores autorizados e dois recusados; filtros/resumo/CSV e ligações conferidos. Contrato do menu actualizado para **106 entradas/seis perfis**; catálogo de avaliação para **22 páginas**, com acesso, CSRF e regresso sem credenciais conferidos. Provas em `proofs/supplier-improvement-report-http.json`, `supplier-improvement-report.json`, `menu-code-contract.json` e `evaluation-access.json`.
+
+Visual/impressão manual continuam pendentes desde o encerramento do controlo da ferramenta por não conseguir confirmar o URL Windows; não se apresenta HTTP como prova visual. Não houve publicação no domínio. Esta entrega conclui a consulta consolidada proposta abaixo. Próximo âmbito funcional: registo do contraditório do fornecedor, com referências documentais e resposta fundamentada, preservando a avaliação original; a política quantitativa permanece por implementar.
+
+## Medidas de melhoria das avaliações — 30 de Outubro de 2026
+
+Implementado o acompanhamento na própria avaliação e no relatório: medidas associadas à revisão aprovada, objectivo, responsável interno autorizado e prazo; progresso e referência de evidência registados pelo responsável; pedido de encerramento, devolução/correcção e encerramento independente fundamentado. Autor, editores do plano e todos os responsáveis anteriores ficam excluídos do encerramento. Transferências de responsabilidade e alterações do prazo exigem justificação; o histórico conserva os valores anteriores e posteriores. O prazo ultrapassado é um aviso, sem encerramento ou penalização automática. Não há pontuação, suspensão, alteração de elegibilidade ou operação financeira.
+
+Reutilizados os quatro perfis existentes autorizados a consultar fornecedores, isolamento de empresa/departamento, recarga do perfil e conflitos de fornecedor/processo. Operações versionadas, criação idempotente e auditoria atómica. Medidas encerradas imutáveis no modelo e por trigger SQL; não existe apagamento. Acompanhamento aparece como anexo datado no relatório, sem alterar o conteúdo da avaliação aprovada. As medidas pertencem à revisão exacta; não são copiadas ou encerradas automaticamente ao rectificar a avaliação. Histórico paginado próprio, com decisões e alterações justificadas.
+
+Verificações: **72 testes/768 asserções**, abrangendo medidas, avaliações, conflitos e relatórios; PHPStan sem erros, TypeScript, lint/formatação dirigidos, Pint e build aprovados. Ensaio HTTP com três perfis na avaliação real conferiu repetição idempotente, evidência em falta recusada, auto-encerramento/encerramento pelo autor recusados, devolução, reagendamento, versão antiga recusada, correcção, encerramento independente e relatório. PostgreSQL recusou UPDATE/DELETE da medida encerrada em transacções revertidas, preservando a linha. Provas em `proofs/supplier-improvements-http.json`, `supplier-improvements-pg.json` e `supplier-improvements-install.json`.
+
+Aplicada somente a migração aditiva **000025** na avaliação persistente 8005, com backup fresco/restauro descartável e hashes iguais, preservando **82 tabelas** anteriores; nova tabela inicialmente vazia. O exemplo QA MM-1 ficou encerrado na AVF-1, sem modificar essa avaliação. Sem contas/concessões novas, migração habitual ou publicação no domínio. A tentativa de conferência visual foi encerrada pela ferramenta por não conseguir confirmar o URL do navegador Windows com confiança suficiente para aplicar a política; não foram tentadas vias alternativas de controlo. Visual e impressão continuam pendentes, sem invalidar os ensaios HTTP nem equivaler a falha do portal. O estado anterior de formatação global fora do incremento permanece documentado abaixo.
+
+Este incremento conclui o acompanhamento proposto no registo anterior. Próximo passo funcional: visão consolidada de medidas por fornecedor, responsável, prazo e estado, ligada aos relatórios, reutilizando estes registos; contraditório do fornecedor e política quantitativa continuam por desenvolver.
+
+## Avaliações qualitativas de fornecedores — 30 de Outubro de 2026
+
+Implementado `/fornecedores/avaliacoes`: criação e recuperação de rascunhos, filtros, âmbito/período, ordens autorizadas, critérios com justificação/evidência, ocorrências críticas e conclusão. Ciclo rascunho → revisão → devolução/correcção ou aprovação independente. Todos os editores e o submetente ficam excluídos da aprovação dessa revisão; perfis e conflitos são reconferidos no servidor. Empresa e departamento restringem as consultas. Reutilizados os quatro perfis existentes de fornecedores, sem contas ou concessões novas. O departamento deriva das ordens seleccionadas; avaliações de vários departamentos ficam no âmbito empresarial.
+
+Gravação idempotente, versões contra sobreposição concorrente e auditoria atómica com conteúdo anterior/posterior. Aprovações imutáveis também por trigger SQL; rectificação cria revisão ligada, conservando o relatório aprovado. Relatório imprimível, estado explícito e histórico paginado. A proposta quantitativa continua apenas proposta: sem pesos, pontuação/classificação automática ou alteração da elegibilidade. Medidas de melhoria e contraditório do fornecedor continuam para incremento próprio.
+
+Verificações: 61 testes/655 asserções (avaliações, conflitos e relatórios), PHPStan sem erros, TypeScript, Pint dirigido, lint dos novos ecrãs e build aprovados. Contrato do menu: 102 entradas/seis perfis; acesso: 21 páginas/seis perfis. HTTP no PostgreSQL local conferiu criação, submissão, auto-aprovação recusada, devolução, correcção, versão desactualizada recusada, aprovação independente, relatório e revisão posterior. Provas em `proofs/supplier-evaluations-http.json`, `supplier-evaluations-install.json` e `supplier-evaluations-pg.json`. Ensaio retomado após corrigir expectativas do script sobre redireccionamentos Inertia, conservando o mesmo exemplo QA e o histórico.
+
+Aplicada **apenas** a migração aditiva 000024 na avaliação 8005, após backup fresco e restauro descartável com conteúdos/hashes iguais. Preservadas as 81 tabelas anteriores; nova tabela inicialmente vazia. Sem contas, concessões ou migrações adicionais. AVF-1 é exemplo fictício aprovado; AVF-2 é rascunho de rectificação; nenhuma operação financeira. Instalação habitual e domínio não migrados/publicados. Visual/impressão manual por executar. O `npm run check` global acusa formatação anterior fora deste incremento; não foi declarado aprovado nem foram reformatados ficheiros alheios por arrasto.
+
+Esta entrega substitui a indicação abaixo de que a avaliação era apenas proposta. Próximo incremento: medidas de melhoria com responsáveis, prazos, evidências e encerramento independente, antes de converter a proposta quantitativa em política operacional.
+
+## Entrada sem credenciais ao abrir links da avaliação — 30 de Outubro de 2026
+
+O utilizador encontrou o formulário de credenciais ao abrir directamente `/relatorios` sem sessão. Corrigido o encaminhamento dos hóspedes no host local de avaliação: o link protegido e `/login` conduzem a `/avaliar`; após escolher o perfil existente, a entrada preserva o destino e os filtros antes de regenerar a sessão. A navegação de regresso só aceita caminhos locais ou URLs da mesma origem/porta; destinos externos, esquemas estranhos, caracteres de controlo e ciclos de login são recusados em favor do painel. As guardas de base/role QA, CSRF, perfis permitidos e isolamento foram preservadas. A instalação habitual continua a usar a autenticação própria.
+
+Incluídos Relatórios no catálogo da avaliação (21 páginas) e testes de navegação/âmbito. Aprovações: 23 testes Feature/172 asserções (15 casos de navegação e oito dos relatórios), PHPStan e Pint dirigidos; prova HTTP repetível na avaliação real com 21 páginas, seis perfis, regresso ao relatório com filtros, CSRF/identificadores inválidos recusados e logout. `scripts/evaluation-access-check.mjs` e `docs/proofs/evaluation-access.json` actualizados. O ensaio usou uma sessão de teste própria, sem encerrar a sessão do navegador do utilizador e sem comandos de negócio. Sem redefinição de credenciais ou instalação no domínio.
+
+## Compatibilização do menu e fluxo até aos relatórios — 30 de Outubro de 2026
+
+Pedido do proprietário: continuar a aplicação, ligar funções do menu ao código necessário e optimizar os percursos até à geração de relatórios. Reutilizados os controllers, políticas, processo e relatório de conferência existentes. Inventário por grupo e lacunas concretas em `menus-benchmark-20261029.md`; prova repetível de 102 entradas/6 perfis em `scripts/menu-code-check.mjs` e `proofs/menu-code-contract.json`. A avaliação de fornecedores foi identificada como proposta de UI, não processo persistente; mantém-se explicitamente por desenvolver.
+
+Implementação: grupo «Relatórios» com `/relatorios`, controller próprio e página Inertia, com filtro por número, estado da requisição, intervalo de criação e departamento autorizado; resumo de todos os resultados e paginação de 25 linhas. Exportação CSV em `/relatorios/compras.csv` aplica os mesmos filtros e âmbito, percorre lotes de 200, inclui metadados/filtros e valores monetários em centavos inteiros sem conversão para float. Textos potencialmente interpretáveis como fórmula são protegidos. O valor estimado não é apresentado como adjudicação, dívida ou pagamento. Leituras PostgreSQL usam snapshot repeatable-read/read-only, conforme o relatório existente; essa cláusula não equivale a novo ensaio de concorrência PostgreSQL nesta passagem.
+
+Percurso entregue: menu → central/filtros → requisição e acompanhamento do processo → relatório de conferência/impressão já existente → regresso à central. O relatório individual passou a incluir recepções e links; facturas e registos de pagamento só aparecem aos perfis financeiros anteriormente autorizados. Estados e montantes são os factos guardados, sem recalcular saldos ou inferir liquidação. O relatório não carrega/exibe referências privadas ou snapshots bancários. Corrigida a descrição de tipo da data já convertida pelo modelo GoodsReceipt, sem mudança do cast/schema.
+
+Verificações: 14 testes Feature/223 asserções em SQLite em memória; oito casos novos da central/ampliação e seis regressões do relatório existente. Incluem intervalo com dia final completo/data final isolada, 207 registos exportados em vários lotes, pesquisa, resumos, isolamento empresa/departamento, perfis revogados, hóspedes, filtros inválidos, inteiro monetário acima da precisão segura do JavaScript, CSV, ligações ao processo/relatório, recepção/factura/pagamento e saldos/auditoria preservados. TypeScript, lint/formatação dirigidos, Pint, PHPStan dos novos/alterados PHP, build e diff-check aprovados; módulo React novo e avaliação 8005 responderam HTTP200. Manifesto dos 18 ficheiros de execução continua íntegro. Conferência visual/impressão manual pendente, sem declarar um PDF gerado/ensaiado ou publicação.
+
+Ficheiros principais: ProcurementReportController, ProcurementReviewReport, GoodsReceipt (tipo), rotas, procurement-navigation, páginas reports/procurement e purchases/review-report, ProcurementReportsTest e menu-code-check. Sem migrações, seeds, novas contas/direitos, escritas de negócio ou activação remota. Preservadas as alterações locais anteriores de empacotamento. Próximo incremento: ciclo persistente de avaliação de fornecedores, desde planeamento/rascunho até revisão, histórico e relatório, com critérios e evidências explícitos; demais lacunas mantidas no inventário. Fonte de partida `0235c76`; alterações desta entrega permanecem locais.
+
+## Orientação comercial e remoção de dependência local — 30 de Outubro de 2026
+
+O proprietário determinou a remoção/correcção dos bloqueios ao desenvolvimento, testes, ajustes e disponibilização, considerando a comercialização do produto. Orientação registada no início de `orientacoes-continuidade.md` e em `AGENTS.md`, prevalecendo sobre adiamentos genéricos históricos. Dependências reais devem ser identificadas e resolvidas; a pendência visual não suspende o desenvolvimento e os testes. Controlos funcionais de isolamento, aprovação independente, integridade e auditoria conservados.
+
+Bloqueio concreto removido: o provider dependia de `../sgf-tempo/packages` quando faltavam os núcleos locais; em produção o fallback não existia e o Compras não era distribuível autonomamente. Incluídos 18 ficheiros de execução dos núcleos de orçamento/obrigações, byte a byte iguais à origem comum, com manifesto SHA-256. Incluídos os templates usados pelo workspace. Não foram copiados seeds, bases, credenciais ou ensaios de provisão. O provider usa agora os pacotes instalados/configurados em todos os ambientes, sem procurar a pasta vizinha. Os artefactos fixados não são uma segunda implementação financeira; actualizações vêm da origem comum com renovação do manifesto.
+
+`composer run runtime:check` verifica versão, bootstraps, existência e hashes; integrado em `ci:check`. Git preserva os bytes dos artefactos e inclui-os nas exportações; os formatadores não reescrevem estes pacotes fixados. Configurações de paths continuam disponíveis para outras formas de instalação. Fonte local de partida `0235c76` (checkpoint do proprietário observado durante o trabalho), com as alterações desta entrega ainda locais.
+
+Verificações aprovadas: 18 hashes iguais à origem; bootstrap com APP_ENV=production confirma por reflexão que BudgetControl e Payables foram carregados de `compras/packages`; 64 testes dirigidos de orçamento, reforços, integração de obrigações, importação histórica e decisões, com 277 asserções, em SQLite em memória. PHPStan do provider, Pint dirigido, sintaxe PHP, Composer validate e diff-check aprovados. Avaliação 8005 continua HTTP200. Não houve migrações, alterações de contas/permissões/dados, pagamentos ou instalação no domínio.
+
+Dependências concretas seguintes: o arranque de avaliação ainda reutiliza o perfil/router local já existente; alguns pagamentos têm implementação e referências exclusivamente sintéticas e necessitam do percurso comercial, schema e configuração correspondentes; o instalador/acesso do domínio Compras não foi identificado e o nome com `www` continua por reconciliar. Estes pontos são trabalho técnico a executar, não novas aprovações organizacionais nem proibição genérica de desenvolvimento. Não confundir a remoção do caminho externo com entrega integral dos pagamentos reais ou publicação já executada.
+
## Paridade com o domínio pedida pela Tempo — 30 de Outubro de 2026
Requisito expresso: a versão vista localmente deve corresponder à de `www.compras.tempo.co.mz`, incluindo menus, dropdowns, visual e processos. A avaliação local passa a ser a referência da apresentação entregue; a aceitação exige conferir a mesma revisão e os assets, com diferenças de configuração/activação documentadas. Preservar dados e acessos de cada ambiente; não publicar os perfis sintéticos sem palavra-passe por consequência desta orientação.
diff --git a/docs/menus-benchmark-20261029.md b/docs/menus-benchmark-20261029.md
index 8484914..48b0779 100644
--- a/docs/menus-benchmark-20261029.md
+++ b/docs/menus-benchmark-20261029.md
@@ -1,5 +1,34 @@
# Menus de Compras — primeira aplicação do benchmark
+**Administração → Parametrização da aplicação, 30/10/2026:** nova entrada `/administracao/parametrizacao`, para perfis/permissões da empresa, utilizadores/atribuições, limites de aprovação, activação e manutenção TEMPO temporária de consulta. Dois administradores permanentes no arranque e na gestão; nenhum perfil de desenvolvimento é atribuído nas novas empresas. Menus passam a aceitar a combinação das permissões configuradas, com recusa correspondente no servidor. Novas empresas usam os seus perfis; a avaliação existente conserva a preparação sem activação automática. Contrato actual: **107 entradas nos seis perfis de compatibilidade**, incluindo provas de múltiplas permissões configuradas; catálogo da avaliação com **23 páginas**. Ver `proofs/organization-configuration.json` e provas relacionadas. A gestão universal de etapas/condições e a granularidade por operação ainda não estão concluídas. Visual pendente pela limitação da ferramenta anteriormente registada.
+
+**Contraditório na avaliação, 30/10/2026:** Fornecedores → Avaliações → revisão aprovada → Contraditório do fornecedor. Entrada pela equipa dos argumentos/documentos recebidos, rascunho, análise independente, devolução/correcção e conclusão fundamentada com seguimento. Histórico e relatório individual conservam a origem e a avaliação aprovada. Mesmos perfis/âmbitos, sem nova permissão ou envio externo. Disponível em 8005 após migração 000026 com backup/restauro e preservação das 83 tabelas anteriores verificados. 39 testes/505 asserções e HTTP/PostgreSQL aprovados; menu permanece com 106 entradas/seis perfis. Provas `proofs/supplier-responses*.json`. Visual/impressão, consolidado dos contraditórios, anexos e prova de comunicação continuam pendentes; esta entrega substitui apenas a pendência do registo/análise nos estados abaixo.
+
+**Consolidado de medidas, 30/10/2026:** grupo Relatórios inclui «Medidas de melhoria», com consulta paginada, filtros, resumo, ligações à avaliação/medida e relatório e CSV de todos os resultados. Atalho adicional nas avaliações. Mesmos quatro perfis de consulta, isolamento por empresa/departamento e nenhuma permissão nova. Contrato actual: **106 entradas somadas nos seis perfis**, catálogo de avaliação com 22 páginas; provas HTTP e automatizadas em `proofs/supplier-improvement-report*.json`. Sem migração ou escrita de negócio; visual/impressão manual pendentes. Esta actualização acrescenta o consolidado aos estados históricos abaixo.
+
+**Medidas de melhoria disponíveis, 30/10/2026:** menu Fornecedores → Avaliações → revisão aprovada → medidas/execução → conferência independente → relatório com acompanhamento datado. Responsáveis/prazos, alterações justificadas, alertas de atraso, evidências, devolução/correcção e encerramento imutável, sem alteração automática da elegibilidade. Disponível em 8005, com migração 000025 e backup/restauro verificados. 72 testes/768 asserções e HTTP/PostgreSQL aprovados. Esta actualização substitui a pendência de medidas no registo abaixo; a proposta quantitativa e o contraditório continuam por desenvolver. Visual/impressão pendentes por encerramento do controlo da ferramenta, que não conseguiu confirmar o URL Windows para aplicar a política.
+
+**Estado actual da avaliação de fornecedores, 30/10/2026:** o menu já abre um ciclo persistente de rascunho, critérios/evidências, submissão, devolução/correcção e aprovação independente. Inclui versões, histórico, rectificação preservando a aprovação anterior e relatório imprimível. Disponível na avaliação 8005; migração aditiva com backup/restauro e preservação das 81 tabelas anteriores conferidos. Sem pontuação automática ou alteração de elegibilidade. 61 testes/655 asserções, tipos, PHPStan, lint dirigido, build, HTTP e triggers PostgreSQL aprovados. Visual/impressão manual pendentes. Este estado substitui a indicação histórica de «proposta de interface» no inventário abaixo; a proposta quantitativa e medidas de melhoria continuam por implementar.
+
+## Compatibilização entre menu, código e relatórios — 30/10/2026
+
+Inventário do código actual, preservando as entregas anteriores. `scripts/menu-code-check.mjs` confere a definição real do menu contra as rotas Laravel GET e o destaque activo: **102 entradas somadas nos seis perfis**, com obrigações ligadas/desligadas. Prova em `proofs/menu-code-contract.json`. É prova de ligação/capacidade do menu, não declaração de funcionamento integral de cada módulo.
+
+| Grupo do menu | Código e percurso existentes | Ligação ao relatório / saldo identificado |
+| --- | --- | --- |
+| Requisições | RequisitionController: criar, editar, submeter, decidir, vistas pessoais/rascunhos; PurchasePlanningController: itens, responsáveis e marcos | O processo reúne as etapas; a central nova localiza pedidos por número, data, estado e departamento. |
+| Aprovações | Elegibilidade e decisões em RequisitionController/RequisitionWorkflow | Conservadas as decisões efectivas; relatórios não executam nem substituem aprovações. |
+| Consultas e adjudicações | QuotationController, abertura, qualificação, admissão, recomendação e PurchaseController; revisão/cancelamento existente | Relatório de conferência recupera bases, versões e decisões; não inventa validade de documentos antigos. |
+| Fornecedores | Cadastro/manutenção/documentos em SupplierController e SupplierDocumentController | **Avaliação de fornecedores continua como proposta de interface**: não existem ainda gravação/revisão/histórico operacional desse módulo. Desenvolver esse fluxo até ao seu relatório é a próxima lacuna funcional concreta. |
+| Recepções e armazém | GoodsReceiptController, StockController, StockOperationController e EquipmentController | O relatório individual passa a incluir recepções associadas e a ligação às respectivas fichas; não presume entrega integral nem adiciona movimentos. |
+| Finanças | Facturas, pagamentos externos, orçamento/reforços e políticas existentes; núcleo comum sob configuração própria | Relatório individual inclui facturas/registos de pagamentos apenas para Finanças/Administração. Montantes preservados, sem cálculo de saldo ou inferência de liquidação. Pagamentos/afectações sintéticos e relatórios agregados financeiros ainda requerem os incrementos próprios. |
+| Administração e controlo | Empresa/equipa/centros, políticas de admissão/recomendação e conflitos | Mantidas regras e autorizações existentes; não há criação de contas ou novos direitos por abrir relatórios. |
+| Relatórios | **Nova central `/relatorios` e exportação `/relatorios/compras.csv`**, ligadas ao backend | Menu → filtros → requisição/processo → relatório de conferência → impressão existente. CSV usa todos os resultados autorizados dos filtros, não só a página visível. |
+
+O novo recorte não encerra contratos, multimoeda, relatórios financeiros agregados, avaliação de fornecedores ou o percurso comercial dos pagamentos ainda sintéticos. A prioridade seguinte é substituir a proposta de avaliação por um ciclo persistente, com critérios/configuração e evidências explícitos, revisão, histórico e relatório; não activar pesos/políticas provisórias silenciosamente.
+
+Verificações deste recorte: 14 testes Feature/223 asserções, incluindo 207 requisições exportadas em mais de um lote, isolamento por empresa/departamento, recarga do perfil, filtros, montantes inteiros exactos, texto CSV não executável como fórmula, relatório até recepção/factura/pagamento, ausência de dados bancários e de efeitos nos saldos/auditoria. Tipos, lint, Pint, PHPStan e build aprovados. Conferência visual/impressão manual pendente; sem migrações, novas escritas de negócio ou publicação no domínio.
+
**Actualização de apresentação, 30/10/2026:** por pedido do proprietário, a navegação autenticada foi colocada na horizontal em ecrãs grandes, com dropdowns e o mesmo visual escuro, grupos, ícones, pesquisa e regras por perfil. O painel móvel mantém-se. Este estado substitui apenas a disposição lateral descrita no registo histórico abaixo; não altera os processos ou permissões. Tipos, lint, formatação e módulo Vite aprovados; conferência visual desta alteração ainda não executada por indisponibilidade do conector do navegador.
29/10/2026. Pedido do proprietário: renovar os menus antes de retomar o trabalho funcional pendente. Referência fornecida: `referencias/benchmark-menus-20261029.txt`. Este incremento aplica a organização da navegação; não declara implementado todo o benchmark.
diff --git a/docs/orientacoes-continuidade.md b/docs/orientacoes-continuidade.md
index e3c2086..e013d0f 100644
--- a/docs/orientacoes-continuidade.md
+++ b/docs/orientacoes-continuidade.md
@@ -1,5 +1,25 @@
# Continuidade do desenvolvimento do SGC
+**Resultados da parametrização, 30/10/2026:** suíte integral 603 testes, 601 aprovados, dois ignorados anteriores, 5 157 asserções; parametrização PostgreSQL descartável 13 testes/96 asserções, com base removida; PHPStan/tipos/lint dirigido/Pint/build/runtime/HTTP aprovados. Menu 107 entradas e catálogo 23 páginas. Provas em `proofs/organization-configuration.json` e documentos associados. As tabelas de configuração da avaliação continuam sem activação/perfis/atribuições; nenhum administrador real ou conta TEMPO foi inventado/criado nessa base. Os dois administradores são definidos no formulário de configuração/entrega. Antes da activação, contas com marcador `configured` não herdam permissões de desenvolvimento; depois dela usam apenas os perfis atribuídos. Marcadores históricos e contas existentes conservam-se sem constituir autoridade de acesso no modo configurado.
+
+**Administração parametrizável e entrega com dois administradores, 30/10/2026:** `/administracao/parametrizacao` no menu, perfis com nomes próprios e permissões funcionais combináveis, criação de utilizadores, atribuição/revogação justificada, versão/auditoria e mínimo de dois administradores permanentes. Novas empresas exigem as duas pessoas e limites de aprovação expressamente definidos; seeder sem contas de exemplo. Marcador interno `configured` não concede direitos; depois da activação os perfis antigos deixam de autorizar. Resolução comum aplicada aos serviços/políticas/controladores, menus e botões; preservar regras e referências históricas. Empresas existentes mantêm preparação até activação explícita, com efeito explicado sobre pessoas não associadas. O proprietário pediu manutenção TEMPO («backdoor»); concretizada como acesso nominativo autenticado, temporário, revogável e auditado de diagnóstico/consulta, sem conta global/entrada oculta ou comandos de negócio. Migração 000027 apenas em 8005, backup/restauro verificado e 84 tabelas preservadas; três tabelas novas vazias. Configuração da avaliação não activada e contas/permissões existentes não alteradas. Ver resultados em `proofs/organization-configuration.json` e provas associadas. Não declarar parametrização integral: catálogo ainda agregado por funções, editor universal de fluxos/permissões por operação e associações financeiras específicas continuam por desenvolver. Visual e publicação pendentes. Esta orientação passa à frente do consolidado do contraditório indicado nos estados anteriores.
+
+**Instrução permanente de parametrização integral — proprietário, 30/10/2026:** toda a aplicação deve permitir aos utilizadores autorizados definir quem é quem, quando cada função/regra deve ser usada e como associar pessoas, funções, unidades, processos e documentos. Abrange perfis/permissões e nomes configuráveis, responsabilidades, múltiplas funções, delegações/substituições, etapas, condições, limites, prazos, vigência, aprovações, menus e relatórios. Configuração pela interface, sem adaptação de código por organização; servidor e interface devem usar a mesma fonte de capacidades/regras. Versão, vigência, auditoria e efeito explícito em processos novos/em curso, preservando decisões concluídas, isolamento e integridade. Orientação consolidada em `AGENTS.md`. **Ainda não implementada integralmente:** existem seis perfis em enum e listas fixas de autorização, inclusive nos módulos recentes; estes são configuração inicial a migrar, não limite definitivo do produto. A prioridade transversal passa a ser a base comum de funções/capacidades e associações configuráveis, com migração compatível das regras actuais, antes de multiplicar novas listas fixas. Entregas e provas anteriores continuam preservadas; o pedido não concede automaticamente novas permissões nas bases existentes.
+
+**Contraditório registado e analisado, 30/10/2026:** revisão aprovada → documentos/argumentos recebidos → rascunho → análise → devolução/correcção ou conclusão independente. Acolhimento total/parcial exige seguimento; conclusão fundamentada imutável, com histórico e anexo datado no relatório, preservando a avaliação. Entrada pela equipa com referências documentais; sem envio externo, portal autenticado do fornecedor ou upload de anexos neste incremento. Reutilizados âmbito, perfis, conflitos, versões e auditoria atómica. 39 testes/505 asserções, PHPStan/tipos/lint/Pint/build, HTTP e triggers PostgreSQL aprovados; provas `proofs/supplier-responses*.json`. Migração 000026 só em 8005 após backup/restauro fresco verificado, 83 tabelas anteriores preservadas; exemplo QA CTR-1 concluído na AVF-1. Sem instalação habitual/publicação, efeitos fiscais/financeiros ou mudança automática da elegibilidade. Visual/impressão permanecem pendentes pela limitação do controlo de URL já registada. Substitui a pendência do registo do contraditório abaixo. Próxima evolução: consolidado por fornecedor/estado/recepção/conclusão e relatório; anexos e comunicação permanecem incrementos próprios.
+
+**Consolidado de medidas entregue, 30/10/2026:** Relatórios → Medidas de melhoria, `/relatorios/medidas-fornecedores`, com filtros por fornecedor/responsável/estado/título/prazo/atraso, resumo de todos os resultados, paginação e ligações à medida na avaliação/relatório. CSV completo dos filtros em lotes de 200, texto seguro para folhas de cálculo; inclui revisões anteriores, sem esconder pendentes ao rectificar avaliações. Leituras PostgreSQL em snapshot read-only, com âmbito de empresa/departamento e perfil recarregado. Sem migrações, contas/concessões, alterações de negócio ou publicação. Disponível em 8005. 36 testes/542 asserções, PHPStan/tipos/lint/Pint/build e HTTP de seis perfis aprovados; menu 106 entradas e catálogo 22 páginas conferidos. Ver `proofs/supplier-improvement-report*.json`. Visual/impressão manual continuam pendentes pela limitação do controlo de URL Windows já registada. Substitui a pendência de consolidado abaixo. Próximo âmbito: contraditório documentado do fornecedor e resposta fundamentada, sem substituir a avaliação aprovada; política quantitativa por implementar.
+
+**Medidas de melhoria entregues, 30/10/2026:** a avaliação aprovada permite registar medidas com responsável/prazo, execução/evidência, pedido de encerramento, devolução/correcção e encerramento independente. Transferências/reagendamentos justificados e histórico; autor/editores/responsáveis não encerram a própria medida. Encerramentos imutáveis também em SQL. Relatório inclui acompanhamento datado sem modificar a avaliação aprovada; sem efeitos automáticos na elegibilidade/finanças. Migração 000025 instalada apenas em 8005 após backup/restauro verificado e preservação de 82 tabelas. Exemplo QA MM-1 encerrado na AVF-1. 72 testes/768 asserções, PHPStan, tipos, lint/Pint/build, HTTP com três perfis e protecções PostgreSQL aprovados. Provas `proofs/supplier-improvements-*.json`. Visual/impressão pendentes: a ferramenta terminou o controlo por não conseguir confirmar o URL Windows para aplicação da política; sem contorno dessa limitação. Sem publicação no domínio ou migração habitual. Substitui a pendência de medidas abaixo. Próximo incremento: consulta consolidada por fornecedor/responsável/prazo/estado e relatório; contraditório e política quantitativa continuam por implementar.
+
+**Avaliação de fornecedores implementada, 30/10/2026:** `/fornecedores/avaliacoes` guarda rascunhos, critérios/evidências e conclusão; submissão, devolução/correcção, aprovação independente de todos os editores, versões, histórico e relatório imprimível. Aprovação imutável; rectificação por revisão ligada. Conflitos e âmbito por empresa/departamento mantidos. Sem pontuação, pesos ou alteração automática de elegibilidade. 61 testes/655 asserções, PHPStan, tipos, lint dirigido e build aprovados; HTTP e triggers PostgreSQL conferidos. Migração 000024 aplicada apenas na avaliação 8005, com backup/restauro verificado e 81 tabelas anteriores preservadas. AVF-1 é exemplo fictício aprovado; AVF-2 é rascunho de rectificação. Ver `desenvolvimento.md` e `proofs/supplier-evaluations-*.json`. Sem publicação no domínio ou migração habitual; visual/impressão manual pendentes. Substitui a lacuna de persistência indicada abaixo. Próximo incremento: medidas de melhoria, responsáveis/prazos/evidências e encerramento independente; não activar pontuações provisórias.
+
+**Acesso directo na avaliação corrigido, 30/10/2026:** links protegidos em 8005, incluindo `/relatorios`, conduzem a `/avaliar` quando não existe sessão; `/login` desse host também usa a entrada sem palavra-passe. Após escolher o proprietário/perfil, regressa ao caminho e filtros guardados. Destinos externos e ciclos de login não são aceites. Mantidas as guardas de host/base/role QA e CSRF; a autenticação da instalação habitual não foi alterada. 23 testes/172 asserções, PHPStan/Pint e prova HTTP das 21 páginas/seis perfis aprovados. Disponibilizar este percurso ao utilizador, sem lhe pedir credenciais da instalação habitual.
+
+**Menu → operação → relatórios, 30/10/2026:** pedido do proprietário para compatibilizar as funções do menu com o código e optimizar os fluxos até à geração dos relatórios. Inventário actualizado em `menus-benchmark-20261029.md`, com 102 entradas/6 perfis conferidas por contrato de rotas/destaque. Entregue central «Relatórios de compras» em `/relatorios`, filtros por criação/estado/departamento/número, resumo paginado e CSV completo dos filtros, com ligações ao processo e relatório individual. Relatório individual ampliado com recepções e, no âmbito financeiro autorizado, facturas e pagamentos externos preservados, sem dados bancários nem cálculo de saldo. 14 testes/223 asserções, tipos, lint, Pint, PHPStan e build aprovados; provas em `proofs/procurement-reports.json`. Próxima lacuna concreta: Avaliação de fornecedores ainda é proposta de interface, sem persistência/revisão/histórico operacional; implementar esse ciclo até ao respectivo relatório. Não declarar todos os módulos concluídos apenas por as ligações do menu abrirem. Alterações locais, sem instalação no domínio; visual/impressão manual continuam por executar, sem bloquear os incrementos seguintes.
+
+**Orientação permanente do proprietário — 30/10/2026:** a aplicação é destinada à comercialização; remover/corrigir os bloqueios que impedem desenvolvimento normal, testes, ajustes e disponibilização. Não exigir de novo autorização por etapas já abrangidas pelo pedido, nem usar pendência genérica de visual/aceitação como bloqueio do código e dos testes. Distinguir limitação técnica de regra funcional aprovada, identificar a sua origem e executar a solução concreta. Preservar dados, contas e os controlos funcionais de integridade/isolamento/aprovação/auditoria. Esta orientação prevalece sobre adiamentos genéricos dos registos históricos; não transforma capacidades ainda sintéticas em operação comercial por mera remoção de guardas. Regras locais consolidadas em `AGENTS.md`.
+
**Paridade de versão local/domínio — decisão da Tempo, 30/10/2026:** a versão acompanhada pelo utilizador durante o desenvolvimento é a referência para `www.compras.tempo.co.mz`: mesmos menus horizontais/dropdowns, visual e implementação dos processos. Não apresentar uma interface antiga como entrega equivalente. Conferir revisão, assets, flags e dependências de cada percurso antes de declarar paridade; dados, identidades e permissões existentes de cada ambiente devem ser preservados. O acesso sem palavra-passe e os perfis sintéticos da avaliação local não foram autorizados no domínio por este pedido. Pagamentos e outros recortes limitados à avaliação exigem reconciliação explícita do âmbito, não mera cópia de flags ou de dados QA. As alterações locais desta sessão ainda não foram enviadas/instaladas e não há confirmação de paridade no servidor. Na leitura de 30/10, o nome com `www` não resolveu por DNS; o endereço sem `www` respondeu a curl, mas isso não confirma a revisão ou os percursos autenticados. O processo/acesso de instalação de Compras não está identificado nos ficheiros de entrega consultados; não reutilizar por inferência o instalador do InvesteMoz.
**Entrada de desenvolvimento definida pela Tempo, 30/10/2026:** o utilizador quer acompanhar a avaliação persistente com menus organizados e processos avançados em **http://127.0.0.1:8005/avaliar**, com «Entrar como proprietário» e os perfis existentes. `composer run dev` usa agora essa avaliação e o Vite; não inicia a instalação habitual em 8015 nem a respectiva fila. Não voltar a apresentar 8015 como entrada normal de desenvolvimento. O arranque reutiliza os dados/configuração QA já existentes, sem provisionamento, migrações, seeds, alteração de contas ou reinício do SGF. A instalação habitual mantém-se separada e só arranca explicitamente com `composer run dev:standard`. Implementação em `scripts/dev-evaluation.php`; verificações e limites no registo de 30/10 em `desenvolvimento.md`.
diff --git a/docs/proofs/evaluation-access.json b/docs/proofs/evaluation-access.json
index 081a685..581f476 100644
--- a/docs/proofs/evaluation-access.json
+++ b/docs/proofs/evaluation-access.json
@@ -1,5 +1,5 @@
{
- "checked_at": "2026-10-29T18:55:02.410Z",
+ "checked_at": "2026-10-30T08:46:24.426Z",
"base": "http://127.0.0.1:8005",
"owner_modules": [
"/dashboard",
@@ -21,7 +21,10 @@
"/conflitos",
"/revisao-compras",
"/politica-admissao",
- "/politica-recomendacao"
+ "/politica-recomendacao",
+ "/relatorios",
+ "/relatorios/medidas-fornecedores",
+ "/administracao/parametrizacao"
],
"roles": [
"requester",
@@ -38,5 +41,7 @@
"business_commands": 0,
"real_accounts_changed": false,
"operational_entry_status": "not running",
- "visual_review": false
+ "visual_review": false,
+ "guest_report_redirect": true,
+ "intended_report_and_filters_preserved": true
}
diff --git a/docs/proofs/menu-code-contract.json b/docs/proofs/menu-code-contract.json
new file mode 100644
index 0000000..dcbfcf6
--- /dev/null
+++ b/docs/proofs/menu-code-contract.json
@@ -0,0 +1,974 @@
+{
+ "checked_at": "2026-10-30T08:46:13.504Z",
+ "result": "passed",
+ "entries": 107,
+ "roles": 6,
+ "configurable_permissions_and_multiple_profiles": true,
+ "source": "procurementNavigation compared with actual Laravel GET routes",
+ "scope": "Menu routing, active entry and visibility contract; not a substitute for functional or visual workflow acceptance",
+ "rows": [
+ {
+ "role": "admin",
+ "group": "Requisições",
+ "id": "new_request",
+ "label": "Nova requisição",
+ "path": "/requisicoes/criar",
+ "action": "App\\Http\\Controllers\\RequisitionController@create",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Requisições",
+ "id": "my_requests",
+ "label": "Minhas requisições",
+ "path": "/requisicoes?view=mine",
+ "action": "App\\Http\\Controllers\\RequisitionController@index",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Requisições",
+ "id": "my_drafts",
+ "label": "Os meus rascunhos",
+ "path": "/requisicoes?view=mine&status=draft",
+ "action": "App\\Http\\Controllers\\RequisitionController@index",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Requisições",
+ "id": "all_requests",
+ "label": "Todas as autorizadas",
+ "path": "/requisicoes?view=all",
+ "action": "App\\Http\\Controllers\\RequisitionController@index",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Requisições",
+ "id": "planning",
+ "label": "Planeamento",
+ "path": "/planeamento",
+ "action": "App\\Http\\Controllers\\PurchasePlanningController@index",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Aprovações",
+ "id": "pending_approvals",
+ "label": "Pendentes para mim",
+ "path": "/requisicoes?view=approvals",
+ "action": "App\\Http\\Controllers\\RequisitionController@index",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Aprovações",
+ "id": "decisions",
+ "label": "Minhas decisões",
+ "path": "/requisicoes?view=decided",
+ "action": "App\\Http\\Controllers\\RequisitionController@index",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Consultas e adjudicações",
+ "id": "quotations",
+ "label": "Cotações e comparação",
+ "path": "/cotacoes",
+ "action": "App\\Http\\Controllers\\QuotationController@index",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Consultas e adjudicações",
+ "id": "orders",
+ "label": "Adjudicações e ordens",
+ "path": "/ordens",
+ "action": "App\\Http\\Controllers\\PurchaseController@index",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Consultas e adjudicações",
+ "id": "purchase_review",
+ "label": "Revisão e cancelamento",
+ "path": "/revisao-compras",
+ "action": "App\\Http\\Controllers\\PurchaseControlController@index",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Fornecedores",
+ "id": "suppliers",
+ "label": "Cadastro e documentos",
+ "path": "/fornecedores",
+ "action": "App\\Http\\Controllers\\SupplierController@index",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Fornecedores",
+ "id": "supplier_evaluations",
+ "label": "Avaliação de fornecedores",
+ "path": "/fornecedores/avaliacoes",
+ "action": "App\\Http\\Controllers\\SupplierEvaluationController@index",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Recepções e armazém",
+ "id": "receipts",
+ "label": "Recepções e divergências",
+ "path": "/recepcoes",
+ "action": "App\\Http\\Controllers\\GoodsReceiptController@index",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Recepções e armazém",
+ "id": "stock",
+ "label": "Artigos e stock",
+ "path": "/stock",
+ "action": "App\\Http\\Controllers\\StockController@index",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Recepções e armazém",
+ "id": "stock_operations",
+ "label": "Saídas e inventários",
+ "path": "/stock/operacoes",
+ "action": "App\\Http\\Controllers\\StockOperationController@index",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Recepções e armazém",
+ "id": "equipment",
+ "label": "Equipamentos e peças",
+ "path": "/equipamentos",
+ "action": "App\\Http\\Controllers\\EquipmentController@index",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Finanças",
+ "id": "invoices",
+ "label": "Facturas e conferência",
+ "path": "/facturas",
+ "action": "App\\Http\\Controllers\\InvoiceController@index",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Finanças",
+ "id": "payments",
+ "label": "Pagamentos externos",
+ "path": "/pagamentos",
+ "action": "App\\Http\\Controllers\\ExternalPaymentController@index",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Finanças",
+ "id": "budgets",
+ "label": "Orçamentos e reforços",
+ "path": "/orcamentos",
+ "action": "App\\Http\\Controllers\\BudgetController@index",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Finanças",
+ "id": "investment",
+ "label": "Regras de investimento",
+ "path": "/regras-investimento",
+ "action": "App\\Http\\Controllers\\InvestmentPolicyController@index",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Administração e controlo",
+ "id": "company",
+ "label": "Empresa, equipa e centros",
+ "path": "/empresa",
+ "action": "App\\Http\\Controllers\\CompanyController@index",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Administração e controlo",
+ "id": "organization_parameters",
+ "label": "Parametrização da aplicação",
+ "path": "/administracao/parametrizacao",
+ "action": "App\\Http\\Controllers\\OrganizationConfigurationController@index",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Administração e controlo",
+ "id": "admission_policy",
+ "label": "Política de admissão",
+ "path": "/politica-admissao",
+ "action": "App\\Http\\Controllers\\FormalAdmissionController@policy",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Administração e controlo",
+ "id": "recommendation_policy",
+ "label": "Política de recomendação",
+ "path": "/politica-recomendacao",
+ "action": "App\\Http\\Controllers\\FormalRecommendationController@policy",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Administração e controlo",
+ "id": "conflicts",
+ "label": "Conflitos de interesses",
+ "path": "/conflitos",
+ "action": "App\\Http\\Controllers\\ConflictDeclarationController@index",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Relatórios",
+ "id": "procurement_reports",
+ "label": "Relatórios de compras",
+ "path": "/relatorios",
+ "action": "App\\Http\\Controllers\\ProcurementReportController@index",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Relatórios",
+ "id": "supplier_improvement_reports",
+ "label": "Medidas de melhoria",
+ "path": "/relatorios/medidas-fornecedores",
+ "action": "App\\Http\\Controllers\\SupplierImprovementReportController@index",
+ "full_page": false
+ },
+ {
+ "role": "admin",
+ "group": "Finanças",
+ "id": "payables",
+ "label": "Obrigações a fornecedores",
+ "path": "/obrigacoes-fornecedores",
+ "action": "App\\Http\\Controllers\\SupplierPayablesController@page",
+ "full_page": true
+ },
+ {
+ "role": "procurement",
+ "group": "Requisições",
+ "id": "new_request",
+ "label": "Nova requisição",
+ "path": "/requisicoes/criar",
+ "action": "App\\Http\\Controllers\\RequisitionController@create",
+ "full_page": false
+ },
+ {
+ "role": "procurement",
+ "group": "Requisições",
+ "id": "my_requests",
+ "label": "Minhas requisições",
+ "path": "/requisicoes?view=mine",
+ "action": "App\\Http\\Controllers\\RequisitionController@index",
+ "full_page": false
+ },
+ {
+ "role": "procurement",
+ "group": "Requisições",
+ "id": "my_drafts",
+ "label": "Os meus rascunhos",
+ "path": "/requisicoes?view=mine&status=draft",
+ "action": "App\\Http\\Controllers\\RequisitionController@index",
+ "full_page": false
+ },
+ {
+ "role": "procurement",
+ "group": "Requisições",
+ "id": "all_requests",
+ "label": "Todas as autorizadas",
+ "path": "/requisicoes?view=all",
+ "action": "App\\Http\\Controllers\\RequisitionController@index",
+ "full_page": false
+ },
+ {
+ "role": "procurement",
+ "group": "Requisições",
+ "id": "planning",
+ "label": "Planeamento",
+ "path": "/planeamento",
+ "action": "App\\Http\\Controllers\\PurchasePlanningController@index",
+ "full_page": false
+ },
+ {
+ "role": "procurement",
+ "group": "Aprovações",
+ "id": "pending_approvals",
+ "label": "Pendentes para mim",
+ "path": "/requisicoes?view=approvals",
+ "action": "App\\Http\\Controllers\\RequisitionController@index",
+ "full_page": false
+ },
+ {
+ "role": "procurement",
+ "group": "Aprovações",
+ "id": "decisions",
+ "label": "Minhas decisões",
+ "path": "/requisicoes?view=decided",
+ "action": "App\\Http\\Controllers\\RequisitionController@index",
+ "full_page": false
+ },
+ {
+ "role": "procurement",
+ "group": "Consultas e adjudicações",
+ "id": "quotations",
+ "label": "Cotações e comparação",
+ "path": "/cotacoes",
+ "action": "App\\Http\\Controllers\\QuotationController@index",
+ "full_page": false
+ },
+ {
+ "role": "procurement",
+ "group": "Consultas e adjudicações",
+ "id": "orders",
+ "label": "Adjudicações e ordens",
+ "path": "/ordens",
+ "action": "App\\Http\\Controllers\\PurchaseController@index",
+ "full_page": false
+ },
+ {
+ "role": "procurement",
+ "group": "Consultas e adjudicações",
+ "id": "purchase_review",
+ "label": "Revisão e cancelamento",
+ "path": "/revisao-compras",
+ "action": "App\\Http\\Controllers\\PurchaseControlController@index",
+ "full_page": false
+ },
+ {
+ "role": "procurement",
+ "group": "Fornecedores",
+ "id": "suppliers",
+ "label": "Cadastro e documentos",
+ "path": "/fornecedores",
+ "action": "App\\Http\\Controllers\\SupplierController@index",
+ "full_page": false
+ },
+ {
+ "role": "procurement",
+ "group": "Fornecedores",
+ "id": "supplier_evaluations",
+ "label": "Avaliação de fornecedores",
+ "path": "/fornecedores/avaliacoes",
+ "action": "App\\Http\\Controllers\\SupplierEvaluationController@index",
+ "full_page": false
+ },
+ {
+ "role": "procurement",
+ "group": "Recepções e armazém",
+ "id": "receipts",
+ "label": "Recepções e divergências",
+ "path": "/recepcoes",
+ "action": "App\\Http\\Controllers\\GoodsReceiptController@index",
+ "full_page": false
+ },
+ {
+ "role": "procurement",
+ "group": "Recepções e armazém",
+ "id": "stock",
+ "label": "Artigos e stock",
+ "path": "/stock",
+ "action": "App\\Http\\Controllers\\StockController@index",
+ "full_page": false
+ },
+ {
+ "role": "procurement",
+ "group": "Recepções e armazém",
+ "id": "stock_operations",
+ "label": "Saídas e inventários",
+ "path": "/stock/operacoes",
+ "action": "App\\Http\\Controllers\\StockOperationController@index",
+ "full_page": false
+ },
+ {
+ "role": "procurement",
+ "group": "Recepções e armazém",
+ "id": "equipment",
+ "label": "Equipamentos e peças",
+ "path": "/equipamentos",
+ "action": "App\\Http\\Controllers\\EquipmentController@index",
+ "full_page": false
+ },
+ {
+ "role": "procurement",
+ "group": "Finanças",
+ "id": "investment",
+ "label": "Regras de investimento",
+ "path": "/regras-investimento",
+ "action": "App\\Http\\Controllers\\InvestmentPolicyController@index",
+ "full_page": false
+ },
+ {
+ "role": "procurement",
+ "group": "Administração e controlo",
+ "id": "conflicts",
+ "label": "Conflitos de interesses",
+ "path": "/conflitos",
+ "action": "App\\Http\\Controllers\\ConflictDeclarationController@index",
+ "full_page": false
+ },
+ {
+ "role": "procurement",
+ "group": "Relatórios",
+ "id": "procurement_reports",
+ "label": "Relatórios de compras",
+ "path": "/relatorios",
+ "action": "App\\Http\\Controllers\\ProcurementReportController@index",
+ "full_page": false
+ },
+ {
+ "role": "procurement",
+ "group": "Relatórios",
+ "id": "supplier_improvement_reports",
+ "label": "Medidas de melhoria",
+ "path": "/relatorios/medidas-fornecedores",
+ "action": "App\\Http\\Controllers\\SupplierImprovementReportController@index",
+ "full_page": false
+ },
+ {
+ "role": "finance",
+ "group": "Requisições",
+ "id": "new_request",
+ "label": "Nova requisição",
+ "path": "/requisicoes/criar",
+ "action": "App\\Http\\Controllers\\RequisitionController@create",
+ "full_page": false
+ },
+ {
+ "role": "finance",
+ "group": "Requisições",
+ "id": "my_requests",
+ "label": "Minhas requisições",
+ "path": "/requisicoes?view=mine",
+ "action": "App\\Http\\Controllers\\RequisitionController@index",
+ "full_page": false
+ },
+ {
+ "role": "finance",
+ "group": "Requisições",
+ "id": "my_drafts",
+ "label": "Os meus rascunhos",
+ "path": "/requisicoes?view=mine&status=draft",
+ "action": "App\\Http\\Controllers\\RequisitionController@index",
+ "full_page": false
+ },
+ {
+ "role": "finance",
+ "group": "Requisições",
+ "id": "all_requests",
+ "label": "Todas as autorizadas",
+ "path": "/requisicoes?view=all",
+ "action": "App\\Http\\Controllers\\RequisitionController@index",
+ "full_page": false
+ },
+ {
+ "role": "finance",
+ "group": "Requisições",
+ "id": "planning",
+ "label": "Planeamento",
+ "path": "/planeamento",
+ "action": "App\\Http\\Controllers\\PurchasePlanningController@index",
+ "full_page": false
+ },
+ {
+ "role": "finance",
+ "group": "Aprovações",
+ "id": "pending_approvals",
+ "label": "Pendentes para mim",
+ "path": "/requisicoes?view=approvals",
+ "action": "App\\Http\\Controllers\\RequisitionController@index",
+ "full_page": false
+ },
+ {
+ "role": "finance",
+ "group": "Aprovações",
+ "id": "decisions",
+ "label": "Minhas decisões",
+ "path": "/requisicoes?view=decided",
+ "action": "App\\Http\\Controllers\\RequisitionController@index",
+ "full_page": false
+ },
+ {
+ "role": "finance",
+ "group": "Consultas e adjudicações",
+ "id": "quotations",
+ "label": "Cotações e comparação",
+ "path": "/cotacoes",
+ "action": "App\\Http\\Controllers\\QuotationController@index",
+ "full_page": false
+ },
+ {
+ "role": "finance",
+ "group": "Consultas e adjudicações",
+ "id": "orders",
+ "label": "Adjudicações e ordens",
+ "path": "/ordens",
+ "action": "App\\Http\\Controllers\\PurchaseController@index",
+ "full_page": false
+ },
+ {
+ "role": "finance",
+ "group": "Consultas e adjudicações",
+ "id": "purchase_review",
+ "label": "Revisão e cancelamento",
+ "path": "/revisao-compras",
+ "action": "App\\Http\\Controllers\\PurchaseControlController@index",
+ "full_page": false
+ },
+ {
+ "role": "finance",
+ "group": "Fornecedores",
+ "id": "suppliers",
+ "label": "Cadastro e documentos",
+ "path": "/fornecedores",
+ "action": "App\\Http\\Controllers\\SupplierController@index",
+ "full_page": false
+ },
+ {
+ "role": "finance",
+ "group": "Fornecedores",
+ "id": "supplier_evaluations",
+ "label": "Avaliação de fornecedores",
+ "path": "/fornecedores/avaliacoes",
+ "action": "App\\Http\\Controllers\\SupplierEvaluationController@index",
+ "full_page": false
+ },
+ {
+ "role": "finance",
+ "group": "Recepções e armazém",
+ "id": "receipts",
+ "label": "Recepções e divergências",
+ "path": "/recepcoes",
+ "action": "App\\Http\\Controllers\\GoodsReceiptController@index",
+ "full_page": false
+ },
+ {
+ "role": "finance",
+ "group": "Recepções e armazém",
+ "id": "stock",
+ "label": "Artigos e stock",
+ "path": "/stock",
+ "action": "App\\Http\\Controllers\\StockController@index",
+ "full_page": false
+ },
+ {
+ "role": "finance",
+ "group": "Recepções e armazém",
+ "id": "stock_operations",
+ "label": "Saídas e inventários",
+ "path": "/stock/operacoes",
+ "action": "App\\Http\\Controllers\\StockOperationController@index",
+ "full_page": false
+ },
+ {
+ "role": "finance",
+ "group": "Recepções e armazém",
+ "id": "equipment",
+ "label": "Equipamentos e peças",
+ "path": "/equipamentos",
+ "action": "App\\Http\\Controllers\\EquipmentController@index",
+ "full_page": false
+ },
+ {
+ "role": "finance",
+ "group": "Finanças",
+ "id": "invoices",
+ "label": "Facturas e conferência",
+ "path": "/facturas",
+ "action": "App\\Http\\Controllers\\InvoiceController@index",
+ "full_page": false
+ },
+ {
+ "role": "finance",
+ "group": "Finanças",
+ "id": "payments",
+ "label": "Pagamentos externos",
+ "path": "/pagamentos",
+ "action": "App\\Http\\Controllers\\ExternalPaymentController@index",
+ "full_page": false
+ },
+ {
+ "role": "finance",
+ "group": "Finanças",
+ "id": "budgets",
+ "label": "Orçamentos e reforços",
+ "path": "/orcamentos",
+ "action": "App\\Http\\Controllers\\BudgetController@index",
+ "full_page": false
+ },
+ {
+ "role": "finance",
+ "group": "Finanças",
+ "id": "investment",
+ "label": "Regras de investimento",
+ "path": "/regras-investimento",
+ "action": "App\\Http\\Controllers\\InvestmentPolicyController@index",
+ "full_page": false
+ },
+ {
+ "role": "finance",
+ "group": "Administração e controlo",
+ "id": "conflicts",
+ "label": "Conflitos de interesses",
+ "path": "/conflitos",
+ "action": "App\\Http\\Controllers\\ConflictDeclarationController@index",
+ "full_page": false
+ },
+ {
+ "role": "finance",
+ "group": "Relatórios",
+ "id": "procurement_reports",
+ "label": "Relatórios de compras",
+ "path": "/relatorios",
+ "action": "App\\Http\\Controllers\\ProcurementReportController@index",
+ "full_page": false
+ },
+ {
+ "role": "finance",
+ "group": "Relatórios",
+ "id": "supplier_improvement_reports",
+ "label": "Medidas de melhoria",
+ "path": "/relatorios/medidas-fornecedores",
+ "action": "App\\Http\\Controllers\\SupplierImprovementReportController@index",
+ "full_page": false
+ },
+ {
+ "role": "finance",
+ "group": "Finanças",
+ "id": "payables",
+ "label": "Obrigações a fornecedores",
+ "path": "/obrigacoes-fornecedores",
+ "action": "App\\Http\\Controllers\\SupplierPayablesController@page",
+ "full_page": true
+ },
+ {
+ "role": "department_head",
+ "group": "Requisições",
+ "id": "new_request",
+ "label": "Nova requisição",
+ "path": "/requisicoes/criar",
+ "action": "App\\Http\\Controllers\\RequisitionController@create",
+ "full_page": false
+ },
+ {
+ "role": "department_head",
+ "group": "Requisições",
+ "id": "my_requests",
+ "label": "Minhas requisições",
+ "path": "/requisicoes?view=mine",
+ "action": "App\\Http\\Controllers\\RequisitionController@index",
+ "full_page": false
+ },
+ {
+ "role": "department_head",
+ "group": "Requisições",
+ "id": "my_drafts",
+ "label": "Os meus rascunhos",
+ "path": "/requisicoes?view=mine&status=draft",
+ "action": "App\\Http\\Controllers\\RequisitionController@index",
+ "full_page": false
+ },
+ {
+ "role": "department_head",
+ "group": "Requisições",
+ "id": "all_requests",
+ "label": "Todas as autorizadas",
+ "path": "/requisicoes?view=all",
+ "action": "App\\Http\\Controllers\\RequisitionController@index",
+ "full_page": false
+ },
+ {
+ "role": "department_head",
+ "group": "Requisições",
+ "id": "planning",
+ "label": "Planeamento",
+ "path": "/planeamento",
+ "action": "App\\Http\\Controllers\\PurchasePlanningController@index",
+ "full_page": false
+ },
+ {
+ "role": "department_head",
+ "group": "Aprovações",
+ "id": "pending_approvals",
+ "label": "Pendentes para mim",
+ "path": "/requisicoes?view=approvals",
+ "action": "App\\Http\\Controllers\\RequisitionController@index",
+ "full_page": false
+ },
+ {
+ "role": "department_head",
+ "group": "Aprovações",
+ "id": "decisions",
+ "label": "Minhas decisões",
+ "path": "/requisicoes?view=decided",
+ "action": "App\\Http\\Controllers\\RequisitionController@index",
+ "full_page": false
+ },
+ {
+ "role": "department_head",
+ "group": "Consultas e adjudicações",
+ "id": "quotations",
+ "label": "Cotações e comparação",
+ "path": "/cotacoes",
+ "action": "App\\Http\\Controllers\\QuotationController@index",
+ "full_page": false
+ },
+ {
+ "role": "department_head",
+ "group": "Consultas e adjudicações",
+ "id": "orders",
+ "label": "Adjudicações e ordens",
+ "path": "/ordens",
+ "action": "App\\Http\\Controllers\\PurchaseController@index",
+ "full_page": false
+ },
+ {
+ "role": "department_head",
+ "group": "Consultas e adjudicações",
+ "id": "purchase_review",
+ "label": "Revisão e cancelamento",
+ "path": "/revisao-compras",
+ "action": "App\\Http\\Controllers\\PurchaseControlController@index",
+ "full_page": false
+ },
+ {
+ "role": "department_head",
+ "group": "Fornecedores",
+ "id": "suppliers",
+ "label": "Cadastro e documentos",
+ "path": "/fornecedores",
+ "action": "App\\Http\\Controllers\\SupplierController@index",
+ "full_page": false
+ },
+ {
+ "role": "department_head",
+ "group": "Fornecedores",
+ "id": "supplier_evaluations",
+ "label": "Avaliação de fornecedores",
+ "path": "/fornecedores/avaliacoes",
+ "action": "App\\Http\\Controllers\\SupplierEvaluationController@index",
+ "full_page": false
+ },
+ {
+ "role": "department_head",
+ "group": "Recepções e armazém",
+ "id": "receipts",
+ "label": "Recepções e divergências",
+ "path": "/recepcoes",
+ "action": "App\\Http\\Controllers\\GoodsReceiptController@index",
+ "full_page": false
+ },
+ {
+ "role": "department_head",
+ "group": "Recepções e armazém",
+ "id": "equipment",
+ "label": "Equipamentos e peças",
+ "path": "/equipamentos",
+ "action": "App\\Http\\Controllers\\EquipmentController@index",
+ "full_page": false
+ },
+ {
+ "role": "department_head",
+ "group": "Administração e controlo",
+ "id": "conflicts",
+ "label": "Conflitos de interesses",
+ "path": "/conflitos",
+ "action": "App\\Http\\Controllers\\ConflictDeclarationController@index",
+ "full_page": false
+ },
+ {
+ "role": "department_head",
+ "group": "Relatórios",
+ "id": "procurement_reports",
+ "label": "Relatórios de compras",
+ "path": "/relatorios",
+ "action": "App\\Http\\Controllers\\ProcurementReportController@index",
+ "full_page": false
+ },
+ {
+ "role": "department_head",
+ "group": "Relatórios",
+ "id": "supplier_improvement_reports",
+ "label": "Medidas de melhoria",
+ "path": "/relatorios/medidas-fornecedores",
+ "action": "App\\Http\\Controllers\\SupplierImprovementReportController@index",
+ "full_page": false
+ },
+ {
+ "role": "warehouse",
+ "group": "Requisições",
+ "id": "new_request",
+ "label": "Nova requisição",
+ "path": "/requisicoes/criar",
+ "action": "App\\Http\\Controllers\\RequisitionController@create",
+ "full_page": false
+ },
+ {
+ "role": "warehouse",
+ "group": "Requisições",
+ "id": "my_requests",
+ "label": "Minhas requisições",
+ "path": "/requisicoes?view=mine",
+ "action": "App\\Http\\Controllers\\RequisitionController@index",
+ "full_page": false
+ },
+ {
+ "role": "warehouse",
+ "group": "Requisições",
+ "id": "my_drafts",
+ "label": "Os meus rascunhos",
+ "path": "/requisicoes?view=mine&status=draft",
+ "action": "App\\Http\\Controllers\\RequisitionController@index",
+ "full_page": false
+ },
+ {
+ "role": "warehouse",
+ "group": "Requisições",
+ "id": "all_requests",
+ "label": "Todas as autorizadas",
+ "path": "/requisicoes?view=all",
+ "action": "App\\Http\\Controllers\\RequisitionController@index",
+ "full_page": false
+ },
+ {
+ "role": "warehouse",
+ "group": "Requisições",
+ "id": "planning",
+ "label": "Planeamento",
+ "path": "/planeamento",
+ "action": "App\\Http\\Controllers\\PurchasePlanningController@index",
+ "full_page": false
+ },
+ {
+ "role": "warehouse",
+ "group": "Consultas e adjudicações",
+ "id": "purchase_review",
+ "label": "Revisão e cancelamento",
+ "path": "/revisao-compras",
+ "action": "App\\Http\\Controllers\\PurchaseControlController@index",
+ "full_page": false
+ },
+ {
+ "role": "warehouse",
+ "group": "Recepções e armazém",
+ "id": "receipts",
+ "label": "Recepções e divergências",
+ "path": "/recepcoes",
+ "action": "App\\Http\\Controllers\\GoodsReceiptController@index",
+ "full_page": false
+ },
+ {
+ "role": "warehouse",
+ "group": "Recepções e armazém",
+ "id": "stock",
+ "label": "Artigos e stock",
+ "path": "/stock",
+ "action": "App\\Http\\Controllers\\StockController@index",
+ "full_page": false
+ },
+ {
+ "role": "warehouse",
+ "group": "Recepções e armazém",
+ "id": "stock_operations",
+ "label": "Saídas e inventários",
+ "path": "/stock/operacoes",
+ "action": "App\\Http\\Controllers\\StockOperationController@index",
+ "full_page": false
+ },
+ {
+ "role": "warehouse",
+ "group": "Recepções e armazém",
+ "id": "equipment",
+ "label": "Equipamentos e peças",
+ "path": "/equipamentos",
+ "action": "App\\Http\\Controllers\\EquipmentController@index",
+ "full_page": false
+ },
+ {
+ "role": "warehouse",
+ "group": "Administração e controlo",
+ "id": "conflicts",
+ "label": "Conflitos de interesses",
+ "path": "/conflitos",
+ "action": "App\\Http\\Controllers\\ConflictDeclarationController@index",
+ "full_page": false
+ },
+ {
+ "role": "requester",
+ "group": "Requisições",
+ "id": "new_request",
+ "label": "Nova requisição",
+ "path": "/requisicoes/criar",
+ "action": "App\\Http\\Controllers\\RequisitionController@create",
+ "full_page": false
+ },
+ {
+ "role": "requester",
+ "group": "Requisições",
+ "id": "my_requests",
+ "label": "Minhas requisições",
+ "path": "/requisicoes?view=mine",
+ "action": "App\\Http\\Controllers\\RequisitionController@index",
+ "full_page": false
+ },
+ {
+ "role": "requester",
+ "group": "Requisições",
+ "id": "my_drafts",
+ "label": "Os meus rascunhos",
+ "path": "/requisicoes?view=mine&status=draft",
+ "action": "App\\Http\\Controllers\\RequisitionController@index",
+ "full_page": false
+ },
+ {
+ "role": "requester",
+ "group": "Requisições",
+ "id": "all_requests",
+ "label": "Todas as autorizadas",
+ "path": "/requisicoes?view=all",
+ "action": "App\\Http\\Controllers\\RequisitionController@index",
+ "full_page": false
+ },
+ {
+ "role": "requester",
+ "group": "Requisições",
+ "id": "planning",
+ "label": "Planeamento",
+ "path": "/planeamento",
+ "action": "App\\Http\\Controllers\\PurchasePlanningController@index",
+ "full_page": false
+ },
+ {
+ "role": "requester",
+ "group": "Consultas e adjudicações",
+ "id": "purchase_review",
+ "label": "Revisão e cancelamento",
+ "path": "/revisao-compras",
+ "action": "App\\Http\\Controllers\\PurchaseControlController@index",
+ "full_page": false
+ },
+ {
+ "role": "requester",
+ "group": "Administração e controlo",
+ "id": "conflicts",
+ "label": "Conflitos de interesses",
+ "path": "/conflitos",
+ "action": "App\\Http\\Controllers\\ConflictDeclarationController@index",
+ "full_page": false
+ }
+ ]
+}
diff --git a/docs/proofs/organization-access-http.json b/docs/proofs/organization-access-http.json
new file mode 100644
index 0000000..3c47e0f
--- /dev/null
+++ b/docs/proofs/organization-access-http.json
@@ -0,0 +1,25 @@
+{
+ "checked_at": "2026-10-30T08:46:09.751Z",
+ "base": "http://127.0.0.1:8005",
+ "path": "/administracao/parametrizacao",
+ "roles": [
+ "admin",
+ "requester",
+ "department_head",
+ "procurement",
+ "finance",
+ "warehouse"
+ ],
+ "owner_access": true,
+ "other_legacy_profiles_refused": true,
+ "passwords_not_returned": true,
+ "configuration_state": {
+ "active": false,
+ "version": 1,
+ "profiles": 0
+ },
+ "business_commands": 0,
+ "accounts_created": 0,
+ "permissions_changed": false,
+ "visual_review": false
+}
diff --git a/docs/proofs/organization-configuration.json b/docs/proofs/organization-configuration.json
new file mode 100644
index 0000000..7d39e0c
--- /dev/null
+++ b/docs/proofs/organization-configuration.json
@@ -0,0 +1,63 @@
+{
+ "checked_at": "2026-10-30T08:47:37.236992+00:00",
+ "full_suite": {
+ "total": 603,
+ "passed": 601,
+ "skipped": 2,
+ "assertions": 5157,
+ "log": "storage/logs/organization-delivery-tests.log"
+ },
+ "postgresql_configuration": {
+ "tests": 13,
+ "passed": 13,
+ "assertions": 96,
+ "disposable_database_removed": true,
+ "log": "storage/logs/organization-pg-tests.log"
+ },
+ "phpstan_errors": 0,
+ "typescript": "passed",
+ "lint_format_targeted": "passed",
+ "pint": "passed",
+ "build": "passed",
+ "build_log": "storage/logs/organization-build.log",
+ "runtime_files_verified": 18,
+ "menu_entries": 107,
+ "evaluation_catalog_pages": 23,
+ "installed_only_in_evaluation": true,
+ "configuration_activated_in_evaluation": false,
+ "accounts_or_permissions_changed_in_evaluation": false,
+ "maintenance": {
+ "named_account": true,
+ "normal_authentication": true,
+ "expiry_required": true,
+ "revocable": true,
+ "audited": true,
+ "read_only": true,
+ "universal_password": false,
+ "hidden_entry": false
+ },
+ "domain_installed": false,
+ "visual_review": false,
+ "remaining": [
+ "permission granularity by operation",
+ "universal workflow stage/condition editor",
+ "specific common financial identity and entitlement associations",
+ "visual and print review"
+ ],
+ "sources": {
+ "app/Services/OrganizationAccess.php": "872f06e50d561714ab50d88dc113c0fa054dea9f170a09bb37f8d39d07b78a09",
+ "app/Services/OrganizationConfiguration.php": "e2d6e725a78dc4f10d931017969d0fcdacaed4dd6b37a4a08a96cb44b09fd1b5",
+ "app/Http/Controllers/OrganizationConfigurationController.php": "9765cee7b1770f48fde4affcefc518a36576190bbaeac8bce079655353377c72",
+ "app/Http/Controllers/CompanyController.php": "5a8112e0f1535f90aeb8cf9785259da8d713d1bfcd3ad908aa3eabd161dba8a3",
+ "app/Http/Middleware/EnsureOrganizationAccess.php": "6782164f18f982fedb91dfb4fb2365895c757d776d7bf1728a91682fa95b8b02",
+ "app/Models/User.php": "835c119c097e7927136e8c7775ebb5ed677f964e766a213ec3c04e0b7388e740",
+ "app/Enums/PurchasingRole.php": "91da2d40bf11988fd6324aba8e5068e6be7c92222be31baf83844c134affe1c4",
+ "database/migrations/2026_10_30_000027_create_organization_access.php": "130d92ba8a093a8f77678e1ce4c1a90125bb996e88ed81633359d224fe77a853",
+ "database/seeders/DatabaseSeeder.php": "2aaea71ca2eba604abd5723e95cd1a99521c61b6f00b70dfedba246f3dcfae48",
+ "resources/js/pages/company/parameters.tsx": "c0760c50d100c8b2dfb3d3ad3b2a05fae14eed7900e95461c766a885d07677d1",
+ "resources/js/pages/company/setup.tsx": "614000ad22692ea3e707a00103227953d95f32fd7d9a7fc031bdbba7a1ccdb48",
+ "resources/js/lib/procurement-navigation.ts": "d7a42ec76e0166e4783bb496834325ad583b903ebf8c999c2ba5a2307431d56d",
+ "tests/Feature/OrganizationConfigurationTest.php": "eef7e82a6efc8ebad46cea8752861c01bec53c552874d72120a6e982f0114d73",
+ "scripts/organization-access-check.mjs": "1ca7cd2bdafa5f62afb26fa0b074d879273cda27c8350b968de54c8f53328bb0"
+ }
+}
diff --git a/docs/proofs/organization-install.json b/docs/proofs/organization-install.json
new file mode 100644
index 0000000..614b634
--- /dev/null
+++ b/docs/proofs/organization-install.json
@@ -0,0 +1,18 @@
+{
+ "checked_at": "2026-10-30T08:28:25+00:00",
+ "migration": "2026_10_30_000027_create_organization_access",
+ "environment": "local evaluation 8005",
+ "backup_file": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-organization-20261030-082820\/before.dump",
+ "backup_sha256": "5ae87fd10b3c7cde3021a8d0976f281a41db32d45e6d7f658de7e20e89e854f6",
+ "backup_bytes": 391786,
+ "restore_verified": true,
+ "existing_tables_preserved": 84,
+ "new_tables": {
+ "organization_access": 0,
+ "organization_profiles": 0,
+ "organization_memberships": 0
+ },
+ "accounts_or_grants_changed": false,
+ "other_migrations_applied": false,
+ "domain_installed": false
+}
diff --git a/docs/proofs/organization-pg.json b/docs/proofs/organization-pg.json
new file mode 100644
index 0000000..c2f1fde
--- /dev/null
+++ b/docs/proofs/organization-pg.json
@@ -0,0 +1,9 @@
+{
+ "checked_at": "2026-10-30T08:40:09+00:00",
+ "database": "disposable PostgreSQL",
+ "application_role_reused_without_privilege_change": true,
+ "result": "passed",
+ "test_file": "tests\/Feature\/OrganizationConfigurationTest.php",
+ "original_evaluation_untouched": true,
+ "database_removed": true
+}
diff --git a/docs/proofs/procurement-reports.json b/docs/proofs/procurement-reports.json
new file mode 100644
index 0000000..4472b3b
--- /dev/null
+++ b/docs/proofs/procurement-reports.json
@@ -0,0 +1,50 @@
+{
+ "date": "2026-10-30",
+ "base_commit": "0235c7609ed52345e91ae1a6b268e45e1eb991af",
+ "scope": "Menu routing inventory, central filtered procurement reports/CSV and existing individual report extended to receipt/invoice/external-payment records.",
+ "checks": {
+ "feature_passed": 14,
+ "assertions": 223,
+ "new_cases": 8,
+ "existing_report_regressions": 6,
+ "export_fixture_records": 207,
+ "menu_entries_across_roles": 102,
+ "roles": 6,
+ "types": "passed",
+ "lint": "passed",
+ "format": "passed",
+ "pint": "passed",
+ "phpstan": "passed after correcting the documented date cast type",
+ "build": "passed",
+ "new_module_http": 200,
+ "evaluation_entry_http": 200,
+ "runtime_manifest_files": 18
+ },
+ "source_sha256": {
+ "app/Http/Controllers/ProcurementReportController.php": "5161063f8f9684595c83300f22e6516e03700917e3b578651dee7254ee5f6a55",
+ "app/Services/ProcurementReviewReport.php": "1d5a41b59c471ad1db5e7f39eb15ac4b080239968341ccdf9a78ce17a301144c",
+ "app/Models/GoodsReceipt.php": "ec9b86ca625a8d4d8972ba1d91636bf37475f680f2212faa1e31c44709660a5a",
+ "routes/web.php": "1014ea42f0544428df49304c16cc55fb0632188ac0ad1940ba6a637b9f228be0",
+ "resources/js/lib/procurement-navigation.ts": "f250199564cd56e73ff5d5851a19dcbbe41df6da7b5c2cb9024c0965b8e087dc",
+ "resources/js/pages/reports/procurement.tsx": "a4ec4c51a8b7b8012852f1ad1d36ac931fec25c41a383c10a9449fddc3bd23d5",
+ "resources/js/pages/purchases/review-report.tsx": "8a5a02d99b5dd5d6f50a41e6e76711b15e578fd09dc039e2b6defc64d20abe04",
+ "tests/Feature/ProcurementReportsTest.php": "fc88ceef66d419ef72e9231e8273da20f2e1f400ad9610ad5ff85cadeb2259b2",
+ "scripts/menu-code-check.mjs": "8480ec5b1f3fd26aa7ff7cbc52c304a632b11e0eaa5a4cacadab0e119ceac236"
+ },
+ "menu_proof": "docs/proofs/menu-code-contract.json",
+ "build_log_local": "storage/logs/development-reports-build.log",
+ "build_log_sha256": "2709a9c383c3110469d6aa08a1efc8b821e24c3b7b8f9a5d3552e403ea9f5ad6",
+ "preservation": {
+ "migrations": 0,
+ "active_business_writes": 0,
+ "new_accounts_or_grants": 0,
+ "balance_or_tax_recalculation": false,
+ "remote_deployment": false
+ },
+ "limits": [
+ "Visual and manual printing not executed.",
+ "PostgreSQL snapshot clause implemented using the existing report pattern; new tests used isolated SQLite, not a PostgreSQL concurrency proof.",
+ "Supplier evaluations remain an interface proposal pending its operational implementation.",
+ "Not a consolidated debt/bank/accounting report; common settlements keep their own history."
+ ]
+}
diff --git a/docs/proofs/supplier-evaluations-http.json b/docs/proofs/supplier-evaluations-http.json
new file mode 100644
index 0000000..369e196
--- /dev/null
+++ b/docs/proofs/supplier-evaluations-http.json
@@ -0,0 +1,15 @@
+{
+ "checked_at": "2026-10-30T07:33:29.638Z",
+ "base": "http://127.0.0.1:8005",
+ "report_path": "/fornecedores/avaliacoes/1/relatorio",
+ "revision_path": "/fornecedores/avaliacoes/2",
+ "create_submit_return_correct_approve": true,
+ "self_approval_refused": true,
+ "stale_write_refused": true,
+ "approved_edit_refused": true,
+ "prior_approved_revision_preserved": true,
+ "history_events": 6,
+ "synthetic_example_only": true,
+ "financial_commands": 0,
+ "visual_review": false
+}
diff --git a/docs/proofs/supplier-evaluations-install.json b/docs/proofs/supplier-evaluations-install.json
new file mode 100644
index 0000000..7a8576a
--- /dev/null
+++ b/docs/proofs/supplier-evaluations-install.json
@@ -0,0 +1,16 @@
+{
+ "checked_at": "2026-10-30T07:31:39+00:00",
+ "migration": "2026_10_30_000024_create_supplier_evaluations",
+ "environment": "local evaluation 8005",
+ "backup_file": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-evaluations-20261030-073134\/before.dump",
+ "backup_sha256": "a919349718964be17fafbe4ec407dc7ec5b096c9c1f961db42cb34dfe3ba1123",
+ "backup_bytes": 364285,
+ "restore_verified": true,
+ "existing_tables_preserved": 81,
+ "new_tables": {
+ "supplier_evaluations": 0
+ },
+ "accounts_or_grants_changed": false,
+ "other_migrations_applied": false,
+ "domain_installed": false
+}
diff --git a/docs/proofs/supplier-evaluations-pg.json b/docs/proofs/supplier-evaluations-pg.json
new file mode 100644
index 0000000..ed9eab8
--- /dev/null
+++ b/docs/proofs/supplier-evaluations-pg.json
@@ -0,0 +1,8 @@
+{
+ "checked_at": "2026-10-30T07:34:52+00:00",
+ "environment": "local evaluation 8005",
+ "approved_update": "refused by PostgreSQL trigger",
+ "approved_delete": "refused by PostgreSQL trigger",
+ "approved_row_unchanged": true,
+ "probe_transactions_rolled_back": true
+}
diff --git a/docs/proofs/supplier-improvement-report-http.json b/docs/proofs/supplier-improvement-report-http.json
new file mode 100644
index 0000000..c27905e
--- /dev/null
+++ b/docs/proofs/supplier-improvement-report-http.json
@@ -0,0 +1,24 @@
+{
+ "checked_at": "2026-10-30T07:51:21.312Z",
+ "base": "http://127.0.0.1:8005",
+ "path": "/relatorios/medidas-fornecedores",
+ "readers": [
+ "admin",
+ "procurement",
+ "finance",
+ "department_head"
+ ],
+ "denied": [
+ "requester",
+ "warehouse"
+ ],
+ "total": 1,
+ "filtered_total": 1,
+ "filters_and_csv_match": true,
+ "csv_ignores_page": true,
+ "closed_not_overdue": true,
+ "invalid_range_refused": true,
+ "linked_evaluation_and_report": true,
+ "business_commands": 0,
+ "visual_review": false
+}
diff --git a/docs/proofs/supplier-improvement-report.json b/docs/proofs/supplier-improvement-report.json
new file mode 100644
index 0000000..6d3fbdb
--- /dev/null
+++ b/docs/proofs/supplier-improvement-report.json
@@ -0,0 +1,29 @@
+{
+ "checked_at": "2026-10-30T07:52:09.806123+00:00",
+ "targeted_tests": 36,
+ "assertions": 542,
+ "new_report_test_cases": 6,
+ "csv_batch_test_rows": 207,
+ "phpstan_errors": 0,
+ "typescript": "passed",
+ "lint_and_format_targeted": "passed",
+ "pint_targeted": "passed",
+ "build": "passed",
+ "build_log": "storage/logs/supplier-improvement-report-build.log",
+ "menu_entries": 106,
+ "roles": 6,
+ "evaluation_catalog_pages": 22,
+ "schema_changes": false,
+ "business_commands": 0,
+ "domain_installed": false,
+ "visual_review": false,
+ "sources": {
+ "app/Http/Controllers/SupplierImprovementReportController.php": "fdaf0d5484e91481cdd5d676eb6951f8575bcc328961e993acf3290f5d8343cc",
+ "app/Models/SupplierImprovement.php": "762af10d9c14d70dd03e51ef6d9a80efe36171a9a0121a985ae0c17f06f03d4e",
+ "resources/js/pages/reports/supplier-improvements.tsx": "070d4c5eaf0a75439751504b21262df1c2f0e60d59e93538ff5d9c5959fe6398",
+ "resources/js/components/supplier-improvements.tsx": "7add7f51d907943682d3f132dc135f0e41b726dd67ddf2478852cf30ba5a21dd",
+ "resources/js/lib/procurement-navigation.ts": "a2a26bbd50c288e7cde589c7d8c2aaa3929f5ae20fc8b01c502db031d95085d4",
+ "tests/Feature/SupplierImprovementReportTest.php": "daf90f8da729a489bdc5116b318514c82a91b89ffbfb2c082b11457661544985",
+ "scripts/supplier-improvement-report-check.mjs": "60c57d36ebc83928198734573b5072240291504b57f2b88be3167f8f7c40c906"
+ }
+}
diff --git a/docs/proofs/supplier-improvements-http.json b/docs/proofs/supplier-improvements-http.json
new file mode 100644
index 0000000..a0f641a
--- /dev/null
+++ b/docs/proofs/supplier-improvements-http.json
@@ -0,0 +1,19 @@
+{
+ "checked_at": "2026-10-30T07:41:16.109Z",
+ "base": "http://127.0.0.1:8005",
+ "evaluation_path": "/fornecedores/avaliacoes/1",
+ "measure_id": 1,
+ "create_retry_idempotent": true,
+ "missing_evidence_refused": true,
+ "self_closure_refused": true,
+ "planner_closure_refused": true,
+ "return_replan_correct_submit_close": true,
+ "stale_plan_refused": true,
+ "closed_edit_refused": true,
+ "parent_evaluation_unchanged": true,
+ "history_events": 7,
+ "report_includes_closed_measure": true,
+ "synthetic_example_only": true,
+ "financial_commands": 0,
+ "visual_review": false
+}
diff --git a/docs/proofs/supplier-improvements-install.json b/docs/proofs/supplier-improvements-install.json
new file mode 100644
index 0000000..285d572
--- /dev/null
+++ b/docs/proofs/supplier-improvements-install.json
@@ -0,0 +1,16 @@
+{
+ "checked_at": "2026-10-30T07:40:33+00:00",
+ "migration": "2026_10_30_000025_create_supplier_improvements",
+ "environment": "local evaluation 8005",
+ "backup_file": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-improvements-20261030-074028\/before.dump",
+ "backup_sha256": "57ae564afea50f15700003de3aeb7a8f2a9ecd72c76d545f9687cbe91d051811",
+ "backup_bytes": 375123,
+ "restore_verified": true,
+ "existing_tables_preserved": 82,
+ "new_tables": {
+ "supplier_improvements": 0
+ },
+ "accounts_or_grants_changed": false,
+ "other_migrations_applied": false,
+ "domain_installed": false
+}
diff --git a/docs/proofs/supplier-improvements-pg.json b/docs/proofs/supplier-improvements-pg.json
new file mode 100644
index 0000000..c925729
--- /dev/null
+++ b/docs/proofs/supplier-improvements-pg.json
@@ -0,0 +1,8 @@
+{
+ "checked_at": "2026-10-30T07:41:48+00:00",
+ "environment": "local evaluation 8005",
+ "closed_update": "refused by PostgreSQL trigger",
+ "closed_delete": "refused by PostgreSQL trigger",
+ "closed_row_unchanged": true,
+ "probe_transactions_rolled_back": true
+}
diff --git a/docs/proofs/supplier-responses-http.json b/docs/proofs/supplier-responses-http.json
new file mode 100644
index 0000000..881bcb9
--- /dev/null
+++ b/docs/proofs/supplier-responses-http.json
@@ -0,0 +1,20 @@
+{
+ "checked_at": "2026-10-30T07:57:43.108Z",
+ "base": "http://127.0.0.1:8005",
+ "evaluation_path": "/fornecedores/avaliacoes/1",
+ "response_id": 1,
+ "idempotent_creation": true,
+ "incomplete_submit_refused": true,
+ "stale_save_refused": true,
+ "self_decision_refused": true,
+ "return_correction_independent_conclusion": true,
+ "accepted_follow_up_required": true,
+ "concluded_edit_refused": true,
+ "parent_evaluation_unchanged": true,
+ "history_events": 7,
+ "individual_report": true,
+ "synthetic_example_only": true,
+ "external_messages_sent": 0,
+ "financial_commands": 0,
+ "visual_review": false
+}
diff --git a/docs/proofs/supplier-responses-install.json b/docs/proofs/supplier-responses-install.json
new file mode 100644
index 0000000..12b590f
--- /dev/null
+++ b/docs/proofs/supplier-responses-install.json
@@ -0,0 +1,16 @@
+{
+ "checked_at": "2026-10-30T07:56:47+00:00",
+ "migration": "2026_10_30_000026_create_supplier_responses",
+ "environment": "local evaluation 8005",
+ "backup_file": "C:\/laragon\/data\/sgf-tempo\/backups\/compras-responses-20261030-075643\/before.dump",
+ "backup_sha256": "278b88b2f46d9b80df3de878c3964a7f7ab62022bd082ce6a26fdd0e2ccccb54",
+ "backup_bytes": 383864,
+ "restore_verified": true,
+ "existing_tables_preserved": 83,
+ "new_tables": {
+ "supplier_responses": 0
+ },
+ "accounts_or_grants_changed": false,
+ "other_migrations_applied": false,
+ "domain_installed": false
+}
diff --git a/docs/proofs/supplier-responses-pg.json b/docs/proofs/supplier-responses-pg.json
new file mode 100644
index 0000000..b3f0f7e
--- /dev/null
+++ b/docs/proofs/supplier-responses-pg.json
@@ -0,0 +1,8 @@
+{
+ "checked_at": "2026-10-30T07:58:09+00:00",
+ "environment": "local evaluation 8005",
+ "decided_update": "refused by PostgreSQL trigger",
+ "decided_delete": "refused by PostgreSQL trigger",
+ "decided_row_unchanged": true,
+ "probe_transactions_rolled_back": true
+}
diff --git a/docs/proofs/supplier-responses.json b/docs/proofs/supplier-responses.json
new file mode 100644
index 0000000..7ce338c
--- /dev/null
+++ b/docs/proofs/supplier-responses.json
@@ -0,0 +1,28 @@
+{
+ "checked_at": "2026-10-30T07:59:03.931961+00:00",
+ "targeted_tests": 39,
+ "assertions": 505,
+ "new_test_cases": 11,
+ "phpstan_errors": 0,
+ "typescript": "passed",
+ "lint_format_pint_targeted": "passed",
+ "build": "passed",
+ "build_log": "storage/logs/supplier-responses-build.log",
+ "menu_entries": 106,
+ "roles": 6,
+ "installed_in_local_evaluation": true,
+ "domain_installed": false,
+ "external_messages_sent": 0,
+ "visual_review": false,
+ "source_hashes": {
+ "app/Models/SupplierResponse.php": "fc3877f2e787483cb56f31ca877eb2dc4f2d266c2eefa47dab1b1ae3ecf163fc",
+ "app/Services/SupplierResponseWorkflow.php": "772af0878f55cd9818cddec62aca3850ae4868c13acf86fe7dd003bac1fbb0b5",
+ "app/Http/Controllers/SupplierResponseController.php": "6960500484974f6ba2be1c63bb1156abde976c9e42837f6d3101a1f8a73092d3",
+ "app/Http/Controllers/SupplierEvaluationController.php": "2523eb6a858fdb62c494d74a8ce7988e7f29d4e0c51c53d3f0899de02987f17d",
+ "database/migrations/2026_10_30_000026_create_supplier_responses.php": "d414f637587088e589d886f792d678674337707e8391a0ab0fcf86dc9ebfaaf4",
+ "resources/js/components/supplier-responses.tsx": "ec0becd110e879f5929a7179c61352e1f31478674e654a41b1f85c64ecd1adf1",
+ "resources/js/pages/suppliers/evaluation.tsx": "509d27438a2a3b5337572357b9c69a2703201789ea75a487b36dfc301f65c8db",
+ "tests/Feature/SupplierResponseTest.php": "00faa0131f7335e1d2f18d781aa16cb914bf13acb1090ab902269f737b87c3c6",
+ "scripts/supplier-response-check.mjs": "31c7b741bbb0becc0426d0437a1d9853616199a86730831dc09b902e66f469c2"
+ }
+}
diff --git a/packages/README.md b/packages/README.md
new file mode 100644
index 0000000..2a78044
--- /dev/null
+++ b/packages/README.md
@@ -0,0 +1,9 @@
+# Núcleos de execução distribuídos com Compras
+
+Estes ficheiros são artefactos fixados dos núcleos comuns de orçamento e obrigações, cuja origem é `sgf-tempo/packages`. Não são outra implementação financeira mantida independentemente.
+
+O Compras carrega estes artefactos em todos os ambientes, incluindo produção, sem precisar da pasta vizinha do SGF. As configurações `COMMON_BUDGET_PATH` e `COMMON_PAYABLES_PATH` continuam disponíveis para instalações que forneçam os mesmos pacotes por outro mecanismo.
+
+`runtime-manifest.json` identifica cada ficheiro distribuído por SHA-256. Conferir com `composer run runtime:check`. Actualizações devem vir da origem comum, passar os testes dos adaptadores e renovar o manifesto; não editar os artefactos directamente nem aplicar formatação que altere os bytes aceites.
+
+O empacotamento não executa migrações, activa pagamentos, cria direitos ou transporta dados. As funcionalidades sintéticas ainda presentes nos núcleos conservam o seu âmbito até à implementação da respectiva operação comercial. Não distribuir configurações privadas ou bases de avaliação com o produto.
diff --git a/packages/budget-control/README.md b/packages/budget-control/README.md
new file mode 100644
index 0000000..35abad1
--- /dev/null
+++ b/packages/budget-control/README.md
@@ -0,0 +1,11 @@
+# Shared budget control — COMMON-BUDGET-SYNTHETIC-v1
+
+Canonical source: this directory. PHP 8.4+, Illuminate Database 13 as supplied by each host. No Brick Math dependency, new vendor packages or version changes. Monetary inputs are exact existing integer MZN cents; document tax/quantity calculations remain in their current host workflows.
+
+`BudgetContext` explicitly carries actor, organisation scope, fiscal reference year and supported currency. `BudgetControl` is the single writer for allocations, approvals, reinforcements, reservation/award/commit/release and invoice budget consumption. Host callbacks acquire the organisation lock, revalidate identity/permissions, audit within the same transaction and check proposer eligibility. No call to auth/session/global DB facade occurs in the package.
+
+Compras uses its existing tables and scope `tenant_id` (company); SGF QA uses separate `cb_` tables scoped by tenant/company/unit with mapped centres/origins. No automatic identifier conversion. There are no sales, payable, payment, industrial budget or remote SGF dependencies.
+
+Load `bootstrap.php` after the host's existing vendor autoloader. For local Compras development the provider locates this source tree. A standalone deployment installs the same versioned package artifact under `packages/budget-control`, or sets `COMMON_BUDGET_PATH` to its installed artifact; the local sibling fallback is disabled in production. No second maintained source copy is created. QA records the loaded source hash in both booted Laravel hosts.
+
+Existing tables/constraints remain valid in Compras; no live migration is run. SGF schema/rights are QA-only in this increment. SGF operational activation, full procurement UI installation and customer data migration remain pending. See the delivery report for evidence and limitations.
diff --git a/packages/budget-control/bootstrap.php b/packages/budget-control/bootstrap.php
new file mode 100644
index 0000000..f780651
--- /dev/null
+++ b/packages/budget-control/bootstrap.php
@@ -0,0 +1,6 @@
+ $scope Explicit host identifiers, never inferred from each other. */
+ public function __construct(public int $actorId, public array $scope, public int $year, public string $currency)
+ {
+ if ($actorId < 1 || $scope === [] || $year < 2026 || $currency !== 'MZN') {
+ throw new \InvalidArgumentException('Contexto orçamental ou moeda não suportados.');
+ }
+ foreach ($scope as $column => $id) {
+ if (! in_array($column, ['tenant_id', 'company_id', 'unit_id'], true) || ! is_int($id) || $id < 1) {
+ throw new \InvalidArgumentException('Âmbito explícito inválido.');
+ }
+ }
+ }
+}
diff --git a/packages/budget-control/src/BudgetControl.php b/packages/budget-control/src/BudgetControl.php
new file mode 100644
index 0000000..ef5db7f
--- /dev/null
+++ b/packages/budget-control/src/BudgetControl.php
@@ -0,0 +1,291 @@
+db->table($this->prefix.$table)->where($this->context->scope);
+ }
+
+ private function atomic(string $action, callable $work): mixed
+ {
+ return $this->db->transaction(function () use ($action, $work) {
+ ($this->lockAndAuthorize)($action, $this->context);
+
+ return $work();
+ });
+ }
+
+ private function fail(string $message, string $field = 'budget', int $status = 422): never
+ {
+ throw new BudgetFailure($message, $field, $status);
+ }
+
+ private function allocation(int $id): object
+ {
+ return $this->query('budgets')->where('id', $id)->lockForUpdate()->first()
+ ?? $this->fail('Dotação fora do âmbito autorizado.', status: 403);
+ }
+
+ private function stamp(): string
+ {
+ return date('Y-m-d H:i:s');
+ }
+
+ private function emit(string $action, array $data, ?int $origin = null): void
+ {
+ ($this->audit)($action, $data, $origin, $this->context);
+ }
+
+ public static function approvedTotal(int $initial, int $reinforcements): int
+ {
+ $sum = $initial + $reinforcements;
+ if (! is_int($sum)) {
+ throw new \OverflowException('Montante orçamental não representável.');
+ }
+
+ return $sum;
+ }
+
+ private function total(object $budget): int
+ {
+ return self::approvedTotal((int) $budget->amount_minor, (int) $this->query('budget_reinforcements')->where('budget_id', $budget->id)->where('status', 'approved')->sum('amount_minor'));
+ }
+
+ /** Host validates the cost centre and input format; original integer amount is preserved. */
+ public function create(array $data): int
+ {
+ return $this->atomic('create', function () use ($data) {
+ $fields = ['cost_center_id', 'year', 'kind', 'includes_tax', 'amount_minor', 'reason'];
+ if (array_diff(array_keys($data), $fields) !== [] || array_diff($fields, array_keys($data)) !== []
+ || ! is_int($data['cost_center_id']) || $data['cost_center_id'] < 1
+ || ! is_int($data['year']) || $data['year'] < 2026 || $data['year'] > 2100
+ || ! is_bool($data['includes_tax']) || ! is_string($data['reason']) || trim($data['reason']) === '') {
+ $this->fail('Dados de dotação inválidos ou campos protegidos.');
+ }
+ if ($this->query('budgets')->where('cost_center_id', $data['cost_center_id'])->where('year', $data['year'])->exists()) {
+ $this->fail('Já existe dotação para este centro e exercício.', status: 409);
+ }
+ if (! is_int($data['amount_minor']) || $data['amount_minor'] < 0 || $data['amount_minor'] > self::MAX_MINOR || ! in_array($data['kind'], ['operating', 'investment'], true)) {
+ $this->fail('Dotação não representável.');
+ }
+ $id = $this->query('budgets')->insertGetId($this->context->scope + $data + ['created_by' => $this->context->actorId, 'created_at' => $this->stamp(), 'updated_at' => $this->stamp()]);
+ $this->emit('budget.created', (array) $this->allocation($id));
+
+ return $id;
+ });
+ }
+
+ public function approve(int $id): void
+ {
+ $this->atomic('approve', function () use ($id) {
+ $budget = $this->allocation($id);
+ if ((int) $budget->created_by === $this->context->actorId) {
+ $this->fail('Aprovação exige identidade distinta.', status: 403);
+ }
+ if ($budget->approved_by) {
+ return;
+ }
+ $this->query('budgets')->where('id', $id)->update(['approved_by' => $this->context->actorId, 'approved_at' => $this->stamp(), 'updated_at' => $this->stamp()]);
+ $this->emit('budget.approved', (array) $this->allocation($id));
+ });
+ }
+
+ /** @return array|null */
+ public function record(int $origin, int $centre, string $requiredKind, string $kind, int $amount = 0, ?int $subtotal = null): ?array
+ {
+ return $this->atomic('record', function () use ($origin, $centre, $requiredKind, $kind, $amount, $subtotal) {
+ if (! in_array($kind, ['reserve', 'award', 'commit', 'release'], true) || $amount < 0) {
+ $this->fail('Movimento orçamental inválido.');
+ }
+ $own = $this->query('budget_entries')->where('requisition_id', $origin);
+ $first = (clone $own)->orderBy('id')->first();
+ $budget = $first ? $this->allocation((int) $first->budget_id) : $this->query('budgets')->where('cost_center_id', $centre)->where('year', $this->context->year)->lockForUpdate()->first();
+ // Exact legacy behaviour: unconfigured development documents are explicitly uncontrolled.
+ if (! $budget) {
+ return null;
+ }
+ if ($budget->kind !== $requiredKind) {
+ $this->fail('A dotação deve ter a classificação explícita adequada: investimento para processos com equipamento; corrente para os restantes.');
+ }
+ if (! $budget->approved_by || ($kind !== 'release' && (int) $budget->year !== $this->context->year)) {
+ $this->fail('Dotação pendente de aprovação ou exercício encerrado.');
+ }
+ if (! $budget->includes_tax && in_array($kind, ['reserve', 'award', 'commit'], true)) {
+ $amount = $subtotal ?? throw new \LogicException('Subtotal necessário para a base sem IVA.');
+ }
+ $result = ['budget_id' => (int) $budget->id, 'year' => (int) $budget->year];
+ $old = (clone $own)->where('kind', $kind)->first();
+ if ($old) {
+ // Reconstruct original target before this entry, including historical rows without hashes.
+ $before = (int) (clone $own)->where('id', '<', $old->id)->sum('reserved_minor');
+ $target = $kind === 'commit' ? (int) $old->committed_minor : $before + (int) $old->reserved_minor;
+ if ($kind !== 'release' && $target !== $amount) {
+ $this->fail('Origem já registada com outro montante; não é criado segundo efeito.');
+ }
+
+ return $result;
+ }
+ $reserved = (int) (clone $own)->sum('reserved_minor');
+ $committed = (int) (clone $own)->sum('committed_minor');
+ if ($committed !== 0 || ($kind !== 'reserve' && ! $first)) {
+ $this->fail('Processo sem reserva válida ou já comprometido.');
+ }
+ [$rd, $cd] = match ($kind) {
+ 'reserve', 'award' => [$amount - $reserved, 0],
+ 'release' => [-$reserved, 0],
+ 'commit' => [-$reserved, $amount],
+ };
+ $balance = $this->balanceOf($budget);
+ if ($balance['available_minor'] < $rd + $cd) {
+ $this->fail('Saldo orçamental insuficiente. Não são permitidos excessos.');
+ }
+ $this->query('budget_entries')->insert($this->context->scope + ['budget_id' => $budget->id, 'requisition_id' => $origin, 'created_by' => $this->context->actorId, 'kind' => $kind, 'reserved_minor' => $rd, 'committed_minor' => $cd, 'created_at' => $this->stamp()]);
+ $this->emit('budget.'.$kind, ['budget_id' => $budget->id, 'reserved_minor' => $rd, 'committed_minor' => $cd], $origin);
+
+ return $result;
+ });
+ }
+
+ /** Only budget consumption: does not create a payable, invoice, payment or accounting entry. */
+ public function consume(int $origin, int $invoice, int $total, int $subtotal): array
+ {
+ return $this->atomic('consume', function () use ($origin, $invoice, $total, $subtotal) {
+ $own = $this->query('budget_entries')->where('requisition_id', $origin);
+ $commit = (clone $own)->where('kind', 'commit')->first();
+ if (! $commit) {
+ $this->fail('A ordem não possui compromisso orçamental. Factura permanece pendente; não é criado saldo retroactivo.', 'invoice');
+ }
+ $budget = $this->allocation((int) $commit->budget_id);
+ if (! $budget->approved_by || (int) $budget->year !== $this->context->year) {
+ $this->fail('Orçamento não aprovado ou fora do exercício.', 'invoice');
+ }
+ $amount = (int) ($budget->includes_tax ? $total : $subtotal);
+ $old = $this->query('budget_consumptions')->where('invoice_id', $invoice)->first();
+ $result = ['budget_id' => (int) $budget->id, 'consumed_minor' => $amount];
+ if ($old) {
+ $entry = (clone $own)->where('kind', 'invoice-'.$invoice)->first();
+ if ((int) $old->budget_id !== (int) $budget->id || (int) $old->amount_minor !== $amount || ! $entry || (int) $entry->committed_minor !== -$amount) {
+ $this->fail('Factura já consumida com outra origem ou montante.', 'invoice');
+ }
+
+ return $result;
+ }
+ if ($amount < 0 || $amount > (int) (clone $own)->sum('committed_minor')) {
+ $this->fail('Compromisso insuficiente para a factura.', 'invoice');
+ }
+ $this->query('budget_entries')->insert($this->context->scope + ['budget_id' => $budget->id, 'requisition_id' => $origin, 'created_by' => $this->context->actorId, 'kind' => 'invoice-'.$invoice, 'reserved_minor' => 0, 'committed_minor' => -$amount, 'created_at' => $this->stamp()]);
+ $this->query('budget_consumptions')->insert($this->context->scope + ['budget_id' => $budget->id, 'invoice_id' => $invoice, 'created_by' => $this->context->actorId, 'amount_minor' => $amount, 'created_at' => $this->stamp()]);
+ $this->emit('budget.consumed', $result + ['invoice_id' => $invoice], $origin);
+
+ return $result;
+ });
+ }
+
+ private function validReinforcement(object $budget, int $amount): void
+ {
+ if (! $budget->approved_by || (int) $budget->year !== $this->context->year || $amount <= 0) {
+ $this->fail('Exige dotação aprovada do exercício actual e montante positivo.', 'reinforcement');
+ }
+ if ($amount > self::MAX_MINOR - $this->total($budget)) {
+ $this->fail('A dotação acumulada ultrapassa o limite técnico de valores suportados.', 'reinforcement');
+ }
+ }
+
+ public function proposeReinforcement(int $id, int $amount, string $reference, string $reason): int
+ {
+ return $this->atomic('reinforcement.propose', function () use ($id, $amount, $reference, $reason) {
+ $budget = $this->allocation($id);
+ $hash = hash('sha256', json_encode(['budget_id' => $id, 'amount_minor' => $amount, 'reason' => $reason], JSON_THROW_ON_ERROR));
+ $old = $this->query('budget_reinforcements')->where('reference', $reference)->first();
+ if ($old) {
+ if ((int) $old->created_by !== $this->context->actorId || $old->payload_hash !== $hash) {
+ $this->fail('Referência já registada com outros dados.', 'reference');
+ }
+
+ return (int) $old->id;
+ }
+ $this->validReinforcement($budget, $amount);
+ if ($reference === '' || $reason === '') {
+ $this->fail('Referência e fundamento obrigatórios.', 'reinforcement');
+ }
+ $rid = $this->query('budget_reinforcements')->insertGetId($this->context->scope + ['budget_id' => $id, 'created_by' => $this->context->actorId, 'reference' => $reference, 'payload_hash' => $hash, 'amount_minor' => $amount, 'reason' => $reason, 'status' => 'pending', 'created_at' => $this->stamp(), 'updated_at' => $this->stamp()]);
+ $this->emit('budget.reinforcement_proposed', ['reinforcement_id' => $rid, 'budget_id' => $id, 'amount_minor' => $amount, 'status' => 'pending']);
+
+ return $rid;
+ });
+ }
+
+ public function reviewReinforcement(int $id, string $decision, string $reason): void
+ {
+ $this->atomic('reinforcement.review', function () use ($id, $decision, $reason) {
+ $r = $this->query('budget_reinforcements')->where('id', $id)->first() ?? $this->fail('Reforço fora do âmbito.', status: 403);
+ $budget = $this->allocation((int) $r->budget_id);
+ $r = $this->query('budget_reinforcements')->where('id', $id)->lockForUpdate()->first();
+ if ((int) $r->created_by === $this->context->actorId) {
+ $this->fail('Aprovação exige identidade distinta.', status: 403);
+ }
+ if (! in_array($decision, ['approved', 'rejected'], true) || trim($reason) === '') {
+ $this->fail('Decisão e fundamento obrigatórios.', 'reinforcement');
+ }
+ if ($r->status !== 'pending') {
+ if ($r->status !== $decision) {
+ $this->fail('Decisão final imutável.', 'reinforcement');
+ }
+
+ return;
+ }
+ if ($decision === 'approved') {
+ $this->validReinforcement($budget, (int) $r->amount_minor);
+ if (! ($this->eligibleAuthor)((int) $r->created_by, $this->context)) {
+ $this->fail('Autor da proposta deixou de ser elegível.', 'reinforcement');
+ }
+ }
+ $this->query('budget_reinforcements')->where('id', $id)->update(['status' => $decision, 'reviewed_by' => $this->context->actorId, 'review_reason' => trim($reason), 'reviewed_at' => $this->stamp(), 'updated_at' => $this->stamp()]);
+ $this->emit('budget.reinforcement_'.$decision, ['reinforcement_id' => $id, 'budget_id' => (int) $r->budget_id, 'amount_minor' => (int) $r->amount_minor, 'status' => $decision]);
+ });
+ }
+
+ private function balanceOf(object $budget): array
+ {
+ $entries = $this->query('budget_entries')->where('budget_id', $budget->id);
+ $reserved = (int) (clone $entries)->sum('reserved_minor');
+ $committed = (int) (clone $entries)->sum('committed_minor');
+ $consumed = (int) $this->query('budget_consumptions')->where('budget_id', $budget->id)->sum('amount_minor');
+ $total = $this->total($budget);
+
+ return ['approved_total_minor' => $budget->approved_by ? $total : 0, 'reinforced_minor' => $total - (int) $budget->amount_minor, 'reserved_minor' => $reserved, 'committed_minor' => $committed, 'consumed_minor' => $consumed, 'available_minor' => $budget->approved_by ? $total - $reserved - $committed - $consumed : 0];
+ }
+
+ public function balance(int $id): array
+ {
+ return $this->atomic('view', fn () => $this->balanceOf($this->allocation($id)));
+ }
+}
diff --git a/packages/budget-control/src/BudgetFailure.php b/packages/budget-control/src/BudgetFailure.php
new file mode 100644
index 0000000..498e987
--- /dev/null
+++ b/packages/budget-control/src/BudgetFailure.php
@@ -0,0 +1,11 @@
+db instanceof Connection
+ && $this->db->getSchemaBuilder()->hasTable('pa_history_imports')
+ && $this->db->getSchemaBuilder()->hasTable('pa_imported_payments');
+ }
+
+ public static function capture(array $source, array $payments): array
+ {
+ if (! is_int($source['payment_count'] ?? null) || $source['payment_count'] < 1 || count($payments) !== $source['payment_count']) {
+ throw new \DomainException('Histórico completo de pagamentos obrigatório; sem omissões ou importação vazia.');
+ }
+ $rows = [];
+ $totals = ['confirmed' => 0, 'pending' => 0, 'rejected' => 0];
+ foreach ($payments as $payment) {
+ foreach (['id', 'company', 'document', 'amount_minor', 'created_by', 'registration_audit_id'] as $key) {
+ if (! is_int($payment[$key] ?? null) || $payment[$key] < 1) {
+ throw new \DomainException('Referência ou montante de pagamento inválido: '.$key);
+ }
+ }
+ $id = $payment['id'];
+ $status = $payment['status'] ?? '';
+ if (isset($rows[$id]) || $payment['company'] !== $source['company'] || $payment['document'] !== $source['document']
+ || ($payment['currency'] ?? null) !== ($source['currency'] ?? null) || ! isset($totals[$status])) {
+ throw new \DomainException('Pagamento duplicado ou incompatível com empresa, factura, moeda ou estado.');
+ }
+ $date = $payment['paid_on'] ?? '';
+ if (! is_string($date) || ! preg_match('/^\d{4}-\d{2}-\d{2}$/D', $date)
+ || ! checkdate((int) substr($date, 5, 2), (int) substr($date, 8, 2), (int) substr($date, 0, 4))) {
+ throw new \DomainException('Data original do pagamento inválida.');
+ }
+ if ($status !== 'pending' && (! is_int($payment['reviewed_by'] ?? null) || $payment['reviewed_by'] < 1
+ || $payment['reviewed_by'] === $payment['created_by'] || ! is_int($payment['decision_audit_id'] ?? null)
+ || $payment['decision_audit_id'] < 1 || empty($payment['reviewed_at']))) {
+ throw new \DomainException('Confirmação ou rejeição sem decisão independente e auditada.');
+ }
+ if ($status === 'pending' && (($payment['reviewed_by'] ?? null) !== null || ($payment['decision_audit_id'] ?? null) !== null || ($payment['reviewed_at'] ?? null) !== null)) {
+ throw new \DomainException('Pagamento pendente com decisão incompatível.');
+ }
+ if ($totals[$status] > PHP_INT_MAX - $payment['amount_minor']) {
+ throw new \DomainException('Total histórico fora dos limites suportados.');
+ }
+ $totals[$status] += $payment['amount_minor'];
+ $rows[$id] = ['id' => $id, 'company' => $payment['company'], 'document' => $payment['document'],
+ 'amount_minor' => $payment['amount_minor'], 'currency' => $payment['currency'], 'status' => $status,
+ 'created_by' => $payment['created_by'], 'paid_on' => $date, 'registration_audit_id' => $payment['registration_audit_id'],
+ 'reviewed_by' => $payment['reviewed_by'] ?? null, 'reviewed_at' => $payment['reviewed_at'] ?? null,
+ 'decision_audit_id' => $payment['decision_audit_id'] ?? null,
+ 'payment_key' => hash('sha256', Payables::canonical([$source['instance'], $source['company'], $id]))];
+ }
+ if (! is_int($source['total_minor'] ?? null) || $totals['confirmed'] > $source['total_minor']) {
+ throw new \DomainException('Pagamentos confirmados superiores ao principal documental.');
+ }
+ ksort($rows);
+ $capture = ['version' => self::VERSION, 'source_key' => Payables::identity($source), 'payments' => array_values($rows),
+ 'confirmed_minor' => $totals['confirmed'], 'pending_minor' => $totals['pending'], 'rejected_minor' => $totals['rejected']];
+
+ return $capture + ['history_hash' => hash('sha256', Payables::canonical($capture))];
+ }
+
+ public function imported(int $obligation): ?object
+ {
+ return $this->available() ? $this->db->table('pa_history_imports')->where($this->scope)->where('obligation_id', $obligation)->first() : null;
+ }
+
+ public function record(int $obligation, int $source, int $actor, array $capture): void
+ {
+ if (! $this->available() || $this->db->transactionLevel() === 0) {
+ throw new \LogicException('Esquema e transacção de importação obrigatórios.');
+ }
+ $this->db->table('pa_history_imports')->insert($this->scope + ['obligation_id' => $obligation, 'source_id' => $source,
+ 'source_key' => $capture['source_key'], 'history_hash' => $capture['history_hash'],
+ 'confirmed_minor' => $capture['confirmed_minor'], 'pending_minor' => $capture['pending_minor'], 'rejected_minor' => $capture['rejected_minor'],
+ 'snapshot' => Payables::canonical($capture), 'imported_by' => $actor, 'created_at' => date('Y-m-d H:i:s')]);
+ foreach ($capture['payments'] as $payment) {
+ $this->db->table('pa_imported_payments')->insert($this->scope + ['obligation_id' => $obligation, 'source_id' => $source,
+ 'payment_key' => $payment['payment_key'], 'legacy_payment_id' => $payment['id'], 'amount_minor' => $payment['amount_minor'],
+ 'status' => $payment['status'], 'snapshot' => Payables::canonical($payment), 'created_at' => date('Y-m-d H:i:s')]);
+ }
+ }
+
+ public function matches(object $import, array $capture): bool
+ {
+ $rows = $this->db->table('pa_imported_payments')->where($this->scope)->where('obligation_id', $import->obligation_id)->orderBy('legacy_payment_id')->get();
+ if ($import->history_hash !== $capture['history_hash'] || $import->source_key !== $capture['source_key']
+ || Payables::canonical(json_decode($import->snapshot, true, 64, JSON_THROW_ON_ERROR)) !== Payables::canonical($capture) || $rows->count() !== count($capture['payments'])) {
+ return false;
+ }
+ foreach ($rows as $index => $row) {
+ $payment = $capture['payments'][$index];
+ if ($row->payment_key !== $payment['payment_key'] || (int) $row->amount_minor !== $payment['amount_minor']
+ || $row->status !== $payment['status'] || Payables::canonical(json_decode($row->snapshot, true, 64, JSON_THROW_ON_ERROR)) !== Payables::canonical($payment)) {
+ return false;
+ }
+ }
+
+ return (int) $import->confirmed_minor === $capture['confirmed_minor'] && (int) $import->pending_minor === $capture['pending_minor'] && (int) $import->rejected_minor === $capture['rejected_minor'];
+ }
+}
diff --git a/packages/payables/src/HistorySchema.php b/packages/payables/src/HistorySchema.php
new file mode 100644
index 0000000..03be9dd
--- /dev/null
+++ b/packages/payables/src/HistorySchema.php
@@ -0,0 +1,61 @@
+getDriverName();
+ if (! in_array($driver, ['pgsql', 'sqlite'], true) || ! Schema::hasTable('pa_obligations')
+ || Schema::hasTable('pa_history_imports') || Schema::hasTable('pa_imported_payments')) {
+ throw new \LogicException('Esquema anterior obrigatório; não adoptar nem reparar tabelas históricas implicitamente.');
+ }
+ $scope = function (Blueprint $table) {
+ foreach (['tenant_id', 'company_id', 'unit_id'] as $key) {
+ $table->unsignedBigInteger($key);
+ }
+ };
+ Schema::create('pa_history_imports', function (Blueprint $table) use ($scope) {
+ $table->id();
+ $scope($table);
+ $table->foreignId('obligation_id')->unique()->constrained('pa_obligations');
+ $table->foreignId('source_id')->unique()->constrained('pa_sources');
+ $table->string('source_key', 64);
+ $table->string('history_hash', 64);
+ $table->bigInteger('confirmed_minor');
+ $table->bigInteger('pending_minor');
+ $table->bigInteger('rejected_minor');
+ $table->jsonb('snapshot');
+ $table->unsignedBigInteger('imported_by');
+ $table->timestamp('created_at');
+ });
+ Schema::create('pa_imported_payments', function (Blueprint $table) use ($scope) {
+ $table->id();
+ $scope($table);
+ $table->foreignId('obligation_id')->constrained('pa_obligations');
+ $table->foreignId('source_id')->constrained('pa_sources');
+ $table->string('payment_key', 64)->unique();
+ $table->unsignedBigInteger('legacy_payment_id');
+ $table->bigInteger('amount_minor');
+ $table->string('status');
+ $table->jsonb('snapshot');
+ $table->timestamp('created_at');
+ $table->unique(['source_id', 'legacy_payment_id']);
+ });
+ foreach (['pa_history_imports', 'pa_imported_payments'] as $table) {
+ if ($driver === 'pgsql') {
+ DB::unprepared("CREATE TRIGGER pa_history_immutable BEFORE UPDATE OR DELETE ON {$table} FOR EACH ROW EXECUTE FUNCTION pa_append_only()");
+ } else {
+ foreach (['UPDATE', 'DELETE'] as $operation) {
+ $trigger = $table.'_immutable_'.strtolower($operation);
+ DB::unprepared("CREATE TRIGGER {$trigger} BEFORE {$operation} ON {$table} BEGIN SELECT RAISE(ABORT, 'PA_APPEND_ONLY'); END");
+ }
+ }
+ }
+ }
+}
diff --git a/packages/payables/src/Payables.php b/packages/payables/src/Payables.php
new file mode 100644
index 0000000..5d5d341
--- /dev/null
+++ b/packages/payables/src/Payables.php
@@ -0,0 +1,368 @@
+db->table('pa_'.$table)->where($this->scope);
+ }
+
+ private function event(string $kind, int $source, array $data): void
+ {
+ $id = $this->db->table('pa_events')->insertGetId($this->scope + ['source_id' => $source, 'actor_id' => $this->actor, 'kind' => $kind, 'payload' => self::canonical($data), 'created_at' => date('Y-m-d H:i:s')]);
+ $this->db->table('pa_outbox')->insert(['event_id' => $id, 'state' => 'captured_no_settlement_or_accounting']);
+ }
+
+ private function source(int $local): array
+ {
+ $s = ($this->loadSource)($local);
+ foreach (['instance', 'company', 'document'] as $key) {
+ if (empty($s[$key])) {
+ throw new \DomainException('Identidade de origem insuficiente.');
+ }
+ }
+
+ return $s;
+ }
+
+ private function locked(array $s): object
+ {
+ $key = self::identity($s);
+ $this->db->table('pa_sources')->insertOrIgnore($this->scope + ['source_key' => $key, 'local_source_id' => $s['local_id'], 'created_at' => date('Y-m-d H:i:s')]);
+ $row = $this->db->table('pa_sources')->where('source_key', $key)->lockForUpdate()->first();
+ if (! $row) {
+ throw new \DomainException('Identidade documental comercial já associada; revisão necessária.');
+ }
+ foreach ($this->scope as $k => $v) {
+ if ((int) $row->$k !== $v) {
+ throw new \DomainException('Origem já associada a outro contexto.');
+ }
+ }
+ if ((int) $row->local_source_id !== (int) $s['local_id']) {
+ throw new \DomainException('Origem canónica já ligada a outro documento local.');
+ }
+ $this->singleResponsibility($row, (int) $s['local_id']);
+
+ return $row;
+ }
+
+ private function singleResponsibility(object $source, int $local): void
+ {
+ $otherSources = $this->q('sources')->where('local_source_id', $local)->where('id', '<>', $source->id)->pluck('id')->all();
+ if ($this->q('obligations')->whereIn('source_id', $otherSources)->exists()) {
+ throw new \DomainException('Documento local já incorporado com outra identidade; reconciliar sem segunda obrigação.');
+ }
+ }
+
+ private function history(array $source): array
+ {
+ if ($this->loadHistory === null || ! (new HistoricalPayments($this->db, $this->scope))->available()) {
+ throw new \DomainException('Passagem de histórico não activada nesta instalação.');
+ }
+
+ return HistoricalPayments::capture($source, ($this->loadHistory)($source['local_id']));
+ }
+
+ private function eligibility(array $source, bool $historical): array
+ {
+ if ($historical) {
+ $source['payment_count'] = 0;
+ }
+
+ return self::missing($source);
+ }
+
+ private function position(object $obligation, ?array $source): array
+ {
+ $store = new HistoricalPayments($this->db, $this->scope);
+ $import = $store->imported((int) $obligation->id);
+ $current = null;
+ $compatible = false;
+ if ($source) {
+ $compatible = ($source['revision'] ?? self::revision($source)) === $obligation->source_revision
+ && $this->eligibility($source, $import !== null) === [];
+ if ($import) {
+ try {
+ $current = (new PaymentDecisions($this->db, $this->scope))->current($import);
+ $compatible = $compatible && self::canonical($current) === self::canonical($this->history($source));
+ } catch (\DomainException $e) {
+ $compatible = false;
+ }
+ }
+ }
+ $confirmed = $current['confirmed_minor'] ?? ($import ? (int) $import->confirmed_minor : 0);
+ $allocated = SupplierSettlements::allocated($this->db, $this->scope, (int) $obligation->id);
+ $balance = (int) $obligation->principal_minor - $confirmed - $allocated;
+ if ($balance < 0) {
+ $compatible = false;
+ }
+
+ return (array) $obligation + ['state' => 'recognised', 'balance_minor' => $compatible ? $balance : null,
+ 'source_conflict' => ! $compatible, 'history_imported' => $import !== null, 'confirmed_payments_minor' => $confirmed,
+ 'allocated_payments_minor' => $allocated, 'payment_state' => ! $compatible ? 'indeterminate' : ($balance === 0 ? 'settled' : ($balance === (int) $obligation->principal_minor ? 'unpaid' : 'partial')),
+ 'pending_payments_minor' => $current['pending_minor'] ?? ($import ? (int) $import->pending_minor : 0), 'rejected_payments_minor' => $current['rejected_minor'] ?? ($import ? (int) $import->rejected_minor : 0),
+ 'due_status' => 'unknown', 'days_overdue' => null, 'documentary_pending' => array_merge(['Vencimento desconhecido; atraso indeterminado.'],
+ $compatible ? [] : ['Origem ou histórico divergente; saldo não presumido.'])];
+ }
+
+ public function settlements(): SupplierSettlements
+ {
+ return new SupplierSettlements($this->db, $this->actor, $this->person, $this->scope, $this->authorize, function (int $id): array {
+ $o = $this->q('obligations')->where('id', $id)->lockForUpdate()->first() ?? throw new \DomainException('Obrigação fora do contexto.');
+ $row = $this->q('sources')->where('id', $o->source_id)->lockForUpdate()->first() ?? throw new \DomainException('Origem ausente.');
+ $source = $this->source((int) $row->local_source_id);
+ if (self::identity($source) !== $row->source_key) {
+ throw new \DomainException('Identidade documental alterada.');
+ }
+ $position = $this->position($o, $source);
+ if ($position['source_conflict'] || $position['balance_minor'] === null) {
+ throw new \DomainException('Origem ou histórico divergente; afectação suspensa.');
+ }
+ if ($position['history_imported']) {
+ throw new \DomainException('Novos pagamentos sobre história importada exigem recorte de compatibilidade próprio; nenhum histórico ignorado.');
+ }
+
+ return $position + ['supplier_key' => hash('sha256', self::canonical([$source['instance'], $source['company'], $source['supplier_id']])),
+ 'supplier_name' => $source['supplier_name']];
+ });
+ }
+
+ public function positionForSource(int $local): ?array
+ {
+ return $this->db->transaction(function () use ($local) {
+ ($this->authorize)('view');
+ $source = $this->source($local);
+ $row = $this->q('sources')->where('source_key', self::identity($source))->first();
+ if (! $row) {
+ $ids = $this->q('sources')->where('local_source_id', $local)->pluck('id')->all();
+ if ($this->q('obligations')->whereIn('source_id', $ids)->exists()) {
+ throw new \DomainException('Identidade da instalação alterada; reconciliar origem antes de calcular saldo.');
+ }
+
+ return null;
+ }
+ $this->singleResponsibility($row, $local);
+ $obligation = $this->q('obligations')->where('source_id', $row->id)->first();
+
+ return $obligation ? $this->position($obligation, $source) : null;
+ });
+ }
+
+ public function decideImportedPayment(int $local, int $payment, \Closure $writeOriginal): void
+ {
+ $this->db->transaction(function () use ($local, $payment, $writeOriginal) {
+ ($this->authorize)('validate');
+ $source = $this->source($local);
+ $row = $this->locked($source);
+ $obligation = $this->q('obligations')->where('source_id', $row->id)->first();
+ $import = $obligation ? (new HistoricalPayments($this->db, $this->scope))->imported((int) $obligation->id) : null;
+ if (! $import || $this->position($obligation, $source)['source_conflict']) {
+ throw new \DomainException('Obrigação histórica ausente ou divergente.');
+ }
+ $writeOriginal();
+ $after = $this->history($this->source($local));
+ (new PaymentDecisions($this->db, $this->scope))->record($import, $after, $payment, $this->actor);
+ $this->event('payable.imported_payment_decided', (int) $row->id, ['obligation_id' => $obligation->id, 'payment_id' => $payment, 'history_hash' => $after['history_hash']]);
+ });
+ }
+
+ public function command(string $action, int $id, string $reason, string $key): array
+ {
+ if (! in_array($action, ['prepare', 'validate', 'prepare_history', 'validate_history'], true) || ! preg_match('/^[a-f0-9-]{36}$/Di', $key) || mb_strlen(trim($reason)) < 3 || mb_strlen($reason) > 2000) {
+ throw new \InvalidArgumentException('Acção, chave ou fundamento inválidos.');
+ }
+
+ return $this->db->transaction(function () use ($action, $id, $reason, $key) {
+ $historical = str_ends_with($action, '_history');
+ $operation = $historical ? substr($action, 0, -8) : $action;
+ ($this->authorize)($operation);
+ $hash = hash('sha256', self::canonical([$action, $id, $reason]));
+ $old = $this->q('commands')->where(['actor_id' => $this->actor, 'command_key' => $key])->first();
+ if ($old) {
+ if ($old->request_hash !== $hash) {
+ throw new \DomainException('Chave usada com conteúdo diferente.');
+ }
+ // Even replay rechecks the current source/revision/payment eligibility.
+ $result = json_decode($old->result, true, 32, JSON_THROW_ON_ERROR);
+ $local = $result['local_source_id'];
+ $s = $this->source($local);
+ $row = $this->locked($s);
+ $capture = $historical ? $this->history($s) : null;
+ $material = self::revision($s);
+ if (($result['source_revision'] ?? null) !== $material || $this->eligibility($s, $historical) !== ($result['missing'] ?? [])
+ || ($result['history_hash'] ?? null) !== ($capture['history_hash'] ?? null)) {
+ return $this->conflict($row, $local, 'A origem/eligibilidade mudou após o comando; original preservado.');
+ }
+
+ return $result + ['replayed' => true];
+ }
+ if ($operation === 'prepare') {
+ $s = $this->source($id);
+ $row = $this->locked($s);
+ $capture = $historical ? $this->history($s) : null;
+ $revision = self::revision($s);
+ $missing = $this->eligibility($s, $historical);
+ $ob = $this->q('obligations')->where('source_id', $row->id)->first();
+ if ($ob) {
+ $position = $this->position($ob, $s);
+ $result = ! $position['source_conflict'] && $position['history_imported'] === $historical && $missing === [] ? ['state' => 'recognised', 'obligation_id' => $ob->id, 'local_source_id' => $id, 'source_revision' => $revision, 'missing' => []] : $this->conflict($row, $id, 'Documento/histórico alterado ou obrigação já incorporada por outro percurso; nenhuma dívida adicional.');
+ } else {
+ $candidate = $this->q('proposals')->where('source_id', $row->id)->orderByDesc('id')->first();
+ $oldCapture = $candidate ? (json_decode($candidate->snapshot, true)['_history_import'] ?? null) : null;
+ if (! $candidate || $candidate->source_revision !== $revision || json_decode($candidate->missing, true) !== $missing
+ || ($oldCapture['history_hash'] ?? null) !== ($capture['history_hash'] ?? null)) {
+ $snapshot = $s + ($capture ? ['_history_import' => $capture] : []);
+ $pid = $this->db->table('pa_proposals')->insertGetId($this->scope + ['source_id' => $row->id, 'source_revision' => $revision, 'capture_version' => ($candidate ? (int) $candidate->capture_version : 0) + 1, 'snapshot' => self::canonical($snapshot), 'missing' => self::canonical($missing), 'prepared_by' => $this->actor, 'prepared_person' => $this->person, 'reason' => $reason, 'created_at' => date('Y-m-d H:i:s')]);
+ $this->event('payable.proposed', $row->id, ['proposal_id' => $pid, 'source_revision' => $revision, 'missing' => $missing]);
+ } else {
+ $pid = $candidate->id;
+ }
+ $result = ['state' => $missing === [] ? 'prepared' : 'pending_source', 'proposal_id' => $pid, 'local_source_id' => $id, 'source_revision' => $revision, 'missing' => $missing];
+ }
+ } else {
+ $p = $this->q('proposals')->where('id', $id)->first();
+ if (! $p) {
+ throw new \DomainException('Proposta fora do âmbito.');
+ }
+ if ((int) $p->prepared_by === $this->actor || $p->prepared_person === $this->person) {
+ throw new \DomainException('Validação exige pessoa distinta da preparação.');
+ }
+ $row = $this->q('sources')->where('id', $p->source_id)->lockForUpdate()->first();
+ $local = (int) $row->local_source_id;
+ $this->singleResponsibility($row, $local);
+ $s = $this->source($local);
+ $capture = $historical ? $this->history($s) : null;
+ $preparedCapture = json_decode($p->snapshot, true)['_history_import'] ?? null;
+ if (($preparedCapture !== null) !== $historical) {
+ throw new \DomainException('Usar o percurso de validação correspondente à proposta.');
+ }
+ $revision = self::revision($s);
+ $missing = $this->eligibility($s, $historical);
+ $latest = $this->q('proposals')->where('source_id', $row->id)->orderByDesc('id')->first();
+ $ob = $this->q('obligations')->where('source_id', $row->id)->first();
+ if ($revision !== $p->source_revision || $latest->id !== $p->id || $missing !== []
+ || ($preparedCapture['history_hash'] ?? null) !== ($capture['history_hash'] ?? null)) {
+ $result = $this->conflict($row, $local, 'Revisão/decisão/pagamentos incompatíveis ou informação insuficiente.');
+ } elseif ($ob) {
+ $position = $this->position($ob, $s);
+ $result = ! $position['source_conflict'] && $position['history_imported'] === $historical ? ['state' => 'recognised', 'obligation_id' => $ob->id, 'local_source_id' => $local, 'source_revision' => $revision, 'missing' => []] : $this->conflict($row, $local, 'Responsabilidade existente incompatível; nenhuma segunda obrigação.');
+ } else {
+ $oid = $this->db->table('pa_obligations')->insertGetId($this->scope + ['source_id' => $row->id, 'proposal_id' => $p->id, 'source_revision' => $revision, 'principal_minor' => $s['total_minor'], 'currency' => 'MZN', 'due_on' => null, 'recognised_by' => $this->actor, 'recognised_person' => $this->person, 'reason' => $reason, 'created_at' => date('Y-m-d H:i:s')]);
+ if ($capture) {
+ (new HistoricalPayments($this->db, $this->scope))->record((int) $oid, (int) $row->id, $this->actor, $capture);
+ $this->event('payable.legacy_history_imported', $row->id, ['obligation_id' => $oid, 'history_hash' => $capture['history_hash'],
+ 'confirmed_minor' => $capture['confirmed_minor'], 'pending_minor' => $capture['pending_minor'], 'rejected_minor' => $capture['rejected_minor']]);
+ }
+ $this->event('payable.recognised', $row->id, ['obligation_id' => $oid, 'proposal_id' => $p->id, 'source_revision' => $revision, 'principal_minor' => $s['total_minor'], 'due_on' => null]);
+ $result = ['state' => 'recognised', 'obligation_id' => $oid, 'local_source_id' => $local, 'source_revision' => $revision, 'missing' => []];
+ }
+ }
+ if ($capture && ($result['state'] ?? '') !== 'conflict') {
+ $result['history_hash'] = $capture['history_hash'];
+ }
+ $this->db->table('pa_commands')->insert($this->scope + ['actor_id' => $this->actor, 'command_key' => $key, 'request_hash' => $hash, 'result' => self::canonical($result), 'created_at' => date('Y-m-d H:i:s')]);
+
+ return $result + ['replayed' => false];
+ });
+ }
+
+ private function conflict(object $row, int $local, string $reason): array
+ {
+ $this->event('payable.source_conflict', $row->id, ['reason' => $reason, 'observed_revision' => self::revision($this->source($local))]);
+
+ return ['state' => 'conflict', 'local_source_id' => $local, 'reason' => $reason];
+ }
+
+ public function listing(array $localSources): array
+ {
+ return $this->db->transaction(function () use ($localSources) {
+ ($this->authorize)('view');
+ $documents = [];
+ foreach ($localSources as $id) {
+ $s = $this->source($id);
+ $documents[] = $s + ['revision' => self::revision($s), 'missing' => self::missing($s)];
+ }
+ $current = [];
+ foreach ($documents as $doc) {
+ $current[self::identity($doc)] = $doc;
+ }
+ $obs = $this->q('obligations')->orderBy('id')->get()->map(function ($o) use ($current) {
+ $origin = $this->q('sources')->where('id', $o->source_id)->first();
+ $doc = $current[$origin->source_key] ?? null;
+
+ return $this->position($o, $doc);
+ })->all();
+
+ return ['synthetic_only' => true, 'documents' => $documents, 'obligations' => $obs, 'proposals' => $this->q('proposals')->orderBy('id')->get(), 'events' => $this->q('events')->orderBy('id')->get(), 'permissions' => ['prepare' => false, 'validate' => false], 'current_actor' => $this->actor];
+ });
+ }
+}
diff --git a/packages/payables/src/PayablesSchema.php b/packages/payables/src/PayablesSchema.php
new file mode 100644
index 0000000..e28c414
--- /dev/null
+++ b/packages/payables/src/PayablesSchema.php
@@ -0,0 +1,109 @@
+getDriverName();
+ if (! in_array($driver, ['pgsql', 'sqlite'], true)) {
+ throw new \LogicException('Unsupported payables database.');
+ }
+ $tables = ['pa_sources', 'pa_proposals', 'pa_obligations', 'pa_events', 'pa_outbox', 'pa_commands'];
+ $all = [...$tables, 'pa_access'];
+ $existing = array_filter($all, fn ($table) => Schema::hasTable($table));
+ if ($existing) {
+ throw new \LogicException('Payables tables already exist; explicit schema reconciliation required.');
+ }
+ $scope = function (Blueprint $t) {
+ foreach (['tenant_id', 'company_id', 'unit_id'] as $k) {
+ $t->unsignedBigInteger($k);
+ }
+ };
+ Schema::create('pa_access', function (Blueprint $t) use ($scope) {
+ $t->id();
+ $scope($t);
+ $t->unsignedBigInteger('user_id');
+ $t->string('person');
+ $t->string('action');
+ $t->unique(['tenant_id', 'company_id', 'unit_id', 'user_id', 'action']);
+ });
+ Schema::create('pa_sources', function (Blueprint $t) use ($scope) {
+ $t->id();
+ $scope($t);
+ $t->string('source_key', 64)->unique();
+ $t->unsignedBigInteger('local_source_id');
+ $t->timestamp('created_at');
+ });
+ Schema::create('pa_proposals', function (Blueprint $t) use ($scope) {
+ $t->id();
+ $scope($t);
+ $t->foreignId('source_id')->constrained('pa_sources');
+ $t->string('source_revision', 64);
+ $t->integer('capture_version');
+ $t->jsonb('snapshot');
+ $t->jsonb('missing');
+ $t->unsignedBigInteger('prepared_by');
+ $t->string('prepared_person');
+ $t->text('reason');
+ $t->timestamp('created_at');
+ $t->unique(['source_id', 'capture_version']);
+ });
+ Schema::create('pa_obligations', function (Blueprint $t) use ($scope) {
+ $t->id();
+ $scope($t);
+ $t->foreignId('source_id')->unique()->constrained('pa_sources');
+ $t->foreignId('proposal_id')->constrained('pa_proposals');
+ $t->string('source_revision', 64);
+ $t->bigInteger('principal_minor');
+ $t->string('currency', 3);
+ $t->date('due_on')->nullable();
+ $t->unsignedBigInteger('recognised_by');
+ $t->string('recognised_person');
+ $t->text('reason');
+ $t->timestamp('created_at');
+ });
+ Schema::create('pa_commands', function (Blueprint $t) use ($scope) {
+ $t->id();
+ $scope($t);
+ $t->unsignedBigInteger('actor_id');
+ $t->uuid('command_key');
+ $t->string('request_hash', 64);
+ $t->jsonb('result');
+ $t->timestamp('created_at');
+ $t->unique(['tenant_id', 'company_id', 'unit_id', 'actor_id', 'command_key']);
+ });
+ Schema::create('pa_events', function (Blueprint $t) use ($scope) {
+ $t->id();
+ $scope($t);
+ $t->foreignId('source_id')->constrained('pa_sources');
+ $t->unsignedBigInteger('actor_id');
+ $t->string('kind');
+ $t->jsonb('payload');
+ $t->timestamp('created_at');
+ });
+ Schema::create('pa_outbox', function (Blueprint $t) {
+ $t->foreignId('event_id')->primary()->constrained('pa_events');
+ $t->string('state');
+ });
+ if ($driver === 'pgsql') {
+ DB::unprepared("CREATE FUNCTION pa_append_only() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'PA_APPEND_ONLY'; END $$");
+ foreach ($tables as $table) {
+ DB::unprepared("CREATE TRIGGER pa_history_immutable BEFORE UPDATE OR DELETE ON {$table} FOR EACH ROW EXECUTE FUNCTION pa_append_only()");
+ }
+ } else {
+ foreach ($tables as $table) {
+ foreach (['UPDATE', 'DELETE'] as $operation) {
+ $trigger = $table.'_immutable_'.strtolower($operation);
+ DB::unprepared("CREATE TRIGGER {$trigger} BEFORE {$operation} ON {$table} BEGIN SELECT RAISE(ABORT, 'PA_APPEND_ONLY'); END");
+ }
+ }
+ }
+ }
+}
diff --git a/packages/payables/src/PaymentDecisionSchema.php b/packages/payables/src/PaymentDecisionSchema.php
new file mode 100644
index 0000000..692c522
--- /dev/null
+++ b/packages/payables/src/PaymentDecisionSchema.php
@@ -0,0 +1,40 @@
+getDriverName();
+ if (! in_array($driver, ['pgsql', 'sqlite'], true) || ! Schema::hasTable('pa_imported_payments') || Schema::hasTable('pa_payment_decisions')) {
+ throw new \LogicException('Histórico anterior obrigatório; não adoptar tabela de decisões existente.');
+ }
+ Schema::create('pa_payment_decisions', function (Blueprint $table) {
+ $table->id();
+ foreach (['tenant_id', 'company_id', 'unit_id'] as $key) {
+ $table->unsignedBigInteger($key);
+ }
+ $table->foreignId('obligation_id')->constrained('pa_obligations');
+ $table->foreignId('imported_payment_id')->unique()->constrained('pa_imported_payments');
+ $table->unsignedInteger('sequence');
+ $table->string('previous_hash', 64);
+ $table->string('history_hash', 64);
+ $table->jsonb('payment_snapshot');
+ $table->unsignedBigInteger('actor_id');
+ $table->timestamp('created_at');
+ $table->unique(['obligation_id', 'sequence']);
+ });
+ if ($driver === 'pgsql') {
+ DB::unprepared('CREATE TRIGGER pa_decision_immutable BEFORE UPDATE OR DELETE ON pa_payment_decisions FOR EACH ROW EXECUTE FUNCTION pa_append_only()');
+ } else {
+ foreach (['UPDATE', 'DELETE'] as $operation) {
+ DB::unprepared('CREATE TRIGGER pa_decision_'.strtolower($operation)." BEFORE {$operation} ON pa_payment_decisions BEGIN SELECT RAISE(ABORT, 'PA_APPEND_ONLY'); END");
+ }
+ }
+ }
+}
diff --git a/packages/payables/src/PaymentDecisions.php b/packages/payables/src/PaymentDecisions.php
new file mode 100644
index 0000000..3f756e9
--- /dev/null
+++ b/packages/payables/src/PaymentDecisions.php
@@ -0,0 +1,108 @@
+ PHP_INT_MAX - $payment['amount_minor']) {
+ throw new \DomainException('Totais de decisões incompatíveis.');
+ }
+ $capture[$key] += $payment['amount_minor'];
+ }
+
+ return $capture + ['history_hash' => hash('sha256', Payables::canonical($capture))];
+ }
+
+ private function transition(array $capture, array $next, int $actor): array
+ {
+ foreach ($capture['payments'] as $index => $payment) {
+ if ($payment['id'] !== ($next['id'] ?? null)) {
+ continue;
+ }
+ $unchanged = ['id', 'company', 'document', 'amount_minor', 'currency', 'created_by', 'paid_on', 'registration_audit_id', 'payment_key'];
+ if ($payment['status'] !== 'pending' || ! in_array($next['status'] ?? null, ['confirmed', 'rejected'], true)
+ || ($next['reviewed_by'] ?? null) !== $actor || $actor === $payment['created_by']
+ || ! is_int($next['decision_audit_id'] ?? null) || $next['decision_audit_id'] < 1 || empty($next['reviewed_at'])
+ || count($payment) !== count($next) || array_diff_key($payment, $next) !== []) {
+ throw new \DomainException('Decisão histórica inválida ou já final.');
+ }
+ foreach ($unchanged as $key) {
+ if ($payment[$key] !== $next[$key]) {
+ throw new \DomainException('A decisão não pode reescrever o pagamento original.');
+ }
+ }
+ $capture['payments'][$index] = $next;
+
+ return $this->rebuild($capture);
+ }
+ throw new \DomainException('Pagamento ausente do histórico importado.');
+ }
+
+ public function current(object $import): array
+ {
+ $capture = json_decode($import->snapshot, true, 64, JSON_THROW_ON_ERROR);
+ if (Payables::canonical($this->rebuild($capture)) !== Payables::canonical($capture)
+ || ! (new HistoricalPayments($this->db, $this->scope))->matches($import, $capture)) {
+ throw new \DomainException('Fotografia original da importação incompatível.');
+ }
+ if (! $this->db->getSchemaBuilder()->hasTable('pa_payment_decisions')) {
+ return $capture;
+ }
+ $sequence = 0;
+ foreach ($this->db->table('pa_payment_decisions')->where($this->scope)->where('obligation_id', $import->obligation_id)->orderBy('sequence')->get() as $decision) {
+ $next = json_decode($decision->payment_snapshot, true, 64, JSON_THROW_ON_ERROR);
+ $link = $this->db->table('pa_imported_payments')->where($this->scope)->where('obligation_id', $import->obligation_id)->where('id', $decision->imported_payment_id)->first();
+ if ((int) $decision->sequence !== ++$sequence || $decision->previous_hash !== $capture['history_hash'] || ! $link || (int) $link->legacy_payment_id !== $next['id']) {
+ throw new \DomainException('Cadeia de decisões históricas incompatível.');
+ }
+ $capture = $this->transition($capture, $next, (int) $decision->actor_id);
+ if ($decision->history_hash !== $capture['history_hash']) {
+ throw new \DomainException('Decisão histórica divergente.');
+ }
+ }
+
+ return $capture;
+ }
+
+ public function record(object $import, array $after, int $paymentId, int $actor): void
+ {
+ if ($this->db->transactionLevel() === 0 || ! $this->db->getSchemaBuilder()->hasTable('pa_payment_decisions')) {
+ throw new \LogicException('Esquema e transacção obrigatórios para decidir histórico.');
+ }
+ $before = $this->current($import);
+ $next = null;
+ foreach ($after['payments'] as $payment) {
+ if ($payment['id'] === $paymentId) {
+ $next = $payment;
+ }
+ }
+ if (! $next) {
+ throw new \DomainException('Pagamento ausente da nova fotografia.');
+ }
+ $expected = $this->transition($before, $next, $actor);
+ if (Payables::canonical($expected) !== Payables::canonical($after)) {
+ throw new \DomainException('Alterações fora da decisão pendente; operação recusada.');
+ }
+ $link = $this->db->table('pa_imported_payments')->where($this->scope)->where('obligation_id', $import->obligation_id)->where('legacy_payment_id', $paymentId)->first();
+ if (! $link || $link->status !== 'pending') {
+ throw new \DomainException('Apenas pendentes originalmente importados podem ser decididos.');
+ }
+ $sequence = 1 + (int) $this->db->table('pa_payment_decisions')->where($this->scope)->where('obligation_id', $import->obligation_id)->max('sequence');
+ $this->db->table('pa_payment_decisions')->insert($this->scope + ['obligation_id' => $import->obligation_id, 'imported_payment_id' => $link->id,
+ 'sequence' => $sequence, 'previous_hash' => $before['history_hash'], 'history_hash' => $after['history_hash'],
+ 'payment_snapshot' => Payables::canonical($next), 'actor_id' => $actor, 'created_at' => date('Y-m-d H:i:s')]);
+ }
+}
diff --git a/packages/payables/src/SettlementHttp.php b/packages/payables/src/SettlementHttp.php
new file mode 100644
index 0000000..27709ac
--- /dev/null
+++ b/packages/payables/src/SettlementHttp.php
@@ -0,0 +1,82 @@
+environment('payable-testing') && $db->getDriverName() === 'pgsql' && $db->getConfig('host') === '127.0.0.1'
+ && preg_match('/^'.$prefix.'[a-f0-9]{12}$/D', $db->getDatabaseName())
+ && $db->selectOne('select current_database() as d')->d === $db->getDatabaseName(), 403, 'Pagamentos apenas no ensaio isolado.');
+ foreach (SettlementSchema::TABLES as $table) {
+ abort_unless($db->getSchemaBuilder()->hasTable($table), 403, 'Ensaio de pagamentos ainda não instalado nesta base.');
+ }
+ }
+
+ /** @return array */
+ private function data(Request $r): array
+ {
+ $this->guard();
+
+ return DB::transaction(function () use ($r): array {
+ $s = app(static::SERVICE);
+ $user = $r->user();
+ $settlements = $s->engine($user)->settlements()->listing();
+ $base = $s->listing($user);
+ foreach ($base['obligations'] as &$o) {
+ $proposal = $base['proposals']->first(fn ($p) => (int) $p->id === (int) $o['proposal_id']);
+ $o['origin_snapshot'] = $proposal ? json_decode($proposal->snapshot, true, 64, JSON_THROW_ON_ERROR) : null;
+ }
+ unset($o);
+ $permissions = [];
+ foreach (['payment_prepare', 'payment_confirm', 'allocation_prepare', 'allocation_confirm'] as $action) {
+ $permissions[$action] = $s->can($user, $action);
+ }
+
+ return ['synthetic_only' => true, 'obligations' => $base['obligations'], 'payments' => $settlements['payments'],
+ 'allocation_proposals' => $settlements['proposals'], 'allocations' => $settlements['allocations'], 'events' => $settlements['events'],
+ 'permissions' => $permissions, 'current_actor' => $user->id];
+ });
+ }
+
+ public function index(Request $r): JsonResponse
+ {
+ return response()->json($this->data($r))->header('Cache-Control', 'no-store');
+ }
+
+ public function page(Request $r): Response
+ {
+ $data = $this->data($r);
+ $settings = ['host' => static::MODE === 'compras' ? 'Compras autónomo' : 'ERP SGF', 'actor' => $r->user()->name,
+ 'csrf' => csrf_token(), 'context' => $r->session()->get('company_context.token'), 'api' => '/api/supplier-payments',
+ 'obligations_url' => static::MODE === 'compras' ? '/obrigacoes-fornecedores' : '/supplier-payables'];
+
+ return response(Workspace::settlements($data, $settings))->header('Cache-Control', 'no-store');
+ }
+
+ public function command(Request $r, string $action): JsonResponse
+ {
+ $this->guard();
+ try {
+ return response()->json(app(static::SERVICE)->engine($r->user())->settlements()->command($action, $r->all(), (string) $r->header('Idempotency-Key')));
+ } catch (\DomainException $e) {
+ abort(409, $e->getMessage());
+ } catch (\InvalidArgumentException $e) {
+ abort(422, $e->getMessage());
+ }
+ }
+}
diff --git a/packages/payables/src/SettlementSchema.php b/packages/payables/src/SettlementSchema.php
new file mode 100644
index 0000000..c267f0d
--- /dev/null
+++ b/packages/payables/src/SettlementSchema.php
@@ -0,0 +1,136 @@
+getDriverName() !== 'pgsql' || ! preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D', $db->getDatabaseName())
+ || $db->selectOne('select current_database() as d')->d !== $db->getDatabaseName()) {
+ throw new \LogicException('Settlement schema requires isolated QA.');
+ }
+ if (array_filter(self::TABLES, fn ($t) => Schema::hasTable($t))) {
+ throw new \LogicException('Settlement schema already exists; no automatic repair.');
+ }
+ $scope = static function (Blueprint $t): void {
+ foreach (['tenant_id', 'company_id', 'unit_id'] as $k) {
+ $t->unsignedBigInteger($k);
+ }
+ };
+ DB::transaction(function () use ($scope): void {
+ Schema::create('ps_payments', function (Blueprint $t) use ($scope) {
+ $t->id();
+ $scope($t);
+ $t->foreignId('obligation_id')->constrained('pa_obligations');
+ $t->string('fact_key', 64)->unique();
+ $t->string('evidence_key', 64)->unique();
+ $t->string('supplier_key', 64);
+ $t->string('currency', 3);
+ $t->bigInteger('amount_minor');
+ $t->date('paid_on');
+ $t->string('fact_reference');
+ $t->string('evidence_reference');
+ $t->string('payload_hash', 64);
+ $t->jsonb('snapshot');
+ $t->unsignedBigInteger('prepared_by');
+ $t->string('prepared_person');
+ $t->text('reason');
+ $t->timestamp('created_at');
+ });
+ Schema::create('ps_payment_decisions', function (Blueprint $t) use ($scope) {
+ $t->id();
+ $scope($t);
+ $t->foreignId('payment_id')->unique()->constrained('ps_payments');
+ $t->string('decision');
+ $t->unsignedBigInteger('actor_id');
+ $t->string('person');
+ $t->string('evidence_reference');
+ $t->text('reason');
+ $t->timestamp('created_at');
+ });
+ Schema::create('ps_proposals', function (Blueprint $t) use ($scope) {
+ $t->id();
+ $scope($t);
+ $t->foreignId('payment_id')->constrained('ps_payments');
+ $t->string('reference_key', 64)->unique();
+ $t->string('reference');
+ $t->string('plan_hash', 64);
+ $t->jsonb('plan');
+ $t->unsignedBigInteger('prepared_by');
+ $t->string('prepared_person');
+ $t->text('reason');
+ $t->timestamp('created_at');
+ $t->unique(['payment_id', 'plan_hash']);
+ });
+ Schema::create('ps_allocation_decisions', function (Blueprint $t) use ($scope) {
+ $t->id();
+ $scope($t);
+ $t->foreignId('proposal_id')->unique()->constrained('ps_proposals');
+ $t->string('decision');
+ $t->unsignedBigInteger('actor_id');
+ $t->string('person');
+ $t->text('reason');
+ $t->timestamp('created_at');
+ });
+ Schema::create('ps_allocation_aliases', function (Blueprint $t) use ($scope) {
+ $t->id();
+ $scope($t);
+ $t->string('reference_key', 64)->unique();
+ $t->string('reference');
+ $t->foreignId('proposal_id')->constrained('ps_proposals');
+ $t->unsignedBigInteger('actor_id');
+ $t->timestamp('created_at');
+ });
+ Schema::create('ps_allocations', function (Blueprint $t) use ($scope) {
+ $t->id();
+ $scope($t);
+ $t->foreignId('payment_id')->constrained('ps_payments');
+ $t->foreignId('obligation_id')->constrained('pa_obligations');
+ $t->foreignId('decision_id')->constrained('ps_allocation_decisions');
+ $t->bigInteger('amount_minor');
+ $t->string('source_revision', 64);
+ $t->timestamp('created_at');
+ $t->unique(['payment_id', 'obligation_id']);
+ });
+ Schema::create('ps_commands', function (Blueprint $t) use ($scope) {
+ $t->id();
+ $scope($t);
+ $t->unsignedBigInteger('actor_id');
+ $t->uuid('command_key');
+ $t->string('request_hash', 64);
+ $t->jsonb('result');
+ $t->timestamp('created_at');
+ $t->unique(['tenant_id', 'company_id', 'unit_id', 'actor_id', 'command_key']);
+ });
+ Schema::create('ps_events', function (Blueprint $t) use ($scope) {
+ $t->id();
+ $scope($t);
+ $t->foreignId('payment_id')->constrained('ps_payments');
+ $t->unsignedBigInteger('actor_id');
+ $t->string('person');
+ $t->string('kind');
+ $t->jsonb('payload');
+ $t->timestamp('created_at');
+ });
+ Schema::create('ps_outbox', function (Blueprint $t) {
+ $t->foreignId('event_id')->primary()->constrained('ps_events');
+ $t->string('state');
+ });
+ foreach (self::TABLES as $table) {
+ DB::unprepared("CREATE TRIGGER ps_history_immutable BEFORE UPDATE OR DELETE ON {$table} FOR EACH ROW EXECUTE FUNCTION pa_append_only()");
+ }
+ foreach (['ps_payments', 'ps_allocations'] as $table) {
+ DB::unprepared("ALTER TABLE {$table} ADD CONSTRAINT {$table}_positive CHECK (amount_minor > 0)");
+ }
+ });
+ }
+}
diff --git a/packages/payables/src/SupplierSettlements.php b/packages/payables/src/SupplierSettlements.php
new file mode 100644
index 0000000..afa53d4
--- /dev/null
+++ b/packages/payables/src/SupplierSettlements.php
@@ -0,0 +1,383 @@
+ $scope
+ * @param \Closure(string):void $authorize
+ * @param \Closure(int):array $obligation
+ */
+ public function __construct(private ConnectionInterface $db, private int $actor, private string $person,
+ private array $scope, private \Closure $authorize, private \Closure $obligation) {}
+
+ private function q(string $table): Builder
+ {
+ return $this->db->table('ps_'.$table)->where($this->scope);
+ }
+
+ private function stamp(): string
+ {
+ return date('Y-m-d H:i:s');
+ }
+
+ /** @param array $v */
+ private function hash(array $v): string
+ {
+ return hash('sha256', Payables::canonical($v));
+ }
+
+ private function key(string $ref): string
+ {
+ // One payment fact per organisation, even if the request arrives through another unit.
+ return $this->hash([array_intersect_key($this->scope, array_flip(['tenant_id', 'company_id'])), $ref]);
+ }
+
+ private function referenceScope(string $table, string $column, string $key): void
+ {
+ $row = $this->db->table('ps_'.$table)->where($column, $key)->first(array_keys($this->scope));
+ if ($row) {
+ foreach ($this->scope as $field => $value) {
+ if ((int) $row->$field !== $value) {
+ throw new \DomainException('Referência já associada noutro contexto da organização; nenhuma duplicação permitida.');
+ }
+ }
+ }
+ }
+
+ public static function minor(mixed $value): int
+ {
+ if (! is_string($value) || ! preg_match('/^(0|[1-9][0-9]{0,11})(?:\.([0-9]{1,2}))?$/D', $value, $m)) {
+ throw new \InvalidArgumentException('Montante decimal positivo, com até duas casas; sem arredondamento.');
+ }
+ $minor = (int) $m[1] * 100 + (int) str_pad($m[2] ?? '', 2, '0');
+ if ($minor < 1) {
+ throw new \InvalidArgumentException('O montante deve ser positivo.');
+ }
+
+ return $minor;
+ }
+
+ private function reference(mixed $v): string
+ {
+ if (! is_string($v) || ! preg_match('/^SIM-[A-Z0-9][A-Z0-9._-]{2,95}$/D', $v)) {
+ throw new \InvalidArgumentException('Referência sintética estável SIM-… obrigatória.');
+ }
+
+ return $v;
+ }
+
+ private function reason(mixed $v): string
+ {
+ if (! is_string($v) || mb_strlen(trim($v)) < 3 || mb_strlen($v) > 2000) {
+ throw new \InvalidArgumentException('Fundamento obrigatório, entre 3 e 2000 caracteres.');
+ }
+
+ return trim($v);
+ }
+
+ private function id(mixed $v): int
+ {
+ if (! is_int($v) || $v < 1) {
+ throw new \InvalidArgumentException('Identificador inteiro positivo obrigatório.');
+ }
+
+ return $v;
+ }
+
+ /** @param array $data
+ * @param list $keys
+ */
+ private function exact(array $data, array $keys): void
+ {
+ if (array_diff(array_keys($data), $keys) || array_diff($keys, array_keys($data))) {
+ throw new \InvalidArgumentException('Campos ausentes ou não permitidos.');
+ }
+ }
+
+ private function payment(int $id): \stdClass
+ {
+ $row = $this->q('payments')->where('id', $id)->lockForUpdate()->first();
+ if (! $row instanceof \stdClass) {
+ throw new \DomainException('Pagamento fora do contexto.');
+ }
+
+ return $row;
+ }
+
+ private function independent(\stdClass $prepared): void
+ {
+ if ((int) $prepared->prepared_by === $this->actor || $prepared->prepared_person === $this->person) {
+ throw new \DomainException('Confirmação exige outra identidade e outra pessoa sintética.');
+ }
+ }
+
+ /** @param array $payload */
+ private function event(int $payment, string $kind, array $payload): void
+ {
+ $id = $this->db->table('ps_events')->insertGetId($this->scope + ['payment_id' => $payment, 'actor_id' => $this->actor,
+ 'person' => $this->person, 'kind' => $kind, 'payload' => Payables::canonical($payload), 'created_at' => $this->stamp()]);
+ $this->db->table('ps_outbox')->insert(['event_id' => $id, 'state' => 'captured_no_bank_or_accounting']);
+ }
+
+ /** @param array $scope */
+ public static function allocated(ConnectionInterface $db, array $scope, int $obligation): int
+ {
+ if (! method_exists($db, 'getSchemaBuilder')) {
+ throw new \LogicException('A ligação exige consulta explícita do esquema financeiro.');
+ }
+ if (! $db->getSchemaBuilder()->hasTable('ps_allocations')) {
+ return 0;
+ }
+
+ return (int) $db->table('ps_allocations')->where($scope)->where('obligation_id', $obligation)->sum('amount_minor');
+ }
+
+ private function unallocated(\stdClass $payment): int
+ {
+ return (int) $payment->amount_minor - (int) $this->q('allocations')->where('payment_id', $payment->id)->sum('amount_minor');
+ }
+
+ /** @param array $data
+ * @return array
+ */
+ public function command(string $action, array $data, string $key): array
+ {
+ $permissions = ['payment-prepare' => 'payment_prepare', 'payment-decide' => 'payment_confirm', 'allocation-prepare' => 'allocation_prepare', 'allocation-decide' => 'allocation_confirm'];
+ if (! isset($permissions[$action]) || ! preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/Di', $key)) {
+ throw new \InvalidArgumentException('Acção ou chave inválida.');
+ }
+
+ return $this->db->transaction(function () use ($action, $data, $key, $permissions): array {
+ ($this->authorize)('payments_view');
+ ($this->authorize)($permissions[$action]); // Host locks organisation and revalidates actor/grants before replay too.
+ $hash = $this->hash([$action, $data]);
+ $old = $this->q('commands')->where(['actor_id' => $this->actor, 'command_key' => $key])->first();
+ if ($old && $old->request_hash !== $hash) {
+ throw new \DomainException('Chave repetida com conteúdo diferente.');
+ }
+ $result = match ($action) {
+ 'payment-prepare' => $this->preparePayment($data), 'payment-decide' => $this->decidePayment($data),
+ 'allocation-prepare' => $this->prepareAllocation($data), 'allocation-decide' => $this->decideAllocation($data),
+ };
+ if ($old && Payables::canonical(json_decode($old->result, true, 32, JSON_THROW_ON_ERROR)) !== Payables::canonical($result)) {
+ throw new \DomainException('Resultado anterior incompatível; não repetir o efeito.');
+ }
+ if (! $old) {
+ $this->db->table('ps_commands')->insert($this->scope + ['actor_id' => $this->actor, 'command_key' => $key,
+ 'request_hash' => $hash, 'result' => Payables::canonical($result), 'created_at' => $this->stamp()]);
+ }
+
+ return $result + ['replayed' => $old !== null];
+ });
+ }
+
+ /** @param array $d
+ * @return array
+ */
+ private function preparePayment(array $d): array
+ {
+ $this->exact($d, ['obligation_id', 'source_revision', 'amount', 'currency', 'paid_on', 'fact_reference', 'evidence_reference', 'reason']);
+ $o = ($this->obligation)($this->id($d['obligation_id']));
+ if ($o['source_revision'] !== $d['source_revision'] || $d['currency'] !== 'MZN' || $o['currency'] !== $d['currency']) {
+ throw new \DomainException('Versão ou moeda incompatível.');
+ }
+ if (! is_string($d['paid_on']) || ! preg_match('/^(\d{4})-(\d{2})-(\d{2})$/D', $d['paid_on'], $date)
+ || ! checkdate((int) $date[2], (int) $date[3], (int) $date[1]) || $d['paid_on'] > date('Y-m-d')) {
+ throw new \InvalidArgumentException('Data factual de pagamento válida obrigatória, sem data futura.');
+ }
+ $fact = $this->reference($d['fact_reference']);
+ $evidence = $this->reference($d['evidence_reference']);
+ $reason = $this->reason($d['reason']);
+ $snapshot = ['obligation_id' => $o['id'], 'source_revision' => $o['source_revision'], 'supplier_key' => $o['supplier_key'],
+ 'amount_minor' => self::minor($d['amount']), 'currency' => $d['currency'], 'paid_on' => $d['paid_on'], 'fact_reference' => $fact, 'evidence_reference' => $evidence, 'reason' => $reason, 'synthetic_only' => true];
+ $hash = $this->hash($snapshot);
+ $this->referenceScope('payments', 'fact_key', $this->key($fact));
+ $this->referenceScope('payments', 'evidence_key', $this->key($evidence));
+ $existing = $this->q('payments')->where(fn ($q) => $q->where('fact_key', $this->key($fact))->orWhere('evidence_key', $this->key($evidence)))->get();
+ if ($existing->isNotEmpty()) {
+ if ($existing->count() !== 1 || $existing->first()->payload_hash !== $hash) {
+ throw new \DomainException('Referência ou evidência já usada com conteúdo incompatível.');
+ }
+
+ return ['payment_id' => (int) $existing->first()->id];
+ }
+ $id = $this->db->table('ps_payments')->insertGetId($this->scope + ['obligation_id' => $o['id'], 'fact_key' => $this->key($fact),
+ 'evidence_key' => $this->key($evidence), 'supplier_key' => $o['supplier_key'], 'currency' => $d['currency'], 'amount_minor' => $snapshot['amount_minor'],
+ 'paid_on' => $d['paid_on'], 'fact_reference' => $fact, 'evidence_reference' => $evidence, 'payload_hash' => $hash, 'snapshot' => Payables::canonical($snapshot),
+ 'prepared_by' => $this->actor, 'prepared_person' => $this->person, 'reason' => $reason, 'created_at' => $this->stamp()]);
+ $this->event($id, 'supplier_payment.proposed', ['payment_id' => $id, 'amount_minor' => $snapshot['amount_minor']]);
+
+ return ['payment_id' => $id];
+ }
+
+ /** @param array $d
+ * @return array
+ */
+ private function decidePayment(array $d): array
+ {
+ $this->exact($d, ['payment_id', 'decision', 'evidence_reference', 'reason']);
+ if (! in_array($d['decision'], ['confirmed', 'rejected'], true)) {
+ throw new \InvalidArgumentException('Decisão inválida.');
+ }
+ $p = $this->payment($this->id($d['payment_id']));
+ $this->independent($p);
+ $o = ($this->obligation)((int) $p->obligation_id);
+ $snapshot = json_decode($p->snapshot, true, 32, JSON_THROW_ON_ERROR);
+ if ($o['source_revision'] !== $snapshot['source_revision'] || $o['supplier_key'] !== $p->supplier_key) {
+ throw new \DomainException('Origem do pagamento alterada; revisão necessária.');
+ }
+ $evidence = $this->reference($d['evidence_reference']);
+ $reason = $this->reason($d['reason']);
+ $old = $this->q('payment_decisions')->where('payment_id', $p->id)->first();
+ if ($old) {
+ if ($old->decision !== $d['decision'] || $old->evidence_reference !== $evidence || $old->reason !== $reason) {
+ throw new \DomainException('Decisão final já registada; correcção exige fluxo próprio.');
+ }
+
+ return ['payment_id' => (int) $p->id, 'decision_id' => (int) $old->id];
+ }
+ $id = $this->db->table('ps_payment_decisions')->insertGetId($this->scope + ['payment_id' => $p->id, 'decision' => $d['decision'],
+ 'evidence_reference' => $evidence, 'reason' => $reason, 'actor_id' => $this->actor, 'person' => $this->person, 'created_at' => $this->stamp()]);
+ $this->event((int) $p->id, 'supplier_payment.'.$d['decision'], ['payment_id' => $p->id, 'decision_id' => $id]);
+
+ return ['payment_id' => (int) $p->id, 'decision_id' => $id];
+ }
+
+ /** @param array $d
+ * @return array
+ */
+ private function prepareAllocation(array $d): array
+ {
+ $this->exact($d, ['payment_id', 'reference', 'allocations', 'reason']);
+ $p = $this->payment($this->id($d['payment_id']));
+ if ($this->q('payment_decisions')->where(['payment_id' => $p->id, 'decision' => 'confirmed'])->doesntExist()) {
+ throw new \DomainException('Pagamento ainda não confirmado.');
+ }
+ $reference = $this->reference($d['reference']);
+ $reason = $this->reason($d['reason']);
+ if (! is_array($d['allocations']) || ! array_is_list($d['allocations']) || count($d['allocations']) < 1 || count($d['allocations']) > 50) {
+ throw new \InvalidArgumentException('Indique entre uma e cinquenta afectações.');
+ }
+ $plan = [];
+ foreach ($d['allocations'] as $line) {
+ if (! is_array($line)) {
+ throw new \InvalidArgumentException('Linha de afectação inválida.');
+ }
+ $this->exact($line, ['obligation_id', 'source_revision', 'amount']);
+ $id = $this->id($line['obligation_id']);
+ if (isset($plan[$id])) {
+ throw new \InvalidArgumentException('Obrigação repetida na mesma proposta.');
+ }
+ $plan[$id] = ['obligation_id' => $id, 'source_revision' => $line['source_revision'], 'amount_minor' => self::minor($line['amount'])];
+ }
+ ksort($plan);
+ $plan = array_values($plan);
+ $hash = $this->hash($plan);
+ $this->referenceScope('proposals', 'reference_key', $this->key($reference));
+ $this->referenceScope('allocation_aliases', 'reference_key', $this->key($reference));
+ // Semantic recovery precedes balance checks: an already-applied plan remains recoverable.
+ $alias = $this->q('allocation_aliases')->where('reference_key', $this->key($reference))->value('proposal_id');
+ $old = $this->q('proposals')->where(fn ($q) => $q->where('reference_key', $this->key($reference))->orWhere('id', $alias ?? 0)->orWhere(fn ($q) => $q->where('payment_id', $p->id)->where('plan_hash', $hash)))->get();
+ if ($old->isNotEmpty()) {
+ if ($old->count() !== 1 || (int) $old->first()->payment_id !== (int) $p->id || $old->first()->plan_hash !== $hash) {
+ throw new \DomainException('Referência de afectação incompatível.');
+ }
+ $this->checkPlan($p, $plan, false);
+ $this->db->table('ps_allocation_aliases')->insertOrIgnore($this->scope + ['reference_key' => $this->key($reference), 'reference' => $reference,
+ 'proposal_id' => $old->first()->id, 'actor_id' => $this->actor, 'created_at' => $this->stamp()]);
+
+ return ['proposal_id' => (int) $old->first()->id];
+ }
+ $this->checkPlan($p, $plan, true);
+ $id = $this->db->table('ps_proposals')->insertGetId($this->scope + ['payment_id' => $p->id, 'reference_key' => $this->key($reference),
+ 'reference' => $reference, 'plan_hash' => $hash, 'plan' => Payables::canonical($plan), 'prepared_by' => $this->actor,
+ 'prepared_person' => $this->person, 'reason' => $reason, 'created_at' => $this->stamp()]);
+ $this->event((int) $p->id, 'supplier_allocation.proposed', ['proposal_id' => $id, 'plan_hash' => $hash]);
+
+ return ['proposal_id' => $id];
+ }
+
+ /** @param list> $plan */
+ private function checkPlan(\stdClass $p, array $plan, bool $checkBalances): void
+ {
+ $total = 0;
+ foreach ($plan as $line) {
+ $o = ($this->obligation)($line['obligation_id']);
+ if ($o['supplier_key'] !== $p->supplier_key || $o['currency'] !== $p->currency || $o['source_revision'] !== $line['source_revision']) {
+ throw new \DomainException('Fornecedor, moeda ou versão incompatível na afectação.');
+ }
+ if ($line['amount_minor'] > PHP_INT_MAX - $total) {
+ throw new \DomainException('Montante fora dos limites.');
+ }
+ $total += $line['amount_minor'];
+ if ($checkBalances && ($line['amount_minor'] > $o['balance_minor'] || $this->q('allocations')->where(['payment_id' => $p->id, 'obligation_id' => $o['id']])->exists())) {
+ throw new \DomainException('Saldo insuficiente ou ligação pagamento/obrigação já confirmada; sem edição silenciosa.');
+ }
+ }
+ if ($checkBalances && $total > $this->unallocated($p)) {
+ throw new \DomainException('Valor proposto superior ao pagamento ainda por afectar.');
+ }
+ }
+
+ /** @param array $d
+ * @return array
+ */
+ private function decideAllocation(array $d): array
+ {
+ $this->exact($d, ['proposal_id', 'decision', 'reason']);
+ if (! in_array($d['decision'], ['confirmed', 'rejected'], true)) {
+ throw new \InvalidArgumentException('Decisão inválida.');
+ }
+ $proposal = $this->q('proposals')->where('id', $this->id($d['proposal_id']))->first() ?? throw new \DomainException('Proposta fora do contexto.');
+ $p = $this->payment((int) $proposal->payment_id);
+ $this->independent($proposal);
+ if ($this->q('payment_decisions')->where(['payment_id' => $p->id, 'decision' => 'confirmed'])->doesntExist()) {
+ throw new \DomainException('Pagamento não confirmado.');
+ }
+ $reason = $this->reason($d['reason']);
+ $plan = json_decode($proposal->plan, true, 64, JSON_THROW_ON_ERROR);
+ $old = $this->q('allocation_decisions')->where('proposal_id', $proposal->id)->first();
+ $this->checkPlan($p, $plan, ! $old && $d['decision'] === 'confirmed');
+ if ($old) {
+ if ($old->decision !== $d['decision'] || $old->reason !== $reason) {
+ throw new \DomainException('Decisão de afectação já final; exige correcção própria.');
+ }
+
+ return ['proposal_id' => (int) $proposal->id, 'decision_id' => (int) $old->id];
+ }
+ $id = $this->db->table('ps_allocation_decisions')->insertGetId($this->scope + ['proposal_id' => $proposal->id, 'decision' => $d['decision'],
+ 'actor_id' => $this->actor, 'person' => $this->person, 'reason' => $reason, 'created_at' => $this->stamp()]);
+ if ($d['decision'] === 'confirmed') {
+ foreach ($plan as $line) {
+ $this->db->table('ps_allocations')->insert($this->scope + $line + ['payment_id' => $p->id, 'decision_id' => $id, 'created_at' => $this->stamp()]);
+ }
+ }
+ $this->event((int) $p->id, 'supplier_allocation.'.$d['decision'], ['proposal_id' => $proposal->id, 'decision_id' => $id, 'plan_hash' => $proposal->plan_hash]);
+
+ return ['proposal_id' => (int) $proposal->id, 'decision_id' => $id];
+ }
+
+ /** @return array */
+ public function listing(): array
+ {
+ return $this->db->transaction(function (): array {
+ ($this->authorize)('payments_view');
+ $payments = $this->q('payments')->orderBy('id')->get()->map(function ($p): array {
+ $decision = $this->q('payment_decisions')->where('payment_id', $p->id)->first();
+
+ return (array) $p + ['decision' => $decision, 'state' => $decision->decision ?? 'proposed',
+ 'unallocated_minor' => $decision?->decision === 'confirmed' ? $this->unallocated($p) : null, 'reconciliation' => 'not_implemented'];
+ })->all();
+ $proposals = $this->q('proposals')->orderBy('id')->get()->map(fn ($p) => (array) $p + ['decision' => $this->q('allocation_decisions')->where('proposal_id', $p->id)->first()])->all();
+
+ return ['synthetic_only' => true, 'payments' => $payments, 'proposals' => $proposals, 'allocations' => $this->q('allocations')->orderBy('id')->get()->all(),
+ 'events' => $this->q('events')->orderBy('id')->get()->all(), 'current_actor' => $this->actor];
+ });
+ }
+}
diff --git a/packages/payables/src/Workspace.php b/packages/payables/src/Workspace.php
new file mode 100644
index 0000000..122d038
--- /dev/null
+++ b/packages/payables/src/Workspace.php
@@ -0,0 +1,22 @@
+htmlspecialchars((string)$v,ENT_QUOTES|ENT_SUBSTITUTE,'UTF-8'); $money=fn($v,$currency='MZN')=>number_format(intdiv((int)$v,100),0,'.',' ').'.'.str_pad((string)abs((int)$v%100),2,'0',STR_PAD_LEFT).' '.$e($currency); ?>
+Obrigações a fornecedores — ensaio
+
+get('payables_demo_entry')): ?>Trocar função ou sair da demonstração
← = $e($settings['return_label']??'Voltar à aplicação') ?> = $e($settings['host']) ?> · = $e($settings['actor']) ?>
Obrigações a fornecedores = $e($settings['organisation']) ?>
ENSAIO SINTÉTICO — SEM UTILIZAÇÃO REAL. Documento, dívida, pagamento e contabilidade são factos distintos. Este percurso reconhece o principal e, quando activado, preserva o histórico existente.
Fornecedor, montantes e conferência são reutilizados da factura. Vencimento desconhecido não é substituído por uma data ou zero dias de atraso.
+Pagamentos e afectações sintéticas — sem execução bancária
Actualizar consulta
+
+
+($d['payment_count']??0)>0); if(($data['history_import_available']??false)&&$data['permissions']['prepare']&&$historyDocuments): ?>Incorporar factura com pagamentos anteriores Preserva o principal integral e liga os pagamentos originais uma única vez. Só os confirmados reduzem o saldo; pendentes e rejeitados mantêm o seu estado. Não executa nem confirma pagamentos.
Factura com histórico= $e($d['number']) ?> · = $money($d['total_minor']) ?> FundamentoPassagem de responsabilidade com preservação dos pagamentos históricos. Preparar passagem com histórico
+Propostas e decisões count()): ?>Ainda não há propostas.
+Proposta #= $e($p->id) ?> · captura = $e($p->capture_version) ?> Preparador #= $e($p->prepared_by) ?> · = $e($p->created_at) ?>
= $e($p->reason) ?>
Fotografia e pendências = $e($p->snapshot) ?> = $e($p->missing) ?>
+snapshot,true)['_history_import']??null; if($history): ?>Histórico proposto: confirmados = $money($history['confirmed_minor']) ?> · pendentes = $money($history['pending_minor']) ?> · rejeitados = $money($history['rejected_minor']) ?>. Os pendentes e rejeitados não reduzem o saldo.
+Fundamento da decisão = $history?'Validar passagem e preservar histórico':'Validar revisão exacta e reconhecer' ?>
+Obrigações reconhecidas Nenhuma obrigação reconhecida. Proposta ou factura conferida não é decisão de reconhecimento.
+
Obrigação #= $e($o['id']) ?> = $money($o['principal_minor']) ?>
Principal reconhecido — vencimento pendente
Pagamentos históricos confirmados: = $money($o['confirmed_payments_minor']) ?>
Pendentes: = $money($o['pending_payments_minor']) ?> · Rejeitados: = $money($o['rejected_payments_minor']) ?> — sem abatimento.
Saldo por liquidar: = $o['balance_minor']===null?'Pendente de revisão':$money($o['balance_minor']) ?>
Importação original preservada. A conferência independente dos pendentes actualiza este saldo uma única vez.
Conferir pagamentos pendentes →
Importação original preservada. Conferência de pendentes não activada nesta instalação.
Origem ou histórico divergente; saldo não presumido. O principal original permanece preservado.
Atraso indeterminado · Dias de atraso: sem valor
Validador #= $e($o['recognised_by']) ?> · = $e($o['created_at']) ?>
Origem, versão e decisão = $e(json_encode($o,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE)) ?>
+Histórico durável #= $e($event->id) ?> · = $e($event->kind) ?> · actor #= $e($event->actor_id) ?> · = $e($event->created_at) ?> = $e($event->payload) ?>
+Sem execução de novos pagamentos, cobrança, efeitos de stock, novo consumo orçamental ou lançamento automático. A passagem de histórico não confirma pagamentos pendentes.
+
diff --git a/packages/payables/ui/settlements.php b/packages/payables/ui/settlements.php
new file mode 100644
index 0000000..5492f85
--- /dev/null
+++ b/packages/payables/ui/settlements.php
@@ -0,0 +1,32 @@
+ htmlspecialchars((string) $v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+$money = static fn ($v) => $v === null ? 'Indeterminado' : number_format(intdiv((int) $v, 100), 0, '.', ' ').'.'.str_pad((string) abs((int) $v % 100), 2, '0', STR_PAD_LEFT).' MZN';
+$eligible = array_values(array_filter($data['obligations'], static fn ($o) => ! $o['source_conflict'] && ! $o['history_imported']));
+$confirmed = array_values(array_filter($data['payments'], static fn ($p) => $p['state'] === 'confirmed'));
+$options = static function () use ($eligible, $e, $money): void {
+ foreach ($eligible as $o) echo '#'.$e($o['id']).' · '.$e($o['origin_snapshot']['supplier_name'] ?? 'Fornecedor').' · '.$e($money($o['balance_minor'])).' ';
+};
+?>
+Pagamentos e afectações a fornecedores — ensaio
+← Obrigações a fornecedores · Trocar função ou sair = $e($settings['host']) ?> · = $e($settings['actor']) ?>
Pagamentos e afectações a fornecedores ENSAIO SINTÉTICO — SEM MOVIMENTAÇÃO DE DINHEIRO. Propor não confirma; confirmar um pagamento não abate automaticamente uma dívida. Só uma afectação confirmada reduz o saldo. Não há execução bancária, reconciliação, stock, orçamento adicional ou lançamento contabilístico.
As referências de pagamento e evidência são artificiais e começam por SIM-. Não introduza dados bancários ou comprovativos reais.
+
Actualizar consulta
+Obrigações e saldo canónico Reconheça primeiro a obrigação no percurso existente. Não é criada uma dívida por registar um pagamento.
+
+2. Pagamentos propostos e decisões Pagamento #= $e($p['id']) ?> · = $e($p['fact_reference']) ?> = $money($p['amount_minor']) ?> · Data simulada = $e($p['paid_on']) ?> · Estado: = $e(['proposed'=>'Proposto','confirmed'=>'Confirmado em ensaio','rejected'=>'Rejeitado'][$p['state']]) ?>
Por afectar: = $money($p['unallocated_minor']) ?> · Reconciliação bancária: não implementada.
Preparação e evidência preservadas = $e($p['snapshot']) ?> = $e(json_encode($p['decision'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) ?>
+Decisão explícitaConfirmar com evidência sintética Rejeitar Evidência artificial da conferência Fundamento da conferência Registar decisão independente Sem pagamentos propostos neste contexto.
+
+4. Propostas de afectação Proposta #= $e($p['id']) ?> · = $e($p['reference']) ?> · Pagamento #= $e($p['payment_id']) ?> Preparador #= $e($p['prepared_by']) ?> · = $e($p['reason']) ?>
Obrigação #= $e($line['obligation_id']) ?>: = $money($line['amount_minor']) ?> · versão = $e($line['source_revision']) ?> Estado: = $p['decision'] ? $e($p['decision']->decision === 'confirmed' ? 'Afectação confirmada' : 'Rejeitada') : 'Proposta — sem efeito no saldo' ?>
+DecisãoConfirmar estas afectações Rejeitar Fundamento da decisão Confirmar decisão e revalidar saldos
+Histórico durável #= $e($event->id) ?> · = $e($event->kind) ?> · actor #= $e($event->actor_id) ?> · = $e($event->created_at) ?> = $e($event->payload) ?>
+Correcções, reversões, reconciliação e novos pagamentos sobre documentos com histórico importado ficam para âmbito próprio. Os originais e as decisões confirmadas não são editados ou apagados.
+
diff --git a/packages/runtime-manifest.json b/packages/runtime-manifest.json
new file mode 100644
index 0000000..14ccc38
--- /dev/null
+++ b/packages/runtime-manifest.json
@@ -0,0 +1,25 @@
+{
+ "version": 1,
+ "packaged_on": "2026-10-30",
+ "canonical_source": "sgf-tempo/packages; pinned runtime artifacts, not a second independently maintained financial implementation",
+ "files": {
+ "budget-control/README.md": "2470e4f915ef16e608db528f92aa375c87eba570da8dcd515cd7bcee3ef9873b",
+ "budget-control/bootstrap.php": "85d71516e5e34f3c6d0ad3cf00e48fd7e031ddb59ae7ebcb4df0bb5145f3df27",
+ "budget-control/src/BudgetContext.php": "200b23fa560cc967bf6c9ccc1387ee9fefd9933f965811f8a4a0ab5f74f12e04",
+ "budget-control/src/BudgetControl.php": "1349d1ab4ab58a83c9abbf43549ebe7e4ed77717ed5463a2defac748cf7b9a31",
+ "budget-control/src/BudgetFailure.php": "30ec4c7a5b45e5980ca517d167aa45e89f22a3429c76365fa1c1a94db5f4d8e0",
+ "payables/bootstrap.php": "8706ae202bf387654ea465bdb9087bcccc54d53b41637fc1a70ccb0211e20c6c",
+ "payables/src/HistoricalPayments.php": "59576afb1b2209fe08e4d1d45be22e364746a815316749ddcfa19930281dbb3d",
+ "payables/src/HistorySchema.php": "6aa05b9c635d051bfa210e9d596491bd6d9210eb5473d3fa84d9bf838a630e70",
+ "payables/src/Payables.php": "dafc76371767b0a9794f0822553e60392a8aa2c270dea8d78ff701a56f5f5bc9",
+ "payables/src/PayablesSchema.php": "5774c24dbe2b6c06a7e51b359eaad88aee7fc99df2640f687b3065eaa03776d4",
+ "payables/src/PaymentDecisionSchema.php": "54f210e38a2dc352aea07ef958aa8f3d817c9f87b99bcafd1c78bb34d712278d",
+ "payables/src/PaymentDecisions.php": "aeb27c1cc44314e44f6e7afed8d6b58d47d638dc3a5e536ba3e94d38fafad8e0",
+ "payables/src/SettlementHttp.php": "85d436f2aed199be12c0223e08e6aa590f223c95250ed60beb046aaef80db285",
+ "payables/src/SettlementSchema.php": "5c7614c79aa0891835a22a1afed4825db4a5d027ea1c6209022f9ade301d87a5",
+ "payables/src/SupplierSettlements.php": "156bd40f3080de66a73c0befd975990e9d7010c2a232a86d751d4db3af4fae93",
+ "payables/src/Workspace.php": "bdde5e243e34d5a3e67050272bb95b1ec5e681b64947d20cf5af9b902b399991",
+ "payables/ui/page.php": "209c6a8b6e52f5ddefbb0e203bd5a8b21c021ff2039dc7bb91b561f4b03bcc04",
+ "payables/ui/settlements.php": "c820eebeb9fd4e99560f94f05db9358af5168c93c281733db0d94d20ae95057a"
+ }
+}
diff --git a/pint.json b/pint.json
index 93061b6..0c0f5f2 100644
--- a/pint.json
+++ b/pint.json
@@ -1,3 +1,4 @@
{
- "preset": "laravel"
+ "preset": "laravel",
+ "exclude": ["packages"]
}
diff --git a/resources/js/components/sgc-workspace-shell.tsx b/resources/js/components/sgc-workspace-shell.tsx
index 54c1499..6bfcef4 100644
--- a/resources/js/components/sgc-workspace-shell.tsx
+++ b/resources/js/components/sgc-workspace-shell.tsx
@@ -36,8 +36,12 @@ export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
const [mobileOpen, setMobileOpen] = useState(false);
const [search, setSearch] = useState('');
const [expanded, setExpanded] = useState>({});
+ const access = page.props.organizationAccess as {
+ active: boolean;
+ permissions: string[];
+ } | null;
const groups = procurementNavigation(
- user?.purchasing_role ?? '',
+ access?.active ? access.permissions : (user?.purchasing_role ?? ''),
page.props.payablesAvailable === true,
);
const activeId = activeProcurementEntry(groups, page.url);
@@ -404,16 +408,18 @@ export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
{searchField()}
-
-
-
- Nova requisição
-
-
+ {page.props.canRequest === true && (
+
+
+
+ Nova requisição
+
+
+ )}
{user && (
@@ -438,10 +444,23 @@ export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
{user.name}
- {roleLabels[
- user.purchasing_role ??
- ''
- ] ?? 'Utilizador'}
+ {(
+ page.props
+ .organizationAccess as {
+ active: boolean;
+ profileLabel: string;
+ } | null
+ )?.active
+ ? (
+ page.props
+ .organizationAccess as {
+ profileLabel: string;
+ }
+ ).profileLabel
+ : (roleLabels[
+ user.purchasing_role ??
+ ''
+ ] ?? 'Utilizador')}
diff --git a/resources/js/components/supplier-evaluation-fields.tsx b/resources/js/components/supplier-evaluation-fields.tsx
new file mode 100644
index 0000000..e780f4f
--- /dev/null
+++ b/resources/js/components/supplier-evaluation-fields.tsx
@@ -0,0 +1,332 @@
+import { Button } from '@/components/ui/button';
+import { controlClass } from '@/components/procurement';
+
+export type CriterionResult =
+ | 'pending'
+ | 'observed'
+ | 'insufficient'
+ | 'not_applicable';
+export type EvaluationPayload = {
+ title: string;
+ nature: string;
+ scope: string;
+ period_start: string | null;
+ period_end: string | null;
+ order_ids: number[];
+ criteria: {
+ label: string;
+ result: CriterionResult;
+ reason: string;
+ evidence: string;
+ }[];
+ conclusion: string;
+ critical_occurrences: string;
+ critical_evidence: string;
+};
+export type EvaluationOrder = {
+ id: number;
+ number: string;
+ supplier_id: number;
+};
+export const evaluationStates: Record = {
+ draft: 'Rascunho',
+ review: 'Em revisão',
+ returned: 'Devolvida para correcção',
+ approved: 'Aprovada',
+};
+export const criterionStates: Record = {
+ pending: 'Por analisar',
+ observed: 'Observação fundamentada',
+ insufficient: 'Evidência insuficiente',
+ not_applicable: 'Não aplicável',
+};
+export const emptyEvaluation = (): EvaluationPayload => ({
+ title: '',
+ nature: '',
+ scope: '',
+ period_start: '',
+ period_end: '',
+ order_ids: [],
+ criteria: [],
+ conclusion: '',
+ critical_occurrences: '',
+ critical_evidence: '',
+});
+
+export default function SupplierEvaluationFields({
+ payload,
+ orders,
+ disabled,
+ onChange,
+}: {
+ payload: EvaluationPayload;
+ orders: EvaluationOrder[];
+ disabled: boolean;
+ onChange: (value: EvaluationPayload) => void;
+}) {
+ const set = (
+ key: K,
+ value: EvaluationPayload[K],
+ ) => onChange({ ...payload, [key]: value });
+ const criterion = (
+ index: number,
+ patch: Partial,
+ ) =>
+ set(
+ 'criteria',
+ payload.criteria.map((row, i) =>
+ i === index ? { ...row, ...patch } : row,
+ ),
+ );
+ return (
+
+
+
+ Título
+ set('title', event.target.value)}
+ />
+
+
+ Componente avaliada
+ set('nature', event.target.value)}
+ >
+ Escolher
+ Bens
+ Serviços
+
+
+
+ Período — início
+
+ set('period_start', event.target.value)
+ }
+ />
+
+
+ Período — fim
+
+ set('period_end', event.target.value)
+ }
+ />
+
+
+
+ Âmbito, locais, entregas e exclusões justificadas
+ set('scope', event.target.value)}
+ />
+
+
+
+ Ordens abrangidas ({payload.order_ids.length})
+
+
+ Seleccione apenas as ordens efectivamente abrangidas. Outras
+ referências contratuais podem ser descritas no âmbito.
+
+
+ {orders.map((order) => (
+
+
+ set(
+ 'order_ids',
+ event.target.checked
+ ? [...payload.order_ids, order.id]
+ : payload.order_ids.filter(
+ (id) => id !== order.id,
+ ),
+ )
+ }
+ />
+ {order.number}
+
+ ))}
+ {orders.length === 0 && (
+
+ Sem ordens autorizadas disponíveis para este
+ fornecedor.
+
+ )}
+
+
+
+ Critérios e evidências
+
+ Registe o cumprimento da entidade fornecedora, com os
+ requisitos acordados e referências recuperáveis. Falta de
+ evidência permanece explícita; não recebe uma nota mínima.
+ Este ciclo é qualitativo, sem pesos ou classificações
+ automáticas.
+
+ {payload.criteria.map((row, index) => (
+
+
+ Critério {index + 1}
+
+
+ Critério e condição verificável
+
+ criterion(index, {
+ label: event.target.value,
+ })
+ }
+ />
+
+
+ Situação
+
+ criterion(index, {
+ result: event.target
+ .value as CriterionResult,
+ })
+ }
+ >
+ {Object.entries(criterionStates).map(
+ ([value, label]) => (
+
+ {label}
+
+ ),
+ )}
+
+
+
+ Justificação, limites e atribuição das ocorrências
+
+ criterion(index, {
+ reason: event.target.value,
+ })
+ }
+ />
+
+
+ Referência de evidência (recepção, inspecção,
+ documento, comunicação)
+
+ criterion(index, {
+ evidence: event.target.value,
+ })
+ }
+ />
+
+
+ set(
+ 'criteria',
+ payload.criteria.filter(
+ (_, i) => i !== index,
+ ),
+ )
+ }
+ >
+ Retirar este critério do rascunho
+
+
+ ))}
+ = 30}
+ onClick={() =>
+ set('criteria', [
+ ...payload.criteria,
+ {
+ label: '',
+ result: 'pending',
+ reason: '',
+ evidence: '',
+ },
+ ])
+ }
+ >
+ Acrescentar critério
+
+
+
+ Conclusão e encaminhamento
+ set('conclusion', event.target.value)}
+ />
+
+
+
+ Ocorrências críticas
+
+
+ Ocorrências e impacto documentado
+
+ set('critical_occurrences', event.target.value)
+ }
+ />
+
+
+ Referência das evidências
+
+ set('critical_evidence', event.target.value)
+ }
+ />
+
+
+
+ );
+}
diff --git a/resources/js/components/supplier-improvements.tsx b/resources/js/components/supplier-improvements.tsx
new file mode 100644
index 0000000..e158d3d
--- /dev/null
+++ b/resources/js/components/supplier-improvements.tsx
@@ -0,0 +1,583 @@
+import { Link, useForm } from '@inertiajs/react';
+import { useRef } from 'react';
+import { Panel, controlClass } from '@/components/procurement';
+import { Button } from '@/components/ui/button';
+
+type Person = { id: number; name: string };
+type Plan = {
+ title: string;
+ description: string;
+ responsible_id: string;
+ due_on: string;
+};
+type Measure = {
+ id: number;
+ title: string;
+ description: string;
+ responsible_id: number;
+ due_on: string;
+ status: string;
+ version: number;
+ result: string | null;
+ evidence: string | null;
+ reviewed_by: number | null;
+ review_reason: string | null;
+ reviewed_at: string | null;
+ canPlan: boolean;
+ canExecute: boolean;
+ canReview: boolean;
+};
+type Event = {
+ id: number;
+ action: string;
+ user_id: number;
+ created_at: string;
+ after: Measure & { change_reason?: string };
+};
+export type ImprovementData = {
+ ready: boolean;
+ canCreate: boolean;
+ today: string;
+ records: Measure[];
+ assignees: Person[];
+ history: {
+ data: Event[];
+ total: number;
+ prev_page_url: string | null;
+ next_page_url: string | null;
+ } | null;
+};
+const states: Record = {
+ open: 'Em execução',
+ review: 'A aguardar conferência',
+ returned: 'Devolvida para correcção',
+ closed: 'Encerrada',
+};
+const events: Record = {
+ create: 'Criação',
+ plan: 'Alteração do plano',
+ progress: 'Execução registada',
+ submit: 'Pedido de encerramento',
+ return: 'Devolução',
+ close: 'Encerramento independente',
+};
+const freshPlan: Plan = {
+ title: '',
+ description: '',
+ responsible_id: '',
+ due_on: '',
+};
+function Errors({ errors }: { errors: Record }) {
+ return Object.keys(errors).length ? (
+
+ {Object.entries(errors).map(([key, text]) => (
+
{text}
+ ))}
+
+ ) : null;
+}
+function PlanFields({
+ data,
+ change,
+ people,
+}: {
+ data: Plan;
+ change: (key: keyof Plan, value: string) => void;
+ people: Person[];
+}) {
+ return (
+
+
+ Medida
+ change('title', e.target.value)}
+ />
+
+
+ Objectivo e resultado esperado
+ change('description', e.target.value)}
+ />
+
+
+ Responsável interno
+ change('responsible_id', e.target.value)}
+ >
+ Escolher responsável
+ {people.map((p) => (
+
+ {p.name}
+
+ ))}
+
+
+
+ Prazo
+ change('due_on', e.target.value)}
+ />
+
+
+ );
+}
+function MeasureCard({
+ measure: m,
+ base,
+ people,
+ names,
+ today,
+}: {
+ measure: Measure;
+ base: string;
+ people: Person[];
+ names: Record;
+ today: string;
+}) {
+ const plan = useForm({
+ version: m.version,
+ title: m.title,
+ description: m.description,
+ responsible_id: String(m.responsible_id),
+ due_on: m.due_on,
+ reason: '',
+ });
+ const progress = useForm({
+ version: m.version,
+ result: m.result ?? '',
+ evidence: m.evidence ?? '',
+ });
+ const decision = useForm({ version: m.version, reason: '' });
+ const url = `${base}/${m.id}`;
+ const overdue = m.status !== 'closed' && m.due_on < today;
+ return (
+
+
+
+ MM-{m.id} · {m.title}
+
+
+ {states[m.status]}
+ {overdue ? ' · Prazo ultrapassado' : ''}
+
+
+ {m.description}
+
+ Responsável: {names[m.responsible_id] ?? `#${m.responsible_id}`}{' '}
+ · Prazo: {m.due_on} · Versão {m.version}
+
+
+ Resultado registado
+
+ {m.result || 'Ainda não registado.'}
+
+
+ Evidência / referência documental
+
+
+ {m.evidence || 'Ainda não indicada.'}
+
+
+ {m.review_reason && (
+
+ Última conferência por{' '}
+ {names[m.reviewed_by ?? 0] ?? `#${m.reviewed_by}`}
+ {m.reviewed_at
+ ? ` em ${new Date(m.reviewed_at).toLocaleString('pt-MZ')}`
+ : ''}
+ : {m.review_reason}
+
+ )}
+ {m.canPlan && (
+
+
+ Alterar plano, responsável ou prazo
+
+ {
+ e.preventDefault();
+ plan.post(url + '/plan', {
+ preserveState: 'errors',
+ preserveScroll: true,
+ });
+ }}
+ >
+
+ plan.setData(key, value)}
+ people={people}
+ />
+
+ Justificação da alteração
+
+ plan.setData('reason', e.target.value)
+ }
+ />
+
+
+ Guardar alteração justificada
+
+
+
+ )}
+ {m.canExecute && (
+ {
+ e.preventDefault();
+ progress.post(url + '/progress', {
+ preserveState: 'errors',
+ preserveScroll: true,
+ });
+ }}
+ >
+
+
+ Resultado da execução
+
+ progress.setData('result', e.target.value)
+ }
+ />
+
+
+ Referência da evidência
+
+ progress.setData('evidence', e.target.value)
+ }
+ />
+
+
+
+ Guardar progresso
+
+
+ progress.post(url + '/submit', {
+ preserveState: 'errors',
+ preserveScroll: true,
+ })
+ }
+ >
+ Pedir encerramento
+
+
+
+ )}
+ {m.canReview && (
+
+
+
+ Fundamentação da conferência
+
+ decision.setData('reason', e.target.value)
+ }
+ />
+
+
+
+ decision.post(url + '/close', {
+ preserveState: 'errors',
+ preserveScroll: true,
+ })
+ }
+ >
+ Confirmar encerramento
+
+
+ decision.post(url + '/return', {
+ preserveState: 'errors',
+ preserveScroll: true,
+ })
+ }
+ >
+ Devolver para correcção
+
+
+
+ )}
+
+ );
+}
+export default function SupplierImprovements({
+ data,
+ evaluationId,
+ names,
+ reportOnly,
+ generatedAt,
+}: {
+ data: ImprovementData;
+ evaluationId: number;
+ names: Record;
+ reportOnly: boolean;
+ generatedAt: string;
+}) {
+ const create = useForm(freshPlan);
+ const operation = useRef(null);
+ const base = `/fornecedores/avaliacoes/${evaluationId}/medidas`;
+ const overdue = data.records.filter(
+ (m) => m.status !== 'closed' && m.due_on < data.today,
+ ).length;
+ return (
+
+
+
+ Acompanhamento associado a esta revisão, consultado em{' '}
+ {new Date(generatedAt).toLocaleString('pt-MZ')}. Não altera
+ o conteúdo da avaliação aprovada nem a elegibilidade do
+ fornecedor. O prazo ultrapassado é um aviso; não encerra a
+ medida automaticamente.
+
+ {!data.ready ? (
+
+ A instalação das medidas de melhoria ainda não está
+ concluída.
+
+ ) : (
+ <>
+
+ {data.records.length} medida(s) ·{' '}
+ {
+ data.records.filter(
+ (m) => m.status === 'closed',
+ ).length
+ }{' '}
+ encerrada(s) · {overdue} com prazo ultrapassado
+
+ {data.records.length === 0 && (
+
+ Sem medidas registadas nesta revisão. As medidas
+ de revisões anteriores permanecem nos
+ respectivos relatórios.
+
+ )}
+ {data.records.map((m) => (
+
+ ))}
+ {data.canCreate && (
+
+
+ Adicionar medida de melhoria
+
+ {
+ e.preventDefault();
+ operation.current ??=
+ crypto.randomUUID();
+ create.transform((values) => ({
+ ...values,
+ operation_key: operation.current,
+ }));
+ create.post(base, {
+ preserveState: 'errors',
+ preserveScroll: true,
+ });
+ }}
+ >
+
+
+ create.setData(key, value)
+ }
+ people={data.assignees}
+ />
+
+ Registar medida
+
+
+
+ )}
+ {!reportOnly &&
+ data.history &&
+ data.history.total > 0 && (
+
+
+ Histórico das medidas (
+ {data.history.total})
+
+
+ {data.history.data.map((event) => (
+
+
+ MM-{event.after.id} ·{' '}
+ {events[
+ event.action.split(
+ '.',
+ )[1]
+ ] ?? event.action}{' '}
+ ·{' '}
+ {names[event.user_id] ??
+ `#${event.user_id}`}{' '}
+ ·{' '}
+ {new Date(
+ event.created_at,
+ ).toLocaleString('pt-MZ')}
+
+
+
+ {event.after.title} ·{' '}
+ {
+ states[
+ event.after
+ .status
+ ]
+ }{' '}
+ · Versão{' '}
+ {event.after.version}
+
+
+ {
+ event.after
+ .description
+ }
+
+
+ Responsável:{' '}
+ {names[
+ event.after
+ .responsible_id
+ ] ??
+ `#${event.after.responsible_id}`}{' '}
+ · Prazo:{' '}
+ {event.after.due_on}
+
+ {event.after
+ .change_reason && (
+
+ Alteração:{' '}
+ {
+ event.after
+ .change_reason
+ }
+
+ )}
+
+ {event.after.result ||
+ 'Sem resultado registado.'}
+
+
+ {event.after.evidence ||
+ 'Sem evidência registada.'}
+
+ {event.after
+ .review_reason && (
+
+ Conferência:{' '}
+ {
+ event.after
+ .review_reason
+ }
+
+ )}
+
+
+ ))}
+
+
+ {data.history.prev_page_url && (
+
+ Anterior
+
+ )}
+ {data.history.next_page_url && (
+
+ Seguinte
+
+ )}
+
+
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/resources/js/components/supplier-responses.tsx b/resources/js/components/supplier-responses.tsx
new file mode 100644
index 0000000..60f0ccc
--- /dev/null
+++ b/resources/js/components/supplier-responses.tsx
@@ -0,0 +1,575 @@
+import { Link, useForm } from '@inertiajs/react';
+import { useRef } from 'react';
+import { Panel, controlClass } from '@/components/procurement';
+import { Button } from '@/components/ui/button';
+
+type Payload = {
+ title: string;
+ received_on: string | null;
+ source_reference: string;
+ statement: string;
+};
+type Decision = {
+ outcome: string;
+ reason: string;
+ follow_up: string;
+ response_reference: string;
+};
+type Record = {
+ id: number;
+ version: number;
+ status: string;
+ payload: Payload;
+ decision: Decision | null;
+ created_by: number;
+ reviewed_by: number | null;
+ reviewed_at: string | null;
+ review_reason: string | null;
+ canEdit: boolean;
+ canReview: boolean;
+};
+type History = {
+ id: number;
+ user_id: number;
+ action: string;
+ created_at: string;
+ after: Record;
+};
+export type ResponseData = {
+ ready: boolean;
+ canCreate: boolean;
+ records: Record[];
+ history: {
+ data: History[];
+ total: number;
+ prev_page_url: string | null;
+ next_page_url: string | null;
+ } | null;
+};
+const states: { [key: string]: string } = {
+ draft: 'Em preparação',
+ review: 'Em análise',
+ returned: 'Devolvido para completar',
+ decided: 'Conclusão registada',
+};
+const outcomes: { [key: string]: string } = {
+ accepted: 'Argumentos acolhidos',
+ partially_accepted: 'Argumentos parcialmente acolhidos',
+ not_accepted: 'Argumentos não acolhidos',
+};
+const actions: { [key: string]: string } = {
+ create: 'Registo',
+ save: 'Alteração',
+ submit: 'Submissão para análise',
+ return: 'Devolução',
+ decide: 'Conclusão independente',
+};
+const empty: Payload = {
+ title: '',
+ received_on: '',
+ source_reference: '',
+ statement: '',
+};
+function Errors({ errors }: { errors: { [key: string]: string } }) {
+ return Object.keys(errors).length ? (
+
+ {Object.entries(errors).map(([key, message]) => (
+
{message}
+ ))}
+
+ ) : null;
+}
+function Fields({
+ payload,
+ change,
+}: {
+ payload: Payload;
+ change: (key: keyof Payload, value: string) => void;
+}) {
+ return (
+
+
+ Assunto
+ change('title', e.target.value)}
+ />
+
+
+ Recebido em
+ change('received_on', e.target.value)}
+ />
+
+
+ Documento ou origem recebida
+ change('source_reference', e.target.value)}
+ />
+
+
+ Argumentos apresentados pelo fornecedor
+ change('statement', e.target.value)}
+ />
+
+
+ );
+}
+function ResponseCard({
+ record: r,
+ base,
+ names,
+}: {
+ record: Record;
+ base: string;
+ names: { [key: number]: string };
+}) {
+ const edit = useForm({ version: r.version, payload: r.payload });
+ const submit = useForm({ version: r.version });
+ const review = useForm({
+ version: r.version,
+ outcome: '',
+ reason: '',
+ follow_up: '',
+ response_reference: '',
+ });
+ const url = `${base}/${r.id}`;
+ const dirty =
+ JSON.stringify(edit.data.payload) !== JSON.stringify(r.payload);
+ return (
+
+
+ CTR-{r.id} · {r.payload.title}
+
+
+ {states[r.status]} · Versão {r.version} · Registado por{' '}
+ {names[r.created_by] ?? `#${r.created_by}`}
+
+ {r.canEdit ? (
+ {
+ e.preventDefault();
+ edit.post(url + '/save', {
+ preserveState: 'errors',
+ preserveScroll: true,
+ });
+ }}
+ >
+
+
+
+ edit.setData('payload', {
+ ...edit.data.payload,
+ [key]: value,
+ })
+ }
+ />
+
+
+ Guardar registo
+
+
+ submit.post(url + '/submit', {
+ preserveState: 'errors',
+ preserveScroll: true,
+ })
+ }
+ >
+ Submeter para análise
+
+
+ {dirty && (
+
+ Guarde as alterações antes de submeter.
+
+ )}
+
+ ) : (
+
+
+ Recebido em:{' '}
+ {r.payload.received_on || 'Ainda não indicado'}
+
+
+ Documento/origem:{' '}
+ {r.payload.source_reference || 'Ainda não indicado'}
+
+
+ {r.payload.statement ||
+ 'Argumentos ainda não transcritos.'}
+
+
+ )}
+ {r.review_reason && (
+
+ Última análise por{' '}
+ {names[r.reviewed_by ?? 0] ?? `#${r.reviewed_by}`}
+ {r.reviewed_at
+ ? ` em ${new Date(r.reviewed_at).toLocaleString('pt-MZ')}`
+ : ''}
+ : {r.review_reason}
+
+ )}
+ {r.decision && (
+
+
+ {outcomes[r.decision.outcome]}
+
+ {r.decision.follow_up && (
+
Seguimento: {r.decision.follow_up}
+ )}
+ {r.decision.response_reference && (
+
+ Referência da resposta:{' '}
+ {r.decision.response_reference}
+
+ )}
+
+ )}
+ {r.canReview && (
+
+
+
+ Conclusão
+
+ review.setData('outcome', e.target.value)
+ }
+ >
+ Escolher conclusão
+ {Object.entries(outcomes).map(([key, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+ Análise fundamentada
+
+ review.setData('reason', e.target.value)
+ }
+ />
+
+
+ Seguimento a dar
+
+ review.setData('follow_up', e.target.value)
+ }
+ />
+
+
+ Referência da resposta preparada (opcional)
+
+ review.setData(
+ 'response_reference',
+ e.target.value,
+ )
+ }
+ />
+
+
+ {
+ review.transform((values) => values);
+ review.post(url + '/decide', {
+ preserveState: 'errors',
+ preserveScroll: true,
+ });
+ }}
+ >
+ Registar conclusão da análise
+
+ {
+ review.transform((values) => ({
+ version: values.version,
+ reason: values.reason,
+ }));
+ review.post(url + '/return', {
+ preserveState: 'errors',
+ preserveScroll: true,
+ });
+ }}
+ >
+ Devolver para completar
+
+
+
+ )}
+
+ );
+}
+export default function SupplierResponses({
+ data,
+ evaluationId,
+ names,
+ reportOnly,
+ generatedAt,
+}: {
+ data: ResponseData;
+ evaluationId: number;
+ names: { [key: number]: string };
+ reportOnly: boolean;
+ generatedAt: string;
+}) {
+ const create = useForm({ payload: empty });
+ const operation = useRef(null);
+ const base = `/fornecedores/avaliacoes/${evaluationId}/contraditorios`;
+ return (
+
+
+
+ Documentos e argumentos recebidos, registados pela equipa.
+ Acompanhamento consultado em{' '}
+ {new Date(generatedAt).toLocaleString('pt-MZ')}. A conclusão
+ fica registada para comunicação ao fornecedor; a avaliação
+ aprovada conserva-se e qualquer rectificação exige uma nova
+ revisão.
+
+ {!data.ready ? (
+
+ A instalação do contraditório ainda não está concluída.
+
+ ) : (
+ <>
+ {!data.records.length && (
+
+ Sem contraditórios registados nesta revisão.
+
+ )}
+ {data.records.map((r) => (
+
+ ))}
+ {data.canCreate && (
+
+
+ Registar documento recebido
+
+ {
+ e.preventDefault();
+ operation.current ??=
+ crypto.randomUUID();
+ create.transform((values) => ({
+ ...values,
+ operation_key: operation.current,
+ }));
+ create.post(base, {
+ preserveState: 'errors',
+ preserveScroll: true,
+ });
+ }}
+ >
+
+
+ create.setData('payload', {
+ ...create.data.payload,
+ [key]: value,
+ })
+ }
+ />
+
+ Guardar rascunho
+
+
+
+ )}
+ {!reportOnly &&
+ data.history &&
+ data.history.total > 0 && (
+
+
+ Histórico do contraditório (
+ {data.history.total})
+
+
+ {data.history.data.map((event) => (
+
+
+ CTR-{event.after.id} ·{' '}
+ {actions[
+ event.action.split(
+ '.',
+ )[1]
+ ] ?? event.action}{' '}
+ ·{' '}
+ {names[event.user_id] ??
+ `#${event.user_id}`}{' '}
+ ·{' '}
+ {new Date(
+ event.created_at,
+ ).toLocaleString('pt-MZ')}
+
+
+
+ {
+ event.after.payload
+ .title
+ }{' '}
+ ·{' '}
+ {
+ states[
+ event.after
+ .status
+ ]
+ }{' '}
+ · Versão{' '}
+ {event.after.version}
+
+
+ Recebido:{' '}
+ {event.after.payload
+ .received_on ||
+ 'Não indicado'}
+
+
+ {
+ event.after.payload
+ .source_reference
+ }
+
+
+ {
+ event.after.payload
+ .statement
+ }
+
+ {event.after
+ .review_reason && (
+
+ Análise:{' '}
+ {
+ event.after
+ .review_reason
+ }
+
+ )}
+ {event.after.decision && (
+ <>
+
+ {
+ outcomes[
+ event
+ .after
+ .decision
+ .outcome
+ ]
+ }
+
+
+ {
+ event.after
+ .decision
+ .follow_up
+ }
+
+
+ {
+ event.after
+ .decision
+ .response_reference
+ }
+
+ >
+ )}
+
+
+ ))}
+
+
+ {data.history.prev_page_url && (
+
+ Anterior
+
+ )}
+ {data.history.next_page_url && (
+
+ Seguinte
+
+ )}
+
+
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/resources/js/lib/procurement-navigation.ts b/resources/js/lib/procurement-navigation.ts
index d77ba17..a7d6be8 100644
--- a/resources/js/lib/procurement-navigation.ts
+++ b/resources/js/lib/procurement-navigation.ts
@@ -6,6 +6,7 @@ import {
Users,
Wallet,
Warehouse,
+ FileBarChart,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
@@ -27,7 +28,7 @@ const finance = ['admin', 'finance'];
const inventory = [...readers, 'warehouse'];
export function procurementNavigation(
- role: string,
+ role: string | string[],
payables: boolean,
): ProcurementGroup[] {
const groups: ProcurementGroup[] = [
@@ -205,6 +206,12 @@ export function procurementNavigation(
path: '/empresa',
roles: ['admin'],
},
+ {
+ id: 'organization_parameters',
+ label: 'Parametrização da aplicação',
+ path: '/administracao/parametrizacao',
+ roles: ['admin'],
+ },
{
id: 'admission_policy',
label: 'Política de admissão',
@@ -224,12 +231,40 @@ export function procurementNavigation(
},
],
},
+ {
+ id: 'reports',
+ label: 'Relatórios',
+ icon: FileBarChart,
+ entries: [
+ {
+ id: 'procurement_reports',
+ label: 'Relatórios de compras',
+ path: '/relatorios',
+ roles: readers,
+ },
+ {
+ id: 'supplier_improvement_reports',
+ label: 'Medidas de melhoria',
+ path: '/relatorios/medidas-fornecedores',
+ roles: readers,
+ },
+ ],
+ },
];
return groups
.map((group) => ({
...group,
entries: group.entries.filter(
- (entry) => !entry.roles || entry.roles.includes(role),
+ (entry) =>
+ (entry.id !== 'new_request' ||
+ !Array.isArray(role) ||
+ role.includes('requester')) &&
+ (!entry.roles ||
+ entry.roles.some((permission) =>
+ (Array.isArray(role) ? role : [role]).includes(
+ permission,
+ ),
+ )),
),
}))
.filter((group) => group.entries.length);
diff --git a/resources/js/pages/company/parameters.tsx b/resources/js/pages/company/parameters.tsx
new file mode 100644
index 0000000..db48738
--- /dev/null
+++ b/resources/js/pages/company/parameters.tsx
@@ -0,0 +1,765 @@
+import { Head, useForm } from '@inertiajs/react';
+import {
+ PageTitle,
+ Panel,
+ FormErrors,
+ controlClass,
+} from '@/components/procurement';
+import { Button } from '@/components/ui/button';
+
+type Profile = { id: number; name: string; permissions: string[] };
+type Member = {
+ user_id: number;
+ profile_id: number;
+ maintenance: boolean;
+ expires_at: string | null;
+ reason: string | null;
+};
+type Person = {
+ id: number;
+ name: string;
+ email: string;
+ department_id: number;
+};
+const base = '/administracao/parametrizacao';
+function Choices({
+ profiles,
+ selected,
+ change,
+}: {
+ profiles: Profile[];
+ selected: number[];
+ change: (ids: number[]) => void;
+}) {
+ return (
+
+ {profiles.map((p) => (
+
+
+ change(
+ e.target.checked
+ ? [...selected, p.id]
+ : selected.filter((id) => id !== p.id),
+ )
+ }
+ />
+ {p.name}
+
+ ))}
+
+ );
+}
+function Assignment({
+ person,
+ profiles,
+ members,
+ version,
+}: {
+ person: Person;
+ profiles: Profile[];
+ members: Member[];
+ version: number;
+}) {
+ const assigned = members.filter((m) => m.user_id === person.id);
+ const form = useForm({
+ version,
+ user_id: person.id,
+ profile_ids: assigned.map((m) => m.profile_id),
+ maintenance: assigned.some((m) => m.maintenance),
+ expires_at: '',
+ reason: '',
+ });
+ return (
+
+
+ {person.name} · {person.email} · {assigned.length} perfil(is)
+
+ {
+ e.preventDefault();
+ form.transform((values) => ({
+ ...values,
+ expires_at: values.expires_at
+ ? new Date(values.expires_at).toISOString()
+ : null,
+ }));
+ form.post(base + '/assign', {
+ preserveState: 'errors',
+ preserveScroll: true,
+ });
+ }}
+ >
+
+ form.setData('profile_ids', ids)}
+ />
+
+
+ form.setData('maintenance', e.target.checked)
+ }
+ />
+ Manutenção TEMPO — diagnóstico e consulta temporários
+
+
+ Expiração (obrigatória para manutenção)
+
+ form.setData('expires_at', e.target.value)
+ }
+ />
+
+ {assigned.some((m) => m.expires_at) && (
+
+ Expiração anterior:{' '}
+ {assigned.find((m) => m.expires_at)?.expires_at}.
+ Indique a expiração para manter um acesso temporário.
+
+ )}
+
+ Fundamento da atribuição ou revogação
+ form.setData('reason', e.target.value)}
+ />
+
+
+ Sem perfis seleccionados, o acesso funcional será revogado
+ quando a parametrização estiver activa. Os dois
+ administradores permanentes devem ser mantidos.
+
+ Guardar atribuição
+
+
+ );
+}
+export default function Parameters({
+ ready,
+ active,
+ version,
+ permissions,
+ profiles,
+ memberships,
+ users,
+ departments,
+ costCenters,
+ notice,
+ companyRules,
+}: {
+ ready: boolean;
+ active: boolean;
+ version: number;
+ permissions: { [key: string]: string };
+ profiles: Profile[];
+ memberships: Member[];
+ users: Person[];
+ departments: { id: number; name: string }[];
+ costCenters: { id: number; code: string; name: string }[];
+ notice?: string;
+ companyRules: {
+ first_limit: string;
+ second_limit: string;
+ includes_tax: boolean;
+ provisional: boolean;
+ };
+}) {
+ const profile = useForm({
+ version,
+ id: '',
+ name: '',
+ permissions: [] as string[],
+ });
+ const user = useForm({
+ version,
+ name: '',
+ email: '',
+ password: '',
+ password_confirmation: '',
+ department_id: '',
+ profile_ids: [] as number[],
+ reason: '',
+ });
+ const activate = useForm({ version, confirm: false, reason: '' });
+ const rules = useForm({
+ version,
+ first_limit: companyRules.first_limit,
+ second_limit: companyRules.second_limit,
+ includes_tax: companyRules.includes_tax,
+ reason: '',
+ });
+ const department = useForm({ name: '' });
+ const center = useForm({ department_id: '', code: '', name: '' });
+ return (
+ <>
+
+
+
+ {notice && (
+
+ {notice}
+
+ )}
+ {!ready ? (
+
+
+ A parametrização ainda não foi instalada neste
+ ambiente.
+
+
+ ) : (
+ <>
+
+
+ {active
+ ? 'Os acessos são determinados pelos perfis e atribuições abaixo.'
+ : 'Crie os perfis e associe os utilizadores antes de activar. A activação substitui os perfis anteriores; utilizadores sem atribuições válidas deixam de ter acesso funcional.'}{' '}
+ Versão {version}.
+
+ {!active && (
+ {
+ e.preventDefault();
+ activate.post(base + '/activate', {
+ preserveState: 'errors',
+ preserveScroll: true,
+ });
+ }}
+ >
+
+
+ Motivo da activação
+
+ activate.setData(
+ 'reason',
+ e.target.value,
+ )
+ }
+ />
+
+
+
+ activate.setData(
+ 'confirm',
+ e.target.checked,
+ )
+ }
+ />
+ Conferi os dois administradores e o
+ efeito das atribuições sobre todos os
+ utilizadores.
+
+
+ Activar configuração da empresa
+
+
+ )}
+
+
+
+ {companyRules.provisional
+ ? 'Os valores actuais são provisórios; confirme e guarde as regras da empresa.'
+ : 'Regras definidas pela empresa.'}{' '}
+ Alterações aplicam-se a novas submissões. Os
+ processos já submetidos conservam as regras
+ anteriores.
+
+ {
+ e.preventDefault();
+ rules.post(base + '/rules', {
+ preserveState: 'errors',
+ preserveScroll: true,
+ });
+ }}
+ >
+
+
+
+
+ Primeiro limite (MZN)
+
+ rules.setData(
+ 'first_limit',
+ e.target.value,
+ )
+ }
+ />
+
+
+ Segundo limite (MZN)
+
+ rules.setData(
+ 'second_limit',
+ e.target.value,
+ )
+ }
+ />
+
+
+
+ rules.setData(
+ 'includes_tax',
+ e.target.checked,
+ )
+ }
+ />
+ Aplicar os limites ao valor com imposto
+
+
+ Supervisão do departamento na primeira
+ etapa; acima do primeiro limite
+ acrescenta-se Compras; acima do segundo
+ acrescenta-se Finanças. Equipamentos mantêm
+ a conferência financeira e técnica. Atribua
+ estas funções às pessoas através dos perfis.
+
+
+ Fundamentação
+
+ rules.setData(
+ 'reason',
+ e.target.value,
+ )
+ }
+ />
+
+
+ Guardar regras da empresa
+
+
+
+
+
+ {profiles.map((p) => (
+
+ profile.setData({
+ version,
+ id: String(p.id),
+ name: p.name,
+ permissions: p.permissions,
+ })
+ }
+ >
+ {p.name}
+
+ ))}
+
+ {
+ e.preventDefault();
+ profile.transform((values) => ({
+ ...values,
+ id: values.id || null,
+ }));
+ profile.post(base + '/profile', {
+ preserveState: 'errors',
+ preserveScroll: true,
+ });
+ }}
+ >
+
+
+ Nome do perfil
+
+ profile.setData(
+ 'name',
+ e.target.value,
+ )
+ }
+ />
+
+
+
+ Permissões funcionais
+
+ {Object.entries(permissions).map(
+ ([key, label]) => (
+
+
+ profile.setData(
+ 'permissions',
+ e.target.checked
+ ? [
+ ...profile
+ .data
+ .permissions,
+ key,
+ ]
+ : profile.data.permissions.filter(
+ (p) =>
+ p !==
+ key,
+ ),
+ )
+ }
+ />
+ {label}
+
+ ),
+ )}
+
+
+
+ {profile.data.id
+ ? 'Actualizar perfil'
+ : 'Criar perfil'}
+
+
+ profile.setData({
+ version,
+ id: '',
+ name: '',
+ permissions: [],
+ })
+ }
+ >
+ Novo perfil
+
+
+
+
+
+
+ {users.map((person) => (
+
+ ))}
+
+
+
+ Criar utilizador
+
+ {
+ e.preventDefault();
+ user.post(base + '/user', {
+ preserveState: 'errors',
+ preserveScroll: true,
+ onSuccess: () =>
+ user.reset(
+ 'password',
+ 'password_confirmation',
+ ),
+ });
+ }}
+ >
+
+
+
+ {(
+ [
+ 'name',
+ 'email',
+ 'password',
+ 'password_confirmation',
+ ] as const
+ ).map((key) => (
+
+ {
+ {
+ name: 'Nome',
+ email: 'Email',
+ password:
+ 'Palavra-passe inicial',
+ password_confirmation:
+ 'Confirmar palavra-passe',
+ }[key]
+ }
+
+ user.setData(
+ key,
+ e.target.value,
+ )
+ }
+ />
+
+ ))}
+
+ Departamento
+
+ user.setData(
+ 'department_id',
+ e.target.value,
+ )
+ }
+ >
+ Escolher
+ {departments.map((d) => (
+
+ {d.name}
+
+ ))}
+
+
+
+
Perfis atribuídos
+
+ user.setData('profile_ids', ids)
+ }
+ />
+
+
+ Fundamento
+
+ user.setData(
+ 'reason',
+ e.target.value,
+ )
+ }
+ />
+
+
+ Criar utilizador
+
+
+
+
+
+
+
{
+ e.preventDefault();
+ department.post(
+ '/empresa/departamentos',
+ { preserveScroll: true },
+ );
+ }}
+ >
+
+
+ Novo departamento
+
+ department.setData(
+ 'name',
+ e.target.value,
+ )
+ }
+ />
+
+
+ Adicionar departamento
+
+
+ {departments
+ .map((d) => d.name)
+ .join(' · ')}
+
+
+
{
+ e.preventDefault();
+ center.post(
+ '/empresa/centros-de-custo',
+ { preserveScroll: true },
+ );
+ }}
+ >
+
+
+ Departamento
+
+ center.setData(
+ 'department_id',
+ e.target.value,
+ )
+ }
+ >
+ Escolher
+ {departments.map((d) => (
+
+ {d.name}
+
+ ))}
+
+
+
+ Código
+
+ center.setData(
+ 'code',
+ e.target.value,
+ )
+ }
+ />
+
+
+ Centro de custo
+
+ center.setData(
+ 'name',
+ e.target.value,
+ )
+ }
+ />
+
+
+ Adicionar centro
+
+
+ {costCenters
+ .map((c) => c.code + ' · ' + c.name)
+ .join(' / ')}
+
+
+
+
+
+
+ Atribua a uma conta nominativa um perfil sem
+ administração, assinale manutenção e defina a
+ expiração. O acesso fica limitado a diagnóstico
+ e consulta, é revogado removendo as atribuições
+ e termina automaticamente na data indicada. As
+ atribuições, alterações e operações mantêm
+ autoria e auditoria; não existe palavra-passe
+ universal.
+
+
+ >
+ )}
+
+ >
+ );
+}
diff --git a/resources/js/pages/company/setup.tsx b/resources/js/pages/company/setup.tsx
index 56dc0b0..1ce8ffd 100644
--- a/resources/js/pages/company/setup.tsx
+++ b/resources/js/pages/company/setup.tsx
@@ -5,7 +5,17 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export default function Setup() {
- const form = useForm({ name: '', department: '' });
+ const form = useForm({
+ name: '',
+ department: '',
+ second_name: '',
+ second_email: '',
+ second_password: '',
+ second_password_confirmation: '',
+ first_limit: '',
+ second_limit: '',
+ includes_tax: true,
+ });
return (
<>
@@ -13,7 +23,7 @@ export default function Setup() {
+
+
+ Segundo administrador da empresa
+
+
+ Nome
+
+ form.setData(
+ 'second_name',
+ e.target.value,
+ )
+ }
+ />
+
+
+ Email
+
+ form.setData(
+ 'second_email',
+ e.target.value,
+ )
+ }
+ />
+
+
+ Palavra-passe inicial
+
+ form.setData(
+ 'second_password',
+ e.target.value,
+ )
+ }
+ />
+
+
+ Confirmar palavra-passe
+
+ form.setData(
+ 'second_password_confirmation',
+ e.target.value,
+ )
+ }
+ />
+
+
Será criado um centro de custo geral. Moeda inicial:
- MZN. Regras de aprovação provisórias.
+ MZN. Defina os limites de aprovação da empresa.
+
+
+ Regras da empresa
+
+
+ Primeiro limite de aprovação (MZN)
+
+ form.setData(
+ 'first_limit',
+ e.target.value,
+ )
+ }
+ />
+
+
+ Segundo limite de aprovação (MZN)
+
+ form.setData(
+ 'second_limit',
+ e.target.value,
+ )
+ }
+ />
+
+
+
+ form.setData(
+ 'includes_tax',
+ e.target.checked,
+ )
+ }
+ />
+ Aplicar os limites ao valor com imposto
+
+
Criar empresa
diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx
index 5fdf395..07c7c92 100644
--- a/resources/js/pages/dashboard.tsx
+++ b/resources/js/pages/dashboard.tsx
@@ -1,4 +1,4 @@
-import { Head, Link } from '@inertiajs/react';
+import { Head, Link, usePage } from '@inertiajs/react';
import {
ArrowRight,
CheckCheck,
@@ -30,6 +30,7 @@ export default function Dashboard({
recent,
approvals,
}: Props) {
+ const canRequest = usePage().props.canRequest === true;
const cards = [
{
label: 'Requisições',
@@ -65,23 +66,25 @@ export default function Dashboard({
title="Visão geral das compras"
description="Acompanhe os pedidos, as aprovações e a actividade da sua empresa."
action={
-
-
-
- {company
- ? 'Nova requisição'
- : 'Configurar empresa'}
-
-
+
+
+ {company
+ ? 'Nova requisição'
+ : 'Configurar empresa'}
+
+
+ )
}
/>
{!company ? (
diff --git a/resources/js/pages/purchase-controls/index.tsx b/resources/js/pages/purchase-controls/index.tsx
index b659d63..f95088f 100644
--- a/resources/js/pages/purchase-controls/index.tsx
+++ b/resources/js/pages/purchase-controls/index.tsx
@@ -23,6 +23,7 @@ type Cancellation = {
};
type Props = {
role: string;
+ permissions: string[];
actorId: number;
policies: Policy[];
reviews: {
@@ -211,7 +212,7 @@ function RevisionForm({ id }: { id: number }) {
}
export default function PurchaseControls({
- role,
+ permissions,
actorId,
policies,
reviews,
@@ -233,7 +234,7 @@ export default function PurchaseControls({
Um pedido pendente suspende decisões e emissão. Ordens,
recepções, facturas e pagamentos exigem um circuito próprio.
- {role === 'admin' && (
+ {permissions.includes('admin') && (
- {role === 'admin' &&
+ {permissions.includes('admin') &&
policy.status === 'pending' &&
policy.id === policies[0]?.id &&
policy.created_by !== actorId &&
@@ -307,7 +308,7 @@ export default function PurchaseControls({
{cancellation.review_reason}
)}
- {role === 'finance' &&
+ {permissions.includes('finance') &&
cancellation.status === 'pending' &&
cancellation.created_by !== actorId && (
) : (
- role === 'procurement' && (
+ permissions.includes('procurement') && (
diff --git a/resources/js/pages/purchases/admission.tsx b/resources/js/pages/purchases/admission.tsx
index 772223a..832a890 100644
--- a/resources/js/pages/purchases/admission.tsx
+++ b/resources/js/pages/purchases/admission.tsx
@@ -28,7 +28,7 @@ type Review = {
verdict: string;
reason: string;
created_at: string;
- snapshot: { name: string; purchasing_role: string };
+ snapshot: { name: string; purchasing_role: string; profile_label?: string };
};
type Decision = {
id: number;
@@ -373,9 +373,10 @@ export default function Admission({
>
{r.snapshot.name} ·{' '}
- {roleLabels[
- r.snapshot.purchasing_role
- ] ??
+ {r.snapshot.profile_label ??
+ roleLabels[
+ r.snapshot.purchasing_role
+ ] ??
r.snapshot.purchasing_role}{' '}
·{' '}
{r.verdict === 'approved'
diff --git a/resources/js/pages/purchases/qualification.tsx b/resources/js/pages/purchases/qualification.tsx
index b6a9bde..41a17b2 100644
--- a/resources/js/pages/purchases/qualification.tsx
+++ b/resources/js/pages/purchases/qualification.tsx
@@ -38,7 +38,13 @@ type Assessment = {
answers: Answer[];
reason: string;
created_at: string;
- snapshot: { analyst: { name: string; purchasing_role: string } };
+ snapshot: {
+ analyst: {
+ name: string;
+ purchasing_role: string;
+ profile_label?: string;
+ };
+ };
};
type Props = {
requisition: { id: number; number: string; justification: string };
@@ -465,8 +471,9 @@ function AssessmentDetails({
· {record.snapshot.analyst.name} · Análise v{record.revision}
- {roleLabels[record.snapshot.analyst.purchasing_role]} ·{' '}
- {date(record.created_at)}
+ {record.snapshot.analyst.profile_label ??
+ roleLabels[record.snapshot.analyst.purchasing_role]}{' '}
+ · {date(record.created_at)}
{record.reason}
diff --git a/resources/js/pages/purchases/recommendation.tsx b/resources/js/pages/purchases/recommendation.tsx
index c57e754..ca77e66 100644
--- a/resources/js/pages/purchases/recommendation.tsx
+++ b/resources/js/pages/purchases/recommendation.tsx
@@ -36,7 +36,7 @@ type Review = {
verdict: string;
reason: string;
created_at: string;
- snapshot: { name: string; purchasing_role: string };
+ snapshot: { name: string; purchasing_role: string; profile_label?: string };
};
type Attempt = {
id: number;
@@ -456,9 +456,11 @@ export default function Recommendation(props: Props) {
>
{review.snapshot.name} ·{' '}
- {roleLabels[
- review.snapshot.purchasing_role
- ] ??
+ {review.snapshot.profile_label ??
+ roleLabels[
+ review.snapshot
+ .purchasing_role
+ ] ??
review.snapshot
.purchasing_role}{' '}
·{' '}
diff --git a/resources/js/pages/purchases/review-report.tsx b/resources/js/pages/purchases/review-report.tsx
index 6f8944d..635f702 100644
--- a/resources/js/pages/purchases/review-report.tsx
+++ b/resources/js/pages/purchases/review-report.tsx
@@ -19,7 +19,7 @@ type Answer = {
type Analysis = {
id: number;
revision: number;
- analyst: { name: string; purchasing_role: string };
+ analyst: { name: string; purchasing_role: string; profile_label?: string };
created_at: string;
reason: string;
answers: Answer[];
@@ -93,6 +93,38 @@ type Report = {
currency: string;
total_minor: number;
} | null;
+ fulfilment: {
+ receipts: {
+ id: number;
+ received_on: string | null;
+ delivery_note: string;
+ }[];
+ finance_visible: boolean;
+ invoices:
+ | {
+ id: number;
+ number: string;
+ status: string;
+ issued_on: string;
+ total_minor: string;
+ currency: string | null;
+ }[]
+ | null;
+ payments:
+ | {
+ id: number;
+ invoice_id: number;
+ status: string;
+ paid_on: string;
+ amount_minor: string;
+ currency: string;
+ }[]
+ | null;
+ };
+};
+const exactMoney = (minor: string, currency: string | null) => {
+ const digits = minor.padStart(3, '0');
+ return `${digits.slice(0, -2)},${digits.slice(-2)} ${currency ?? '(moeda não documentada)'}`;
};
const money = (minor: number, currency: string) =>
`${(minor / 100).toLocaleString('pt-PT', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`;
@@ -139,7 +171,12 @@ export default function ReviewReport({ report }: { report: Report }) {
title="Relatório de conferência"
description={`${report.company} · ${r.department} · Solicitante: ${r.requester}`}
action={
-
+
+
+
+ Relatórios de compras
+
+
Ver processo
@@ -415,13 +452,13 @@ export default function ReviewReport({ report }: { report: Report }) {
.name
}{' '}
·{' '}
- {
+ {analysis.analyst
+ .profile_label ??
roleLabels[
analysis
.analyst
.purchasing_role
- ]
- }{' '}
+ ]}{' '}
· Análise v
{analysis.revision}
@@ -611,6 +648,131 @@ export default function ReviewReport({ report }: { report: Report }) {
+
+
+
+ Recepções
+ {report.fulfilment.receipts.length === 0 ? (
+ Sem recepções registadas.
+ ) : (
+
+ {report.fulfilment.receipts.map(
+ (receipt) => (
+
+ Recepção #{receipt.id} ·{' '}
+ {receipt.received_on
+ ? date(receipt.received_on)
+ : 'Data por indicar'}{' '}
+ · Guia: {receipt.delivery_note}.{' '}
+
+ Consultar recepção
+
+
+ ),
+ )}
+
+ )}
+
+ {!report.fulfilment.finance_visible ? (
+
+ Facturas e pagamentos estão disponíveis aos
+ perfis autorizados de Finanças e Administração.
+
+ ) : (
+ <>
+
+ Facturas
+ {!report.fulfilment.invoices?.length ? (
+ Sem facturas registadas.
+ ) : (
+
+ {report.fulfilment.invoices.map(
+ (invoice) => (
+
+ Factura {invoice.number}{' '}
+ ·{' '}
+ {date(
+ invoice.issued_on,
+ )}{' '}
+ ·{' '}
+ {statusLabels[
+ invoice.status
+ ] ??
+ invoice.status}{' '}
+ · Total documental:{' '}
+ {exactMoney(
+ invoice.total_minor,
+ invoice.currency,
+ )}
+ .
+
+ ),
+ )}
+
+ )}
+
+
+
+ Registos de pagamentos externos
+
+ {!report.fulfilment.payments?.length ? (
+
+ Sem registos de pagamentos externos.
+
+ ) : (
+
+ {report.fulfilment.payments.map(
+ (payment) => (
+
+ Registo #{payment.id} ·
+ Factura{' '}
+ {report.fulfilment.invoices?.find(
+ (invoice) =>
+ invoice.id ===
+ payment.invoice_id,
+ )?.number ??
+ `#${payment.invoice_id}`}{' '}
+ ·{' '}
+ {date(payment.paid_on)}{' '}
+ ·{' '}
+ {{
+ pending:
+ 'Por conferir',
+ confirmed:
+ 'Confirmado',
+ rejected:
+ 'Rejeitado',
+ }[payment.status] ??
+ payment.status}{' '}
+ ·{' '}
+ {exactMoney(
+ payment.amount_minor,
+ payment.currency,
+ )}
+ .
+
+ ),
+ )}
+
+ )}
+
+ Os montantes são os registos
+ preservados, sem cálculo de saldo neste
+ relatório. Obrigações e afectações do
+ núcleo comum têm o seu próprio
+ histórico.
+
+
+ >
+ )}
+
+
+ >
+ );
+}
diff --git a/resources/js/pages/requisitions/index.tsx b/resources/js/pages/requisitions/index.tsx
index 9e39c4d..518818e 100644
--- a/resources/js/pages/requisitions/index.tsx
+++ b/resources/js/pages/requisitions/index.tsx
@@ -1,4 +1,4 @@
-import { Head, Link, useForm } from '@inertiajs/react';
+import { Head, Link, useForm, usePage } from '@inertiajs/react';
import { Plus, Search } from 'lucide-react';
import {
PageTitle,
@@ -23,6 +23,7 @@ type Props = {
filters: { search?: string; status?: string; view?: string };
};
export default function Index({ requisitions, filters }: Props) {
+ const canRequest = usePage().props.canRequest === true;
const form = useForm({
search: filters.search ?? '',
status: filters.status ?? '',
@@ -63,12 +64,14 @@ export default function Index({ requisitions, filters }: Props) {
title={view.title}
description={view.description}
action={
-
-
-
- Nova requisição
-
-
+ canRequest && (
+
+
+
+ Nova requisição
+
+
+ )
}
/>
diff --git a/resources/js/pages/suppliers/evaluation.tsx b/resources/js/pages/suppliers/evaluation.tsx
new file mode 100644
index 0000000..fa94885
--- /dev/null
+++ b/resources/js/pages/suppliers/evaluation.tsx
@@ -0,0 +1,538 @@
+import { Head, Link, useForm } from '@inertiajs/react';
+import { Printer } from 'lucide-react';
+import SupplierImprovements from '@/components/supplier-improvements';
+import type { ImprovementData } from '@/components/supplier-improvements';
+import SupplierResponses from '@/components/supplier-responses';
+import type { ResponseData } from '@/components/supplier-responses';
+import { PageTitle, Panel, controlClass } from '@/components/procurement';
+import { Button } from '@/components/ui/button';
+import SupplierEvaluationFields, {
+ evaluationStates,
+ criterionStates,
+} from '@/components/supplier-evaluation-fields';
+import type {
+ EvaluationPayload,
+ EvaluationOrder,
+} from '@/components/supplier-evaluation-fields';
+
+type Evaluation = {
+ id: number;
+ supplier_id: number;
+ revision: number;
+ version: number;
+ status: string;
+ previous_id: number | null;
+ created_by: number;
+ contributors: number[];
+ supplier_snapshot: { id: number; name: string; nuit: string };
+ payload: EvaluationPayload;
+ review_reason: string | null;
+ reviewed_at: string | null;
+};
+type History = {
+ id: number;
+ action: string;
+ user_id: number;
+ after: Evaluation;
+ created_at: string;
+};
+const actions: Record = {
+ created: 'Criação',
+ updated: 'Alteração',
+ submit: 'Submissão',
+ approve: 'Aprovação',
+ return: 'Devolução',
+ revised: 'Nova revisão',
+};
+export default function SupplierEvaluation({
+ evaluation: e,
+ orders,
+ canEdit,
+ canReview,
+ canRevise,
+ reportOnly,
+ nextRevisionId,
+ history,
+ people,
+ notice,
+ generatedAt,
+ improvements,
+ responses,
+}: {
+ evaluation: Evaluation;
+ orders: EvaluationOrder[];
+ canEdit: boolean;
+ canReview: boolean;
+ canRevise: boolean;
+ reportOnly: boolean;
+ nextRevisionId: number | null;
+ history: {
+ data: History[];
+ total: number;
+ prev_page_url: string | null;
+ next_page_url: string | null;
+ };
+ people: Record;
+ notice?: string;
+ generatedAt: string;
+ improvements: ImprovementData;
+ responses: ResponseData;
+}) {
+ const form = useForm({ version: e.version, payload: e.payload });
+ const command = useForm({ version: e.version, reason: '' });
+ const base = `/fornecedores/avaliacoes/${e.id}`;
+ const dirty =
+ JSON.stringify(form.data.payload) !== JSON.stringify(e.payload);
+ const waiting = e.payload.criteria.filter((row) =>
+ ['pending', 'insufficient'].includes(row.result),
+ ).length;
+ const submit = (action: string) =>
+ command.post(`${base}/${action}`, {
+ preserveScroll: true,
+ preserveState: 'errors',
+ });
+ const errors = [
+ ...Object.values(form.errors),
+ ...Object.values(command.errors),
+ ];
+ return (
+ <>
+
+
+
+
+
+
+ Avaliações
+
+
+ {!reportOnly && (
+
+
+ Abrir relatório
+
+
+ )}
+ {reportOnly && (
+ <>
+
+
+ Voltar à avaliação
+
+
+ window.print()}>
+
+ Imprimir
+
+ >
+ )}
+
+ }
+ />
+ {notice && (
+
+ {notice}
+
+ )}
+
+
+ Autor: {people[e.created_by] ?? `#${e.created_by}`}
+
+ NUIT preservado: {e.supplier_snapshot.nuit}
+ {e.previous_id && (
+
+ Revisão anterior preservada
+
+ )}
+ {nextRevisionId && (
+
+ Abrir revisão posterior
+
+ )}
+
+ {e.status !== 'approved' && (
+
+ {evaluationStates[e.status]} — este documento ainda não
+ contém uma aprovação independente.
+
+ )}
+
+ Registo qualitativo da prestação da entidade fornecedora.
+ Sem pontuação ou classificação automática; não altera por si
+ só a qualificação, suspensão ou elegibilidade do fornecedor.
+
+ {canEdit ? (
+
+ {
+ event.preventDefault();
+ form.put(base, {
+ preserveScroll: true,
+ preserveState: 'errors',
+ });
+ }}
+ >
+
+ form.setData('payload', payload)
+ }
+ />
+ {errors.map((error, index) => (
+
+ {error}
+
+ ))}
+
+
+ Guardar rascunho
+
+ submit('submit')}
+ >
+ Submeter para revisão
+
+
+ {dirty && (
+
+ Guarde as alterações antes de submeter. A
+ revisão compara os dados efectivamente
+ guardados.
+
+ )}
+
+
+ ) : (
+ <>
+
+
+
+ Componente:{' '}
+ {e.payload.nature === 'goods'
+ ? 'Bens'
+ : 'Serviços'}{' '}
+ · Período:{' '}
+ {e.payload.period_start ?? 'Por indicar'} —{' '}
+ {e.payload.period_end ?? 'Por indicar'}.
+
+
+ {e.payload.scope ||
+ 'Âmbito ainda não indicado.'}
+
+
+ Ordens abrangidas:{' '}
+ {e.payload.order_ids.length
+ ? e.payload.order_ids.map((id) => (
+
+ {orders.find(
+ (order) =>
+ order.id === id,
+ )?.number ?? `#${id}`}
+
+ ))
+ : 'Sem ordens associadas; conferir as referências declaradas no âmbito.'}
+
+
+ {e.payload.criteria.length} critérios ·{' '}
+ {waiting} por analisar ou com evidência
+ insuficiente.
+
+ {e.payload.criteria.map((row, index) => (
+
+
+ {index + 1}.{' '}
+ {row.label ||
+ 'Critério por identificar'}
+
+ {criterionStates[row.result]}
+
+ Justificação:{' '}
+ {row.reason || 'Por completar'}
+
+
+ Evidência declarada:{' '}
+ {row.evidence || 'Não indicada'}
+
+
+ ))}
+
Conclusão
+
+ {e.payload.conclusion ||
+ 'Ainda não registada.'}
+
+
+ Ocorrências críticas
+
+
+ {e.payload.critical_occurrences ||
+ 'Sem informação registada neste campo; não equivale a uma declaração de ausência de ocorrências.'}
+
+ {e.payload.critical_evidence && (
+
+ Evidência: {e.payload.critical_evidence}
+
+ )}
+
+
+ >
+ )}
+ {e.review_reason && (
+
+
+ {e.review_reason}
+
+
+ {e.reviewed_at}
+
+
+ )}
+ {canReview && (
+
+ event.preventDefault()}
+ >
+
+ Confirme âmbito, atribuição das ocorrências,
+ aplicabilidade e referências. Evidências
+ pendentes não são convertidas em notas nem
+ ignoradas.
+
+
+ Fundamento da decisão
+
+ command.setData(
+ 'reason',
+ event.target.value,
+ )
+ }
+ />
+
+ {errors.map((error, index) => (
+
+ {error}
+
+ ))}
+
+ 0}
+ onClick={() => submit('approve')}
+ >
+ Aprovar avaliação
+
+ submit('return')}
+ >
+ Devolver para correcção
+
+
+ {waiting > 0 && (
+
+ Existem {waiting} critérios pendentes.
+ Devolva para completar as evidências antes
+ de aprovar.
+
+ )}
+
+
+ )}
+ {e.status === 'review' && !canReview && !reportOnly && (
+
+ Aguarda conferência por outra pessoa autorizada que não
+ tenha elaborado ou submetido esta revisão.
+
+ )}
+ {canRevise && (
+
+
+ A rectificação cria outro rascunho, com número de
+ revisão próprio. A versão aprovada anterior mantém o
+ seu relatório e histórico.
+
+ submit('rever')}
+ >
+ Preparar nova revisão
+
+ {errors.map((error, index) => (
+
+ {error}
+
+ ))}
+
+ )}
+ {!reportOnly && (
+
+
+ {history.total} registos preservados. Cada alteração
+ conserva a sua fotografia; versões aprovadas não são
+ reescritas.
+
+
+ {history.data.map((event) => (
+
+
+ {actions[
+ event.action.split('.').at(-1) ?? ''
+ ] ?? event.action}{' '}
+ · Versão {event.after.version} ·{' '}
+ {people[event.user_id] ??
+ `#${event.user_id}`}{' '}
+ ·{' '}
+ {new Date(
+ event.created_at,
+ ).toLocaleString('pt-MZ')}
+
+
+
+ {
+ evaluationStates[
+ event.after.status
+ ]
+ }{' '}
+ · {event.after.payload.title}
+
+
+ {event.after.payload.scope}
+
+ {event.after.payload.criteria.map(
+ (row, index) => (
+
+ {row.label} ·{' '}
+ {
+ criterionStates[
+ row.result
+ ]
+ }{' '}
+ · {row.reason} · Evidência:{' '}
+ {row.evidence ||
+ 'Não indicada'}
+
+ ),
+ )}
+
+ {event.after.payload.conclusion}
+
+ {event.after.review_reason && (
+
+ Decisão:{' '}
+ {event.after.review_reason}
+
+ )}
+
+
+ ))}
+
+
+ {history.prev_page_url && (
+
+ Anterior
+
+ )}
+ {history.next_page_url && (
+
+ Seguinte
+
+ )}
+
+
+ )}
+
+
+
+ SGC · AVF-{e.id} · Revisão {e.revision} ·{' '}
+ {evaluationStates[e.status]} · Consulta de{' '}
+ {new Date(generatedAt).toLocaleString('pt-MZ')}. As
+ referências são declarações documentadas, não verificação
+ automática do conteúdo.
+
+
+ >
+ );
+}
diff --git a/resources/js/pages/suppliers/evaluations.tsx b/resources/js/pages/suppliers/evaluations.tsx
index fdc3ff2..dd2b36d 100644
--- a/resources/js/pages/suppliers/evaluations.tsx
+++ b/resources/js/pages/suppliers/evaluations.tsx
@@ -1,39 +1,349 @@
-import { Head } from '@inertiajs/react';
-import { useState } from 'react';
-import { PageTitle, Panel } from '@/components/procurement';
+import { Head, Link, useForm } from '@inertiajs/react';
+import { PageTitle, Panel, controlClass } from '@/components/procurement';
+import { Button } from '@/components/ui/button';
+import SupplierEvaluationFields, {
+ emptyEvaluation,
+ evaluationStates,
+} from '@/components/supplier-evaluation-fields';
+import type { EvaluationOrder } from '@/components/supplier-evaluation-fields';
import proposal from './evaluation-proposal';
-const areas = [
- ['Avaliações planeadas', 'Definir fornecedor, período, âmbito e responsáveis pela avaliação.'],
- ['Nova avaliação', 'Identificar contratos e ordens abrangidas, escolher a grelha de bens ou serviços e reunir notas, justificações e evidências.'],
- ['Rascunhos', 'Preparar a avaliação e completar evidências antes de submeter para revisão.'],
- ['Avaliações em revisão', 'Conferência independente, com devolução para correcção ou aprovação documentada.'],
- ['Avaliações aprovadas', 'Consultar versões aprovadas; rectificações devem gerar revisão identificável.'],
- ['Histórico por fornecedor', 'Consultar a evolução, observações e contestações com decisão documentada. Comparar apenas âmbitos e grelhas equivalentes.'],
- ['Planos de melhoria', 'Acompanhar acções, responsáveis, prazos, evidências de conclusão e validação de eficácia.'],
- ['Relatórios de avaliação', 'Ficha por fornecedor e período, resultados por critério, ocorrências críticas e cobertura de evidências.'],
-];
-export default function SupplierEvaluations() {
- const [selected, setSelected] = useState(0);
- return <>
-
-
-
-
- Proposta funcional para validação da organização. As áreas abaixo descrevem o circuito previsto; o registo e a aprovação de avaliações ainda não estão disponíveis. Pesos, escala e limiares são ilustrativos.
-
-
- {areas.map(([label], index) => setSelected(index)} className={`rounded-lg border px-4 py-2 text-sm ${selected === index ? 'bg-primary text-primary-foreground' : 'bg-card hover:bg-muted'}`}>{label} )}
-
-
- Área prevista na proposta. Não são apresentados resultados de fornecedores reais.
-
-
- Consultar campos, grelhas, evidências e regras propostas
- {proposal}
+type Row = {
+ id: number;
+ revision: number;
+ version: number;
+ status: string;
+ supplier: string;
+ title: string;
+ period_start: string | null;
+ period_end: string | null;
+};
+export default function SupplierEvaluations({
+ ready,
+ records,
+ filters,
+ suppliers,
+ orders,
+ notice,
+}: {
+ ready: boolean;
+ records: {
+ data: Row[];
+ total: number;
+ current_page: number;
+ last_page: number;
+ prev_page_url: string | null;
+ next_page_url: string | null;
+ } | null;
+ filters: { supplier_id?: number; status?: string };
+ suppliers: { id: number; name: string; nuit: string }[];
+ orders: EvaluationOrder[];
+ notice?: string;
+}) {
+ const form = useForm({
+ supplier_id: '',
+ operation_key: crypto.randomUUID(),
+ payload: emptyEvaluation(),
+ });
+ const filter = useForm({
+ supplier_id: String(filters.supplier_id ?? ''),
+ status: filters.status ?? '',
+ });
+ const supplierOrders = orders.filter(
+ (order) => order.supplier_id === Number(form.data.supplier_id),
+ );
+ return (
+ <>
+
+
+
+
+ Acompanhar medidas de melhoria
+
+
+ }
+ />
+ {notice && (
+
+ {notice}
+
+ )}
+ {!ready && (
+
+ O módulo está a ser instalado. O cadastro de
+ fornecedores e os processos existentes permanecem
+ disponíveis.
+
+ )}
+
+ {
+ event.preventDefault();
+ filter.get('/fornecedores/avaliacoes', {
+ preserveState: true,
+ });
+ }}
+ >
+
+ Fornecedor
+
+ filter.setData(
+ 'supplier_id',
+ event.target.value,
+ )
+ }
+ >
+ Todos os autorizados
+ {suppliers.map((supplier) => (
+
+ {supplier.name}
+
+ ))}
+
+
+
+ Estado
+
+ filter.setData('status', event.target.value)
+ }
+ >
+ Todos
+ {Object.entries(evaluationStates).map(
+ ([value, label]) => (
+
+ {label}
+
+ ),
+ )}
+
+
+
+ Aplicar filtros
+
+
+ Limpar
+
+
+ {records && (
+ <>
+
+ {records.total} avaliações/revisões no âmbito
+ autorizado.
+
+
+
+
+
+ Avaliação
+
+ Fornecedor / período
+
+ Estado
+ Consultar
+
+
+
+ {records.data.map((row) => (
+
+
+ AVF-{row.id} · Revisão{' '}
+ {row.revision}
+
+ {row.title}
+
+
+
+ {row.supplier}
+
+ {row.period_start ??
+ 'Início por indicar'}{' '}
+ —{' '}
+ {row.period_end ??
+ 'Fim por indicar'}
+
+
+
+ {evaluationStates[
+ row.status
+ ] ?? row.status}
+
+
+
+ Abrir avaliação
+
+
+ Relatório
+
+
+
+ ))}
+
+
+
+ {records.data.length === 0 && (
+
+ Sem avaliações nos filtros e no âmbito
+ autorizado.
+
+ )}
+
+
+ Página {records.current_page} de{' '}
+ {records.last_page}
+
+
+ {records.prev_page_url && (
+
+ Anterior
+
+ )}
+ {records.next_page_url && (
+
+ Seguinte
+
+ )}
+
+
+ >
+ )}
+
+
+ {
+ event.preventDefault();
+ form.post('/fornecedores/avaliacoes', {
+ preserveState: 'errors',
+ preserveScroll: true,
+ });
+ }}
+ >
+
+ Fornecedor
+
+ form.setData({
+ ...form.data,
+ supplier_id: event.target.value,
+ payload: {
+ ...form.data.payload,
+ order_ids: [],
+ },
+ })
+ }
+ >
+ Escolher fornecedor
+ {suppliers.map((supplier) => (
+
+ {supplier.name} · {supplier.nuit}
+
+ ))}
+
+
+
+ form.setData('payload', payload)
+ }
+ />
+ {Object.entries(form.errors).map(([key, error]) => (
+
+ {error}
+
+ ))}
+
+ Guardar rascunho
+
+
+
+
+
+ Proposta de grelhas quantitativas e evoluções previstas
+
+
+ O ciclo disponível é qualitativo. Pesos, limiares,
+ pontuações, planos de melhoria acompanhados e
+ contestação mantêm-se distintos desta entrega; os
+ valores ilustrativos abaixo não foram activados como
+ política.
+
+
+ {proposal}
+
-
-
- >;
+
+ >
+ );
}
-SupplierEvaluations.layout = { breadcrumbs: [{ title: 'Fornecedores', href: '/fornecedores' }, { title: 'Avaliação de fornecedores', href: '/fornecedores/avaliacoes' }] };
+SupplierEvaluations.layout = {
+ breadcrumbs: [
+ { title: 'Fornecedores', href: '/fornecedores' },
+ {
+ title: 'Avaliação de fornecedores',
+ href: '/fornecedores/avaliacoes',
+ },
+ ],
+};
diff --git a/resources/js/types/auth.ts b/resources/js/types/auth.ts
index bf6f2e7..35e16ad 100644
--- a/resources/js/types/auth.ts
+++ b/resources/js/types/auth.ts
@@ -8,7 +8,8 @@ export type User = {
| 'procurement'
| 'finance'
| 'warehouse'
- | 'admin';
+ | 'admin'
+ | 'configured';
avatar?: string;
email_verified_at: string | null;
created_at: string;
diff --git a/resources/js/types/procurement.ts b/resources/js/types/procurement.ts
index 86ed19c..1f29e03 100644
--- a/resources/js/types/procurement.ts
+++ b/resources/js/types/procurement.ts
@@ -68,6 +68,7 @@ export const roleLabels: Record = {
finance: 'Finanças',
warehouse: 'Recepção / Armazém',
admin: 'Administrador',
+ configured: 'Perfil definido pela empresa',
};
export const statusLabels: Record = {
draft: 'Rascunho',
diff --git a/routes/evaluation.php b/routes/evaluation.php
index d231712..db6b95c 100644
--- a/routes/evaluation.php
+++ b/routes/evaluation.php
@@ -2,22 +2,24 @@
use App\Enums\PurchasingRole;
use App\Models\User;
+use App\Services\EvaluationNavigation;
+use App\Services\RequisitionWorkflow;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;
+use Illuminate\Validation\Rule;
// Only the existing isolated local Compras evaluation database may use this entry.
$evaluationScope = static function (Request $request): User {
- abort_unless(app()->environment('payable-testing')
- && in_array($request->ip(), ['127.0.0.1', '::1'], true)
- && in_array($request->getHost(), ['127.0.0.1', 'localhost', '::1'], true), 404);
+ abort_unless(EvaluationNavigation::local($request), 404);
$db = DB::connection();
abort_unless($db->getDriverName() === 'pgsql' && $db->getConfig('host') === '127.0.0.1'
&& preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', $db->getDatabaseName()), 404);
$identity = $db->selectOne('select current_database() as db, current_user as role');
abort_unless($identity->db === $db->getDatabaseName()
&& $identity->role === 'payable_qa_'.substr($identity->db, -12), 404);
+
return User::where('email', 'preparacao-obrigacoes@example.test')->firstOrFail();
};
@@ -31,6 +33,9 @@ $evaluationPages = [
'/pagamentos' => 'Pagamentos externos', '/obrigacoes-fornecedores' => 'Obrigações a fornecedores',
'/conflitos' => 'Conflitos de interesses', '/revisao-compras' => 'Revisão e cancelamento',
'/politica-admissao' => 'Política de admissão', '/politica-recomendacao' => 'Política de recomendação',
+ '/relatorios' => 'Relatórios de compras',
+ '/relatorios/medidas-fornecedores' => 'Medidas de melhoria de fornecedores',
+ '/administracao/parametrizacao' => 'Parametrização da aplicação',
];
Route::get('/avaliar', function (Request $request) use ($evaluationScope, $evaluationPages) {
@@ -46,15 +51,19 @@ Route::get('/avaliar', function (Request $request) use ($evaluationScope, $evalu
}
$html .= 'Experimentar funções operacionais Para preparar ou decidir passos específicos, podes entrar nestes perfis sintéticos sem pedir contas ou credenciais.
';
foreach (PurchasingRole::cases() as $role) {
- if ($role === PurchasingRole::Admin) continue;
+ if ($role === PurchasingRole::Admin) {
+ continue;
+ }
$html .= ''.$role->label().' ';
}
+
return response($html.'Ambiente sintético partilhado. Sem transferências bancárias. Não altera a instalação habitual na porta 8015. Aprovações próprias e restantes regras de integridade não são dispensadas.
')->header('Cache-Control', 'no-store')->header('X-Robots-Tag', 'noindex, nofollow');
});
Route::post('/avaliar/entrar', function (Request $request) use ($evaluationScope) {
$source = $evaluationScope($request);
- $data = $request->validate(['role' => ['required', \Illuminate\Validation\Rule::enum(PurchasingRole::class)]]);
+ $destination = EvaluationNavigation::returnPath($request->session()->get('url.intended'), $request);
+ $data = $request->validate(['role' => ['required', Rule::enum(PurchasingRole::class)]]);
abort_if(array_diff(array_keys($request->all()), ['role', '_token']) !== [], 422);
$role = PurchasingRole::from($data['role']);
$user = DB::transaction(function () use ($source, $role) {
@@ -68,6 +77,7 @@ Route::post('/avaliar/entrar', function (Request $request) use ($evaluationScope
foreach ($role === PurchasingRole::Admin ? ['view', 'prepare', 'validate'] : ['view'] as $action) {
DB::table('pa_access')->insertOrIgnore(['user_id' => $user->id, 'tenant_id' => $source->tenant_id, 'company_id' => 0, 'unit_id' => 0, 'person' => 'evaluation-person-'.$user->id, 'action' => $action]);
}
+
return $user;
});
Auth::logout();
@@ -75,6 +85,7 @@ Route::post('/avaliar/entrar', function (Request $request) use ($evaluationScope
$request->session()->regenerateToken();
Auth::login($user, false);
$request->session()->regenerate();
- app(\App\Services\RequisitionWorkflow::class)->audit($user, 'evaluation.entered', null, null, ['role' => $role->value, 'synthetic' => true]);
- return redirect('/dashboard');
+ app(RequisitionWorkflow::class)->audit($user, 'evaluation.entered', null, null, ['role' => $role->value, 'synthetic' => true]);
+
+ return redirect($destination);
})->middleware('throttle:30,1');
diff --git a/routes/web.php b/routes/web.php
index 3be610b..ac78a74 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -12,6 +12,8 @@ use App\Http\Controllers\FormalRecommendationController;
use App\Http\Controllers\GoodsReceiptController;
use App\Http\Controllers\InvestmentPolicyController;
use App\Http\Controllers\InvoiceController;
+use App\Http\Controllers\OrganizationConfigurationController;
+use App\Http\Controllers\ProcurementReportController;
use App\Http\Controllers\ProcurementReviewReportController;
use App\Http\Controllers\ProposalOpeningController;
use App\Http\Controllers\PurchaseControlController;
@@ -25,7 +27,14 @@ use App\Http\Controllers\StockOperationController;
use App\Http\Controllers\SupplierBankController;
use App\Http\Controllers\SupplierController;
use App\Http\Controllers\SupplierDocumentController;
+use App\Http\Controllers\SupplierEvaluationController;
+use App\Http\Controllers\SupplierImprovementController;
+use App\Http\Controllers\SupplierImprovementReportController;
+use App\Http\Controllers\SupplierPayablesController;
+use App\Http\Controllers\SupplierPaymentsController;
+use App\Http\Controllers\SupplierResponseController;
use App\Http\Middleware\EnsureCompanyIsConfigured;
+use App\Http\Middleware\EnsureOrganizationAccess;
use Illuminate\Support\Facades\Route;
if (app()->environment('payable-testing')) {
@@ -35,23 +44,33 @@ if (app()->environment('payable-testing')) {
Route::inertia('/', 'home', ['auth' => ['user' => null]])->name('home');
require __DIR__.'/preview.php';
-Route::middleware(['auth', 'verified'])->group(function () {
+Route::middleware(['auth', 'verified', EnsureOrganizationAccess::class])->group(function () {
Route::get('dashboard', [RequisitionController::class, 'dashboard'])->name('dashboard');
Route::get('empresa/iniciar', [CompanyController::class, 'setup'])->name('company.setup');
Route::post('empresa/iniciar', [CompanyController::class, 'store'])->name('company.store');
Route::middleware(EnsureCompanyIsConfigured::class)->group(function () {
- Route::get('fornecedores/avaliacoes', function (\Illuminate\Http\Request $request) {
- abort_unless(in_array($request->user()->purchasing_role, [\App\Enums\PurchasingRole::Admin, \App\Enums\PurchasingRole::Procurement, \App\Enums\PurchasingRole::Finance, \App\Enums\PurchasingRole::DepartmentHead]), 403);
-
- return \Inertia\Inertia::render('suppliers/evaluations');
- })->name('suppliers.evaluations');
- Route::get('obrigacoes-fornecedores', [\App\Http\Controllers\SupplierPayablesController::class,'page']);
- Route::get('supplier-payments', [\App\Http\Controllers\SupplierPaymentsController::class,'page']);
- Route::get('api/supplier-payments', [\App\Http\Controllers\SupplierPaymentsController::class,'index']);
- Route::post('api/supplier-payments/{action}', [\App\Http\Controllers\SupplierPaymentsController::class,'command'])->whereIn('action',['payment-prepare','payment-decide','allocation-prepare','allocation-decide']);
- Route::get('api/supplier-payables', [\App\Http\Controllers\SupplierPayablesController::class,'index']);
- Route::post('api/supplier-payables/{action}', [\App\Http\Controllers\SupplierPayablesController::class,'command'])->whereIn('action',['prepare','validate','prepare_history','validate_history']);
+ Route::get('administracao/parametrizacao', [OrganizationConfigurationController::class, 'index'])->name('organization.parameters');
+ Route::post('administracao/parametrizacao/{command}', [OrganizationConfigurationController::class, 'command'])->whereIn('command', ['profile', 'user', 'assign', 'activate', 'rules']);
+ Route::get('fornecedores/avaliacoes', [SupplierEvaluationController::class, 'index'])->name('suppliers.evaluations');
+ Route::get('relatorios/medidas-fornecedores', [SupplierImprovementReportController::class, 'index'])->name('supplier-improvements.report');
+ Route::get('relatorios/medidas-fornecedores.csv', [SupplierImprovementReportController::class, 'csv']);
+ Route::post('fornecedores/avaliacoes', [SupplierEvaluationController::class, 'store'])->name('supplier-evaluations.store');
+ Route::get('fornecedores/avaliacoes/{evaluation}', [SupplierEvaluationController::class, 'show'])->whereNumber('evaluation')->name('supplier-evaluations.show');
+ Route::put('fornecedores/avaliacoes/{evaluation}', [SupplierEvaluationController::class, 'update'])->whereNumber('evaluation');
+ Route::post('fornecedores/avaliacoes/{evaluation}/rever', [SupplierEvaluationController::class, 'revise'])->whereNumber('evaluation');
+ Route::post('fornecedores/avaliacoes/{evaluation}/{action}', [SupplierEvaluationController::class, 'transition'])->whereNumber('evaluation')->whereIn('action', ['submit', 'approve', 'return']);
+ Route::get('fornecedores/avaliacoes/{evaluation}/relatorio', [SupplierEvaluationController::class, 'report'])->whereNumber('evaluation')->name('supplier-evaluations.report');
+ Route::post('fornecedores/avaliacoes/{evaluation}/contraditorios', [SupplierResponseController::class, 'store'])->whereNumber('evaluation');
+ Route::post('fornecedores/avaliacoes/{evaluation}/contraditorios/{record}/{command}', [SupplierResponseController::class, 'update'])->whereNumber('evaluation')->whereNumber('record')->whereIn('command', ['save', 'submit', 'return', 'decide']);
+ Route::post('fornecedores/avaliacoes/{evaluation}/medidas', [SupplierImprovementController::class, 'store'])->whereNumber('evaluation');
+ Route::post('fornecedores/avaliacoes/{evaluation}/medidas/{measure}/{command}', [SupplierImprovementController::class, 'update'])->whereNumber('evaluation')->whereNumber('measure')->whereIn('command', ['plan', 'progress', 'submit', 'close', 'return']);
+ Route::get('obrigacoes-fornecedores', [SupplierPayablesController::class, 'page']);
+ Route::get('supplier-payments', [SupplierPaymentsController::class, 'page']);
+ Route::get('api/supplier-payments', [SupplierPaymentsController::class, 'index']);
+ Route::post('api/supplier-payments/{action}', [SupplierPaymentsController::class, 'command'])->whereIn('action', ['payment-prepare', 'payment-decide', 'allocation-prepare', 'allocation-decide']);
+ Route::get('api/supplier-payables', [SupplierPayablesController::class, 'index']);
+ Route::post('api/supplier-payables/{action}', [SupplierPayablesController::class, 'command'])->whereIn('action', ['prepare', 'validate', 'prepare_history', 'validate_history']);
Route::get('politica-recomendacao', [FormalRecommendationController::class, 'policy'])->name('recommendations.policy');
Route::post('politica-recomendacao', [FormalRecommendationController::class, 'configure']);
Route::get('requisicoes/{requisition}/recomendacao', [FormalRecommendationController::class, 'show'])->name('recommendations.show');
@@ -66,6 +85,8 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::post('requisicoes/{requisition}/admissao/decisoes', [FormalAdmissionController::class, 'propose']);
Route::post('admissoes/{decision}/conferir', [FormalAdmissionController::class, 'review']);
Route::get('requisicoes/{requisition}/relatorio', [ProcurementReviewReportController::class, 'show'])->name('purchases.report');
+ Route::get('relatorios', [ProcurementReportController::class, 'index'])->name('reports.index');
+ Route::get('relatorios/compras.csv', [ProcurementReportController::class, 'export'])->name('reports.export');
Route::get('requisicoes/{requisition}/qualificacao', [DocumentaryQualificationController::class, 'show'])->name('qualifications.show');
Route::post('requisicoes/{requisition}/qualificacao', [DocumentaryQualificationController::class, 'define']);
Route::post('qualificacoes/{set}/analises', [DocumentaryQualificationController::class, 'assess']);
diff --git a/scripts/check-runtime-packages.php b/scripts/check-runtime-packages.php
new file mode 100644
index 0000000..e7639a3
--- /dev/null
+++ b/scripts/check-runtime-packages.php
@@ -0,0 +1,25 @@
+ $expected) {
+ if (! preg_match('~^(budget-control|payables)/(bootstrap\.php|README\.md|(src|ui)/[A-Za-z]+\.php)$~D', $path)
+ || ! is_file($root.$path)
+ || hash_file('sha256', $root.$path) !== $expected) {
+ $errors[] = $path;
+ }
+}
+if ($errors) {
+ fwrite(STDERR, 'Pacotes de execução divergentes: '.implode(', ', $errors)."\n");
+ exit(1);
+}
+fwrite(STDOUT, count($manifest['files'])." ficheiros de execução conferidos por SHA-256. Sem dependência da pasta SGF.\n");
diff --git a/scripts/evaluation-access-check.mjs b/scripts/evaluation-access-check.mjs
index 607392a..1700845 100644
--- a/scripts/evaluation-access-check.mjs
+++ b/scripts/evaluation-access-check.mjs
@@ -3,9 +3,9 @@ import fs from 'node:fs/promises';
const base = 'http://127.0.0.1:8005';
const cookies = new Map();
-async function request(path, method = 'GET', body) {
+async function request(path, method = 'GET', body, accept = 'application/json') {
const response = await fetch(base + path, {method, redirect: 'manual', headers: {
- Accept: 'application/json', Cookie: [...cookies].map(([k,v]) => `${k}=${v}`).join('; '),
+ Accept: accept, Cookie: [...cookies].map(([k,v]) => `${k}=${v}`).join('; '),
...(body ? {'Content-Type': 'application/x-www-form-urlencoded'} : {}),
}, ...(body ? {body: new URLSearchParams(body)} : {})});
for (const cookie of response.headers.getSetCookie()) {
@@ -15,22 +15,28 @@ async function request(path, method = 'GET', body) {
return {status: response.status, text: await response.text(), location: response.headers.get('location')};
}
const token = text => text.match(/name="_token" value="([^"]+)"/)[1];
-async function enter(role) {
+async function enter(role, destination = '/dashboard') {
const page = await request('/avaliar');
assert.equal(page.status, 200);
const entered = await request('/avaliar/entrar', 'POST', {role, _token: token(page.text)});
assert.equal(entered.status, 302, role);
- assert.equal(new URL(entered.location).pathname, '/dashboard');
+ assert.equal(new URL(entered.location).pathname, destination);
assert.equal((await request('/dashboard')).status, 200);
+ return entered;
}
let page = await request('/avaliar');
+const guestReport = await request('/relatorios?status=approved', 'GET', undefined, 'text/html');
+assert.equal(guestReport.status, 302);
+assert.equal(new URL(guestReport.location).pathname, '/avaliar');
+assert.equal(new URL((await request('/login', 'GET', undefined, 'text/html')).location).pathname, '/avaliar');
assert.equal((await request('/avaliar/entrar','POST',{role:'admin'})).status,419);
assert.equal((await request('/avaliar/entrar','POST',{role:'root',_token:token(page.text)})).status,422);
assert.equal((await request('/avaliar/entrar','POST',{role:'admin',user_id:'1',_token:token(page.text)})).status,422);
-await enter('admin');
+const reportEntry = await enter('admin', '/relatorios');
+assert.equal(new URL(reportEntry.location).searchParams.get('status'), 'approved');
const modules = ['/dashboard','/empresa','/requisicoes','/planeamento','/fornecedores','/cotacoes','/ordens',
'/recepcoes','/stock','/stock/operacoes','/equipamentos','/orcamentos','/regras-investimento','/facturas',
- '/pagamentos','/obrigacoes-fornecedores','/conflitos','/revisao-compras','/politica-admissao','/politica-recomendacao'];
+ '/pagamentos','/obrigacoes-fornecedores','/conflitos','/revisao-compras','/politica-admissao','/politica-recomendacao','/relatorios','/relatorios/medidas-fornecedores','/administracao/parametrizacao'];
for (const path of modules) assert.equal((await request(path)).status,200,path);
const roles = ['requester','department_head','procurement','finance','warehouse','admin'];
for (const role of roles) await enter(role);
@@ -44,5 +50,5 @@ await fs.mkdir('docs/proofs',{recursive:true});
await fs.writeFile('docs/proofs/evaluation-access.json',JSON.stringify({checked_at:new Date().toISOString(),base,
owner_modules:modules,roles,csrf_enforced:true,arbitrary_role_refused:true,arbitrary_user_refused:true,logout:true,
business_commands:0,real_accounts_changed:false,operational_entry_status:operational?.status??'not running',
- visual_review:false},null,2)+'\n');
-console.log('Owner entry, 20 module pages, six roles, CSRF, rejected identifiers and logout passed.');
+ visual_review:false,guest_report_redirect:true,intended_report_and_filters_preserved:true},null,2)+'\n');
+console.log('Owner entry, 23 module pages, six roles, report return, CSRF, rejected identifiers and logout passed.');
diff --git a/scripts/menu-code-check.mjs b/scripts/menu-code-check.mjs
new file mode 100644
index 0000000..897eb58
--- /dev/null
+++ b/scripts/menu-code-check.mjs
@@ -0,0 +1,111 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import { spawnSync } from 'node:child_process';
+import {
+ procurementNavigation,
+ activeProcurementEntry,
+} from '../resources/js/lib/procurement-navigation.ts';
+
+const result = spawnSync(
+ 'php',
+ ['artisan', 'route:list', '--json', '--except-vendor'],
+ { encoding: 'utf8' },
+);
+assert.equal(result.status, 0, result.stderr);
+const routes = JSON.parse(result.stdout);
+const rows = new Map();
+for (const role of [
+ 'admin',
+ 'procurement',
+ 'finance',
+ 'department_head',
+ 'warehouse',
+ 'requester',
+]) {
+ for (const payables of [false, true]) {
+ const groups = procurementNavigation(role, payables);
+ for (const group of groups)
+ for (const entry of group.entries) {
+ const uri = new URL(entry.path, 'http://local').pathname.slice(
+ 1,
+ );
+ const route = routes.find(
+ (route) =>
+ route.uri === uri &&
+ route.method.split('|').includes('GET'),
+ );
+ assert.ok(
+ route,
+ `${role}: ${entry.label} has no registered GET route (${uri})`,
+ );
+ assert.equal(
+ activeProcurementEntry(groups, entry.path),
+ entry.id,
+ `${role}: wrong active menu for ${entry.path}`,
+ );
+ rows.set(`${role}:${entry.id}`, {
+ role,
+ group: group.label,
+ id: entry.id,
+ label: entry.label,
+ path: entry.path,
+ action: route.action,
+ full_page: !!entry.fullPage,
+ });
+ }
+ assert.equal(
+ groups.some((group) =>
+ group.entries.some(
+ (entry) => entry.id === 'procurement_reports',
+ ),
+ ),
+ ['admin', 'procurement', 'finance', 'department_head'].includes(
+ role,
+ ),
+ );
+ }
+}
+const configuredFinance = procurementNavigation(['finance'], false).flatMap(
+ (g) => g.entries,
+);
+assert.equal(
+ configuredFinance.some((e) => e.id === 'organization_parameters'),
+ false,
+);
+assert.equal(
+ configuredFinance.some((e) => e.id === 'new_request'),
+ false,
+);
+const configuredMulti = procurementNavigation(
+ ['finance', 'requester', 'admin'],
+ false,
+).flatMap((g) => g.entries);
+assert.equal(
+ configuredMulti.some((e) => e.id === 'organization_parameters'),
+ true,
+);
+assert.equal(
+ configuredMulti.some((e) => e.id === 'new_request'),
+ true,
+);
+const proof = {
+ checked_at: new Date().toISOString(),
+ result: 'passed',
+ entries: rows.size,
+ roles: 6,
+ configurable_permissions_and_multiple_profiles: true,
+ source: 'procurementNavigation compared with actual Laravel GET routes',
+ scope: 'Menu routing, active entry and visibility contract; not a substitute for functional or visual workflow acceptance',
+ rows: [...rows.values()],
+};
+fs.writeFileSync(
+ 'docs/proofs/menu-code-contract.json',
+ JSON.stringify(proof, null, 2) + '\n',
+);
+console.log(
+ JSON.stringify({
+ result: proof.result,
+ entries: proof.entries,
+ roles: proof.roles,
+ }),
+);
diff --git a/scripts/organization-access-check.mjs b/scripts/organization-access-check.mjs
new file mode 100644
index 0000000..c2a81ac
--- /dev/null
+++ b/scripts/organization-access-check.mjs
@@ -0,0 +1,130 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs/promises';
+
+const base = 'http://127.0.0.1:8005';
+function client() {
+ const cookies = new Map();
+ let csrf;
+ let version;
+ async function request(path, method = 'GET', data, inertia = true) {
+ const response = await fetch(base + path, {
+ method,
+ redirect: 'manual',
+ headers: {
+ Accept: inertia ? 'application/json' : 'text/html',
+ Cookie: [...cookies].map(([k, v]) => `${k}=${v}`).join('; '),
+ ...(inertia
+ ? {
+ 'X-Inertia': 'true',
+ 'X-Inertia-Version': version ?? '',
+ }
+ : {}),
+ ...(data
+ ? {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-TOKEN': csrf,
+ }
+ : {}),
+ },
+ ...(data ? { body: JSON.stringify(data) } : {}),
+ });
+ for (const cookie of response.headers.getSetCookie()) {
+ const pair = cookie.split(';', 1)[0],
+ i = pair.indexOf('=');
+ cookies.set(pair.slice(0, i), pair.slice(i + 1));
+ }
+ const text = await response.text();
+ return {
+ status: response.status,
+ location: response.headers.get('location'),
+ text,
+ page: response.headers
+ .get('content-type')
+ ?.includes('application/json')
+ ? JSON.parse(text)
+ : null,
+ };
+ }
+ return {
+ request,
+ async enter(role) {
+ let page = await request('/avaliar', 'GET', undefined, false);
+ assert.equal(page.status, 200);
+ csrf = page.text.match(/name="_token" value="([^"]+)"/)[1];
+ assert.equal(
+ (await request('/avaliar/entrar', 'POST', { role }, false))
+ .status,
+ 302,
+ );
+ page = await request('/avaliar', 'GET', undefined, false);
+ csrf = page.text.match(/name="_token" value="([^"]+)"/)[1];
+ const dashboard = await request(
+ '/dashboard',
+ 'GET',
+ undefined,
+ false,
+ );
+ const encoded = dashboard.text.match(
+ /