diff --git a/README.md b/README.md
index 1cf5a8d..005cac9 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,9 @@ Crie um pedido, guarde o rascunho, confira os valores e submeta. Entre com as co
- Cotações para requisições aprovadas: uma proposta por fornecedor, linhas completas, IVA herdado e cálculo exacto em centavos.
- Matriz comparativa com três fornecedores elegíveis e duas avaliações por proposta; preço e prazo automáticos, notas de qualidade, histórico e pagamento, declaração de ausência de conflito e auditoria.
- RF-AF-01: declarações pessoais por fornecedor/processo, correcções com histórico, consultas por perfil/empresa e impedimentos no servidor. A associação de fornecedores volta a verificar os intervenientes anteriores; declarações de ausência não anulam conflitos registados.
+- Adjudicação fundamentada da proposta de maior pontuação, com escolha entre empates, revalidação de conflitos/propostas/avaliadores e excepções bloqueadas sem política aprovada.
+- Novo ciclo explícito de aprovação do valor final, segundo os escalões e a base de IVA guardados no processo. As decisões da estimativa não são reutilizadas.
+- Ordens únicas por processo, com valores e condições congelados, emissão idempotente, listagem, detalhe, histórico e impressão/gravação em PDF pelo navegador. São documentos de desenvolvimento, sem autorização para compras reais e sem validação orçamental.
Os menus **Fornecedores** e **Cotações** estão disponíveis para Administração, Procurement, Finanças e chefes de departamento. Procurement regista; Finanças valida os fornecedores; chefes apenas consultam/avaliam processos do seu departamento. O administrador não substitui os perfis operacionais: pode criar a equipa em **Empresa e equipa**. As contas existentes não foram alteradas.
@@ -48,9 +51,9 @@ O IVA é informado pelo utilizador e calculado no servidor. A taxa inicial de 0%
## Próximas etapas
-Adjudicação e ordens de compra com nova verificação dos conflitos; dados bancários com dupla validação; equipamentos e peças com aprovação técnica; recepção e stock; orçamento, facturas e pagamentos; moedas e câmbio; subscrições, licenças e Docker. A política de periodicidade, validade e levantamento de conflitos continua por definir.
+Recepção parcial e qualidade; equipamentos e peças com aprovação técnica e stock; dados bancários com dupla validação; orçamento, facturas e pagamentos; moedas e câmbio; subscrições, licenças e Docker. 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 matriz assinala quando está completa, mas não adjudica nem emite ordens. As propostas usam MZN; a multimoeda fica para a fase correspondente.
+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.
Esta base não implementa ainda os onze controlos anti-fraude nem todos os requisitos não funcionais do documento. MFA obrigatório, gestão completa do ciclo de vida dos acessos, delegações, política de levantamento de conflitos, alteração de regras com dupla aprovação, retenção, cópias de segurança e restauro por empresa serão tratados nas etapas correspondentes. Não usar para compras reais antes da validação destas regras e controlos.
diff --git a/app/Models/PurchaseOrderItem.php b/app/Models/PurchaseOrderItem.php
index cb52f38..b788b83 100644
--- a/app/Models/PurchaseOrderItem.php
+++ b/app/Models/PurchaseOrderItem.php
@@ -13,4 +13,9 @@ class PurchaseOrderItem extends Model
public $timestamps = false;
protected $guarded = ['id'];
+
+ protected function casts(): array
+ {
+ return ['quantity' => 'decimal:3', 'tax_rate' => 'decimal:2'];
+ }
}
diff --git a/app/Services/ProposalComparison.php b/app/Services/ProposalComparison.php
index 5352487..7253444 100644
--- a/app/Services/ProposalComparison.php
+++ b/app/Services/ProposalComparison.php
@@ -125,7 +125,7 @@ class ProposalComparison
private function validWeights(array $weights): bool
{
$keys = ['price', 'quality', 'delivery', 'history', 'payment'];
- if (count($weights) !== count($keys) || array_sum($weights) !== 100) {
+ if (count($weights) !== count($keys)) {
return false;
}
foreach ($keys as $key) {
@@ -134,7 +134,7 @@ class ProposalComparison
}
}
- return true;
+ return array_sum($weights) === 100;
}
private function validAmounts(Quotation $quote, Requisition $requisition): bool
diff --git a/app/Services/PurchaseWorkflow.php b/app/Services/PurchaseWorkflow.php
index 13ee797..e558df9 100644
--- a/app/Services/PurchaseWorkflow.php
+++ b/app/Services/PurchaseWorkflow.php
@@ -3,10 +3,13 @@
namespace App\Services;
use App\Enums\PurchasingRole;
+use App\Models\ProposalEvaluation;
use App\Models\PurchaseAward;
use App\Models\PurchaseOrder;
use App\Models\Quotation;
+use App\Models\QuotationItem;
use App\Models\Requisition;
+use App\Models\Supplier;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\DB;
@@ -112,6 +115,11 @@ class PurchaseWorkflow
...$source->only(['unit', 'category', 'purchase_type']), 'tenant_id' => $user->tenant_id,
];
}
+ $validUntil = $quote->attributesToArray()['valid_until'] ?? null;
+ $deliveryDate = $award->attributesToArray()['delivery_date'] ?? null;
+ if (! is_string($validUntil) || ! is_string($deliveryDate)) {
+ throw ValidationException::withMessages(['delivery_date' => 'As datas da proposta e da entrega devem estar completas antes da emissão.']);
+ }
$snapshot = [
'development_only' => true, 'budget_checked' => false,
'company' => Tenant::query()->findOrFail($user->tenant_id)->only(['id', 'name']),
@@ -120,12 +128,12 @@ class PurchaseWorkflow
'cost_center' => $requisition->costCenter->only(['id', 'code', 'name']),
'requester' => $requisition->requester?->only(['id', 'name']),
'supplier' => $supplier->only(['id', 'name', 'nuit', 'contact', 'email', 'phone', 'address']),
- 'quotation' => $quote->only(['id', 'reference', 'currency', 'payment_terms', 'delivery_days', 'valid_until']),
- 'award' => $award->only(['id', 'justification', 'delivery_date', 'delivery_location']),
+ 'quotation' => [...$quote->only(['id', 'reference', 'currency', 'payment_terms', 'delivery_days']), 'valid_until' => $validUntil],
+ 'award' => [...$award->only(['id', 'justification', 'delivery_location']), 'delivery_date' => $deliveryDate],
'issuer' => $user->only(['id', 'name']), 'approval_rules' => $award->approval_snapshot,
'estimate_approvals' => $requisition->steps()->get()->toArray(),
'final_approvals' => $award->approvals()->get()->toArray(),
- 'decision_matrix' => $award->decision_matrix, 'items' => $lines,
+ 'decision_matrix' => $this->comparison->inspect($requisition)['rows'], 'items' => $lines,
];
$order = PurchaseOrder::query()->create([
'tenant_id' => $user->tenant_id, 'requisition_id' => $requisition->id, 'purchase_award_id' => $award->id,
@@ -161,6 +169,13 @@ class PurchaseWorkflow
/** @return array{rows: list>, ready: bool, top_ids: list, fingerprint: string} */
private function checkComparison(User $user, Requisition $requisition, Quotation $quote): array
{
+ // Also coordinate with supplier reviews, which lock supplier rows without the company mutex.
+ $quotes = Quotation::query()->where('requisition_id', $requisition->id)->orderBy('id')->lockForUpdate()->get(['id', 'supplier_id']);
+ Supplier::query()->whereIn('id', $quotes->pluck('supplier_id'))->orderBy('id')->lockForUpdate()->get();
+ QuotationItem::query()->whereIn('quotation_id', $quotes->pluck('id'))->orderBy('id')->lockForUpdate()->get();
+ ProposalEvaluation::query()->whereIn('quotation_id', $quotes->pluck('id'))->orderBy('id')->lockForUpdate()->get();
+ User::query()->where('tenant_id', $user->tenant_id)->orderBy('id')->lockForUpdate()->get();
+ $requisition->items()->orderBy('id')->lockForUpdate()->get();
$this->guard->assertParticipation($user, $requisition);
$this->guard->assertProcess($requisition, $quote->supplier_id, $user);
$result = $this->comparison->inspect($requisition);
@@ -213,7 +228,7 @@ class PurchaseWorkflow
throw ValidationException::withMessages(['approvals' => 'A requisição deve ter todas as aprovações obrigatórias da estimativa, preservadas no histórico.']);
}
foreach ($steps as $index => $step) {
- if ($step->decision !== 'approved' || $step->role !== $roles[$index] || $step->approver_id === null || in_array($step->approver_id, $seen, true) ||
+ if ($step->decision !== 'approved' || $step->decided_at === null || $step->role !== $roles[$index] || $step->approver_id === null || in_array($step->approver_id, $seen, true) ||
! User::query()->where('tenant_id', $requisition->tenant_id)->whereKey($step->approver_id)->exists()) {
throw ValidationException::withMessages(['approvals' => 'O histórico de aprovação da estimativa está incompleto ou inconsistente.']);
}
@@ -232,7 +247,7 @@ class PurchaseWorkflow
}
foreach ($steps as $index => $step) {
$user = User::query()->where('tenant_id', $requisition->tenant_id)->find($step->approver_id);
- if (! $user || $step->decision !== 'approved' || $step->role !== $roles[$index] || $user->purchasing_role->value !== $step->role ||
+ if (! $user || $step->decision !== 'approved' || $step->decided_at === null || $step->role !== $roles[$index] || $user->purchasing_role->value !== $step->role ||
($step->role === 'department_head' && $user->department_id !== $requisition->department_id) || in_array($user->id, $seen, true)) {
throw ValidationException::withMessages(['approvals' => 'As aprovações do valor final devem continuar elegíveis e ser de pessoas distintas.']);
}
diff --git a/docs/consulta-livre.md b/docs/consulta-livre.md
new file mode 100644
index 0000000..196b599
--- /dev/null
+++ b/docs/consulta-livre.md
@@ -0,0 +1,21 @@
+# Consulta livre para revisão visual
+
+A entrada `/` encaminha para `/consulta`, uma demonstração pública sem login,
+criada a pedido do utilizador para avaliar navegação, ecrãs e cores.
+
+O ecrã usa exclusivamente exemplos fictícios locais e não consulta registos de
+negócio. Não permite gravar, aprovar, adjudicar ou descarregar documentos reais.
+As rotas operacionais mantêm autenticação e as permissões existentes. A conta,
+os dados e a configuração da empresa foram preservados.
+
+Implementação: `routes/preview.php` e `resources/js/pages/preview/index.tsx`.
+O login oferece também uma ligação à demonstração. A página não usa o layout
+autenticado e não recebe a identidade do utilizador nas propriedades públicas.
+
+Ao desenvolver novos módulos, manter a demonstração identificada como fictícia.
+Os exemplos de ordens são ilustrativos; não constituem prova de implementação
+do fluxo operacional. Não substituir os controlos de acesso por acesso anónimo.
+
+Validação: quatro testes dirigidos passaram, com 41 asserções, incluindo a
+entrada livre, ausência de identidade e manutenção da autenticação nas rotas
+de negócio. Revisão visual no navegador pendente por falha da ligação de controlo.
diff --git a/docs/desenvolvimento.md b/docs/desenvolvimento.md
index cfbf16a..ee63ba3 100644
--- a/docs/desenvolvimento.md
+++ b/docs/desenvolvimento.md
@@ -1,5 +1,61 @@
# Desenvolvimento do ProcuraFlow
+## Incremento actual de adjudicação e ordens de compra
+
+Implementado em 28 de Outubro de 2026 no projecto Laravel existente, segundo a secção «Próximo incremento após RF-AF-01» das orientações. O RF-AF-01, os fornecedores e os documentos privados foram reutilizados e preservados. Não foram recriadas contas, executados seeds ou alterados o SGF Tempo, o Investmoz ou a configuração local.
+
+### Selecção e revalidação
+
+- Procurement regista a adjudicação a partir da página de comparação de cotações. Exige justificação escrita e data/local de entrega. O solicitante não selecciona a sua própria compra; Administração consulta, sem substituir os perfis operacionais.
+- `ProposalComparison` unifica a matriz da interface e a decisão no servidor. Recalcula preços, prazos e notas com aritmética racional; arredonda a pontuação final a duas casas decimais. Quantidades, preços e IVA são verificados em precisão decimal e centavos. Linhas devem corresponder à requisição, e os totais têm de conferir com as linhas. A entrega suporta apenas MZN.
+- Requer três fornecedores distintos com propostas elegíveis, válidas e comparáveis; cada proposta elegível precisa de duas avaliações elegíveis. Os avaliadores têm de continuar na mesma empresa e num perfil operacional permitido, respeitar o departamento quando são chefes, ser distintos e não ser o solicitante. Uma declaração de ausência não substitui a verificação de conflitos.
+- Escolha limitada ao topo da pontuação. Empates permitem seleccionar uma das propostas empatadas, sempre com fundamento escrito. Dispensa de cotações, escolha abaixo do topo e outros caminhos de excepção permanecem bloqueados sem política aprovada; parâmetros enviados pelo navegador não dispensam estas regras.
+- Cada operação decisiva revalida o `ConflictGuard`, incluindo solicitante, aprovadores, autores de cotações, avaliadores e agora autor/aprovadores da adjudicação. Novas declarações continuam a bloquear operações sem apagar o histórico.
+- A selecção fecha o registo de novas propostas e avaliações desse processo. A matriz de decisão e a sua impressão digital são guardadas. Alterações aos valores, avaliações, ponderações ou elegibilidade da matriz impedem a utilização das aprovações para emitir sobre uma base diferente.
+
+### Escalões e ciclo de aprovação do valor final
+
+- Os limites e a base com/sem IVA vêm do snapshot guardado na requisição; não se substituem pela configuração actual da empresa. Processos sem snapshot válido ou histórico completo de aprovação da estimativa são bloqueados.
+- A adjudicação abre explicitamente um novo ciclo de aprovações para o valor final, mesmo quando fica no mesmo escalão. As decisões da estimativa são preservadas e não são reaproveitadas. O topo do primeiro escalão é inclusivo, tal como o segundo; ultrapassagens acrescentam Procurement e Finanças, conforme as regras guardadas.
+- As etapas são sequenciais. Solicitante e autor da adjudicação não aprovam o ciclo; cada etapa é decidida por uma pessoa distinta. A rejeição exige fundamento. Antes de emitir, verifica-se novamente se os aprovadores finais continuam elegíveis e sem conflito.
+- Equipamento e peças com tipos ainda não suportados são bloqueados, sem presumir aprovação técnica ou limites próprios. Não existem excepções automáticas para urgência, fornecedor único ou valores acima de limites sem política definida.
+
+### Ordem emitida e consulta
+
+- Ordem única por requisição e adjudicação, com referências `OC-ANO-ID`, transacção e unicidade na base de dados. Repetir a emissão devolve a ordem existente sem novas linhas, novo número, nova auditoria ou alteração dos dados congelados. Podem existir intervalos na sequência de identificadores.
+- A ordem congela na emissão linhas, quantidades/unidades, preços, IVA, totais, moeda, fornecedor/contactos, empresa, departamento, centro de custo, referência/condições da proposta, entrega, fundamento, emitente, matriz revalidada e aprovações. A matriz guardada na adjudicação continua separada como histórico da escolha. Alterações posteriores às fichas e propostas não reescrevem a ordem.
+- Listagem, detalhe, emitente, histórico e versão HTML para impressão ou gravação em PDF pelo navegador. A impressão escapa o conteúdo fornecido pelos utilizadores. Não há geração de PDF no servidor ou envio por email.
+- Administração, Procurement e Finanças consultam na própria empresa; chefes apenas processos do seu departamento. Solicitantes e armazém não recebem acesso aos documentos financeiros nesta etapa.
+- Todas as ordens exibem **Documento de desenvolvimento. Não autoriza compras reais.** O snapshot guarda `development_only=true` e `budget_checked=false`. **O orçamento ainda não é verificado**, tanto na interface como na impressão. Não foram criadas ordens reais neste incremento.
+- Falhar a auditoria reverte a adjudicação e as suas etapas, ou a emissão e as suas linhas. Os modelos das ordens e linhas impedem edição/eliminação; a protecção contra SQL administrativo continua dependente das permissões operacionais.
+
+### Verificação e preservação
+
+- Suite completa Laravel: **165 passaram, 3 ignorados, 1.662 asserções**. Os 46 testes específicos de adjudicação/ordens passaram; a suite inclui também os testes anteriores de RF-AF-01/fornecedores/anexos e os três testes de pré-visualização pública acrescentados em paralelo. Os testes usam SQLite em memória, não a base de negócio.
+- Cobertura: acesso por perfil e departamento; isolamento de empresas; valores/IVA e quantidades fraccionárias; fronteiras de 25.000 e 150.000 MZN; base com/sem IVA guardada; novo ciclo de aprovação; empates; excepções bloqueadas; conflitos/suspensão/expiração após avaliação ou selecção; elegibilidade de avaliadores/aprovadores; integridade das linhas; prevenção de repetição com unicidade/idempotência; snapshots; impressão com escape de HTML; rollback se falhar a auditoria.
+- PHPStan aprovado, sem erros; Pint aprovado em todos os ficheiros PHP; verificação frontend sem avisos ou erros; TypeScript aprovado; compilação aprovada; `git diff --check` sem erros.
+- Migração aditiva `2026_10_28_000005_create_purchase_awards_and_orders` aplicada no PostgreSQL local após os testes. Contagens antes/depois iguais: 1 utilizador, 1 empresa e 0 requisições, fornecedores, cotações, avaliações, documentos e declarações de conflito. Novas tabelas de adjudicações, aprovações finais, ordens e linhas vazias. Não foram criados registos sintéticos na base real.
+- O SHA-256 do `.env` manteve-se igual antes e depois. Não houve seeds, alterações de credenciais ou configuração, nem reinicialização de dados.
+- As rotas de ordens/adjudicação estão registadas. O acesso sem autenticação a `/ordens` redireccionou para a entrada ProcuraFlow em 8015, com HTTP 200. As páginas autenticadas e o conteúdo da impressão foram verificados em testes HTTP/Inertia, não visualmente.
+
+Foram detectadas alterações paralelas na pré-visualização pública, na entrada e nas respectivas rotas/testes; foram preservadas e incluídas na verificação final, sem as apresentar como parte desta implementação. As alterações funcionais deste incremento permanecem limitadas à adjudicação/ordens e à integração necessária com os módulos anteriores.
+
+### Limitações e próximo passo
+
+**Revisão visual autenticada, revisão visual da impressão/PDF e concorrência real em PostgreSQL continuam pendentes.** Não foram realizados testes de carga. O código usa bloqueio por empresa e bloqueios dos registos de comparação/fornecedores/utilizadores dentro da transacção; isso não constitui validação de concorrência real.
+
+Ainda não existe cancelamento/reabertura de adjudicações, alteração da data de entrega depois da selecção ou substituição de uma ordem emitida. Se a base aprovada mudar, faltar elegibilidade ou a data de entrega passar, o sistema impede a emissão; não assume uma autorização de correcção. As políticas e o fluxo de revisão/cancelamento devem ser definidos antes de ampliar esses caminhos.
+
+Permanecem pendentes orçamento, multimoeda, dados bancários com dupla validação, aprovações técnicas de equipamentos/peças e as políticas comerciais em aberto. Próximo incremento: recepção parcial e conferência de qualidade com segregação do emitente, seguido de equipamentos, peças e stock. Não avançar para compras ou pagamentos reais.
+
+### Ficheiros deste incremento
+
+Migração `2026_10_28_000005`; modelos `PurchaseAward`, `PurchaseAwardApproval`, `PurchaseOrder`, `PurchaseOrderItem` e trait de imutabilidade; serviços `ProposalComparison` e `PurchaseWorkflow`; controlador `PurchaseController`; integração em `ConflictGuard` e `QuotationController`; casts decimais de `QuotationItem` e `ProposalEvaluation`; rotas autenticadas e menu; tipos `purchase.ts`, páginas `purchases/award`, `purchases/index`, `purchases/show`, ligação da comparação e vista `purchases/print.blade.php`; testes `PurchaseAwardOrderTest`; documentação.
+
+## Entrega anterior RF-AF-01
+
+Os registos abaixo conservam os resultados anteriores. A indicação de adjudicação e ordens como próximo passo nessas entregas fica substituída pelo incremento acima.
+
## Incremento actual RF-AF-01 de 28 de Outubro de 2026
### Implementado
diff --git a/docs/orientacoes-continuidade.md b/docs/orientacoes-continuidade.md
index b70b8bb..45700bd 100644
--- a/docs/orientacoes-continuidade.md
+++ b/docs/orientacoes-continuidade.md
@@ -1,5 +1,12 @@
# Continuidade do desenvolvimento do ProcuraFlow
+## Entrada livre para revisão visual
+
+A pedido do utilizador, `/` abre agora `/consulta`, com exemplos fictícios sem
+login. Consultar `docs/consulta-livre.md`. Preservar esta entrada e a ligação no
+login; manter as rotas e os dados reais protegidos. Esta demonstração é uma
+ferramenta de revisão visual e não substitui os módulos operacionais abaixo.
+
## Próximo incremento após RF-AF-01
O relatório RF-AF-01 está actualizado e a migração 000004 foi confirmada aplicada.
diff --git a/resources/js/app.tsx b/resources/js/app.tsx
index e0885aa..e42ad38 100644
--- a/resources/js/app.tsx
+++ b/resources/js/app.tsx
@@ -13,6 +13,7 @@ void createInertiaApp({
layout: (name) => {
switch (true) {
case name === 'welcome':
+ case name.startsWith('preview/'):
return null;
case name.startsWith('auth/'):
return AuthLayout;
diff --git a/resources/js/pages/auth/login.tsx b/resources/js/pages/auth/login.tsx
index 1260536..6e1375a 100644
--- a/resources/js/pages/auth/login.tsx
+++ b/resources/js/pages/auth/login.tsx
@@ -21,6 +21,12 @@ export default function Login({ status, canResetPassword }: Props) {
<>
+
+
+
Ambiente de desenvolvimento: os documentos não autorizam compras reais. O orçamento ainda não é verificado. Excepções às três propostas ou à maior pontuação permanecem bloqueadas enquanto não existir política aprovada.