From 95dd2cc1ed2172a01342b81efc4a4d90b5b55f28 Mon Sep 17 00:00:00 2001 From: nala Date: Wed, 28 Oct 2026 16:57:45 +0200 Subject: [PATCH] Implementa regras de investimento por empresa com dupla aprovacao --- README.md | 11 +- app/Http/Controllers/BudgetController.php | 5 +- .../InvestmentPolicyController.php | 40 +++ app/Models/Budget.php | 1 + app/Models/InvestmentPolicy.php | 36 +++ app/Models/InvestmentPolicyReview.php | 16 + app/Models/PurchaseAward.php | 3 +- app/Services/BudgetWorkflow.php | 4 + app/Services/InvestmentWorkflow.php | 122 +++++++ app/Services/PurchaseWorkflow.php | 21 +- app/Services/ReceiptWorkflow.php | 2 +- ...0_28_000014_create_investment_policies.php | 47 +++ docs/compatibilidade.md | 13 +- docs/desenvolvimento.md | 87 +++++ .../investimento-concorrencia-postgresql.json | 30 ++ docs/investimento-migracao-000014.json | 16 + docs/orientacoes-continuidade.md | 12 + docs/proposta-regras-financeiras.md | 5 + resources/js/components/app-sidebar.tsx | 14 + resources/js/lib/preview-navigation.ts | 1 + resources/js/pages/budgets/index.tsx | 32 ++ resources/js/pages/equipment/index.tsx | 8 +- resources/js/pages/investment/index.tsx | 300 ++++++++++++++++++ resources/js/pages/preview/index.tsx | 38 ++- resources/js/pages/purchases/show.tsx | 23 ++ resources/js/pages/requisitions/form.tsx | 6 +- resources/js/pages/requisitions/show.tsx | 11 +- resources/js/types/purchase.ts | 14 + resources/views/purchases/print.blade.php | 5 + routes/preview.php | 3 +- routes/web.php | 4 + tests/Concurrency/investment-worker.php | 35 ++ tests/Concurrency/run-investment.php | 127 ++++++++ tests/Feature/InvestmentPolicyTest.php | 130 ++++++++ tests/Feature/PurchaseAwardOrderTest.php | 84 +++++ tests/Support/InvestmentFixture.php | 29 ++ tests/Support/apply-investment-migration.php | 111 +++++++ 37 files changed, 1418 insertions(+), 28 deletions(-) create mode 100644 app/Http/Controllers/InvestmentPolicyController.php create mode 100644 app/Models/InvestmentPolicy.php create mode 100644 app/Models/InvestmentPolicyReview.php create mode 100644 app/Services/InvestmentWorkflow.php create mode 100644 database/migrations/2026_10_28_000014_create_investment_policies.php create mode 100644 docs/investimento-concorrencia-postgresql.json create mode 100644 docs/investimento-migracao-000014.json create mode 100644 resources/js/pages/investment/index.tsx create mode 100644 tests/Concurrency/investment-worker.php create mode 100644 tests/Concurrency/run-investment.php create mode 100644 tests/Feature/InvestmentPolicyTest.php create mode 100644 tests/Support/InvestmentFixture.php create mode 100644 tests/Support/apply-investment-migration.php diff --git a/README.md b/README.md index 8782bc8..2720ad6 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,13 @@ Crie um pedido, guarde o rascunho, confira os valores e submeta. Entre com as co ## Entrega actual +- Regras de investimento próprias por empresa: tecto por processo, base de IVA + e vigência explícitos, com duas aprovações administrativas distintas do autor. + Equipamento exige também validação técnica, Finanças e reserva em dotação de + investimento. Regra congelada na adjudicação/ordem; mudança de base bloqueia + emissão com aprovações antigas. Migração 000014 aplicada após backup/restauro + verificados. Sem regras ou limites fictícios na base real. + - Reforços de orçamento por empresa/centro/exercício, com proposta, aprovação independente e histórico imutável. A dotação inicial e a base de IVA são preservadas; apenas reforços aprovados aumentam o saldo disponível. Migração @@ -55,7 +62,7 @@ Crie um pedido, guarde o rascunho, confira os valores e submeta. Entre com as co Estornos continuam pendentes; não constitui validação operacional integral do orçamento. Interface em `/orcamentos`. -- Equipamentos e peças ligados aos artigos existentes, compatibilidades, criticidade configurada, mínimos explícitos e alertas, saídas rastreáveis e validação técnica. Finanças é obrigatória para equipamento em qualquer valor; a adjudicação/emissão de investimento continua bloqueada até definição do limite próprio. Migração 000008 aplicada no lote 9 após backup recuperável e verificação da preservação. Demonstração em `/consulta/equipamentos`. +- Equipamentos e peças ligados aos artigos existentes, compatibilidades, criticidade configurada, mínimos explícitos e alertas, saídas rastreáveis e validação técnica. Finanças é obrigatória para equipamento em qualquer valor; a adjudicação/emissão exige regra própria vigente e reserva em dotação de investimento. Migração 000008 aplicada no lote 9 após backup recuperável e verificação da preservação. Demonstração em `/consulta/equipamentos`. - Página inicial em `/`, demonstração pública por módulo em `/consulta` e navegação autenticada agrupada por área e perfil. - Recepções parciais/totais com qualidade, divergências, quantidades pendentes e segregação do emitente. Entradas de stock apenas para quantidades físicas aceites, sem duplicação; catálogo, armazéns, existências e histórico por empresa. - Saídas com destino, responsável e fundamento; transferências atómicas; contagens físicas e ajustes justificados, com histórico e protecção contra saldos negativos e contagens desactualizadas. Migração 000007 aplicada na base actual, lote 8, após backup com recuperação ensaiada e confirmação da preservação dos registos; demonstração em `/consulta/operacoes-stock`. @@ -87,7 +94,7 @@ O IVA é informado pelo utilizador e calculado no servidor. A taxa inicial de 0% ## Próximas etapas -Revisão visual dos módulos entregues; reposição de stock; conciliação de pagamentos externos; regras de investimento, correcções de facturas e estornos; moedas e câmbio; subscrições, licenças e Docker. Equipamentos, peças, validação técnica e alertas de mínimo já estão implementados; a adjudicação de equipamento aguarda a definição da regra de investimento. Periodicidade dos inventários, tolerâncias e investigação de divergências exigem decisão da empresa. A política de periodicidade, validade e levantamento de conflitos e os procedimentos de revisão/cancelamento de adjudicações continuam por definir. +Revisão visual dos módulos entregues; reposição de stock; conciliação de pagamentos externos; revisão/cancelamento de processos, correcções de facturas e estornos; moedas e câmbio; subscrições, licenças e Docker. Equipamentos, peças, validação técnica e alertas de mínimo já estão implementados; cada empresa deve configurar e aprovar a sua regra e dotação de investimento antes de adjudicar equipamento. Periodicidade dos inventários, tolerâncias e investigação de divergências exigem decisão da empresa. A política de periodicidade, validade e levantamento de conflitos e os procedimentos de revisão/cancelamento de adjudicações continuam por definir. O cadastro guarda referências e ficheiros anexos no disco privado `supplier_documents`, em `storage/app/supplier-documents`, fora da pasta pública e sem URLs públicas. As fichas abrem pelo directório **Fornecedores → Ver ficha → Abrir ficha, edição e documentos**. As versões antigas e documentos existentes são preservados na auditoria e no armazenamento; nesta entrega não há eliminação de documentos, antivírus, quotas acumuladas ou política de retenção. A validação do NUIT é de formato e unicidade, sem consulta a uma autoridade fiscal. Além das declarações por operação, existem declarações pessoais por fornecedor e processo; a periodicidade anual e a política de validade/levantamento ainda não foram definidas. As propostas e avaliações registadas não são editáveis nesta etapa. Os pesos 40/20/15/15/10 são provisórios e ficam guardados por proposta. A comparação liga à fase de adjudicação e emissão. A ordem exige novas aprovações do valor final e revalidação no servidor. As propostas usam MZN; a multimoeda fica para a fase correspondente. diff --git a/app/Http/Controllers/BudgetController.php b/app/Http/Controllers/BudgetController.php index 7400735..81f8e08 100644 --- a/app/Http/Controllers/BudgetController.php +++ b/app/Http/Controllers/BudgetController.php @@ -45,10 +45,11 @@ class BudgetController extends Controller { $this->access($request); $data = $request->validate(['cost_center_id' => ['required', 'integer', Rule::exists('cost_centers', 'id')->where('tenant_id', $request->user()->tenant_id)], 'year' => 'required|integer|min:2026|max:2100', 'includes_tax' => 'required|boolean', 'amount' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'], 'reason' => 'required|string|max:4000']); - DB::transaction(function () use ($request, $data) { + $kind = $request->validate(['kind' => ['sometimes', Rule::in(['operating', 'investment'])]])['kind'] ?? 'operating'; + DB::transaction(function () use ($request, $data, $kind) { app(ConflictGuard::class)->lockCompany($request->user()); abort_if(Budget::query()->where('cost_center_id', $data['cost_center_id'])->where('year', $data['year'])->exists(), 409, 'Já existe dotação para este centro e exercício.'); - $budget = Budget::query()->create(['tenant_id' => $request->user()->tenant_id, 'created_by' => $request->user()->id, 'cost_center_id' => $data['cost_center_id'], 'year' => $data['year'], 'includes_tax' => $data['includes_tax'], 'amount_minor' => BigDecimal::of($data['amount'])->multipliedBy(100)->toInt(), 'reason' => $data['reason']]); + $budget = Budget::query()->create(['tenant_id' => $request->user()->tenant_id, 'created_by' => $request->user()->id, 'cost_center_id' => $data['cost_center_id'], 'year' => $data['year'], 'kind' => $kind, 'includes_tax' => $data['includes_tax'], 'amount_minor' => BigDecimal::of($data['amount'])->multipliedBy(100)->toInt(), 'reason' => $data['reason']]); app(RequisitionWorkflow::class)->audit($request->user(), 'budget.created', null, null, $budget->toArray()); }); diff --git a/app/Http/Controllers/InvestmentPolicyController.php b/app/Http/Controllers/InvestmentPolicyController.php new file mode 100644 index 0000000..49d4d8a --- /dev/null +++ b/app/Http/Controllers/InvestmentPolicyController.php @@ -0,0 +1,40 @@ +user()->purchasing_role, [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]); + } + + public function store(Request $request, InvestmentWorkflow $workflow): RedirectResponse + { + abort_unless($request->user()->purchasing_role === 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); + + return back(); + } + + public function review(Request $request, InvestmentPolicy $policy, InvestmentWorkflow $workflow): RedirectResponse + { + $data = $request->validate(['decision' => 'required|in:approved,rejected', 'reason' => 'required|string|max:4000']); + $workflow->review($request->user(), $policy, $data['decision'], $data['reason']); + + return back(); + } +} diff --git a/app/Models/Budget.php b/app/Models/Budget.php index 80101ea..db34ba7 100644 --- a/app/Models/Budget.php +++ b/app/Models/Budget.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Model; * @property int $amount_minor * @property int $created_by * @property int|null $approved_by + * @property string $kind */ class Budget extends Model { diff --git a/app/Models/InvestmentPolicy.php b/app/Models/InvestmentPolicy.php new file mode 100644 index 0000000..b494213 --- /dev/null +++ b/app/Models/InvestmentPolicy.php @@ -0,0 +1,36 @@ + 'pending']; + + protected function casts(): array + { + return ['includes_tax' => 'boolean', 'ceiling_minor' => 'integer']; + } + + protected static function booted(): void + { + static::updating(function (InvestmentPolicy $policy) { + if ($policy->getRawOriginal('status') !== 'pending' || array_diff(array_keys($policy->getDirty()), ['status', 'decided_at', 'updated_at']) !== []) { + throw new \LogicException('Preservar os parâmetros e as decisões da regra.'); + } + }); + static::deleting(fn () => throw new \LogicException('Preservar versões de investimento.')); + } +} diff --git a/app/Models/InvestmentPolicyReview.php b/app/Models/InvestmentPolicyReview.php new file mode 100644 index 0000000..dda41fd --- /dev/null +++ b/app/Models/InvestmentPolicyReview.php @@ -0,0 +1,16 @@ + $approval_snapshot * @property list> $decision_matrix + * @property array|null $investment_snapshot */ class PurchaseAward extends Model { @@ -25,7 +26,7 @@ class PurchaseAward extends Model protected function casts(): array { - return ['approval_snapshot' => 'array', 'decision_matrix' => 'array', 'delivery_date' => 'date:Y-m-d']; + return ['approval_snapshot' => 'array', 'decision_matrix' => 'array', 'investment_snapshot' => 'array', 'delivery_date' => 'date:Y-m-d']; } /** @return HasMany */ diff --git a/app/Services/BudgetWorkflow.php b/app/Services/BudgetWorkflow.php index 5f8e38c..7bbbdd7 100644 --- a/app/Services/BudgetWorkflow.php +++ b/app/Services/BudgetWorkflow.php @@ -30,6 +30,10 @@ class BudgetWorkflow if (! $budget) { return null; } + $requiredKind = $request->items()->where('purchase_type', 'equipment')->exists() ? 'investment' : 'operating'; + if ($budget->kind !== $requiredKind) { + throw ValidationException::withMessages(['budget' => '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 !== now()->year)) { throw ValidationException::withMessages(['budget' => 'Dotação pendente de aprovação ou exercício encerrado.']); } diff --git a/app/Services/InvestmentWorkflow.php b/app/Services/InvestmentWorkflow.php new file mode 100644 index 0000000..8abbfef --- /dev/null +++ b/app/Services/InvestmentWorkflow.php @@ -0,0 +1,122 @@ +id() === $user->id && $user->tenant_id !== null && $user->purchasing_role === PurchasingRole::Admin, 403); + } + + /** @param array $data */ + public function propose(User $user, array $data): InvestmentPolicy + { + $this->admin($user); + + return DB::transaction(function () use ($user, $data) { + app(ConflictGuard::class)->lockCompany($user); + $user = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->findOrFail($user->id); + $this->admin($user); + $latest = InvestmentPolicy::query()->latest('revision')->first(); + if ((int) $data['previous_id'] !== ($latest->id ?? 0) || $data['mode'] !== 'ceiling' || $data['scope'] !== 'process_total' || $data['currency'] !== 'MZN') { + throw ValidationException::withMessages(['investment' => 'Versão desactualizada ou regra não suportada. Não se presume um circuito de excepção.']); + } + $policy = InvestmentPolicy::query()->create(['tenant_id' => $user->tenant_id, 'created_by' => $user->id, 'revision' => ($latest->revision ?? 0) + 1, 'mode' => $data['mode'], 'scope' => $data['scope'], 'currency' => $data['currency'], 'ceiling_minor' => BigDecimal::of((string) $data['ceiling'])->multipliedBy(100)->toScale(0)->toInt(), 'includes_tax' => $data['includes_tax'], 'valid_from' => $data['valid_from'], 'valid_until' => $data['valid_until'], 'reason' => $data['reason']]); + app(RequisitionWorkflow::class)->audit($user, 'investment.proposed', null, null, $policy->toArray()); + + return $policy; + }); + } + + public function review(User $user, InvestmentPolicy $policy, string $decision, string $reason): void + { + $this->admin($user); + abort_unless($policy->tenant_id === $user->tenant_id && $policy->created_by !== $user->id, 403); + if (! in_array($decision, ['approved', 'rejected']) || trim($reason) === '') { + throw ValidationException::withMessages(['investment' => 'Decisão e fundamento obrigatórios.']); + } + DB::transaction(function () use ($user, $policy, $decision, $reason) { + app(ConflictGuard::class)->lockCompany($user); + $user = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->findOrFail($user->id); + $this->admin($user); + $policy = InvestmentPolicy::query()->lockForUpdate()->findOrFail($policy->id); + $existing = InvestmentPolicyReview::query()->where('investment_policy_id', $policy->id)->where('reviewed_by', $user->id)->first(); + if ($existing) { + if ($existing->decision !== $decision || $existing->reason !== $reason) { + throw ValidationException::withMessages(['investment' => 'Decisão anterior imutável.']); + } + + return; + } + if ($policy->status !== 'pending' || InvestmentPolicy::query()->max('revision') !== $policy->revision) { + throw ValidationException::withMessages(['investment' => 'Versão decidida ou substituída por proposta mais recente.']); + } + 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)) { + throw ValidationException::withMessages(['investment' => 'Proponente e validadores devem continuar elegíveis até à publicação.']); + } + } + InvestmentPolicyReview::query()->create(['tenant_id' => $user->tenant_id, 'investment_policy_id' => $policy->id, 'reviewed_by' => $user->id, 'decision' => $decision, 'reason' => $reason, 'created_at' => now()]); + if ($decision === 'rejected') { + $policy->update(['status' => 'rejected', 'decided_at' => now()]); + } elseif (InvestmentPolicyReview::query()->where('investment_policy_id', $policy->id)->where('decision', 'approved')->count() === 2) { + $policy->update(['status' => 'approved', 'decided_at' => now()]); + } + app(RequisitionWorkflow::class)->audit($user, 'investment.'.$decision, null, null, ['policy_id' => $policy->id, 'status' => $policy->status]); + }); + } + + public function current(): ?InvestmentPolicy + { + return InvestmentPolicy::query()->where('status', 'approved')->where('valid_from', '<=', today()->format('Y-m-d'))->latest('revision')->first(); + } + + public function requireCurrent(): InvestmentPolicy + { + $policy = $this->current(); + if (! $policy || $policy->valid_until < today()->format('Y-m-d') || $policy->mode !== 'ceiling' || $policy->scope !== 'process_total' || $policy->currency !== 'MZN') { + throw ValidationException::withMessages(['purchase_type' => 'É necessária regra de investimento própria, com duas aprovações administrativas e vigência válida. Sem regra aplicável, adjudicação e emissão de equipamento bloqueadas.']); + } + + return $policy; + } + + /** @return array|null */ + public function check(Requisition $request, Quotation $quotation): ?array + { + if (! $request->items()->where('purchase_type', 'equipment')->exists()) { + return null; + } + $policy = $this->requireCurrent(); + $amount = $policy->includes_tax ? $quotation->total_minor : $quotation->subtotal_minor; + if ($amount > $policy->ceiling_minor || $quotation->currency !== 'MZN') { + throw ValidationException::withMessages(['investment' => 'O total do processo excede o tecto de investimento da empresa ou usa moeda não suportada. Não há dispensa automática.']); + } + app(EquipmentWorkflow::class)->assertTechnical($request); + $entry = BudgetEntry::query()->where('requisition_id', $request->id)->where('kind', 'reserve')->first(); + $budget = $entry ? Budget::query()->lockForUpdate()->find($entry->budget_id) : null; + if (! $budget || ! $budget->approved_by || $budget->kind !== 'investment' || $budget->cost_center_id !== $request->cost_center_id || (int) $budget->year !== now()->year) { + throw ValidationException::withMessages(['investment' => 'Exige reserva em dotação de investimento aprovada do centro e exercício. Não se atribui orçamento retroactivo.']); + } + $basis = app(EquipmentWorkflow::class)->basis($request); + $technical = TechnicalReview::query()->where('requisition_id', $request->id)->where('basis_hash', $basis)->where('decision', 'approved')->firstOrFail(); + + return [...$policy->only(['id', 'revision', 'mode', 'scope', 'currency', 'ceiling_minor', 'includes_tax', 'valid_from', 'valid_until']), 'reviewer_ids' => InvestmentPolicyReview::query()->where('investment_policy_id', $policy->id)->orderBy('reviewed_by')->pluck('reviewed_by')->all(), 'budget_id' => $budget->id, 'technical_review_id' => $technical->id, 'technical_basis_hash' => $basis]; + } +} diff --git a/app/Services/PurchaseWorkflow.php b/app/Services/PurchaseWorkflow.php index 35e17d2..84fe9f9 100644 --- a/app/Services/PurchaseWorkflow.php +++ b/app/Services/PurchaseWorkflow.php @@ -36,6 +36,7 @@ class PurchaseWorkflow $this->sourceApproved($requisition, $rules); $quote = Quotation::query()->where('requisition_id', $requisition->id)->lockForUpdate()->findOrFail($data['quotation_id']); $result = $this->checkComparison($user, $requisition, $quote); + $investment = app(InvestmentWorkflow::class)->check($requisition, $quote); app(BudgetWorkflow::class)->record($user, $requisition, 'award', $quote->total_minor, $quote->subtotal_minor); $award = PurchaseAward::query()->create([ 'tenant_id' => $user->tenant_id, 'requisition_id' => $requisition->id, 'quotation_id' => $quote->id, @@ -43,8 +44,9 @@ class PurchaseWorkflow 'delivery_location' => $data['delivery_location'], 'status' => 'pending', 'subtotal_minor' => $quote->subtotal_minor, 'tax_minor' => $quote->tax_minor, 'total_minor' => $quote->total_minor, 'approval_snapshot' => $rules, 'decision_matrix' => $result['rows'], 'comparison_hash' => $result['fingerprint'], + 'investment_snapshot' => $investment, ]); - $roles = $this->roles($rules, $rules['approval_includes_tax'] ? $quote->total_minor : $quote->subtotal_minor); + $roles = $this->roles($rules, $rules['approval_includes_tax'] ? $quote->total_minor : $quote->subtotal_minor, $requisition->items()->where('purchase_type', 'equipment')->exists()); foreach ($roles as $position => $role) { $award->approvals()->create(['tenant_id' => $user->tenant_id, 'position' => $position + 1, 'role' => $role]); } @@ -130,6 +132,8 @@ class PurchaseWorkflow $snapshot = [ 'development_only' => true, 'budget_checked' => false, 'budget_control' => app(BudgetWorkflow::class)->record($user, $requisition, 'commit', $quote->total_minor, $quote->subtotal_minor), + 'investment_rules' => $award->investment_snapshot, + 'equipment_requests' => $requisition->items()->where('purchase_type', 'equipment')->get()->map(fn ($line) => $line->only(['id', 'description', 'production_area']))->all(), 'company' => Tenant::query()->findOrFail($user->tenant_id)->only(['id', 'name']), 'requisition' => $requisition->only(['id', 'number', 'justification']), 'department' => $requisition->department->only(['id', 'name']), @@ -166,6 +170,9 @@ class PurchaseWorkflow $this->supportedItems($requisition); $this->sourceApproved($requisition, $rules); $quote = Quotation::query()->where('requisition_id', $requisition->id)->lockForUpdate()->findOrFail($award->quotation_id); + if (app(InvestmentWorkflow::class)->check($requisition, $quote) !== $award->investment_snapshot) { + throw ValidationException::withMessages(['investment' => 'A regra de investimento ou a base técnica/orçamental mudou. As aprovações anteriores não autorizam uma emissão com outra base.']); + } $result = $this->checkComparison($user, $requisition, $quote); if ($result['fingerprint'] !== $award->comparison_hash || $award->approval_snapshot !== $rules || $quote->subtotal_minor !== $award->subtotal_minor || $quote->tax_minor !== $award->tax_minor || $quote->total_minor !== $award->total_minor) { @@ -214,13 +221,13 @@ class PurchaseWorkflow /** @param array{first_limit_minor: int, second_limit_minor: int, approval_includes_tax: bool, rules_provisional: bool} $rules * @return list */ - private function roles(array $rules, int $amount): array + private function roles(array $rules, int $amount, bool $equipment = false): array { $roles = ['department_head']; if ($amount > $rules['first_limit_minor']) { $roles[] = 'procurement'; } - if ($amount > $rules['second_limit_minor']) { + if ($amount > $rules['second_limit_minor'] || $equipment) { $roles[] = 'finance'; } @@ -230,7 +237,7 @@ class PurchaseWorkflow /** @param array{first_limit_minor: int, second_limit_minor: int, approval_includes_tax: bool, rules_provisional: bool} $rules */ private function sourceApproved(Requisition $requisition, array $rules): void { - $roles = $this->roles($rules, $rules['approval_includes_tax'] ? $requisition->total_minor : $requisition->subtotal_minor); + $roles = $this->roles($rules, $rules['approval_includes_tax'] ? $requisition->total_minor : $requisition->subtotal_minor, $requisition->items()->where('purchase_type', 'equipment')->exists()); $steps = $requisition->steps()->get(); $seen = [$requisition->requester_id]; if ($requisition->status !== 'approved' || $steps->count() !== count($roles)) { @@ -248,7 +255,7 @@ class PurchaseWorkflow private function validateFinalApprovals(PurchaseAward $award, Requisition $requisition): void { $rules = $this->rules($requisition); - $roles = $this->roles($rules, $rules['approval_includes_tax'] ? $award->total_minor : $award->subtotal_minor); + $roles = $this->roles($rules, $rules['approval_includes_tax'] ? $award->total_minor : $award->subtotal_minor, $requisition->items()->where('purchase_type', 'equipment')->exists()); $steps = $award->approvals()->get(); $seen = [$requisition->requester_id, $award->created_by]; if ($steps->count() !== count($roles)) { @@ -268,10 +275,10 @@ class PurchaseWorkflow private function supportedItems(Requisition $requisition): void { if ($requisition->items()->where('purchase_type', 'equipment')->exists()) { - throw ValidationException::withMessages(['purchase_type' => 'O limite próprio de investimento e a sua regra de aplicação ainda não foram definidos pela empresa. A validação técnica e Finanças não dispensam essa decisão; adjudicação e emissão de equipamento bloqueadas.']); + app(InvestmentWorkflow::class)->requireCurrent(); } foreach ($requisition->items()->get() as $line) { - if (! in_array($line->purchase_type, ['consumable', 'service', 'other', 'spare_part']) || ($line->purchase_type === 'spare_part' && (! $line->equipment_id || ! $line->spare_part_id))) { + if (! in_array($line->purchase_type, ['consumable', 'service', 'other', 'spare_part', 'equipment']) || ($line->purchase_type === 'spare_part' && (! $line->equipment_id || ! $line->spare_part_id))) { throw ValidationException::withMessages(['purchase_type' => 'Tipo não suportado ou peça sem equipamento/artigo associado. Complete o cadastro antes da compra.']); } app(EquipmentWorkflow::class)->line($line->toArray(), $requisition->department_id, $requisition->priority); diff --git a/app/Services/ReceiptWorkflow.php b/app/Services/ReceiptWorkflow.php index dbe243a..ceeee7b 100644 --- a/app/Services/ReceiptWorkflow.php +++ b/app/Services/ReceiptWorkflow.php @@ -134,7 +134,7 @@ class ReceiptWorkflow throw ValidationException::withMessages(['items' => 'Descreva as reservas, danos ou divergências de qualidade.']); } $stockItem = ! empty($input['stock_item_id']) ? StockItem::query()->lockForUpdate()->findOrFail($input['stock_item_id']) : null; - $physical = in_array($source->purchase_type, ['consumable', 'other', 'spare_part']); + $physical = in_array($source->purchase_type, ['consumable', 'other', 'spare_part', 'equipment']); if ($source->purchase_type === 'spare_part') { if (! $source->spare_part_id || ! $source->equipment_id) { throw ValidationException::withMessages(['items' => 'Peça sem associação autorizada na ordem. A recepção está bloqueada.']); diff --git a/database/migrations/2026_10_28_000014_create_investment_policies.php b/database/migrations/2026_10_28_000014_create_investment_policies.php new file mode 100644 index 0000000..7c8654f --- /dev/null +++ b/database/migrations/2026_10_28_000014_create_investment_policies.php @@ -0,0 +1,47 @@ +id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('created_by')->constrained('users')->restrictOnDelete(); + $table->unsignedInteger('revision'); + $table->string('status')->default('pending'); + $table->string('mode'); + $table->string('scope'); + $table->string('currency', 3); + $table->bigInteger('ceiling_minor'); + $table->boolean('includes_tax'); + $table->date('valid_from'); + $table->date('valid_until'); + $table->text('reason'); + $table->timestamp('decided_at')->nullable(); + $table->timestamps(); + $table->unique(['tenant_id', 'revision']); + }); + Schema::create('investment_policy_reviews', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('investment_policy_id')->constrained()->restrictOnDelete(); + $table->foreignId('reviewed_by')->constrained('users')->restrictOnDelete(); + $table->string('decision'); + $table->text('reason'); + $table->timestamp('created_at'); + $table->unique(['investment_policy_id', 'reviewed_by']); + }); + Schema::table('budgets', fn (Blueprint $table) => $table->string('kind')->default('operating')); + Schema::table('purchase_awards', fn (Blueprint $table) => $table->json('investment_snapshot')->nullable()); + } + + public function down(): void + { + throw new LogicException('Preservar regras e decisões de investimento.'); + } +}; diff --git a/docs/compatibilidade.md b/docs/compatibilidade.md index bdc0670..e43b1e2 100644 --- a/docs/compatibilidade.md +++ b/docs/compatibilidade.md @@ -13,7 +13,7 @@ Estado actualizado em 28 de Outubro de 2026. Projecto activo: C:\laragon\www\com | Ordens | Emissão única/idempotente, snapshots, consulta e impressão | Orçamento não validado; documentos de desenvolvimento | | Recepção | Parcial/total, qualidade, divergências, pendentes, segregação do emitente e auditoria | Substituições e anexos; rejeições não autorizam receber acima do total | | Stock | Artigos, armazéns, entradas, saídas fundamentadas, transferências atómicas, contagens e ajustes auditados por empresa | Revisão visual, reposição e conversões | -| Equipamentos e peças | Cadastro, compatibilidade, mínimos, alertas, validação técnica e rastreabilidade | Regra de investimento; adjudicação/emissão de equipamento bloqueadas | +| Equipamentos e peças | Cadastro, compatibilidade, mínimos, validação técnica e investimento condicionado | Configuração empresarial, agregação por projecto e fraccionamento | | Orçamento | Dotação, reforços aprovados, reservas e compromissos por empresa/centro/exercício | Revisão visual, reduções/transferências e validação operacional integral | | Facturas e pagamentos | Facturas, consumo e registo/conferência de pagamentos externos | Estornos, notas de crédito e anexos | | Início e navegação | Página inicial própria, menus por área/perfil e consulta pública por módulo | Revisão visual | @@ -23,6 +23,17 @@ Preservar enums, políticas, modelos relacionais, Brick Math e isolamento por em ## Verificação actual +Regras de investimento entregues por empresa, com parâmetros explícitos e duas +aprovações administrativas independentes. Dotação classificada, reserva e Finanças +obrigatórias para equipamento; regra congelada e revalidada antes de emitir. +Migração 000014 no lote 15 após backup/restauro verificados de 46 tabelas. +Três cenários concorrentes PostgreSQL aprovados. Suite actual: **393 aprovados, +3 ignorados, 2.886 asserções**. Nenhuma regra criada na base real. Aprovação +adicional, agregação por projecto, detecção de fraccionamento, revisão/cancelamento +de processos e revisão visual continuam pendentes. + +### Histórico da entrega de reforços + Reforços entregues por empresa/centro/exercício, com aprovação independente e histórico, sem reescrever a dotação inicial. Migração 000013 no lote 14 após backup e restauro verificados de 45 tabelas. Três cenários concorrentes PostgreSQL diff --git a/docs/desenvolvimento.md b/docs/desenvolvimento.md index ca5ba85..efb3485 100644 --- a/docs/desenvolvimento.md +++ b/docs/desenvolvimento.md @@ -1,5 +1,92 @@ # Desenvolvimento do SGC +## Regras de investimento por empresa + +Implementadas em `/regras-investimento`, sem montantes predefinidos ou regras +criadas na base real. Administração propõe tecto, base com/sem IVA, vigência, +âmbito e fundamento. **Dois administradores distintos do proponente** aprovam +a versão. Finanças e Procurement consultam; outros perfis não configuram. +Proponente e validadores mantêm elegibilidade até à publicação. Decisões e +parâmetros são preservados; correcção exige nova versão. + +Nesta etapa, a empresa escolhe explicitamente um **tecto absoluto em MZN sobre +o total do processo que contenha equipamento**. O limite é inclusivo e a base +de IVA é independente dos escalões gerais. Não se presume aprovação adicional, +agregação por projecto, câmbio ou limite ilimitado. Zero explícito é um tecto +zero, não ausência de limite. Não existe detecção automática de fraccionamento; +este incremento não certifica o controlo global desse requisito. + +### Vigência e integração + +Uma proposta pendente/rejeitada não substitui a regra aprovada. Nova versão +aprovada passa a ser aplicável a partir da sua data inicial. Depois de uma +versão mais recente entrar em vigor, a sua expiração bloqueia o fluxo; não +ressuscita uma regra anterior mais permissiva. As aprovações administrativas +publicadas são históricas; mudanças posteriores de perfil não reescrevem a regra. + +Adjudicação, aprovação do valor final e emissão verificam regra aplicável, +tecto, validação técnica e reserva em dotação de investimento aprovada do +centro/exercício. Finanças é obrigatória para equipamento tanto na estimativa +como no novo ciclo do valor final, para qualquer valor. A versão, os parâmetros, +os dois validadores, a base técnica e a dotação ficam congelados na adjudicação. +Mudança de regra/base antes da emissão bloqueia o reaproveitamento das aprovações. +Ordens já emitidas mantêm a regra congelada e emissão repetida devolve a mesma ordem. + +Orçamentos distinguem despesa corrente de investimento. Registos anteriores +receberam `operating`; não foram convertidos em investimento. A interface exige +classificação explícita para novas dotações; chamadas anteriores sem esse campo +continuam limitadas a despesa corrente. Mantém-se uma dotação por centro/ano: +para separar classes no mesmo ano, a empresa usa centros distintos. A dotação +aprovada e a sua classificação não são reescritas por reforços. Não se atribui +reserva de investimento retroactivamente a processos anteriores sem reserva. + +Recepções de equipamento emitido passam a admitir entrada física aceite, com +armazém/artigo/unidade explícitos. Não criam automaticamente uma máquina no +cadastro, não capitalizam activos nem inventam compatibilidades. A área/linha +do pedido e a regra aplicada ficam no snapshot da ordem, no detalhe e na impressão. + +### Verificações e aplicação + +Suite completa: **393 testes aprovados, 3 ignorados, 2.886 asserções**. +Os **70 testes** de regras de investimento e adjudicação passaram, incluindo +15 de governação e oito novos cenários integrados de equipamento: duas assinaturas, +isolamento, vigência, ausência de retrocesso após expiração, tecto inclusivo, +bases de IVA, Finanças obrigatória, dotação/validação técnica, alteração de regra, +emissão, recepção física e preservação da ordem após nova publicação. +PHPStan, Pint, TypeScript, compilação e formatação/lint frontend aprovados. + +Três cenários concorrentes PostgreSQL aprovados: assinaturas distintas, mesma +assinatura repetida e propostas de versões concorrentes. Dois processos e duas +ligações independentes observados simultaneamente à espera de bloqueios por +cenário. Estados e contagens conferidos; base temporária eliminada. São testes +de serviço, não de carga ou de todos os fluxos concorrentes de emissão. +[Evidência](investimento-concorrencia-postgresql.json). + +Migração 000014 aplicada no **lote 15** após backup custom PostgreSQL 17 de +**250.983 bytes** e restauro verificado das **46 tabelas** anteriores numa base +temporária, depois eliminada. Dump e manifesto partilharam snapshot consistente. +A verificação antes/depois projectou as colunas anteriores, preservando dados +de negócio e restrições enquanto eram acrescentadas classificação e snapshot. +`.env`, contas e credenciais preservados. Tabelas de regras e decisões vazias, +sem políticas fictícias, sementes ou limites definidos para empresas reais. +Backup e manifesto privados, fora do Git. +[Evidência sem dados privados](investimento-migracao-000014.json). + +### Limitações e continuidade + +`/consulta/investimento` é demonstração fictícia, sem consultas/gravações de +negócio. **Revisão visual pública, autenticada e de impressão permanece pendente**; +o navegador está indisponível. Testes HTTP/Inertia e compilação não a substituem. +Também não há validação operacional integral ou autorização de compras reais. + +Empresas devem criar as dotações e regras efectivamente decididas pelos seus +responsáveis. Sem regra vigente e reserva apropriada, equipamento permanece +bloqueado. Circuitos acima do tecto, agregação por projecto, classificação +automática de investimento e detecção de fraccionamento não foram implementados. +Próximo incremento: revisão/cancelamento controlado de processos afectados por +mudanças de base, com política explícita, histórico e novas aprovações; não +reutilizar decisões antigas nem alterar ordens emitidas directamente. + ## Reforços orçamentais por empresa Implementadas propostas de reforço em `/orcamentos`, por dotação existente de diff --git a/docs/investimento-concorrencia-postgresql.json b/docs/investimento-concorrencia-postgresql.json new file mode 100644 index 0000000..eb8a09f --- /dev/null +++ b/docs/investimento-concorrencia-postgresql.json @@ -0,0 +1,30 @@ +{ + "tested_at_utc": "2026-10-28T13:24:32+00:00", + "separate_database": true, + "transport": "Independent PHP service transactions and PostgreSQL connections", + "scenarios": [ + { + "name": "distinct_approvals", + "passed": true, + "overlapping_lock_waits": 2, + "state": "approved", + "review_count": 2 + }, + { + "name": "same_approval", + "passed": true, + "overlapping_lock_waits": 2, + "state": "pending", + "review_count": 1 + }, + { + "name": "competing_versions", + "passed": true, + "overlapping_lock_waits": 2, + "state": "pending", + "review_count": 0 + } + ], + "passed": true, + "test_database_dropped": true +} diff --git a/docs/investimento-migracao-000014.json b/docs/investimento-migracao-000014.json new file mode 100644 index 0000000..acda353 --- /dev/null +++ b/docs/investimento-migracao-000014.json @@ -0,0 +1,16 @@ +{ + "verified_at_utc": "2026-10-28T13:27:20+00:00", + "migration": "2026_10_28_000014_create_investment_policies", + "batch": 15, + "backup_created": true, + "backup_bytes": 250983, + "restore_verified": true, + "restored_tables": 46, + "restore_database_dropped": true, + "business_records_unchanged": true, + "existing_constraints_preserved": true, + "env_unchanged": true, + "investment_policies_count": 0, + "investment_policy_reviews_count": 0, + "operational_validation": false +} diff --git a/docs/orientacoes-continuidade.md b/docs/orientacoes-continuidade.md index 8471777..834106a 100644 --- a/docs/orientacoes-continuidade.md +++ b/docs/orientacoes-continuidade.md @@ -1,5 +1,17 @@ # Continuidade do desenvolvimento do SGC +## Próxima etapa após regras de investimento + +Regras próprias por empresa, duas aprovações administrativas e integração com +equipamento já implementadas; migração 000014 aplicada, lote 15. Não repetir +as etapas históricas abaixo. Sem valores predefinidos ou regras fictícias na +base real. Cada empresa decide os seus parâmetros e dotações de investimento. +Prosseguir com revisão/cancelamento controlado de processos afectados por +mudanças de base, com política explícita, rastreabilidade e novas aprovações. +Não reescrever ordens emitidas ou reutilizar implicitamente aprovações antigas. +Agregação por projecto/fraccionamento e circuitos acima do tecto não estão +suportados. Revisão visual e validação operacional integral continuam pendentes. + ## Próxima etapa após reforços Reforços por empresa/centro/exercício já estão implementados e migrados diff --git a/docs/proposta-regras-financeiras.md b/docs/proposta-regras-financeiras.md index 25156d6..31382b6 100644 --- a/docs/proposta-regras-financeiras.md +++ b/docs/proposta-regras-financeiras.md @@ -3,6 +3,11 @@ Estado: proposta para decisão empresarial, ainda sem aprovação nem implementação. Referência funcional: entrega `75ffdb3`. O orçamento continua sem validação. +As regras de investimento passaram a suportar tecto absoluto por processo, +base de IVA e vigência explícitas, com duas aprovações administrativas. Os +valores continuam a ser decisões de cada empresa; modalidades adicionais e +agregação por projecto não estão implementadas. Consultar o relatório actual. + Actualização: o utilizador autorizou avançar com a abordagem. Dotações, reservas, compromissos e conferência de facturas/consumo foram implementados e migrados. A base de IVA é uma decisão explícita de cada empresa. As secções seguintes diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 6e206cb..bae551d 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -44,6 +44,15 @@ export function AppSidebar() { ].includes(auth.user.purchasing_role ?? ''); const purchasingItems: NavItem[] = [ { title: 'Requisições', href: '/requisicoes', icon: ClipboardList }, + ...(auth.user.purchasing_role === 'procurement' + ? [ + { + title: 'Regras de investimento', + href: '/regras-investimento', + icon: FileCheck2, + }, + ] + : []), ...(canReadPurchases ? [ { title: 'Fornecedores', href: '/fornecedores', icon: Users }, @@ -136,6 +145,11 @@ export function AppSidebar() { href: '/orcamentos', icon: FileCheck2, }, + { + title: 'Regras de investimento', + href: '/regras-investimento', + icon: FileCheck2, + }, { title: 'Facturas', href: '/facturas', diff --git a/resources/js/lib/preview-navigation.ts b/resources/js/lib/preview-navigation.ts index 3bf7f1e..105cf7c 100644 --- a/resources/js/lib/preview-navigation.ts +++ b/resources/js/lib/preview-navigation.ts @@ -13,5 +13,6 @@ export const previewPaths: Record = { bank: '/consulta/dados-bancarios', payments: '/consulta/pagamentos', reinforcements: '/consulta/reforcos', + investment: '/consulta/investimento', appearance: '/consulta/aparencia', }; diff --git a/resources/js/pages/budgets/index.tsx b/resources/js/pages/budgets/index.tsx index 1effa74..466534d 100644 --- a/resources/js/pages/budgets/index.tsx +++ b/resources/js/pages/budgets/index.tsx @@ -30,6 +30,7 @@ type Budget = { includes_tax: boolean; reinforced_minor: number; approved_total_minor: number; + kind: string; }; export default function Budgets({ centers, @@ -57,6 +58,7 @@ export default function Budgets({ amount: '', reason: '', includes_tax: '', + kind: '', }); return ( <> @@ -91,6 +93,30 @@ export default function Budgets({ }} > + +

+ Cada centro/exercício admite uma dotação. Para + separar despesa corrente de investimento no mesmo + exercício, use centros distintos. +

+ + + + + + +