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

←

·

Obrigações a fornecedores

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

+

Facturas de origem

+ +
DocumentoFornecedorBaseImposto documentalTotalConferência / revisão
Origem e pendências
+

Preparar a obrigação

Sem edição de fornecedor, valor ou imposto. Vencimento mantém-se desconhecido neste recorte.

+($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.

+

Propostas e decisões

count()): ?>

Ainda não há propostas.

+

Proposta #id) ?> · captura capture_version) ?>

Preparador #prepared_by) ?> · created_at) ?>

reason) ?>

Fotografia e pendências
snapshot) ?>
missing) ?>
+snapshot,true)['_history_import']??null; if($history): ?>

Histórico proposto: confirmados · pendentes · rejeitados . Os pendentes e rejeitados não reduzem o saldo.

+
+

Obrigações reconhecidas

Nenhuma obrigação reconhecida. Proposta ou factura conferida não é decisão de reconhecimento.

+

Obrigação #

Principal reconhecido — vencimento pendente

Pagamentos históricos confirmados:

Pendentes: · Rejeitados: — sem abatimento.

Saldo por liquidar:

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 # ·

Origem, versão e decisão
+

Histórico durável

#id) ?> · kind) ?> · actor #actor_id) ?> · created_at) ?>
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 ''; +}; +?> +Pagamentos e afectações a fornecedores — ensaio
+
← Obrigações a fornecedores · Trocar função ou sair

·

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.

+

+

Obrigações e saldo canónico

Origem / fornecedorPrincipalAfectado neste percursoSaldoVencimento
# ·
Versão e decisão
Pendente
Atraso indeterminado
Dias de atraso: sem valor

Reconheça primeiro a obrigação no percurso existente. Não é criada uma dívida por registar um pagamento.

+

1. Propor pagamento sintético

+

2. Pagamentos propostos e decisões

Pagamento # ·

· Data simulada · Estado: 'Proposto','confirmed'=>'Confirmado em ensaio','rejected'=>'Rejeitado'][$p['state']]) ?>

Por afectar: · Reconciliação bancária: não implementada.

Preparação e evidência preservadas
+

Sem pagamentos propostos neste contexto.

+

3. Propor afectações

Mesmo fornecedor e moeda. O excedente fica por afectar. Não existe reembolso/crédito automático nem edição de uma afectação confirmada.

+

4. Propostas de afectação

Proposta # · · Pagamento #

Preparador # ·

  • Obrigação #: · versão

Estado: decision === 'confirmed' ? 'Afectação confirmada' : 'Rejeitada') : 'Proposta — sem efeito no saldo' ?>

+
+

Histórico durável

#id) ?> · kind) ?> · actor #actor_id) ?> · created_at) ?>
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()}
- + {page.props.canRequest === true && ( + + )} {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 ( +
+
+ + + + +
+