Implementa regras de investimento por empresa com dupla aprovacao
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\InvestmentPolicy;
|
||||
use App\Models\InvestmentPolicyReview;
|
||||
use App\Services\InvestmentWorkflow;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
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);
|
||||
$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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/** @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property int $created_by
|
||||
* @property int $revision
|
||||
* @property string $status
|
||||
*/
|
||||
class InvestmentPolicy extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $attributes = ['status' => '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.'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AppendOnlyInventoryRecord;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class InvestmentPolicyReview extends Model
|
||||
{
|
||||
use AppendOnlyInventoryRecord, BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
* @property string $comparison_hash
|
||||
* @property array<string, mixed> $approval_snapshot
|
||||
* @property list<array<string, mixed>> $decision_matrix
|
||||
* @property array<string,mixed>|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<PurchaseAwardApproval, $this> */
|
||||
|
||||
@@ -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.']);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Budget;
|
||||
use App\Models\BudgetEntry;
|
||||
use App\Models\InvestmentPolicy;
|
||||
use App\Models\InvestmentPolicyReview;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\TechnicalReview;
|
||||
use App\Models\User;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class InvestmentWorkflow
|
||||
{
|
||||
private function admin(User $user): void
|
||||
{
|
||||
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->purchasing_role === PurchasingRole::Admin, 403);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $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<string,mixed>|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];
|
||||
}
|
||||
}
|
||||
@@ -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<string>
|
||||
*/
|
||||
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);
|
||||
|
||||
@@ -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.']);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?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('investment_policies', function (Blueprint $table) {
|
||||
$table->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.');
|
||||
}
|
||||
};
|
||||
+12
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -13,5 +13,6 @@ export const previewPaths: Record<string, string> = {
|
||||
bank: '/consulta/dados-bancarios',
|
||||
payments: '/consulta/pagamentos',
|
||||
reinforcements: '/consulta/reforcos',
|
||||
investment: '/consulta/investimento',
|
||||
appearance: '/consulta/aparencia',
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
<label>
|
||||
Classificação da dotação
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={form.data.kind}
|
||||
onChange={(e) =>
|
||||
form.setData('kind', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Decisão da empresa</option>
|
||||
<option value="operating">
|
||||
Despesa corrente
|
||||
</option>
|
||||
<option value="investment">
|
||||
Investimento (processos com equipamento)
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-sm">
|
||||
Cada centro/exercício admite uma dotação. Para
|
||||
separar despesa corrente de investimento no mesmo
|
||||
exercício, use centros distintos.
|
||||
</p>
|
||||
<label>
|
||||
Base de controlo
|
||||
<select
|
||||
@@ -179,6 +205,12 @@ export default function Budgets({
|
||||
· Dotação: {money(b.amount_minor)} · Base:{' '}
|
||||
{b.includes_tax ? 'com IVA' : 'sem IVA'}
|
||||
</p>
|
||||
<p>
|
||||
Classificação:{' '}
|
||||
{b.kind === 'investment'
|
||||
? 'Investimento'
|
||||
: 'Despesa corrente'}
|
||||
</p>
|
||||
<p>
|
||||
Reservado: {money(b.reserved_minor)} · Comprometido:{' '}
|
||||
{money(b.committed_minor)} · Consumido:{' '}
|
||||
|
||||
@@ -151,10 +151,10 @@ export default function EquipmentPage({
|
||||
O responsável da área (chefe do departamento), distinto
|
||||
do solicitante, valida tecnicamente a versão do pedido
|
||||
de equipamento. Finanças é obrigatória para qualquer
|
||||
valor. A adjudicação e emissão de equipamento permanecem
|
||||
bloqueadas: o limite próprio de investimento e a sua
|
||||
aplicação exigem decisão da empresa. Uma aprovação
|
||||
técnica não resolve essa pendência.
|
||||
valor. A adjudicação e emissão de equipamento exigem
|
||||
regra de investimento vigente, com duas aprovações
|
||||
administrativas, e reserva em dotação de investimento. A
|
||||
validação técnica não substitui estes controlos.
|
||||
</p>
|
||||
<Link
|
||||
className="mt-3 inline-block text-sm font-semibold text-teal-700"
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { Head, useForm, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
FormErrors,
|
||||
Panel,
|
||||
PageTitle,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { money } from '@/types/procurement';
|
||||
|
||||
type Policy = {
|
||||
id: number;
|
||||
created_by: number;
|
||||
revision: number;
|
||||
status: string;
|
||||
mode: string;
|
||||
scope: string;
|
||||
currency: string;
|
||||
ceiling_minor: number;
|
||||
includes_tax: boolean;
|
||||
valid_from: string;
|
||||
valid_until: string;
|
||||
reason: string;
|
||||
};
|
||||
type Review = {
|
||||
id: number;
|
||||
investment_policy_id: number;
|
||||
reviewed_by: number;
|
||||
decision: string;
|
||||
reason: string;
|
||||
};
|
||||
function Proposal({ previousId }: { previousId: number }) {
|
||||
const form = useForm({
|
||||
previous_id: previousId,
|
||||
mode: '',
|
||||
scope: '',
|
||||
currency: '',
|
||||
ceiling: '',
|
||||
includes_tax: '',
|
||||
valid_from: '',
|
||||
valid_until: '',
|
||||
reason: '',
|
||||
});
|
||||
return (
|
||||
<Panel
|
||||
title="Propor nova versão"
|
||||
description="São necessárias duas aprovações de administradores distintos do proponente. Valores e vigência são decisões da empresa."
|
||||
>
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.transform((d) => ({
|
||||
...d,
|
||||
includes_tax: d.includes_tax === '1',
|
||||
}));
|
||||
form.post('/regras-investimento');
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
<label>
|
||||
Significado do limite
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={form.data.mode}
|
||||
onChange={(e) => form.setData('mode', e.target.value)}
|
||||
>
|
||||
<option value="">Decisão explícita</option>
|
||||
<option value="ceiling">
|
||||
Tecto absoluto: acima do limite, bloquear
|
||||
</option>
|
||||
<option disabled>
|
||||
Aprovação adicional: circuito ainda por definir
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Âmbito
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={form.data.scope}
|
||||
onChange={(e) => form.setData('scope', e.target.value)}
|
||||
>
|
||||
<option value="">Decisão explícita</option>
|
||||
<option value="process_total">
|
||||
Total do processo que inclua equipamento
|
||||
</option>
|
||||
<option disabled>
|
||||
Agregação por projecto: ainda não suportada
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Moeda
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={form.data.currency}
|
||||
onChange={(e) =>
|
||||
form.setData('currency', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Seleccionar</option>
|
||||
<option value="MZN">MZN</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Tecto (MZN)
|
||||
<Input
|
||||
required
|
||||
inputMode="decimal"
|
||||
value={form.data.ceiling}
|
||||
onChange={(e) =>
|
||||
form.setData('ceiling', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Base de IVA
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={form.data.includes_tax}
|
||||
onChange={(e) =>
|
||||
form.setData('includes_tax', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Decisão explícita</option>
|
||||
<option value="1">Com IVA</option>
|
||||
<option value="0">Sem IVA</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Início da vigência
|
||||
<Input
|
||||
required
|
||||
type="date"
|
||||
value={form.data.valid_from}
|
||||
onChange={(e) =>
|
||||
form.setData('valid_from', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Fim da vigência
|
||||
<Input
|
||||
required
|
||||
type="date"
|
||||
value={form.data.valid_until}
|
||||
onChange={(e) =>
|
||||
form.setData('valid_until', 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}>
|
||||
Propor para duas aprovações
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
function Decision({ id }: { id: number }) {
|
||||
const form = useForm({ decision: 'approved', reason: '' });
|
||||
return (
|
||||
<form
|
||||
className="mt-4 grid gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.post(`/regras-investimento/${id}/decidir`);
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
<label>
|
||||
Decisão
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.decision}
|
||||
onChange={(e) => form.setData('decision', e.target.value)}
|
||||
>
|
||||
<option value="approved">Aprovar esta versão</option>
|
||||
<option value="rejected">Rejeitar esta versão</option>
|
||||
</select>
|
||||
</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 decisão imutável</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
export default function Investment({
|
||||
policies,
|
||||
reviews,
|
||||
currentPolicyId,
|
||||
canManage,
|
||||
}: {
|
||||
policies: Policy[];
|
||||
reviews: Review[];
|
||||
currentPolicyId: number | null;
|
||||
canManage: boolean;
|
||||
}) {
|
||||
const { auth } = usePage().props;
|
||||
return (
|
||||
<>
|
||||
<Head title="Regras de investimento" />
|
||||
<div className="mx-auto grid w-full max-w-6xl gap-6 p-6">
|
||||
<PageTitle
|
||||
eyebrow="SGC · Regras da empresa"
|
||||
title="Investimento em equipamentos"
|
||||
description="Tecto explícito e versionado, com duas aprovações administrativas. Não substitui validação técnica, Finanças ou disponibilidade orçamental."
|
||||
/>
|
||||
<p className="rounded border border-amber-300 p-4 text-sm">
|
||||
Sem regra vigente, a adjudicação e emissão ficam bloqueadas.
|
||||
Não há limites automáticos, dispensa de cotações ou
|
||||
autorização para fraccionar aquisições. Os documentos
|
||||
continuam de desenvolvimento.
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
Uma proposta pendente não substitui a regra aprovada. Uma
|
||||
nova regra aprovada aplica-se a partir do início da sua
|
||||
vigência. Quando uma regra mais recente entra em vigor, não
|
||||
se regressa a versões anteriores se ela expirar.
|
||||
Adjudicações guardam a versão aplicável; mudanças
|
||||
posteriores exigem revisão do processo.
|
||||
</p>
|
||||
{canManage && (
|
||||
<Proposal
|
||||
key={policies[0]?.id ?? 0}
|
||||
previousId={policies[0]?.id ?? 0}
|
||||
/>
|
||||
)}
|
||||
{policies.map((p) => (
|
||||
<Panel
|
||||
key={p.id}
|
||||
title={`Versão ${p.revision} · ${p.status === 'approved' ? 'Aprovada' : p.status === 'rejected' ? 'Rejeitada' : 'Pendente'}`}
|
||||
>
|
||||
{currentPolicyId === p.id && (
|
||||
<p className="mb-3 font-semibold text-teal-700">
|
||||
Regra vigente seleccionada
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
Tecto {money(p.ceiling_minor)} ·{' '}
|
||||
{p.includes_tax ? 'com IVA' : 'sem IVA'} · total do
|
||||
processo com equipamento
|
||||
</p>
|
||||
<p>
|
||||
Vigência: {p.valid_from} a {p.valid_until}
|
||||
</p>
|
||||
<p className="my-3 whitespace-pre-wrap">{p.reason}</p>
|
||||
{reviews
|
||||
.filter((r) => r.investment_policy_id === p.id)
|
||||
.map((r) => (
|
||||
<p
|
||||
key={r.id}
|
||||
className="my-2 rounded border p-3 text-sm"
|
||||
>
|
||||
{r.decision === 'approved'
|
||||
? 'Aprovação'
|
||||
: 'Rejeição'}{' '}
|
||||
· Administrador #{r.reviewed_by} ·{' '}
|
||||
{r.reason}
|
||||
</p>
|
||||
))}
|
||||
{canManage &&
|
||||
p.status === 'pending' &&
|
||||
p.id === policies[0]?.id &&
|
||||
p.created_by !== auth.user.id &&
|
||||
!reviews.some(
|
||||
(r) =>
|
||||
r.investment_policy_id === p.id &&
|
||||
r.reviewed_by === auth.user.id,
|
||||
) && <Decision id={p.id} />}
|
||||
</Panel>
|
||||
))}
|
||||
{policies.length === 0 && (
|
||||
<p>Sem regras propostas nesta empresa.</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -122,6 +122,7 @@ const navigation = [
|
||||
{ id: 'bank', title: 'Dados bancários', icon: ShieldCheck },
|
||||
{ id: 'payments', title: 'Pagamentos externos', icon: FileText },
|
||||
{ id: 'reinforcements', title: 'Reforços orçamentais', icon: FileText },
|
||||
{ id: 'investment', title: 'Regras de investimento', icon: ShieldCheck },
|
||||
{ id: 'appearance', title: 'Cores e componentes', icon: Palette },
|
||||
];
|
||||
type Detail = { title: string; subtitle: string; rows: [string, string][] };
|
||||
@@ -132,7 +133,7 @@ const navigationGroups = [
|
||||
{ title: 'Controlo', ids: ['conflicts'] },
|
||||
{
|
||||
title: 'Finanças',
|
||||
ids: ['invoices', 'bank', 'payments', 'reinforcements'],
|
||||
ids: ['invoices', 'bank', 'payments', 'reinforcements', 'investment'],
|
||||
},
|
||||
{ title: 'Apresentação', ids: ['appearance'] },
|
||||
];
|
||||
@@ -669,6 +670,41 @@ export default function Preview({
|
||||
)}
|
||||
{screen === 'operations' && <OperationsDemo />}
|
||||
{screen === 'equipment' && <EquipmentDemo />}
|
||||
{screen === 'investment' && (
|
||||
<Panel
|
||||
title="Regra de investimento · exemplo de circuito"
|
||||
description="Sem limites predefinidos, consultas ou gravações de negócio."
|
||||
>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p>
|
||||
A empresa escolhe o tecto por processo,
|
||||
a base com/sem IVA e as datas. Dois
|
||||
administradores distintos do proponente
|
||||
aprovam a versão.
|
||||
</p>
|
||||
<p>
|
||||
Equipamento exige também validação
|
||||
técnica, Finanças para qualquer valor e
|
||||
reserva em dotação de investimento. O
|
||||
tecto aplica-se ao total do processo que
|
||||
contenha equipamento.
|
||||
</p>
|
||||
<p>
|
||||
Sem regra vigente, ou acima do tecto, a
|
||||
adjudicação fica bloqueada. A ordem
|
||||
conserva a versão aplicada; alterações
|
||||
posteriores não reescrevem documentos
|
||||
emitidos.
|
||||
</p>
|
||||
<p>
|
||||
Aprovação adicional, agregação por
|
||||
projecto e detecção automática de
|
||||
fraccionamento não fazem parte desta
|
||||
etapa.
|
||||
</p>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
{screen === 'reinforcements' && (
|
||||
<Panel
|
||||
title="Reforço orçamental · exemplo fictício"
|
||||
|
||||
@@ -63,6 +63,29 @@ export default function OrderShow({
|
||||
' Regras de aprovação provisórias.'}
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{snapshot.investment_rules && (
|
||||
<Panel title="Regra de investimento congelada">
|
||||
<p>
|
||||
Versão {snapshot.investment_rules.revision} ·
|
||||
Tecto{' '}
|
||||
{money(snapshot.investment_rules.ceiling_minor)}{' '}
|
||||
·{' '}
|
||||
{snapshot.investment_rules.includes_tax
|
||||
? 'com IVA'
|
||||
: 'sem IVA'}
|
||||
</p>
|
||||
<p>
|
||||
Vigência: {snapshot.investment_rules.valid_from}{' '}
|
||||
a {snapshot.investment_rules.valid_until}
|
||||
</p>
|
||||
{snapshot.equipment_requests?.map((line) => (
|
||||
<p key={line.id} className="mt-2">
|
||||
{line.description} · Área:{' '}
|
||||
{line.production_area}
|
||||
</p>
|
||||
))}
|
||||
</Panel>
|
||||
)}
|
||||
<Panel title="Fornecedor">
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div>
|
||||
|
||||
@@ -295,9 +295,9 @@ export default function RequisitionForm({
|
||||
}
|
||||
>
|
||||
<option value="equipment">
|
||||
Equipamento (emissão
|
||||
bloqueada até definição da
|
||||
regra de investimento)
|
||||
Equipamento (exige regra e
|
||||
dotação de investimento
|
||||
aprovadas)
|
||||
</option>
|
||||
<option value="spare_part">
|
||||
Peça sobressalente
|
||||
|
||||
@@ -77,11 +77,12 @@ export default function Show({
|
||||
<p className="mb-4 text-sm">
|
||||
Responsável da área, distinto do solicitante.
|
||||
Finanças é obrigatória para qualquer valor. A
|
||||
adjudicação e a emissão permanecem bloqueadas
|
||||
enquanto o limite próprio de investimento e a sua
|
||||
regra de aplicação não forem definidos. O orçamento
|
||||
ainda não é validado; documentos de desenvolvimento
|
||||
não autorizam compras reais.
|
||||
adjudicação e a emissão exigem regra de investimento
|
||||
vigente, com duas aprovações administrativas, e
|
||||
reserva em dotação de investimento. Sem estas
|
||||
condições permanecem bloqueadas. O orçamento ainda
|
||||
não é validado; documentos de desenvolvimento não
|
||||
autorizam compras reais.
|
||||
</p>
|
||||
{technical?.reviews.map((t) => (
|
||||
<p
|
||||
|
||||
@@ -66,6 +66,20 @@ export type PurchaseOrder = {
|
||||
total_minor: number;
|
||||
items: OrderItem[];
|
||||
snapshot: {
|
||||
investment_rules?: {
|
||||
id: number;
|
||||
revision: number;
|
||||
ceiling_minor: number;
|
||||
includes_tax: boolean;
|
||||
valid_from: string;
|
||||
valid_until: string;
|
||||
reviewer_ids: number[];
|
||||
} | null;
|
||||
equipment_requests?: {
|
||||
id: number;
|
||||
description: string;
|
||||
production_area: string;
|
||||
}[];
|
||||
company: { name: string };
|
||||
department: { name: string };
|
||||
cost_center: { code: string; name: string };
|
||||
|
||||
@@ -30,6 +30,11 @@
|
||||
<div><h2>Condições</h2>Entrega: {{ $snapshot['award']['delivery_date'] }}<br><span class="pre">{{ $snapshot['award']['delivery_location'] }}</span><br>Pagamento: {{ $snapshot['quotation']['payment_terms'] }}<br>Proposta: {{ $snapshot['quotation']['reference'] }}<br>{{ $snapshot['department']['name'] }} · {{ $snapshot['cost_center']['code'] }}<br>Emitente: {{ $snapshot['issuer']['name'] }}<br>Emissão: {{ $order->issued_at->format('Y-m-d H:i:s') }}</div>
|
||||
</div>
|
||||
<h2>Artigos e valores</h2>
|
||||
@if(!empty($snapshot['investment_rules']))
|
||||
<h2>Regra de investimento congelada</h2>
|
||||
<p>Versão {{ $snapshot['investment_rules']['revision'] }} · Tecto {{ $money($snapshot['investment_rules']['ceiling_minor']) }} · {{ $snapshot['investment_rules']['includes_tax'] ? 'com IVA' : 'sem IVA' }} · Vigência {{ $snapshot['investment_rules']['valid_from'] }} a {{ $snapshot['investment_rules']['valid_until'] }}.</p>
|
||||
@foreach($snapshot['equipment_requests'] ?? [] as $context)<p class="pre">{{ $context['description'] }} · Área: {{ $context['production_area'] }}</p>@endforeach
|
||||
@endif
|
||||
<table><thead><tr><th>Descrição</th><th>Quantidade</th><th>Preço sem IVA</th><th>IVA</th><th>Total com IVA</th></tr></thead><tbody>
|
||||
@foreach($order->items as $item)
|
||||
<tr><td class="pre">{{ $item->description }}</td><td>{{ $item->quantity }} {{ $item->unit }}</td><td>{{ $money($item->unit_price_minor) }}</td><td>{{ $item->tax_rate }}%</td><td>{{ $money($item->total_minor) }}</td></tr>
|
||||
|
||||
+2
-1
@@ -7,7 +7,7 @@ use Inertia\Inertia;
|
||||
// Public visual consultation. No business records or authenticated identities.
|
||||
Route::get('consulta', function (Request $request) {
|
||||
$screen = $request->query('ecra', 'overview');
|
||||
$allowed = ['overview', 'requests', 'suppliers', 'quotes', 'conflicts', 'orders', 'receipts', 'stock', 'operations', 'equipment', 'invoices', 'bank', 'payments', 'reinforcements', 'appearance'];
|
||||
$allowed = ['overview', 'requests', 'suppliers', 'quotes', 'conflicts', 'orders', 'receipts', 'stock', 'operations', 'equipment', 'invoices', 'bank', 'payments', 'reinforcements', 'investment', 'appearance'];
|
||||
|
||||
return Inertia::render('preview/index', [
|
||||
'auth' => ['user' => null],
|
||||
@@ -23,6 +23,7 @@ Route::get('consulta/{screen}', function (string $screen) {
|
||||
$screens['dados-bancarios'] = 'bank';
|
||||
$screens['pagamentos'] = 'payments';
|
||||
$screens['reforcos'] = 'reinforcements';
|
||||
$screens['investimento'] = 'investment';
|
||||
abort_unless(isset($screens[$screen]), 404);
|
||||
|
||||
return Inertia::render('preview/index', ['auth' => ['user' => null], 'initialScreen' => $screens[$screen]]);
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Http\Controllers\ConflictDeclarationController;
|
||||
use App\Http\Controllers\EquipmentController;
|
||||
use App\Http\Controllers\ExternalPaymentController;
|
||||
use App\Http\Controllers\GoodsReceiptController;
|
||||
use App\Http\Controllers\InvestmentPolicyController;
|
||||
use App\Http\Controllers\InvoiceController;
|
||||
use App\Http\Controllers\PurchaseController;
|
||||
use App\Http\Controllers\QuotationController;
|
||||
@@ -28,6 +29,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('regras-investimento', [InvestmentPolicyController::class, 'index'])->name('investment.index');
|
||||
Route::post('regras-investimento', [InvestmentPolicyController::class, 'store'])->name('investment.store');
|
||||
Route::post('regras-investimento/{policy}/decidir', [InvestmentPolicyController::class, 'review'])->name('investment.review');
|
||||
Route::post('orcamentos/{budget}/reforcos', [BudgetReinforcementController::class, 'store'])->name('budgets.reinforcements.store');
|
||||
Route::post('reforcos/{reinforcement}/decidir', [BudgetReinforcementController::class, 'review'])->name('budgets.reinforcements.review');
|
||||
Route::get('pagamentos', [ExternalPaymentController::class, 'index'])->name('payments.index');
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InvestmentPolicy;
|
||||
use App\Models\User;
|
||||
use App\Services\InvestmentWorkflow;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
require __DIR__.'/../../vendor/autoload.php';
|
||||
$database = $argv[1] ?? '';
|
||||
if (! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) {
|
||||
throw new LogicException('Base de teste inválida.');
|
||||
}
|
||||
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
if (! $app->environment('testing') || DB::connection()->getDatabaseName() !== $database) {
|
||||
throw new LogicException('Worker não isolado.');
|
||||
}
|
||||
DB::select('select set_config(?, ?, false)', ['application_name', 'sgc_investment_'.$argv[4]]);
|
||||
$pid = DB::selectOne('select pg_backend_pid() as pid')->pid;
|
||||
$user = User::query()->findOrFail($argv[2]);
|
||||
auth()->guard()->setUser($user);
|
||||
$payload = json_decode(stream_get_contents(STDIN), true, 512, JSON_THROW_ON_ERROR);
|
||||
try {
|
||||
if ($payload['action'] === 'propose') {
|
||||
app(InvestmentWorkflow::class)->propose($user, $payload['data']);
|
||||
} else {
|
||||
app(InvestmentWorkflow::class)->review($user, InvestmentPolicy::query()->findOrFail($argv[3]), 'approved', 'Conferência administrativa de teste');
|
||||
}
|
||||
$status = 200;
|
||||
} catch (ValidationException) {
|
||||
$status = 422;
|
||||
}
|
||||
echo json_encode(['status' => $status, 'backend_pid' => $pid], JSON_THROW_ON_ERROR);
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\InvestmentPolicy;
|
||||
use App\Models\InvestmentPolicyReview;
|
||||
use App\Models\User;
|
||||
use App\Services\InvestmentWorkflow;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
require __DIR__.'/../../vendor/autoload.php';
|
||||
foreach (['APP_ENV' => 'testing', 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array', 'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array'] as $key => $value) {
|
||||
putenv($key.'='.$value);
|
||||
$_ENV[$key] = $_SERVER[$key] = $value;
|
||||
}
|
||||
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
require __DIR__.'/../Support/ReceiptFixture.php';
|
||||
$original = DB::connection();
|
||||
if ($original->getDriverName() !== 'pgsql') {
|
||||
throw new LogicException('Exige PostgreSQL.');
|
||||
}
|
||||
$config = $original->getConfig();
|
||||
$admin = $original->getPdo();
|
||||
$database = 'compras_receipts_test_'.bin2hex(random_bytes(6));
|
||||
$created = false;
|
||||
$processes = [];
|
||||
$report = ['tested_at_utc' => gmdate('c'), 'separate_database' => true, 'transport' => 'Independent PHP service transactions and PostgreSQL connections', 'scenarios' => []];
|
||||
try {
|
||||
$admin->exec('CREATE DATABASE "'.$database.'"');
|
||||
$created = true;
|
||||
config(['database.connections.pgsql' => [...$config, 'database' => $database, 'url' => null]]);
|
||||
DB::purge('pgsql');
|
||||
DB::setDefaultConnection('pgsql');
|
||||
Schema::clearResolvedInstance('db.schema');
|
||||
app()->forgetInstance('db.schema');
|
||||
if (DB::connection()->getDatabaseName() !== $database || Schema::getConnection()->getDatabaseName() !== $database) {
|
||||
throw new LogicException('Migração fora do isolamento recusada.');
|
||||
}
|
||||
Artisan::call('migrate', ['--database' => 'pgsql', '--force' => true]);
|
||||
$workerEnv = ['APP_ENV' => 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_URL' => '', 'DB_DATABASE' => $database, 'DB_HOST' => $config['host'], 'DB_PORT' => (string) $config['port'], 'DB_USERNAME' => $config['username'], 'DB_PASSWORD' => $config['password'], 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array'];
|
||||
foreach (['distinct_approvals', 'same_approval', 'competing_versions'] as $scenario) {
|
||||
$f = receiptFixture();
|
||||
$author = User::factory()->create(['tenant_id' => $f['tenant']->id, 'purchasing_role' => PurchasingRole::Admin]);
|
||||
$first = User::factory()->create(['tenant_id' => $author->tenant_id, 'purchasing_role' => PurchasingRole::Admin]);
|
||||
$second = User::factory()->create(['tenant_id' => $author->tenant_id, 'purchasing_role' => PurchasingRole::Admin]);
|
||||
auth()->guard()->setUser($author);
|
||||
$data = ['previous_id' => 0, 'mode' => 'ceiling', 'scope' => 'process_total', 'currency' => 'MZN', 'ceiling' => '100.00', 'includes_tax' => true, 'valid_from' => today()->format('Y-m-d'), 'valid_until' => today()->addMonth()->format('Y-m-d'), 'reason' => 'Apenas teste isolado'];
|
||||
$policy = app(InvestmentWorkflow::class)->propose($author, $data);
|
||||
$payload = ['action' => 'review'];
|
||||
$target = $policy->id;
|
||||
if ($scenario === 'same_approval') {
|
||||
$second = $first;
|
||||
} elseif ($scenario === 'competing_versions') {
|
||||
$first = $second = $author;
|
||||
$payload = ['action' => 'propose', 'data' => [...$data, 'previous_id' => $policy->id]];
|
||||
}
|
||||
$connection = DB::connection();
|
||||
$connection->beginTransaction();
|
||||
$connection->table('tenants')->where('id', $author->tenant_id)->lockForUpdate()->first();
|
||||
$processes = [];
|
||||
foreach ([$first, $second] as $index => $user) {
|
||||
$process = new Process([PHP_BINARY, __DIR__.'/investment-worker.php', $database, (string) $user->id, (string) $target, $index === 0 ? 'A' : 'B'], base_path(), $workerEnv);
|
||||
$process->setInput(json_encode($payload, JSON_THROW_ON_ERROR));
|
||||
$process->setTimeout(40);
|
||||
$process->start();
|
||||
$processes[] = $process;
|
||||
}
|
||||
$statement = $admin->prepare("SELECT count(*) FROM pg_stat_activity WHERE datname = ? AND application_name IN ('sgc_investment_A','sgc_investment_B') AND wait_event_type = 'Lock'");
|
||||
$deadline = microtime(true) + 20;
|
||||
do {
|
||||
$statement->execute([$database]);
|
||||
$waits = (int) $statement->fetchColumn();
|
||||
if ($waits === 2) {
|
||||
break;
|
||||
}
|
||||
foreach ($processes as $process) {
|
||||
if (! $process->isRunning()) {
|
||||
throw new RuntimeException('Worker terminou antes do bloqueio: '.$process->getErrorOutput());
|
||||
}
|
||||
}
|
||||
usleep(20000);
|
||||
} while (microtime(true) < $deadline);
|
||||
$connection->commit();
|
||||
if ($waits !== 2) {
|
||||
throw new RuntimeException('Sem sobreposição de bloqueios.');
|
||||
}
|
||||
$results = [];
|
||||
foreach ($processes as $process) {
|
||||
$process->wait();
|
||||
if (! $process->isSuccessful()) {
|
||||
throw new RuntimeException('Falha do worker: '.$process->getErrorOutput());
|
||||
}
|
||||
$results[] = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
$statuses = array_column($results, 'status');
|
||||
sort($statuses);
|
||||
$state = InvestmentPolicy::query()->latest('revision')->firstOrFail()->status;
|
||||
$count = InvestmentPolicyReview::query()->count();
|
||||
if ($statuses !== ($scenario === 'competing_versions' ? [200, 422] : [200, 200]) || $state !== ($scenario === 'distinct_approvals' ? 'approved' : 'pending') || $count !== match ($scenario) {
|
||||
'distinct_approvals' => 2, 'same_approval' => 1, default => 0
|
||||
} || InvestmentPolicy::query()->count() !== ($scenario === 'competing_versions' ? 2 : 1) || count(array_unique(array_column($results, 'backend_pid'))) !== 2) {
|
||||
throw new RuntimeException('Resultado concorrente de regras inesperado.');
|
||||
}
|
||||
$report['scenarios'][] = ['name' => $scenario, 'passed' => true, 'overlapping_lock_waits' => $waits, 'state' => $state, 'review_count' => $count];
|
||||
}
|
||||
$report['passed'] = true;
|
||||
} finally {
|
||||
foreach ($processes as $process) {
|
||||
if ($process->isRunning()) {
|
||||
$process->stop();
|
||||
}
|
||||
}
|
||||
if (isset($connection) && $connection->transactionLevel() > 0) {
|
||||
$connection->rollBack();
|
||||
}
|
||||
DB::disconnect('pgsql');
|
||||
if ($created && $database !== $original->getDatabaseName() && preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) {
|
||||
$admin->exec('DROP DATABASE "'.$database.'"');
|
||||
$report['test_database_dropped'] = true;
|
||||
}
|
||||
}
|
||||
file_put_contents(base_path('docs/investimento-concorrencia-postgresql.json'), json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
|
||||
echo json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\InvestmentPolicy;
|
||||
use App\Models\InvestmentPolicyReview;
|
||||
use App\Models\User;
|
||||
use App\Services\InvestmentWorkflow;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
require_once __DIR__.'/../Support/InvestmentFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture();
|
||||
$this->admins = [];
|
||||
for ($index = 0; $index < 3; $index++) {
|
||||
$this->admins[] = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Admin]);
|
||||
}
|
||||
$this->actingAs($this->admins[0]);
|
||||
$this->data = ['previous_id' => 0, 'mode' => 'ceiling', 'scope' => 'process_total', 'currency' => 'MZN', 'ceiling' => '100.00', 'includes_tax' => true, 'valid_from' => today()->format('Y-m-d'), 'valid_until' => today()->addMonth()->format('Y-m-d'), 'reason' => 'Decisão explícita fictícia'];
|
||||
});
|
||||
|
||||
function proposeInvestmentTest(object $test): InvestmentPolicy
|
||||
{
|
||||
$test->actingAs($test->admins[0]);
|
||||
|
||||
return app(InvestmentWorkflow::class)->propose($test->admins[0], $test->data);
|
||||
}
|
||||
|
||||
function reviewInvestmentTest(object $test, InvestmentPolicy $policy, int $actor, string $decision = 'approved'): void
|
||||
{
|
||||
$test->actingAs($test->admins[$actor]);
|
||||
app(InvestmentWorkflow::class)->review($test->admins[$actor], $policy, $decision, 'Conferência administrativa de teste');
|
||||
}
|
||||
|
||||
test('investment policy requires two independent administrators without counting repeats', function () {
|
||||
$policy = proposeInvestmentTest($this);
|
||||
reviewInvestmentTest($this, $policy, 1);
|
||||
reviewInvestmentTest($this, $policy, 1);
|
||||
expect($policy->refresh()->status)->toBe('pending')->and(InvestmentPolicyReview::query()->count())->toBe(1);
|
||||
reviewInvestmentTest($this, $policy, 2);
|
||||
expect($policy->refresh()->status)->toBe('approved')->and(app(InvestmentWorkflow::class)->requireCurrent()->id)->toBe($policy->id);
|
||||
});
|
||||
|
||||
test('policy author cannot approve own proposal', function () {
|
||||
$policy = proposeInvestmentTest($this);
|
||||
$this->post('/regras-investimento/'.$policy->id.'/decidir', ['decision' => 'approved', 'reason' => 'Teste'])->assertForbidden();
|
||||
});
|
||||
|
||||
test('policy parameters and decisions cannot be rewritten', function () {
|
||||
$policy = proposeInvestmentTest($this);
|
||||
expect(fn () => $policy->update(['ceiling_minor' => 1]))->toThrow(LogicException::class);
|
||||
reviewInvestmentTest($this, $policy, 1, 'rejected');
|
||||
expect(fn () => reviewInvestmentTest($this, $policy, 2))->toThrow(ValidationException::class);
|
||||
expect(fn () => InvestmentPolicyReview::query()->firstOrFail()->delete())->toThrow(LogicException::class);
|
||||
});
|
||||
|
||||
test('an unapproved draft does not replace an approved company rule', function () {
|
||||
$approved = publishInvestmentFixture($this->f['tenant']->id);
|
||||
$this->data['previous_id'] = $approved->id;
|
||||
$draft = proposeInvestmentTest($this);
|
||||
expect($draft->status)->toBe('pending')->and(app(InvestmentWorkflow::class)->requireCurrent()->id)->toBe($approved->id);
|
||||
});
|
||||
|
||||
test('new rule starts on its explicit date and expiry cannot restore an older more permissive rule', function () {
|
||||
$old = publishInvestmentFixture($this->f['tenant']->id, '500.00');
|
||||
$this->data['previous_id'] = $old->id;
|
||||
$this->data['valid_from'] = today()->addDay()->format('Y-m-d');
|
||||
$this->data['valid_until'] = today()->addDays(2)->format('Y-m-d');
|
||||
$new = proposeInvestmentTest($this);
|
||||
reviewInvestmentTest($this, $new, 1);
|
||||
reviewInvestmentTest($this, $new, 2);
|
||||
expect(app(InvestmentWorkflow::class)->requireCurrent()->id)->toBe($old->id);
|
||||
$this->travel(1)->days();
|
||||
expect(app(InvestmentWorkflow::class)->requireCurrent()->id)->toBe($new->id);
|
||||
$this->travel(3)->days();
|
||||
expect(fn () => app(InvestmentWorkflow::class)->requireCurrent())->toThrow(ValidationException::class);
|
||||
$this->travelBack();
|
||||
});
|
||||
|
||||
test('stale proposals and decisions on superseded drafts are refused', function () {
|
||||
$first = proposeInvestmentTest($this);
|
||||
expect(fn () => proposeInvestmentTest($this))->toThrow(ValidationException::class);
|
||||
$this->data['previous_id'] = $first->id;
|
||||
proposeInvestmentTest($this);
|
||||
expect(fn () => reviewInvestmentTest($this, $first, 1))->toThrow(ValidationException::class);
|
||||
});
|
||||
|
||||
test('unsupported limit semantics cannot be configured by the browser', function (string $field, string $value) {
|
||||
$this->post('/regras-investimento', [...$this->data, $field => $value])->assertSessionHasErrors($field);
|
||||
expect(InvestmentPolicy::query()->count())->toBe(0);
|
||||
})->with([['mode', 'additional_approval'], ['scope', 'project'], ['currency', 'USD']]);
|
||||
|
||||
test('finance and procurement may read company rules but cannot change them', function (PurchasingRole $role) {
|
||||
$user = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => $role]);
|
||||
$this->actingAs($user)->get('/regras-investimento')->assertOk();
|
||||
$this->post('/regras-investimento', $this->data)->assertForbidden();
|
||||
})->with([PurchasingRole::Finance, PurchasingRole::Procurement]);
|
||||
|
||||
test('foreign administrators cannot read or approve another company policy', function () {
|
||||
$policy = proposeInvestmentTest($this);
|
||||
$other = receiptFixture();
|
||||
$user = User::factory()->create(['tenant_id' => $other['tenant']->id, 'purchasing_role' => PurchasingRole::Admin]);
|
||||
$this->actingAs($user)->post('/regras-investimento/'.$policy->id.'/decidir', ['decision' => 'approved', 'reason' => 'Teste'])->assertNotFound();
|
||||
$this->get('/regras-investimento')->assertOk()->assertInertia(fn ($page) => $page->has('policies', 0));
|
||||
});
|
||||
|
||||
test('audit failure rolls back policy proposal and publication decision', function () {
|
||||
$policy = proposeInvestmentTest($this);
|
||||
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->andThrow(new RuntimeException('audit failed'));
|
||||
expect(fn () => reviewInvestmentTest($this, $policy, 1))->toThrow(RuntimeException::class);
|
||||
expect(InvestmentPolicyReview::query()->count())->toBe(0);
|
||||
$this->data['previous_id'] = $policy->id;
|
||||
expect(fn () => proposeInvestmentTest($this))->toThrow(RuntimeException::class);
|
||||
expect(InvestmentPolicy::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('an administrator losing eligibility before publication blocks the second signature', function () {
|
||||
$policy = proposeInvestmentTest($this);
|
||||
reviewInvestmentTest($this, $policy, 1);
|
||||
$this->admins[1]->forceFill(['purchasing_role' => PurchasingRole::Finance])->save();
|
||||
expect(fn () => reviewInvestmentTest($this, $policy, 2))->toThrow(ValidationException::class);
|
||||
expect($policy->refresh()->status)->toBe('pending');
|
||||
});
|
||||
|
||||
test('public investment example contains no company policies', function () {
|
||||
auth()->forgetGuards();
|
||||
$this->get('/consulta/investimento')->assertOk()->assertInertia(fn ($page) => $page->where('initialScreen', 'investment')->where('auth.user', null)->missing('policies')->missing('reviews'));
|
||||
});
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Budget;
|
||||
use App\Models\CostCenter;
|
||||
use App\Models\CriticalityLevel;
|
||||
use App\Models\Department;
|
||||
use App\Models\Equipment;
|
||||
use App\Models\EquipmentSparePart;
|
||||
use App\Models\InvestmentPolicy;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Quotation;
|
||||
@@ -18,6 +20,9 @@ use App\Models\Supplier;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use App\Services\BudgetWorkflow;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\EquipmentWorkflow;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -60,6 +65,27 @@ function issuePurchaseOrder(object $test, PurchaseAward $award): PurchaseOrder
|
||||
return PurchaseOrder::query()->firstOrFail();
|
||||
}
|
||||
|
||||
function prepareEquipmentInvestment(object $test, string $ceiling = '100.00', bool $includesTax = true): InvestmentPolicy
|
||||
{
|
||||
require_once __DIR__.'/../Support/InvestmentFixture.php';
|
||||
$test->item->update(['purchase_type' => 'equipment', 'production_area' => 'Linha fictícia de investimento']);
|
||||
$test->r->update(['status' => 'draft']);
|
||||
$test->r->refresh();
|
||||
$test->actingAs($test->head);
|
||||
$technical = app(EquipmentWorkflow::class);
|
||||
$technical->review($test->head, $test->r, $technical->basis($test->r), 'approved', 'Validação técnica fictícia independente.');
|
||||
$test->r->update(['status' => 'approved']);
|
||||
$test->r->steps()->create(['tenant_id' => $test->tenant->id, 'position' => 2, 'role' => 'finance', 'decision' => 'approved', 'approver_id' => $test->finance->id, 'decided_at' => now()]);
|
||||
$test->actingAs($test->owner);
|
||||
Budget::query()->create(['tenant_id' => $test->tenant->id, 'cost_center_id' => $test->r->cost_center_id, 'year' => now()->year, 'kind' => 'investment', 'amount_minor' => 100000, 'includes_tax' => true, 'created_by' => $test->admin->id, 'approved_by' => $test->finance->id, 'reason' => 'Dotação fictícia de investimento']);
|
||||
DB::transaction(function () use ($test) {
|
||||
app(ConflictGuard::class)->lockCompany($test->owner);
|
||||
app(BudgetWorkflow::class)->record($test->owner, $test->r, 'reserve', $test->r->total_minor, $test->r->subtotal_minor);
|
||||
});
|
||||
|
||||
return publishInvestmentFixture($test->tenant->id, $ceiling, $includesTax);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::query()->create(['name' => 'Empresa das ordens']);
|
||||
$this->department = Department::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Produção']);
|
||||
@@ -402,3 +428,61 @@ test('spare purchase flows through award order receipt and issue with equipment
|
||||
$this->postJson('/stock/operacoes', ['kind' => 'issue', 'reference' => 'SAI-FLOW', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $warehouse->id, 'stock_item_id' => $stock->id, 'responsible_id' => $receiver->id, 'destination' => 'Produção', 'reason' => 'Montagem no equipamento', 'quantity' => '0.500', 'equipment_id' => $asset->id])->assertCreated();
|
||||
expect((int) StockMovement::query()->where('stock_item_id', $stock->id)->sum('quantity_milli'))->toBe(500)->and(StockMovement::query()->where('equipment_id', $asset->id)->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('equipment within the explicit inclusive investment ceiling requires finance and flows through receipt', function () {
|
||||
$policy = prepareEquipmentInvestment($this);
|
||||
$award = selectPurchaseAward($this);
|
||||
expect($award->approvals()->pluck('role')->all())->toBe(['department_head', 'finance']);
|
||||
$order = issuePurchaseOrder($this, $award);
|
||||
expect($order->snapshot['investment_rules']['id'])->toBe($policy->id)
|
||||
->and($order->snapshot['investment_rules']['ceiling_minor'])->toBe(10000)
|
||||
->and($order->snapshot['equipment_requests'][0]['production_area'])->toBe('Linha fictícia de investimento');
|
||||
$receiver = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => PurchasingRole::Warehouse]);
|
||||
$this->actingAs($receiver);
|
||||
$warehouse = Warehouse::query()->create(['tenant_id' => $this->tenant->id, 'created_by' => $receiver->id, 'code' => 'INV', 'name' => 'Armazém fictício']);
|
||||
$stock = StockItem::query()->create(['tenant_id' => $this->tenant->id, 'created_by' => $receiver->id, 'code' => 'EQ-INV', 'name' => 'Equipamento recebido fictício', 'unit' => 'un']);
|
||||
$this->postJson('/ordens/'.$order->id.'/recepcoes', ['idempotency_key' => (string) Str::uuid(), 'delivery_note' => 'GR-INV', 'received_on' => today()->format('Y-m-d'), 'warehouse_id' => $warehouse->id, 'items' => [['purchase_order_item_id' => $order->items()->firstOrFail()->id, 'received_quantity' => '1.000', 'accepted_quantity' => '1.000', 'quality_status' => 'compliant', 'stock_item_id' => $stock->id]]])->assertCreated();
|
||||
expect((int) StockMovement::query()->where('stock_item_id', $stock->id)->sum('quantity_milli'))->toBe(1000);
|
||||
publishInvestmentFixture($this->tenant->id, '200.00');
|
||||
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasNoErrors();
|
||||
expect(PurchaseOrder::query()->count())->toBe(1)->and($order->refresh()->snapshot['investment_rules']['ceiling_minor'])->toBe(10000);
|
||||
});
|
||||
|
||||
test('equipment above the company investment ceiling cannot be awarded', function () {
|
||||
prepareEquipmentInvestment($this, '99.99');
|
||||
$this->actingAs($this->buyer)->post($this->awardUrl, $this->selection)->assertSessionHasErrors('investment');
|
||||
expect(PurchaseAward::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('investment tax basis is explicit and independent of ordinary approval tiers', function (bool $includesTax) {
|
||||
awardPrices($this, 10000, 16);
|
||||
prepareEquipmentInvestment($this, '100.00', $includesTax);
|
||||
if ($includesTax) {
|
||||
$this->actingAs($this->buyer)->post($this->awardUrl, $this->selection)->assertSessionHasErrors('investment');
|
||||
} else {
|
||||
$order = issuePurchaseOrder($this, selectPurchaseAward($this));
|
||||
expect($order->total_minor)->toBe(11600)->and($order->snapshot['investment_rules']['includes_tax'])->toBeFalse();
|
||||
}
|
||||
})->with([true, false]);
|
||||
|
||||
test('a newly published investment policy cannot reuse an existing final approval cycle', function () {
|
||||
prepareEquipmentInvestment($this);
|
||||
$award = selectPurchaseAward($this);
|
||||
approvePurchaseAward($this, $award);
|
||||
publishInvestmentFixture($this->tenant->id, '200.00');
|
||||
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasErrors('investment');
|
||||
expect(PurchaseOrder::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('equipment cannot bypass technical finance or investment budget requirements', function (string $missing) {
|
||||
prepareEquipmentInvestment($this);
|
||||
if ($missing === 'technical') {
|
||||
DB::table('technical_reviews')->where('requisition_id', $this->r->id)->delete();
|
||||
} elseif ($missing === 'finance') {
|
||||
$this->r->steps()->where('role', 'finance')->delete();
|
||||
} else {
|
||||
DB::table('budgets')->where('tenant_id', $this->tenant->id)->update(['kind' => 'operating']);
|
||||
}
|
||||
$this->actingAs($this->buyer)->post($this->awardUrl, $this->selection)->assertSessionHasErrors($missing === 'technical' ? 'technical' : ($missing === 'finance' ? 'approvals' : 'investment'));
|
||||
expect(PurchaseAward::query()->count())->toBe(0);
|
||||
})->with(['technical', 'finance', 'budget']);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\InvestmentPolicy;
|
||||
use App\Models\User;
|
||||
use App\Services\InvestmentWorkflow;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
function publishInvestmentFixture(int $tenantId, string $ceiling = '100.00', bool $includesTax = true): InvestmentPolicy
|
||||
{
|
||||
$connection = DB::connection();
|
||||
$isolated = ($connection->getDriverName() === 'sqlite' && $connection->getDatabaseName() === ':memory:') || ($connection->getDriverName() === 'pgsql' && preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $connection->getDatabaseName()));
|
||||
if (! app()->environment('testing') || ! $isolated) {
|
||||
throw new LogicException('Regras fictícias proibidas na base de negócio.');
|
||||
}
|
||||
$admins = [];
|
||||
for ($index = 0; $index < 3; $index++) {
|
||||
$admins[] = User::factory()->create(['tenant_id' => $tenantId, 'purchasing_role' => PurchasingRole::Admin]);
|
||||
}
|
||||
auth()->guard()->setUser($admins[0]);
|
||||
$previous = InvestmentPolicy::query()->latest('revision')->first();
|
||||
$policy = app(InvestmentWorkflow::class)->propose($admins[0], ['previous_id' => $previous->id ?? 0, 'mode' => 'ceiling', 'scope' => 'process_total', 'currency' => 'MZN', 'ceiling' => $ceiling, 'includes_tax' => $includesTax, 'valid_from' => today()->format('Y-m-d'), 'valid_until' => today()->addMonth()->format('Y-m-d'), 'reason' => 'Política fictícia para teste isolado']);
|
||||
foreach ([$admins[1], $admins[2]] as $admin) {
|
||||
auth()->guard()->setUser($admin);
|
||||
app(InvestmentWorkflow::class)->review($admin, $policy, 'approved', 'Decisão independente de teste');
|
||||
}
|
||||
|
||||
return $policy->refresh();
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
// Explicit operational entry point: backup, restore verification, then only migration 000014.
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
require __DIR__.'/../../vendor/autoload.php';
|
||||
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$connection = DB::connection();
|
||||
if ($connection->getDriverName() !== 'pgsql' || $connection->getDatabaseName() !== 'compras_dev' || ! $app->environment('local')) {
|
||||
throw new LogicException('Aplicação recusada fora de compras_dev/local.');
|
||||
}
|
||||
$pdo = $connection->getPdo();
|
||||
$migration = '2026_10_28_000014_create_investment_policies';
|
||||
if ($pdo->query("SELECT count(*) FROM migrations WHERE migration = '$migration'")->fetchColumn() > 0) {
|
||||
throw new LogicException('Migração já aplicada; não repetir.');
|
||||
}
|
||||
$config = $connection->getConfig();
|
||||
$bin = 'C:/Program Files/PostgreSQL/17/bin/';
|
||||
foreach (['pg_dump.exe', 'pg_restore.exe'] as $tool) {
|
||||
if (! is_file($bin.$tool)) {
|
||||
throw new LogicException('Ferramentas PostgreSQL 17 ausentes.');
|
||||
}
|
||||
}
|
||||
$envHash = hash_file('sha256', base_path('.env'));
|
||||
$directory = storage_path('app/private/backups/investment-000014-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(3)));
|
||||
if (! mkdir($directory, 0700, true)) {
|
||||
throw new RuntimeException('Não foi possível criar backup privado.');
|
||||
}
|
||||
$env = ['PGHOST' => $config['host'], 'PGPORT' => (string) $config['port'], 'PGUSER' => $config['username'], 'PGPASSWORD' => $config['password'], 'PGSSLMODE' => $config['sslmode'] ?? 'prefer'];
|
||||
$quote = fn (string $name) => '"'.str_replace('"', '""', $name).'"';
|
||||
$columns = [];
|
||||
$fingerprints = function (PDO $db, array $tables) use ($quote, &$columns): array {
|
||||
$result = [];
|
||||
foreach ($tables as $table) {
|
||||
$rows = $db->query('SELECT md5(row_to_json(t)::text) FROM (SELECT '.implode(', ', array_map($quote, $columns[$table])).' FROM public.'.$quote($table).') t')->fetchAll(PDO::FETCH_COLUMN);
|
||||
sort($rows, SORT_STRING);
|
||||
$result[$table] = ['count' => count($rows), 'sha256' => hash('sha256', implode('\n', $rows))];
|
||||
}
|
||||
|
||||
return $result;
|
||||
};
|
||||
$tables = $pdo->query("SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename")->fetchAll(PDO::FETCH_COLUMN);
|
||||
foreach ($tables as $table) {
|
||||
$statement = $pdo->prepare("SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ? ORDER BY ordinal_position");
|
||||
$statement->execute([$table]);
|
||||
$columns[$table] = $statement->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
$restoreDatabase = 'compras_restore_check_'.bin2hex(random_bytes(6));
|
||||
$restoreCreated = false;
|
||||
try {
|
||||
// pg_dump and the manifest read exactly the same committed snapshot.
|
||||
$pdo->beginTransaction();
|
||||
$pdo->exec('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
|
||||
$snapshot = $pdo->query('SELECT pg_export_snapshot()')->fetchColumn();
|
||||
$backupState = $fingerprints($pdo, $tables);
|
||||
$dump = new Process([$bin.'pg_dump.exe', '--format=custom', '--no-owner', '--no-acl', '--snapshot='.$snapshot, '--file='.$directory.'/database.dump', '--dbname='.$config['database']], base_path(), $env);
|
||||
$dump->setTimeout(120);
|
||||
$dump->run();
|
||||
if (! $dump->isSuccessful()) {
|
||||
throw new RuntimeException('Falha no backup PostgreSQL; migração não executada.');
|
||||
}
|
||||
$pdo->commit();
|
||||
$pdo->exec('CREATE DATABASE '.$quote($restoreDatabase));
|
||||
$restoreCreated = true;
|
||||
$restore = new Process([$bin.'pg_restore.exe', '--exit-on-error', '--no-owner', '--no-acl', '--dbname='.$restoreDatabase, $directory.'/database.dump'], base_path(), $env);
|
||||
$restore->setTimeout(120);
|
||||
$restore->run();
|
||||
if (! $restore->isSuccessful()) {
|
||||
throw new RuntimeException('Restauro de verificação falhou; migração não executada.');
|
||||
}
|
||||
config(['database.connections.budget_restore' => [...$config, 'url' => null, 'database' => $restoreDatabase]]);
|
||||
$restored = DB::connection('budget_restore')->getPdo();
|
||||
if ($fingerprints($restored, $tables) !== $backupState) {
|
||||
throw new RuntimeException('Restauro não corresponde ao backup; migração recusada.');
|
||||
}
|
||||
file_put_contents($directory.'/manifest.json', json_encode(['tables' => $backupState, 'columns' => $columns, 'dump_sha256' => hash_file('sha256', $directory.'/database.dump')], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
file_put_contents($directory.'/RECUPERACAO.txt', "Backup custom PostgreSQL 17 verificado por restauro numa base separada.\nUsar pg_restore --exit-on-error --no-owner --no-acl --dbname=NOVA_BASE database.dump.\nAs credenciais pertencem ao ambiente local e não estão neste manifesto.\nNão restaurar por cima da base existente.\n");
|
||||
DB::disconnect('budget_restore');
|
||||
unset($restored);
|
||||
$pdo->exec('DROP DATABASE '.$quote($restoreDatabase));
|
||||
$restoreCreated = false;
|
||||
$businessTables = array_values(array_diff($tables, ['migrations', 'sessions', 'cache', 'cache_locks', 'jobs', 'failed_jobs', 'job_batches']));
|
||||
$before = $fingerprints($pdo, $businessTables);
|
||||
$oldMigrations = $pdo->query('SELECT id, migration, batch FROM migrations ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
|
||||
$constraints = $pdo->query("SELECT conname, pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE connamespace = 'public'::regnamespace ORDER BY conname, definition")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$exit = Artisan::call('migrate', ['--path' => 'database/migrations/'.$migration.'.php', '--force' => true]);
|
||||
if ($exit !== 0) {
|
||||
throw new RuntimeException('Migração falhou; consultar backup privado.');
|
||||
}
|
||||
$after = $fingerprints($pdo, $businessTables);
|
||||
$newMigrations = $pdo->query('SELECT id, migration, batch FROM migrations ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
|
||||
$newConstraints = $pdo->query("SELECT conname, pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE connamespace = 'public'::regnamespace ORDER BY conname, definition")->fetchAll(PDO::FETCH_ASSOC);
|
||||
if ($before !== $after || array_slice($newMigrations, 0, count($oldMigrations)) !== $oldMigrations || count($newMigrations) !== count($oldMigrations) + 1 || array_diff(array_map('serialize', $constraints), array_map('serialize', $newConstraints)) !== [] || hash_file('sha256', base_path('.env')) !== $envHash) {
|
||||
throw new RuntimeException('Verificação de preservação falhou; não executar mais escritas.');
|
||||
}
|
||||
$report = ['verified_at_utc' => gmdate('c'), 'migration' => $migration, 'batch' => (int) end($newMigrations)['batch'], 'backup_created' => true, 'backup_bytes' => filesize($directory.'/database.dump'), 'restore_verified' => true, 'restored_tables' => count($tables), 'restore_database_dropped' => true, 'business_records_unchanged' => true, 'existing_constraints_preserved' => true, 'env_unchanged' => true, 'investment_policies_count' => (int) $pdo->query('SELECT count(*) FROM investment_policies')->fetchColumn(), 'investment_policy_reviews_count' => (int) $pdo->query('SELECT count(*) FROM investment_policy_reviews')->fetchColumn(), 'operational_validation' => false];
|
||||
file_put_contents(base_path('docs/investimento-migracao-000014.json'), json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
|
||||
echo json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
} finally {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
DB::disconnect('budget_restore');
|
||||
if ($restoreCreated && preg_match('/^compras_restore_check_[a-f0-9]{12}$/', $restoreDatabase) && $restoreDatabase !== $connection->getDatabaseName()) {
|
||||
$pdo->exec('DROP DATABASE '.$quote($restoreDatabase));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user