+4
-2
@@ -1,8 +1,10 @@
|
||||
APP_NAME=Laravel
|
||||
APP_NAME=ProcuraFlow
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost:8000
|
||||
APP_URL=http://127.0.0.1:8015
|
||||
SERVER_PORT=8015
|
||||
SESSION_COOKIE=procuraflow_session
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# ProcuraFlow
|
||||
|
||||
Sistema de gestão de compras em Laravel 13, React, TypeScript e Inertia. Esta entrega implementa a base de requisições e aprovações da especificação de procurement.
|
||||
|
||||
Este é o projecto activo em `C:\laragon\www\compras`. Preserve a conta existente. A implementação Python criada separadamente fica apenas como referência; consulte [o mapa de compatibilidade](docs/compatibilidade.md) antes de adaptar funcionalidades.
|
||||
|
||||
## Executar localmente
|
||||
|
||||
Endereço local deste projecto: **http://127.0.0.1:8015**. Use `SERVER_PORT=8015`, `APP_URL=http://127.0.0.1:8015` e `SESSION_COOKIE=procuraflow_session` no `.env` para evitar colisões com outros projectos. O comando `php artisan serve` respeita `SERVER_PORT`.
|
||||
|
||||
As dependências e o `.env` devem estar configurados. O projecto suporta a ligação PostgreSQL existente; os testes usam SQLite em memória.
|
||||
|
||||
```sh
|
||||
php artisan migrate
|
||||
npm install
|
||||
npm run build
|
||||
composer dev
|
||||
```
|
||||
|
||||
Abra a aplicação, registe uma conta e configure a empresa no painel. A primeira conta torna-se administradora exclusivamente da empresa que cria. Em **Empresa e equipa**, adicione departamentos, centros de custo e utilizadores com os perfis necessários. Contas registadas publicamente não entram automaticamente numa empresa existente.
|
||||
|
||||
Crie um pedido, guarde o rascunho, confira os valores e submeta. Entre com as contas de aprovação para decidir cada etapa. O solicitante não pode aprovar o próprio pedido; um administrador não substitui os aprovadores. Cada etapa exige uma pessoa diferente. Sem um aprovador elegível o pedido fica pendente.
|
||||
|
||||
## Entrega actual
|
||||
|
||||
- Empresas com filtragem de consultas por organização e testes de isolamento.
|
||||
- Departamentos, centros de custo e criação administrativa de contas com perfis.
|
||||
- Requisições com rascunho editável, linhas, categoria, quantidade, preço e IVA indicado por linha.
|
||||
- Cálculos exactos no servidor com Brick Math, valores monetários em centavos e arredondamento por linha a meio para cima.
|
||||
- Referências únicas baseadas na sequência de identificadores da base de dados; podem existir intervalos na numeração.
|
||||
- Aprovações sequenciais com transacção e bloqueio da requisição, rejeição fundamentada e regras guardadas na submissão.
|
||||
- Histórico de criação, edição, submissão e decisão. O modelo impede a edição e eliminação de entradas; protecção contra alterações por SQL ou acesso administrativo directo ainda exige permissões próprias na base de dados.
|
||||
- Painel com indicadores reais, pesquisa e paginação de requisições.
|
||||
- Fornecedores: cadastro com NUIT único por empresa, referências documentais, beneficiários, impedimentos, revisão e activação/suspensão por Finanças, distinta do autor.
|
||||
- Manutenção de fornecedores por Procurement: edição, renovação da próxima revisão e controlo de versões. Alterações efectivas e novos documentos exigem nova validação independente por Finanças.
|
||||
- Documentos privados de fornecedores (PDF, JPEG e PNG, de 1 KB a 2 MB): envio por Procurement e download autenticado por Administração, Procurement ou Finanças da mesma empresa, com auditoria. Chefes consultam a ficha sem acesso aos documentos.
|
||||
- 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.
|
||||
|
||||
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.
|
||||
|
||||
## Regras provisórias
|
||||
|
||||
Os limites iniciais são 25.000 e 150.000 MZN, inclusive, com base no total com IVA. São parâmetros por empresa na tabela `tenants`, guardados em centavos. Estes valores e a base com IVA são hipóteses de desenvolvimento, ainda sem validação de Finanças. A edição das regras pela interface e a dupla validação administrativa não estão implementadas.
|
||||
|
||||
O IVA é informado pelo utilizador e calculado no servidor. A taxa inicial de 0% no formulário não representa uma decisão fiscal; ainda falta o catálogo de taxas com vigência, dedutibilidade e validação por Finanças. A prioridade urgente não dispensa aprovações.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
As declarações pessoais abrem no menu **Conflitos de interesses**. Só o autor pode corrigir os próprios fundamentos/datas, acrescentando histórico; Administração e Finanças podem consultar as declarações da empresa. Não existe caducidade automática nem retirada de conflitos. A revisão visual autenticada e a concorrência real continuam pendentes; consulte [o relatório de desenvolvimento](docs/desenvolvimento.md).
|
||||
|
||||
## Verificação
|
||||
|
||||
```sh
|
||||
php artisan test --compact
|
||||
php vendor/bin/phpstan analyse --no-progress
|
||||
php vendor/bin/pint --test
|
||||
npm run check
|
||||
npm run types:check
|
||||
npm run build
|
||||
```
|
||||
|
||||
Os testes cobrem cálculos, entradas manipuladas, limites de aprovação, separação de funções, ordem das decisões, rejeição e acesso entre empresas. A concorrência real em PostgreSQL requer testes com várias ligações; SQLite em memória não demonstra o comportamento dos bloqueios em produção.
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum PurchasingRole: string
|
||||
{
|
||||
case Requester = 'requester';
|
||||
case DepartmentHead = 'department_head';
|
||||
case Procurement = 'procurement';
|
||||
case Finance = 'finance';
|
||||
case Warehouse = 'warehouse';
|
||||
case Admin = 'admin';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Requester => 'Solicitante',
|
||||
self::DepartmentHead => 'Chefe de departamento',
|
||||
self::Procurement => 'Procurement',
|
||||
self::Finance => 'Finanças',
|
||||
self::Warehouse => 'Recepção / Armazém',
|
||||
self::Admin => 'Administrador',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\CostCenter;
|
||||
use App\Models\Department;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class CompanyController extends Controller
|
||||
{
|
||||
public function setup(Request $request): Response|RedirectResponse
|
||||
{
|
||||
return $request->user()->tenant_id ? to_route('dashboard') : Inertia::render('company/setup');
|
||||
}
|
||||
|
||||
public function store(Request $request, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['name' => 'required|string|max:150', 'department' => 'required|string|max:100']);
|
||||
DB::transaction(function () use ($request, $data, $workflow) {
|
||||
$user = User::query()->lockForUpdate()->findOrFail($request->user()->id);
|
||||
abort_if($user->tenant_id !== null, 403);
|
||||
$tenant = Tenant::query()->create(['name' => $data['name']]);
|
||||
$department = Department::query()->create(['tenant_id' => $tenant->id, 'name' => $data['department']]);
|
||||
CostCenter::query()->create(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'code' => 'GERAL', 'name' => 'Centro de custo geral']);
|
||||
$user->forceFill(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Admin])->save();
|
||||
$workflow->audit($user, 'company.created', null, null, ['name' => $data['name']]);
|
||||
});
|
||||
|
||||
return to_route('dashboard');
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->admin($request);
|
||||
|
||||
return Inertia::render('company/index', [
|
||||
'company' => Tenant::query()->findOrFail($request->user()->tenant_id),
|
||||
'departments' => Department::query()->orderBy('name')->get(),
|
||||
'costCenters' => CostCenter::query()->orderBy('code')->get(),
|
||||
'users' => User::query()->where('tenant_id', $request->user()->tenant_id)->orderBy('name')->get(['id', 'name', 'email', 'department_id', 'purchasing_role']),
|
||||
'roles' => collect(PurchasingRole::cases())->map(fn (PurchasingRole $role) => ['value' => $role->value, 'label' => $role->label()]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function department(Request $request, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$this->admin($request);
|
||||
$data = $request->validate(['name' => ['required', 'string', 'max:100', Rule::unique('departments')->where('tenant_id', $request->user()->tenant_id)]]);
|
||||
DB::transaction(function () use ($request, $data, $workflow) {
|
||||
$department = Department::query()->create(['tenant_id' => $request->user()->tenant_id, ...$data]);
|
||||
$workflow->audit($request->user(), 'department.created', null, null, $department->toArray());
|
||||
});
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function costCenter(Request $request, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$this->admin($request);
|
||||
$data = $request->validate([
|
||||
'department_id' => ['required', Rule::exists('departments', 'id')->where('tenant_id', $request->user()->tenant_id)],
|
||||
'code' => ['required', 'string', 'max:30', Rule::unique('cost_centers')->where('tenant_id', $request->user()->tenant_id)],
|
||||
'name' => 'required|string|max:100',
|
||||
]);
|
||||
DB::transaction(function () use ($request, $data, $workflow) {
|
||||
$center = CostCenter::query()->create(['tenant_id' => $request->user()->tenant_id, ...$data]);
|
||||
$workflow->audit($request->user(), 'cost_center.created', null, null, $center->toArray());
|
||||
});
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function user(Request $request, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$this->admin($request);
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:100', 'email' => 'required|email|max:255|unique:users',
|
||||
'password' => 'required|string|min:12|confirmed',
|
||||
'department_id' => ['required', Rule::exists('departments', 'id')->where('tenant_id', $request->user()->tenant_id)],
|
||||
'purchasing_role' => ['required', Rule::enum(PurchasingRole::class)],
|
||||
]);
|
||||
DB::transaction(function () use ($request, $data, $workflow) {
|
||||
$user = new User;
|
||||
$user->forceFill($data + ['tenant_id' => $request->user()->tenant_id])->save();
|
||||
$workflow->audit($request->user(), 'user.created', null, null, $user->only(['id', 'name', 'email', 'department_id', 'purchasing_role']));
|
||||
});
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
private function admin(Request $request): void
|
||||
{
|
||||
abort_unless($request->user()->purchasing_role === PurchasingRole::Admin, 403);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ConflictDeclaration;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\Supplier;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ConflictDeclarationController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
return Inertia::render('conflicts/index', [
|
||||
'declarations' => ConflictDeclaration::query()->visibleTo($request->user())
|
||||
->with(['author:id,name', 'supplier:id,name', 'requisition:id,number', 'successor:id,previous_id'])
|
||||
->latest('id')->paginate(15),
|
||||
// These minimal choices allow personal declarations without granting supplier-management access.
|
||||
'suppliers' => Supplier::query()->orderBy('name')->get(['id', 'name']),
|
||||
'requisitions' => Requisition::query()->visibleTo($request->user())->latest()->get(['id', 'number']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, ConflictGuard $guard, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'scope' => ['required', Rule::in(['supplier', 'process', 'process_supplier'])],
|
||||
'supplier_id' => ['nullable', 'required_if:scope,supplier,process_supplier', 'prohibited_if:scope,process', 'integer', Rule::exists('suppliers', 'id')->where('tenant_id', $request->user()->tenant_id)],
|
||||
'requisition_id' => ['nullable', 'required_if:scope,process,process_supplier', 'prohibited_if:scope,supplier', 'integer', Rule::exists('requisitions', 'id')->where('tenant_id', $request->user()->tenant_id)],
|
||||
'has_conflict' => 'required|boolean', 'known_since' => 'required|date_format:Y-m-d|before_or_equal:today',
|
||||
'reason' => 'required|string|min:10|max:5000',
|
||||
'user_id' => 'prohibited', 'tenant_id' => 'prohibited', 'declared_at' => 'prohibited',
|
||||
'root_id' => 'prohibited', 'previous_id' => 'prohibited',
|
||||
]);
|
||||
DB::transaction(function () use ($request, $guard, $workflow, $data) {
|
||||
$guard->lockCompany($request->user());
|
||||
if (! empty($data['requisition_id'])) {
|
||||
Requisition::query()->visibleTo($request->user())->findOrFail($data['requisition_id']);
|
||||
}
|
||||
$declaration = ConflictDeclaration::query()->create([
|
||||
'tenant_id' => $request->user()->tenant_id, 'user_id' => $request->user()->id,
|
||||
'supplier_id' => $data['supplier_id'] ?? null, 'requisition_id' => $data['requisition_id'] ?? null,
|
||||
'has_conflict' => $data['has_conflict'], 'known_since' => $data['known_since'],
|
||||
'reason' => $data['reason'], 'declared_at' => now(),
|
||||
]);
|
||||
$workflow->audit($request->user(), 'conflict.declared', null, null, $declaration->toArray());
|
||||
});
|
||||
|
||||
return to_route('conflicts.index');
|
||||
}
|
||||
|
||||
public function show(Request $request, ConflictDeclaration $declaration): Response
|
||||
{
|
||||
ConflictDeclaration::query()->visibleTo($request->user())->findOrFail($declaration->id);
|
||||
$rootId = $declaration->root_id ?? $declaration->id;
|
||||
$history = ConflictDeclaration::query()->where(fn ($query) => $query->whereKey($rootId)->orWhere('root_id', $rootId))
|
||||
->with(['author:id,name', 'supplier:id,name', 'requisition:id,number'])->orderBy('id')->get();
|
||||
$latest = $history->last();
|
||||
|
||||
return Inertia::render('conflicts/show', ['history' => $history, 'canCorrect' => $latest?->user_id === $request->user()->id]);
|
||||
}
|
||||
|
||||
public function update(Request $request, ConflictDeclaration $declaration, ConflictGuard $guard, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
abort_unless($declaration->user_id === $request->user()->id, 403);
|
||||
$data = $request->validate([
|
||||
'known_since' => 'required|date_format:Y-m-d|before_or_equal:today', 'reason' => 'required|string|min:10|max:5000',
|
||||
'has_conflict' => 'prohibited', 'supplier_id' => 'prohibited', 'requisition_id' => 'prohibited',
|
||||
'user_id' => 'prohibited', 'tenant_id' => 'prohibited', 'declared_at' => 'prohibited',
|
||||
'root_id' => 'prohibited', 'previous_id' => 'prohibited',
|
||||
]);
|
||||
DB::transaction(function () use ($request, $guard, $workflow, $declaration, $data) {
|
||||
$guard->lockCompany($request->user());
|
||||
$declaration = ConflictDeclaration::query()->lockForUpdate()->findOrFail($declaration->id);
|
||||
if ($declaration->successor()->exists()) {
|
||||
throw ValidationException::withMessages(['history' => 'Já existe uma correcção mais recente. Reabra o histórico antes de corrigir.']);
|
||||
}
|
||||
$revision = ConflictDeclaration::query()->create([
|
||||
...$declaration->only(['tenant_id', 'user_id', 'supplier_id', 'requisition_id', 'has_conflict']),
|
||||
...$data, 'declared_at' => now(), 'root_id' => $declaration->root_id ?? $declaration->id, 'previous_id' => $declaration->id,
|
||||
]);
|
||||
$workflow->audit($request->user(), 'conflict.corrected', null, $declaration->toArray(), $revision->toArray());
|
||||
});
|
||||
|
||||
return to_route('conflicts.show', $declaration);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Requisition;
|
||||
use App\Services\ProposalComparison;
|
||||
use App\Services\PurchaseWorkflow;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class PurchaseController extends Controller
|
||||
{
|
||||
private function access(Request $request): void
|
||||
{
|
||||
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
}
|
||||
|
||||
public function award(Request $request, Requisition $requisition, ProposalComparison $comparison, PurchaseWorkflow $workflow): Response
|
||||
{
|
||||
$this->access($request);
|
||||
Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id);
|
||||
$award = PurchaseAward::query()->where('requisition_id', $requisition->id)->with('approvals')->first();
|
||||
|
||||
return Inertia::render('purchases/award', [
|
||||
'requisition' => $requisition, 'comparison' => $comparison->inspect($requisition), 'award' => $award,
|
||||
'canSelect' => $request->user()->purchasing_role === PurchasingRole::Procurement && $award === null && $request->user()->id !== $requisition->requester_id,
|
||||
'canDecide' => $award !== null && $workflow->canDecide($request->user(), $award, $requisition),
|
||||
'canIssue' => $award?->status === 'approved' && $request->user()->purchasing_role === PurchasingRole::Procurement,
|
||||
'order' => PurchaseOrder::query()->where('requisition_id', $requisition->id)->first(['id', 'number']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function select(Request $request, Requisition $requisition, PurchaseWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$this->access($request);
|
||||
$data = $request->validate([
|
||||
'quotation_id' => 'required|integer', 'justification' => 'required|string|min:10|max:5000',
|
||||
'delivery_date' => 'required|date_format:Y-m-d|after_or_equal:today', 'delivery_location' => 'required|string|min:3|max:500',
|
||||
'status' => 'prohibited', 'total_minor' => 'prohibited', 'subtotal_minor' => 'prohibited', 'tax_minor' => 'prohibited',
|
||||
'approval_snapshot' => 'prohibited', 'decision_matrix' => 'prohibited', 'created_by' => 'prohibited', 'tenant_id' => 'prohibited',
|
||||
'override' => 'prohibited', 'exception' => 'prohibited',
|
||||
]);
|
||||
$workflow->select($request->user(), $requisition, $data);
|
||||
|
||||
return to_route('purchases.award', $requisition);
|
||||
}
|
||||
|
||||
public function decide(Request $request, PurchaseAward $award, PurchaseWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$this->access($request);
|
||||
$data = $request->validate(['decision' => ['required', Rule::in(['approved', 'rejected'])], 'comment' => 'nullable|required_if:decision,rejected|string|max:2000']);
|
||||
$workflow->decide($request->user(), $award, $data['decision'], $data['comment'] ?? null);
|
||||
|
||||
return to_route('purchases.award', $award->requisition_id);
|
||||
}
|
||||
|
||||
public function issue(Request $request, PurchaseAward $award, PurchaseWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$this->access($request);
|
||||
$request->validate([
|
||||
'status' => 'prohibited', 'snapshot' => 'prohibited', 'total_minor' => 'prohibited', 'subtotal_minor' => 'prohibited', 'tax_minor' => 'prohibited',
|
||||
'issued_by' => 'prohibited', 'tenant_id' => 'prohibited', 'quotation_id' => 'prohibited', 'override' => 'prohibited', 'exception' => 'prohibited',
|
||||
]);
|
||||
$order = $workflow->issue($request->user(), $award);
|
||||
|
||||
return to_route('orders.show', $order);
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->access($request);
|
||||
|
||||
return Inertia::render('purchases/index', [
|
||||
'orders' => PurchaseOrder::query()->visibleTo($request->user())->latest('id')->paginate(15),
|
||||
'awards' => PurchaseAward::query()->whereIn('requisition_id', Requisition::query()->visibleTo($request->user())->select('id'))->latest()->paginate(15, ['*'], 'awards_page'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, PurchaseOrder $order): Response
|
||||
{
|
||||
$this->access($request);
|
||||
PurchaseOrder::query()->visibleTo($request->user())->findOrFail($order->id);
|
||||
|
||||
return Inertia::render('purchases/show', [
|
||||
'order' => $order->load('items'),
|
||||
'history' => AuditLog::query()->where('requisition_id', $order->requisition_id)->whereIn('action', ['award.created', 'award.approved', 'award.rejected', 'purchase_order.issued'])->orderBy('id')->get(['id', 'user_id', 'action', 'created_at']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function print(Request $request, PurchaseOrder $order): View
|
||||
{
|
||||
$this->access($request);
|
||||
PurchaseOrder::query()->visibleTo($request->user())->findOrFail($order->id);
|
||||
|
||||
return view('purchases.print', ['order' => $order->load('items'), 'snapshot' => $order->snapshot]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\ProposalEvaluation;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\Supplier;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\ProposalComparison;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Brick\Math\RoundingMode;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class QuotationController extends Controller
|
||||
{
|
||||
private function access(Request $request): void
|
||||
{
|
||||
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->access($request);
|
||||
|
||||
return Inertia::render('quotations/index', ['requisitions' => Requisition::query()->visibleTo($request->user())->where('status', 'approved')->with('department:id,name')->latest()->paginate(15)]);
|
||||
}
|
||||
|
||||
public function show(Request $request, Requisition $requisition): Response
|
||||
{
|
||||
$this->access($request);
|
||||
Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id);
|
||||
$guard = app(ConflictGuard::class);
|
||||
$result = app(ProposalComparison::class)->inspect($requisition);
|
||||
$scores = array_map(fn ($row) => [...$row, 'conflict_blocked' => $guard->processBlocked($requisition, $row['supplier_id'], $request->user())], $result['rows']);
|
||||
$closed = PurchaseAward::query()->where('requisition_id', $requisition->id)->exists();
|
||||
|
||||
return Inertia::render('quotations/show', [
|
||||
'requisition' => $requisition->load('items'), 'quotations' => $scores,
|
||||
'suppliers' => Supplier::query()->where('status', 'active')->where('blocked', false)->whereDate('next_review', '>=', today())->orderBy('name')->get(['id', 'name']),
|
||||
'canCreate' => ! $closed && $request->user()->purchasing_role === PurchasingRole::Procurement && $requisition->status === 'approved' && ! $guard->participationBlocked($request->user(), $requisition),
|
||||
'canEvaluate' => ! $closed && $request->user()->purchasing_role !== PurchasingRole::Admin && $request->user()->id !== $requisition->requester_id && $requisition->status === 'approved' && ! $guard->participationBlocked($request->user(), $requisition),
|
||||
'ready' => $result['ready'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->purchasing_role === PurchasingRole::Procurement, 403);
|
||||
$data = $request->validate([
|
||||
'supplier_id' => 'required|integer', 'reference' => 'required|string|max:100',
|
||||
'delivery_days' => 'required|integer|min:1|max:3650', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:today',
|
||||
'payment_terms' => 'required|string|min:3|max:500', 'no_conflict' => 'accepted',
|
||||
'items' => 'required|array|min:1|max:100', 'items.*.requisition_item_id' => 'required|integer|distinct',
|
||||
'items.*.unit_price' => ['required', 'numeric', 'decimal:0,2', 'min:0.01', 'max:1000000000'],
|
||||
]);
|
||||
DB::transaction(function () use ($request, $requisition, $data, $workflow) {
|
||||
$guard = app(ConflictGuard::class);
|
||||
$guard->lockCompany($request->user());
|
||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
||||
abort_unless($requisition->status === 'approved', 403);
|
||||
if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
|
||||
throw ValidationException::withMessages(['award' => 'As propostas estão fechadas pela adjudicação deste processo.']);
|
||||
}
|
||||
$guard->assertParticipation($request->user(), $requisition);
|
||||
$supplier = Supplier::query()->whereKey($data['supplier_id'])->lockForUpdate()->firstOrFail();
|
||||
$guard->assertProcess($requisition, $supplier->id, $request->user());
|
||||
if (! $supplier->eligible()) {
|
||||
throw ValidationException::withMessages(['supplier_id' => 'O fornecedor precisa de estar activo, sem impedimento e com revisão válida.']);
|
||||
}
|
||||
if (Quotation::query()->where('requisition_id', $requisition->id)->where('supplier_id', $supplier->id)->exists()) {
|
||||
throw ValidationException::withMessages(['supplier_id' => 'Já existe uma proposta deste fornecedor neste processo.']);
|
||||
}
|
||||
$lines = $requisition->items()->get()->keyBy('id');
|
||||
if ($lines->count() !== count($data['items'])) {
|
||||
throw ValidationException::withMessages(['items' => 'A proposta deve conter todas as linhas da requisição.']);
|
||||
}
|
||||
$items = [];
|
||||
$subtotal = 0;
|
||||
$tax = 0;
|
||||
foreach ($data['items'] as $input) {
|
||||
$line = $lines->get($input['requisition_item_id']);
|
||||
if (! $line) {
|
||||
throw ValidationException::withMessages(['items' => 'Artigo não pertence a esta requisição.']);
|
||||
}
|
||||
$price = BigDecimal::of($input['unit_price'])->multipliedBy(100)->toScale(0)->toInt();
|
||||
$amount = BigDecimal::of($line->quantity)->multipliedBy($price)->toScale(0, RoundingMode::HalfUp)->toInt();
|
||||
$vat = BigDecimal::of($amount)->multipliedBy($line->tax_rate)->dividedBy(100, 0, RoundingMode::HalfUp)->toInt();
|
||||
$subtotal += $amount;
|
||||
$tax += $vat;
|
||||
if ($subtotal + $tax > 100000000000000) {
|
||||
throw ValidationException::withMessages(['items' => 'Valor total acima do limite suportado.']);
|
||||
}
|
||||
$items[] = ['tenant_id' => $request->user()->tenant_id, 'requisition_item_id' => $line->id, 'description' => $line->description, 'quantity' => $line->quantity, 'unit_price_minor' => $price, 'tax_rate' => $line->tax_rate, 'subtotal_minor' => $amount, 'tax_minor' => $vat, 'total_minor' => $amount + $vat];
|
||||
}
|
||||
if ($subtotal <= 0) {
|
||||
throw ValidationException::withMessages(['items' => 'O total arredondado deve ser pelo menos 0,01 MZN.']);
|
||||
}
|
||||
$quote = Quotation::query()->create([
|
||||
'tenant_id' => $request->user()->tenant_id, 'requisition_id' => $requisition->id, 'supplier_id' => $supplier->id, 'created_by' => $request->user()->id,
|
||||
'reference' => $data['reference'], 'delivery_days' => $data['delivery_days'], 'valid_until' => $data['valid_until'], 'payment_terms' => $data['payment_terms'],
|
||||
'subtotal_minor' => $subtotal, 'tax_minor' => $tax, 'total_minor' => $subtotal + $tax, 'currency' => 'MZN',
|
||||
'weights' => ['price' => 40, 'quality' => 20, 'delivery' => 15, 'history' => 15, 'payment' => 10],
|
||||
]);
|
||||
$quote->items()->createMany($items);
|
||||
$workflow->audit($request->user(), 'quotation.created', $requisition, null, [...$quote->load('items')->toArray(), 'no_conflict' => true]);
|
||||
});
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function evaluate(Request $request, Quotation $quotation, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$this->access($request);
|
||||
abort_if($request->user()->purchasing_role === PurchasingRole::Admin, 403);
|
||||
$data = $request->validate(['quality' => 'required|numeric|decimal:0,2|between:0,10', 'history' => 'required|numeric|decimal:0,2|between:0,10', 'payment' => 'required|numeric|decimal:0,2|between:0,10', 'comment' => 'required|string|min:10|max:2000', 'no_conflict' => 'accepted']);
|
||||
DB::transaction(function () use ($request, $quotation, $data, $workflow) {
|
||||
$guard = app(ConflictGuard::class);
|
||||
$guard->lockCompany($request->user());
|
||||
$requisition = Requisition::query()->visibleTo($request->user())->lockForUpdate()->findOrFail($quotation->requisition_id);
|
||||
abort_unless($requisition->status === 'approved' && $requisition->requester_id !== $request->user()->id, 403);
|
||||
if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
|
||||
throw ValidationException::withMessages(['award' => 'As avaliações estão fechadas pela adjudicação deste processo.']);
|
||||
}
|
||||
$guard->assertParticipation($request->user(), $requisition);
|
||||
$quotation = Quotation::query()->lockForUpdate()->findOrFail($quotation->id);
|
||||
$guard->assertProcess($requisition, $quotation->supplier_id, $request->user());
|
||||
if ($quotation->valid_until < today() || ! $quotation->supplier?->eligible()) {
|
||||
throw ValidationException::withMessages(['evaluation' => 'Proposta expirada ou fornecedor não elegível.']);
|
||||
}
|
||||
if ($quotation->evaluations()->where('evaluator_id', $request->user()->id)->exists()) {
|
||||
throw ValidationException::withMessages(['evaluation' => 'Já avaliou esta proposta.']);
|
||||
}
|
||||
$evaluation = ProposalEvaluation::query()->create([...$data, 'no_conflict' => true, 'tenant_id' => $request->user()->tenant_id, 'quotation_id' => $quotation->id, 'evaluator_id' => $request->user()->id]);
|
||||
$workflow->audit($request->user(), 'quotation.evaluated', $requisition, null, $evaluation->toArray());
|
||||
});
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreRequisitionRequest;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\CostCenter;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class RequisitionController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$filters = $request->validate(['search' => 'nullable|string|max:100', 'status' => ['nullable', Rule::in(['draft', 'pending', 'approved', 'rejected'])]]);
|
||||
$query = Requisition::query()->visibleTo($request->user())->with(['requester:id,name', 'department:id,name', 'steps']);
|
||||
if (! empty($filters['search'])) {
|
||||
$query->where('number', 'like', '%'.$filters['search'].'%');
|
||||
}
|
||||
if (! empty($filters['status'])) {
|
||||
$query->where('status', $filters['status']);
|
||||
}
|
||||
|
||||
return Inertia::render('requisitions/index', [
|
||||
'requisitions' => $query->latest()->paginate(15)->withQueryString(),
|
||||
'filters' => $filters,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
return Inertia::render('requisitions/form', [
|
||||
'costCenters' => CostCenter::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(),
|
||||
'requisition' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreRequisitionRequest $request, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$requisition = $workflow->save($request->user(), $request->validated());
|
||||
|
||||
return to_route('requisitions.show', $requisition);
|
||||
}
|
||||
|
||||
public function show(Request $request, Requisition $requisition): Response
|
||||
{
|
||||
Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id);
|
||||
$requisition->load(['items', 'steps', 'requester:id,name', 'department:id,name', 'costCenter']);
|
||||
|
||||
return Inertia::render('requisitions/show', [
|
||||
'requisition' => $requisition,
|
||||
'canEdit' => $requisition->requester_id === $request->user()->id && $requisition->status === 'draft',
|
||||
'canDecide' => $requisition->canDecide($request->user()),
|
||||
'conflictBlocked' => app(ConflictGuard::class)->participationBlocked($request->user(), $requisition),
|
||||
'audit' => AuditLog::query()->where('requisition_id', $requisition->id)->orderBy('id')->get(['id', 'action', 'user_id', 'created_at']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(Request $request, Requisition $requisition): Response
|
||||
{
|
||||
abort_unless($requisition->requester_id === $request->user()->id && $requisition->status === 'draft', 403);
|
||||
|
||||
return Inertia::render('requisitions/form', [
|
||||
'requisition' => $requisition->load('items'),
|
||||
'costCenters' => CostCenter::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(StoreRequisitionRequest $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$workflow->save($request->user(), $request->validated(), $requisition);
|
||||
|
||||
return to_route('requisitions.show', $requisition);
|
||||
}
|
||||
|
||||
public function submit(Request $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$workflow->submit($request->user(), $requisition);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function decide(Request $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'decision' => ['required', Rule::in(['approved', 'rejected'])],
|
||||
'comment' => ['nullable', 'required_if:decision,rejected', 'string', 'max:2000'],
|
||||
]);
|
||||
$workflow->decide($request->user(), $requisition, $data['decision'], $data['comment'] ?? null);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function dashboard(Request $request): Response
|
||||
{
|
||||
$visible = Requisition::query()->visibleTo($request->user());
|
||||
$pending = (clone $visible)->where('status', 'pending')->with('steps')->get();
|
||||
|
||||
return Inertia::render('dashboard', [
|
||||
'company' => $request->user()->tenant_id ? Tenant::query()->find($request->user()->tenant_id) : null,
|
||||
'stats' => [
|
||||
'total' => (clone $visible)->count(),
|
||||
'pending' => $pending->count(),
|
||||
'approved' => (clone $visible)->where('status', 'approved')->count(),
|
||||
'approvedMinor' => (clone $visible)->where('status', 'approved')->sum('total_minor'),
|
||||
'awaitingMe' => $pending->filter(fn (Requisition $r) => $r->canDecide($request->user()))->count(),
|
||||
],
|
||||
'recent' => (clone $visible)->with(['requester:id,name', 'department:id,name'])->latest()->limit(6)->get(),
|
||||
'approvals' => $pending->filter(fn (Requisition $r) => $r->canDecide($request->user()))->take(5)->values(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@@ -50,6 +51,12 @@ class ProfileController extends Controller
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user->tenant_id !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => 'Uma conta associada a uma empresa não pode ser eliminada desta forma, para preservar os registos de compras e auditoria.',
|
||||
]);
|
||||
}
|
||||
|
||||
Auth::logout();
|
||||
|
||||
$user->delete();
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Http\Requests\SaveSupplierRequest;
|
||||
use App\Models\Supplier;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class SupplierController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
$filters = $request->validate(['search' => 'nullable|string|max:100']);
|
||||
$query = Supplier::query();
|
||||
if (! empty($filters['search'])) {
|
||||
$query->where(fn ($q) => $q->where('name', 'like', '%'.$filters['search'].'%')->orWhere('nuit', 'like', '%'.$filters['search'].'%'));
|
||||
}
|
||||
|
||||
return Inertia::render('suppliers/index', ['suppliers' => $query->orderBy('name')->paginate(15)->withQueryString(), 'filters' => $filters, 'canCreate' => $request->user()->purchasing_role === PurchasingRole::Procurement, 'canReview' => $request->user()->purchasing_role === PurchasingRole::Finance]);
|
||||
}
|
||||
|
||||
public function store(SaveSupplierRequest $request, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
DB::transaction(function () use ($request, $data, $workflow) {
|
||||
$supplier = Supplier::query()->create([...$data, 'tenant_id' => $request->user()->tenant_id, 'created_by' => $request->user()->id, 'last_changed_by' => $request->user()->id, 'status' => 'pending']);
|
||||
$workflow->audit($request->user(), 'supplier.created', null, null, $supplier->toArray());
|
||||
});
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function show(Request $request, Supplier $supplier): Response
|
||||
{
|
||||
Gate::authorize('view', $supplier);
|
||||
$canDocuments = $request->user()->can('viewDocuments', $supplier);
|
||||
|
||||
return Inertia::render('suppliers/show', [
|
||||
'supplier' => $supplier,
|
||||
'documents' => $canDocuments ? $supplier->documents()->latest('id')->get() : [],
|
||||
'canEdit' => $request->user()->can('update', $supplier),
|
||||
'canDocuments' => $canDocuments,
|
||||
'canReview' => $request->user()->purchasing_role === PurchasingRole::Finance,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(Supplier $supplier): Response
|
||||
{
|
||||
Gate::authorize('update', $supplier);
|
||||
|
||||
return Inertia::render('suppliers/edit', ['supplier' => $supplier]);
|
||||
}
|
||||
|
||||
public function update(SaveSupplierRequest $request, Supplier $supplier, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
Gate::authorize('update', $supplier);
|
||||
$data = $request->validated();
|
||||
DB::transaction(function () use ($request, $supplier, $workflow, $data) {
|
||||
$supplier = Supplier::query()->lockForUpdate()->findOrFail($supplier->id);
|
||||
if ($supplier->version !== (int) $data['version']) {
|
||||
throw ValidationException::withMessages(['version' => 'A ficha foi alterada por outro utilizador. Reabra a edição para carregar os dados actuais.']);
|
||||
}
|
||||
$before = $supplier->toArray();
|
||||
unset($data['version']);
|
||||
$supplier->fill($data);
|
||||
if ($supplier->isDirty()) {
|
||||
$supplier->fill(['status' => 'pending', 'reviewed_by' => null, 'review_comment' => null, 'last_changed_by' => $request->user()->id, 'version' => $supplier->version + 1])->save();
|
||||
$workflow->audit($request->user(), 'supplier.updated', null, $before, $supplier->toArray());
|
||||
}
|
||||
});
|
||||
|
||||
return to_route('suppliers.show', $supplier);
|
||||
}
|
||||
|
||||
public function review(Request $request, Supplier $supplier, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->purchasing_role === PurchasingRole::Finance, 403);
|
||||
$data = $request->validate(['status' => ['required', Rule::in(['active', 'suspended'])], 'comment' => 'required|string|min:10|max:2000', 'version' => 'required|integer|min:1']);
|
||||
DB::transaction(function () use ($request, $supplier, $data, $workflow) {
|
||||
$supplier = Supplier::query()->lockForUpdate()->findOrFail($supplier->id);
|
||||
abort_if($supplier->created_by === $request->user()->id, 403);
|
||||
abort_if($supplier->last_changed_by === $request->user()->id, 403);
|
||||
if ($supplier->version !== (int) $data['version']) {
|
||||
throw ValidationException::withMessages(['version' => 'A ficha ou os documentos foram alterados. Reabra a ficha antes de validar.']);
|
||||
}
|
||||
if ($data['status'] === 'active' && ($supplier->blocked || $supplier->next_review < today())) {
|
||||
throw ValidationException::withMessages(['status' => 'Fornecedor impedido ou com revisão vencida. Não pode ser activado.']);
|
||||
}
|
||||
$before = $supplier->toArray();
|
||||
$supplier->update(['status' => $data['status'], 'review_comment' => $data['comment'], 'reviewed_by' => $request->user()->id, 'version' => $supplier->version + 1]);
|
||||
$workflow->audit($request->user(), 'supplier.reviewed', null, $before, $supplier->toArray());
|
||||
});
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Supplier;
|
||||
use App\Models\SupplierDocument;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Throwable;
|
||||
|
||||
class SupplierDocumentController extends Controller
|
||||
{
|
||||
public function store(Request $request, Supplier $supplier, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
Gate::authorize('update', $supplier);
|
||||
$data = $request->validate([
|
||||
'document_type' => ['required', Rule::in(['license', 'tax', 'conflict', 'other'])],
|
||||
'file' => ['required', 'file', 'mimes:pdf,jpg,jpeg,png', 'extensions:pdf,jpg,jpeg,png', 'min:1', 'max:2048'],
|
||||
]);
|
||||
$file = $request->file('file');
|
||||
abort_unless($file instanceof UploadedFile, 422);
|
||||
$path = $file->store($supplier->tenant_id.'/'.$supplier->id, 'supplier_documents');
|
||||
abort_if($path === false, 500);
|
||||
try {
|
||||
DB::transaction(function () use ($request, $supplier, $workflow, $data, $file, $path) {
|
||||
$supplier = Supplier::query()->lockForUpdate()->findOrFail($supplier->id);
|
||||
$before = $supplier->toArray();
|
||||
$name = Str::substr(Str::replaceMatches('/[^\pL\pN ._()-]/u', '_', basename(str_replace('\\', '/', $file->getClientOriginalName()))), 0, 180);
|
||||
$document = $supplier->documents()->create([
|
||||
'tenant_id' => $supplier->tenant_id, 'uploaded_by' => $request->user()->id,
|
||||
'document_type' => $data['document_type'], 'original_name' => $name,
|
||||
'path' => $path, 'mime_type' => $file->getMimeType(), 'size' => $file->getSize(),
|
||||
'sha256' => hash_file('sha256', $file->getPathname()),
|
||||
]);
|
||||
$supplier->update(['status' => 'pending', 'reviewed_by' => null, 'review_comment' => null, 'last_changed_by' => $request->user()->id, 'version' => $supplier->version + 1]);
|
||||
$workflow->audit($request->user(), 'supplier.document_uploaded', null, $before, ['supplier' => $supplier->toArray(), 'document' => $document->toArray()]);
|
||||
});
|
||||
} catch (Throwable $exception) {
|
||||
Storage::disk('supplier_documents')->delete($path);
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function download(Request $request, Supplier $supplier, SupplierDocument $document, RequisitionWorkflow $workflow): StreamedResponse
|
||||
{
|
||||
Gate::authorize('viewDocuments', $supplier);
|
||||
abort_unless($document->supplier_id === $supplier->id, 404);
|
||||
$disk = Storage::disk('supplier_documents');
|
||||
abort_unless($disk->exists($document->path), 404);
|
||||
$workflow->audit($request->user(), 'supplier.document_downloaded', null, null, ['supplier_id' => $supplier->id, 'document_id' => $document->id, 'name' => $document->original_name]);
|
||||
|
||||
return $disk->download($document->path, $document->original_name, [
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
'Content-Security-Policy' => "default-src 'none'; sandbox",
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureCompanyIsConfigured
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->user()?->tenant_id) {
|
||||
return redirect()->route('company.setup');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Supplier;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SaveSupplierRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->purchasing_role === PurchasingRole::Procurement;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
$supplier = $this->route('supplier');
|
||||
$uniqueNuit = Rule::unique('suppliers')->where('tenant_id', $this->user()?->tenant_id);
|
||||
if ($supplier instanceof Supplier) {
|
||||
$uniqueNuit->ignore($supplier->id);
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => 'required|string|max:200',
|
||||
'nuit' => ['required', 'regex:/^\d{9}$/', $uniqueNuit],
|
||||
'contact' => 'required|string|max:200', 'email' => 'required|email|max:200',
|
||||
'phone' => 'required|string|max:50', 'address' => 'required|string|max:500',
|
||||
'category' => 'required|string|max:200', 'beneficiaries' => 'required|string|min:3|max:2000',
|
||||
'document_references' => 'required|string|min:5|max:3000', 'diligence_notes' => 'required|string|min:10|max:3000',
|
||||
'blocked' => 'required|boolean', 'next_review' => 'required|date_format:Y-m-d|after_or_equal:today',
|
||||
'version' => $supplier instanceof Supplier ? 'required|integer|min:1' : 'prohibited',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreRequisitionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
return $user !== null && $user->tenant_id !== null && $user->department_id !== null;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'cost_center_id' => ['required', 'integer', Rule::exists('cost_centers', 'id')->where('tenant_id', $this->user()?->tenant_id)->where('department_id', $this->user()?->department_id)],
|
||||
'priority' => ['required', Rule::in(['normal', 'high', 'urgent'])],
|
||||
'needed_by' => ['required', 'date_format:Y-m-d', 'after_or_equal:today'],
|
||||
'justification' => ['required', 'string', 'min:10', 'max:5000'],
|
||||
'items' => ['required', 'array', 'min:1', 'max:50'],
|
||||
'items.*.description' => ['required', 'string', 'max:255'],
|
||||
'items.*.category' => ['required', 'string', 'max:100'],
|
||||
'items.*.purchase_type' => ['required', Rule::in(['consumable', 'service', 'other'])],
|
||||
'items.*.quantity' => ['required', 'numeric', 'min:0.001', 'max:1000000', 'regex:/^\d+(\.\d{1,3})?$/'],
|
||||
'items.*.unit' => ['required', 'string', 'max:30'],
|
||||
'items.*.unit_price' => ['required', 'numeric', 'min:0.01', 'max:100000000', 'regex:/^\d+(\.\d{1,2})?$/'],
|
||||
'items.*.tax_rate' => ['required', 'numeric', 'between:0,100', 'regex:/^\d+(\.\d{1,2})?$/'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'cost_center_id.exists' => 'Seleccione um centro de custo do seu departamento.',
|
||||
'needed_by.after_or_equal' => 'A entrega pretendida deve ser hoje ou numa data futura.',
|
||||
'justification.min' => 'Descreva a necessidade da compra com pelo menos 10 caracteres.',
|
||||
'items.*.quantity.regex' => 'Use até três casas decimais na quantidade.',
|
||||
'items.*.unit_price.regex' => 'Use até duas casas decimais no preço.',
|
||||
'items.*.tax_rate.regex' => 'Use até duas casas decimais na taxa de IVA.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property string $role
|
||||
* @property string $decision
|
||||
* @property int|null $approver_id
|
||||
*/
|
||||
class ApprovalStep extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use LogicException;
|
||||
|
||||
class AuditLog extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['before' => 'array', 'after' => 'array'];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::updating(fn () => throw new LogicException('O histórico de auditoria não pode ser alterado.'));
|
||||
static::deleting(fn () => throw new LogicException('O histórico de auditoria não pode ser eliminado.'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
trait BelongsToTenant
|
||||
{
|
||||
protected static function bootBelongsToTenant(): void
|
||||
{
|
||||
static::addGlobalScope('tenant', function (Builder $query) {
|
||||
$tenantId = auth()->check() ? auth()->user()->tenant_id : null;
|
||||
$query->where($query->getModel()->qualifyColumn('tenant_id'), $tenantId ?? 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use LogicException;
|
||||
|
||||
trait ImmutablePurchaseRecord
|
||||
{
|
||||
protected static function bootImmutablePurchaseRecord(): void
|
||||
{
|
||||
static::updating(fn () => throw new LogicException('O documento emitido não pode ser reescrito.'));
|
||||
static::deleting(fn () => throw new LogicException('O documento emitido deve ser preservado.'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use LogicException;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property int $user_id
|
||||
* @property int|null $supplier_id
|
||||
* @property int|null $requisition_id
|
||||
* @property int|null $root_id
|
||||
* @property bool $has_conflict
|
||||
*/
|
||||
class ConflictDeclaration extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['has_conflict' => 'boolean', 'known_since' => 'date:Y-m-d', 'declared_at' => 'datetime'];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::updating(fn () => throw new LogicException('As declarações são preservadas; registe uma correcção.'));
|
||||
static::deleting(fn () => throw new LogicException('As declarações de conflito não podem ser eliminadas.'));
|
||||
}
|
||||
|
||||
/** @param Builder<ConflictDeclaration> $query */
|
||||
public function scopeVisibleTo(Builder $query, User $user): void
|
||||
{
|
||||
if (! in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance])) {
|
||||
$query->where('user_id', $user->id);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function author(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Supplier, $this> */
|
||||
public function supplier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Supplier::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Requisition, $this> */
|
||||
public function requisition(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Requisition::class);
|
||||
}
|
||||
|
||||
/** @return HasOne<ConflictDeclaration, $this> */
|
||||
public function successor(): HasOne
|
||||
{
|
||||
return $this->hasOne(self::class, 'previous_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $department_id
|
||||
*/
|
||||
class CostCenter extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/** @property int $id */
|
||||
class Department extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property string $quality
|
||||
* @property string $history
|
||||
* @property string $payment
|
||||
*/
|
||||
class ProposalEvaluation extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['no_conflict' => 'boolean', 'quality' => 'decimal:2', 'history' => 'decimal:2', 'payment' => 'decimal:2'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property int $requisition_id
|
||||
* @property int $quotation_id
|
||||
* @property int $created_by
|
||||
* @property string $status
|
||||
* @property string $comparison_hash
|
||||
* @property array<string, mixed> $approval_snapshot
|
||||
* @property list<array<string, mixed>> $decision_matrix
|
||||
*/
|
||||
class PurchaseAward extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['approval_snapshot' => 'array', 'decision_matrix' => 'array', 'delivery_date' => 'date:Y-m-d'];
|
||||
}
|
||||
|
||||
/** @return HasMany<PurchaseAwardApproval, $this> */
|
||||
public function approvals(): HasMany
|
||||
{
|
||||
return $this->hasMany(PurchaseAwardApproval::class)->orderBy('position');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PurchaseAwardApproval extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use App\Models\Concerns\ImmutablePurchaseRecord;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property int $requisition_id
|
||||
* @property string $number
|
||||
* @property array<string, mixed> $snapshot
|
||||
*/
|
||||
class PurchaseOrder extends Model
|
||||
{
|
||||
use BelongsToTenant, ImmutablePurchaseRecord;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['snapshot' => 'array', 'issued_at' => 'datetime'];
|
||||
}
|
||||
|
||||
/** @return HasMany<PurchaseOrderItem, $this> */
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(PurchaseOrderItem::class);
|
||||
}
|
||||
|
||||
/** @param Builder<PurchaseOrder> $query */
|
||||
public function scopeVisibleTo(Builder $query, User $user): void
|
||||
{
|
||||
$query->whereIn('requisition_id', Requisition::query()->visibleTo($user)->select('id'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use App\Models\Concerns\ImmutablePurchaseRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PurchaseOrderItem extends Model
|
||||
{
|
||||
use BelongsToTenant, ImmutablePurchaseRecord;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/** @property array<string, mixed> $weights */
|
||||
class Quotation extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['weights' => 'array', 'valid_until' => 'date:Y-m-d'];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Supplier, $this> */
|
||||
public function supplier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Supplier::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<QuotationItem, $this> */
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(QuotationItem::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<ProposalEvaluation, $this> */
|
||||
public function evaluations(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProposalEvaluation::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property string $quantity
|
||||
* @property string $tax_rate
|
||||
*/
|
||||
class QuotationItem extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['quantity' => 'decimal:3', 'tax_rate' => 'decimal:2'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use App\Services\ConflictGuard;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property int $department_id
|
||||
* @property int $requester_id
|
||||
* @property string $number
|
||||
* @property string $status
|
||||
* @property int $subtotal_minor
|
||||
* @property int $tax_minor
|
||||
* @property int $total_minor
|
||||
* @property Collection<int, ApprovalStep> $steps
|
||||
* @property Collection<int, RequisitionItem> $items
|
||||
*/
|
||||
class Requisition extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['approval_snapshot' => 'array', 'submitted_at' => 'datetime', 'needed_by' => 'date:Y-m-d'];
|
||||
}
|
||||
|
||||
/** @return HasMany<RequisitionItem, $this> */
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(RequisitionItem::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<ApprovalStep, $this> */
|
||||
public function steps(): HasMany
|
||||
{
|
||||
return $this->hasMany(ApprovalStep::class)->orderBy('position');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function requester(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'requester_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Department, $this> */
|
||||
public function department(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Department::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<CostCenter, $this> */
|
||||
public function costCenter(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CostCenter::class);
|
||||
}
|
||||
|
||||
/** @param Builder<Requisition> $query */
|
||||
public function scopeVisibleTo(Builder $query, User $user): void
|
||||
{
|
||||
match ($user->purchasing_role) {
|
||||
PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance => null,
|
||||
PurchasingRole::DepartmentHead => $query->where('department_id', $user->department_id),
|
||||
default => $query->where('requester_id', $user->id),
|
||||
};
|
||||
}
|
||||
|
||||
public function canDecide(User $user): bool
|
||||
{
|
||||
if ($this->tenant_id !== $user->tenant_id || $this->status !== 'pending' || $this->requester_id === $user->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->steps->contains('approver_id', $user->id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$next = $this->steps->firstWhere('decision', 'pending');
|
||||
|
||||
return $next !== null && $next->role === $user->purchasing_role->value
|
||||
&& ($next->role !== 'department_head' || $this->department_id === $user->department_id)
|
||||
&& ! app(ConflictGuard::class)->participationBlocked($user, $this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Decimal casts expose exact decimal strings for monetary calculations.
|
||||
*
|
||||
* @property string $quantity
|
||||
* @property string $tax_rate
|
||||
*/
|
||||
class RequisitionItem extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['quantity' => 'decimal:3', 'tax_rate' => 'decimal:2'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property int $created_by
|
||||
* @property int|null $last_changed_by
|
||||
* @property int $version
|
||||
*/
|
||||
class Supplier extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $attributes = ['version' => 1];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['blocked' => 'boolean', 'next_review' => 'date:Y-m-d'];
|
||||
}
|
||||
|
||||
public function eligible(): bool
|
||||
{
|
||||
return $this->status === 'active' && ! $this->blocked && $this->next_review >= today();
|
||||
}
|
||||
|
||||
/** @return HasMany<SupplierDocument, $this> */
|
||||
public function documents(): HasMany
|
||||
{
|
||||
return $this->hasMany(SupplierDocument::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $supplier_id
|
||||
* @property string $path
|
||||
* @property string $original_name
|
||||
*/
|
||||
class SupplierDocument extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $hidden = ['path', 'tenant_id'];
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property int $first_limit_minor
|
||||
* @property int $second_limit_minor
|
||||
* @property bool $approval_includes_tax
|
||||
* @property bool $rules_provisional
|
||||
*/
|
||||
class Tenant extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['approval_includes_tax' => 'boolean', 'rules_provisional' => 'boolean'];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
@@ -12,6 +13,9 @@ use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int|null $tenant_id
|
||||
* @property int|null $department_id
|
||||
* @property PurchasingRole $purchasing_role
|
||||
* @property string $name
|
||||
* @property string $email
|
||||
* @property Carbon|null $email_verified_at
|
||||
@@ -38,6 +42,7 @@ class User extends Authenticatable
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'purchasing_role' => PurchasingRole::class,
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\User;
|
||||
|
||||
class SupplierPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->tenant_id !== null && in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
|
||||
}
|
||||
|
||||
public function view(User $user, Supplier $supplier): bool
|
||||
{
|
||||
return $this->viewAny($user) && $user->tenant_id === $supplier->tenant_id;
|
||||
}
|
||||
|
||||
public function update(User $user, Supplier $supplier): bool
|
||||
{
|
||||
return $user->purchasing_role === PurchasingRole::Procurement && $user->tenant_id === $supplier->tenant_id;
|
||||
}
|
||||
|
||||
public function viewDocuments(User $user, Supplier $supplier): bool
|
||||
{
|
||||
return $this->view($user, $supplier) && in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\ConflictDeclaration;
|
||||
use App\Models\ProposalEvaluation;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\PurchaseAwardApproval;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ConflictGuard
|
||||
{
|
||||
/** Call first inside a transaction, before locking a requisition or supplier. */
|
||||
public function lockCompany(User $user): void
|
||||
{
|
||||
Tenant::query()->whereKey($user->tenant_id)->lockForUpdate()->firstOrFail();
|
||||
}
|
||||
|
||||
public function hasConflict(int $userId, ?int $supplierId, ?int $requisitionId): bool
|
||||
{
|
||||
return ConflictDeclaration::query()->where('user_id', $userId)->where('has_conflict', true)
|
||||
->where(function ($scopes) use ($supplierId, $requisitionId) {
|
||||
// Supplier-wide statements apply only once that supplier is known.
|
||||
$scopes->where(function ($supplierScope) use ($supplierId) {
|
||||
$supplierScope->whereNull('requisition_id')->where('supplier_id', $supplierId ?? 0);
|
||||
});
|
||||
if ($requisitionId !== null) {
|
||||
$scopes->orWhere(function ($processScope) use ($supplierId, $requisitionId) {
|
||||
$processScope->where('requisition_id', $requisitionId)->where(function ($supplier) use ($supplierId) {
|
||||
$supplier->whereNull('supplier_id');
|
||||
if ($supplierId !== null) {
|
||||
$supplier->orWhere('supplier_id', $supplierId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
})->exists();
|
||||
}
|
||||
|
||||
public function participationBlocked(User $user, Requisition $requisition): bool
|
||||
{
|
||||
if ($this->hasConflict($user->id, null, $requisition->id)) {
|
||||
return true;
|
||||
}
|
||||
foreach ($this->participants($requisition) as $userId) {
|
||||
if ($this->hasConflict($userId, null, $requisition->id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
foreach (Quotation::query()->where('requisition_id', $requisition->id)->pluck('supplier_id') as $supplierId) {
|
||||
if ($this->processBlocked($requisition, $supplierId, $user)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function assertParticipation(User $user, Requisition $requisition): void
|
||||
{
|
||||
if ($this->participationBlocked($user, $requisition)) {
|
||||
throw ValidationException::withMessages(['conflict' => 'Existe um conflito de interesses activo que impede a sua participação neste processo.']);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
private function participants(Requisition $requisition): array
|
||||
{
|
||||
$quotes = Quotation::query()->where('requisition_id', $requisition->id);
|
||||
$awards = PurchaseAward::query()->where('requisition_id', $requisition->id);
|
||||
|
||||
$participants = collect([$requisition->requester_id])
|
||||
->merge($requisition->steps()->whereNotNull('approver_id')->pluck('approver_id'))
|
||||
->merge((clone $quotes)->pluck('created_by'))
|
||||
->merge(ProposalEvaluation::query()->whereIn('quotation_id', (clone $quotes)->select('id'))->pluck('evaluator_id'))
|
||||
->merge((clone $awards)->pluck('created_by'))
|
||||
->merge(PurchaseAwardApproval::query()->whereIn('purchase_award_id', (clone $awards)->select('id'))->whereNotNull('approver_id')->pluck('approver_id'))
|
||||
->merge(AuditLog::query()->where('requisition_id', $requisition->id)->whereIn('action', [
|
||||
'requisition.created', 'requisition.updated', 'requisition.submitted',
|
||||
'approval.approved', 'approval.rejected', 'quotation.created', 'quotation.evaluated',
|
||||
'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued',
|
||||
])->pluck('user_id'))
|
||||
->unique()->values()->all();
|
||||
$ids = [];
|
||||
foreach ($participants as $participant) {
|
||||
$id = filter_var($participant, FILTER_VALIDATE_INT);
|
||||
if ($id === false) {
|
||||
throw new \LogicException('Identificador inválido no histórico de intervenientes.');
|
||||
}
|
||||
$ids[] = $id;
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public function processBlocked(Requisition $requisition, int $supplierId, ?User $actor = null): bool
|
||||
{
|
||||
$participants = $this->participants($requisition);
|
||||
if ($actor !== null) {
|
||||
$participants[] = $actor->id;
|
||||
}
|
||||
foreach (array_unique($participants) as $userId) {
|
||||
if ($this->hasConflict($userId, $supplierId, $requisition->id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function assertProcess(Requisition $requisition, int $supplierId, User $actor): void
|
||||
{
|
||||
if ($this->processBlocked($requisition, $supplierId, $actor)) {
|
||||
throw ValidationException::withMessages(['conflict' => 'Existe um conflito de interesses activo de um interveniente neste processo com este fornecedor. A operação está impedida, mesmo com declaração de ausência de conflito.']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\ProposalEvaluation;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\User;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Brick\Math\BigRational;
|
||||
use Brick\Math\Exception\MathException;
|
||||
use Brick\Math\RoundingMode;
|
||||
|
||||
class ProposalComparison
|
||||
{
|
||||
/** @return array{rows: list<array<string, mixed>>, ready: bool, top_ids: list<int>, fingerprint: string} */
|
||||
public function inspect(Requisition $requisition): array
|
||||
{
|
||||
$quotes = Quotation::query()->where('requisition_id', $requisition->id)
|
||||
->with(['supplier', 'items' => fn ($q) => $q->orderBy('id'), 'evaluations' => fn ($q) => $q->orderBy('id')])->orderBy('id')->get();
|
||||
$guard = app(ConflictGuard::class);
|
||||
$referenceWeights = $quotes->first()?->weights;
|
||||
$rows = [];
|
||||
foreach ($quotes as $quote) {
|
||||
$reasons = [];
|
||||
if (! $quote->supplier?->eligible() || $quote->valid_until < today()) {
|
||||
$reasons[] = 'Fornecedor não elegível ou proposta expirada.';
|
||||
}
|
||||
if (! $this->validWeights($quote->weights) || $quote->weights != $referenceWeights) {
|
||||
$reasons[] = 'As ponderações das propostas não são comparáveis.';
|
||||
}
|
||||
if (! $this->validAmounts($quote, $requisition)) {
|
||||
$reasons[] = 'Moeda, linhas ou valores inconsistentes com a requisição.';
|
||||
}
|
||||
if ($guard->processBlocked($requisition, $quote->supplier_id)) {
|
||||
$reasons[] = 'Conflito de interesses de um interveniente.';
|
||||
}
|
||||
$evaluations = $quote->evaluations->filter(fn ($evaluation) => $this->eligibleEvaluator($evaluation, $requisition))->values();
|
||||
$rows[] = [
|
||||
...$quote->toArray(), 'id' => $quote->id, 'supplier_id' => $quote->supplier_id,
|
||||
'weights' => $quote->weights, 'subtotal_minor' => $quote->subtotal_minor, 'delivery_days' => $quote->delivery_days,
|
||||
'items' => $quote->items->toArray(), 'evaluations' => $quote->evaluations->toArray(),
|
||||
'eligible' => $reasons === [], 'ineligibility_reasons' => $reasons,
|
||||
'qualified_evaluations' => $evaluations->toArray(), 'evaluation_count' => $evaluations->count(),
|
||||
'score' => null, 'score_minor' => null,
|
||||
];
|
||||
}
|
||||
$eligible = array_values(array_filter($rows, fn ($row) => $row['eligible']));
|
||||
$minimum = null;
|
||||
$days = null;
|
||||
foreach ($eligible as $row) {
|
||||
$minimum = $minimum === null ? $row['subtotal_minor'] : min($minimum, $row['subtotal_minor']);
|
||||
$days = $days === null ? $row['delivery_days'] : min($days, $row['delivery_days']);
|
||||
}
|
||||
$best = null;
|
||||
$topIds = [];
|
||||
foreach ($rows as &$row) {
|
||||
if ($row['eligible'] && $row['evaluation_count'] >= 2) {
|
||||
$weights = $row['weights'];
|
||||
$score = BigRational::of($minimum)->dividedBy($row['subtotal_minor'])->multipliedBy(10 * $weights['price'])
|
||||
->plus(BigRational::of($days)->dividedBy($row['delivery_days'])->multipliedBy(10 * $weights['delivery']));
|
||||
foreach (['quality', 'history', 'payment'] as $criterion) {
|
||||
$sum = BigRational::zero();
|
||||
foreach ($row['qualified_evaluations'] as $evaluation) {
|
||||
$sum = $sum->plus($evaluation[$criterion]);
|
||||
}
|
||||
$score = $score->plus($sum->dividedBy($row['evaluation_count'])->multipliedBy($weights[$criterion]));
|
||||
}
|
||||
$minor = $score->dividedBy(100)->toScale(2, RoundingMode::HalfUp)->multipliedBy(100)->toInt();
|
||||
$row['score_minor'] = $minor;
|
||||
$row['score'] = $minor / 100;
|
||||
if ($best === null || $minor > $best) {
|
||||
$best = $minor;
|
||||
$topIds = [$row['id']];
|
||||
} elseif ($minor === $best) {
|
||||
$topIds[] = $row['id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
$basis = [];
|
||||
foreach ($rows as $row) {
|
||||
$basis[] = [
|
||||
'quotation' => array_intersect_key($row, array_flip(['id', 'supplier_id', 'reference', 'delivery_days', 'valid_until', 'payment_terms', 'subtotal_minor', 'tax_minor', 'total_minor', 'currency', 'weights'])),
|
||||
'items' => $row['items'], 'evaluations' => $row['evaluations'],
|
||||
'qualified_ids' => array_column($row['qualified_evaluations'], 'id'),
|
||||
'eligible' => $row['eligible'], 'score_minor' => $row['score_minor'],
|
||||
];
|
||||
}
|
||||
$ready = count($eligible) >= 3 && count(array_unique(array_column($eligible, 'supplier_id'))) >= 3;
|
||||
foreach ($eligible as $row) {
|
||||
$ready = $ready && $row['evaluation_count'] >= 2;
|
||||
}
|
||||
|
||||
return ['rows' => $rows, 'ready' => $ready, 'top_ids' => $topIds, 'fingerprint' => hash('sha256', json_encode($basis, JSON_THROW_ON_ERROR))];
|
||||
}
|
||||
|
||||
public function eligibleEvaluator(ProposalEvaluation $evaluation, Requisition $requisition): bool
|
||||
{
|
||||
$user = User::query()->where('tenant_id', $requisition->tenant_id)->find($evaluation->evaluator_id);
|
||||
if (! $user || ! $evaluation->no_conflict || $user->id === $requisition->requester_id) {
|
||||
return false;
|
||||
}
|
||||
if (! $this->eligibleRole($user, $requisition)) {
|
||||
return false;
|
||||
}
|
||||
foreach (['quality', 'history', 'payment'] as $criterion) {
|
||||
if (! is_numeric($evaluation->$criterion) || $evaluation->$criterion < 0 || $evaluation->$criterion > 10) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function eligibleRole(User $user, Requisition $requisition): bool
|
||||
{
|
||||
return $user->tenant_id === $requisition->tenant_id &&
|
||||
(in_array($user->purchasing_role, [PurchasingRole::Procurement, PurchasingRole::Finance]) ||
|
||||
($user->purchasing_role === PurchasingRole::DepartmentHead && $user->department_id === $requisition->department_id));
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $weights */
|
||||
private function validWeights(array $weights): bool
|
||||
{
|
||||
$keys = ['price', 'quality', 'delivery', 'history', 'payment'];
|
||||
if (count($weights) !== count($keys) || array_sum($weights) !== 100) {
|
||||
return false;
|
||||
}
|
||||
foreach ($keys as $key) {
|
||||
if (! isset($weights[$key]) || ! is_int($weights[$key]) || $weights[$key] < 0 || $weights[$key] > 100) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function validAmounts(Quotation $quote, Requisition $requisition): bool
|
||||
{
|
||||
if ($quote->currency !== 'MZN' || $quote->delivery_days < 1 || $quote->subtotal_minor <= 0 || $quote->total_minor > 100000000000000) {
|
||||
return false;
|
||||
}
|
||||
$requested = $requisition->items()->get()->keyBy('id');
|
||||
if ($requested->isEmpty() || $requested->count() !== $quote->items->count()) {
|
||||
return false;
|
||||
}
|
||||
$subtotal = 0;
|
||||
$tax = 0;
|
||||
$seen = [];
|
||||
try {
|
||||
foreach ($quote->items as $item) {
|
||||
$original = $requested->get($item->requisition_item_id);
|
||||
if (! $original || in_array($item->requisition_item_id, $seen) || $item->unit_price_minor < 1 ||
|
||||
$item->quantity <= 0 || $item->tax_rate < 0 || $item->tax_rate > 100 ||
|
||||
! BigDecimal::of($item->quantity)->isEqualTo($original->quantity) ||
|
||||
! BigDecimal::of($item->tax_rate)->isEqualTo($original->tax_rate)) {
|
||||
return false;
|
||||
}
|
||||
$seen[] = $item->requisition_item_id;
|
||||
$amount = BigDecimal::of($item->quantity)->multipliedBy($item->unit_price_minor)->toScale(0, RoundingMode::HalfUp)->toInt();
|
||||
$vat = BigDecimal::of($amount)->multipliedBy($item->tax_rate)->dividedBy(100, 0, RoundingMode::HalfUp)->toInt();
|
||||
if ($amount !== $item->subtotal_minor || $vat !== $item->tax_minor || $amount + $vat !== $item->total_minor) {
|
||||
return false;
|
||||
}
|
||||
$subtotal += $amount;
|
||||
$tax += $vat;
|
||||
}
|
||||
} catch (MathException) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $subtotal === $quote->subtotal_minor && $tax === $quote->tax_minor && $subtotal + $tax === $quote->total_minor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PurchaseWorkflow
|
||||
{
|
||||
public function __construct(private ConflictGuard $guard, private ProposalComparison $comparison, private RequisitionWorkflow $audit) {}
|
||||
|
||||
/** @param array{quotation_id: int, justification: string, delivery_date: string, delivery_location: string} $data */
|
||||
public function select(User $user, Requisition $requisition, array $data): PurchaseAward
|
||||
{
|
||||
return DB::transaction(function () use ($user, $requisition, $data) {
|
||||
$this->guard->lockCompany($user);
|
||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
||||
abort_unless($user->purchasing_role === PurchasingRole::Procurement && $user->id !== $requisition->requester_id, 403);
|
||||
if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
|
||||
throw ValidationException::withMessages(['award' => 'Este processo já tem uma adjudicação. Não é permitida uma segunda selecção.']);
|
||||
}
|
||||
$rules = $this->rules($requisition);
|
||||
$this->sourceApproved($requisition, $rules);
|
||||
$this->supportedItems($requisition);
|
||||
$quote = Quotation::query()->where('requisition_id', $requisition->id)->lockForUpdate()->findOrFail($data['quotation_id']);
|
||||
$result = $this->checkComparison($user, $requisition, $quote);
|
||||
$award = PurchaseAward::query()->create([
|
||||
'tenant_id' => $user->tenant_id, 'requisition_id' => $requisition->id, 'quotation_id' => $quote->id,
|
||||
'created_by' => $user->id, 'justification' => $data['justification'], 'delivery_date' => $data['delivery_date'],
|
||||
'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'],
|
||||
]);
|
||||
$roles = $this->roles($rules, $rules['approval_includes_tax'] ? $quote->total_minor : $quote->subtotal_minor);
|
||||
foreach ($roles as $position => $role) {
|
||||
$award->approvals()->create(['tenant_id' => $user->tenant_id, 'position' => $position + 1, 'role' => $role]);
|
||||
}
|
||||
$this->audit->audit($user, 'award.created', $requisition, null, $award->load('approvals')->toArray());
|
||||
|
||||
return $award;
|
||||
});
|
||||
}
|
||||
|
||||
public function canDecide(User $user, PurchaseAward $award, Requisition $requisition): bool
|
||||
{
|
||||
if ($award->status !== 'pending' || $user->tenant_id !== $award->tenant_id || in_array($user->id, [$requisition->requester_id, $award->created_by])) {
|
||||
return false;
|
||||
}
|
||||
$steps = $award->approvals()->get();
|
||||
$next = $steps->firstWhere('decision', 'pending');
|
||||
|
||||
return $next !== null && $next->role === $user->purchasing_role->value &&
|
||||
($next->role !== 'department_head' || $user->department_id === $requisition->department_id) &&
|
||||
! $steps->contains('approver_id', $user->id) && ! $this->guard->participationBlocked($user, $requisition);
|
||||
}
|
||||
|
||||
public function decide(User $user, PurchaseAward $award, string $decision, ?string $comment): void
|
||||
{
|
||||
DB::transaction(function () use ($user, $award, $decision, $comment) {
|
||||
$this->guard->lockCompany($user);
|
||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id);
|
||||
$award = PurchaseAward::query()->lockForUpdate()->findOrFail($award->id);
|
||||
abort_unless($this->canDecide($user, $award, $requisition), 403);
|
||||
if ($decision === 'approved') {
|
||||
$this->currentBasis($user, $award, $requisition);
|
||||
}
|
||||
$step = $award->approvals()->where('decision', 'pending')->firstOrFail();
|
||||
$step->update(['decision' => $decision, 'approver_id' => $user->id, 'comment' => $comment, 'decided_at' => now()]);
|
||||
$status = $decision === 'rejected' ? 'rejected' : ($award->approvals()->where('decision', 'pending')->exists() ? 'pending' : 'approved');
|
||||
$award->update(['status' => $status]);
|
||||
$this->audit->audit($user, 'award.'.$decision, $requisition, ['status' => 'pending'], ['award_id' => $award->id, 'status' => $status, 'approval' => $step->toArray()]);
|
||||
});
|
||||
}
|
||||
|
||||
public function issue(User $user, PurchaseAward $award): PurchaseOrder
|
||||
{
|
||||
return DB::transaction(function () use ($user, $award) {
|
||||
$this->guard->lockCompany($user);
|
||||
abort_unless($user->purchasing_role === PurchasingRole::Procurement, 403);
|
||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id);
|
||||
$award = PurchaseAward::query()->lockForUpdate()->findOrFail($award->id);
|
||||
// An already committed issue is returned, never re-created or re-snapshotted.
|
||||
$existing = PurchaseOrder::query()->where('requisition_id', $requisition->id)->first();
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
if ($award->status !== 'approved') {
|
||||
throw ValidationException::withMessages(['approvals' => 'Conclua as aprovações do valor adjudicado antes de emitir a ordem.']);
|
||||
}
|
||||
$quote = $this->currentBasis($user, $award, $requisition);
|
||||
$this->validateFinalApprovals($award, $requisition);
|
||||
if ($award->delivery_date < today()) {
|
||||
throw ValidationException::withMessages(['delivery_date' => 'A data de entrega da adjudicação já passou. Não é possível emitir esta ordem.']);
|
||||
}
|
||||
$requisition->load(['department', 'costCenter', 'requester']);
|
||||
$supplier = $quote->supplier;
|
||||
abort_unless($supplier !== null && $requisition->department !== null && $requisition->costCenter !== null, 409);
|
||||
$lines = [];
|
||||
$requested = $requisition->items()->get()->keyBy('id');
|
||||
foreach ($quote->items()->orderBy('id')->get() as $item) {
|
||||
$source = $requested->get($item->requisition_item_id);
|
||||
abort_if($source === null, 409);
|
||||
$lines[] = [
|
||||
...$item->only(['requisition_item_id', 'description', 'quantity', 'unit_price_minor', 'tax_rate', 'subtotal_minor', 'tax_minor', 'total_minor']),
|
||||
...$source->only(['unit', 'category', 'purchase_type']), 'tenant_id' => $user->tenant_id,
|
||||
];
|
||||
}
|
||||
$snapshot = [
|
||||
'development_only' => true, 'budget_checked' => false,
|
||||
'company' => Tenant::query()->findOrFail($user->tenant_id)->only(['id', 'name']),
|
||||
'requisition' => $requisition->only(['id', 'number', 'justification']),
|
||||
'department' => $requisition->department->only(['id', 'name']),
|
||||
'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']),
|
||||
'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,
|
||||
];
|
||||
$order = PurchaseOrder::query()->create([
|
||||
'tenant_id' => $user->tenant_id, 'requisition_id' => $requisition->id, 'purchase_award_id' => $award->id,
|
||||
'supplier_id' => $supplier->id, 'issued_by' => $user->id, 'currency' => $quote->currency,
|
||||
'subtotal_minor' => $quote->subtotal_minor, 'tax_minor' => $quote->tax_minor, 'total_minor' => $quote->total_minor,
|
||||
'snapshot' => $snapshot, 'issued_at' => now(),
|
||||
]);
|
||||
// Complete the generated reference before this transaction exposes the immutable document.
|
||||
DB::table('purchase_orders')->where('id', $order->id)->update(['number' => sprintf('OC-%s-%06d', now()->format('Y'), $order->id)]);
|
||||
$order->refresh();
|
||||
$order->items()->createMany($lines);
|
||||
$this->audit->audit($user, 'purchase_order.issued', $requisition, null, $order->load('items')->toArray());
|
||||
|
||||
return $order;
|
||||
});
|
||||
}
|
||||
|
||||
private function currentBasis(User $user, PurchaseAward $award, Requisition $requisition): Quotation
|
||||
{
|
||||
$rules = $this->rules($requisition);
|
||||
$this->sourceApproved($requisition, $rules);
|
||||
$this->supportedItems($requisition);
|
||||
$quote = Quotation::query()->where('requisition_id', $requisition->id)->lockForUpdate()->findOrFail($award->quotation_id);
|
||||
$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) {
|
||||
throw ValidationException::withMessages(['comparison' => 'A base da adjudicação mudou. As aprovações anteriores não autorizam a emissão com dados diferentes.']);
|
||||
}
|
||||
|
||||
return $quote;
|
||||
}
|
||||
|
||||
/** @return array{rows: list<array<string, mixed>>, ready: bool, top_ids: list<int>, fingerprint: string} */
|
||||
private function checkComparison(User $user, Requisition $requisition, Quotation $quote): array
|
||||
{
|
||||
$this->guard->assertParticipation($user, $requisition);
|
||||
$this->guard->assertProcess($requisition, $quote->supplier_id, $user);
|
||||
$result = $this->comparison->inspect($requisition);
|
||||
if (! $result['ready']) {
|
||||
throw ValidationException::withMessages(['comparison' => 'São exigidas três propostas elegíveis de fornecedores distintos, com pelo menos duas avaliações elegíveis cada. Excepções sem política aprovada estão bloqueadas.']);
|
||||
}
|
||||
if (! in_array($quote->id, $result['top_ids'], true)) {
|
||||
throw ValidationException::withMessages(['quotation_id' => 'Escolha uma proposta de maior pontuação. A escolha fora do topo exige política de escalão superior ainda não aprovada.']);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array{first_limit_minor: int, second_limit_minor: int, approval_includes_tax: bool, rules_provisional: bool} */
|
||||
private function rules(Requisition $requisition): array
|
||||
{
|
||||
$rules = $requisition->getAttribute('approval_snapshot');
|
||||
if (! is_array($rules) || ! isset($rules['first_limit_minor'], $rules['second_limit_minor'], $rules['approval_includes_tax'], $rules['rules_provisional']) ||
|
||||
! is_int($rules['first_limit_minor']) || ! is_int($rules['second_limit_minor']) || ! is_bool($rules['approval_includes_tax']) || ! is_bool($rules['rules_provisional']) ||
|
||||
$rules['first_limit_minor'] < 0 || $rules['second_limit_minor'] < $rules['first_limit_minor']) {
|
||||
throw ValidationException::withMessages(['policy' => 'O processo não tem regras de aprovação guardadas e válidas. Não é possível presumir limites ou a base de IVA.']);
|
||||
}
|
||||
|
||||
return ['first_limit_minor' => $rules['first_limit_minor'], 'second_limit_minor' => $rules['second_limit_minor'], 'approval_includes_tax' => $rules['approval_includes_tax'], 'rules_provisional' => $rules['rules_provisional']];
|
||||
}
|
||||
|
||||
/** @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
|
||||
{
|
||||
$roles = ['department_head'];
|
||||
if ($amount > $rules['first_limit_minor']) {
|
||||
$roles[] = 'procurement';
|
||||
}
|
||||
if ($amount > $rules['second_limit_minor']) {
|
||||
$roles[] = 'finance';
|
||||
}
|
||||
|
||||
return $roles;
|
||||
}
|
||||
|
||||
/** @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);
|
||||
$steps = $requisition->steps()->get();
|
||||
$seen = [$requisition->requester_id];
|
||||
if ($requisition->status !== 'approved' || $steps->count() !== count($roles)) {
|
||||
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) ||
|
||||
! 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.']);
|
||||
}
|
||||
$seen[] = $step->approver_id;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
$steps = $award->approvals()->get();
|
||||
$seen = [$requisition->requester_id, $award->created_by];
|
||||
if ($steps->count() !== count($roles)) {
|
||||
throw ValidationException::withMessages(['approvals' => 'O ciclo de aprovação do valor final está incompleto.']);
|
||||
}
|
||||
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 ||
|
||||
($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.']);
|
||||
}
|
||||
$this->guard->assertProcess($requisition, Quotation::query()->findOrFail($award->quotation_id)->supplier_id, $user);
|
||||
$seen[] = $user->id;
|
||||
}
|
||||
}
|
||||
|
||||
private function supportedItems(Requisition $requisition): void
|
||||
{
|
||||
if ($requisition->items()->whereNotIn('purchase_type', ['consumable', 'service', 'other'])->exists()) {
|
||||
throw ValidationException::withMessages(['purchase_type' => 'Equipamento e peças exigem regras adicionais ainda não suportadas. A emissão para estes tipos está bloqueada.']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Brick\Math\RoundingMode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class RequisitionWorkflow
|
||||
{
|
||||
/** @param array<string, mixed> $data */
|
||||
public function save(User $user, array $data, ?Requisition $existing = null): Requisition
|
||||
{
|
||||
return DB::transaction(function () use ($user, $data, $existing) {
|
||||
app(ConflictGuard::class)->lockCompany($user);
|
||||
$requisition = $existing ? Requisition::query()->lockForUpdate()->findOrFail($existing->id) : new Requisition;
|
||||
if ($existing) {
|
||||
abort_unless($requisition->requester_id === $user->id && $requisition->status === 'draft', 403);
|
||||
app(ConflictGuard::class)->assertParticipation($user, $requisition);
|
||||
}
|
||||
$before = $existing ? $requisition->load('items')->toArray() : null;
|
||||
$items = [];
|
||||
$subtotal = 0;
|
||||
$tax = 0;
|
||||
/** @var list<array{description: string, category: string, purchase_type: string, quantity: string, unit: string, unit_price: string, tax_rate: string}> $lines */
|
||||
$lines = $data['items'];
|
||||
foreach ($lines as $line) {
|
||||
$price = BigDecimal::of($line['unit_price'])->multipliedBy(100)->toScale(0)->toInt();
|
||||
$lineSubtotal = BigDecimal::of($line['quantity'])->multipliedBy($price)->toScale(0, RoundingMode::HalfUp)->toInt();
|
||||
$lineTax = BigDecimal::of($lineSubtotal)->multipliedBy($line['tax_rate'])->dividedBy(100, 0, RoundingMode::HalfUp)->toInt();
|
||||
$items[] = [
|
||||
'tenant_id' => $user->tenant_id,
|
||||
'description' => $line['description'], 'category' => $line['category'],
|
||||
'purchase_type' => $line['purchase_type'], 'quantity' => $line['quantity'], 'unit' => $line['unit'],
|
||||
'unit_price_minor' => $price, 'tax_rate' => $line['tax_rate'],
|
||||
'subtotal_minor' => $lineSubtotal, 'tax_minor' => $lineTax, 'total_minor' => $lineSubtotal + $lineTax,
|
||||
];
|
||||
$subtotal += $lineSubtotal;
|
||||
$tax += $lineTax;
|
||||
if ($subtotal + $tax > 100000000000000) {
|
||||
throw ValidationException::withMessages(['items' => 'O total da requisição ultrapassa o limite suportado de 1.000.000.000.000 MZN.']);
|
||||
}
|
||||
}
|
||||
$requisition->fill([
|
||||
'tenant_id' => $user->tenant_id, 'requester_id' => $user->id, 'department_id' => $user->department_id,
|
||||
'cost_center_id' => $data['cost_center_id'], 'priority' => $data['priority'],
|
||||
'needed_by' => $data['needed_by'], 'justification' => $data['justification'],
|
||||
'subtotal_minor' => $subtotal, 'tax_minor' => $tax, 'total_minor' => $subtotal + $tax,
|
||||
])->save();
|
||||
if (! $existing) {
|
||||
$requisition->update(['number' => sprintf('REQ-%s-%06d', now()->format('Y'), $requisition->id)]);
|
||||
}
|
||||
$requisition->items()->delete();
|
||||
$requisition->items()->createMany($items);
|
||||
$requisition->load('items');
|
||||
$this->audit($user, $existing ? 'requisition.updated' : 'requisition.created', $requisition, $before, $requisition->toArray());
|
||||
|
||||
return $requisition;
|
||||
});
|
||||
}
|
||||
|
||||
public function submit(User $user, Requisition $requisition): void
|
||||
{
|
||||
DB::transaction(function () use ($user, $requisition) {
|
||||
app(ConflictGuard::class)->lockCompany($user);
|
||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
||||
abort_unless($requisition->requester_id === $user->id && $requisition->status === 'draft', 403);
|
||||
app(ConflictGuard::class)->assertParticipation($user, $requisition);
|
||||
if ($requisition->needed_by < today()) {
|
||||
throw ValidationException::withMessages(['submission' => 'Actualize a data de entrega antes de submeter.']);
|
||||
}
|
||||
$tenant = Tenant::query()->findOrFail($user->tenant_id);
|
||||
$amount = $tenant->approval_includes_tax ? $requisition->total_minor : $requisition->subtotal_minor;
|
||||
$roles = ['department_head'];
|
||||
if ($amount > $tenant->first_limit_minor) {
|
||||
$roles[] = 'procurement';
|
||||
}
|
||||
if ($amount > $tenant->second_limit_minor) {
|
||||
$roles[] = 'finance';
|
||||
}
|
||||
foreach ($roles as $position => $role) {
|
||||
$requisition->steps()->create(['tenant_id' => $user->tenant_id, 'position' => $position + 1, 'role' => $role]);
|
||||
}
|
||||
$snapshot = $tenant->only(['first_limit_minor', 'second_limit_minor', 'approval_includes_tax', 'rules_provisional']);
|
||||
$requisition->update(['status' => 'pending', 'submitted_at' => now(), 'approval_snapshot' => $snapshot]);
|
||||
$this->audit($user, 'requisition.submitted', $requisition, ['status' => 'draft'], ['status' => 'pending', 'rules' => $snapshot, 'roles' => $roles]);
|
||||
});
|
||||
}
|
||||
|
||||
public function decide(User $user, Requisition $requisition, string $decision, ?string $comment): void
|
||||
{
|
||||
DB::transaction(function () use ($user, $requisition, $decision, $comment) {
|
||||
app(ConflictGuard::class)->lockCompany($user);
|
||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
||||
$requisition->load('steps');
|
||||
abort_unless($requisition->canDecide($user), 403);
|
||||
$step = $requisition->steps->firstWhere('decision', 'pending');
|
||||
abort_if($step === null, 409);
|
||||
$step->update(['decision' => $decision, 'approver_id' => $user->id, 'comment' => $comment, 'decided_at' => now()]);
|
||||
$status = $decision === 'rejected' ? 'rejected' : ($requisition->steps()->where('decision', 'pending')->exists() ? 'pending' : 'approved');
|
||||
$requisition->update(['status' => $status]);
|
||||
$this->audit($user, 'approval.'.$decision, $requisition, ['status' => 'pending'], ['status' => $status, 'step_id' => $step->id, 'role' => $step->role, 'comment' => $comment]);
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string, mixed>|null $before
|
||||
* @param array<string, mixed> $after
|
||||
*/
|
||||
public function audit(User $user, string $action, ?Requisition $requisition, ?array $before, array $after): void
|
||||
{
|
||||
AuditLog::query()->create([
|
||||
'tenant_id' => $user->tenant_id, 'user_id' => $user->id,
|
||||
'requisition_id' => $requisition?->id, 'action' => $action,
|
||||
'ip_address' => request()->ip(), 'before' => $before, 'after' => $after,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,14 @@ return [
|
||||
|
||||
'disks' => [
|
||||
|
||||
'supplier_documents' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/supplier-documents'),
|
||||
'visibility' => 'private',
|
||||
'serve' => false,
|
||||
'throw' => true,
|
||||
],
|
||||
|
||||
'local' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/private'),
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<?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('tenants', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('currency', 3)->default('MZN');
|
||||
$table->unsignedBigInteger('first_limit_minor')->default(2500000);
|
||||
$table->unsignedBigInteger('second_limit_minor')->default(15000000);
|
||||
$table->boolean('approval_includes_tax')->default(true);
|
||||
$table->boolean('rules_provisional')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('departments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||
$table->string('name');
|
||||
$table->timestamps();
|
||||
$table->unique(['tenant_id', 'name']);
|
||||
});
|
||||
|
||||
Schema::create('cost_centers', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('department_id')->constrained()->restrictOnDelete();
|
||||
$table->string('code');
|
||||
$table->string('name');
|
||||
$table->timestamps();
|
||||
$table->unique(['tenant_id', 'code']);
|
||||
});
|
||||
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->foreignId('tenant_id')->nullable()->constrained()->restrictOnDelete();
|
||||
$table->foreignId('department_id')->nullable()->constrained()->restrictOnDelete();
|
||||
$table->string('purchasing_role')->default('requester');
|
||||
});
|
||||
|
||||
Schema::create('requisitions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||
$table->string('number')->nullable()->unique();
|
||||
$table->foreignId('requester_id')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignId('department_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('cost_center_id')->constrained()->restrictOnDelete();
|
||||
$table->string('priority');
|
||||
$table->date('needed_by');
|
||||
$table->text('justification');
|
||||
$table->string('status')->default('draft');
|
||||
$table->string('currency', 3)->default('MZN');
|
||||
$table->unsignedBigInteger('subtotal_minor');
|
||||
$table->unsignedBigInteger('tax_minor');
|
||||
$table->unsignedBigInteger('total_minor');
|
||||
$table->json('approval_snapshot')->nullable();
|
||||
$table->timestamp('submitted_at')->nullable();
|
||||
$table->timestamps();
|
||||
$table->index(['tenant_id', 'status']);
|
||||
});
|
||||
|
||||
Schema::create('requisition_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('requisition_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('description');
|
||||
$table->string('category');
|
||||
$table->string('purchase_type');
|
||||
$table->decimal('quantity', 12, 3);
|
||||
$table->string('unit', 30);
|
||||
$table->unsignedBigInteger('unit_price_minor');
|
||||
$table->decimal('tax_rate', 5, 2);
|
||||
$table->unsignedBigInteger('subtotal_minor');
|
||||
$table->unsignedBigInteger('tax_minor');
|
||||
$table->unsignedBigInteger('total_minor');
|
||||
});
|
||||
|
||||
Schema::create('approval_steps', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('requisition_id')->constrained()->restrictOnDelete();
|
||||
$table->unsignedSmallInteger('position');
|
||||
$table->string('role');
|
||||
$table->string('decision')->default('pending');
|
||||
$table->foreignId('approver_id')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$table->text('comment')->nullable();
|
||||
$table->timestamp('decided_at')->nullable();
|
||||
$table->unique(['requisition_id', 'position']);
|
||||
});
|
||||
|
||||
Schema::create('audit_logs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->restrictOnDelete();
|
||||
$table->string('action');
|
||||
$table->foreignId('requisition_id')->nullable()->constrained()->restrictOnDelete();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->json('before')->nullable();
|
||||
$table->json('after');
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('audit_logs');
|
||||
Schema::dropIfExists('approval_steps');
|
||||
Schema::dropIfExists('requisition_items');
|
||||
Schema::dropIfExists('requisitions');
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('department_id');
|
||||
$table->dropConstrainedForeignId('tenant_id');
|
||||
$table->dropColumn('purchasing_role');
|
||||
});
|
||||
Schema::dropIfExists('cost_centers');
|
||||
Schema::dropIfExists('departments');
|
||||
Schema::dropIfExists('tenants');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
<?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('suppliers', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
|
||||
$table->string('name');
|
||||
$table->string('nuit', 9);
|
||||
$table->string('contact');
|
||||
$table->string('email');
|
||||
$table->string('phone', 50);
|
||||
$table->string('address', 500);
|
||||
$table->string('category');
|
||||
$table->text('beneficiaries');
|
||||
$table->text('document_references');
|
||||
$table->text('diligence_notes');
|
||||
$table->boolean('blocked')->default(false);
|
||||
$table->string('status')->default('pending');
|
||||
$table->date('next_review');
|
||||
$table->foreignId('reviewed_by')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$table->text('review_comment')->nullable();
|
||||
$table->timestamps();
|
||||
$table->unique(['tenant_id', 'nuit']);
|
||||
});
|
||||
Schema::create('quotations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('requisition_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('supplier_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
|
||||
$table->string('reference', 100);
|
||||
$table->unsignedInteger('delivery_days');
|
||||
$table->date('valid_until');
|
||||
$table->string('payment_terms', 500);
|
||||
$table->unsignedBigInteger('subtotal_minor');
|
||||
$table->unsignedBigInteger('tax_minor');
|
||||
$table->unsignedBigInteger('total_minor');
|
||||
$table->string('currency', 3)->default('MZN');
|
||||
$table->json('weights');
|
||||
$table->timestamps();
|
||||
$table->unique(['requisition_id', 'supplier_id']);
|
||||
});
|
||||
Schema::create('quotation_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('quotation_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('requisition_item_id')->constrained()->restrictOnDelete();
|
||||
$table->string('description');
|
||||
$table->decimal('quantity', 12, 3);
|
||||
$table->unsignedBigInteger('unit_price_minor');
|
||||
$table->decimal('tax_rate', 5, 2);
|
||||
$table->unsignedBigInteger('subtotal_minor');
|
||||
$table->unsignedBigInteger('tax_minor');
|
||||
$table->unsignedBigInteger('total_minor');
|
||||
$table->timestamps();
|
||||
$table->unique(['quotation_id', 'requisition_item_id']);
|
||||
});
|
||||
Schema::create('proposal_evaluations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('quotation_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('evaluator_id')->constrained('users')->restrictOnDelete();
|
||||
$table->decimal('quality', 4, 2);
|
||||
$table->decimal('history', 4, 2);
|
||||
$table->decimal('payment', 4, 2);
|
||||
$table->text('comment');
|
||||
$table->boolean('no_conflict');
|
||||
$table->timestamps();
|
||||
$table->unique(['quotation_id', 'evaluator_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('proposal_evaluations');
|
||||
Schema::dropIfExists('quotation_items');
|
||||
Schema::dropIfExists('quotations');
|
||||
Schema::dropIfExists('suppliers');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
<?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::table('suppliers', function (Blueprint $table) {
|
||||
$table->foreignId('last_changed_by')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$table->unsignedInteger('version')->default(1);
|
||||
});
|
||||
|
||||
Schema::create('supplier_documents', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('supplier_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('uploaded_by')->constrained('users')->restrictOnDelete();
|
||||
$table->string('document_type', 30);
|
||||
$table->string('original_name', 200);
|
||||
$table->string('path')->unique();
|
||||
$table->string('mime_type', 100);
|
||||
$table->unsignedBigInteger('size');
|
||||
$table->string('sha256', 64);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('supplier_documents');
|
||||
Schema::table('suppliers', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('last_changed_by');
|
||||
$table->dropColumn('version');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?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('conflict_declarations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('supplier_id')->nullable()->constrained()->restrictOnDelete();
|
||||
$table->foreignId('requisition_id')->nullable()->constrained()->restrictOnDelete();
|
||||
$table->boolean('has_conflict');
|
||||
$table->date('known_since');
|
||||
$table->text('reason');
|
||||
$table->timestamp('declared_at');
|
||||
$table->foreignId('root_id')->nullable()->constrained('conflict_declarations')->restrictOnDelete();
|
||||
$table->foreignId('previous_id')->nullable()->unique()->constrained('conflict_declarations')->restrictOnDelete();
|
||||
$table->index(['tenant_id', 'user_id', 'has_conflict']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('conflict_declarations');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
<?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('purchase_awards', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('requisition_id')->unique()->constrained()->restrictOnDelete();
|
||||
$table->foreignId('quotation_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
|
||||
$table->text('justification');
|
||||
$table->date('delivery_date');
|
||||
$table->string('delivery_location', 500);
|
||||
$table->string('status')->default('pending');
|
||||
$table->unsignedBigInteger('subtotal_minor');
|
||||
$table->unsignedBigInteger('tax_minor');
|
||||
$table->unsignedBigInteger('total_minor');
|
||||
$table->json('approval_snapshot');
|
||||
$table->json('decision_matrix');
|
||||
$table->string('comparison_hash', 64);
|
||||
$table->timestamps();
|
||||
});
|
||||
Schema::create('purchase_award_approvals', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('purchase_award_id')->constrained()->restrictOnDelete();
|
||||
$table->unsignedSmallInteger('position');
|
||||
$table->string('role');
|
||||
$table->string('decision')->default('pending');
|
||||
$table->foreignId('approver_id')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$table->text('comment')->nullable();
|
||||
$table->timestamp('decided_at')->nullable();
|
||||
$table->unique(['purchase_award_id', 'position']);
|
||||
});
|
||||
Schema::create('purchase_orders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('requisition_id')->unique()->constrained()->restrictOnDelete();
|
||||
$table->foreignId('purchase_award_id')->unique()->constrained()->restrictOnDelete();
|
||||
$table->foreignId('supplier_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('issued_by')->constrained('users')->restrictOnDelete();
|
||||
$table->string('number')->nullable()->unique();
|
||||
$table->string('status')->default('issued');
|
||||
$table->string('currency', 3);
|
||||
$table->unsignedBigInteger('subtotal_minor');
|
||||
$table->unsignedBigInteger('tax_minor');
|
||||
$table->unsignedBigInteger('total_minor');
|
||||
$table->json('snapshot');
|
||||
$table->timestamp('issued_at');
|
||||
$table->index(['tenant_id', 'issued_at']);
|
||||
});
|
||||
Schema::create('purchase_order_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('purchase_order_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('requisition_item_id')->constrained()->restrictOnDelete();
|
||||
$table->string('description');
|
||||
$table->string('category');
|
||||
$table->string('purchase_type');
|
||||
$table->string('unit', 30);
|
||||
$table->decimal('quantity', 12, 3);
|
||||
$table->unsignedBigInteger('unit_price_minor');
|
||||
$table->decimal('tax_rate', 5, 2);
|
||||
$table->unsignedBigInteger('subtotal_minor');
|
||||
$table->unsignedBigInteger('tax_minor');
|
||||
$table->unsignedBigInteger('total_minor');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('purchase_order_items');
|
||||
Schema::dropIfExists('purchase_orders');
|
||||
Schema::dropIfExists('purchase_award_approvals');
|
||||
Schema::dropIfExists('purchase_awards');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
# Consolidação do ProcuraFlow
|
||||
|
||||
Estado verificado em 28 de Outubro de 2026.
|
||||
|
||||
## Projecto activo
|
||||
|
||||
O projecto activo é `C:\laragon\www\compras`, em Laravel, React, TypeScript,
|
||||
Inertia e PostgreSQL, em http://127.0.0.1:8015. A conta já foi criada.
|
||||
Preservar `APP_URL=http://127.0.0.1:8015`, `SERVER_PORT=8015`,
|
||||
`SESSION_COOKIE=procuraflow_session` e `SESSION_DRIVER=database`.
|
||||
O SGF Tempo é independente.
|
||||
|
||||
A implementação Python/React na pasta de outputs da conversa fica descontinuada
|
||||
como aplicação executável e preservada apenas como referência. Não importar
|
||||
contas sintéticas, bases SQLite, sessões ou palavras-passe. Não executar os seus
|
||||
servidores ou contentores. O iniciador antigo passa a indicar o endereço do projecto Laravel, sem iniciar outro servidor.
|
||||
|
||||
## Compatibilidade funcional
|
||||
|
||||
| Área | Base a preservar | Adaptação da referência experimental |
|
||||
| ------------------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| Acesso | Fortify, conta existente, sessões e equipa | Não transportar login Python nem contas fictícias |
|
||||
| Organização | Empresa, departamentos, centros de custo e isolamento | Aplicar o mesmo isolamento nos novos módulos |
|
||||
| Requisições | Rascunhos editáveis, validações, relações e numeração | Acrescentar funções sem substituir as existentes |
|
||||
| Aprovações | RequisitionWorkflow, perfis, etapas e snapshots | Adaptar cenários e testes adicionais |
|
||||
| Valores | Brick Math e centavos | Não importar o armazenamento genérico JSON |
|
||||
| Fornecedores e cotações | Cadastro, validação, propostas e comparação implementados no Laravel | Adaptados com tabelas próprias, isolamento, centavos e auditoria existentes |
|
||||
| Ordens, recepções e facturas | Ainda ausentes nesta base | Adaptar estados, recepções parciais e conferência |
|
||||
| Stock, equipamentos e orçamento | Ainda ausentes nesta base | Adaptar movimentos, mínimos, inventários e compromissos |
|
||||
| Relatórios e auditoria | Indicadores e histórico existentes | Adaptar exportação e controlos adicionais |
|
||||
|
||||
A consolidação inicial definiu a base única de continuidade. Posteriormente,
|
||||
fornecedores e cotações foram adaptados ao Laravel; os restantes módulos
|
||||
experimentais continuam por implementar nesta base.
|
||||
|
||||
## Diferenças a resolver em cada adaptação
|
||||
|
||||
- Laravel aplica provisoriamente escalões com IVA; o protótipo Python usava sem
|
||||
IVA. Preservar a configuração existente e snapshots, até decisão de Finanças.
|
||||
- Perfis e estados têm identificadores diferentes: usar os enums e políticas
|
||||
Laravel, sem criar perfis concorrentes.
|
||||
- Preservar as tabelas relacionais e o isolamento por empresa. Não importar a
|
||||
tabela JSON genérica do protótipo.
|
||||
- Manter a edição de rascunhos e a gestão da equipa já existentes no Laravel.
|
||||
- Não apresentar funcionalidades ainda ausentes como implementadas nem considerar
|
||||
o protótipo uma validação para compras reais.
|
||||
|
||||
## Verificação
|
||||
|
||||
- Página de entrada em 8015: HTTP 200 e texto “Entrar no ProcuraFlow”.
|
||||
- Testes Laravel: 47 passaram, 3 ignorados, 219 asserções.
|
||||
- Testes em SQLite em memória, conforme phpunit.xml; não na base real.
|
||||
- Nenhuma conta, credencial ou dado real foi alterado nesta consolidação.
|
||||
|
||||
## Adaptação de fornecedores e cotações
|
||||
|
||||
A migração aditiva `2026_10_28_000002_create_suppliers_and_quotations` criou
|
||||
suppliers, quotations, quotation_items e proposal_evaluations no PostgreSQL.
|
||||
A comparação dos registos antes e depois confirmou que utilizadores, empresas,
|
||||
departamentos, centros de custo, requisições, linhas e aprovações permaneceram
|
||||
inalterados. Não foram criadas contas ou dados sintéticos na base real.
|
||||
|
||||
Validações: 54 testes passaram, 3 ignorados, 305 asserções; análise estática PHP,
|
||||
tipos TypeScript, formatação e compilação da interface aprovadas.
|
||||
|
||||
Âmbito: cadastro e validação de fornecedores, propostas por requisição aprovada,
|
||||
preços em MZN, IVA herdado, avaliação independente e indicação de completude
|
||||
da matriz. Os módulos usam a conta/sessão Laravel existente. O navegador abriu
|
||||
a entrada na porta 8015; a inspecção visual das páginas autenticadas fica
|
||||
dependente do início de sessão do utilizador. Não foi pedida nem alterada a senha.
|
||||
|
||||
Edição/renovação de fornecedores e anexos privados foram acrescentados no
|
||||
incremento descrito em [desenvolvimento.md](desenvolvimento.md). Continuam
|
||||
pendentes dados bancários, registo anual de conflitos, adjudicação, ordens,
|
||||
recepção e restantes módulos. Os resultados anteriores acima referem-se à
|
||||
adaptação inicial; consultar o relatório de desenvolvimento para a validação actual.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Desenvolvimento do ProcuraFlow
|
||||
|
||||
## Incremento actual RF-AF-01 de 28 de Outubro de 2026
|
||||
|
||||
### Implementado
|
||||
|
||||
Declarações pessoais de conflito de interesses na base Laravel e React existente, sem avançar para adjudicação ou ordens. Fornecedores, anexos privados, contas e configuração anteriores foram preservados. O SGF Tempo e o Investmoz não foram alterados.
|
||||
|
||||
- Interface em português em **Conflitos de interesses**, disponível para todos os perfis com empresa configurada. Cada utilizador declara exclusivamente em nome próprio. O servidor fixa autor, empresa e data/hora do registo.
|
||||
- Âmbitos: fornecedor em todos os processos; processo independentemente do fornecedor; fornecedor num processo específico. São guardados fundamento, data de conhecimento da situação e data/hora de cada declaração ou correcção.
|
||||
- Administração e Finanças consultam as declarações da sua empresa para controlo. Procurement, chefes, solicitantes e armazém consultam apenas as próprias. Nenhum perfil altera declarações alheias. As opções de fornecedores mostram só identificador e nome; as opções de processos respeitam a visibilidade do perfil e departamento.
|
||||
- As correcções pessoais acrescentam um registo ligado ao anterior e à declaração inicial. Só corrigem fundamento e data de conhecimento; autor, empresa, âmbito e situação declarada não são alteráveis. Correcções de uma versão já corrigida são recusadas. O histórico completo é consultável e gera auditoria com antes/depois.
|
||||
- Uma declaração de ausência não anula uma declaração de conflito, mesmo posterior ou noutro âmbito sobreposto. Não há caducidade, prazo anual ou autorização para retirada implementados. A data de conhecimento é descritiva e não adia o impedimento. Periodicidade, validade e levantamento dependem de decisão da empresa, sem valores presumidos.
|
||||
|
||||
### Impedimentos no servidor
|
||||
|
||||
- A requisição sem fornecedor continua a poder ser criada, editada, submetida e aprovada quando há apenas conflito com um fornecedor ainda não associado. Um conflito pessoal específico do processo, já conhecido, impede a participação nesse processo.
|
||||
- Ao associar um fornecedor através de uma cotação, o servidor verifica o operador actual, solicitante, aprovadores anteriores, autores de propostas, avaliadores anteriores e autores de acções de participação no histórico de auditoria. As verificações usam os utilizadores históricos, independentemente do perfil actual. O registo da própria declaração não é tratado como participação em compras.
|
||||
- Reedição/submissão de requisições, decisões de aprovação, registo de cotações e avaliações verificam novamente os conflitos dos intervenientes e dos fornecedores já associados. O campo de ausência de conflito de uma operação não permite contornar os impedimentos.
|
||||
- Conflitos registados depois de uma avaliação conservam as cotações, avaliações, aprovações e auditoria anteriores. A elegibilidade e a pontuação são recalculadas na comparação; propostas afectadas deixam de contar como elegíveis, e novas operações afectadas ficam impedidas. Não há alteração retroactiva dos estados ou eliminação de decisões históricas.
|
||||
- Escritas de declarações, correcções e operações de participação começam por bloquear a empresa na transacção, antes dos bloqueios do processo/fornecedor. Esta protecção abrangente serializa estas escritas por empresa; o efeito real na concorrência e desempenho ainda não foi testado. Leituras da interface não constituem autorização para uma futura adjudicação: essa operação terá de verificar novamente os impedimentos dentro da sua transacção.
|
||||
- Declarações e correcções falham integralmente se falhar a auditoria. O modelo impede edição destrutiva e eliminação; a protecção contra SQL directo ou administração da base de dados continua dependente de permissões operacionais adequadas.
|
||||
|
||||
### Verificações realizadas
|
||||
|
||||
- Laravel/Pest: **116 passaram, 3 ignorados, 808 asserções**. Inclui os testes anteriores de fornecedores e anexos e novos cenários de autoria, seis perfis, visibilidade, isolamento, correcções imutáveis, versões antigas, âmbitos, ausência sem poder de anulação, datas sem caducidade, fornecedor desconhecido, intervenientes anteriores, alterações após avaliação, bloqueios por pedidos directos, auditoria e rollback.
|
||||
- PHPStan aprovado, sem erros; Pint aprovado; verificação frontend sem avisos ou erros; TypeScript aprovado; compilação aprovada; `git diff --check` sem erros. Os testes usam SQLite em memória, não a base de negócio.
|
||||
- Migração aditiva `2026_10_28_000004_create_conflict_declarations` aplicada no PostgreSQL local depois dos testes. Contagens antes/depois preservadas: 1 utilizador, 1 empresa e 0 requisições, fornecedores, cotações, avaliações e documentos. A nova tabela está vazia; não foram criadas declarações sintéticas na base real.
|
||||
- O SHA-256 do `.env` permaneceu igual antes e depois. Não houve seeds, recriação de contas, alteração de credenciais, reinicialização de dados ou mudanças de configuração.
|
||||
- As quatro rotas de conflitos estão registadas. O acesso HTTP sem sessão a `/conflitos` redireccionou para a entrada ProcuraFlow na porta 8015, que respondeu HTTP 200. Isto não constitui revisão visual autenticada.
|
||||
|
||||
### Pendências e próximo passo
|
||||
|
||||
**Continuam pendentes a revisão visual autenticada e os testes de concorrência real em PostgreSQL.** Não foram realizados nem apresentados como aprovados. Também não foram testados carga ou desempenho dos bloqueios por empresa.
|
||||
|
||||
A política de periodicidade/validade das declarações e o procedimento autorizado para levantar ou tratar conflitos permanecem por decidir. Não há retirada de conflitos nem declarações em nome de terceiros. A entrega cobre os âmbitos e impedimentos nas operações actualmente existentes; não representa certificação integral do sistema.
|
||||
|
||||
Próximo incremento: adjudicação e ordens de compra, reutilizando `ConflictGuard` para verificar novamente todos os intervenientes e o fornecedor antes de seleccionar ou emitir, além das três cotações, avaliações independentes e escalões aplicáveis. Dados bancários com dupla validação continuam pendentes. Não avançar com políticas ou excepções comerciais ainda não definidas.
|
||||
|
||||
### Ficheiros do RF-AF-01
|
||||
|
||||
Migração `2026_10_28_000004`; modelo `ConflictDeclaration`; controlador `ConflictDeclarationController`; serviço `ConflictGuard`; integração em `RequisitionWorkflow`, `Requisition`, `RequisitionController` e `QuotationController`; rotas e menu; tipos `conflict.ts`, páginas `conflicts/index` e `conflicts/show` e avisos nas páginas de requisição/cotações; testes `ConflictDeclarationTest`; documentação de desenvolvimento e README.
|
||||
|
||||
## Entrega anterior de fornecedores e anexos
|
||||
|
||||
O registo abaixo conserva o estado e os resultados da entrega anterior. As referências a declarações de conflito como próximo passo dizem respeito a essa entrega e são substituídas pelo incremento RF-AF-01 acima.
|
||||
|
||||
## Incremento de 28 de Outubro de 2026
|
||||
|
||||
Edição e renovação de fornecedores e documentos privados, na base Laravel e React existente. As orientações de continuidade foram lidas nesta sessão. A inspecção inicial confirmou cadastro, validação e cotações já implementados; os novos recursos de edição e documentos ainda estavam ausentes. Não houve passagem para outro chat nem criação de outro projecto.
|
||||
|
||||
### Implementação
|
||||
|
||||
- Procurement pode editar a ficha e renovar a data da próxima revisão. Os campos são validados no servidor, incluindo NUIT único por empresa. Campos de propriedade, validação e estado enviados pelo navegador não são aceites.
|
||||
- Alterações efectivas devolvem o fornecedor a pendente, limpam a validação actual e registam o antes e depois na auditoria. Gravações sem alterações preservam a validação. As cotações existentes permanecem guardadas; a elegibilidade depende do estado actual do fornecedor.
|
||||
- Cada ficha tem versão. Edições e decisões de Finanças com uma versão antiga são recusadas. O criador e o último utilizador que alterou a ficha ou enviou documentos não podem validar o próprio trabalho, mesmo após mudança de perfil.
|
||||
- PDF, JPEG e PNG, entre 1 KB e 2 MB por ficheiro. O limite respeita o `upload_max_filesize=2M` do PHP local, sem alterar configuração global ou `.env`. O servidor verifica MIME, extensão e tamanho.
|
||||
- Documentos em disco dedicado `supplier_documents`, fora de `public` e fora do disco local com URLs assinadas. Caminhos internos não são enviados para a interface. Downloads autenticados e autorizados por empresa e perfil, com validação da ligação ao fornecedor, sem visualização inline e sem cache partilhada.
|
||||
- Apenas Procurement envia documentos. Administração, Procurement e Finanças da mesma empresa podem descarregar. Chefes consultam a ficha sem metadados ou downloads dos documentos; solicitantes e armazém não acedem à ficha.
|
||||
- Envios e downloads geram auditoria. Novos documentos invalidam a validação do fornecedor; os anteriores são preservados. Falhas da transacção removem o ficheiro recém-enviado e revertem os metadados e o estado.
|
||||
|
||||
### Ficheiros deste incremento
|
||||
|
||||
Migração `2026_10_28_000003_add_supplier_maintenance_and_documents`; `SupplierController`, `SupplierDocumentController`, `SaveSupplierRequest`, `SupplierPolicy`, modelos `Supplier` e `SupplierDocument`; disco em `config/filesystems.php`; rotas de fornecedores; tipos `supplier.ts` e páginas `suppliers/index`, `suppliers/edit` e `suppliers/show`; testes `SupplierMaintenanceTest` e adaptação da versão em `SupplierQuotationTest`.
|
||||
|
||||
### Verificação concluída
|
||||
|
||||
- Testes Laravel: 74 passaram, 3 ignorados, 447 asserções. Cobrem edição, versões antigas, renovação, validação independente, MIME/extensão/tamanho, permissões, isolamento entre empresas, ligação documento/fornecedor, downloads auditados e reversão quando falha a auditoria.
|
||||
- PHPStan sem erros; Pint aprovado; verificação frontend sem avisos ou erros; TypeScript e compilação aprovados; `git diff --check` sem erros.
|
||||
- Migração aditiva `2026_10_28_000003` aplicada no PostgreSQL local depois dos testes. Contagens anteriores e posteriores iguais: 1 utilizador, 1 empresa, 0 requisições, 0 fornecedores, 0 cotações e 0 avaliações; nova tabela de documentos vazia. Não foram executados seeds, criadas contas ou alteradas credenciais/configuração.
|
||||
- Entrada em http://127.0.0.1:8015/login confirmada com HTTP 200 e identificação ProcuraFlow; oito rotas de fornecedores registadas. As páginas autenticadas e as permissões foram verificadas nos testes Inertia; não foi feita inspecção visual autenticada no navegador.
|
||||
|
||||
As verificações funcionais usam SQLite em memória e armazenamento simulado, sem ficheiros ou registos de teste na base real. A migração é a única escrita realizada na base real neste incremento.
|
||||
|
||||
### Limitações e próximo passo
|
||||
|
||||
Não há eliminação/substituição de documentos, antivírus, quotas acumuladas ou política de retenção nesta entrega. O hash SHA-256 é registado na recepção; não constitui assinatura digital ou análise antivírus. A revisão visual autenticada depende da sessão existente; não pedir nem redefinir a palavra-passe para testar. Concorrência real em PostgreSQL requer testes com ligações simultâneas, além dos testes de versões e transacções.
|
||||
|
||||
Continuam pendentes dados bancários com dupla validação e cadastro de declarações de conflito; não considerar a ficha ou a diligência devida como conformidade integral com RF-AF-01, RF-AF-05 ou RF-AF-06. Próximo passo: declarações de conflito e, depois, adjudicação e ordens, mantendo os controlos e decisões provisórias descritos na especificação. Conta, credenciais, configuração e dados existentes devem continuar preservados.
|
||||
@@ -0,0 +1,145 @@
|
||||
# Continuidade do desenvolvimento do ProcuraFlow
|
||||
|
||||
## Próximo incremento após RF-AF-01
|
||||
|
||||
O relatório RF-AF-01 está actualizado e a migração 000004 foi confirmada aplicada.
|
||||
As orientações antigas sobre implementar conflitos ficam como histórico; não
|
||||
refazer o módulo. Avançar para adjudicação e ordens de compra no mesmo Laravel.
|
||||
|
||||
Reutilizar ConflictGuard, auditoria, perfis, isolamento por empresa e cálculos
|
||||
em centavos. A operação deve revalidar dentro da transacção todos os
|
||||
intervenientes, conflitos, fornecedor activo/revisão válida, validade das
|
||||
propostas, três fornecedores distintos e pelo menos dois avaliadores elegíveis
|
||||
por proposta. Não confiar no indicador de prontidão mostrado anteriormente.
|
||||
|
||||
Escolher a proposta de maior pontuação com justificação escrita. Empates devem
|
||||
permitir escolha fundamentada entre as propostas empatadas. Não inventar
|
||||
autorização para dispensar cotações ou escolher fora da maior pontuação:
|
||||
enquanto a política de escalão superior/excepções estiver indefinida, bloquear
|
||||
esses caminhos e explicar o motivo ao utilizador.
|
||||
|
||||
Conferir os escalões com o valor adjudicado e a base com/sem IVA guardada no
|
||||
processo. Se exigir aprovações adicionais, impedir a emissão até essas etapas
|
||||
serem decididas por pessoas elegíveis e distintas. Preservar decisões históricas;
|
||||
não reutilizar implicitamente uma aprovação de um escalão inferior. Respeitar os
|
||||
requisitos adicionais de equipamento previstos no documento; se ainda não
|
||||
suportados, bloquear a emissão para esse tipo em vez de autorizar por omissão.
|
||||
|
||||
Criar ordem numerada com linhas, quantidades, preços, IVA, fornecedor, moeda,
|
||||
condições de pagamento, data/local de entrega, justificação e matriz de avaliação
|
||||
congelados no momento da emissão. Alterar depois a ficha do fornecedor ou a
|
||||
proposta não pode reescrever a ordem emitida. Impedir emissão duplicada, inclusive
|
||||
por repetição do pedido; usar restrição de unicidade e transacção. Não permitir
|
||||
modificar directamente montantes/estado da ordem pelo navegador.
|
||||
|
||||
Entregar listagem, detalhe e impressão/PDF com acesso autenticado e por empresa,
|
||||
incluindo emitente e histórico. Não enviar a ordem por email nem autorizar
|
||||
compras reais. Se o orçamento ainda não estiver implementado, assinalar essa
|
||||
limitação; não apresentar uma validação orçamental inexistente.
|
||||
|
||||
Testar permissões, isolamento, valores e IVA, fronteiras de escalões, empate,
|
||||
excepções bloqueadas, conflitos ou suspensão depois da avaliação, validade,
|
||||
duas avaliações, duplicação/idempotência, snapshot e rollback de auditoria.
|
||||
Manter revisão visual autenticada e concorrência PostgreSQL como pendentes até
|
||||
verificação efectiva. Actualizar docs/desenvolvimento.md com resultados,
|
||||
migração aditiva, preservação de dados e limitações. Começar a implementação,
|
||||
sem se limitar a confirmar a leitura das orientações.
|
||||
|
||||
## Próximo incremento após fornecedores e anexos
|
||||
|
||||
A entrega de edição/renovação e documentos privados foi reportada como concluída.
|
||||
O acompanhamento confirmou o relatório actualizado e a aplicação da migração
|
||||
000003 no PostgreSQL. A revisão visual autenticada e testes de concorrência real
|
||||
continuam pendentes e não devem ser apresentados como aprovados.
|
||||
|
||||
Avançar agora com declarações de conflito de interesses (RF-AF-01), antes da
|
||||
adjudicação. Reutilizar utilizadores, fornecedores, empresas e auditoria existentes.
|
||||
Implementar declaração pessoal por fornecedor e por processo; guardar autor,
|
||||
âmbito, datas, fundamento e histórico. Não permitir declarar ou retirar conflitos
|
||||
em nome de outro utilizador sem uma política e autorização explícitas. Alterações
|
||||
devem manter o histórico, sem apagar declarações anteriores. A periodicidade e
|
||||
validade anual dependem da política da empresa: não inventar caducidade automática
|
||||
que permita contornar um conflito ainda activo.
|
||||
|
||||
Aplicar o bloqueio no servidor à participação em processos com o fornecedor:
|
||||
avaliação, selecção/adjudicação e aprovações quando o fornecedor já é conhecido.
|
||||
Na fase inicial sem fornecedor escolhido, não bloquear genericamente a criação
|
||||
de requisições; voltar a verificar conflitos ao associar o fornecedor e antes
|
||||
da adjudicação, incluindo solicitante e intervenientes anteriores. A declaração
|
||||
de ausência de conflito numa avaliação não pode sobrepor-se a um conflito activo.
|
||||
|
||||
Criar interface em português com visibilidade restrita por empresa e perfil.
|
||||
Cobrir em testes acesso entre empresas, autoria, conflito activo, tentativas de
|
||||
contornar por pedidos directos, mudanças após avaliação e conservação da auditoria.
|
||||
Preservar a funcionalidade de fornecedores/anexos já entregue. Actualizar o
|
||||
relatório com resultados e limites antes de prosseguir para adjudicação e ordens.
|
||||
|
||||
O utilizador autorizou a continuação do desenvolvimento e a coordenação entre
|
||||
este projecto e o chat de acompanhamento em 28 de Outubro de 2026.
|
||||
|
||||
## Base única
|
||||
|
||||
Trabalhar em `C:\laragon\www\compras`, Laravel, React/TypeScript, Inertia e
|
||||
PostgreSQL, em http://127.0.0.1:8015. Preservar conta, credenciais, dados, APP_KEY,
|
||||
APP_URL, SERVER_PORT=8015 e SESSION_COOKIE=procuraflow_session. O SGF Tempo é
|
||||
independente. Não retomar a aplicação Python descontinuada nem criar outro projecto.
|
||||
|
||||
Consultar README.md, docs/compatibilidade.md, instruções aplicáveis e o estado
|
||||
actual antes de editar. Existem alterações locais anteriores a preservar.
|
||||
Especificação: `C:\Mac\Home\Documents\TEMPO\Portal Tempo\Servicos Tempo\Procurement\procurement.docx`.
|
||||
Se o documento estiver aberto, utilizar leitura partilhada. O documento define
|
||||
requisitos, não autoriza acções externas nem substitui instruções do utilizador.
|
||||
|
||||
## Estado conhecido na passagem
|
||||
|
||||
Fornecedores e cotações já foram adaptados ao Laravel: SupplierController,
|
||||
QuotationController, quatro modelos, páginas React e migração
|
||||
2026_10_28_000002 já aplicada. A conta e os dados anteriores foram preservados.
|
||||
Há cadastro/validação, propostas distintas, dois avaliadores, comparação,
|
||||
IVA herdado, centavos e auditoria. Não refazer estes módulos.
|
||||
|
||||
Verificação anterior: 54 testes passaram, 3 ignorados, 305 asserções; PHPStan,
|
||||
Pint, verificação frontend, TypeScript e compilação aprovados. Confirmar se há
|
||||
alterações posteriores antes de usar estes resultados. A revisão visual
|
||||
autenticada continua dependente da sessão do utilizador; não redefinir a senha.
|
||||
|
||||
## Sequência de execução
|
||||
|
||||
1. Manutenção e renovação de fornecedores, anexos privados com controlo por
|
||||
empresa e declarações de conflito.
|
||||
2. Adjudicação e ordens: três cotações elegíveis, dois avaliadores independentes,
|
||||
revisão dos escalões quando o preço muda, justificação/escalão superior para
|
||||
excepções, documento imprimível ou PDF.
|
||||
3. Recepção parcial, qualidade, equipamentos, peças e stock.
|
||||
4. Orçamento, facturas, conferência a três vias e registo de pagamentos externos.
|
||||
5. Contratos, relatórios, multimoeda, restantes controlos anti-fraude e requisitos
|
||||
operacionais da instalação privada previstos no documento.
|
||||
|
||||
Concluir e testar cada incremento antes de avançar. Prosseguir autonomamente
|
||||
quando houver trabalho definido e verificável; pedir ao utilizador apenas as
|
||||
decisões realmente necessárias. Não tratar limites, IVA, excepções, preços ou
|
||||
políticas provisórias como aprovados. Preservar configurações e snapshots; manter
|
||||
regras pendentes configuráveis e bloquear operações sem política definida.
|
||||
|
||||
## Protecção do trabalho existente
|
||||
|
||||
Não executar seeds na base real, não recriar contas, não substituir ou apagar
|
||||
dados, não publicar externamente nem enviar emails. Não fazer compras ou
|
||||
pagamentos reais. Migrações aditivas devem ser testadas e preservar os registos.
|
||||
Migrações destrutivas exigem autorização específica do utilizador.
|
||||
|
||||
Manter docs/desenvolvimento.md com incremento actual, requisitos cobertos,
|
||||
ficheiros alterados, testes e resultados, limitações, decisões pendentes e próximo
|
||||
passo. O acompanhamento consultará esse relatório, sem editar código em paralelo.
|
||||
Verificar permissões, isolamento, segregação, cálculos e auditoria, além de
|
||||
PHPStan, Pint, tipos e compilação. Não anunciar como concluído o que não foi
|
||||
implementado ou verificado.
|
||||
|
||||
## Coordenação
|
||||
|
||||
Chat executor: “Criar sistema de compras”, identificador
|
||||
`01a18181-1611-7a33-9d79-abd981f42bfe`, host local.
|
||||
Na primeira tentativa de passagem, o envio falhou porque o chat já tinha uma
|
||||
sessão de escrita activa. Este ficheiro não prova que o executor o tenha lido.
|
||||
O acompanhamento deve tentar entregar a orientação quando o chat estiver
|
||||
disponível e registar o resultado, sem interromper nem duplicar o executor.
|
||||
@@ -6,7 +6,7 @@ import AppLayout from '@/layouts/app-layout';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
import SettingsLayout from '@/layouts/settings/layout';
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||
const appName = 'ProcuraFlow';
|
||||
|
||||
void createInertiaApp({
|
||||
title: (title) => (title ? `${title} - ${appName}` : appName),
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { usePage } from '@inertiajs/react';
|
||||
|
||||
import AppLogoIcon from '@/components/app-logo-icon';
|
||||
|
||||
export default function AppLogo() {
|
||||
const { name } = usePage().props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground">
|
||||
@@ -12,7 +8,7 @@ export default function AppLogo() {
|
||||
</div>
|
||||
<div className="ml-1 grid flex-1 text-left text-sm">
|
||||
<span className="mb-0.5 truncate leading-tight font-semibold">
|
||||
{name}
|
||||
ProcuraFlow
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { BookOpen, FolderGit2, LayoutGrid } from 'lucide-react';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
Building2,
|
||||
ClipboardList,
|
||||
LayoutGrid,
|
||||
Users,
|
||||
Scale,
|
||||
ShieldAlert,
|
||||
FileCheck2,
|
||||
} from 'lucide-react';
|
||||
import AppLogo from '@/components/app-logo';
|
||||
import { NavFooter } from '@/components/nav-footer';
|
||||
import { NavMain } from '@/components/nav-main';
|
||||
import { NavUser } from '@/components/nav-user';
|
||||
import {
|
||||
@@ -18,26 +25,40 @@ import type { NavItem } from '@/types';
|
||||
|
||||
const mainNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
title: 'Visão geral',
|
||||
href: dashboard(),
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
];
|
||||
|
||||
const footerNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Repository',
|
||||
href: 'https://github.com/laravel/react-starter-kit',
|
||||
icon: FolderGit2,
|
||||
},
|
||||
{
|
||||
title: 'Documentation',
|
||||
href: 'https://laravel.com/docs/starter-kits#react',
|
||||
icon: BookOpen,
|
||||
},
|
||||
{ title: 'Requisições', href: '/requisicoes', icon: ClipboardList },
|
||||
{ title: 'Conflitos de interesses', href: '/conflitos', icon: ShieldAlert },
|
||||
];
|
||||
|
||||
export function AppSidebar() {
|
||||
const { auth } = usePage().props;
|
||||
const purchasingItems = [
|
||||
'admin',
|
||||
'procurement',
|
||||
'finance',
|
||||
'department_head',
|
||||
].includes(auth.user.purchasing_role ?? '')
|
||||
? [
|
||||
...mainNavItems,
|
||||
{ title: 'Fornecedores', href: '/fornecedores', icon: Users },
|
||||
{ title: 'Cotações', href: '/cotacoes', icon: Scale },
|
||||
{ title: 'Ordens de compra', href: '/ordens', icon: FileCheck2 },
|
||||
]
|
||||
: mainNavItems;
|
||||
const items =
|
||||
auth.user.purchasing_role === 'admin'
|
||||
? [
|
||||
...purchasingItems,
|
||||
{
|
||||
title: 'Empresa e equipa',
|
||||
href: '/empresa',
|
||||
icon: Building2,
|
||||
},
|
||||
]
|
||||
: purchasingItems;
|
||||
return (
|
||||
<Sidebar collapsible="icon" variant="inset">
|
||||
<SidebarHeader>
|
||||
@@ -53,11 +74,10 @@ export function AppSidebar() {
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<NavMain items={mainNavItems} />
|
||||
<NavMain items={items} />
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<NavFooter items={footerNavItems} className="mt-auto" />
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { ArrowUpRight, ClipboardList } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { date, money, statusLabels } from '@/types/procurement';
|
||||
import type { Requisition } from '@/types/procurement';
|
||||
|
||||
export const controlClass =
|
||||
'h-10 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||
export function PageTitle({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold tracking-widest text-teal-700 uppercase dark:text-teal-400">
|
||||
{eyebrow}
|
||||
</p>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function Panel({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-xl border bg-card p-5 shadow-xs">
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-5">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
export function Status({ value }: { value: string }) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
value === 'approved'
|
||||
? 'border-teal-200 bg-teal-50 text-teal-800 dark:bg-teal-950 dark:text-teal-200'
|
||||
: value === 'pending'
|
||||
? 'border-amber-200 bg-amber-50 text-amber-800 dark:bg-amber-950 dark:text-amber-200'
|
||||
: value === 'rejected'
|
||||
? 'border-rose-200 bg-rose-50 text-rose-800 dark:bg-rose-950 dark:text-rose-200'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{statusLabels[value] ?? value}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
export function RequisitionTable({ rows }: { rows: Requisition[] }) {
|
||||
if (!rows.length)
|
||||
return (
|
||||
<div className="py-12 text-center">
|
||||
<ClipboardList className="mx-auto mb-3 size-9 text-muted-foreground/50" />
|
||||
<p className="font-medium">Sem requisições para apresentar</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Os pedidos de compra aparecem aqui depois de criados.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="pb-3 font-medium">Requisição</th>
|
||||
<th className="pb-3 font-medium">Departamento</th>
|
||||
<th className="pb-3 font-medium">Data</th>
|
||||
<th className="pb-3 font-medium">Estado</th>
|
||||
<th className="pb-3 text-right font-medium">
|
||||
Total com IVA
|
||||
</th>
|
||||
<th>
|
||||
<span className="sr-only">Abrir</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="border-b last:border-0">
|
||||
<td className="py-4 pr-4">
|
||||
<Link
|
||||
className="font-semibold hover:text-teal-700"
|
||||
href={`/requisicoes/${r.id}`}
|
||||
>
|
||||
{r.number}
|
||||
</Link>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{r.requester?.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="pr-4">
|
||||
{r.department?.name ?? '—'}
|
||||
</td>
|
||||
<td className="pr-4 whitespace-nowrap">
|
||||
{date(r.created_at)}
|
||||
</td>
|
||||
<td className="pr-4">
|
||||
<Status value={r.status} />
|
||||
</td>
|
||||
<td className="text-right font-medium whitespace-nowrap">
|
||||
{money(Number(r.total_minor))}
|
||||
</td>
|
||||
<td className="pl-3">
|
||||
<Link
|
||||
href={`/requisicoes/${r.id}`}
|
||||
aria-label={`Abrir ${r.number}`}
|
||||
>
|
||||
<ArrowUpRight className="size-4" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function FormErrors({ errors }: { errors: Record<string, string> }) {
|
||||
if (!Object.keys(errors).length) return null;
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive"
|
||||
>
|
||||
<p className="mb-1 font-medium">Verifique os dados:</p>
|
||||
<ul className="list-inside list-disc">
|
||||
{Object.entries(errors).map(([key, value]) => (
|
||||
<li key={key}>{value}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ type Props = {
|
||||
export default function Login({ status, canResetPassword }: Props) {
|
||||
return (
|
||||
<>
|
||||
<Head title="Log in" />
|
||||
<Head title="Entrar" />
|
||||
|
||||
<Form
|
||||
{...store.form()}
|
||||
@@ -30,7 +30,7 @@ export default function Login({ status, canResetPassword }: Props) {
|
||||
<>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Label htmlFor="email">Endereço de email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
@@ -46,14 +46,16 @@ export default function Login({ status, canResetPassword }: Props) {
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Label htmlFor="password">
|
||||
Palavra-passe
|
||||
</Label>
|
||||
{canResetPassword && (
|
||||
<TextLink
|
||||
href={request()}
|
||||
className="ml-auto text-sm"
|
||||
tabIndex={5}
|
||||
>
|
||||
Forgot your password?
|
||||
Esqueceu a palavra-passe?
|
||||
</TextLink>
|
||||
)}
|
||||
</div>
|
||||
@@ -63,7 +65,7 @@ export default function Login({ status, canResetPassword }: Props) {
|
||||
required
|
||||
tabIndex={2}
|
||||
autoComplete="current-password"
|
||||
placeholder="Password"
|
||||
placeholder="Palavra-passe"
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
@@ -74,7 +76,7 @@ export default function Login({ status, canResetPassword }: Props) {
|
||||
name="remember"
|
||||
tabIndex={3}
|
||||
/>
|
||||
<Label htmlFor="remember">Remember me</Label>
|
||||
<Label htmlFor="remember">Manter sessão</Label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -85,14 +87,14 @@ export default function Login({ status, canResetPassword }: Props) {
|
||||
data-test="login-button"
|
||||
>
|
||||
{processing && <Spinner />}
|
||||
Log in
|
||||
Entrar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{' '}
|
||||
Ainda não tem conta?{' '}
|
||||
<TextLink href={register()} tabIndex={5}>
|
||||
Sign up
|
||||
Criar conta
|
||||
</TextLink>
|
||||
</div>
|
||||
</>
|
||||
@@ -109,6 +111,6 @@ export default function Login({ status, canResetPassword }: Props) {
|
||||
}
|
||||
|
||||
Login.layout = {
|
||||
title: 'Log in to your account',
|
||||
description: 'Enter your email and password below to log in',
|
||||
title: 'Entrar no ProcuraFlow',
|
||||
description: 'Introduza o seu email e a palavra-passe',
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ type Props = {
|
||||
export default function Register({ passwordRules }: Props) {
|
||||
return (
|
||||
<>
|
||||
<Head title="Register" />
|
||||
<Head title="Registar" />
|
||||
<Form
|
||||
{...store.form()}
|
||||
resetOnSuccess={['password', 'password_confirmation']}
|
||||
@@ -27,7 +27,7 @@ export default function Register({ passwordRules }: Props) {
|
||||
<>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Label htmlFor="name">Nome</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
@@ -36,7 +36,7 @@ export default function Register({ passwordRules }: Props) {
|
||||
tabIndex={1}
|
||||
autoComplete="name"
|
||||
name="name"
|
||||
placeholder="Full name"
|
||||
placeholder="Nome completo"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.name}
|
||||
@@ -45,7 +45,7 @@ export default function Register({ passwordRules }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Label htmlFor="email">Endereço de email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
@@ -59,14 +59,14 @@ export default function Register({ passwordRules }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Label htmlFor="password">Palavra-passe</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
required
|
||||
tabIndex={3}
|
||||
autoComplete="new-password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
placeholder="Palavra-passe"
|
||||
passwordrules={passwordRules}
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
@@ -74,7 +74,7 @@ export default function Register({ passwordRules }: Props) {
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password_confirmation">
|
||||
Confirm password
|
||||
Confirmar palavra-passe
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="password_confirmation"
|
||||
@@ -82,7 +82,7 @@ export default function Register({ passwordRules }: Props) {
|
||||
tabIndex={4}
|
||||
autoComplete="new-password"
|
||||
name="password_confirmation"
|
||||
placeholder="Confirm password"
|
||||
placeholder="Confirmar palavra-passe"
|
||||
passwordrules={passwordRules}
|
||||
/>
|
||||
<InputError
|
||||
@@ -97,14 +97,14 @@ export default function Register({ passwordRules }: Props) {
|
||||
data-test="register-user-button"
|
||||
>
|
||||
{processing && <Spinner />}
|
||||
Create account
|
||||
Criar conta
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
Already have an account?{' '}
|
||||
Já tem conta?{' '}
|
||||
<TextLink href={login()} tabIndex={6}>
|
||||
Log in
|
||||
Entrar
|
||||
</TextLink>
|
||||
</div>
|
||||
</>
|
||||
@@ -115,6 +115,6 @@ export default function Register({ passwordRules }: Props) {
|
||||
}
|
||||
|
||||
Register.layout = {
|
||||
title: 'Create an account',
|
||||
description: 'Enter your details below to create your account',
|
||||
title: 'Criar conta',
|
||||
description: 'Registe a conta para configurar a sua empresa',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import {
|
||||
FormErrors,
|
||||
PageTitle,
|
||||
Panel,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { money, roleLabels } from '@/types/procurement';
|
||||
import type { Company, Department, CostCenter } from '@/types/procurement';
|
||||
|
||||
type Member = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
department_id: number;
|
||||
purchasing_role: string;
|
||||
};
|
||||
type Props = {
|
||||
company: Company;
|
||||
departments: Department[];
|
||||
costCenters: CostCenter[];
|
||||
users: Member[];
|
||||
roles: { value: string; label: string }[];
|
||||
};
|
||||
|
||||
export default function CompanyPage({
|
||||
company,
|
||||
departments,
|
||||
costCenters,
|
||||
users,
|
||||
roles,
|
||||
}: Props) {
|
||||
const department = useForm({ name: '' });
|
||||
const center = useForm({ department_id: '', code: '', name: '' });
|
||||
const user = useForm({
|
||||
name: '',
|
||||
email: '',
|
||||
department_id: '',
|
||||
purchasing_role: 'requester',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Head title="Empresa e equipa" />
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-7 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow="Administração"
|
||||
title={company.name}
|
||||
description="Organize os departamentos, centros de custo e contas da equipa."
|
||||
/>
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<Panel
|
||||
title="Departamentos"
|
||||
description="Cada chefe aprova apenas pedidos do seu departamento."
|
||||
>
|
||||
<div className="mb-5 flex flex-wrap gap-2">
|
||||
{departments.map((d) => (
|
||||
<span
|
||||
className="rounded-md bg-muted px-3 py-1 text-sm"
|
||||
key={d.id}
|
||||
>
|
||||
{d.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
department.post('/empresa/departamentos', {
|
||||
onSuccess: () => department.reset(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={department.errors} />
|
||||
<Label htmlFor="department-name">
|
||||
Novo departamento
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="department-name"
|
||||
required
|
||||
value={department.data.name}
|
||||
onChange={(e) =>
|
||||
department.setData(
|
||||
'name',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button disabled={department.processing}>
|
||||
Adicionar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
<Panel title="Centros de custo">
|
||||
<div className="mb-5 space-y-2 text-sm">
|
||||
{costCenters.map((c) => (
|
||||
<p key={c.id}>
|
||||
<strong>{c.code}</strong> · {c.name}
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{
|
||||
departments.find(
|
||||
(d) => d.id === c.department_id,
|
||||
)?.name
|
||||
}
|
||||
</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
center.post('/empresa/centros-de-custo', {
|
||||
onSuccess: () => center.reset(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={center.errors} />
|
||||
<Label htmlFor="center-department">
|
||||
Departamento
|
||||
</Label>
|
||||
<select
|
||||
id="center-department"
|
||||
className={controlClass}
|
||||
required
|
||||
value={center.data.department_id}
|
||||
onChange={(e) =>
|
||||
center.setData(
|
||||
'department_id',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Seleccione</option>
|
||||
{departments.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(['code', 'name'] as const).map((key) => (
|
||||
<div className="space-y-2" key={key}>
|
||||
<Label htmlFor={`center-${key}`}>
|
||||
{key === 'code'
|
||||
? 'Código'
|
||||
: 'Designação'}
|
||||
</Label>
|
||||
<Input
|
||||
id={`center-${key}`}
|
||||
required
|
||||
value={center.data[key]}
|
||||
onChange={(e) =>
|
||||
center.setData(
|
||||
key,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button disabled={center.processing}>
|
||||
Adicionar centro de custo
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
</div>
|
||||
<Panel
|
||||
title="Equipa"
|
||||
description="O perfil define as operações disponíveis. Um administrador não substitui os aprovadores."
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b text-muted-foreground">
|
||||
<tr>
|
||||
<th className="pb-3">Nome</th>
|
||||
<th>Email</th>
|
||||
<th>Departamento</th>
|
||||
<th>Perfil</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr
|
||||
key={u.id}
|
||||
className="border-b last:border-0"
|
||||
>
|
||||
<td className="py-3 pr-4 font-medium">
|
||||
{u.name}
|
||||
</td>
|
||||
<td className="pr-4">{u.email}</td>
|
||||
<td className="pr-4">
|
||||
{
|
||||
departments.find(
|
||||
(d) =>
|
||||
d.id ===
|
||||
u.department_id,
|
||||
)?.name
|
||||
}
|
||||
</td>
|
||||
<td>{roleLabels[u.purchasing_role]}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<form
|
||||
className="mt-6 space-y-4 border-t pt-5"
|
||||
autoComplete="off"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
user.post('/empresa/utilizadores', {
|
||||
onSuccess: () => user.reset(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<h3 className="text-sm font-semibold">
|
||||
Adicionar utilizador
|
||||
</h3>
|
||||
<FormErrors errors={user.errors} />
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{(
|
||||
[
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Nome',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Email',
|
||||
type: 'email',
|
||||
},
|
||||
{
|
||||
key: 'password',
|
||||
label: 'Palavra-passe inicial',
|
||||
type: 'password',
|
||||
},
|
||||
{
|
||||
key: 'password_confirmation',
|
||||
label: 'Confirmar palavra-passe',
|
||||
type: 'password',
|
||||
},
|
||||
] as const
|
||||
).map((field) => (
|
||||
<div className="space-y-2" key={field.key}>
|
||||
<Label htmlFor={`member-${field.key}`}>
|
||||
{field.label}
|
||||
</Label>
|
||||
<Input
|
||||
id={`member-${field.key}`}
|
||||
type={field.type}
|
||||
autoComplete={
|
||||
field.type === 'password'
|
||||
? 'new-password'
|
||||
: 'off'
|
||||
}
|
||||
minLength={
|
||||
field.type === 'password'
|
||||
? 12
|
||||
: undefined
|
||||
}
|
||||
required
|
||||
value={user.data[field.key]}
|
||||
onChange={(e) =>
|
||||
user.setData(
|
||||
field.key,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="member-department">
|
||||
Departamento
|
||||
</Label>
|
||||
<select
|
||||
id="member-department"
|
||||
className={controlClass}
|
||||
required
|
||||
value={user.data.department_id}
|
||||
onChange={(e) =>
|
||||
user.setData(
|
||||
'department_id',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Seleccione</option>
|
||||
{departments.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="member-role">Perfil</Label>
|
||||
<select
|
||||
id="member-role"
|
||||
className={controlClass}
|
||||
value={user.data.purchasing_role}
|
||||
onChange={(e) =>
|
||||
user.setData(
|
||||
'purchasing_role',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
{roles.map((r) => (
|
||||
<option key={r.value} value={r.value}>
|
||||
{r.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Palavras-passe com pelo menos 12 caracteres. Nesta
|
||||
etapa as contas são criadas pelo administrador;
|
||||
convites por email ficam para uma próxima etapa.
|
||||
</p>
|
||||
<Button disabled={user.processing}>
|
||||
Criar utilizador
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
<Panel
|
||||
title="Política inicial de aprovação"
|
||||
description="Configuração provisória. A alteração com dupla validação administrativa será implementada numa próxima etapa."
|
||||
>
|
||||
<div className="grid gap-4 text-sm md:grid-cols-3">
|
||||
<p>
|
||||
Até {money(company.first_limit_minor)}
|
||||
<br />
|
||||
<strong>Chefe de departamento</strong>
|
||||
</p>
|
||||
<p>
|
||||
Até {money(company.second_limit_minor)}
|
||||
<br />
|
||||
<strong>Chefe + Procurement</strong>
|
||||
</p>
|
||||
<p>
|
||||
Acima de {money(company.second_limit_minor)}
|
||||
<br />
|
||||
<strong>Chefe + Procurement + Finanças</strong>
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
Base de cálculo:{' '}
|
||||
{company.approval_includes_tax ? 'com' : 'sem'} IVA.
|
||||
Cada etapa exige uma pessoa distinta do solicitante e
|
||||
dos aprovadores anteriores.
|
||||
</p>
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
CompanyPage.layout = {
|
||||
breadcrumbs: [{ title: 'Empresa e equipa', href: '/empresa' }],
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { FormErrors, PageTitle, Panel } from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export default function Setup() {
|
||||
const form = useForm({ name: '', department: '' });
|
||||
return (
|
||||
<>
|
||||
<Head title="Configurar empresa" />
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-7 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow="Primeiros passos"
|
||||
title="A sua empresa"
|
||||
description="Crie a organização e o departamento inicial. A sua conta ficará como administradora desta empresa."
|
||||
/>
|
||||
<Panel title="Dados da organização">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.post('/empresa/iniciar');
|
||||
}}
|
||||
className="space-y-5"
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="company-name">
|
||||
Nome da empresa
|
||||
</Label>
|
||||
<Input
|
||||
id="company-name"
|
||||
required
|
||||
maxLength={150}
|
||||
value={form.data.name}
|
||||
onChange={(e) =>
|
||||
form.setData('name', e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="department">
|
||||
Primeiro departamento
|
||||
</Label>
|
||||
<Input
|
||||
id="department"
|
||||
required
|
||||
maxLength={100}
|
||||
value={form.data.department}
|
||||
onChange={(e) =>
|
||||
form.setData('department', e.target.value)
|
||||
}
|
||||
placeholder="Ex.: Administração"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Será criado um centro de custo geral. Moeda inicial:
|
||||
MZN. Regras de aprovação provisórias.
|
||||
</p>
|
||||
<Button disabled={form.processing}>
|
||||
Criar empresa
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Setup.layout = {
|
||||
breadcrumbs: [{ title: 'Configurar empresa', href: '/empresa/iniciar' }],
|
||||
};
|
||||
@@ -0,0 +1,307 @@
|
||||
import { Head, Link, useForm, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
FormErrors,
|
||||
PageTitle,
|
||||
Panel,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { conflictScope } from '@/types/conflict';
|
||||
import type { ConflictDeclaration } from '@/types/conflict';
|
||||
import { date } from '@/types/procurement';
|
||||
|
||||
type Props = {
|
||||
declarations: {
|
||||
data: ConflictDeclaration[];
|
||||
prev_page_url: string | null;
|
||||
next_page_url: string | null;
|
||||
};
|
||||
suppliers: { id: number; name: string }[];
|
||||
requisitions: { id: number; number: string }[];
|
||||
};
|
||||
|
||||
export default function Conflicts({
|
||||
declarations,
|
||||
suppliers,
|
||||
requisitions,
|
||||
}: Props) {
|
||||
const { auth } = usePage().props;
|
||||
const form = useForm({
|
||||
scope: 'supplier',
|
||||
supplier_id: '',
|
||||
requisition_id: '',
|
||||
has_conflict: '1',
|
||||
known_since: '',
|
||||
reason: '',
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Head title="Conflitos de interesses" />
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-7 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow="Integridade nas compras · RF-AF-01"
|
||||
title="Conflitos de interesses"
|
||||
description="Registe a sua situação pessoal perante um fornecedor ou processo. Administração e Finanças podem consultar o registo da empresa; os restantes perfis consultam apenas as suas declarações."
|
||||
/>
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
|
||||
Um conflito registado mantém o impedimento, mesmo que exista
|
||||
uma declaração posterior de ausência. Não há caducidade
|
||||
automática nem retirada de conflitos nesta etapa: essas
|
||||
condições dependem da política da empresa. A data de
|
||||
conhecimento descreve o facto e não adia o bloqueio.
|
||||
</div>
|
||||
<Panel
|
||||
title="Nova declaração pessoal"
|
||||
description={`Autor: ${auth.user.name}. A data e a hora do registo são guardadas pelo servidor.`}
|
||||
>
|
||||
<form
|
||||
className="space-y-5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.transform((data) => ({
|
||||
...data,
|
||||
has_conflict: data.has_conflict === '1',
|
||||
supplier_id:
|
||||
data.scope === 'process'
|
||||
? null
|
||||
: data.supplier_id || null,
|
||||
requisition_id:
|
||||
data.scope === 'supplier'
|
||||
? null
|
||||
: data.requisition_id || null,
|
||||
}));
|
||||
form.post('/conflitos', {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => form.reset(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conflict-scope">Âmbito</Label>
|
||||
<select
|
||||
id="conflict-scope"
|
||||
className={controlClass}
|
||||
value={form.data.scope}
|
||||
onChange={(e) =>
|
||||
form.setData('scope', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="supplier">
|
||||
Fornecedor, em qualquer processo
|
||||
</option>
|
||||
<option value="process">
|
||||
Processo, independentemente do
|
||||
fornecedor
|
||||
</option>
|
||||
<option value="process_supplier">
|
||||
Fornecedor neste processo
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conflict-result">
|
||||
Situação declarada
|
||||
</Label>
|
||||
<select
|
||||
id="conflict-result"
|
||||
className={controlClass}
|
||||
value={form.data.has_conflict}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'has_conflict',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="1">
|
||||
Existe conflito de interesses
|
||||
</option>
|
||||
<option value="0">
|
||||
Não identifico conflito de interesses
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
{form.data.scope !== 'process' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conflict-supplier">
|
||||
Fornecedor
|
||||
</Label>
|
||||
<select
|
||||
id="conflict-supplier"
|
||||
className={controlClass}
|
||||
required
|
||||
value={form.data.supplier_id}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'supplier_id',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Seleccione</option>
|
||||
{suppliers.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{form.data.scope !== 'supplier' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conflict-process">
|
||||
Processo / requisição
|
||||
</Label>
|
||||
<select
|
||||
id="conflict-process"
|
||||
className={controlClass}
|
||||
required
|
||||
value={form.data.requisition_id}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'requisition_id',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Seleccione</option>
|
||||
{requisitions.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.number}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conflict-known">
|
||||
Data de conhecimento da situação
|
||||
</Label>
|
||||
<Input
|
||||
id="conflict-known"
|
||||
type="date"
|
||||
required
|
||||
value={form.data.known_since}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'known_since',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conflict-reason">
|
||||
Fundamento da declaração
|
||||
</Label>
|
||||
<textarea
|
||||
id="conflict-reason"
|
||||
className={`${controlClass} h-28 py-3`}
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={5000}
|
||||
value={form.data.reason}
|
||||
onChange={(e) =>
|
||||
form.setData('reason', e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button disabled={form.processing}>
|
||||
Registar declaração pessoal
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
<Panel
|
||||
title="Declarações e correcções"
|
||||
description="O histórico é preservado. Só o autor pode corrigir o fundamento e a data de conhecimento, sem mudar o âmbito ou retirar o conflito."
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b text-muted-foreground">
|
||||
<tr>
|
||||
<th className="pr-4 pb-3">
|
||||
Autor e registo
|
||||
</th>
|
||||
<th className="pr-4">Âmbito</th>
|
||||
<th className="pr-4">Situação</th>
|
||||
<th>
|
||||
<span className="sr-only">
|
||||
Histórico
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{declarations.data.map((d) => (
|
||||
<tr
|
||||
key={d.id}
|
||||
className="border-b last:border-0"
|
||||
>
|
||||
<td className="py-4 pr-4">
|
||||
<p className="font-medium">
|
||||
{d.author.name}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{date(d.declared_at)} · #{d.id}
|
||||
{d.previous_id
|
||||
? ` · Correcção de #${d.previous_id}`
|
||||
: ''}
|
||||
</p>
|
||||
</td>
|
||||
<td className="pr-4">
|
||||
{conflictScope(d)}
|
||||
</td>
|
||||
<td className="pr-4">
|
||||
{d.has_conflict
|
||||
? 'Conflito registado'
|
||||
: 'Ausência declarada'}
|
||||
{d.successor && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Com correcção posterior
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<Link
|
||||
className="text-teal-700 dark:text-teal-400"
|
||||
href={`/conflitos/${d.id}`}
|
||||
>
|
||||
Ver histórico
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{declarations.data.length === 0 && (
|
||||
<p className="py-6 text-sm text-muted-foreground">
|
||||
Ainda não existem declarações disponíveis para o seu
|
||||
perfil.
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex gap-4 text-sm">
|
||||
{declarations.prev_page_url && (
|
||||
<Link href={declarations.prev_page_url}>
|
||||
Anterior
|
||||
</Link>
|
||||
)}
|
||||
{declarations.next_page_url && (
|
||||
<Link href={declarations.next_page_url}>
|
||||
Seguinte
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Conflicts.layout = {
|
||||
breadcrumbs: [{ title: 'Conflitos de interesses', href: '/conflitos' }],
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import {
|
||||
FormErrors,
|
||||
PageTitle,
|
||||
Panel,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { conflictScope } from '@/types/conflict';
|
||||
import type { ConflictDeclaration } from '@/types/conflict';
|
||||
import { date } from '@/types/procurement';
|
||||
|
||||
export default function DeclarationHistory({
|
||||
history,
|
||||
canCorrect,
|
||||
}: {
|
||||
history: ConflictDeclaration[];
|
||||
canCorrect: boolean;
|
||||
}) {
|
||||
const current = history[history.length - 1];
|
||||
const form = useForm({
|
||||
reason: current.reason,
|
||||
known_since: current.known_since.slice(0, 10),
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Head title="Histórico da declaração" />
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-7 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow="Conflitos de interesses"
|
||||
title="Histórico da declaração"
|
||||
description={`${current.author.name} · ${conflictScope(current)}`}
|
||||
action={
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/conflitos">Voltar às declarações</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Panel
|
||||
title={
|
||||
current.has_conflict
|
||||
? 'Conflito registado com impedimento'
|
||||
: 'Ausência de conflito declarada'
|
||||
}
|
||||
description="Uma ausência declarada não retira outros conflitos registados. Não há caducidade ou levantamento automático."
|
||||
>
|
||||
<ol className="space-y-5">
|
||||
{history.map((d) => (
|
||||
<li
|
||||
className="border-l-2 border-teal-600/40 pl-4"
|
||||
key={d.id}
|
||||
>
|
||||
<p className="text-sm font-semibold">
|
||||
Registo #{d.id}
|
||||
{d.previous_id
|
||||
? ` · Correcção de #${d.previous_id}`
|
||||
: ' · Declaração inicial'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{d.author.name} · Registado em{' '}
|
||||
{date(d.declared_at)} às{' '}
|
||||
{d.declared_at.slice(11, 19)} UTC ·
|
||||
Conhecimento em {date(d.known_since)}
|
||||
</p>
|
||||
<p className="mt-3 text-sm whitespace-pre-wrap">
|
||||
{d.reason}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Panel>
|
||||
{canCorrect && (
|
||||
<Panel
|
||||
title="Corrigir fundamento ou data de conhecimento"
|
||||
description="A correcção cria um novo registo e mantém os anteriores. O autor, âmbito e situação declarada permanecem iguais."
|
||||
>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.put(`/conflitos/${current.id}`, {
|
||||
preserveScroll: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="correct-date">
|
||||
Data de conhecimento
|
||||
</Label>
|
||||
<Input
|
||||
id="correct-date"
|
||||
type="date"
|
||||
required
|
||||
value={form.data.known_since}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'known_since',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="correct-reason">
|
||||
Fundamento corrigido
|
||||
</Label>
|
||||
<textarea
|
||||
id="correct-reason"
|
||||
className={`${controlClass} h-28 py-3`}
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={5000}
|
||||
value={form.data.reason}
|
||||
onChange={(e) =>
|
||||
form.setData('reason', e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button disabled={form.processing}>
|
||||
Registar correcção no histórico
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
DeclarationHistory.layout = {
|
||||
breadcrumbs: [
|
||||
{ title: 'Conflitos de interesses', href: '/conflitos' },
|
||||
{ title: 'Histórico', href: '#' },
|
||||
],
|
||||
};
|
||||
@@ -1,26 +1,181 @@
|
||||
import { Head } from '@inertiajs/react';
|
||||
import { PlaceholderPattern } from '@/components/ui/placeholder-pattern';
|
||||
import { dashboard } from '@/routes';
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCheck,
|
||||
ClipboardList,
|
||||
Clock3,
|
||||
Plus,
|
||||
Wallet,
|
||||
} from 'lucide-react';
|
||||
import { PageTitle, Panel, RequisitionTable } from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { money } from '@/types/procurement';
|
||||
import type { Company, Requisition } from '@/types/procurement';
|
||||
|
||||
export default function Dashboard() {
|
||||
type Props = {
|
||||
company: Company | null;
|
||||
stats: {
|
||||
total: number;
|
||||
pending: number;
|
||||
approved: number;
|
||||
approvedMinor: number;
|
||||
awaitingMe: number;
|
||||
};
|
||||
recent: Requisition[];
|
||||
approvals: Requisition[];
|
||||
};
|
||||
export default function Dashboard({
|
||||
company,
|
||||
stats,
|
||||
recent,
|
||||
approvals,
|
||||
}: Props) {
|
||||
const cards = [
|
||||
{
|
||||
label: 'Requisições',
|
||||
value: stats.total,
|
||||
icon: ClipboardList,
|
||||
detail: 'Total de pedidos visíveis',
|
||||
},
|
||||
{
|
||||
label: 'Em aprovação',
|
||||
value: stats.pending,
|
||||
icon: Clock3,
|
||||
detail: `${stats.awaitingMe} aguardam a sua decisão`,
|
||||
},
|
||||
{
|
||||
label: 'Aprovadas',
|
||||
value: stats.approved,
|
||||
icon: CheckCheck,
|
||||
detail: 'Todas as etapas concluídas',
|
||||
},
|
||||
{
|
||||
label: 'Valor aprovado',
|
||||
value: money(Number(stats.approvedMinor)),
|
||||
icon: Wallet,
|
||||
detail: 'Estimativa em MZN, com IVA',
|
||||
},
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<Head title="Dashboard" />
|
||||
<div className="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4">
|
||||
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
|
||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
||||
</div>
|
||||
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
|
||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
||||
</div>
|
||||
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
|
||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative min-h-[100vh] flex-1 overflow-hidden rounded-xl border border-sidebar-border/70 md:min-h-min dark:border-sidebar-border">
|
||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
||||
</div>
|
||||
<Head title="Visão geral" />
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-7 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow={company?.name ?? 'Bem-vindo ao ProcuraFlow'}
|
||||
title="Visão geral das compras"
|
||||
description="Acompanhe os pedidos, as aprovações e a actividade da sua empresa."
|
||||
action={
|
||||
<Button
|
||||
asChild
|
||||
className="bg-teal-700 text-white hover:bg-teal-800"
|
||||
>
|
||||
<Link
|
||||
href={
|
||||
company
|
||||
? '/requisicoes/criar'
|
||||
: '/empresa/iniciar'
|
||||
}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{company
|
||||
? 'Nova requisição'
|
||||
: 'Configurar empresa'}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{!company ? (
|
||||
<Panel
|
||||
title="Vamos configurar a sua empresa"
|
||||
description="Crie a organização e o primeiro departamento. Depois pode adicionar a equipa e começar a registar pedidos."
|
||||
>
|
||||
<Button asChild>
|
||||
<Link href="/empresa/iniciar">
|
||||
Começar configuração{' '}
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</Panel>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{cards.map((card) => (
|
||||
<div
|
||||
key={card.label}
|
||||
className="rounded-xl border bg-card p-5 shadow-xs"
|
||||
>
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>{card.label}</span>
|
||||
<card.icon className="size-4 text-teal-700 dark:text-teal-400" />
|
||||
</div>
|
||||
<p className="mt-5 text-2xl font-semibold tracking-tight">
|
||||
{card.value}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{card.detail}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{company.rules_provisional && (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
|
||||
<strong>
|
||||
Ambiente de desenvolvimento · regras
|
||||
provisórias.
|
||||
</strong>{' '}
|
||||
Escalões de {money(company.first_limit_minor)} e{' '}
|
||||
{money(company.second_limit_minor)}, calculados{' '}
|
||||
{company.approval_includes_tax ? 'com' : 'sem'}{' '}
|
||||
IVA. Os parâmetros precisam de validação antes
|
||||
de compras reais.
|
||||
</div>
|
||||
)}
|
||||
{approvals.length > 0 && (
|
||||
<Panel
|
||||
title="À espera da sua aprovação"
|
||||
description="Abra um pedido para conferir os artigos e registar a decisão."
|
||||
>
|
||||
<div className="divide-y">
|
||||
{approvals.map((r) => (
|
||||
<Link
|
||||
key={r.id}
|
||||
href={`/requisicoes/${r.id}`}
|
||||
className="flex items-center justify-between gap-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{r.number}
|
||||
</p>
|
||||
<p className="mt-1 line-clamp-1 text-sm text-muted-foreground">
|
||||
{r.justification}
|
||||
</p>
|
||||
</div>
|
||||
<span className="flex shrink-0 items-center gap-3 text-sm font-medium">
|
||||
{money(r.total_minor)}
|
||||
<ArrowRight className="size-4" />
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
<Panel
|
||||
title="Requisições recentes"
|
||||
description="Últimos pedidos disponíveis para o seu perfil."
|
||||
>
|
||||
<RequisitionTable rows={recent} />
|
||||
<div className="mt-4 border-t pt-4">
|
||||
<Link
|
||||
href="/requisicoes"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-teal-700 dark:text-teal-400"
|
||||
>
|
||||
Ver todas as requisições{' '}
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -29,8 +184,8 @@ export default function Dashboard() {
|
||||
Dashboard.layout = {
|
||||
breadcrumbs: [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
href: dashboard(),
|
||||
title: 'Visão geral',
|
||||
href: '/dashboard',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { FormErrors, PageTitle, Panel, controlClass } from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { money, roleLabels, date } from '@/types/procurement';
|
||||
import type { MatrixRow, PurchaseAward } from '@/types/purchase';
|
||||
|
||||
type Props = {
|
||||
requisition: { id: number; number: string }; comparison: { rows: MatrixRow[]; ready: boolean; top_ids: number[] };
|
||||
award: PurchaseAward | null; canSelect: boolean; canDecide: boolean; canIssue: boolean; order: { id: number; number: string } | null;
|
||||
};
|
||||
export default function Award({ requisition, comparison, award, canSelect, canDecide, canIssue, order }: Props) {
|
||||
const selection = useForm({ quotation_id: '', justification: '', delivery_date: '', delivery_location: '' });
|
||||
const decision = useForm({ decision: 'approved', comment: '' });
|
||||
const issue = useForm({});
|
||||
return <><Head title={`Adjudicação · ${requisition.number}`} /><div className="mx-auto flex w-full max-w-6xl flex-col gap-7 p-5 lg:p-8"><PageTitle eyebrow={requisition.number} title="Adjudicação e emissão" description="Escolha fundamentada da proposta de maior pontuação, seguida de aprovação explícita do valor final." action={<Button asChild variant="outline"><Link href={`/requisicoes/${requisition.id}/cotacoes`}>Consultar cotações</Link></Button>} />
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">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.</div>
|
||||
<Panel title="Comparação actual" description={comparison.ready ? 'Matriz completa para análise. O servidor volta a verificar tudo em cada operação.' : 'A matriz ainda não reúne três propostas elegíveis, cada uma com duas avaliações elegíveis.'}><div className="overflow-x-auto"><table className="w-full text-left text-sm"><thead className="border-b text-muted-foreground"><tr><th className="pb-3 pr-4">Fornecedor</th><th className="pr-4">Total com IVA</th><th className="pr-4">Avaliações elegíveis</th><th>Pontuação</th></tr></thead><tbody>{comparison.rows.map(q => <tr key={q.id} className="border-b last:border-0"><td className="py-4 pr-4"><p className="font-medium">{q.supplier?.name} · {q.reference}</p>{q.ineligibility_reasons.map(reason => <p className="mt-1 text-xs text-destructive" key={reason}>{reason}</p>)}</td><td className="pr-4 whitespace-nowrap">{money(q.total_minor)}</td><td className="pr-4">{q.evaluation_count} / 2</td><td>{q.score === null ? 'Pendente' : `${q.score.toFixed(2)} / 10`}{comparison.top_ids.includes(q.id) && <p className="text-xs font-medium text-teal-700 dark:text-teal-400">Maior pontuação</p>}</td></tr>)}</tbody></table></div></Panel>
|
||||
{canSelect && <Panel title="Seleccionar proposta" description="Empates permitem escolha fundamentada entre as propostas no topo. A selecção fecha novas propostas e avaliações."><form className="space-y-5" onSubmit={e => { e.preventDefault(); selection.post(`/requisicoes/${requisition.id}/adjudicacao`); }}><FormErrors errors={selection.errors} /><div className="space-y-2"><Label htmlFor="award-quote">Proposta</Label><select id="award-quote" className={controlClass} required value={selection.data.quotation_id} onChange={e => selection.setData('quotation_id', e.target.value)}><option value="">Seleccione uma proposta no topo</option>{comparison.rows.filter(q => comparison.top_ids.includes(q.id)).map(q => <option key={q.id} value={q.id}>{q.supplier.name} · {money(q.total_minor)} · {q.score?.toFixed(2)} / 10</option>)}</select></div><div className="space-y-2"><Label htmlFor="award-justification">Justificação da adjudicação</Label><textarea id="award-justification" className={`${controlClass} h-24 py-3`} required minLength={10} maxLength={5000} value={selection.data.justification} onChange={e => selection.setData('justification', e.target.value)} /></div><div className="grid gap-4 sm:grid-cols-2"><div className="space-y-2"><Label htmlFor="award-date">Data acordada de entrega</Label><Input id="award-date" type="date" required value={selection.data.delivery_date} onChange={e => selection.setData('delivery_date', e.target.value)} /></div><div className="space-y-2"><Label htmlFor="award-location">Local de entrega</Label><Input id="award-location" required maxLength={500} value={selection.data.delivery_location} onChange={e => selection.setData('delivery_location', e.target.value)} /></div></div><p className="text-sm text-muted-foreground">Será aberto um novo ciclo de aprovações pelo valor adjudicado. As decisões da estimativa ficam preservadas e não são reutilizadas. Solicitante e autor da adjudicação não aprovam este ciclo.</p><Button disabled={selection.processing || !comparison.ready}>Registar adjudicação para aprovação</Button></form></Panel>}
|
||||
{award && <Panel title={`Adjudicação #${award.id}`} description={`Estado: ${award.status === 'pending' ? 'Em aprovação' : award.status === 'approved' ? 'Aprovada para emissão' : 'Rejeitada'}`}><p className="text-sm whitespace-pre-wrap">{award.justification}</p><dl className="mt-5 grid gap-4 text-sm sm:grid-cols-3"><div><dt className="text-muted-foreground">Total final com IVA</dt><dd className="mt-1 font-semibold">{money(award.total_minor)}</dd></div><div><dt className="text-muted-foreground">Base dos escalões guardados</dt><dd className="mt-1">{award.approval_snapshot.approval_includes_tax ? 'Com IVA' : 'Sem IVA'}{award.approval_snapshot.rules_provisional ? ' · Provisórios' : ''}</dd></div><div><dt className="text-muted-foreground">Entrega</dt><dd className="mt-1">{date(award.delivery_date)} · {award.delivery_location}</dd></div></dl><ol className="mt-6 space-y-3 border-t pt-5">{award.approvals.map(step => <li key={step.id} className="text-sm"><strong>{step.position}. {roleLabels[step.role]}</strong> · {step.decision === 'pending' ? 'Aguarda decisão' : step.decision === 'approved' ? 'Aprovada' : 'Rejeitada'}{step.approver_id ? ` · Utilizador #${step.approver_id}` : ''}{step.comment && <p className="mt-1 text-muted-foreground">{step.comment}</p>}</li>)}</ol></Panel>}
|
||||
{canDecide && award && <Panel title="Decidir sobre o valor final"><form className="space-y-4" onSubmit={e => { e.preventDefault(); decision.post(`/adjudicacoes/${award.id}/decidir`); }}><FormErrors errors={decision.errors} /><Label htmlFor="award-decision">Decisão</Label><select id="award-decision" className={controlClass} value={decision.data.decision} onChange={e => decision.setData('decision', e.target.value)}><option value="approved">Aprovar etapa</option><option value="rejected">Rejeitar adjudicação</option></select><Label htmlFor="award-comment">Fundamento (obrigatório se rejeitar)</Label><textarea id="award-comment" className={`${controlClass} h-24 py-3`} required={decision.data.decision === 'rejected'} maxLength={2000} value={decision.data.comment} onChange={e => decision.setData('comment', e.target.value)} /><Button disabled={decision.processing}>Registar decisão</Button></form></Panel>}
|
||||
{order ? <Button asChild><Link href={`/ordens/${order.id}`}>Abrir ordem {order.number}</Link></Button> : canIssue && award && <Panel title="Emitir ordem para revisão" description="O servidor revalida a matriz, os conflitos e as aprovações. A ordem emitida mantém um retrato dos dados e não pode ser reescrita."><FormErrors errors={issue.errors} /><Button className="mt-4" disabled={issue.processing} onClick={() => issue.post(`/adjudicacoes/${award.id}/emitir`)}>Emitir documento de desenvolvimento</Button></Panel>}
|
||||
</div></>;
|
||||
}
|
||||
Award.layout = { breadcrumbs: [{ title: 'Ordens de compra', href: '/ordens' }, { title: 'Adjudicação', href: '#' }] };
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { PageTitle, Panel } from '@/components/procurement';
|
||||
import { money, date } from '@/types/procurement';
|
||||
import type { PurchaseAward, PurchaseOrder } from '@/types/purchase';
|
||||
|
||||
type Page<T> = { data: T[]; prev_page_url: string | null; next_page_url: string | null };
|
||||
export default function Orders({ orders, awards }: { orders: Page<PurchaseOrder>; awards: Page<PurchaseAward> }) {
|
||||
return <><Head title="Ordens de compra" /><div className="mx-auto flex w-full max-w-7xl flex-col gap-7 p-5 lg:p-8"><PageTitle eyebrow="Compras" title="Adjudicações e ordens" description="Acompanhe as aprovações dos valores finais e os documentos emitidos para revisão." /><Panel title="Ordens emitidas"><div className="overflow-x-auto"><table className="w-full text-left text-sm"><thead className="border-b text-muted-foreground"><tr><th className="pb-3 pr-4">Ordem</th><th className="pr-4">Fornecedor</th><th className="pr-4">Emissão</th><th>Total com IVA</th></tr></thead><tbody>{orders.data.map(order => <tr key={order.id} className="border-b last:border-0"><td className="py-4 pr-4"><Link href={`/ordens/${order.id}`} className="font-medium text-teal-700 dark:text-teal-400">{order.number}</Link><p className="mt-1 text-xs text-muted-foreground">{order.snapshot.requisition.number}</p></td><td className="pr-4">{order.snapshot.supplier.name}</td><td className="pr-4">{date(order.issued_at)}</td><td>{money(order.total_minor)}</td></tr>)}</tbody></table></div>{orders.data.length === 0 && <p className="py-6 text-sm text-muted-foreground">Ainda não foram emitidas ordens.</p>}<div className="mt-4 flex gap-4 text-sm">{orders.prev_page_url && <Link href={orders.prev_page_url}>Anterior</Link>}{orders.next_page_url && <Link href={orders.next_page_url}>Seguinte</Link>}</div></Panel><Panel title="Adjudicações"><div className="divide-y">{awards.data.map(award => <Link href={`/requisicoes/${award.requisition_id}/adjudicacao`} key={award.id} className="flex items-center justify-between gap-4 py-4 text-sm"><div><strong>Adjudicação #{award.id}</strong><p className="mt-1 text-muted-foreground">{award.status === 'pending' ? 'Em aprovação' : award.status === 'approved' ? 'Aprovada para emissão' : 'Rejeitada'}</p></div><span>{money(award.total_minor)}</span></Link>)}{awards.data.length === 0 && <p className="py-6 text-sm text-muted-foreground">Inicie a adjudicação a partir da comparação de cotações.</p>}</div><div className="mt-4 flex gap-4 text-sm">{awards.prev_page_url && <Link href={awards.prev_page_url}>Anterior</Link>}{awards.next_page_url && <Link href={awards.next_page_url}>Seguinte</Link>}</div></Panel></div></>;
|
||||
}
|
||||
Orders.layout = { breadcrumbs: [{ title: 'Ordens de compra', href: '/ordens' }] };
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { Printer } from 'lucide-react';
|
||||
import { PageTitle, Panel } from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { date, money, roleLabels } from '@/types/procurement';
|
||||
import type { PurchaseOrder } from '@/types/purchase';
|
||||
|
||||
type History = { id: number; action: string; user_id: number; created_at: string };
|
||||
const actions: Record<string, string> = { 'award.created': 'Adjudicação registada', 'award.approved': 'Etapa do valor final aprovada', 'award.rejected': 'Adjudicação rejeitada', 'purchase_order.issued': 'Ordem emitida para revisão' };
|
||||
export default function OrderShow({ order, history }: { order: PurchaseOrder; history: History[] }) {
|
||||
const snapshot = order.snapshot;
|
||||
return <><Head title={order.number} /><div className="mx-auto flex w-full max-w-6xl flex-col gap-7 p-5 lg:p-8"><PageTitle eyebrow={snapshot.company.name} title={order.number} description={`${snapshot.requisition.number} · Emitida em ${date(order.issued_at)} por ${snapshot.issuer.name}`} action={<Button asChild><a href={`/ordens/${order.id}/imprimir`} target="_blank" rel="noopener noreferrer"><Printer className="size-4" />Imprimir / guardar PDF</a></Button>} /><div className="rounded-xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100"><strong>Documento de desenvolvimento. Não autoriza compras reais.</strong> Orçamento não verificado. Os dados apresentados foram congelados na emissão.{snapshot.approval_rules.rules_provisional && ' Regras de aprovação provisórias.'}</div>
|
||||
<div className="grid gap-6 md:grid-cols-2"><Panel title="Fornecedor"><dl className="space-y-3 text-sm"><div><dt className="text-muted-foreground">Nome e identificação fiscal</dt><dd>{snapshot.supplier.name} · NUIT {snapshot.supplier.nuit}</dd></div><div><dt className="text-muted-foreground">Contacto</dt><dd>{snapshot.supplier.contact} · {snapshot.supplier.phone}<br />{snapshot.supplier.email}</dd></div><div><dt className="text-muted-foreground">Morada</dt><dd className="whitespace-pre-wrap">{snapshot.supplier.address}</dd></div></dl></Panel><Panel title="Condições acordadas"><dl className="space-y-3 text-sm"><div><dt className="text-muted-foreground">Entrega</dt><dd>{date(snapshot.award.delivery_date)} · {snapshot.award.delivery_location}</dd></div><div><dt className="text-muted-foreground">Pagamento</dt><dd>{snapshot.quotation.payment_terms}</dd></div><div><dt className="text-muted-foreground">Departamento e centro de custo</dt><dd>{snapshot.department.name} · {snapshot.cost_center.code} · {snapshot.cost_center.name}</dd></div><div><dt className="text-muted-foreground">Proposta</dt><dd>{snapshot.quotation.reference} · {order.currency}</dd></div></dl></Panel></div>
|
||||
<Panel title="Artigos e valores congelados"><div className="overflow-x-auto"><table className="w-full text-left text-sm"><thead className="border-b text-muted-foreground"><tr><th className="pb-3 pr-4">Descrição</th><th className="pr-4">Quantidade</th><th className="pr-4">Preço sem IVA</th><th className="pr-4">IVA</th><th>Total com IVA</th></tr></thead><tbody>{order.items.map(item => <tr key={item.id} className="border-b"><td className="py-4 pr-4">{item.description}</td><td className="pr-4 whitespace-nowrap">{Number(item.quantity)} {item.unit}</td><td className="pr-4 whitespace-nowrap">{money(item.unit_price_minor)}</td><td className="pr-4">{Number(item.tax_rate)}%</td><td className="whitespace-nowrap">{money(item.total_minor)}</td></tr>)}</tbody></table></div><dl className="mt-5 ml-auto max-w-sm space-y-3 text-sm"><div className="flex justify-between gap-4"><dt>Subtotal sem IVA</dt><dd>{money(order.subtotal_minor)}</dd></div><div className="flex justify-between gap-4"><dt>IVA</dt><dd>{money(order.tax_minor)}</dd></div><div className="flex justify-between gap-4 border-t pt-3 font-semibold"><dt>Total com IVA</dt><dd>{money(order.total_minor)}</dd></div></dl></Panel>
|
||||
<Panel title="Fundamento e matriz congelada"><p className="text-sm whitespace-pre-wrap">{snapshot.award.justification}</p><div className="mt-5 space-y-4">{snapshot.decision_matrix.map(row => <div key={row.id} className="rounded-lg border p-4 text-sm"><p className="font-semibold">{row.supplier.name} · {row.reference} · {row.score === null ? 'Não elegível / incompleta' : `${row.score.toFixed(2)} / 10`}</p><p className="mt-1 text-muted-foreground">{money(row.total_minor)} com IVA · {row.evaluation_count} avaliações elegíveis</p>{row.evaluations.map(e => <p key={e.evaluator_id} className="mt-2 text-xs">Avaliador #{e.evaluator_id}: qualidade {e.quality}, histórico {e.history}, pagamento {e.payment}. {e.comment}</p>)}</div>)}</div></Panel>
|
||||
<Panel title="Aprovações do valor final"><ol className="space-y-3">{snapshot.final_approvals.map(step => <li key={step.id} className="text-sm">{roleLabels[step.role]} · Utilizador #{step.approver_id} · Aprovada{step.comment && <p className="mt-1 text-muted-foreground">{step.comment}</p>}</li>)}</ol><p className="mt-4 text-xs text-muted-foreground">Base: {snapshot.approval_rules.approval_includes_tax ? 'com' : 'sem'} IVA. As aprovações da estimativa foram preservadas separadamente.</p></Panel><Panel title="Histórico do processo"><ol className="space-y-3">{history.map(event => <li key={event.id} className="text-sm">{actions[event.action]} · {date(event.created_at)} · Utilizador #{event.user_id}</li>)}</ol></Panel><Link href="/ordens" className="text-sm text-teal-700 dark:text-teal-400">Voltar às ordens</Link>
|
||||
</div></>;
|
||||
}
|
||||
OrderShow.layout = { breadcrumbs: [{ title: 'Ordens de compra', href: '/ordens' }, { title: 'Documento emitido', href: '#' }] };
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { PageTitle, Panel } from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { money } from '@/types/procurement';
|
||||
|
||||
type Process = {
|
||||
id: number;
|
||||
number: string;
|
||||
justification: string;
|
||||
total_minor: number;
|
||||
department: { name: string };
|
||||
};
|
||||
export default function Quotations({
|
||||
requisitions,
|
||||
}: {
|
||||
requisitions: {
|
||||
data: Process[];
|
||||
prev_page_url: string | null;
|
||||
next_page_url: string | null;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Head title="Cotações" />
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-7 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow="Consulta ao mercado"
|
||||
title="Cotações e avaliação"
|
||||
description="Requisições aprovadas disponíveis para recolha e comparação de propostas."
|
||||
/>
|
||||
<Panel title="Processos de compra">
|
||||
<div className="divide-y">
|
||||
{requisitions.data.map((r) => (
|
||||
<div
|
||||
className="flex flex-wrap items-center justify-between gap-4 py-5"
|
||||
key={r.id}
|
||||
>
|
||||
<div>
|
||||
<strong>
|
||||
{r.number} · {r.department.name}
|
||||
</strong>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{r.justification}
|
||||
</p>
|
||||
<p className="mt-2 text-sm">
|
||||
Estimativa com IVA:{' '}
|
||||
{money(r.total_minor)}
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link
|
||||
href={`/requisicoes/${r.id}/cotacoes`}
|
||||
>
|
||||
Comparar propostas
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!requisitions.data.length && (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
As requisições surgem aqui depois de todas as
|
||||
aprovações.
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex gap-4">
|
||||
{requisitions.prev_page_url && (
|
||||
<Link href={requisitions.prev_page_url}>
|
||||
Anterior
|
||||
</Link>
|
||||
)}
|
||||
{requisitions.next_page_url && (
|
||||
<Link href={requisitions.next_page_url}>
|
||||
Seguinte
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Quotations.layout = { breadcrumbs: [{ title: 'Cotações', href: '/cotacoes' }] };
|
||||
@@ -0,0 +1,533 @@
|
||||
import { Head, Link, useForm, usePage } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
FormErrors,
|
||||
PageTitle,
|
||||
Panel,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { money } from '@/types/procurement';
|
||||
|
||||
type Item = {
|
||||
id: number;
|
||||
description: string;
|
||||
quantity: string;
|
||||
tax_rate: string;
|
||||
unit_price_minor: number;
|
||||
total_minor: number;
|
||||
};
|
||||
type Evaluation = {
|
||||
id: number;
|
||||
evaluator_id: number;
|
||||
quality: string;
|
||||
history: string;
|
||||
payment: string;
|
||||
comment: string;
|
||||
};
|
||||
type Quote = {
|
||||
id: number;
|
||||
reference: string;
|
||||
supplier: { name: string };
|
||||
subtotal_minor: number;
|
||||
tax_minor: number;
|
||||
total_minor: number;
|
||||
delivery_days: number;
|
||||
valid_until: string;
|
||||
payment_terms: string;
|
||||
evaluations: Evaluation[];
|
||||
eligible: boolean;
|
||||
conflict_blocked: boolean;
|
||||
score: number | null;
|
||||
items: Item[];
|
||||
};
|
||||
export default function Comparison({
|
||||
requisition,
|
||||
quotations,
|
||||
suppliers,
|
||||
canCreate,
|
||||
canEvaluate,
|
||||
ready,
|
||||
}: {
|
||||
requisition: {
|
||||
id: number;
|
||||
number: string;
|
||||
justification: string;
|
||||
items: Item[];
|
||||
};
|
||||
quotations: Quote[];
|
||||
suppliers: { id: number; name: string }[];
|
||||
canCreate: boolean;
|
||||
canEvaluate: boolean;
|
||||
ready: boolean;
|
||||
}) {
|
||||
const { auth } = usePage().props;
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
const form = useForm({
|
||||
supplier_id: '',
|
||||
reference: '',
|
||||
delivery_days: '1',
|
||||
valid_until: '',
|
||||
payment_terms: '',
|
||||
no_conflict: false,
|
||||
items: requisition.items.map((i) => ({
|
||||
requisition_item_id: i.id,
|
||||
unit_price: '',
|
||||
})),
|
||||
});
|
||||
const evaluation = useForm({
|
||||
quality: '5',
|
||||
history: '5',
|
||||
payment: '5',
|
||||
comment: '',
|
||||
no_conflict: false,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Head title={`Cotações · ${requisition.number}`} />
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-7 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow={requisition.number}
|
||||
title="Comparar propostas"
|
||||
description={requisition.justification}
|
||||
action={
|
||||
canCreate ? (
|
||||
<Button onClick={() => setAdding(!adding)}>
|
||||
{adding
|
||||
? 'Fechar formulário'
|
||||
: 'Registar cotação'}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<Link className="text-sm font-medium text-teal-700 dark:text-teal-400" href={`/requisicoes/${requisition.id}/adjudicacao`}>Adjudicação, aprovações do valor final e ordem de compra</Link>
|
||||
<Link
|
||||
className="text-sm text-teal-700"
|
||||
href={`/requisicoes/${requisition.id}`}
|
||||
>
|
||||
Consultar requisição e aprovações
|
||||
</Link>
|
||||
<Link className="text-sm text-teal-700" href="/conflitos">
|
||||
Registar ou consultar a minha declaração de conflito
|
||||
</Link>
|
||||
<div
|
||||
className={`rounded-xl border p-5 text-sm ${ready ? 'border-teal-200 bg-teal-50 text-teal-900' : 'border-amber-200 bg-amber-50 text-amber-900'}`}
|
||||
>
|
||||
<strong>
|
||||
{ready
|
||||
? 'Comparação completa para revisão.'
|
||||
: 'Comparação ainda incompleta.'}
|
||||
</strong>{' '}
|
||||
São necessárias três propostas elegíveis de fornecedores
|
||||
distintos e duas avaliações independentes por proposta. Esta
|
||||
etapa prepara a adjudicação e a emissão numa fase própria.
|
||||
</div>
|
||||
{adding && (
|
||||
<Panel
|
||||
title="Nova proposta"
|
||||
description="MZN · Quantidades e IVA herdados da requisição. Os preços são calculados no servidor."
|
||||
>
|
||||
<form
|
||||
className="space-y-5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.post(
|
||||
`/requisicoes/${requisition.id}/cotacoes`,
|
||||
{
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
setAdding(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="supplier">
|
||||
Fornecedor activo
|
||||
</Label>
|
||||
<select
|
||||
id="supplier"
|
||||
className={controlClass}
|
||||
required
|
||||
value={form.data.supplier_id}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'supplier_id',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Seleccione</option>
|
||||
{suppliers.map((s) => (
|
||||
<option value={s.id} key={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{(
|
||||
[
|
||||
[
|
||||
'reference',
|
||||
'Referência do fornecedor',
|
||||
'text',
|
||||
],
|
||||
[
|
||||
'delivery_days',
|
||||
'Prazo de entrega (dias)',
|
||||
'number',
|
||||
],
|
||||
['valid_until', 'Válida até', 'date'],
|
||||
[
|
||||
'payment_terms',
|
||||
'Condições de pagamento',
|
||||
'text',
|
||||
],
|
||||
] as const
|
||||
).map(([key, label, type]) => (
|
||||
<div className="space-y-2" key={key}>
|
||||
<Label htmlFor={key}>{label}</Label>
|
||||
<Input
|
||||
id={key}
|
||||
type={type}
|
||||
min={
|
||||
type === 'number'
|
||||
? 1
|
||||
: undefined
|
||||
}
|
||||
required
|
||||
value={form.data[key]}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
key,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{requisition.items.map((i, index) => (
|
||||
<div
|
||||
className="grid items-center gap-3 rounded-lg border p-4 md:grid-cols-2"
|
||||
key={i.id}
|
||||
>
|
||||
<div>
|
||||
<strong className="text-sm">
|
||||
{i.description}
|
||||
</strong>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{i.quantity} unidades · IVA{' '}
|
||||
{i.tax_rate}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`price-${i.id}`}>
|
||||
Preço unitário sem IVA (MZN)
|
||||
</Label>
|
||||
<Input
|
||||
id={`price-${i.id}`}
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
required
|
||||
value={
|
||||
form.data.items[index]
|
||||
.unit_price
|
||||
}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'items',
|
||||
form.data.items.map(
|
||||
(line, k) =>
|
||||
k === index
|
||||
? {
|
||||
...line,
|
||||
unit_price:
|
||||
e
|
||||
.target
|
||||
.value,
|
||||
}
|
||||
: line,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-start gap-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
required
|
||||
checked={form.data.no_conflict}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'no_conflict',
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
Declaro que não tenho conflito de interesses com
|
||||
este fornecedor.
|
||||
</label>
|
||||
<Button disabled={form.processing}>
|
||||
Guardar proposta
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
)}
|
||||
<Panel
|
||||
title="Matriz de comparação"
|
||||
description="Pesos provisórios: preço 40%, qualidade 20%, prazo 15%, histórico 15%, pagamento 10%. Preço e prazo calculados automaticamente entre propostas elegíveis."
|
||||
>
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b text-xs text-muted-foreground">
|
||||
<tr>
|
||||
{[
|
||||
'Fornecedor / referência',
|
||||
'Sem IVA',
|
||||
'IVA',
|
||||
'Total',
|
||||
'Entrega',
|
||||
'Avaliações',
|
||||
'Pontuação',
|
||||
'',
|
||||
].map((h, i) => (
|
||||
<th key={i} className="pr-4 pb-3">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{quotations.map((q) => (
|
||||
<tr className="border-b" key={q.id}>
|
||||
<td className="py-4 pr-4">
|
||||
<strong>{q.supplier.name}</strong>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{q.reference} · válida até{' '}
|
||||
{q.valid_until.slice(0, 10)}
|
||||
</p>
|
||||
{!q.eligible && (
|
||||
<p className="text-xs text-red-700">
|
||||
Proposta não elegível:
|
||||
validade, fornecedor ou
|
||||
conflito de intervenientes.
|
||||
</p>
|
||||
)}
|
||||
{q.conflict_blocked && (
|
||||
<p className="text-xs text-red-700">
|
||||
Participação impedida por
|
||||
conflito de interesses.
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="pr-4 whitespace-nowrap">
|
||||
{money(q.subtotal_minor)}
|
||||
</td>
|
||||
<td className="pr-4 whitespace-nowrap">
|
||||
{money(q.tax_minor)}
|
||||
</td>
|
||||
<td className="pr-4 whitespace-nowrap">
|
||||
{money(q.total_minor)}
|
||||
</td>
|
||||
<td className="pr-4">
|
||||
{q.delivery_days} dias
|
||||
</td>
|
||||
<td className="pr-4">
|
||||
{q.evaluation_count} / 2
|
||||
</td>
|
||||
<td className="pr-4">
|
||||
{q.score === null
|
||||
? 'Pendente'
|
||||
: `${q.score.toFixed(2)} / 10`}
|
||||
</td>
|
||||
<td>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelected(q.id);
|
||||
evaluation.reset();
|
||||
evaluation.clearErrors();
|
||||
}}
|
||||
>
|
||||
Detalhe
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!quotations.length && (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
Ainda não foram registadas propostas.
|
||||
</p>
|
||||
)}
|
||||
</Panel>
|
||||
{quotations
|
||||
.filter((q) => q.id === selected)
|
||||
.map((q) => (
|
||||
<Panel
|
||||
key={q.id}
|
||||
title={`${q.supplier.name} · ${q.reference}`}
|
||||
description={`Pagamento: ${q.payment_terms}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{q.items.map((i) => (
|
||||
<p className="text-sm" key={i.id}>
|
||||
{i.description} · {i.quantity} ×{' '}
|
||||
{money(i.unit_price_minor)} · total com
|
||||
IVA {money(i.total_minor)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<h3 className="mt-6 mb-3 font-semibold">
|
||||
Avaliações registadas
|
||||
</h3>
|
||||
{q.evaluations.map((ev) => (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="mb-3 rounded-lg border p-4 text-sm"
|
||||
>
|
||||
<strong>
|
||||
Avaliador #{ev.evaluator_id}
|
||||
</strong>
|
||||
<p>
|
||||
Qualidade {ev.quality} · histórico{' '}
|
||||
{ev.history} · pagamento {ev.payment}
|
||||
</p>
|
||||
<p className="mt-2 whitespace-pre-wrap text-muted-foreground">
|
||||
{ev.comment}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{canEvaluate &&
|
||||
q.eligible &&
|
||||
!q.conflict_blocked &&
|
||||
!q.evaluations.some(
|
||||
(ev) => ev.evaluator_id === auth.user.id,
|
||||
) && (
|
||||
<form
|
||||
className="mt-5 space-y-4 border-t pt-5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
evaluation.post(
|
||||
`/cotacoes/${q.id}/avaliar`,
|
||||
{
|
||||
onSuccess: () =>
|
||||
evaluation.reset(),
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<FormErrors
|
||||
errors={evaluation.errors}
|
||||
/>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{(
|
||||
[
|
||||
[
|
||||
'quality',
|
||||
'Qualidade técnica',
|
||||
],
|
||||
[
|
||||
'history',
|
||||
'Histórico (5 para novo)',
|
||||
],
|
||||
[
|
||||
'payment',
|
||||
'Condições de pagamento',
|
||||
],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<div
|
||||
className="space-y-2"
|
||||
key={key}
|
||||
>
|
||||
<Label htmlFor={key}>
|
||||
{label} · 0 a 10
|
||||
</Label>
|
||||
<Input
|
||||
id={key}
|
||||
type="number"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.01"
|
||||
required
|
||||
value={
|
||||
evaluation.data[key]
|
||||
}
|
||||
onChange={(e) =>
|
||||
evaluation.setData(
|
||||
key,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Label htmlFor="comment">
|
||||
Fundamento da avaliação
|
||||
</Label>
|
||||
<textarea
|
||||
id="comment"
|
||||
className={controlClass}
|
||||
required
|
||||
minLength={10}
|
||||
value={evaluation.data.comment}
|
||||
onChange={(e) =>
|
||||
evaluation.setData(
|
||||
'comment',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<label className="flex items-start gap-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
required
|
||||
checked={
|
||||
evaluation.data.no_conflict
|
||||
}
|
||||
onChange={(e) =>
|
||||
evaluation.setData(
|
||||
'no_conflict',
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
Declaro não ter conflito de
|
||||
interesses e confirmo a minha
|
||||
avaliação independente.
|
||||
</label>
|
||||
<Button
|
||||
disabled={evaluation.processing}
|
||||
>
|
||||
Registar avaliação
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</Panel>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Comparison.layout = {
|
||||
breadcrumbs: [
|
||||
{ title: 'Cotações', href: '/cotacoes' },
|
||||
{ title: 'Comparação', href: '#' },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,378 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { Plus, Save, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
FormErrors,
|
||||
PageTitle,
|
||||
Panel,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { money } from '@/types/procurement';
|
||||
import type { CostCenter, Requisition } from '@/types/procurement';
|
||||
|
||||
type Line = {
|
||||
key: string;
|
||||
description: string;
|
||||
category: string;
|
||||
purchase_type: string;
|
||||
quantity: string;
|
||||
unit: string;
|
||||
unit_price: string;
|
||||
tax_rate: string;
|
||||
};
|
||||
const emptyLine = (): Line => ({
|
||||
key: crypto.randomUUID(),
|
||||
description: '',
|
||||
category: '',
|
||||
purchase_type: 'consumable',
|
||||
quantity: '1',
|
||||
unit: 'un',
|
||||
unit_price: '',
|
||||
tax_rate: '0',
|
||||
});
|
||||
const lineFields = [
|
||||
{ key: 'description', label: 'Descrição', type: 'text', maxLength: 255 },
|
||||
{ key: 'category', label: 'Categoria', type: 'text', maxLength: 100 },
|
||||
{
|
||||
key: 'quantity',
|
||||
label: 'Quantidade',
|
||||
type: 'number',
|
||||
min: '0.001',
|
||||
max: '1000000',
|
||||
step: '0.001',
|
||||
},
|
||||
{ key: 'unit', label: 'Unidade', type: 'text', maxLength: 30 },
|
||||
{
|
||||
key: 'unit_price',
|
||||
label: 'Preço unitário sem IVA (MZN)',
|
||||
type: 'number',
|
||||
min: '0.01',
|
||||
max: '100000000',
|
||||
step: '0.01',
|
||||
},
|
||||
{
|
||||
key: 'tax_rate',
|
||||
label: 'Taxa de IVA (%)',
|
||||
type: 'number',
|
||||
min: '0',
|
||||
max: '100',
|
||||
step: '0.01',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default function RequisitionForm({
|
||||
costCenters,
|
||||
requisition,
|
||||
}: {
|
||||
costCenters: CostCenter[];
|
||||
requisition: Requisition | null;
|
||||
}) {
|
||||
const form = useForm({
|
||||
cost_center_id: requisition ? String(requisition.cost_center_id) : '',
|
||||
priority: requisition?.priority ?? 'normal',
|
||||
needed_by: requisition?.needed_by ?? '',
|
||||
justification: requisition?.justification ?? '',
|
||||
items: requisition?.items.map((i) => ({
|
||||
key: String(i.id),
|
||||
description: i.description,
|
||||
category: i.category,
|
||||
purchase_type: i.purchase_type,
|
||||
quantity: String(i.quantity),
|
||||
unit: i.unit,
|
||||
unit_price: (i.unit_price_minor / 100).toFixed(2),
|
||||
tax_rate: String(i.tax_rate),
|
||||
})) ?? [emptyLine()],
|
||||
});
|
||||
const changeLine = (index: number, field: keyof Line, value: string) =>
|
||||
form.setData(
|
||||
'items',
|
||||
form.data.items.map((item, i) =>
|
||||
i === index ? { ...item, [field]: value } : item,
|
||||
),
|
||||
);
|
||||
const estimate = form.data.items.reduce(
|
||||
(sum, i) =>
|
||||
sum +
|
||||
Math.round(
|
||||
Math.round(Number(i.quantity) * Number(i.unit_price) * 100) *
|
||||
(1 + Number(i.tax_rate) / 100),
|
||||
),
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Head
|
||||
title={requisition ? 'Editar requisição' : 'Nova requisição'}
|
||||
/>
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-7 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow="Pedido de compra"
|
||||
title={
|
||||
requisition
|
||||
? `Editar ${requisition.number}`
|
||||
: 'Nova requisição'
|
||||
}
|
||||
description="Descreva a necessidade e os artigos. Guarde o rascunho e confira os valores antes de submeter."
|
||||
/>
|
||||
<form
|
||||
className="space-y-6"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (requisition)
|
||||
form.put(`/requisicoes/${requisition.id}`);
|
||||
else form.post('/requisicoes');
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
{costCenters.length === 0 && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border p-4 text-sm"
|
||||
>
|
||||
O seu departamento ainda não tem centros de custo.
|
||||
Peça ao administrador para criar um antes de
|
||||
registar pedidos.
|
||||
</div>
|
||||
)}
|
||||
<Panel
|
||||
title="Informações do pedido"
|
||||
description="O solicitante e o departamento são determinados pela sua conta."
|
||||
>
|
||||
<div className="grid gap-5 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cost-center">
|
||||
Centro de custo
|
||||
</Label>
|
||||
<select
|
||||
id="cost-center"
|
||||
required
|
||||
className={controlClass}
|
||||
value={form.data.cost_center_id}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'cost_center_id',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Seleccione</option>
|
||||
{costCenters.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.code} · {c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="priority">Prioridade</Label>
|
||||
<select
|
||||
id="priority"
|
||||
className={controlClass}
|
||||
value={form.data.priority}
|
||||
onChange={(e) =>
|
||||
form.setData('priority', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="high">Alta</option>
|
||||
<option value="urgent">Urgente</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="needed-by">
|
||||
Entrega pretendida
|
||||
</Label>
|
||||
<Input
|
||||
id="needed-by"
|
||||
type="date"
|
||||
required
|
||||
value={form.data.needed_by}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'needed_by',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-3">
|
||||
<Label htmlFor="justification">
|
||||
Justificação da compra
|
||||
</Label>
|
||||
<textarea
|
||||
id="justification"
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={5000}
|
||||
className={`${controlClass} h-24 py-3`}
|
||||
placeholder="Explique a necessidade e a utilização prevista…"
|
||||
value={form.data.justification}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'justification',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel
|
||||
title="Artigos e serviços"
|
||||
description="Preços em MZN. Indique a taxa de IVA aplicável; o valor inicial de 0% deve ser revisto por Finanças."
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{form.data.items.map((line, index) => (
|
||||
<fieldset
|
||||
className="rounded-lg border bg-muted/20 p-4"
|
||||
key={line.key}
|
||||
>
|
||||
<legend className="px-2 text-xs font-semibold text-muted-foreground">
|
||||
LINHA {index + 1}
|
||||
</legend>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{lineFields.map(
|
||||
({ key, label, ...attributes }) => (
|
||||
<div
|
||||
className="space-y-2"
|
||||
key={key}
|
||||
>
|
||||
<Label
|
||||
htmlFor={`${key}-${line.key}`}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${key}-${line.key}`}
|
||||
{...attributes}
|
||||
required
|
||||
value={line[key]}
|
||||
onChange={(e) =>
|
||||
changeLine(
|
||||
index,
|
||||
key,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`type-${line.key}`}>
|
||||
Tipo de compra
|
||||
</Label>
|
||||
<select
|
||||
id={`type-${line.key}`}
|
||||
className={controlClass}
|
||||
value={line.purchase_type}
|
||||
onChange={(e) =>
|
||||
changeLine(
|
||||
index,
|
||||
'purchase_type',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="consumable">
|
||||
Insumos / matéria-prima
|
||||
</option>
|
||||
<option value="service">
|
||||
Serviço
|
||||
</option>
|
||||
<option value="other">
|
||||
Outro
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={
|
||||
form.data.items.length === 1
|
||||
}
|
||||
onClick={() =>
|
||||
form.setData(
|
||||
'items',
|
||||
form.data.items.filter(
|
||||
(_, i) => i !== index,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Remover linha
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap items-center justify-between gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={form.data.items.length >= 50}
|
||||
onClick={() =>
|
||||
form.setData('items', [
|
||||
...form.data.items,
|
||||
emptyLine(),
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Adicionar linha
|
||||
</Button>
|
||||
<p className="text-sm">
|
||||
Total estimado com IVA{' '}
|
||||
<strong className="ml-3 text-lg">
|
||||
{money(
|
||||
Number.isFinite(estimate)
|
||||
? estimate
|
||||
: 0,
|
||||
)}
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-muted-foreground">
|
||||
O servidor confirma os cálculos ao guardar.
|
||||
Equipamentos e peças terão formulários próprios numa
|
||||
próxima etapa.
|
||||
</p>
|
||||
</Panel>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" asChild>
|
||||
<Link
|
||||
href={
|
||||
requisition
|
||||
? `/requisicoes/${requisition.id}`
|
||||
: '/requisicoes'
|
||||
}
|
||||
>
|
||||
Cancelar
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
form.processing || costCenters.length === 0
|
||||
}
|
||||
>
|
||||
<Save className="size-4" />
|
||||
Guardar rascunho
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
RequisitionForm.layout = {
|
||||
breadcrumbs: [
|
||||
{ title: 'Requisições', href: '/requisicoes' },
|
||||
{ title: 'Formulário', href: '#' },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import {
|
||||
PageTitle,
|
||||
Panel,
|
||||
RequisitionTable,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { statusLabels } from '@/types/procurement';
|
||||
import type { Requisition } from '@/types/procurement';
|
||||
|
||||
type Props = {
|
||||
requisitions: {
|
||||
data: Requisition[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
prev_page_url: string | null;
|
||||
next_page_url: string | null;
|
||||
total: number;
|
||||
};
|
||||
filters: { search?: string; status?: string };
|
||||
};
|
||||
export default function Index({ requisitions, filters }: Props) {
|
||||
const form = useForm({
|
||||
search: filters.search ?? '',
|
||||
status: filters.status ?? '',
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Head title="Requisições" />
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-7 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow="Compras"
|
||||
title="Requisições"
|
||||
description="Registe necessidades e acompanhe cada pedido até à aprovação."
|
||||
action={
|
||||
<Button asChild>
|
||||
<Link href="/requisicoes/criar">
|
||||
<Plus className="size-4" />
|
||||
Nova requisição
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Panel title="Pedidos de compra">
|
||||
<form
|
||||
className="mb-6 flex flex-wrap gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.get('/requisicoes', { preserveState: true });
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
aria-label="Pesquisar pela referência"
|
||||
placeholder="Pesquisar referência…"
|
||||
value={form.data.search}
|
||||
onChange={(e) =>
|
||||
form.setData('search', e.target.value)
|
||||
}
|
||||
/>
|
||||
<select
|
||||
className={`${controlClass} max-w-48`}
|
||||
aria-label="Filtrar por estado"
|
||||
value={form.data.status}
|
||||
onChange={(e) =>
|
||||
form.setData('status', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Todos os estados</option>
|
||||
{Object.entries(statusLabels).map(
|
||||
([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
<Button variant="outline" disabled={form.processing}>
|
||||
<Search className="size-4" />
|
||||
Filtrar
|
||||
</Button>
|
||||
</form>
|
||||
<RequisitionTable rows={requisitions.data} />
|
||||
<div className="mt-5 flex flex-wrap items-center justify-between gap-3 border-t pt-4 text-sm text-muted-foreground">
|
||||
<p>
|
||||
{requisitions.total} pedidos · Página{' '}
|
||||
{requisitions.current_page} de{' '}
|
||||
{requisitions.last_page}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{requisitions.prev_page_url && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={requisitions.prev_page_url}>
|
||||
Anterior
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{requisitions.next_page_url && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={requisitions.next_page_url}>
|
||||
Seguinte
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Index.layout = {
|
||||
breadcrumbs: [{ title: 'Requisições', href: '/requisicoes' }],
|
||||
};
|
||||
@@ -0,0 +1,368 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { Check, Circle, Pencil, Send } from 'lucide-react';
|
||||
import {
|
||||
FormErrors,
|
||||
PageTitle,
|
||||
Panel,
|
||||
Status,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { date, money, priorityLabels, roleLabels } from '@/types/procurement';
|
||||
import type { Requisition } from '@/types/procurement';
|
||||
|
||||
type Audit = {
|
||||
id: number;
|
||||
action: string;
|
||||
user_id: number;
|
||||
created_at: string;
|
||||
};
|
||||
const actions: Record<string, string> = {
|
||||
'requisition.created': 'Rascunho criado',
|
||||
'requisition.updated': 'Rascunho actualizado',
|
||||
'requisition.submitted': 'Pedido submetido',
|
||||
'approval.approved': 'Etapa aprovada',
|
||||
'approval.rejected': 'Pedido rejeitado',
|
||||
};
|
||||
|
||||
export default function Show({
|
||||
requisition: r,
|
||||
canEdit,
|
||||
canDecide,
|
||||
conflictBlocked,
|
||||
audit,
|
||||
}: {
|
||||
requisition: Requisition;
|
||||
canEdit: boolean;
|
||||
canDecide: boolean;
|
||||
conflictBlocked: boolean;
|
||||
audit: Audit[];
|
||||
}) {
|
||||
const submission = useForm({});
|
||||
const decision = useForm<{ decision: string; comment: string }>({
|
||||
decision: 'approved',
|
||||
comment: '',
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Head title={r.number} />
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-7 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow="Requisição de compra"
|
||||
title={r.number}
|
||||
description={`${r.requester?.name} · ${r.department?.name} · ${date(r.created_at)}`}
|
||||
action={<Status value={r.status} />}
|
||||
/>
|
||||
<FormErrors errors={submission.errors} />
|
||||
{conflictBlocked && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
|
||||
Existe um conflito de interesses activo que impede a
|
||||
participação neste processo. As operações são
|
||||
verificadas pelo servidor.{' '}
|
||||
<Link href="/conflitos" className="underline">
|
||||
Consultar as declarações disponíveis para o meu
|
||||
perfil
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
)}
|
||||
{canEdit && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border bg-muted/30 p-5">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Confira o pedido antes de o enviar para aprovação.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/requisicoes/${r.id}/editar`}>
|
||||
<Pencil className="size-4" />
|
||||
Editar
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
disabled={submission.processing}
|
||||
onClick={() =>
|
||||
submission.post(
|
||||
`/requisicoes/${r.id}/submeter`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Send className="size-4" />
|
||||
Submeter para aprovação
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<Panel title="Detalhes do pedido">
|
||||
<dl className="grid gap-5 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
Centro de custo
|
||||
</dt>
|
||||
<dd className="mt-1 font-medium">
|
||||
{r.cost_center?.code} ·{' '}
|
||||
{r.cost_center?.name}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
Prioridade
|
||||
</dt>
|
||||
<dd className="mt-1 font-medium">
|
||||
{priorityLabels[r.priority]}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
Entrega pretendida
|
||||
</dt>
|
||||
<dd className="mt-1 font-medium">
|
||||
{date(r.needed_by)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<dt className="text-muted-foreground">
|
||||
Justificação
|
||||
</dt>
|
||||
<dd className="mt-2 whitespace-pre-wrap">
|
||||
{r.justification}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Panel>
|
||||
<Panel
|
||||
title="Artigos e valores"
|
||||
description="Moeda: MZN · Montantes estimados"
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="pb-3">Artigo</th>
|
||||
<th className="pr-3">Qtd.</th>
|
||||
<th className="pr-3">
|
||||
Preço s/ IVA
|
||||
</th>
|
||||
<th className="pr-3">IVA</th>
|
||||
<th className="text-right">
|
||||
Total c/ IVA
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{r.items.map((i) => (
|
||||
<tr key={i.id} className="border-b">
|
||||
<td className="py-4 pr-4">
|
||||
<p className="font-medium">
|
||||
{i.description}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{i.category}
|
||||
</p>
|
||||
</td>
|
||||
<td className="pr-3 whitespace-nowrap">
|
||||
{Number(i.quantity)}{' '}
|
||||
{i.unit}
|
||||
</td>
|
||||
<td className="pr-3 whitespace-nowrap">
|
||||
{money(i.unit_price_minor)}
|
||||
</td>
|
||||
<td className="pr-3">
|
||||
{Number(i.tax_rate)}%
|
||||
</td>
|
||||
<td className="text-right whitespace-nowrap">
|
||||
{money(i.total_minor)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<dl className="mt-5 ml-auto max-w-xs space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt>Subtotal sem IVA</dt>
|
||||
<dd>{money(r.subtotal_minor)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt>IVA</dt>
|
||||
<dd>{money(r.tax_minor)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between border-t pt-3 text-base font-semibold">
|
||||
<dt>Total com IVA</dt>
|
||||
<dd>{money(r.total_minor)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Panel>
|
||||
{canDecide && (
|
||||
<Panel
|
||||
title="Registar decisão"
|
||||
description="A decisão fica associada à sua conta e ao histórico deste pedido."
|
||||
>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
decision.post(
|
||||
`/requisicoes/${r.id}/decidir`,
|
||||
{
|
||||
onSuccess: () =>
|
||||
decision.reset(),
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={decision.errors} />
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="decision">
|
||||
Decisão
|
||||
</Label>
|
||||
<select
|
||||
id="decision"
|
||||
className={controlClass}
|
||||
value={decision.data.decision}
|
||||
onChange={(e) =>
|
||||
decision.setData(
|
||||
'decision',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="approved">
|
||||
Aprovar etapa
|
||||
</option>
|
||||
<option value="rejected">
|
||||
Rejeitar pedido
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="comment">
|
||||
Comentário{' '}
|
||||
{decision.data.decision ===
|
||||
'rejected'
|
||||
? '(obrigatório)'
|
||||
: '(opcional)'}
|
||||
</Label>
|
||||
<textarea
|
||||
id="comment"
|
||||
className={`${controlClass} h-24 py-3`}
|
||||
required={
|
||||
decision.data.decision ===
|
||||
'rejected'
|
||||
}
|
||||
maxLength={2000}
|
||||
value={decision.data.comment}
|
||||
onChange={(e) =>
|
||||
decision.setData(
|
||||
'comment',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={decision.processing}
|
||||
variant={
|
||||
decision.data.decision ===
|
||||
'rejected'
|
||||
? 'destructive'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
Confirmar decisão
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<Panel title="Percurso de aprovação">
|
||||
{r.steps.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
As etapas serão calculadas pelo servidor
|
||||
quando submeter o pedido.
|
||||
</p>
|
||||
) : (
|
||||
<ol className="space-y-5">
|
||||
{r.steps.map((s) => (
|
||||
<li className="flex gap-3" key={s.id}>
|
||||
<span
|
||||
className={`flex size-8 shrink-0 items-center justify-center rounded-full ${s.decision === 'approved' ? 'bg-teal-100 text-teal-800 dark:bg-teal-950 dark:text-teal-200' : 'bg-muted text-muted-foreground'}`}
|
||||
>
|
||||
{s.decision === 'approved' ? (
|
||||
<Check className="size-4" />
|
||||
) : (
|
||||
<Circle className="size-3" />
|
||||
)}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{roleLabels[s.role]}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{s.decision === 'pending'
|
||||
? r.status ===
|
||||
'rejected'
|
||||
? 'Não realizada'
|
||||
: 'Aguarda decisão'
|
||||
: s.decision ===
|
||||
'approved'
|
||||
? 'Aprovada'
|
||||
: 'Rejeitada'}
|
||||
{s.approver_id
|
||||
? ` · Utilizador #${s.approver_id}`
|
||||
: ''}
|
||||
</p>
|
||||
{s.comment && (
|
||||
<p className="mt-2 text-sm whitespace-pre-wrap">
|
||||
{s.comment}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
{r.approval_snapshot?.rules_provisional && (
|
||||
<p className="mt-5 border-t pt-4 text-xs text-amber-700 dark:text-amber-400">
|
||||
Regras provisórias guardadas na submissão.
|
||||
Base:{' '}
|
||||
{r.approval_snapshot.approval_includes_tax
|
||||
? 'com'
|
||||
: 'sem'}{' '}
|
||||
IVA.
|
||||
</p>
|
||||
)}
|
||||
</Panel>
|
||||
<Panel title="Histórico">
|
||||
<ol className="space-y-4">
|
||||
{audit.map((a) => (
|
||||
<li
|
||||
key={a.id}
|
||||
className="border-l-2 border-teal-600/30 pl-3"
|
||||
>
|
||||
<p className="text-sm font-medium">
|
||||
{actions[a.action] ?? a.action}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{date(a.created_at)} · Utilizador #
|
||||
{a.user_id}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Show.layout = {
|
||||
breadcrumbs: [
|
||||
{ title: 'Requisições', href: '/requisicoes' },
|
||||
{ title: 'Detalhe', href: '#' },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import {
|
||||
FormErrors,
|
||||
PageTitle,
|
||||
Panel,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { supplierFields } from '@/types/supplier';
|
||||
import type { Supplier } from '@/types/supplier';
|
||||
|
||||
export default function EditSupplier({ supplier }: { supplier: Supplier }) {
|
||||
const form = useForm({
|
||||
name: supplier.name,
|
||||
nuit: supplier.nuit,
|
||||
contact: supplier.contact,
|
||||
email: supplier.email,
|
||||
phone: supplier.phone,
|
||||
address: supplier.address,
|
||||
category: supplier.category,
|
||||
beneficiaries: supplier.beneficiaries,
|
||||
document_references: supplier.document_references,
|
||||
diligence_notes: supplier.diligence_notes,
|
||||
next_review: supplier.next_review.slice(0, 10),
|
||||
blocked: supplier.blocked,
|
||||
version: supplier.version,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Head title={`Editar ${supplier.name}`} />
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-7 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow="Fornecedores"
|
||||
title="Editar ficha do fornecedor"
|
||||
description={supplier.name}
|
||||
/>
|
||||
<Panel title="Dados e renovação da diligência">
|
||||
<p className="mb-5 rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
|
||||
Alterações à ficha retiram a validação actual. Finanças
|
||||
terá de rever o fornecedor antes de este voltar a ser
|
||||
elegível para novas cotações.
|
||||
</p>
|
||||
<form
|
||||
className="space-y-5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.put(`/fornecedores/${supplier.id}`);
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{Object.entries(supplierFields).map(
|
||||
([key, label]) => {
|
||||
const name =
|
||||
key as keyof typeof supplierFields;
|
||||
const multiline = [
|
||||
'beneficiaries',
|
||||
'document_references',
|
||||
'diligence_notes',
|
||||
'address',
|
||||
].includes(key);
|
||||
return (
|
||||
<div className="space-y-2" key={key}>
|
||||
<Label htmlFor={`supplier-${key}`}>
|
||||
{label}
|
||||
</Label>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
id={`supplier-${key}`}
|
||||
required
|
||||
className={`${controlClass} h-24 py-2`}
|
||||
value={form.data[name]}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
name,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={`supplier-${key}`}
|
||||
required
|
||||
type={
|
||||
key === 'email'
|
||||
? 'email'
|
||||
: key ===
|
||||
'next_review'
|
||||
? 'date'
|
||||
: 'text'
|
||||
}
|
||||
value={form.data[name]}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
name,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<label className="flex items-center gap-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.data.blocked}
|
||||
onChange={(e) =>
|
||||
form.setData('blocked', e.target.checked)
|
||||
}
|
||||
/>
|
||||
Fornecedor consta da lista de impedidos
|
||||
</label>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/fornecedores/${supplier.id}`}>
|
||||
Cancelar
|
||||
</Link>
|
||||
</Button>
|
||||
<Button disabled={form.processing}>
|
||||
Guardar alterações
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
EditSupplier.layout = {
|
||||
breadcrumbs: [
|
||||
{ title: 'Fornecedores', href: '/fornecedores' },
|
||||
{ title: 'Editar ficha', href: '#' },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,348 @@
|
||||
import { Head, Link, router, useForm } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
FormErrors,
|
||||
PageTitle,
|
||||
Panel,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
type Supplier = {
|
||||
id: number;
|
||||
name: string;
|
||||
nuit: string;
|
||||
contact: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
category: string;
|
||||
beneficiaries: string;
|
||||
document_references: string;
|
||||
diligence_notes: string;
|
||||
status: string;
|
||||
blocked: boolean;
|
||||
next_review: string;
|
||||
review_comment: string | null;
|
||||
version: number;
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
pending: 'Pendente de validação',
|
||||
active: 'Activo',
|
||||
suspended: 'Suspenso',
|
||||
};
|
||||
const fields = {
|
||||
name: 'Nome legal',
|
||||
nuit: 'NUIT (9 dígitos)',
|
||||
contact: 'Pessoa de contacto',
|
||||
email: 'Email',
|
||||
phone: 'Telefone',
|
||||
address: 'Morada',
|
||||
category: 'Categoria de fornecimento',
|
||||
beneficiaries: 'Beneficiários efectivos',
|
||||
document_references: 'Referências dos documentos verificados',
|
||||
diligence_notes: 'Resultado da diligência devida',
|
||||
next_review: 'Próxima revisão',
|
||||
};
|
||||
export default function Suppliers({
|
||||
suppliers,
|
||||
filters,
|
||||
canCreate,
|
||||
canReview,
|
||||
}: {
|
||||
suppliers: {
|
||||
data: Supplier[];
|
||||
prev_page_url: string | null;
|
||||
next_page_url: string | null;
|
||||
};
|
||||
filters: { search?: string };
|
||||
canCreate: boolean;
|
||||
canReview: boolean;
|
||||
}) {
|
||||
const form = useForm({
|
||||
name: '',
|
||||
nuit: '',
|
||||
contact: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
category: '',
|
||||
beneficiaries: '',
|
||||
document_references: '',
|
||||
diligence_notes: '',
|
||||
next_review: '',
|
||||
blocked: false,
|
||||
});
|
||||
const review = useForm({ status: 'active', comment: '', version: 0 });
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [selected, setSelected] = useState<Supplier | null>(null);
|
||||
const [search, setSearch] = useState(filters.search ?? '');
|
||||
return (
|
||||
<>
|
||||
<Head title="Fornecedores" />
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-7 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow="Consulta ao mercado"
|
||||
title="Fornecedores"
|
||||
description="Cadastro, diligência devida e validação independente por Finanças."
|
||||
action={
|
||||
canCreate ? (
|
||||
<Button onClick={() => setCreating(!creating)}>
|
||||
{creating
|
||||
? 'Fechar formulário'
|
||||
: 'Novo fornecedor'}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{creating && (
|
||||
<Panel title="Registar fornecedor">
|
||||
<form
|
||||
className="space-y-5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.post('/fornecedores', {
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
setCreating(false);
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{Object.entries(fields).map(([key, label]) => {
|
||||
const name = key as keyof typeof fields;
|
||||
return (
|
||||
<div className="space-y-2" key={key}>
|
||||
<Label htmlFor={key}>{label}</Label>
|
||||
<Input
|
||||
id={key}
|
||||
required
|
||||
type={
|
||||
key === 'email'
|
||||
? 'email'
|
||||
: key === 'next_review'
|
||||
? 'date'
|
||||
: 'text'
|
||||
}
|
||||
value={form.data[name]}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
name,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<label className="flex items-center gap-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.data.blocked}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'blocked',
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
Fornecedor consta da lista de impedidos
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Registe as referências do alvará, situação
|
||||
fiscal e declaração de conflitos que verificou.
|
||||
Após o registo pode abrir a ficha para anexar os
|
||||
documentos privados. Os dados bancários serão
|
||||
tratados numa etapa posterior.
|
||||
</p>
|
||||
<Button disabled={form.processing}>
|
||||
Guardar para validação
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
)}
|
||||
<Panel title="Directório de fornecedores">
|
||||
<form
|
||||
className="mb-5 flex gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
router.get(
|
||||
'/fornecedores',
|
||||
{ search },
|
||||
{ preserveState: true },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
aria-label="Pesquisar fornecedor"
|
||||
placeholder="Pesquisar nome ou NUIT"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<Button variant="outline">Pesquisar</Button>
|
||||
</form>
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b text-muted-foreground">
|
||||
<tr>
|
||||
{[
|
||||
'Fornecedor',
|
||||
'NUIT',
|
||||
'Estado',
|
||||
'Próxima revisão',
|
||||
'',
|
||||
].map((h, i) => (
|
||||
<th className="pr-4 pb-3" key={i}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{suppliers.data.map((s) => (
|
||||
<tr className="border-b" key={s.id}>
|
||||
<td className="py-4 pr-4">
|
||||
<strong>{s.name}</strong>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{s.category}
|
||||
</p>
|
||||
</td>
|
||||
<td className="pr-4">{s.nuit}</td>
|
||||
<td className="pr-4">
|
||||
{labels[s.status]}
|
||||
{s.blocked && (
|
||||
<span className="block text-red-700">
|
||||
Impedido
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="pr-4">
|
||||
{s.next_review.slice(0, 10)}
|
||||
</td>
|
||||
<td>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelected(s);
|
||||
review.reset();
|
||||
review.clearErrors();
|
||||
review.setData(
|
||||
'version',
|
||||
s.version,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Ver ficha
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!suppliers.data.length && (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
Nenhum fornecedor encontrado.
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex gap-4">
|
||||
{suppliers.prev_page_url && (
|
||||
<Link href={suppliers.prev_page_url}>Anterior</Link>
|
||||
)}
|
||||
{suppliers.next_page_url && (
|
||||
<Link href={suppliers.next_page_url}>Seguinte</Link>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
{selected && (
|
||||
<Panel
|
||||
title={selected.name}
|
||||
description={`NUIT ${selected.nuit}`}
|
||||
>
|
||||
<Button asChild variant="outline" className="mb-5">
|
||||
<Link href={`/fornecedores/${selected.id}`}>
|
||||
Abrir ficha, edição e documentos
|
||||
</Link>
|
||||
</Button>
|
||||
<dl className="grid gap-4 text-sm md:grid-cols-2">
|
||||
{Object.entries(fields).map(([key, label]) => (
|
||||
<div key={key}>
|
||||
<dt className="text-muted-foreground">
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="mt-1 whitespace-pre-wrap">
|
||||
{selected[key as keyof typeof fields]}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{selected.review_comment && (
|
||||
<p className="mt-5 text-sm">
|
||||
Última validação: {selected.review_comment}
|
||||
</p>
|
||||
)}
|
||||
{canReview && (
|
||||
<form
|
||||
className="mt-6 space-y-4 border-t pt-5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
review.post(
|
||||
`/fornecedores/${selected.id}/validar`,
|
||||
{ onSuccess: () => setSelected(null) },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={review.errors} />
|
||||
<Label htmlFor="review-status">
|
||||
Resultado da validação
|
||||
</Label>
|
||||
<select
|
||||
id="review-status"
|
||||
className={controlClass}
|
||||
value={review.data.status}
|
||||
onChange={(e) =>
|
||||
review.setData('status', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="active">
|
||||
Activar fornecedor
|
||||
</option>
|
||||
<option value="suspended">
|
||||
Suspender fornecedor
|
||||
</option>
|
||||
</select>
|
||||
<Label htmlFor="review-comment">
|
||||
Fundamento da decisão
|
||||
</Label>
|
||||
<textarea
|
||||
id="review-comment"
|
||||
className={controlClass}
|
||||
required
|
||||
minLength={10}
|
||||
value={review.data.comment}
|
||||
onChange={(e) =>
|
||||
review.setData(
|
||||
'comment',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button disabled={review.processing}>
|
||||
Registar decisão
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Suppliers.layout = {
|
||||
breadcrumbs: [{ title: 'Fornecedores', href: '/fornecedores' }],
|
||||
};
|
||||
@@ -0,0 +1,318 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { Download, FileLock2, Pencil, Upload } from 'lucide-react';
|
||||
import { useRef } from 'react';
|
||||
import {
|
||||
FormErrors,
|
||||
PageTitle,
|
||||
Panel,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { date } from '@/types/procurement';
|
||||
import {
|
||||
documentTypes,
|
||||
supplierFields,
|
||||
supplierStatuses,
|
||||
} from '@/types/supplier';
|
||||
import type { Supplier } from '@/types/supplier';
|
||||
|
||||
type Document = {
|
||||
id: number;
|
||||
original_name: string;
|
||||
document_type: string;
|
||||
size: number;
|
||||
created_at: string;
|
||||
sha256: string;
|
||||
};
|
||||
type Props = {
|
||||
supplier: Supplier;
|
||||
documents: Document[];
|
||||
canEdit: boolean;
|
||||
canDocuments: boolean;
|
||||
canReview: boolean;
|
||||
};
|
||||
|
||||
export default function SupplierShow({
|
||||
supplier,
|
||||
documents,
|
||||
canEdit,
|
||||
canDocuments,
|
||||
canReview,
|
||||
}: Props) {
|
||||
const upload = useForm<{ file: File | null; document_type: string }>({
|
||||
file: null,
|
||||
document_type: 'license',
|
||||
});
|
||||
const review = useForm({ status: 'active', comment: '' });
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<>
|
||||
<Head title={supplier.name} />
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-7 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow="Ficha de fornecedor"
|
||||
title={supplier.name}
|
||||
description={`NUIT ${supplier.nuit} · ${supplierStatuses[supplier.status]}${supplier.blocked ? ' · Impedido' : ''}`}
|
||||
action={
|
||||
canEdit ? (
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/fornecedores/${supplier.id}/editar`}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
Editar ficha
|
||||
</Link>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<Panel title="Dados do fornecedor">
|
||||
<dl className="grid gap-5 text-sm md:grid-cols-2">
|
||||
{Object.entries(supplierFields).map(([key, label]) => (
|
||||
<div key={key}>
|
||||
<dt className="text-muted-foreground">
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="mt-1 whitespace-pre-wrap">
|
||||
{key === 'next_review'
|
||||
? date(supplier.next_review)
|
||||
: supplier[
|
||||
key as keyof typeof supplierFields
|
||||
]}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{supplier.review_comment && (
|
||||
<p className="mt-5 border-t pt-4 text-sm">
|
||||
Última validação: {supplier.review_comment}
|
||||
</p>
|
||||
)}
|
||||
</Panel>
|
||||
{canDocuments ? (
|
||||
<Panel
|
||||
title="Documentos privados"
|
||||
description="Acesso restrito a Administração, Procurement e Finanças da empresa. As transferências ficam registadas em auditoria."
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{documents.map((document) => (
|
||||
<div
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border p-4"
|
||||
key={document.id}
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<FileLock2 className="mt-1 size-5 shrink-0 text-teal-700" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium break-all">
|
||||
{document.original_name}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{
|
||||
documentTypes[
|
||||
document.document_type
|
||||
]
|
||||
}{' '}
|
||||
·{' '}
|
||||
{Math.ceil(
|
||||
document.size / 1024,
|
||||
)}{' '}
|
||||
KB · {date(document.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a
|
||||
href={`/fornecedores/${supplier.id}/documentos/${document.id}`}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Descarregar
|
||||
<span className="sr-only">
|
||||
{' '}
|
||||
{document.original_name}
|
||||
</span>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{documents.length === 0 && (
|
||||
<p className="py-4 text-sm text-muted-foreground">
|
||||
Ainda não existem documentos anexados a esta
|
||||
ficha.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<form
|
||||
className="mt-6 space-y-4 border-t pt-5"
|
||||
encType="multipart/form-data"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
upload.post(
|
||||
`/fornecedores/${supplier.id}/documentos`,
|
||||
{
|
||||
forceFormData: true,
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
upload.reset();
|
||||
if (fileInput.current)
|
||||
fileInput.current.value =
|
||||
'';
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<h3 className="text-sm font-semibold">
|
||||
Anexar documento
|
||||
</h3>
|
||||
<FormErrors errors={upload.errors} />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="document-type">
|
||||
Tipo de documento
|
||||
</Label>
|
||||
<select
|
||||
id="document-type"
|
||||
className={controlClass}
|
||||
value={upload.data.document_type}
|
||||
onChange={(e) =>
|
||||
upload.setData(
|
||||
'document_type',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
{Object.entries(documentTypes).map(
|
||||
([value, label]) => (
|
||||
<option
|
||||
value={value}
|
||||
key={value}
|
||||
>
|
||||
{label}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="supplier-file">
|
||||
Ficheiro (PDF, JPEG ou PNG; até 2
|
||||
MB)
|
||||
</Label>
|
||||
<Input
|
||||
id="supplier-file"
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
required
|
||||
onChange={(e) =>
|
||||
upload.setData(
|
||||
'file',
|
||||
e.target.files?.[0] ?? null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
O envio de um documento devolve o fornecedor
|
||||
ao estado pendente de validação. Os
|
||||
documentos existentes permanecem no
|
||||
histórico.
|
||||
</p>
|
||||
{upload.progress && (
|
||||
<p className="text-xs" role="status">
|
||||
Envio: {upload.progress.percentage}%
|
||||
</p>
|
||||
)}
|
||||
<Button disabled={upload.processing}>
|
||||
<Upload className="size-4" />
|
||||
Enviar documento
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</Panel>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
O acesso aos documentos está reservado a Administração,
|
||||
Procurement e Finanças.
|
||||
</p>
|
||||
)}
|
||||
{canReview && (
|
||||
<Panel
|
||||
title="Validação independente por Finanças"
|
||||
description="Confira os dados e os documentos actuais antes de decidir."
|
||||
>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
review.transform((data) => ({
|
||||
...data,
|
||||
version: supplier.version,
|
||||
}));
|
||||
review.post(
|
||||
`/fornecedores/${supplier.id}/validar`,
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => review.reset(),
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={review.errors} />
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="review-result">Resultado</Label>
|
||||
<select
|
||||
id="review-result"
|
||||
className={controlClass}
|
||||
value={review.data.status}
|
||||
onChange={(e) =>
|
||||
review.setData('status', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="active">
|
||||
Activar fornecedor
|
||||
</option>
|
||||
<option value="suspended">
|
||||
Suspender fornecedor
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="review-reason">
|
||||
Fundamento da decisão
|
||||
</Label>
|
||||
<textarea
|
||||
id="review-reason"
|
||||
className={`${controlClass} h-24 py-2`}
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={2000}
|
||||
value={review.data.comment}
|
||||
onChange={(e) =>
|
||||
review.setData(
|
||||
'comment',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button disabled={review.processing}>
|
||||
Registar decisão
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
SupplierShow.layout = {
|
||||
breadcrumbs: [
|
||||
{ title: 'Fornecedores', href: '/fornecedores' },
|
||||
{ title: 'Ficha e documentos', href: '#' },
|
||||
],
|
||||
};
|
||||
@@ -2,6 +2,13 @@ export type User = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
purchasing_role?:
|
||||
| 'requester'
|
||||
| 'department_head'
|
||||
| 'procurement'
|
||||
| 'finance'
|
||||
| 'warehouse'
|
||||
| 'admin';
|
||||
avatar?: string;
|
||||
email_verified_at: string | null;
|
||||
created_at: string;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export type ConflictDeclaration = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
supplier_id: number | null;
|
||||
requisition_id: number | null;
|
||||
has_conflict: boolean;
|
||||
known_since: string;
|
||||
declared_at: string;
|
||||
reason: string;
|
||||
previous_id: number | null;
|
||||
root_id: number | null;
|
||||
author: { name: string };
|
||||
supplier: { name: string } | null;
|
||||
requisition: { number: string } | null;
|
||||
successor?: { id: number } | null;
|
||||
};
|
||||
|
||||
export function conflictScope(declaration: ConflictDeclaration) {
|
||||
return [declaration.supplier?.name, declaration.requisition?.number]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
export type Department = { id: number; name: string };
|
||||
export type CostCenter = {
|
||||
id: number;
|
||||
department_id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
export type Company = {
|
||||
id: number;
|
||||
name: string;
|
||||
first_limit_minor: number;
|
||||
second_limit_minor: number;
|
||||
approval_includes_tax: boolean;
|
||||
rules_provisional: boolean;
|
||||
};
|
||||
export type RequisitionItem = {
|
||||
id?: number;
|
||||
description: string;
|
||||
category: string;
|
||||
purchase_type: string;
|
||||
quantity: string;
|
||||
unit: string;
|
||||
unit_price_minor: number;
|
||||
tax_rate: string;
|
||||
subtotal_minor: number;
|
||||
tax_minor: number;
|
||||
total_minor: number;
|
||||
};
|
||||
export type ApprovalStep = {
|
||||
id: number;
|
||||
position: number;
|
||||
role: string;
|
||||
decision: string;
|
||||
approver_id: number | null;
|
||||
comment: string | null;
|
||||
decided_at: string | null;
|
||||
};
|
||||
export type Requisition = {
|
||||
id: number;
|
||||
number: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
needed_by: string;
|
||||
justification: string;
|
||||
cost_center_id: number;
|
||||
subtotal_minor: number;
|
||||
tax_minor: number;
|
||||
total_minor: number;
|
||||
created_at: string;
|
||||
requester?: { name: string };
|
||||
department?: Department;
|
||||
cost_center?: CostCenter;
|
||||
items: RequisitionItem[];
|
||||
steps: ApprovalStep[];
|
||||
approval_snapshot?: {
|
||||
rules_provisional: boolean;
|
||||
approval_includes_tax: boolean;
|
||||
} | null;
|
||||
};
|
||||
export const roleLabels: Record<string, string> = {
|
||||
requester: 'Solicitante',
|
||||
department_head: 'Chefe de departamento',
|
||||
procurement: 'Procurement',
|
||||
finance: 'Finanças',
|
||||
warehouse: 'Recepção / Armazém',
|
||||
admin: 'Administrador',
|
||||
};
|
||||
export const statusLabels: Record<string, string> = {
|
||||
draft: 'Rascunho',
|
||||
pending: 'Em aprovação',
|
||||
approved: 'Aprovada',
|
||||
rejected: 'Rejeitada',
|
||||
};
|
||||
export const priorityLabels: Record<string, string> = {
|
||||
normal: 'Normal',
|
||||
high: 'Alta',
|
||||
urgent: 'Urgente',
|
||||
};
|
||||
export const money = (minor: number) =>
|
||||
new Intl.NumberFormat('pt-MZ', {
|
||||
style: 'currency',
|
||||
currency: 'MZN',
|
||||
}).format(minor / 100);
|
||||
export const date = (value: string) =>
|
||||
new Intl.DateTimeFormat('pt-MZ').format(
|
||||
new Date(value.slice(0, 10) + 'T12:00:00'),
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
export type MatrixRow = {
|
||||
id: number; reference: string; supplier: { name: string }; supplier_id: number;
|
||||
eligible: boolean; ineligibility_reasons: string[]; evaluation_count: number;
|
||||
score: number | null; score_minor: number | null; subtotal_minor: number; tax_minor: number; total_minor: number;
|
||||
evaluations: { evaluator_id: number; quality: string; history: string; payment: string; comment: string }[];
|
||||
};
|
||||
export type AwardApproval = { id: number; position: number; role: string; decision: string; approver_id: number | null; comment: string | null };
|
||||
export type PurchaseAward = {
|
||||
id: number; requisition_id: number; quotation_id: number; status: string; justification: string;
|
||||
subtotal_minor: number; tax_minor: number; total_minor: number; delivery_date: string; delivery_location: string;
|
||||
approval_snapshot: { approval_includes_tax: boolean; rules_provisional: boolean }; approvals: AwardApproval[];
|
||||
};
|
||||
export type OrderItem = { id: number; description: string; quantity: string; unit: string; unit_price_minor: number; tax_rate: string; subtotal_minor: number; tax_minor: number; total_minor: number };
|
||||
export type PurchaseOrder = {
|
||||
id: number; number: string; requisition_id: number; issued_at: string; currency: string;
|
||||
subtotal_minor: number; tax_minor: number; total_minor: number; items: OrderItem[];
|
||||
snapshot: {
|
||||
company: { name: string }; department: { name: string }; cost_center: { code: string; name: string };
|
||||
requisition: { number: string }; issuer: { name: string }; requester: { name: string };
|
||||
supplier: { name: string; nuit: string; contact: string; phone: string; email: string; address: string };
|
||||
quotation: { reference: string; payment_terms: string }; award: { justification: string; delivery_date: string; delivery_location: string };
|
||||
approval_rules: { approval_includes_tax: boolean; rules_provisional: boolean };
|
||||
decision_matrix: MatrixRow[]; final_approvals: AwardApproval[];
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
export type Supplier = {
|
||||
id: number;
|
||||
name: string;
|
||||
nuit: string;
|
||||
contact: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
category: string;
|
||||
beneficiaries: string;
|
||||
document_references: string;
|
||||
diligence_notes: string;
|
||||
blocked: boolean;
|
||||
next_review: string;
|
||||
status: string;
|
||||
review_comment: string | null;
|
||||
version: number;
|
||||
};
|
||||
|
||||
export const supplierFields = {
|
||||
name: 'Nome legal',
|
||||
nuit: 'NUIT (9 dígitos)',
|
||||
contact: 'Pessoa de contacto',
|
||||
email: 'Email',
|
||||
phone: 'Telefone',
|
||||
address: 'Morada',
|
||||
category: 'Categoria de fornecimento',
|
||||
beneficiaries: 'Beneficiários efectivos',
|
||||
document_references: 'Referências dos documentos verificados',
|
||||
diligence_notes: 'Resultado da diligência devida',
|
||||
next_review: 'Próxima revisão',
|
||||
};
|
||||
export const supplierStatuses: Record<string, string> = {
|
||||
pending: 'Pendente de validação',
|
||||
active: 'Activo',
|
||||
suspended: 'Suspenso',
|
||||
};
|
||||
export const documentTypes: Record<string, string> = {
|
||||
license: 'Alvará / licença',
|
||||
tax: 'Situação fiscal',
|
||||
conflict: 'Declaração de conflitos',
|
||||
other: 'Outro documento',
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ $order->number }} · ProcuraFlow</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 17mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { color: #111; font: 12px/1.5 Arial, sans-serif; margin: 24px auto; max-width: 900px; }
|
||||
h1 { font-size: 25px; margin-bottom: 2px; } h2 { font-size: 16px; margin-top: 24px; }
|
||||
.notice { border: 1px solid #aaa; padding: 12px; margin: 18px 0; }
|
||||
.columns { display: flex; gap: 30px; } .columns > div { flex: 1; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 14px 0; }
|
||||
th, td { border: 1px solid #ddd; text-align: left; padding: 8px; vertical-align: top; }
|
||||
th { background: #eee; } thead { display: table-header-group; } tr { break-inside: avoid; }
|
||||
.totals { margin-left: auto; width: 330px; } .pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.small { font-size: 10px; color: #444; } button { padding: 10px 18px; cursor: pointer; }
|
||||
@media print { body { margin: 0; max-width: none; } .no-print { display: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@php($money = fn ($minor) => number_format($minor / 100, 2, ',', '.') . ' MZN')
|
||||
<button type="button" class="no-print" onclick="window.print()">Imprimir ou guardar como PDF</button>
|
||||
<h1>Ordem de compra {{ $order->number }}</h1>
|
||||
<p>{{ $snapshot['company']['name'] }} · {{ $snapshot['requisition']['number'] }}</p>
|
||||
<div class="notice"><strong>Documento de desenvolvimento. Não autoriza compras reais.</strong><br>Orçamento não verificado. @if($snapshot['approval_rules']['rules_provisional']) Regras de aprovação provisórias. @endif</div>
|
||||
<div class="columns">
|
||||
<div><h2>Fornecedor</h2><strong>{{ $snapshot['supplier']['name'] }}</strong><br>NUIT {{ $snapshot['supplier']['nuit'] }}<br><span class="pre">{{ $snapshot['supplier']['address'] }}</span><br>{{ $snapshot['supplier']['contact'] }}<br>{{ $snapshot['supplier']['phone'] }} · {{ $snapshot['supplier']['email'] }}</div>
|
||||
<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>
|
||||
<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>
|
||||
@endforeach
|
||||
</tbody></table>
|
||||
<table class="totals"><tr><td>Subtotal sem IVA</td><td>{{ $money($order->subtotal_minor) }}</td></tr><tr><td>IVA</td><td>{{ $money($order->tax_minor) }}</td></tr><tr><th>Total com IVA</th><th>{{ $money($order->total_minor) }}</th></tr></table>
|
||||
<h2>Justificação da adjudicação</h2><p class="pre">{{ $snapshot['award']['justification'] }}</p>
|
||||
<h2>Matriz de avaliação congelada</h2>
|
||||
<table><thead><tr><th>Fornecedor / proposta</th><th>Total com IVA</th><th>Avaliações elegíveis</th><th>Pontuação</th></tr></thead><tbody>
|
||||
@foreach($snapshot['decision_matrix'] as $row)
|
||||
<tr><td>{{ $row['supplier']['name'] }} · {{ $row['reference'] }}<br>@foreach($row['evaluations'] as $evaluation)<span class="small">Avaliador #{{ $evaluation['evaluator_id'] }}: qualidade {{ $evaluation['quality'] }}, histórico {{ $evaluation['history'] }}, pagamento {{ $evaluation['payment'] }}. {{ $evaluation['comment'] }}</span><br>@endforeach</td><td>{{ $money($row['total_minor']) }}</td><td>{{ $row['evaluation_count'] }}</td><td>{{ $row['score'] === null ? 'Não elegível / incompleta' : number_format($row['score'], 2, ',', '.') . ' / 10' }}</td></tr>
|
||||
@endforeach
|
||||
</tbody></table>
|
||||
<h2>Aprovações do valor final</h2><ul>@foreach($snapshot['final_approvals'] as $approval)<li>{{ \App\Enums\PurchasingRole::from($approval['role'])->label() }} · Utilizador #{{ $approval['approver_id'] }} · {{ $approval['decided_at'] }} · {{ $approval['comment'] }}</li>@endforeach</ul>
|
||||
<p class="small">Base dos escalões: {{ $snapshot['approval_rules']['approval_includes_tax'] ? 'com IVA' : 'sem IVA' }}. Dados congelados na emissão; alterações posteriores às fichas não reescrevem este documento. Não foi enviado por email.</p>
|
||||
</body>
|
||||
</html>
|
||||
+50
-2
@@ -1,11 +1,59 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\CompanyController;
|
||||
use App\Http\Controllers\ConflictDeclarationController;
|
||||
use App\Http\Controllers\QuotationController;
|
||||
use App\Http\Controllers\PurchaseController;
|
||||
use App\Http\Controllers\RequisitionController;
|
||||
use App\Http\Controllers\SupplierController;
|
||||
use App\Http\Controllers\SupplierDocumentController;
|
||||
use App\Http\Middleware\EnsureCompanyIsConfigured;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::inertia('/', 'welcome')->name('home');
|
||||
Route::redirect('/', '/dashboard')->name('home');
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::inertia('dashboard', 'dashboard')->name('dashboard');
|
||||
Route::get('dashboard', [RequisitionController::class, 'dashboard'])->name('dashboard');
|
||||
Route::get('empresa/iniciar', [CompanyController::class, 'setup'])->name('company.setup');
|
||||
Route::post('empresa/iniciar', [CompanyController::class, 'store'])->name('company.store');
|
||||
|
||||
Route::middleware(EnsureCompanyIsConfigured::class)->group(function () {
|
||||
Route::get('requisicoes/{requisition}/adjudicacao', [PurchaseController::class, 'award'])->name('purchases.award');
|
||||
Route::post('requisicoes/{requisition}/adjudicacao', [PurchaseController::class, 'select'])->name('purchases.select');
|
||||
Route::post('adjudicacoes/{award}/decidir', [PurchaseController::class, 'decide'])->name('purchases.decide');
|
||||
Route::post('adjudicacoes/{award}/emitir', [PurchaseController::class, 'issue'])->name('purchases.issue');
|
||||
Route::get('ordens', [PurchaseController::class, 'index'])->name('orders.index');
|
||||
Route::get('ordens/{order}', [PurchaseController::class, 'show'])->name('orders.show');
|
||||
Route::get('ordens/{order}/imprimir', [PurchaseController::class, 'print'])->name('orders.print');
|
||||
Route::get('conflitos', [ConflictDeclarationController::class, 'index'])->name('conflicts.index');
|
||||
Route::post('conflitos', [ConflictDeclarationController::class, 'store'])->name('conflicts.store');
|
||||
Route::get('conflitos/{declaration}', [ConflictDeclarationController::class, 'show'])->name('conflicts.show');
|
||||
Route::put('conflitos/{declaration}', [ConflictDeclarationController::class, 'update'])->name('conflicts.update');
|
||||
Route::get('fornecedores', [SupplierController::class, 'index'])->name('suppliers.index');
|
||||
Route::post('fornecedores', [SupplierController::class, 'store'])->name('suppliers.store');
|
||||
Route::get('fornecedores/{supplier}', [SupplierController::class, 'show'])->name('suppliers.show');
|
||||
Route::get('fornecedores/{supplier}/editar', [SupplierController::class, 'edit'])->name('suppliers.edit');
|
||||
Route::put('fornecedores/{supplier}', [SupplierController::class, 'update'])->name('suppliers.update');
|
||||
Route::post('fornecedores/{supplier}/documentos', [SupplierDocumentController::class, 'store'])->middleware('throttle:20,1')->name('suppliers.documents.store');
|
||||
Route::get('fornecedores/{supplier}/documentos/{document}', [SupplierDocumentController::class, 'download'])->name('suppliers.documents.download');
|
||||
Route::post('fornecedores/{supplier}/validar', [SupplierController::class, 'review'])->name('suppliers.review');
|
||||
Route::get('cotacoes', [QuotationController::class, 'index'])->name('quotations.index');
|
||||
Route::get('requisicoes/{requisition}/cotacoes', [QuotationController::class, 'show'])->name('quotations.show');
|
||||
Route::post('requisicoes/{requisition}/cotacoes', [QuotationController::class, 'store'])->name('quotations.store');
|
||||
Route::post('cotacoes/{quotation}/avaliar', [QuotationController::class, 'evaluate'])->name('quotations.evaluate');
|
||||
Route::get('empresa', [CompanyController::class, 'index'])->name('company.index');
|
||||
Route::post('empresa/departamentos', [CompanyController::class, 'department'])->name('company.departments');
|
||||
Route::post('empresa/centros-de-custo', [CompanyController::class, 'costCenter'])->name('company.cost-centers');
|
||||
Route::post('empresa/utilizadores', [CompanyController::class, 'user'])->name('company.users');
|
||||
Route::get('requisicoes', [RequisitionController::class, 'index'])->name('requisitions.index');
|
||||
Route::get('requisicoes/criar', [RequisitionController::class, 'create'])->name('requisitions.create');
|
||||
Route::post('requisicoes', [RequisitionController::class, 'store'])->name('requisitions.store');
|
||||
Route::get('requisicoes/{requisition}', [RequisitionController::class, 'show'])->name('requisitions.show');
|
||||
Route::get('requisicoes/{requisition}/editar', [RequisitionController::class, 'edit'])->name('requisitions.edit');
|
||||
Route::put('requisicoes/{requisition}', [RequisitionController::class, 'update'])->name('requisitions.update');
|
||||
Route::post('requisicoes/{requisition}/submeter', [RequisitionController::class, 'submit'])->name('requisitions.submit');
|
||||
Route::post('requisicoes/{requisition}/decidir', [RequisitionController::class, 'decide'])->name('requisitions.decide');
|
||||
});
|
||||
});
|
||||
|
||||
require __DIR__.'/settings.php';
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\ConflictDeclaration;
|
||||
use App\Models\CostCenter;
|
||||
use App\Models\Department;
|
||||
use App\Models\ProposalEvaluation;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
function personalConflict(int $supplierId, ?int $requisitionId = null, bool $hasConflict = true): array
|
||||
{
|
||||
return ['scope' => $requisitionId ? 'process_supplier' : 'supplier', 'supplier_id' => $supplierId, 'requisition_id' => $requisitionId, 'has_conflict' => $hasConflict, 'known_since' => now()->subYears(3)->format('Y-m-d'), 'reason' => 'Relação pessoal relevante declarada para efeitos de transparência.'];
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::query()->create(['name' => 'Conflitos A']);
|
||||
$this->department = Department::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Operações']);
|
||||
$this->center = CostCenter::query()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'code' => 'OPS', 'name' => 'Operações']);
|
||||
foreach (['owner' => PurchasingRole::Requester, 'buyer' => PurchasingRole::Procurement, 'head' => PurchasingRole::DepartmentHead, 'finance' => PurchasingRole::Finance, 'admin' => PurchasingRole::Admin] as $key => $role) {
|
||||
$this->{$key} = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'purchasing_role' => $role]);
|
||||
}
|
||||
$this->actingAs($this->buyer);
|
||||
$supplierData = ['tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'name' => 'Fornecedor A', 'nuit' => '123456789', 'contact' => 'Contacto', 'email' => 'contacto@example.test', 'phone' => '123456789', 'address' => 'Maputo', 'category' => 'Material', 'beneficiaries' => 'Beneficiário de teste', 'document_references' => 'Referências documentais do processo.', 'diligence_notes' => 'Documentação de teste conferida.', 'status' => 'active', 'next_review' => now()->addYear()];
|
||||
$this->supplier = Supplier::query()->create($supplierData);
|
||||
$this->otherSupplier = Supplier::query()->create([...$supplierData, 'name' => 'Fornecedor B', 'nuit' => '987654321']);
|
||||
$this->r = Requisition::query()->create(['tenant_id' => $this->tenant->id, 'requester_id' => $this->owner->id, 'department_id' => $this->department->id, 'cost_center_id' => $this->center->id, 'number' => 'REQ-CONFLICT', 'priority' => 'normal', 'needed_by' => now()->addWeek(), 'justification' => 'Pedido de teste de conflitos.', 'status' => 'approved', 'subtotal_minor' => 10000, 'tax_minor' => 0, 'total_minor' => 10000]);
|
||||
$this->item = $this->r->items()->create(['tenant_id' => $this->tenant->id, 'description' => 'Material', 'category' => 'Material', 'purchase_type' => 'consumable', 'quantity' => '1', 'unit' => 'un', 'unit_price_minor' => 10000, 'tax_rate' => '0', 'subtotal_minor' => 10000, 'tax_minor' => 0, 'total_minor' => 10000]);
|
||||
$this->quote = ['supplier_id' => $this->supplier->id, 'reference' => 'PROP-CONFLICT', 'delivery_days' => 5, 'valid_until' => now()->addMonth()->format('Y-m-d'), 'payment_terms' => '30 dias', 'no_conflict' => true, 'items' => [['requisition_item_id' => $this->item->id, 'unit_price' => '100.00']]];
|
||||
$this->evaluation = ['quality' => '8', 'history' => '5', 'payment' => '7', 'comment' => 'Avaliação independente para testes.', 'no_conflict' => true];
|
||||
});
|
||||
|
||||
test('every purchasing profile can make a personal declaration with server controlled authorship', function (PurchasingRole $role) {
|
||||
$user = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => $role]);
|
||||
$this->actingAs($user)->post('/conflitos', personalConflict($this->supplier->id))->assertSessionHasNoErrors()->assertRedirect('/conflitos');
|
||||
$declaration = ConflictDeclaration::query()->firstOrFail();
|
||||
expect($declaration->user_id)->toBe($user->id)->and($declaration->tenant_id)->toBe($this->tenant->id);
|
||||
expect($declaration->declared_at)->not->toBeNull();
|
||||
expect(AuditLog::query()->where('action', 'conflict.declared')->count())->toBe(1);
|
||||
$this->post('/conflitos', [...personalConflict($this->supplier->id), 'user_id' => $this->owner->id])->assertSessionHasErrors('user_id');
|
||||
})->with(PurchasingRole::cases());
|
||||
|
||||
test('consultation is limited to personal declarations except finance and administration oversight', function (PurchasingRole $role, int $expected) {
|
||||
$this->actingAs($this->owner)->post('/conflitos', personalConflict($this->supplier->id));
|
||||
$declaration = ConflictDeclaration::query()->firstOrFail();
|
||||
$user = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'purchasing_role' => $role]);
|
||||
$this->actingAs($user)->get('/conflitos')->assertInertia(fn (Assert $page) => $page->component('conflicts/index')->has('declarations.data', $expected)->missing('suppliers.0.beneficiaries')->missing('suppliers.0.email'));
|
||||
if ($expected === 1) {
|
||||
$this->get('/conflitos/'.$declaration->id)->assertInertia(fn (Assert $page) => $page->component('conflicts/show')->where('canCorrect', false));
|
||||
} else {
|
||||
$this->get('/conflitos/'.$declaration->id)->assertNotFound();
|
||||
}
|
||||
$this->put('/conflitos/'.$declaration->id, ['reason' => 'Tentativa de corrigir declaração alheia.', 'known_since' => now()->format('Y-m-d')])->assertForbidden();
|
||||
})->with([[PurchasingRole::Admin, 1], [PurchasingRole::Finance, 1], [PurchasingRole::Procurement, 0], [PurchasingRole::DepartmentHead, 0], [PurchasingRole::Requester, 0], [PurchasingRole::Warehouse, 0]]);
|
||||
|
||||
test('corrections append history preserve the original assertion and reject stale edits', function () {
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id));
|
||||
$original = ConflictDeclaration::query()->firstOrFail();
|
||||
$before = $original->toArray();
|
||||
$correction = ['reason' => 'Fundamento corrigido com informação complementar.', 'known_since' => now()->subYear()->format('Y-m-d')];
|
||||
$this->put('/conflitos/'.$original->id, $correction)->assertSessionHasNoErrors();
|
||||
expect($original->fresh()->toArray())->toBe($before);
|
||||
$latest = ConflictDeclaration::query()->latest('id')->firstOrFail();
|
||||
expect($latest->previous_id)->toBe($original->id)->and($latest->root_id)->toBe($original->id)->and($latest->has_conflict)->toBeTrue()->and($latest->user_id)->toBe($this->buyer->id);
|
||||
$audit = AuditLog::query()->where('action', 'conflict.corrected')->firstOrFail();
|
||||
expect($audit->before['reason'])->toBe($before['reason'])->and($audit->after['reason'])->toBe($correction['reason']);
|
||||
$this->put('/conflitos/'.$original->id, $correction)->assertSessionHasErrors('history');
|
||||
$this->get('/conflitos/'.$original->id)->assertInertia(fn (Assert $page) => $page->has('history', 2)->where('canCorrect', true));
|
||||
$this->put('/conflitos/'.$latest->id, [...$correction, 'reason' => 'Segunda correcção pessoal do fundamento.'])->assertSessionHasNoErrors();
|
||||
$this->get('/conflitos/'.$original->id)->assertInertia(fn (Assert $page) => $page->has('history', 3));
|
||||
});
|
||||
|
||||
test('corrections cannot remove conflicts change scope author or company', function (string $field, mixed $value) {
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id));
|
||||
$declaration = ConflictDeclaration::query()->firstOrFail();
|
||||
$this->put('/conflitos/'.$declaration->id, ['reason' => 'Tentativa de alterar campos protegidos.', 'known_since' => now()->format('Y-m-d'), $field => $value])->assertSessionHasErrors($field);
|
||||
expect(ConflictDeclaration::query()->count())->toBe(1);
|
||||
})->with([['has_conflict', false], ['supplier_id', 999], ['requisition_id', 999], ['user_id', 999], ['tenant_id', 999], ['declared_at', '2020-01-01'], ['previous_id', 999], ['root_id', 999]]);
|
||||
|
||||
test('declaration dates describe knowledge without automatic expiry and absence cannot override a conflict', function () {
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id))->assertSessionHasNoErrors();
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id, null, false))->assertSessionHasNoErrors();
|
||||
$this->post('/conflitos', [...personalConflict($this->supplier->id), 'known_since' => now()->addDay()->format('Y-m-d')])->assertSessionHasErrors('known_since');
|
||||
expect(app(ConflictGuard::class)->hasConflict($this->buyer->id, $this->supplier->id, $this->r->id))->toBeTrue();
|
||||
$this->post('/requisicoes/'.$this->r->id.'/cotacoes', $this->quote)->assertSessionHasErrors('conflict');
|
||||
expect(Quotation::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('absence alone does not prevent legitimate participation', function () {
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id, null, false));
|
||||
$this->post('/requisicoes/'.$this->r->id.'/cotacoes', $this->quote)->assertSessionHasNoErrors();
|
||||
expect(Quotation::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('unknown supplier does not block a request but association rechecks its requester', function () {
|
||||
$this->actingAs($this->owner)->post('/conflitos', personalConflict($this->supplier->id));
|
||||
$payload = ['cost_center_id' => $this->center->id, 'priority' => 'normal', 'needed_by' => now()->addWeek()->format('Y-m-d'), 'justification' => 'Requisição criada sem fornecedor definido.', 'items' => [['description' => 'Material', 'category' => 'Material', 'purchase_type' => 'consumable', 'quantity' => '1', 'unit' => 'un', 'unit_price' => '100', 'tax_rate' => '0']]];
|
||||
$this->post('/requisicoes', $payload)->assertSessionHasNoErrors();
|
||||
$r = Requisition::query()->latest('id')->firstOrFail();
|
||||
$this->put('/requisicoes/'.$r->id, $payload)->assertSessionHasNoErrors();
|
||||
$this->post('/requisicoes/'.$r->id.'/submeter')->assertSessionHasNoErrors();
|
||||
$this->actingAs($this->head)->post('/requisicoes/'.$r->id.'/decidir', ['decision' => 'approved'])->assertSessionHasNoErrors();
|
||||
expect($r->refresh()->status)->toBe('approved');
|
||||
$quote = [...$this->quote, 'items' => [['requisition_item_id' => $r->items()->firstOrFail()->id, 'unit_price' => '100']]];
|
||||
$this->actingAs($this->buyer)->post('/requisicoes/'.$r->id.'/cotacoes', $quote)->assertSessionHasErrors('conflict');
|
||||
$this->post('/requisicoes/'.$r->id.'/cotacoes', [...$quote, 'supplier_id' => $this->otherSupplier->id])->assertSessionHasNoErrors();
|
||||
});
|
||||
|
||||
test('supplier association checks prior approvers even if their role has changed', function () {
|
||||
$this->r->steps()->create(['tenant_id' => $this->tenant->id, 'position' => 1, 'role' => 'department_head', 'decision' => 'approved', 'approver_id' => $this->head->id]);
|
||||
$this->actingAs($this->head)->post('/conflitos', personalConflict($this->supplier->id));
|
||||
$this->head->forceFill(['purchasing_role' => PurchasingRole::Warehouse])->save();
|
||||
$this->actingAs($this->buyer)->post('/requisicoes/'.$this->r->id.'/cotacoes', $this->quote)->assertSessionHasErrors('conflict');
|
||||
expect(Quotation::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('new associations recheck earlier quotation authors and evaluators', function (string $participant) {
|
||||
$this->post('/requisicoes/'.$this->r->id.'/cotacoes', [...$this->quote, 'supplier_id' => $this->otherSupplier->id])->assertSessionHasNoErrors();
|
||||
$quote = Quotation::query()->firstOrFail();
|
||||
$actor = $participant === 'author' ? $this->buyer : $this->finance;
|
||||
if ($participant === 'evaluator') {
|
||||
$this->actingAs($actor)->post('/cotacoes/'.$quote->id.'/avaliar', $this->evaluation)->assertSessionHasNoErrors();
|
||||
}
|
||||
$this->actingAs($actor)->post('/conflitos', personalConflict($this->supplier->id))->assertSessionHasNoErrors();
|
||||
$newBuyer = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'purchasing_role' => PurchasingRole::Procurement]);
|
||||
$this->actingAs($newBuyer)->post('/requisicoes/'.$this->r->id.'/cotacoes', $this->quote)->assertSessionHasErrors('conflict');
|
||||
expect(Quotation::query()->count())->toBe(1);
|
||||
})->with(['author', 'evaluator']);
|
||||
|
||||
test('historical audit participants are rechecked at supplier association', function () {
|
||||
$formerParticipant = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => PurchasingRole::Requester]);
|
||||
$this->actingAs($formerParticipant)->post('/conflitos', personalConflict($this->supplier->id));
|
||||
AuditLog::query()->create(['tenant_id' => $this->tenant->id, 'user_id' => $formerParticipant->id, 'requisition_id' => $this->r->id, 'action' => 'requisition.updated', 'after' => ['status' => 'draft']]);
|
||||
$this->actingAs($this->buyer)->post('/requisicoes/'.$this->r->id.'/cotacoes', $this->quote)->assertSessionHasErrors('conflict');
|
||||
});
|
||||
|
||||
test('process supplier declarations only affect their declared process and supplier', function () {
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id, $this->r->id))->assertSessionHasNoErrors();
|
||||
$guard = app(ConflictGuard::class);
|
||||
expect($guard->hasConflict($this->buyer->id, $this->supplier->id, $this->r->id))->toBeTrue();
|
||||
expect($guard->hasConflict($this->buyer->id, $this->otherSupplier->id, $this->r->id))->toBeFalse();
|
||||
expect($guard->hasConflict($this->buyer->id, $this->supplier->id, $this->r->id + 1))->toBeFalse();
|
||||
$this->post('/requisicoes/'.$this->r->id.'/cotacoes', [...$this->quote, 'supplier_id' => $this->otherSupplier->id])->assertSessionHasNoErrors();
|
||||
});
|
||||
|
||||
test('a process wide conflict blocks participation regardless of candidate supplier', function () {
|
||||
$this->post('/conflitos', [...personalConflict($this->supplier->id, $this->r->id), 'scope' => 'process', 'supplier_id' => null])->assertSessionHasNoErrors();
|
||||
$this->post('/requisicoes/'.$this->r->id.'/cotacoes', $this->quote)->assertSessionHasErrors('conflict');
|
||||
$this->post('/requisicoes/'.$this->r->id.'/cotacoes', [...$this->quote, 'supplier_id' => $this->otherSupplier->id])->assertSessionHasErrors('conflict');
|
||||
});
|
||||
|
||||
test('process declarations only refer to processes visible to their author', function () {
|
||||
$outsider = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'purchasing_role' => PurchasingRole::Requester]);
|
||||
$this->actingAs($outsider)->get('/conflitos')->assertInertia(fn (Assert $page) => $page->has('requisitions', 0));
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id, $this->r->id))->assertNotFound();
|
||||
});
|
||||
|
||||
test('direct evaluations cannot bypass active conflict with an absence checkbox', function () {
|
||||
$this->post('/requisicoes/'.$this->r->id.'/cotacoes', $this->quote);
|
||||
$q = Quotation::query()->firstOrFail();
|
||||
$this->actingAs($this->finance)->post('/conflitos', personalConflict($this->supplier->id));
|
||||
$this->post('/cotacoes/'.$q->id.'/avaliar', $this->evaluation)->assertSessionHasErrors('conflict');
|
||||
expect(ProposalEvaluation::query()->count())->toBe(0);
|
||||
$this->get('/requisicoes/'.$this->r->id.'/cotacoes')->assertInertia(fn (Assert $page) => $page->where('quotations.0.conflict_blocked', true)->where('canEvaluate', false));
|
||||
});
|
||||
|
||||
test('late conflicts keep past evaluations and invalidate comparison readiness', function () {
|
||||
$supplierData = $this->supplier->only(['tenant_id', 'created_by', 'contact', 'email', 'phone', 'address', 'category', 'beneficiaries', 'document_references', 'diligence_notes', 'status', 'next_review']);
|
||||
$third = Supplier::query()->create([...$supplierData, 'name' => 'Fornecedor C', 'nuit' => '111222333']);
|
||||
foreach ([$this->supplier, $this->otherSupplier, $third] as $supplier) {
|
||||
$this->actingAs($this->buyer)->post('/requisicoes/'.$this->r->id.'/cotacoes', [...$this->quote, 'supplier_id' => $supplier->id])->assertSessionHasNoErrors();
|
||||
$q = Quotation::query()->latest('id')->firstOrFail();
|
||||
$this->post('/cotacoes/'.$q->id.'/avaliar', $this->evaluation)->assertSessionHasNoErrors();
|
||||
$this->actingAs($this->finance)->post('/cotacoes/'.$q->id.'/avaliar', $this->evaluation)->assertSessionHasNoErrors();
|
||||
}
|
||||
$this->get('/requisicoes/'.$this->r->id.'/cotacoes')->assertInertia(fn (Assert $page) => $page->where('ready', true));
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id))->assertSessionHasNoErrors();
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id, null, false))->assertSessionHasNoErrors();
|
||||
$this->get('/requisicoes/'.$this->r->id.'/cotacoes')->assertInertia(fn (Assert $page) => $page->where('ready', false)->where('quotations.0.eligible', false)->where('quotations.0.score', null)->has('quotations.0.evaluations', 2));
|
||||
expect(ProposalEvaluation::query()->count())->toBe(6);
|
||||
expect(AuditLog::query()->where('action', 'quotation.evaluated')->count())->toBe(6);
|
||||
});
|
||||
|
||||
test('known supplier conflicts block pending approvals on the server', function () {
|
||||
$this->post('/requisicoes/'.$this->r->id.'/cotacoes', $this->quote);
|
||||
$this->r->update(['status' => 'pending']);
|
||||
$this->r->steps()->create(['tenant_id' => $this->tenant->id, 'position' => 1, 'role' => 'department_head']);
|
||||
$this->actingAs($this->head)->post('/conflitos', personalConflict($this->supplier->id));
|
||||
$this->get('/requisicoes/'.$this->r->id)->assertInertia(fn (Assert $page) => $page->where('canDecide', false)->where('conflictBlocked', true));
|
||||
$this->post('/requisicoes/'.$this->r->id.'/decidir', ['decision' => 'approved'])->assertForbidden();
|
||||
expect($this->r->refresh()->status)->toBe('pending');
|
||||
expect($this->r->steps()->firstOrFail()->decision)->toBe('pending');
|
||||
});
|
||||
|
||||
test('a conflict declared by an earlier requester blocks subsequent approval by a different person', function () {
|
||||
$this->post('/requisicoes/'.$this->r->id.'/cotacoes', $this->quote)->assertSessionHasNoErrors();
|
||||
$this->r->update(['status' => 'pending']);
|
||||
$this->r->steps()->create(['tenant_id' => $this->tenant->id, 'position' => 1, 'role' => 'department_head']);
|
||||
$this->actingAs($this->owner)->post('/conflitos', personalConflict($this->supplier->id));
|
||||
$this->actingAs($this->head)->post('/requisicoes/'.$this->r->id.'/decidir', ['decision' => 'approved'])->assertForbidden();
|
||||
expect($this->r->steps()->firstOrFail()->decision)->toBe('pending');
|
||||
});
|
||||
|
||||
test('scope requires the corresponding existing supplier and process', function () {
|
||||
$this->post('/conflitos', [...personalConflict($this->supplier->id), 'supplier_id' => null])->assertSessionHasErrors('supplier_id');
|
||||
$this->post('/conflitos', [...personalConflict($this->supplier->id), 'scope' => 'process', 'supplier_id' => null])->assertSessionHasErrors('requisition_id');
|
||||
$this->post('/conflitos', [...personalConflict($this->supplier->id, $this->r->id), 'scope' => 'supplier'])->assertSessionHasErrors('requisition_id');
|
||||
expect(ConflictDeclaration::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('correction audit failure preserves the original and allows a later valid correction', function () {
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id));
|
||||
$declaration = ConflictDeclaration::query()->firstOrFail();
|
||||
$this->mock(RequisitionWorkflow::class, function ($mock) {
|
||||
$mock->shouldReceive('audit')->once()->andThrow(new RuntimeException('Falha de auditoria da correcção.'));
|
||||
});
|
||||
$this->put('/conflitos/'.$declaration->id, ['reason' => 'Correcção que não pode ficar parcialmente gravada.', 'known_since' => now()->format('Y-m-d')])->assertStatus(500);
|
||||
expect(ConflictDeclaration::query()->count())->toBe(1)->and($declaration->successor()->exists())->toBeFalse();
|
||||
expect(AuditLog::query()->where('action', 'conflict.corrected')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('process conflict blocks submission and editing without erasing the draft', function () {
|
||||
$this->r->update(['status' => 'draft']);
|
||||
$this->actingAs($this->owner)->post('/conflitos', [...personalConflict($this->supplier->id, $this->r->id), 'scope' => 'process', 'supplier_id' => null]);
|
||||
$this->post('/requisicoes/'.$this->r->id.'/submeter')->assertSessionHasErrors('conflict');
|
||||
expect($this->r->refresh()->status)->toBe('draft')->and($this->r->items()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('declarations and all changes isolate companies', function () {
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id, $this->r->id));
|
||||
$declaration = ConflictDeclaration::query()->firstOrFail();
|
||||
$other = Tenant::query()->create(['name' => 'Conflitos B']);
|
||||
$admin = User::factory()->create(['tenant_id' => $other->id, 'purchasing_role' => PurchasingRole::Admin]);
|
||||
$this->actingAs($admin)->get('/conflitos')->assertInertia(fn (Assert $page) => $page->has('declarations.data', 0)->has('suppliers', 0)->has('requisitions', 0));
|
||||
$this->get('/conflitos/'.$declaration->id)->assertNotFound();
|
||||
$this->put('/conflitos/'.$declaration->id, ['reason' => 'Tentativa de outro inquilino.', 'known_since' => now()->format('Y-m-d')])->assertNotFound();
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id))->assertSessionHasErrors('supplier_id');
|
||||
$this->post('/conflitos', [...personalConflict($this->supplier->id, $this->r->id), 'scope' => 'process', 'supplier_id' => null])->assertSessionHasErrors('requisition_id');
|
||||
});
|
||||
|
||||
test('failed audit rolls back declaration and creates no partial blocking record', function () {
|
||||
$this->mock(RequisitionWorkflow::class, function ($mock) {
|
||||
$mock->shouldReceive('audit')->once()->andThrow(new RuntimeException('Falha de auditoria simulada.'));
|
||||
});
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id))->assertStatus(500);
|
||||
expect(ConflictDeclaration::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('declaration model rejects destructive mutation and deletion', function () {
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id));
|
||||
$declaration = ConflictDeclaration::query()->firstOrFail();
|
||||
expect(fn () => $declaration->update(['has_conflict' => false]))->toThrow(LogicException::class);
|
||||
expect(fn () => $declaration->delete())->toThrow(LogicException::class);
|
||||
});
|
||||
|
||||
test('guests cannot consult or submit declarations', function () {
|
||||
auth()->logout();
|
||||
$this->get('/conflitos')->assertRedirect('/login');
|
||||
$this->post('/conflitos', personalConflict($this->supplier->id))->assertRedirect('/login');
|
||||
});
|
||||
@@ -3,5 +3,5 @@
|
||||
test('returns a successful response', function () {
|
||||
$response = $this->get(route('home'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertRedirect('/dashboard');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\ApprovalStep;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\CostCenter;
|
||||
use App\Models\Department;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
function procurementUser(Tenant $tenant, Department $department, PurchasingRole $role = PurchasingRole::Requester): User
|
||||
{
|
||||
return User::factory()->create(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'purchasing_role' => $role]);
|
||||
}
|
||||
|
||||
function procurementPayload(int $center, string $price = '100.00', string $tax = '0'): array
|
||||
{
|
||||
return [
|
||||
'cost_center_id' => $center, 'priority' => 'normal', 'needed_by' => now()->addWeek()->format('Y-m-d'),
|
||||
'justification' => 'Material necessário para as operações do departamento.',
|
||||
'items' => [['description' => 'Material', 'category' => 'Consumíveis', 'purchase_type' => 'consumable', 'quantity' => '1', 'unit' => 'un', 'unit_price' => $price, 'tax_rate' => $tax]],
|
||||
];
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::query()->create(['name' => 'Empresa A']);
|
||||
$this->department = Department::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Operações']);
|
||||
$this->center = CostCenter::query()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'code' => 'OPS', 'name' => 'Operações']);
|
||||
$this->requester = procurementUser($this->tenant, $this->department);
|
||||
});
|
||||
|
||||
test('an unassigned account can create an isolated company and becomes its administrator', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user)->post('/empresa/iniciar', ['name' => 'Nova empresa', 'department' => 'Geral'])->assertRedirect('/dashboard');
|
||||
expect($user->refresh()->purchasing_role)->toBe(PurchasingRole::Admin);
|
||||
expect($user->tenant_id)->not->toBe($this->tenant->id);
|
||||
$this->actingAs($user)->post('/empresa/iniciar', ['name' => 'Outra empresa', 'department' => 'Geral'])->assertForbidden();
|
||||
});
|
||||
|
||||
test('server calculates money and ignores forged totals identity status and currency', function () {
|
||||
$payload = procurementPayload($this->center->id, '0.05', '16');
|
||||
$payload['items'][0]['quantity'] = '3';
|
||||
$payload += ['tenant_id' => 999, 'requester_id' => 999, 'total_minor' => 1, 'status' => 'approved', 'currency' => 'USD'];
|
||||
$this->actingAs($this->requester)->post('/requisicoes', $payload)->assertSessionHasNoErrors()->assertRedirect();
|
||||
$r = Requisition::query()->firstOrFail();
|
||||
expect($r->subtotal_minor)->toBe(15)->and($r->tax_minor)->toBe(2)->and($r->total_minor)->toBe(17);
|
||||
expect($r->requester_id)->toBe($this->requester->id)->and($r->status)->toBe('draft')->and($r->currency)->toBe('MZN');
|
||||
expect(AuditLog::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('approval boundaries are inclusive and snapshotted', function (string $price, int $steps) {
|
||||
$this->actingAs($this->requester)->post('/requisicoes', procurementPayload($this->center->id, $price))->assertRedirect();
|
||||
$r = Requisition::query()->firstOrFail();
|
||||
$this->post("/requisicoes/{$r->id}/submeter")->assertSessionHasNoErrors()->assertRedirect();
|
||||
expect($r->refresh()->status)->toBe('pending')->and($r->steps()->count())->toBe($steps);
|
||||
expect($r->approval_snapshot['first_limit_minor'])->toBe(2500000);
|
||||
$this->post("/requisicoes/{$r->id}/submeter")->assertForbidden();
|
||||
expect($r->steps()->count())->toBe($steps);
|
||||
})->with([['25000.00', 1], ['25000.01', 2], ['150000.00', 2], ['150000.01', 3]]);
|
||||
|
||||
test('tax can move a request into a higher approval tier', function () {
|
||||
$this->actingAs($this->requester)->post('/requisicoes', procurementPayload($this->center->id, '25000.00', '16'));
|
||||
$r = Requisition::query()->firstOrFail();
|
||||
$this->post("/requisicoes/{$r->id}/submeter")->assertRedirect();
|
||||
expect($r->steps()->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('approvals enforce order role department and distinct actors', function () {
|
||||
$this->actingAs($this->requester)->post('/requisicoes', procurementPayload($this->center->id, '150000.01'));
|
||||
$r = Requisition::query()->firstOrFail();
|
||||
$this->post("/requisicoes/{$r->id}/submeter");
|
||||
$head = procurementUser($this->tenant, $this->department, PurchasingRole::DepartmentHead);
|
||||
$procurement = procurementUser($this->tenant, $this->department, PurchasingRole::Procurement);
|
||||
$finance = procurementUser($this->tenant, $this->department, PurchasingRole::Finance);
|
||||
$admin = procurementUser($this->tenant, $this->department, PurchasingRole::Admin);
|
||||
$this->actingAs($finance)->post("/requisicoes/{$r->id}/decidir", ['decision' => 'approved'])->assertForbidden();
|
||||
$this->actingAs($admin)->post("/requisicoes/{$r->id}/decidir", ['decision' => 'approved'])->assertForbidden();
|
||||
$this->actingAs($head)->post("/requisicoes/{$r->id}/decidir", ['decision' => 'approved'])->assertRedirect();
|
||||
$head->forceFill(['purchasing_role' => PurchasingRole::Procurement])->save();
|
||||
$this->actingAs($head)->post("/requisicoes/{$r->id}/decidir", ['decision' => 'approved'])->assertForbidden();
|
||||
$this->actingAs($procurement)->post("/requisicoes/{$r->id}/decidir", ['decision' => 'approved'])->assertRedirect();
|
||||
$this->actingAs($finance)->post("/requisicoes/{$r->id}/decidir", ['decision' => 'approved'])->assertRedirect();
|
||||
expect($r->refresh()->status)->toBe('approved');
|
||||
$this->post("/requisicoes/{$r->id}/decidir", ['decision' => 'approved'])->assertForbidden();
|
||||
expect(AuditLog::query()->where('requisition_id', $r->id)->count())->toBe(5);
|
||||
});
|
||||
|
||||
test('requesters cannot approve their own request even as department heads', function () {
|
||||
$this->requester->forceFill(['purchasing_role' => PurchasingRole::DepartmentHead])->save();
|
||||
$this->actingAs($this->requester)->post('/requisicoes', procurementPayload($this->center->id));
|
||||
$r = Requisition::query()->firstOrFail();
|
||||
$this->post("/requisicoes/{$r->id}/submeter");
|
||||
$this->post("/requisicoes/{$r->id}/decidir", ['decision' => 'approved'])->assertForbidden();
|
||||
});
|
||||
|
||||
test('a head from another department cannot approve or read the request', function () {
|
||||
$this->actingAs($this->requester)->post('/requisicoes', procurementPayload($this->center->id));
|
||||
$r = Requisition::query()->firstOrFail();
|
||||
$this->post("/requisicoes/{$r->id}/submeter");
|
||||
$other = Department::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Outro']);
|
||||
$head = procurementUser($this->tenant, $other, PurchasingRole::DepartmentHead);
|
||||
$this->actingAs($head)->get("/requisicoes/{$r->id}")->assertNotFound();
|
||||
$this->post("/requisicoes/{$r->id}/decidir", ['decision' => 'approved'])->assertForbidden();
|
||||
});
|
||||
|
||||
test('rejection requires a reason and ends the approval flow', function () {
|
||||
$this->actingAs($this->requester)->post('/requisicoes', procurementPayload($this->center->id));
|
||||
$r = Requisition::query()->firstOrFail();
|
||||
$this->post("/requisicoes/{$r->id}/submeter");
|
||||
$this->actingAs(procurementUser($this->tenant, $this->department, PurchasingRole::DepartmentHead));
|
||||
$this->post("/requisicoes/{$r->id}/decidir", ['decision' => 'rejected'])->assertSessionHasErrors('comment');
|
||||
$this->post("/requisicoes/{$r->id}/decidir", ['decision' => 'rejected', 'comment' => 'Necessidade não demonstrada.'])->assertRedirect();
|
||||
expect($r->refresh()->status)->toBe('rejected');
|
||||
});
|
||||
|
||||
test('submitted requests cannot be edited', function () {
|
||||
$payload = procurementPayload($this->center->id);
|
||||
$this->actingAs($this->requester)->post('/requisicoes', $payload);
|
||||
$r = Requisition::query()->firstOrFail();
|
||||
$this->put("/requisicoes/{$r->id}", $payload)->assertRedirect();
|
||||
$this->post("/requisicoes/{$r->id}/submeter");
|
||||
$this->put("/requisicoes/{$r->id}", $payload)->assertForbidden();
|
||||
});
|
||||
|
||||
test('another company cannot list read edit submit or approve a request', function () {
|
||||
$this->actingAs($this->requester)->post('/requisicoes', procurementPayload($this->center->id));
|
||||
$r = Requisition::query()->firstOrFail();
|
||||
$otherTenant = Tenant::query()->create(['name' => 'Empresa B']);
|
||||
$otherDepartment = Department::query()->create(['tenant_id' => $otherTenant->id, 'name' => 'Geral']);
|
||||
$otherUser = procurementUser($otherTenant, $otherDepartment, PurchasingRole::Admin);
|
||||
$this->actingAs($otherUser)->get('/requisicoes')->assertInertia(fn (Assert $page) => $page->has('requisitions.data', 0));
|
||||
$this->get("/requisicoes/{$r->id}")->assertNotFound();
|
||||
$this->get("/requisicoes/{$r->id}/editar")->assertNotFound();
|
||||
$this->post("/requisicoes/{$r->id}/submeter")->assertNotFound();
|
||||
$this->post("/requisicoes/{$r->id}/decidir", ['decision' => 'approved'])->assertNotFound();
|
||||
$this->post('/requisicoes', procurementPayload($this->center->id))->assertSessionHasErrors('cost_center_id');
|
||||
});
|
||||
|
||||
test('ordinary users cannot administer the company or assign roles', function () {
|
||||
$this->actingAs($this->requester)->get('/empresa')->assertForbidden();
|
||||
$this->post('/empresa/departamentos', ['name' => 'Novo'])->assertForbidden();
|
||||
$this->post('/empresa/utilizadores', [])->assertForbidden();
|
||||
});
|
||||
|
||||
test('invalid monetary inputs are rejected', function (string $quantity, string $price) {
|
||||
$payload = procurementPayload($this->center->id, $price);
|
||||
$payload['items'][0]['quantity'] = $quantity;
|
||||
$this->actingAs($this->requester)->post('/requisicoes', $payload)->assertSessionHasErrors();
|
||||
expect(Requisition::query()->count())->toBe(0);
|
||||
})->with([['0', '10'], ['1', '-10'], ['1', '0.001'], ['0.0001', '10'], ['1e3', '10']]);
|
||||
|
||||
test('application audit entries cannot be updated or deleted', function () {
|
||||
$this->actingAs($this->requester)->post('/requisicoes', procurementPayload($this->center->id));
|
||||
$audit = AuditLog::query()->firstOrFail();
|
||||
expect(fn () => $audit->update(['action' => 'changed']))->toThrow(LogicException::class);
|
||||
expect(fn () => $audit->delete())->toThrow(LogicException::class);
|
||||
});
|
||||
|
||||
test('tenant models fail closed without a signed in user', function () {
|
||||
expect(Department::query()->count())->toBe(0)->and(CostCenter::query()->count())->toBe(0)->and(ApprovalStep::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('company accounts cannot be deleted through personal settings', function () {
|
||||
$this->actingAs($this->requester)->delete(route('profile.destroy'), ['password' => 'password'])->assertSessionHasErrors('password');
|
||||
$this->assertAuthenticatedAs($this->requester);
|
||||
$this->assertDatabaseHas('users', ['id' => $this->requester->id]);
|
||||
});
|
||||
|
||||
test('administrators can provision team accounts scoped to their company', function () {
|
||||
$admin = procurementUser($this->tenant, $this->department, PurchasingRole::Admin);
|
||||
$this->actingAs($admin)->post('/empresa/utilizadores', [
|
||||
'name' => 'Chefe de teste', 'email' => 'chefe@example.test',
|
||||
'password' => 'Test-password-1234', 'password_confirmation' => 'Test-password-1234',
|
||||
'department_id' => $this->department->id, 'purchasing_role' => 'department_head',
|
||||
'tenant_id' => 999,
|
||||
])->assertSessionHasNoErrors()->assertRedirect();
|
||||
$member = User::query()->where('email', 'chefe@example.test')->firstOrFail();
|
||||
expect($member->tenant_id)->toBe($this->tenant->id)->and($member->purchasing_role)->toBe(PurchasingRole::DepartmentHead);
|
||||
$this->get('/empresa')->assertInertia(fn (Assert $page) => $page->component('company/index')->has('users', 3));
|
||||
});
|
||||
|
||||
test('requisition pages expose the stored lines and actual approval capabilities', function () {
|
||||
$this->actingAs($this->requester)->get('/requisicoes/criar')->assertInertia(fn (Assert $page) => $page->component('requisitions/form')->has('costCenters', 1));
|
||||
$this->post('/requisicoes', procurementPayload($this->center->id));
|
||||
$r = Requisition::query()->firstOrFail();
|
||||
$this->get("/requisicoes/{$r->id}")->assertInertia(fn (Assert $page) => $page->component('requisitions/show')->has('requisition.items', 1)->where('canEdit', true)->where('canDecide', false));
|
||||
$this->post("/requisicoes/{$r->id}/submeter");
|
||||
$head = procurementUser($this->tenant, $this->department, PurchasingRole::DepartmentHead);
|
||||
$this->actingAs($head)->get("/requisicoes/{$r->id}")->assertInertia(fn (Assert $page) => $page->where('canEdit', false)->where('canDecide', true)->has('requisition.steps', 1));
|
||||
$this->get('/dashboard')->assertInertia(fn (Assert $page) => $page->where('stats.awaitingMe', 1)->has('approvals', 1));
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Department;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\SupplierDocument;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
function maintenanceSupplierData(): array
|
||||
{
|
||||
return ['name' => 'Fornecedor existente', 'nuit' => '123456789', 'contact' => 'Contacto comercial', 'email' => 'fornecedor@example.test', 'phone' => '123456789', 'address' => 'Maputo', 'category' => 'Material', 'beneficiaries' => 'Beneficiário de teste', 'document_references' => 'Documentos verificados no processo de teste.', 'diligence_notes' => 'Documentação conferida para testes de manutenção.', 'blocked' => false, 'next_review' => now()->addYear()->format('Y-m-d')];
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('supplier_documents');
|
||||
$this->tenant = Tenant::query()->create(['name' => 'Manutenção A']);
|
||||
$department = Department::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Operações']);
|
||||
$this->buyer = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Procurement]);
|
||||
$this->finance = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Finance]);
|
||||
$this->actingAs($this->buyer);
|
||||
$this->supplier = Supplier::query()->create([...maintenanceSupplierData(), 'tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'status' => 'active', 'reviewed_by' => $this->finance->id, 'review_comment' => 'Validação anterior preservada em auditoria.']);
|
||||
$this->url = '/fornecedores/'.$this->supplier->id;
|
||||
});
|
||||
|
||||
test('procurement can edit and renew a supplier but cannot forge validation or ownership', function () {
|
||||
$this->get($this->url.'/editar')->assertInertia(fn (Assert $page) => $page->component('suppliers/edit')->where('supplier.version', 1));
|
||||
$this->put($this->url, [...maintenanceSupplierData(), 'version' => 1, 'name' => 'Nome actualizado', 'tenant_id' => 999, 'created_by' => 999, 'status' => 'active', 'reviewed_by' => 999])->assertSessionHasNoErrors()->assertRedirect($this->url);
|
||||
$supplier = $this->supplier->refresh();
|
||||
expect($supplier->name)->toBe('Nome actualizado')->and($supplier->status)->toBe('pending')->and($supplier->version)->toBe(2);
|
||||
expect($supplier->tenant_id)->toBe($this->tenant->id)->and($supplier->created_by)->toBe($this->buyer->id)->and($supplier->reviewed_by)->toBeNull()->and($supplier->last_changed_by)->toBe($this->buyer->id);
|
||||
expect($supplier->eligible())->toBeFalse();
|
||||
$audit = AuditLog::query()->where('action', 'supplier.updated')->firstOrFail();
|
||||
expect($audit->before['name'])->toBe('Fornecedor existente')->and($audit->after['name'])->toBe('Nome actualizado');
|
||||
});
|
||||
|
||||
test('unchanged edits preserve validation and do not create audit noise', function () {
|
||||
$this->put($this->url, [...maintenanceSupplierData(), 'version' => 1])->assertSessionHasNoErrors();
|
||||
expect($this->supplier->refresh()->status)->toBe('active')->and($this->supplier->version)->toBe(1);
|
||||
expect(AuditLog::query()->where('action', 'supplier.updated')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('stale edits cannot overwrite newer supplier data', function () {
|
||||
$this->put($this->url, [...maintenanceSupplierData(), 'version' => 1, 'name' => 'Actualização concorrente'])->assertSessionHasNoErrors();
|
||||
$this->put($this->url, [...maintenanceSupplierData(), 'version' => 1, 'name' => 'Ficha antiga'])->assertSessionHasErrors('version');
|
||||
expect($this->supplier->refresh()->name)->toBe('Actualização concorrente');
|
||||
});
|
||||
|
||||
test('editing validates nuit uniqueness within company and renewal date', function () {
|
||||
Supplier::query()->create([...maintenanceSupplierData(), 'nuit' => '987654321', 'tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id]);
|
||||
$this->put($this->url, [...maintenanceSupplierData(), 'version' => 1, 'nuit' => '987654321'])->assertSessionHasErrors('nuit');
|
||||
$this->put($this->url, [...maintenanceSupplierData(), 'version' => 1, 'next_review' => now()->subDay()->format('Y-m-d')])->assertSessionHasErrors('next_review');
|
||||
});
|
||||
|
||||
test('non procurement roles cannot change suppliers or upload documents', function (PurchasingRole $role) {
|
||||
$user = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => $role]);
|
||||
$this->actingAs($user)->get($this->url.'/editar')->assertForbidden();
|
||||
$this->put($this->url, [...maintenanceSupplierData(), 'version' => 1])->assertForbidden();
|
||||
$this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('alvara.pdf', 10, 'application/pdf')])->assertForbidden();
|
||||
expect(SupplierDocument::query()->count())->toBe(0);
|
||||
})->with([PurchasingRole::Finance, PurchasingRole::Admin, PurchasingRole::Requester, PurchasingRole::DepartmentHead, PurchasingRole::Warehouse]);
|
||||
|
||||
test('private upload saves metadata invalidates validation and audits without leaking storage paths', function () {
|
||||
$this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('alvara.pdf', 10, 'application/pdf')])->assertSessionHasNoErrors()->assertRedirect();
|
||||
$document = SupplierDocument::query()->firstOrFail();
|
||||
Storage::disk('supplier_documents')->assertExists($document->path);
|
||||
expect($document->path)->toStartWith($this->tenant->id.'/'.$this->supplier->id.'/');
|
||||
expect($document->original_name)->toBe('alvara.pdf')->and(strlen($document->sha256))->toBe(64);
|
||||
expect($document->toArray())->not->toHaveKey('path');
|
||||
expect($this->supplier->refresh()->status)->toBe('pending')->and($this->supplier->version)->toBe(2);
|
||||
expect(AuditLog::query()->where('action', 'supplier.document_uploaded')->firstOrFail()->after['document'])->not->toHaveKey('path');
|
||||
$this->get($this->url)->assertInertia(fn (Assert $page) => $page->component('suppliers/show')->has('documents', 1)->missing('documents.0.path')->where('canEdit', true)->where('canDocuments', true));
|
||||
$this->get('/storage/'.$document->path)->assertForbidden();
|
||||
});
|
||||
|
||||
test('authorised downloads are attachments and recorded in audit', function (PurchasingRole $role) {
|
||||
$this->post($this->url.'/documentos', ['document_type' => 'tax', 'file' => UploadedFile::fake()->create('certidao.pdf', 10, 'application/pdf')]);
|
||||
$document = SupplierDocument::query()->firstOrFail();
|
||||
$user = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => $role]);
|
||||
$this->actingAs($user)->get($this->url.'/documentos/'.$document->id)->assertOk()->assertDownload('certidao.pdf')->assertHeader('X-Content-Type-Options', 'nosniff')->assertHeader('Content-Type', 'application/octet-stream');
|
||||
expect(AuditLog::query()->where('action', 'supplier.document_downloaded')->count())->toBe(1);
|
||||
})->with([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance]);
|
||||
|
||||
test('heads see the supplier but cannot inspect or download confidential documents', function () {
|
||||
$this->post($this->url.'/documentos', ['document_type' => 'conflict', 'file' => UploadedFile::fake()->create('conflitos.pdf', 10, 'application/pdf')]);
|
||||
$document = SupplierDocument::query()->firstOrFail();
|
||||
$head = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
||||
$this->actingAs($head)->get($this->url)->assertInertia(fn (Assert $page) => $page->has('documents', 0)->where('canDocuments', false));
|
||||
$this->get($this->url.'/documentos/'.$document->id)->assertForbidden();
|
||||
});
|
||||
|
||||
test('uploads validate contents extension size and document classification', function () {
|
||||
$this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('script.php', 1, 'text/x-php')])->assertSessionHasErrors('file');
|
||||
$this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('script.php', 1, 'application/pdf')])->assertSessionHasErrors('file');
|
||||
$this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('grande.pdf', 2049, 'application/pdf')])->assertSessionHasErrors('file');
|
||||
$this->post($this->url.'/documentos', ['document_type' => 'invalid', 'file' => UploadedFile::fake()->create('alvara.pdf', 1, 'application/pdf')])->assertSessionHasErrors('document_type');
|
||||
expect(SupplierDocument::query()->count())->toBe(0)->and(Storage::disk('supplier_documents')->allFiles())->toBe([]);
|
||||
expect($this->supplier->refresh()->status)->toBe('active');
|
||||
});
|
||||
|
||||
test('same company document cannot be downloaded under another supplier', function () {
|
||||
$this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('alvara.pdf', 1, 'application/pdf')]);
|
||||
$document = SupplierDocument::query()->firstOrFail();
|
||||
$other = Supplier::query()->create([...maintenanceSupplierData(), 'nuit' => '987654321', 'tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id]);
|
||||
$this->get('/fornecedores/'.$other->id.'/documentos/'.$document->id)->assertNotFound();
|
||||
expect(AuditLog::query()->where('action', 'supplier.document_downloaded')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('all maintenance and attachment routes reject other companies', function () {
|
||||
$this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('alvara.pdf', 1, 'application/pdf')]);
|
||||
$document = SupplierDocument::query()->firstOrFail();
|
||||
$other = Tenant::query()->create(['name' => 'Empresa B']);
|
||||
$user = User::factory()->create(['tenant_id' => $other->id, 'purchasing_role' => PurchasingRole::Procurement]);
|
||||
$this->actingAs($user)->get($this->url)->assertNotFound();
|
||||
$this->get($this->url.'/editar')->assertNotFound();
|
||||
$this->put($this->url, [...maintenanceSupplierData(), 'version' => 2])->assertNotFound();
|
||||
$this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('alvara.pdf', 1, 'application/pdf')])->assertNotFound();
|
||||
$this->get($this->url.'/documentos/'.$document->id)->assertNotFound();
|
||||
});
|
||||
|
||||
test('review of a stale version is rejected after a new document upload', function () {
|
||||
$this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('alvara.pdf', 1, 'application/pdf')]);
|
||||
$this->actingAs($this->finance)->post($this->url.'/validar', ['status' => 'active', 'comment' => 'Revisão de ficha antiga não permitida.', 'version' => 1])->assertSessionHasErrors('version');
|
||||
$this->post($this->url.'/validar', ['status' => 'active', 'comment' => 'Dados e documento actual conferidos.', 'version' => 2])->assertSessionHasNoErrors();
|
||||
expect($this->supplier->refresh()->status)->toBe('active')->and($this->supplier->version)->toBe(3);
|
||||
});
|
||||
|
||||
test('last editor cannot validate the supplier after changing to finance role', function () {
|
||||
$editor = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => PurchasingRole::Procurement]);
|
||||
$this->actingAs($editor)->put($this->url, [...maintenanceSupplierData(), 'version' => 1, 'contact' => 'Novo contacto'])->assertSessionHasNoErrors();
|
||||
$editor->forceFill(['purchasing_role' => PurchasingRole::Finance])->save();
|
||||
$this->actingAs($editor)->post($this->url.'/validar', ['status' => 'active', 'comment' => 'Tentativa de validar alteração própria.', 'version' => 2])->assertForbidden();
|
||||
});
|
||||
|
||||
test('file is cleaned up and database rolled back if audit cannot be written', function () {
|
||||
$this->mock(RequisitionWorkflow::class, function ($mock) {
|
||||
$mock->shouldReceive('audit')->once()->andThrow(new RuntimeException('Falha de auditoria simulada.'));
|
||||
});
|
||||
$this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('alvara.pdf', 1, 'application/pdf')])->assertStatus(500);
|
||||
expect(SupplierDocument::query()->count())->toBe(0)->and(Storage::disk('supplier_documents')->allFiles())->toBe([]);
|
||||
expect($this->supplier->refresh()->status)->toBe('active')->and($this->supplier->version)->toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\CostCenter;
|
||||
use App\Models\Department;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
function supplierPayload(string $nuit = '123456789'): array
|
||||
{
|
||||
return ['name' => 'Fornecedor de teste', 'nuit' => $nuit, 'contact' => 'Contacto', 'email' => 'test@example.com', 'phone' => '123456789', 'address' => 'Maputo', 'category' => 'Material', 'beneficiaries' => 'Beneficiário teste', 'document_references' => 'Alvará e declaração verificados no processo 123.', 'diligence_notes' => 'Documentação conferida e sem impedimentos.', 'blocked' => false, 'next_review' => now()->addYear()->format('Y-m-d')];
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::query()->create(['name' => 'Cotação A']);
|
||||
$this->department = Department::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Produção']);
|
||||
$this->center = CostCenter::query()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'name' => 'Produção', 'code' => 'P']);
|
||||
$this->buyer = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'purchasing_role' => PurchasingRole::Procurement]);
|
||||
$this->finance = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'purchasing_role' => PurchasingRole::Finance]);
|
||||
$this->owner = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'purchasing_role' => PurchasingRole::Requester]);
|
||||
$this->actingAs($this->buyer);
|
||||
$this->supplier = Supplier::query()->create([...supplierPayload(), 'tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'status' => 'active']);
|
||||
$this->r = Requisition::query()->create(['tenant_id' => $this->tenant->id, 'requester_id' => $this->owner->id, 'department_id' => $this->department->id, 'cost_center_id' => $this->center->id, 'number' => 'REQ-TEST', 'priority' => 'normal', 'needed_by' => now()->addWeek(), 'justification' => 'Pedido aprovado para testes.', 'status' => 'approved', 'subtotal_minor' => 15, 'tax_minor' => 2, 'total_minor' => 17]);
|
||||
$this->item = $this->r->items()->create(['tenant_id' => $this->tenant->id, 'description' => 'Material', 'category' => 'Material', 'purchase_type' => 'consumable', 'quantity' => '3', 'unit' => 'un', 'unit_price_minor' => 5, 'tax_rate' => '16', 'subtotal_minor' => 15, 'tax_minor' => 2, 'total_minor' => 17]);
|
||||
$this->quote = ['supplier_id' => $this->supplier->id, 'reference' => 'PROP-01', 'delivery_days' => 10, 'valid_until' => now()->addMonth()->format('Y-m-d'), 'payment_terms' => '30 dias', 'no_conflict' => true, 'items' => [['requisition_item_id' => $this->item->id, 'unit_price' => '0.05']]];
|
||||
});
|
||||
|
||||
test('supplier creation enforces role tenant and unique nuit', function () {
|
||||
$this->actingAs($this->owner)->post('/fornecedores', supplierPayload('987654321'))->assertForbidden();
|
||||
$this->actingAs($this->buyer)->post('/fornecedores', [...supplierPayload('987654321'), 'status' => 'active', 'tenant_id' => 999])->assertSessionHasNoErrors()->assertRedirect();
|
||||
$s = Supplier::query()->where('nuit', '987654321')->firstOrFail();
|
||||
expect($s->status)->toBe('pending')->and($s->tenant_id)->toBe($this->tenant->id);
|
||||
$this->post('/fornecedores', supplierPayload('987654321'))->assertSessionHasErrors('nuit');
|
||||
$this->post('/fornecedores', supplierPayload('invalid'))->assertSessionHasErrors('nuit');
|
||||
});
|
||||
|
||||
test('supplier review is independent and prevents blocked activation', function () {
|
||||
$body = ['status' => 'active', 'comment' => 'Documentação verificada por Finanças.', 'version' => $this->supplier->version];
|
||||
$this->post("/fornecedores/{$this->supplier->id}/validar", $body)->assertForbidden();
|
||||
$this->buyer->forceFill(['purchasing_role' => PurchasingRole::Finance])->save();
|
||||
$this->actingAs($this->buyer)->post("/fornecedores/{$this->supplier->id}/validar", $body)->assertForbidden();
|
||||
$this->actingAs($this->finance)->post("/fornecedores/{$this->supplier->id}/validar", $body)->assertSessionHasNoErrors()->assertRedirect();
|
||||
$this->supplier->update(['blocked' => true]);
|
||||
$this->post("/fornecedores/{$this->supplier->id}/validar", [...$body, 'version' => $this->supplier->fresh()->version])->assertSessionHasErrors('status');
|
||||
});
|
||||
|
||||
test('quotes calculate decimal totals inherit tax and reject duplicates', function () {
|
||||
$this->post("/requisicoes/{$this->r->id}/cotacoes", [...$this->quote, 'total_minor' => 999, 'tenant_id' => 999])->assertSessionHasNoErrors()->assertRedirect();
|
||||
$q = Quotation::query()->firstOrFail();
|
||||
expect($q->subtotal_minor)->toBe(15)->and($q->tax_minor)->toBe(2)->and($q->total_minor)->toBe(17)->and($q->tenant_id)->toBe($this->tenant->id);
|
||||
expect((float) $q->items()->first()->tax_rate)->toBe(16.0);
|
||||
$this->post("/requisicoes/{$this->r->id}/cotacoes", $this->quote)->assertSessionHasErrors('supplier_id');
|
||||
expect(Quotation::query()->count())->toBe(1);
|
||||
expect(AuditLog::query()->where('action', 'quotation.created')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('quotes require approval active suppliers valid dates and exact request items', function () {
|
||||
$url = "/requisicoes/{$this->r->id}/cotacoes";
|
||||
$this->r->update(['status' => 'draft']);
|
||||
$this->post($url, $this->quote)->assertForbidden();
|
||||
$this->r->update(['status' => 'approved']);
|
||||
$this->supplier->update(['status' => 'pending']);
|
||||
$this->post($url, $this->quote)->assertSessionHasErrors('supplier_id');
|
||||
$this->supplier->update(['status' => 'active']);
|
||||
$this->post($url, [...$this->quote, 'valid_until' => now()->subDay()->format('Y-m-d')])->assertSessionHasErrors('valid_until');
|
||||
$this->post($url, [...$this->quote, 'items' => [['requisition_item_id' => 999, 'unit_price' => '1']]])->assertSessionHasErrors('items');
|
||||
$this->post($url, [...$this->quote, 'no_conflict' => false])->assertSessionHasErrors('no_conflict');
|
||||
expect(Quotation::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('two independent evaluations and three eligible suppliers complete comparison', function () {
|
||||
foreach (['123456789', '123456788', '123456787'] as $index => $nuit) {
|
||||
$s = $index === 0 ? $this->supplier : Supplier::query()->create([...supplierPayload($nuit), 'tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'status' => 'active']);
|
||||
$this->actingAs($this->buyer)->post("/requisicoes/{$this->r->id}/cotacoes", [...$this->quote, 'supplier_id' => $s->id])->assertSessionHasNoErrors();
|
||||
$q = Quotation::query()->latest('id')->firstOrFail();
|
||||
$ev = ['quality' => 8, 'history' => 5, 'payment' => 8, 'comment' => 'Avaliação técnica independente.', 'no_conflict' => true];
|
||||
$this->post("/cotacoes/{$q->id}/avaliar", $ev)->assertSessionHasNoErrors();
|
||||
$this->post("/cotacoes/{$q->id}/avaliar", $ev)->assertSessionHasErrors('evaluation');
|
||||
$this->actingAs($this->finance)->post("/cotacoes/{$q->id}/avaliar", $ev)->assertSessionHasNoErrors();
|
||||
}
|
||||
$this->get("/requisicoes/{$this->r->id}/cotacoes")->assertInertia(fn (Assert $page) => $page->component('quotations/show')->where('ready', true)->has('quotations', 3)->where('quotations.0.score', 8.65));
|
||||
$this->supplier->update(['status' => 'suspended']);
|
||||
$this->get("/requisicoes/{$this->r->id}/cotacoes")->assertInertia(fn (Assert $page) => $page->where('ready', false)->where('quotations.0.eligible', false));
|
||||
});
|
||||
|
||||
test('requester cannot evaluate own purchase after role change', function () {
|
||||
$this->post("/requisicoes/{$this->r->id}/cotacoes", $this->quote);
|
||||
$q = Quotation::query()->firstOrFail();
|
||||
$this->owner->forceFill(['purchasing_role' => PurchasingRole::Finance])->save();
|
||||
$this->actingAs($this->owner)->post("/cotacoes/{$q->id}/avaliar", ['quality' => 8, 'history' => 5, 'payment' => 8, 'comment' => 'Avaliação de pedido próprio.', 'no_conflict' => true])->assertForbidden();
|
||||
});
|
||||
|
||||
test('supplier quote and evaluation routes isolate companies', function () {
|
||||
$this->post("/requisicoes/{$this->r->id}/cotacoes", $this->quote);
|
||||
$q = Quotation::query()->firstOrFail();
|
||||
$tenant = Tenant::query()->create(['name' => 'Empresa B']);
|
||||
$other = User::factory()->create(['tenant_id' => $tenant->id, 'purchasing_role' => PurchasingRole::Finance]);
|
||||
$this->actingAs($other)->get('/fornecedores')->assertInertia(fn (Assert $p) => $p->has('suppliers.data', 0));
|
||||
$this->post("/fornecedores/{$this->supplier->id}/validar", ['status' => 'active', 'comment' => 'Tentativa de outro inquilino.'])->assertNotFound();
|
||||
$this->get("/requisicoes/{$this->r->id}/cotacoes")->assertNotFound();
|
||||
$this->post("/cotacoes/{$q->id}/avaliar", ['quality' => 8, 'history' => 5, 'payment' => 8, 'comment' => 'Tentativa de outro inquilino.', 'no_conflict' => true])->assertNotFound();
|
||||
});
|
||||
Reference in New Issue
Block a user