diff --git a/.env.example b/.env.example index 40bb13b..1125a04 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..1cf5a8d --- /dev/null +++ b/README.md @@ -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. diff --git a/app/Enums/PurchasingRole.php b/app/Enums/PurchasingRole.php new file mode 100644 index 0000000..0f4ace3 --- /dev/null +++ b/app/Enums/PurchasingRole.php @@ -0,0 +1,25 @@ + 'Solicitante', + self::DepartmentHead => 'Chefe de departamento', + self::Procurement => 'Procurement', + self::Finance => 'Finanças', + self::Warehouse => 'Recepção / Armazém', + self::Admin => 'Administrador', + }; + } +} diff --git a/app/Http/Controllers/CompanyController.php b/app/Http/Controllers/CompanyController.php new file mode 100644 index 0000000..3d133c8 --- /dev/null +++ b/app/Http/Controllers/CompanyController.php @@ -0,0 +1,104 @@ +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); + } +} diff --git a/app/Http/Controllers/ConflictDeclarationController.php b/app/Http/Controllers/ConflictDeclarationController.php new file mode 100644 index 0000000..e662d4d --- /dev/null +++ b/app/Http/Controllers/ConflictDeclarationController.php @@ -0,0 +1,95 @@ + 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); + } +} diff --git a/app/Http/Controllers/PurchaseController.php b/app/Http/Controllers/PurchaseController.php new file mode 100644 index 0000000..0e466d3 --- /dev/null +++ b/app/Http/Controllers/PurchaseController.php @@ -0,0 +1,105 @@ +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]); + } +} diff --git a/app/Http/Controllers/QuotationController.php b/app/Http/Controllers/QuotationController.php new file mode 100644 index 0000000..bc0f105 --- /dev/null +++ b/app/Http/Controllers/QuotationController.php @@ -0,0 +1,148 @@ +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(); + } +} diff --git a/app/Http/Controllers/RequisitionController.php b/app/Http/Controllers/RequisitionController.php new file mode 100644 index 0000000..59c964d --- /dev/null +++ b/app/Http/Controllers/RequisitionController.php @@ -0,0 +1,119 @@ +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(), + ]); + } +} diff --git a/app/Http/Controllers/Settings/ProfileController.php b/app/Http/Controllers/Settings/ProfileController.php index d19f963..9a1de92 100644 --- a/app/Http/Controllers/Settings/ProfileController.php +++ b/app/Http/Controllers/Settings/ProfileController.php @@ -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(); diff --git a/app/Http/Controllers/SupplierController.php b/app/Http/Controllers/SupplierController.php new file mode 100644 index 0000000..a4e8e3d --- /dev/null +++ b/app/Http/Controllers/SupplierController.php @@ -0,0 +1,106 @@ +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(); + } +} diff --git a/app/Http/Controllers/SupplierDocumentController.php b/app/Http/Controllers/SupplierDocumentController.php new file mode 100644 index 0000000..8d718bc --- /dev/null +++ b/app/Http/Controllers/SupplierDocumentController.php @@ -0,0 +1,69 @@ +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", + ]); + } +} diff --git a/app/Http/Middleware/EnsureCompanyIsConfigured.php b/app/Http/Middleware/EnsureCompanyIsConfigured.php new file mode 100644 index 0000000..b23da18 --- /dev/null +++ b/app/Http/Middleware/EnsureCompanyIsConfigured.php @@ -0,0 +1,19 @@ +user()?->tenant_id) { + return redirect()->route('company.setup'); + } + + return $next($request); + } +} diff --git a/app/Http/Requests/SaveSupplierRequest.php b/app/Http/Requests/SaveSupplierRequest.php new file mode 100644 index 0000000..e8c0e64 --- /dev/null +++ b/app/Http/Requests/SaveSupplierRequest.php @@ -0,0 +1,37 @@ +user()?->purchasing_role === PurchasingRole::Procurement; + } + + /** @return array */ + 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', + ]; + } +} diff --git a/app/Http/Requests/StoreRequisitionRequest.php b/app/Http/Requests/StoreRequisitionRequest.php new file mode 100644 index 0000000..8239df4 --- /dev/null +++ b/app/Http/Requests/StoreRequisitionRequest.php @@ -0,0 +1,48 @@ +user(); + + return $user !== null && $user->tenant_id !== null && $user->department_id !== null; + } + + /** @return array */ + 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 */ + 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.', + ]; + } +} diff --git a/app/Models/ApprovalStep.php b/app/Models/ApprovalStep.php new file mode 100644 index 0000000..ee5cfdf --- /dev/null +++ b/app/Models/ApprovalStep.php @@ -0,0 +1,20 @@ + '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.')); + } +} diff --git a/app/Models/Concerns/BelongsToTenant.php b/app/Models/Concerns/BelongsToTenant.php new file mode 100644 index 0000000..acf090e --- /dev/null +++ b/app/Models/Concerns/BelongsToTenant.php @@ -0,0 +1,16 @@ +check() ? auth()->user()->tenant_id : null; + $query->where($query->getModel()->qualifyColumn('tenant_id'), $tenantId ?? 0); + }); + } +} diff --git a/app/Models/Concerns/ImmutablePurchaseRecord.php b/app/Models/Concerns/ImmutablePurchaseRecord.php new file mode 100644 index 0000000..e9f4135 --- /dev/null +++ b/app/Models/Concerns/ImmutablePurchaseRecord.php @@ -0,0 +1,14 @@ + throw new LogicException('O documento emitido não pode ser reescrito.')); + static::deleting(fn () => throw new LogicException('O documento emitido deve ser preservado.')); + } +} diff --git a/app/Models/ConflictDeclaration.php b/app/Models/ConflictDeclaration.php new file mode 100644 index 0000000..95f3170 --- /dev/null +++ b/app/Models/ConflictDeclaration.php @@ -0,0 +1,72 @@ + '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 $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 */ + public function author(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** @return BelongsTo */ + public function supplier(): BelongsTo + { + return $this->belongsTo(Supplier::class); + } + + /** @return BelongsTo */ + public function requisition(): BelongsTo + { + return $this->belongsTo(Requisition::class); + } + + /** @return HasOne */ + public function successor(): HasOne + { + return $this->hasOne(self::class, 'previous_id'); + } +} diff --git a/app/Models/CostCenter.php b/app/Models/CostCenter.php new file mode 100644 index 0000000..3cd1ad6 --- /dev/null +++ b/app/Models/CostCenter.php @@ -0,0 +1,17 @@ + 'boolean', 'quality' => 'decimal:2', 'history' => 'decimal:2', 'payment' => 'decimal:2']; + } +} diff --git a/app/Models/PurchaseAward.php b/app/Models/PurchaseAward.php new file mode 100644 index 0000000..20b1975 --- /dev/null +++ b/app/Models/PurchaseAward.php @@ -0,0 +1,36 @@ + $approval_snapshot + * @property list> $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 */ + public function approvals(): HasMany + { + return $this->hasMany(PurchaseAwardApproval::class)->orderBy('position'); + } +} diff --git a/app/Models/PurchaseAwardApproval.php b/app/Models/PurchaseAwardApproval.php new file mode 100644 index 0000000..e3cb34f --- /dev/null +++ b/app/Models/PurchaseAwardApproval.php @@ -0,0 +1,15 @@ + $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 */ + public function items(): HasMany + { + return $this->hasMany(PurchaseOrderItem::class); + } + + /** @param Builder $query */ + public function scopeVisibleTo(Builder $query, User $user): void + { + $query->whereIn('requisition_id', Requisition::query()->visibleTo($user)->select('id')); + } +} diff --git a/app/Models/PurchaseOrderItem.php b/app/Models/PurchaseOrderItem.php new file mode 100644 index 0000000..cb52f38 --- /dev/null +++ b/app/Models/PurchaseOrderItem.php @@ -0,0 +1,16 @@ + $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 */ + public function supplier(): BelongsTo + { + return $this->belongsTo(Supplier::class); + } + + /** @return HasMany */ + public function items(): HasMany + { + return $this->hasMany(QuotationItem::class); + } + + /** @return HasMany */ + public function evaluations(): HasMany + { + return $this->hasMany(ProposalEvaluation::class); + } +} diff --git a/app/Models/QuotationItem.php b/app/Models/QuotationItem.php new file mode 100644 index 0000000..34e0f6e --- /dev/null +++ b/app/Models/QuotationItem.php @@ -0,0 +1,22 @@ + 'decimal:3', 'tax_rate' => 'decimal:2']; + } +} diff --git a/app/Models/Requisition.php b/app/Models/Requisition.php new file mode 100644 index 0000000..75aa67f --- /dev/null +++ b/app/Models/Requisition.php @@ -0,0 +1,94 @@ + $steps + * @property Collection $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 */ + public function items(): HasMany + { + return $this->hasMany(RequisitionItem::class); + } + + /** @return HasMany */ + public function steps(): HasMany + { + return $this->hasMany(ApprovalStep::class)->orderBy('position'); + } + + /** @return BelongsTo */ + public function requester(): BelongsTo + { + return $this->belongsTo(User::class, 'requester_id'); + } + + /** @return BelongsTo */ + public function department(): BelongsTo + { + return $this->belongsTo(Department::class); + } + + /** @return BelongsTo */ + public function costCenter(): BelongsTo + { + return $this->belongsTo(CostCenter::class); + } + + /** @param Builder $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); + } +} diff --git a/app/Models/RequisitionItem.php b/app/Models/RequisitionItem.php new file mode 100644 index 0000000..805f204 --- /dev/null +++ b/app/Models/RequisitionItem.php @@ -0,0 +1,26 @@ + 'decimal:3', 'tax_rate' => 'decimal:2']; + } +} diff --git a/app/Models/Supplier.php b/app/Models/Supplier.php new file mode 100644 index 0000000..22d1c85 --- /dev/null +++ b/app/Models/Supplier.php @@ -0,0 +1,39 @@ + 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 */ + public function documents(): HasMany + { + return $this->hasMany(SupplierDocument::class); + } +} diff --git a/app/Models/SupplierDocument.php b/app/Models/SupplierDocument.php new file mode 100644 index 0000000..ca8a1f6 --- /dev/null +++ b/app/Models/SupplierDocument.php @@ -0,0 +1,21 @@ + 'boolean', 'rules_provisional' => 'boolean']; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 0bedb54..e96ca40 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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', ]; diff --git a/app/Policies/SupplierPolicy.php b/app/Policies/SupplierPolicy.php new file mode 100644 index 0000000..1d84ee4 --- /dev/null +++ b/app/Policies/SupplierPolicy.php @@ -0,0 +1,30 @@ +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]); + } +} diff --git a/app/Services/ConflictGuard.php b/app/Services/ConflictGuard.php new file mode 100644 index 0000000..2aa27fb --- /dev/null +++ b/app/Services/ConflictGuard.php @@ -0,0 +1,122 @@ +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 */ + 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.']); + } + } +} diff --git a/app/Services/ProposalComparison.php b/app/Services/ProposalComparison.php new file mode 100644 index 0000000..5352487 --- /dev/null +++ b/app/Services/ProposalComparison.php @@ -0,0 +1,176 @@ +>, ready: bool, top_ids: list, 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 $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; + } +} diff --git a/app/Services/PurchaseWorkflow.php b/app/Services/PurchaseWorkflow.php new file mode 100644 index 0000000..13ee797 --- /dev/null +++ b/app/Services/PurchaseWorkflow.php @@ -0,0 +1,250 @@ +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>, ready: bool, top_ids: list, 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 + */ + 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.']); + } + } +} diff --git a/app/Services/RequisitionWorkflow.php b/app/Services/RequisitionWorkflow.php new file mode 100644 index 0000000..5616557 --- /dev/null +++ b/app/Services/RequisitionWorkflow.php @@ -0,0 +1,122 @@ + $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 $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|null $before + * @param array $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, + ]); + } +} diff --git a/config/filesystems.php b/config/filesystems.php index 6cfdfb3..ba2be01 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -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'), diff --git a/database/migrations/2026_10_28_000001_create_procurement_foundation.php b/database/migrations/2026_10_28_000001_create_procurement_foundation.php new file mode 100644 index 0000000..6bf855a --- /dev/null +++ b/database/migrations/2026_10_28_000001_create_procurement_foundation.php @@ -0,0 +1,124 @@ +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'); + } +}; diff --git a/database/migrations/2026_10_28_000002_create_suppliers_and_quotations.php b/database/migrations/2026_10_28_000002_create_suppliers_and_quotations.php new file mode 100644 index 0000000..fccf5cf --- /dev/null +++ b/database/migrations/2026_10_28_000002_create_suppliers_and_quotations.php @@ -0,0 +1,88 @@ +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'); + } +}; diff --git a/database/migrations/2026_10_28_000003_add_supplier_maintenance_and_documents.php b/database/migrations/2026_10_28_000003_add_supplier_maintenance_and_documents.php new file mode 100644 index 0000000..8439424 --- /dev/null +++ b/database/migrations/2026_10_28_000003_add_supplier_maintenance_and_documents.php @@ -0,0 +1,39 @@ +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'); + }); + } +}; diff --git a/database/migrations/2026_10_28_000004_create_conflict_declarations.php b/database/migrations/2026_10_28_000004_create_conflict_declarations.php new file mode 100644 index 0000000..70eeb59 --- /dev/null +++ b/database/migrations/2026_10_28_000004_create_conflict_declarations.php @@ -0,0 +1,31 @@ +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'); + } +}; diff --git a/database/migrations/2026_10_28_000005_create_purchase_awards_and_orders.php b/database/migrations/2026_10_28_000005_create_purchase_awards_and_orders.php new file mode 100644 index 0000000..0a83c2b --- /dev/null +++ b/database/migrations/2026_10_28_000005_create_purchase_awards_and_orders.php @@ -0,0 +1,83 @@ +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'); + } +}; diff --git a/docs/compatibilidade.md b/docs/compatibilidade.md new file mode 100644 index 0000000..77e7384 --- /dev/null +++ b/docs/compatibilidade.md @@ -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. diff --git a/docs/desenvolvimento.md b/docs/desenvolvimento.md new file mode 100644 index 0000000..cfbf16a --- /dev/null +++ b/docs/desenvolvimento.md @@ -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. diff --git a/docs/orientacoes-continuidade.md b/docs/orientacoes-continuidade.md new file mode 100644 index 0000000..b70b8bb --- /dev/null +++ b/docs/orientacoes-continuidade.md @@ -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. diff --git a/resources/js/app.tsx b/resources/js/app.tsx index 5fb8ca2..e0885aa 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -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), diff --git a/resources/js/components/app-logo.tsx b/resources/js/components/app-logo.tsx index ecee5f1..9e19c41 100644 --- a/resources/js/components/app-logo.tsx +++ b/resources/js/components/app-logo.tsx @@ -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 ( <>
@@ -12,7 +8,7 @@ export default function AppLogo() {
- {name} + ProcuraFlow
diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index a38aa1d..9d2c814 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -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 ( @@ -53,11 +74,10 @@ export function AppSidebar() { - + - diff --git a/resources/js/components/procurement.tsx b/resources/js/components/procurement.tsx new file mode 100644 index 0000000..deb6e1c --- /dev/null +++ b/resources/js/components/procurement.tsx @@ -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 ( +
+
+

+ {eyebrow} +

+

+ {title} +

+

+ {description} +

+
+ {action} +
+ ); +} +export function Panel({ + title, + description, + children, +}: { + title: string; + description?: string; + children: ReactNode; +}) { + return ( +
+

{title}

+ {description && ( +

+ {description} +

+ )} +
{children}
+
+ ); +} +export function Status({ value }: { value: string }) { + return ( + + {statusLabels[value] ?? value} + + ); +} +export function RequisitionTable({ rows }: { rows: Requisition[] }) { + if (!rows.length) + return ( +
+ +

Sem requisições para apresentar

+

+ Os pedidos de compra aparecem aqui depois de criados. +

+
+ ); + return ( +
+ + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + ))} + +
RequisiçãoDepartamentoDataEstado + Total com IVA + + Abrir +
+ + {r.number} + +
+ {r.requester?.name} +
+
+ {r.department?.name ?? '—'} + + {date(r.created_at)} + + + + {money(Number(r.total_minor))} + + + + +
+
+ ); +} +export function FormErrors({ errors }: { errors: Record }) { + if (!Object.keys(errors).length) return null; + return ( +
+

Verifique os dados:

+
    + {Object.entries(errors).map(([key, value]) => ( +
  • {value}
  • + ))} +
+
+ ); +} diff --git a/resources/js/pages/auth/login.tsx b/resources/js/pages/auth/login.tsx index dd1a8ac..1260536 100644 --- a/resources/js/pages/auth/login.tsx +++ b/resources/js/pages/auth/login.tsx @@ -19,7 +19,7 @@ type Props = { export default function Login({ status, canResetPassword }: Props) { return ( <> - +
- +
- + {canResetPassword && ( - Forgot your password? + Esqueceu a palavra-passe? )}
@@ -63,7 +65,7 @@ export default function Login({ status, canResetPassword }: Props) { required tabIndex={2} autoComplete="current-password" - placeholder="Password" + placeholder="Palavra-passe" />
@@ -74,7 +76,7 @@ export default function Login({ status, canResetPassword }: Props) { name="remember" tabIndex={3} /> - +
- Don't have an account?{' '} + Ainda não tem conta?{' '} - Sign up + Criar conta
@@ -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', }; diff --git a/resources/js/pages/auth/register.tsx b/resources/js/pages/auth/register.tsx index ce766ef..7cb6570 100644 --- a/resources/js/pages/auth/register.tsx +++ b/resources/js/pages/auth/register.tsx @@ -16,7 +16,7 @@ type Props = { export default function Register({ passwordRules }: Props) { return ( <> - +
- +
- +
- + @@ -74,7 +74,7 @@ export default function Register({ passwordRules }: Props) {
{processing && } - Create account + Criar conta
- Already have an account?{' '} + Já tem conta?{' '} - Log in + Entrar
@@ -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', }; diff --git a/resources/js/pages/company/index.tsx b/resources/js/pages/company/index.tsx new file mode 100644 index 0000000..b25ad23 --- /dev/null +++ b/resources/js/pages/company/index.tsx @@ -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 ( + <> + +
+ +
+ +
+ {departments.map((d) => ( + + {d.name} + + ))} +
+ { + e.preventDefault(); + department.post('/empresa/departamentos', { + onSuccess: () => department.reset(), + }); + }} + > + + +
+ + department.setData( + 'name', + e.target.value, + ) + } + /> + +
+ +
+ +
+ {costCenters.map((c) => ( +

+ {c.code} · {c.name} + + { + departments.find( + (d) => d.id === c.department_id, + )?.name + } + +

+ ))} +
+
{ + e.preventDefault(); + center.post('/empresa/centros-de-custo', { + onSuccess: () => center.reset(), + }); + }} + > + + + +
+ {(['code', 'name'] as const).map((key) => ( +
+ + + center.setData( + key, + e.target.value, + ) + } + /> +
+ ))} +
+ + +
+
+ +
+ + + + + + + + + + + {users.map((u) => ( + + + + + + + ))} + +
NomeEmailDepartamentoPerfil
+ {u.name} + {u.email} + { + departments.find( + (d) => + d.id === + u.department_id, + )?.name + } + {roleLabels[u.purchasing_role]}
+
+
{ + e.preventDefault(); + user.post('/empresa/utilizadores', { + onSuccess: () => user.reset(), + }); + }} + > +

+ Adicionar utilizador +

+ +
+ {( + [ + { + 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) => ( +
+ + + user.setData( + field.key, + e.target.value, + ) + } + /> +
+ ))} +
+ + +
+
+ + +
+
+

+ 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. +

+ + +
+ +
+

+ Até {money(company.first_limit_minor)} +
+ Chefe de departamento +

+

+ Até {money(company.second_limit_minor)} +
+ Chefe + Procurement +

+

+ Acima de {money(company.second_limit_minor)} +
+ Chefe + Procurement + Finanças +

+
+

+ Base de cálculo:{' '} + {company.approval_includes_tax ? 'com' : 'sem'} IVA. + Cada etapa exige uma pessoa distinta do solicitante e + dos aprovadores anteriores. +

+
+
+ + ); +} +CompanyPage.layout = { + breadcrumbs: [{ title: 'Empresa e equipa', href: '/empresa' }], +}; diff --git a/resources/js/pages/company/setup.tsx b/resources/js/pages/company/setup.tsx new file mode 100644 index 0000000..56dc0b0 --- /dev/null +++ b/resources/js/pages/company/setup.tsx @@ -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 ( + <> + +
+ + +
{ + e.preventDefault(); + form.post('/empresa/iniciar'); + }} + className="space-y-5" + > + +
+ + + form.setData('name', e.target.value) + } + /> +
+
+ + + form.setData('department', e.target.value) + } + placeholder="Ex.: Administração" + /> +
+

+ Será criado um centro de custo geral. Moeda inicial: + MZN. Regras de aprovação provisórias. +

+ + +
+
+ + ); +} +Setup.layout = { + breadcrumbs: [{ title: 'Configurar empresa', href: '/empresa/iniciar' }], +}; diff --git a/resources/js/pages/conflicts/index.tsx b/resources/js/pages/conflicts/index.tsx new file mode 100644 index 0000000..81f8113 --- /dev/null +++ b/resources/js/pages/conflicts/index.tsx @@ -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 ( + <> + +
+ +
+ 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. +
+ +
{ + 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(), + }); + }} + > + +
+
+ + +
+
+ + +
+ {form.data.scope !== 'process' && ( +
+ + +
+ )} + {form.data.scope !== 'supplier' && ( +
+ + +
+ )} +
+ + + form.setData( + 'known_since', + e.target.value, + ) + } + /> +
+
+
+ +