Adiciona dotacoes e reservas orcamentais por empresa
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Budget;
|
||||
use App\Models\BudgetEntry;
|
||||
use App\Models\CostCenter;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class BudgetController extends Controller
|
||||
{
|
||||
private function access(Request $request): void
|
||||
{
|
||||
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance]), 403);
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->access($request);
|
||||
|
||||
return Inertia::render('budgets/index', ['centers' => CostCenter::query()->orderBy('code')->get(), 'budgets' => Budget::query()->latest('id')->get()->map(function (Budget $budget) {
|
||||
$entries = BudgetEntry::query()->where('budget_id', $budget->id);
|
||||
$reserved = (int) (clone $entries)->sum('reserved_minor');
|
||||
$committed = (int) $entries->sum('committed_minor');
|
||||
|
||||
return [...$budget->toArray(), 'reserved_minor' => $reserved, 'committed_minor' => $committed, 'available_minor' => $budget->amount_minor - $reserved - $committed];
|
||||
})]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$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) {
|
||||
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']]);
|
||||
app(RequisitionWorkflow::class)->audit($request->user(), 'budget.created', null, null, $budget->toArray());
|
||||
});
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function approve(Request $request, Budget $budget): RedirectResponse
|
||||
{
|
||||
$this->access($request);
|
||||
DB::transaction(function () use ($request, $budget) {
|
||||
app(ConflictGuard::class)->lockCompany($request->user());
|
||||
$budget = Budget::query()->lockForUpdate()->findOrFail($budget->id);
|
||||
abort_if($budget->created_by === $request->user()->id, 403);
|
||||
if ($budget->approved_by) {
|
||||
return;
|
||||
}
|
||||
$budget->update(['approved_by' => $request->user()->id, 'approved_at' => now()]);
|
||||
app(RequisitionWorkflow::class)->audit($request->user(), 'budget.approved', null, null, $budget->toArray());
|
||||
});
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/** @property int $id
|
||||
* @property int $amount_minor
|
||||
* @property int $created_by
|
||||
* @property int|null $approved_by
|
||||
*/
|
||||
class Budget extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['includes_tax' => 'boolean'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AppendOnlyInventoryRecord;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class BudgetEntry extends Model
|
||||
{
|
||||
use AppendOnlyInventoryRecord, BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Budget;
|
||||
use App\Models\BudgetEntry;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\User;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class BudgetWorkflow
|
||||
{
|
||||
/** Called inside the procurement transaction, after the company lock.
|
||||
* @return array<string,int>|null
|
||||
*/
|
||||
public function record(User $user, Requisition $request, string $kind, int $amount = 0, ?int $subtotal = null): ?array
|
||||
{
|
||||
$existing = BudgetEntry::query()->where('requisition_id', $request->id)->orderBy('id')->first();
|
||||
$budget = $existing
|
||||
? Budget::query()->lockForUpdate()->findOrFail($existing->budget_id)
|
||||
: Budget::query()->where('cost_center_id', $request->cost_center_id)->where('year', now()->year)->lockForUpdate()->first();
|
||||
// Unconfigured legacy processes remain development documents, explicitly unchecked.
|
||||
if (! $budget) {
|
||||
return null;
|
||||
}
|
||||
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.']);
|
||||
}
|
||||
if (! $budget->includes_tax && in_array($kind, ['reserve', 'award', 'commit'])) {
|
||||
$amount = $subtotal ?? throw new \LogicException('Subtotal necessário para a base sem IVA.');
|
||||
}
|
||||
if (BudgetEntry::query()->where('requisition_id', $request->id)->where('kind', $kind)->exists()) {
|
||||
return ['budget_id' => $budget->id, 'year' => (int) $budget->year];
|
||||
}
|
||||
$own = BudgetEntry::query()->where('requisition_id', $request->id);
|
||||
$reserved = (int) (clone $own)->sum('reserved_minor');
|
||||
$committed = (int) $own->sum('committed_minor');
|
||||
if ($committed !== 0 || ($kind !== 'reserve' && ! $existing)) {
|
||||
throw ValidationException::withMessages(['budget' => 'Processo sem reserva válida ou já comprometido.']);
|
||||
}
|
||||
[$reserveDelta, $commitDelta] = match ($kind) {
|
||||
'reserve', 'award' => [$amount - $reserved, 0],
|
||||
'release' => [-$reserved, 0],
|
||||
'commit' => [-$reserved, $amount],
|
||||
default => throw new \LogicException('Movimento orçamental inválido.'),
|
||||
};
|
||||
$ledger = BudgetEntry::query()->where('budget_id', $budget->id);
|
||||
$used = (int) (clone $ledger)->sum('reserved_minor') + (int) $ledger->sum('committed_minor');
|
||||
if ($used + $reserveDelta + $commitDelta > $budget->amount_minor) {
|
||||
throw ValidationException::withMessages(['budget' => 'Saldo orçamental insuficiente. Não são permitidos excessos.']);
|
||||
}
|
||||
BudgetEntry::query()->create(['tenant_id' => $user->tenant_id, 'budget_id' => $budget->id, 'requisition_id' => $request->id, 'created_by' => $user->id, 'kind' => $kind, 'reserved_minor' => $reserveDelta, 'committed_minor' => $commitDelta, 'created_at' => now()]);
|
||||
app(RequisitionWorkflow::class)->audit($user, 'budget.'.$kind, $request, null, ['budget_id' => $budget->id, 'reserved_minor' => $reserveDelta, 'committed_minor' => $commitDelta]);
|
||||
|
||||
return ['budget_id' => $budget->id, 'year' => (int) $budget->year];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
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,
|
||||
'created_by' => $user->id, 'justification' => $data['justification'], 'delivery_date' => $data['delivery_date'],
|
||||
@@ -80,6 +81,9 @@ class PurchaseWorkflow
|
||||
$step->update(['decision' => $decision, 'approver_id' => $user->id, 'comment' => $comment, 'decided_at' => now()]);
|
||||
$status = $decision === 'rejected' ? 'rejected' : ($award->approvals()->where('decision', 'pending')->exists() ? 'pending' : 'approved');
|
||||
$award->update(['status' => $status]);
|
||||
if ($decision === 'rejected') {
|
||||
app(BudgetWorkflow::class)->record($user, $requisition, 'release');
|
||||
}
|
||||
$this->audit->audit($user, 'award.'.$decision, $requisition, ['status' => 'pending'], ['award_id' => $award->id, 'status' => $status, 'approval' => $step->toArray()]);
|
||||
});
|
||||
}
|
||||
@@ -125,6 +129,7 @@ class PurchaseWorkflow
|
||||
}
|
||||
$snapshot = [
|
||||
'development_only' => true, 'budget_checked' => false,
|
||||
'budget_control' => app(BudgetWorkflow::class)->record($user, $requisition, 'commit', $quote->total_minor, $quote->subtotal_minor),
|
||||
'company' => Tenant::query()->findOrFail($user->tenant_id)->only(['id', 'name']),
|
||||
'requisition' => $requisition->only(['id', 'number', 'justification']),
|
||||
'department' => $requisition->department->only(['id', 'name']),
|
||||
|
||||
@@ -81,6 +81,7 @@ class RequisitionWorkflow
|
||||
throw ValidationException::withMessages(['submission' => 'Actualize a data de entrega antes de submeter.']);
|
||||
}
|
||||
$tenant = Tenant::query()->findOrFail($user->tenant_id);
|
||||
app(BudgetWorkflow::class)->record($user, $requisition, 'reserve', $requisition->total_minor, $requisition->subtotal_minor);
|
||||
$amount = $tenant->approval_includes_tax ? $requisition->total_minor : $requisition->subtotal_minor;
|
||||
$roles = ['department_head'];
|
||||
if ($amount > $tenant->first_limit_minor) {
|
||||
@@ -113,6 +114,9 @@ class RequisitionWorkflow
|
||||
$step->update(['decision' => $decision, 'approver_id' => $user->id, 'comment' => $comment, 'decided_at' => now()]);
|
||||
$status = $decision === 'rejected' ? 'rejected' : ($requisition->steps()->where('decision', 'pending')->exists() ? 'pending' : 'approved');
|
||||
$requisition->update(['status' => $status]);
|
||||
if ($decision === 'rejected') {
|
||||
app(BudgetWorkflow::class)->record($user, $requisition, 'release');
|
||||
}
|
||||
$this->audit($user, 'approval.'.$decision, $requisition, ['status' => 'pending'], ['status' => $status, 'step_id' => $step->id, 'role' => $step->role, 'comment' => $comment]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('budgets', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('cost_center_id')->constrained()->restrictOnDelete();
|
||||
$table->unsignedSmallInteger('year');
|
||||
$table->bigInteger('amount_minor');
|
||||
$table->boolean('includes_tax');
|
||||
$table->text('reason');
|
||||
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignId('approved_by')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$table->timestamp('approved_at')->nullable();
|
||||
$table->timestamps();
|
||||
$table->unique(['tenant_id', 'cost_center_id', 'year']);
|
||||
});
|
||||
Schema::create('budget_entries', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('budget_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('requisition_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
|
||||
$table->string('kind');
|
||||
$table->bigInteger('reserved_minor');
|
||||
$table->bigInteger('committed_minor');
|
||||
$table->timestamp('created_at');
|
||||
$table->unique(['requisition_id', 'kind']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
throw new LogicException('Preservar dotações e movimentos orçamentais.');
|
||||
}
|
||||
};
|
||||
+21
-3
@@ -12,8 +12,8 @@ Estado actualizado em 28 de Outubro de 2026. Projecto activo: C:\laragon\www\com
|
||||
| Adjudicação | Escolha fundamentada, revalidação e aprovação do valor final | Excepções, revisão e cancelamento sem política ficam bloqueados |
|
||||
| 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; mínimos, reposição, equipamento/peças e conversões |
|
||||
| Equipamentos e peças | Tipos não suportados ficam bloqueados | Aprovação técnica e regras específicas |
|
||||
| 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 |
|
||||
| Orçamento, facturas e pagamentos | Ainda não implementados | Definir regras antes do próximo incremento |
|
||||
| Início e navegação | Página inicial própria, menus por área/perfil e consulta pública por módulo | Revisão visual |
|
||||
| Auditoria | Histórico das operações implementadas | Exportações, retenção e protecção contra SQL administrativo |
|
||||
@@ -22,6 +22,24 @@ Preservar enums, políticas, modelos relacionais, Brick Math e isolamento por em
|
||||
|
||||
## Verificação actual
|
||||
|
||||
Referência consolidada: `75ffdb3`, com **262 testes aprovados, 3 ignorados e
|
||||
2.327 asserções**. Nova verificação dirigida de equipamentos, adjudicação,
|
||||
ordens e navegação: **87 testes aprovados, 1.163 asserções**. As onze migrações,
|
||||
incluindo 000008 no lote 9, foram confirmadas aplicadas por `migrate:status`.
|
||||
As páginas públicas inicial, consulta, equipamentos, recepções, stock e operações
|
||||
responderam HTTP 200. Os acessos sem sessão a equipamentos, recepções, stock e
|
||||
ordens responderam 302 para login. Estes resultados não são revisão visual.
|
||||
|
||||
O navegador voltou a reportar `The browser access host is unavailable`.
|
||||
Revisão visual pública, autenticada e de impressão permanece pendente.
|
||||
Orçamento e facturas não estão implementados; as regras empresariais ainda
|
||||
estão por definir. A [proposta de regras financeiras](proposta-regras-financeiras.md)
|
||||
é um documento para decisão, não uma aprovação. **O orçamento continua sem validação.**
|
||||
Sete cenários PostgreSQL de stock constam da evidência histórica, incluindo
|
||||
saída de peça com equipamento; não foram novamente executados nesta revisão.
|
||||
|
||||
## Histórico da entrega de stock anterior aos equipamentos
|
||||
|
||||
Na implementação, 233 testes passaram, 3 ignorados, 2.163 asserções em SQLite em memória; PHPStan, Pint, frontend, TypeScript e compilação aprovados. **Migrações até 000007 efectivamente aplicadas na base actual; 000007 no lote 8, sem migrações pendentes.** Antes da aplicação foi criado backup custom PostgreSQL e restaurado com sucesso numa base temporária exclusiva, depois eliminada. Contagens e hashes das 31 tabelas anteriores coincidiram antes/depois; restrições existentes preservadas. Mantêm-se 1 utilizador, 1 empresa, 1 departamento e 1 centro de custo. Sem seeds, recriação de contas ou movimentos fictícios; `stock_movements` e `stock_operations` vazias. `.env` inalterado. [Evidência da migração e recuperação](stock-migracao-000007.json).
|
||||
|
||||
Backup privado: `storage/app/private/backups/stock-000007-20261028-111943-4deef2/`, com `database.dump`, manifesto, cópia do ambiente e instruções de recuperação para uma base nova. Nenhuma restauração foi feita sobre a base actual. A migração acrescenta a tabela e ligação de operações, relaxa a obrigatoriedade da recepção e preserva os valores existentes; o PostgreSQL já utilizava `bigint` assinado. `down()` permanece bloqueado para proteger o histórico.
|
||||
@@ -30,4 +48,4 @@ Quatro cenários simultâneos de recepção passaram numa base PostgreSQL separa
|
||||
|
||||
Seis cenários simultâneos de stock aprovados em PostgreSQL separado, eliminado no final: excesso de saídas, repetição de saída/transferência/ajuste, transferências opostas e ajuste desactualizado. [Evidência](stock-concorrencia-postgresql.json). Apenas Armazém executa; Administração, Procurement e Finanças consultam. Contagens não movimentam stock; ajustes fundamentados exigem contagem compatível e sem movimentos posteriores. Periodicidade, tolerâncias e investigação/resolução das divergências exigem decisão da empresa; não foi inventado um circuito de aprovação.
|
||||
|
||||
Após a aplicação, `/consulta/operacoes-stock` respondeu HTTP 200 e `/stock/operacoes` redireccionou para login (302) no cliente sem sessão. Revisão visual pública e autenticada pendentes: a nova tentativa devolveu `The browser access host is unavailable`, impedindo também verificar a sessão existente. A revisão de impressão continua pendente. Os resultados de testes acima são os da implementação, não uma nova execução nesta aplicação. Resultados históricos, âmbito e limitações em [desenvolvimento.md](desenvolvimento.md). **O orçamento continua sem validação.** Equipamentos não foram iniciados.
|
||||
Após a aplicação, `/consulta/operacoes-stock` respondeu HTTP 200 e `/stock/operacoes` redireccionou para login (302) no cliente sem sessão. Os resultados desta secção são históricos. A entrega posterior de equipamentos substitui as antigas indicações de que esse módulo ainda não tinha sido iniciado. Resultados e limitações em [desenvolvimento.md](desenvolvimento.md).
|
||||
|
||||
@@ -1,5 +1,49 @@
|
||||
# Desenvolvimento do SGC
|
||||
|
||||
## Primeiro incremento de orçamento por empresa
|
||||
|
||||
Implementados cadastro de dotação anual por empresa/centro de custo, montantes
|
||||
explícitos em centavos MZN e escolha obrigatória da base com/sem IVA. Preparação
|
||||
e aprovação por pessoas distintas de Administração/Finanças. Sem valores de
|
||||
dotação predefinidos. A configuração pertence exclusivamente à empresa actual.
|
||||
|
||||
Reservas na submissão, ajuste na adjudicação, libertação na rejeição e conversão
|
||||
em compromisso na emissão reutilizam a transacção e o bloqueio por empresa.
|
||||
Movimentos imutáveis, idempotência por processo/operação e auditoria transaccional.
|
||||
Dotação pendente ou insuficiente bloqueia operações no centro/exercício configurado.
|
||||
Centros sem dotação preservam o fluxo anterior de desenvolvimento, explicitamente
|
||||
sem controlo orçamental. Não há activação global implícita para todos os clientes.
|
||||
O snapshot mantém `budget_checked=false`, acrescentando a referência ao controlo
|
||||
efectuado; não constitui validação financeira completa ou autorização de compra real.
|
||||
|
||||
Interface em `/orcamentos`, restrita a Administração e Finanças. Não existem
|
||||
reforços, edição de dotações, facturas, pagamentos ou migração financeira de
|
||||
processos anteriores. Os limites de investimento continuam por definir por empresa;
|
||||
equipamentos permanecem bloqueados para adjudicação/emissão.
|
||||
|
||||
Migração aditiva 000009 preparada e testada apenas em SQLite isolado; **não aplicada
|
||||
à base real**. Exige backup/restauro verificado antes da aplicação. Não foram
|
||||
alteradas contas, configurações, credenciais ou dados de negócio. Dez testes novos
|
||||
passaram, com 27 asserções; PHPStan e TypeScript aprovados. Revisão visual e
|
||||
concorrência PostgreSQL deste módulo continuam pendentes. **O orçamento continua
|
||||
sem validação operacional.**
|
||||
|
||||
## Validação das pendências após consolidação
|
||||
|
||||
Recolhido o resultado final da nova execução dirigida: **87 testes aprovados,
|
||||
1.163 asserções**, abrangendo equipamentos/peças, adjudicação/ordens e navegação.
|
||||
Não substitui o resultado da suite completa consolidada de 262 aprovados e 3
|
||||
ignorados. Onze migrações confirmadas aplicadas, sem pendentes. Seis entradas
|
||||
públicas responderam HTTP 200; quatro rotas protegidas responderam 302 para login.
|
||||
|
||||
O acesso ao navegador continua indisponível (`The browser access host is unavailable`).
|
||||
A revisão visual pública, autenticada e de impressão não foi concluída.
|
||||
O utilizador confirmou que as regras financeiras estão por definir. Foi preparada
|
||||
uma [proposta de regras](proposta-regras-financeiras.md) para decisão empresarial,
|
||||
sem activar orçamento, facturas ou desbloquear investimentos. **O orçamento
|
||||
continua sem validação.** Corrigido o mapa de compatibilidade que ainda descrevia
|
||||
equipamentos como não iniciados.
|
||||
|
||||
## Revisão e consolidação — 28 de Outubro de 2026
|
||||
|
||||
- Recolhido o resultado final da execução da suite: **262 testes aprovados,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Proposta de regras financeiras do SGC
|
||||
|
||||
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.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
Implementar primeiro orçamento e reservas; depois compromisso de ordens e
|
||||
conferência de facturas. Pagamentos ficam para um incremento posterior.
|
||||
Cada etapa exige testes e auditoria antes de permitir utilização operacional.
|
||||
|
||||
## Orçamento
|
||||
|
||||
- Exercício anual, por empresa e centro de custo, em MZN. Guardar os montantes
|
||||
em centavos. Datas de início/fim são explícitas; não transportar saldos
|
||||
automaticamente para outro exercício.
|
||||
- Proposta inicial: controlar o total com IVA, por prudência de tesouraria.
|
||||
Finanças deve confirmar esta base e a política de IVA antes da implementação.
|
||||
- Dotação inicial e reforços exigem preparação e aprovação por duas pessoas
|
||||
distintas da empresa, com fundamento, data e histórico. Nenhuma alteração
|
||||
substitui os registos anteriores ou cria saldo por omissão.
|
||||
- Disponível = dotação aprovada − reservas abertas − compromissos abertos
|
||||
− consumo líquido validado. As três parcelas são mutuamente exclusivas para
|
||||
cada quantidade/valor: converter entre elas não duplica a despesa.
|
||||
- Rascunho não reserva. Submissão reserva a estimativa integral, dentro de
|
||||
transacção, com verificação do disponível. Sem dotação válida ou saldo,
|
||||
impedir submissão; não criar autorização de excesso automática.
|
||||
- Rejeição definitiva liberta a reserva com movimento inverso auditado.
|
||||
Edição de pedido submetido exige fluxo explícito de revisão, ainda por construir.
|
||||
- Adjudicação ajusta a reserva ao valor final apenas após nova verificação do
|
||||
saldo; redução liberta a diferença. As aprovações do valor final mantêm-se.
|
||||
- Emissão converte reserva em compromisso, sem reduzir novamente o disponível.
|
||||
Falha na emissão reverte toda a operação financeira e documental.
|
||||
- Não permitir transferências entre centros de custo, cancelamentos, reabertura
|
||||
ou passagem de exercício enquanto os respectivos fluxos não forem aprovados.
|
||||
|
||||
## Facturas
|
||||
|
||||
- Factura ligada a fornecedor, ordem emitida e empresa; identidade e número
|
||||
normalizado únicos por fornecedor/empresa. Impedir repetição da mesma operação.
|
||||
- Finanças regista e outra pessoa de Finanças valida, distinta do autor.
|
||||
Sem duas pessoas elegíveis, a factura permanece pendente.
|
||||
- Conferir ordem, recepções aceites e factura por linha: quantidade, unidade,
|
||||
preço, IVA e total. Aceitar facturação parcial até ao recebido/aceite ainda
|
||||
não facturado. Serviços exigem uma aceitação registada antes da validação.
|
||||
- Proposta inicial: tolerância zero para diferenças de preço, quantidade ou
|
||||
imposto. Divergências suspendem a validação até existir procedimento aprovado;
|
||||
não corrigir silenciosamente a ordem ou as recepções.
|
||||
- Validar a factura converte a parcela correspondente do compromisso em consumo.
|
||||
A parcela por facturar mantém-se comprometida; a soma não pode ultrapassar
|
||||
o autorizado. O pagamento futuro liquida a dívida, sem consumir outra vez
|
||||
o orçamento.
|
||||
- Notas de crédito, adiantamentos, despesas sem ordem, diferenças cambiais e
|
||||
facturação entre exercícios ficam bloqueados até definição dos respectivos fluxos.
|
||||
- Não criar pagamentos automáticos nem enviar documentos a terceiros.
|
||||
|
||||
## Investimento em equipamentos
|
||||
|
||||
- Manter validação técnica do responsável da área e Finanças para qualquer
|
||||
valor, com separação do solicitante e registo das decisões.
|
||||
- Criar uma dotação de investimento identificada, distinguindo-a de despesa
|
||||
corrente; a classificação exige decisão explícita, sem inferência pela descrição.
|
||||
- O limite próprio de investimento deve ser configurado por empresa, com base
|
||||
com/sem IVA, vigência, âmbito por aquisição/projecto e significado explícito:
|
||||
tecto absoluto ou valor que exige aprovação adicional. São decisões em aberto.
|
||||
- Não propor um valor monetário arbitrário. Enquanto a regra estiver ausente
|
||||
ou não aprovada por duas pessoas distintas, manter adjudicação e emissão
|
||||
de equipamento bloqueadas. Proibir fraccionamento para contornar o limite.
|
||||
|
||||
## Validação de aceitação a implementar
|
||||
|
||||
1. Duas submissões simultâneas disputando saldo: apenas as que cabem na dotação
|
||||
podem reservar; saldo nunca negativo.
|
||||
2. Repetição de submissão, adjudicação, emissão ou validação de factura não
|
||||
duplica reserva, compromisso, consumo ou auditoria.
|
||||
3. Falha de auditoria reverte a transacção inteira. Os lançamentos anteriores
|
||||
não podem ser editados ou eliminados; correcções usam inversões explícitas.
|
||||
4. Facturas parciais consomem só a parcela aceite, preservando compromisso
|
||||
remanescente; excesso, duplicação e diferenças são recusados.
|
||||
5. Rejeição liberta exactamente a reserva existente, uma única vez.
|
||||
6. Isolamento entre empresas, centros de custo, exercícios e perfis;
|
||||
ninguém aprova a própria dotação ou factura.
|
||||
7. Equipamento sem regra de investimento aprovada permanece bloqueado,
|
||||
incluindo nos limites e acima deles. Validar fronteiras após definição da regra.
|
||||
8. Executar concorrência PostgreSQL em base isolada e revisão visual pública,
|
||||
autenticada e de impressão; testes funcionais não substituem a revisão visual.
|
||||
|
||||
## Decisões necessárias para activar o desenvolvimento financeiro
|
||||
|
||||
Confirmar exercício anual, base com IVA, circuito de dupla aprovação,
|
||||
reserva na submissão e tolerância zero. Definir dotação inicial por centro de
|
||||
custo e os parâmetros/autoridade do investimento. Esta proposta não constitui
|
||||
aprovação destas regras nem certificação para compras reais.
|
||||
@@ -125,6 +125,20 @@ export function AppSidebar() {
|
||||
<SidebarContent>
|
||||
<NavMain title="Geral" items={generalNavItems} />
|
||||
<NavMain title="Compras" items={purchasingItems} />
|
||||
{['admin', 'finance'].includes(
|
||||
auth.user.purchasing_role ?? '',
|
||||
) && (
|
||||
<NavMain
|
||||
title="Finanças"
|
||||
items={[
|
||||
{
|
||||
title: 'Orçamentos',
|
||||
href: '/orcamentos',
|
||||
icon: FileCheck2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{inventoryItems.length > 0 && (
|
||||
<NavMain title="Armazém" items={inventoryItems} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Head, router, useForm, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
Panel,
|
||||
PageTitle,
|
||||
FormErrors,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { money } from '@/types/procurement';
|
||||
|
||||
type Budget = {
|
||||
id: number;
|
||||
cost_center_id: number;
|
||||
year: number;
|
||||
amount_minor: number;
|
||||
reserved_minor: number;
|
||||
committed_minor: number;
|
||||
available_minor: number;
|
||||
created_by: number;
|
||||
approved_by: number | null;
|
||||
reason: string;
|
||||
includes_tax: boolean;
|
||||
};
|
||||
export default function Budgets({
|
||||
centers,
|
||||
budgets,
|
||||
}: {
|
||||
centers: { id: number; code: string; name: string }[];
|
||||
budgets: Budget[];
|
||||
}) {
|
||||
const { auth } = usePage().props;
|
||||
const form = useForm({
|
||||
cost_center_id: '',
|
||||
year: new Date().getFullYear(),
|
||||
amount: '',
|
||||
reason: '',
|
||||
includes_tax: '',
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Head title="Orçamentos" />
|
||||
<div className="mx-auto grid w-full max-w-6xl gap-6 p-6">
|
||||
<PageTitle
|
||||
eyebrow="SGC · Finanças"
|
||||
title="Orçamentos"
|
||||
description="Dotação anual por empresa e centro de custo, em MZN. A empresa define a base de IVA."
|
||||
/>
|
||||
<p className="rounded-lg border border-amber-300 p-4 text-sm">
|
||||
Desenvolvimento. O orçamento continua sem validação
|
||||
operacional. Centros sem dotação continuam no circuito
|
||||
anterior, sem controlo orçamental. Facturas e pagamentos
|
||||
ainda não implementados.
|
||||
</p>
|
||||
<Panel
|
||||
title="Preparar dotação"
|
||||
description="Outra pessoa de Administração ou Finanças deve aprovar. Não existem alterações ou reforços nesta etapa."
|
||||
>
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.transform((data) => ({
|
||||
...data,
|
||||
includes_tax: data.includes_tax === '1',
|
||||
}));
|
||||
form.post('/orcamentos');
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
<label>
|
||||
Base de controlo
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={form.data.includes_tax}
|
||||
onChange={(e) =>
|
||||
form.setData('includes_tax', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Decisão da empresa</option>
|
||||
<option value="1">Com IVA</option>
|
||||
<option value="0">Sem IVA</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Centro de custo
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={form.data.cost_center_id}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'cost_center_id',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Seleccionar</option>
|
||||
{centers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.code} · {c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Exercício
|
||||
<Input
|
||||
required
|
||||
type="number"
|
||||
min="2026"
|
||||
max="2100"
|
||||
value={form.data.year}
|
||||
onChange={(e) =>
|
||||
form.setData('year', Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Dotação com IVA (MZN)
|
||||
<Input
|
||||
required
|
||||
inputMode="decimal"
|
||||
value={form.data.amount}
|
||||
onChange={(e) =>
|
||||
form.setData('amount', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Fundamento
|
||||
<textarea
|
||||
required
|
||||
className="w-full rounded border p-3"
|
||||
value={form.data.reason}
|
||||
onChange={(e) =>
|
||||
form.setData('reason', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Button disabled={form.processing}>
|
||||
Guardar para aprovação independente
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
{budgets.map((b) => (
|
||||
<Panel
|
||||
key={b.id}
|
||||
title={`${centers.find((c) => c.id === b.cost_center_id)?.code ?? b.cost_center_id} · ${b.year}`}
|
||||
>
|
||||
<p>
|
||||
{b.approved_by
|
||||
? 'Aprovado'
|
||||
: 'Pendente de aprovação'}{' '}
|
||||
· Dotação: {money(b.amount_minor)}
|
||||
</p>
|
||||
<p>
|
||||
Reservado: {money(b.reserved_minor)} · Comprometido:{' '}
|
||||
{money(b.committed_minor)} · Disponível:{' '}
|
||||
{money(b.available_minor)}
|
||||
</p>
|
||||
<p className="my-3">{b.reason}</p>
|
||||
{!b.approved_by && b.created_by !== auth.user.id && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
router.post(`/orcamentos/${b.id}/aprovar`)
|
||||
}
|
||||
>
|
||||
Aprovar dotação
|
||||
</Button>
|
||||
)}
|
||||
</Panel>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\BudgetController;
|
||||
use App\Http\Controllers\CompanyController;
|
||||
use App\Http\Controllers\ConflictDeclarationController;
|
||||
use App\Http\Controllers\EquipmentController;
|
||||
@@ -23,6 +24,9 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::post('empresa/iniciar', [CompanyController::class, 'store'])->name('company.store');
|
||||
|
||||
Route::middleware(EnsureCompanyIsConfigured::class)->group(function () {
|
||||
Route::get('orcamentos', [BudgetController::class, 'index'])->name('budgets.index');
|
||||
Route::post('orcamentos', [BudgetController::class, 'store'])->name('budgets.store');
|
||||
Route::post('orcamentos/{budget}/aprovar', [BudgetController::class, 'approve'])->name('budgets.approve');
|
||||
Route::get('equipamentos', [EquipmentController::class, 'index'])->name('equipment.index');
|
||||
Route::post('equipamentos/{kind}', [EquipmentController::class, 'store'])->name('equipment.store');
|
||||
Route::post('requisicoes/{requisition}/validacao-tecnica', [RequisitionController::class, 'technical'])->name('requisitions.technical');
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Budget;
|
||||
use App\Models\BudgetEntry;
|
||||
use App\Models\User;
|
||||
use App\Services\BudgetWorkflow;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture();
|
||||
$f = $this->f;
|
||||
$this->author = User::factory()->create(['tenant_id' => $f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
|
||||
$this->approver = User::factory()->create(['tenant_id' => $f['tenant']->id, 'purchasing_role' => PurchasingRole::Admin]);
|
||||
$this->actingAs($this->author);
|
||||
$this->budget = Budget::query()->create(['tenant_id' => $f['tenant']->id, 'cost_center_id' => $f['r']->cost_center_id, 'year' => now()->year, 'amount_minor' => 20000, 'includes_tax' => true, 'reason' => 'Dotação fictícia', 'created_by' => $this->author->id]);
|
||||
});
|
||||
|
||||
function budgetRecord(object $test, string $kind, int $amount = 0, ?int $subtotal = null): mixed
|
||||
{
|
||||
return DB::transaction(function () use ($test, $kind, $amount, $subtotal) {
|
||||
app(ConflictGuard::class)->lockCompany($test->author);
|
||||
|
||||
return app(BudgetWorkflow::class)->record($test->author, $test->f['r'], $kind, $amount, $subtotal);
|
||||
});
|
||||
}
|
||||
|
||||
test('budget requires independent approval and records only once', function () {
|
||||
$this->post('/orcamentos/'.$this->budget->id.'/aprovar')->assertForbidden();
|
||||
$this->actingAs($this->approver)->post('/orcamentos/'.$this->budget->id.'/aprovar')->assertRedirect();
|
||||
$this->post('/orcamentos/'.$this->budget->id.'/aprovar')->assertRedirect();
|
||||
expect($this->budget->refresh()->approved_by)->toBe($this->approver->id);
|
||||
});
|
||||
|
||||
test('pending allocation blocks reservation', function () {
|
||||
expect(fn () => budgetRecord($this, 'reserve', 10000))->toThrow(ValidationException::class);
|
||||
expect(BudgetEntry::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('reserve award and commitment do not count spending twice', function () {
|
||||
$this->budget->update(['approved_by' => $this->approver->id]);
|
||||
budgetRecord($this, 'reserve', 10000);
|
||||
budgetRecord($this, 'reserve', 10000);
|
||||
budgetRecord($this, 'award', 15000);
|
||||
budgetRecord($this, 'commit', 15000);
|
||||
budgetRecord($this, 'commit', 15000);
|
||||
expect((int) BudgetEntry::query()->sum('reserved_minor'))->toBe(0)
|
||||
->and((int) BudgetEntry::query()->sum('committed_minor'))->toBe(15000)
|
||||
->and(BudgetEntry::query()->count())->toBe(3);
|
||||
});
|
||||
|
||||
test('over budget adjustments roll back without losing reservation', function () {
|
||||
$this->budget->update(['approved_by' => $this->approver->id]);
|
||||
budgetRecord($this, 'reserve', 10000);
|
||||
expect(fn () => budgetRecord($this, 'award', 20001))->toThrow(ValidationException::class);
|
||||
expect((int) BudgetEntry::query()->sum('reserved_minor'))->toBe(10000);
|
||||
});
|
||||
|
||||
test('rejection releases reservation once and history is immutable', function () {
|
||||
$this->budget->update(['approved_by' => $this->approver->id]);
|
||||
budgetRecord($this, 'reserve', 10000);
|
||||
budgetRecord($this, 'release');
|
||||
budgetRecord($this, 'release');
|
||||
expect((int) BudgetEntry::query()->sum('reserved_minor'))->toBe(0);
|
||||
expect(fn () => BudgetEntry::query()->firstOrFail()->delete())->toThrow(LogicException::class);
|
||||
});
|
||||
|
||||
test('company chooses its own tax basis', function () {
|
||||
$this->budget->update(['approved_by' => $this->approver->id, 'includes_tax' => false]);
|
||||
budgetRecord($this, 'reserve', 11600, 10000);
|
||||
expect((int) BudgetEntry::query()->sum('reserved_minor'))->toBe(10000);
|
||||
});
|
||||
|
||||
test('foreign company cannot read or approve allocation', function () {
|
||||
$other = receiptFixture();
|
||||
$user = User::factory()->create(['tenant_id' => $other['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
|
||||
$this->actingAs($user)->post('/orcamentos/'.$this->budget->id.'/aprovar')->assertNotFound();
|
||||
expect(Budget::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('warehouse cannot prepare or consult budgets', function () {
|
||||
$this->actingAs($this->f['receiver'])->get('/orcamentos')->assertForbidden();
|
||||
$this->post('/orcamentos', [])->assertForbidden();
|
||||
});
|
||||
|
||||
test('allocation amounts and tax basis are mandatory company decisions', function () {
|
||||
$this->budget->delete();
|
||||
$data = ['cost_center_id' => $this->f['r']->cost_center_id, 'year' => now()->year, 'amount' => '123.45', 'reason' => 'Decisão fictícia'];
|
||||
$this->postJson('/orcamentos', $data)->assertUnprocessable()->assertJsonValidationErrors('includes_tax');
|
||||
$this->post('/orcamentos', [...$data, 'includes_tax' => false])->assertRedirect()->assertSessionHasNoErrors();
|
||||
expect(Budget::query()->firstOrFail()->amount_minor)->toBe(12345)
|
||||
->and(Budget::query()->firstOrFail()->includes_tax)->toBeFalse();
|
||||
});
|
||||
|
||||
test('failed audit rolls back budget reservation', function () {
|
||||
$this->budget->update(['approved_by' => $this->approver->id]);
|
||||
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->andThrow(new RuntimeException('audit unavailable'));
|
||||
expect(fn () => budgetRecord($this, 'reserve', 10000))->toThrow(RuntimeException::class);
|
||||
expect(BudgetEntry::query()->count())->toBe(0);
|
||||
});
|
||||
Reference in New Issue
Block a user