diff --git a/README.md b/README.md index 005cac9..7043e10 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ProcuraFlow +# Sistema de Gestão de Compras (SGC) 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. @@ -23,6 +23,10 @@ Crie um pedido, guarde o rascunho, confira os valores e submeta. Entre com as co ## Entrega actual +- Equipamentos e peças ligados aos artigos existentes, compatibilidades, criticidade configurada, mínimos explícitos e alertas, saídas rastreáveis e validação técnica. Finanças é obrigatória para equipamento em qualquer valor; a adjudicação/emissão de investimento continua bloqueada até definição do limite próprio. Migração 000008 aplicada no lote 9 após backup recuperável e verificação da preservação. Demonstração em `/consulta/equipamentos`. +- Página inicial em `/`, demonstração pública por módulo em `/consulta` e navegação autenticada agrupada por área e perfil. +- Recepções parciais/totais com qualidade, divergências, quantidades pendentes e segregação do emitente. Entradas de stock apenas para quantidades físicas aceites, sem duplicação; catálogo, armazéns, existências e histórico por empresa. +- Saídas com destino, responsável e fundamento; transferências atómicas; contagens físicas e ajustes justificados, com histórico e protecção contra saldos negativos e contagens desactualizadas. Migração 000007 aplicada na base actual, lote 8, após backup com recuperação ensaiada e confirmação da preservação dos registos; demonstração em `/consulta/operacoes-stock`. - 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. @@ -51,13 +55,13 @@ O IVA é informado pelo utilizador e calculado no servidor. A taxa inicial de 0% ## Próximas etapas -Recepção parcial e qualidade; equipamentos e peças com aprovação técnica e stock; dados bancários com dupla validação; orçamento, facturas e pagamentos; moedas e câmbio; subscrições, licenças e Docker. A política de periodicidade, validade e levantamento de conflitos e os procedimentos de revisão/cancelamento de adjudicações continuam por definir. +Revisão visual dos módulos entregues; reposição de stock; dados bancários com dupla validação; orçamento, facturas e pagamentos; moedas e câmbio; subscrições, licenças e Docker. Equipamentos, peças, validação técnica e alertas de mínimo já estão implementados; a adjudicação de equipamento aguarda a definição da regra de investimento. Periodicidade dos inventários, tolerâncias e investigação de divergências exigem decisão da empresa. A política de periodicidade, validade e levantamento de conflitos e os procedimentos de revisão/cancelamento de adjudicações continuam por definir. O cadastro guarda referências e ficheiros anexos no disco privado `supplier_documents`, em `storage/app/supplier-documents`, fora da pasta pública e sem URLs públicas. As fichas abrem pelo directório **Fornecedores → Ver ficha → Abrir ficha, edição e documentos**. As versões antigas e documentos existentes são preservados na auditoria e no armazenamento; nesta entrega não há eliminação de documentos, antivírus, quotas acumuladas ou política de retenção. A validação do NUIT é de formato e unicidade, sem consulta a uma autoridade fiscal. Além das declarações por operação, existem declarações pessoais por fornecedor e processo; a periodicidade anual e a política de validade/levantamento ainda não foram definidas. As propostas e avaliações registadas não são editáveis nesta etapa. Os pesos 40/20/15/15/10 são provisórios e ficam guardados por proposta. A comparação liga à fase de adjudicação e emissão. A ordem exige novas aprovações do valor final e revalidação no servidor. As propostas usam MZN; a multimoeda fica para a fase correspondente. Esta base não implementa ainda os onze controlos anti-fraude nem todos os requisitos não funcionais do documento. MFA obrigatório, gestão completa do ciclo de vida dos acessos, delegações, política de levantamento de conflitos, alteração de regras com dupla aprovação, retenção, cópias de segurança e restauro por empresa serão tratados nas etapas correspondentes. Não usar para compras reais antes da validação destas regras e controlos. -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). +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 continua pendente. Estão documentados quatro cenários reais de concorrência de recepção e sete de stock aprovados em PostgreSQL isolado; isto não valida a concorrência de todos os módulos nem a carga. Consulte [o relatório de desenvolvimento](docs/desenvolvimento.md). ## Verificação @@ -70,4 +74,4 @@ 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. +Os testes cobrem cálculos, entradas manipuladas, limites de aprovação, separação de funções, ordem das decisões, rejeição, recepção, stock, navegação e acesso entre empresas. SQLite em memória não demonstra os bloqueios PostgreSQL. Para os quatro cenários simultâneos de recepção, execute `php tests/Concurrency/run-receipts.php`: exige permissão de criar bases, cria uma base exclusiva `compras_receipts_test_*`, verifica o isolamento antes de migrar e elimina-a no final. Não altera o `.env` nem executa fixtures na base real. O orçamento ainda não é validado. diff --git a/app/Http/Controllers/EquipmentController.php b/app/Http/Controllers/EquipmentController.php new file mode 100644 index 0000000..4fd0875 --- /dev/null +++ b/app/Http/Controllers/EquipmentController.php @@ -0,0 +1,121 @@ +user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403); + $assets = Equipment::query(); + if ($request->user()->purchasing_role === PurchasingRole::DepartmentHead) { + $assets->where('department_id', $request->user()->department_id); + } + $equipment = $assets->orderBy('code')->get(); + $links = EquipmentSparePart::query()->whereIn('equipment_id', $equipment->pluck('id'))->get(); + $parts = SparePart::query(); + if ($request->user()->purchasing_role === PurchasingRole::DepartmentHead) { + $parts->whereIn('id', $links->pluck('spare_part_id')); + } + $parts = $parts->get()->map(function (SparePart $part) { + $sum = StockMovement::query()->where('stock_item_id', $part->stock_item_id)->sum('quantity_milli'); + $balance = BigDecimal::of((string) $sum)->toInt(); + + return [...$part->toArray(), 'balance_milli' => $balance, 'below_minimum' => $balance < $part->minimum_milli]; + }); + $items = StockItem::query(); + $departments = Department::query(); + if ($request->user()->purchasing_role === PurchasingRole::DepartmentHead) { + $items->whereIn('id', $parts->pluck('stock_item_id')->merge($equipment->pluck('stock_item_id')->filter())); + $departments->whereKey($request->user()->department_id); + } + + return Inertia::render('equipment/index', [ + 'equipment' => $equipment, 'parts' => $parts, 'links' => $links, 'levels' => CriticalityLevel::query()->orderBy('code')->get(), + 'items' => $items->orderBy('code')->get(['id', 'code', 'name', 'unit']), + 'departments' => $departments->orderBy('name')->get(['id', 'name']), + 'history' => StockOperation::query()->whereNotNull('equipment_id')->whereIn('equipment_id', $equipment->pluck('id'))->latest('id')->paginate(15), + 'canManage' => in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement]), + 'canConfigure' => $request->user()->purchasing_role === PurchasingRole::Admin, + ]); + } + + public function store(Request $request, string $kind): RedirectResponse + { + $user = $request->user(); + abort_unless(in_array($kind, ['categorias', 'activos', 'pecas', 'associacoes']), 404); + abort_unless($kind === 'categorias' ? $user->purchasing_role === PurchasingRole::Admin : in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement]), 403); + $tenant = $user->tenant_id; + $exists = fn (string $table) => Rule::exists($table, 'id')->where('tenant_id', $tenant); + $request->merge(['code' => Str::upper(Str::squish($request->string('code')->toString()))]); + $rules = match ($kind) { + 'categorias' => ['code' => ['required', 'regex:/^[A-Z0-9][A-Z0-9._-]{0,29}$/', Rule::unique('criticality_levels')->where('tenant_id', $tenant)], 'name' => 'required|string|max:150', 'criteria' => 'required|string|max:4000', 'is_critical' => 'required|boolean'], + 'activos' => ['id' => ['nullable', 'integer', $exists('equipment')], 'version' => 'nullable|required_with:id|integer|min:1', 'code' => ['required', 'regex:/^[A-Z0-9][A-Z0-9._-]{0,29}$/', Rule::unique('equipment')->where('tenant_id', $tenant)->ignore($request->input('id'))], 'description' => 'required|string|max:255', 'area' => 'required|string|max:200', 'serial_number' => 'nullable|string|max:100', 'status' => ['required', Rule::in(['active', 'stopped', 'retired'])], 'department_id' => ['required', 'integer', $exists('departments')], 'stock_item_id' => ['nullable', 'integer', $exists('stock_items')], 'criticality_level_id' => ['required', 'integer', $exists('criticality_levels')]], + 'pecas' => ['id' => ['nullable', 'integer', $exists('spare_parts')], 'version' => 'nullable|required_with:id|integer|min:1', 'stock_item_id' => ['required', 'integer', $exists('stock_items'), Rule::unique('spare_parts')->ignore($request->input('id'))], 'criticality_level_id' => ['required', 'integer', $exists('criticality_levels')], 'minimum_quantity' => ['required', 'regex:/^\d{1,9}(\.\d{1,3})?$/']], + 'associacoes' => ['equipment_id' => ['required', 'integer', $exists('equipment')], 'spare_part_id' => ['required', 'integer', $exists('spare_parts')]], + }; + $data = $request->validate($rules); + DB::transaction(function () use ($data, $kind, $user) { + app(ConflictGuard::class)->lockCompany($user); + $model = match ($kind) { + 'categorias' => new CriticalityLevel,'activos' => new Equipment,'pecas' => new SparePart,'associacoes' => new EquipmentSparePart + }; + $id = $data['id'] ?? null; + $before = null; + $input = $data; + unset($input['id'],$input['version']); + if ($id) { + $model = $model->newQuery()->whereKey($id)->lockForUpdate()->firstOrFail(); + if (! $model instanceof Equipment && ! $model instanceof SparePart) { + throw new \LogicException('Este cadastro é apenas de acréscimo.'); + } + $before = $model->toArray(); + if ((int) $data['version'] !== $model->version) { + throw ValidationException::withMessages(['version' => 'Ficha alterada. Actualize antes de guardar.']); + }if ($kind === 'pecas' && (int) $input['stock_item_id'] !== $model->stock_item_id) { + throw ValidationException::withMessages(['stock_item_id' => 'O artigo de uma peça não pode ser substituído; preserve a rastreabilidade.']); + }$input['version'] = $model->version + 1; + } + if ($kind === 'pecas') { + $input['minimum_milli'] = ReceiptWorkflow::milli($input['minimum_quantity']); + unset($input['minimum_quantity']); + } + if ($kind === 'associacoes' && EquipmentSparePart::query()->where('equipment_id', $input['equipment_id'])->where('spare_part_id', $input['spare_part_id'])->exists()) { + throw ValidationException::withMessages(['spare_part_id' => 'Esta associação já existe e permanece no histórico.']); + } + if (! $id) { + $input['tenant_id'] = $user->tenant_id; + $input['created_by'] = $user->id; + } + if (in_array($kind, ['categorias', 'associacoes'])) { + $input['created_at'] = now(); + } + $model->fill($input)->save(); + app(RequisitionWorkflow::class)->audit($user, 'equipment.'.$kind.($id ? '_updated' : '_created'), null, $before, $model->toArray()); + }); + + return back(); + } +} diff --git a/app/Http/Controllers/GoodsReceiptController.php b/app/Http/Controllers/GoodsReceiptController.php new file mode 100644 index 0000000..e6ebced --- /dev/null +++ b/app/Http/Controllers/GoodsReceiptController.php @@ -0,0 +1,81 @@ + */ + private function visibleOrders(Request $request): Builder + { + Gate::authorize('viewAny', GoodsReceipt::class); + $orders = PurchaseOrder::query(); + if ($request->user()->purchasing_role === PurchasingRole::DepartmentHead) { + $orders->whereIn('requisition_id', Requisition::query()->visibleTo($request->user())->select('id')); + } + + return $orders; + } + + public function index(Request $request, ReceiptWorkflow $workflow): Response + { + $orders = $this->visibleOrders($request); + + return Inertia::render('receipts/index', [ + 'orders' => (clone $orders)->latest('id')->paginate(15)->through(fn (PurchaseOrder $order) => [ + 'id' => $order->id, 'number' => $order->number, 'supplier_name' => $order->snapshot['supplier']['name'], 'progress' => $workflow->progress($order), + ]), + 'receipts' => GoodsReceipt::query()->whereIn('purchase_order_id', (clone $orders)->select('id'))->latest('id')->paginate(15, ['*'], 'receipts_page'), + ]); + } + + public function order(Request $request, PurchaseOrder $order, ReceiptWorkflow $workflow): Response + { + Gate::authorize('viewOrder', [GoodsReceipt::class, $order]); + + return Inertia::render('receipts/order', [ + 'order' => $order->only(['id', 'number', 'issued_by']), 'supplier_name' => $order->snapshot['supplier']['name'], + 'progress' => $workflow->progress($order), + 'receipts' => GoodsReceipt::query()->where('purchase_order_id', $order->id)->latest('id')->get(), + 'warehouses' => Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']), + 'stockItems' => StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']), + 'canConfirm' => $request->user()->can('create', [GoodsReceipt::class, $order]), + ]); + } + + public function store(ConfirmReceiptRequest $request, PurchaseOrder $order, ReceiptWorkflow $workflow): JsonResponse|RedirectResponse + { + $receipt = $workflow->confirm($request->user(), $order, $request->validated()); + if ($request->expectsJson()) { + return response()->json(['id' => $receipt->id, 'number' => $receipt->number, 'created' => $receipt->wasRecentlyCreated, 'status' => $receipt->status], $receipt->wasRecentlyCreated ? 201 : 200); + } + + return to_route('receipts.show', $receipt); + } + + public function show(GoodsReceipt $receipt): Response + { + Gate::authorize('view', $receipt); + + return Inertia::render('receipts/show', [ + 'receipt' => $receipt->load('items'), + 'movements' => StockMovement::query()->whereIn('goods_receipt_item_id', $receipt->items()->select('id'))->orderBy('id')->get(), + ]); + } +} diff --git a/app/Http/Controllers/RequisitionController.php b/app/Http/Controllers/RequisitionController.php index 59c964d..da14413 100644 --- a/app/Http/Controllers/RequisitionController.php +++ b/app/Http/Controllers/RequisitionController.php @@ -5,9 +5,15 @@ namespace App\Http\Controllers; use App\Http\Requests\StoreRequisitionRequest; use App\Models\AuditLog; use App\Models\CostCenter; +use App\Models\Equipment; +use App\Models\EquipmentSparePart; use App\Models\Requisition; +use App\Models\SparePart; +use App\Models\StockItem; +use App\Models\TechnicalReview; use App\Models\Tenant; use App\Services\ConflictGuard; +use App\Services\EquipmentWorkflow; use App\Services\RequisitionWorkflow; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -39,6 +45,7 @@ class RequisitionController extends Controller return Inertia::render('requisitions/form', [ 'costCenters' => CostCenter::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(), 'requisition' => null, + ...$this->equipmentOptions($request), ]); } @@ -60,6 +67,11 @@ class RequisitionController extends Controller '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']), + 'technical' => [ + 'canReview' => app(EquipmentWorkflow::class)->canReview($request->user(), $requisition), + 'basis_hash' => app(EquipmentWorkflow::class)->basis($requisition), + 'reviews' => TechnicalReview::query()->where('requisition_id', $requisition->id)->orderBy('id')->get(), + ], ]); } @@ -70,9 +82,27 @@ class RequisitionController extends Controller return Inertia::render('requisitions/form', [ 'requisition' => $requisition->load('items'), 'costCenters' => CostCenter::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(), + ...$this->equipmentOptions($request), ]); } + /** @return array */ + private function equipmentOptions(Request $request): array + { + $equipment = Equipment::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(['id', 'code', 'description']); + $parts = SparePart::query()->whereIn('id', EquipmentSparePart::query()->whereIn('equipment_id', $equipment->pluck('id'))->select('spare_part_id'))->get(['id', 'stock_item_id']); + + return ['equipment' => $equipment, 'spareParts' => $parts, 'stockItems' => StockItem::query()->whereIn('id', $parts->pluck('stock_item_id'))->get(['id', 'code', 'name', 'unit'])]; + } + + public function technical(Request $request, Requisition $requisition): RedirectResponse + { + $data = $request->validate(['basis_hash' => 'required|string|size:64', 'decision' => ['required', Rule::in(['approved', 'rejected'])], 'reason' => 'required|string|max:4000']); + app(EquipmentWorkflow::class)->review($request->user(), $requisition, $data['basis_hash'], $data['decision'], $data['reason']); + + return back(); + } + public function update(StoreRequisitionRequest $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse { $workflow->save($request->user(), $request->validated(), $requisition); diff --git a/app/Http/Controllers/StockController.php b/app/Http/Controllers/StockController.php new file mode 100644 index 0000000..e65f4f6 --- /dev/null +++ b/app/Http/Controllers/StockController.php @@ -0,0 +1,87 @@ +user()->purchasing_role, $roles), 403); + } + + public function index(Request $request): Response + { + $this->access($request); + $filters = $request->validate([ + 'search' => 'nullable|string|max:100', + 'warehouse_id' => ['nullable', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $request->user()->tenant_id)], + ]); + $items = StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']); + $warehouses = Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']); + $selected = StockItem::query(); + if (! empty($filters['search'])) { + $selected->where(fn ($q) => $q->where('code', 'like', '%'.$filters['search'].'%')->orWhere('name', 'like', '%'.$filters['search'].'%')); + } + $movements = StockMovement::query()->whereIn('stock_item_id', $selected->select('id')); + if (! empty($filters['warehouse_id'])) { + $movements->where('warehouse_id', $filters['warehouse_id']); + } + $balances = (clone $movements)->selectRaw('stock_item_id, warehouse_id, SUM(quantity_milli) as quantity_milli')->groupBy('stock_item_id', 'warehouse_id')->get()->map(fn ($balance) => [ + 'stock_item_id' => $balance->stock_item_id, 'warehouse_id' => $balance->warehouse_id, + 'quantity_milli' => BigDecimal::of($balance->quantity_milli)->toInt(), + ]); + + return Inertia::render('stock/index', [ + 'items' => $items, 'warehouses' => $warehouses, 'balances' => $balances, + 'movements' => $movements->latest('id')->paginate(20)->withQueryString(), 'filters' => $filters, + 'canManage' => in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Warehouse]), + ]); + } + + public function item(Request $request, RequisitionWorkflow $audit): RedirectResponse + { + $this->access($request, true); + $request->merge(['code' => Str::upper(trim($request->string('code')->toString())), 'unit' => Str::lower(trim($request->string('unit')->toString()))]); + $data = $request->validate([ + 'code' => ['required', 'regex:/^[A-Z0-9][A-Z0-9._-]{0,29}$/', Rule::unique('stock_items')->where('tenant_id', $request->user()->tenant_id)], + 'name' => 'required|string|max:200', 'unit' => 'required|string|max:30', + ]); + DB::transaction(function () use ($request, $audit, $data) { + $item = StockItem::query()->create([...$data, 'tenant_id' => $request->user()->tenant_id, 'created_by' => $request->user()->id]); + $audit->audit($request->user(), 'stock.item_created', null, null, $item->toArray()); + }); + + return back(); + } + + public function warehouse(Request $request, RequisitionWorkflow $audit): RedirectResponse + { + $this->access($request, true); + $request->merge(['code' => Str::upper(trim($request->string('code')->toString()))]); + $data = $request->validate([ + 'code' => ['required', 'regex:/^[A-Z0-9][A-Z0-9._-]{0,29}$/', Rule::unique('warehouses')->where('tenant_id', $request->user()->tenant_id)], + 'name' => 'required|string|max:200', + ]); + DB::transaction(function () use ($request, $audit, $data) { + $warehouse = Warehouse::query()->create([...$data, 'tenant_id' => $request->user()->tenant_id, 'created_by' => $request->user()->id]); + $audit->audit($request->user(), 'stock.warehouse_created', null, null, $warehouse->toArray()); + }); + + return back(); + } +} diff --git a/app/Http/Controllers/StockOperationController.php b/app/Http/Controllers/StockOperationController.php new file mode 100644 index 0000000..439f5ad --- /dev/null +++ b/app/Http/Controllers/StockOperationController.php @@ -0,0 +1,77 @@ +user()->purchasing_role, [PurchasingRole::Warehouse, PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement]), 403); + $items = StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']); + $warehouses = Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']); + $balances = []; + foreach ($items as $item) { + foreach ($warehouses as $warehouse) { + $balances[] = ['stock_item_id' => $item->id, 'warehouse_id' => $warehouse->id, ...$workflow->balance($item->id, $warehouse->id)]; + } + } + + return Inertia::render('stock/operations', [ + 'items' => $items, 'warehouses' => $warehouses, 'balances' => $balances, + 'users' => User::query()->where('tenant_id', $request->user()->tenant_id)->orderBy('name')->get(['id', 'name']), + 'operations' => StockOperation::query()->latest('id')->paginate(20), + 'counts' => StockOperation::query()->where('kind', 'count')->whereNotIn('id', StockOperation::query()->whereNotNull('count_id')->select('count_id'))->latest('id')->get(), + 'canOperate' => $request->user()->purchasing_role === PurchasingRole::Warehouse, + 'equipment' => Equipment::query()->orderBy('code')->get(['id', 'code', 'description']), + ]); + } + + public function store(Request $request, StockWorkflow $workflow): JsonResponse|RedirectResponse + { + abort_unless($request->user()->purchasing_role === PurchasingRole::Warehouse, 403); + $request->merge(['reference' => Str::upper(Str::squish($request->string('reference')->toString())), 'reason' => trim($request->string('reason')->toString()), 'destination' => trim($request->string('destination')->toString())]); + $tenant = $request->user()->tenant_id; + $data = $request->validate([ + 'kind' => ['required', Rule::in(['issue', 'transfer', 'count', 'adjustment'])], + 'reference' => 'required|string|max:100', 'idempotency_key' => 'required|uuid', 'reason' => 'required|string|max:4000', + 'responsible_id' => ['required', 'integer', Rule::exists('users', 'id')->where('tenant_id', $tenant)], + 'warehouse_id' => ['required', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $tenant)], + 'stock_item_id' => ['required', 'integer', Rule::exists('stock_items', 'id')->where('tenant_id', $tenant)], + 'destination_warehouse_id' => ['exclude_unless:kind,transfer', 'required', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $tenant)], + 'destination' => 'exclude_unless:kind,issue|required|string|max:200', + 'equipment_id' => ['exclude_unless:kind,issue', 'nullable', 'integer', Rule::exists('equipment', 'id')->where('tenant_id', $tenant)], + 'quantity' => ['exclude_if:kind,adjustment', 'required', 'regex:/^\d{1,9}(\.\d{1,3})?$/', $request->input('kind') === 'count' ? 'gte:0' : 'gt:0'], + 'ledger_version' => 'exclude_unless:kind,count|required|integer|min:0', + 'count_id' => ['exclude_unless:kind,adjustment', 'required', 'integer', Rule::exists('stock_operations', 'id')->where('tenant_id', $tenant)->where('kind', 'count')], + ]); + if (empty($data['equipment_id'])) { + unset($data['equipment_id']); + } + foreach (['responsible_id', 'warehouse_id', 'stock_item_id', 'destination_warehouse_id', 'ledger_version', 'count_id', 'equipment_id'] as $key) { + if (isset($data[$key])) { + $data[$key] = (int) $data[$key]; + } + } + $data['quantity'] = isset($data['quantity']) ? (string) BigDecimal::of($data['quantity'])->toScale(3) : '0.000'; + ksort($data); + $operation = $workflow->record($request->user(), $data); + + return $request->expectsJson() ? response()->json(['id' => $operation->id, 'created' => $operation->wasRecentlyCreated], $operation->wasRecentlyCreated ? 201 : 200) : back(); + } +} diff --git a/app/Http/Requests/ConfirmReceiptRequest.php b/app/Http/Requests/ConfirmReceiptRequest.php new file mode 100644 index 0000000..0cfb6a8 --- /dev/null +++ b/app/Http/Requests/ConfirmReceiptRequest.php @@ -0,0 +1,53 @@ +route('order'); + + return $order instanceof PurchaseOrder && ($this->user()?->can('create', [GoodsReceipt::class, $order]) ?? false); + } + + /** @return array */ + public function rules(): array + { + return [ + 'idempotency_key' => 'required|uuid', 'delivery_note' => 'required|string|max:100', + 'received_on' => 'required|date_format:Y-m-d|before_or_equal:today', + 'warehouse_id' => ['nullable', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $this->user()?->tenant_id)], + 'items' => 'required|array|min:1|max:100', 'items.*.purchase_order_item_id' => 'required|integer|distinct', + 'items.*.received_quantity' => ['required', 'string', 'numeric', 'gt:0', 'max:1000000', 'regex:/^\d+(\.\d{1,3})?$/'], + 'items.*.accepted_quantity' => ['required', 'string', 'numeric', 'min:0', 'max:1000000', 'regex:/^\d+(\.\d{1,3})?$/'], + 'items.*.quality_status' => ['required', Rule::in(['compliant', 'reservations', 'nonconforming'])], + 'items.*.notes' => 'nullable|string|max:3000', + 'items.*.stock_item_id' => ['nullable', 'integer', Rule::exists('stock_items', 'id')->where('tenant_id', $this->user()?->tenant_id)], + 'tenant_id' => 'prohibited', 'received_by' => 'prohibited', 'status' => 'prohibited', 'snapshot' => 'prohibited', + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'delivery_note.required' => 'Indique a guia de remessa do fornecedor.', + 'received_on.before_or_equal' => 'A recepção não pode ser registada numa data futura.', + 'items.required' => 'Seleccione pelo menos uma linha para conferir.', + 'items.min' => 'Seleccione pelo menos uma linha para conferir.', + 'items.*.received_quantity.regex' => 'Use quantidades com até três casas decimais.', + 'items.*.accepted_quantity.regex' => 'Use quantidades com até três casas decimais.', + 'items.*.received_quantity.string' => 'Envie a quantidade recebida como texto decimal para manter a precisão.', + 'items.*.accepted_quantity.string' => 'Envie a quantidade aceite como texto decimal para manter a precisão.', + 'warehouse_id.exists' => 'Seleccione um armazém da sua empresa.', + 'items.*.stock_item_id.exists' => 'Seleccione um artigo de stock da sua empresa.', + 'idempotency_key.uuid' => 'A chave de confirmação é inválida. Reabra o formulário.', + ]; + } +} diff --git a/app/Http/Requests/StoreRequisitionRequest.php b/app/Http/Requests/StoreRequisitionRequest.php index 8239df4..6d5a912 100644 --- a/app/Http/Requests/StoreRequisitionRequest.php +++ b/app/Http/Requests/StoreRequisitionRequest.php @@ -25,7 +25,11 @@ class StoreRequisitionRequest extends FormRequest '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.*.purchase_type' => ['required', Rule::in(['consumable', 'service', 'other', 'equipment', 'spare_part'])], + 'items.*.equipment_id' => ['exclude_unless:items.*.purchase_type,spare_part', 'required', 'integer', Rule::exists('equipment', 'id')->where('tenant_id', $this->user()?->tenant_id)->where('department_id', $this->user()?->department_id)], + 'items.*.spare_part_id' => ['exclude_unless:items.*.purchase_type,spare_part', 'required', 'integer', Rule::exists('spare_parts', 'id')->where('tenant_id', $this->user()?->tenant_id)], + 'items.*.production_area' => 'exclude_unless:items.*.purchase_type,equipment|required|string|max:200', + 'items.*.downtime_started_at' => 'exclude_unless:items.*.purchase_type,spare_part|nullable|date_format:Y-m-d\TH:i|before_or_equal:now', '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})?$/'], diff --git a/app/Models/Concerns/AppendOnlyInventoryRecord.php b/app/Models/Concerns/AppendOnlyInventoryRecord.php new file mode 100644 index 0000000..d72c533 --- /dev/null +++ b/app/Models/Concerns/AppendOnlyInventoryRecord.php @@ -0,0 +1,14 @@ + throw new LogicException('O histórico de recepção e stock não pode ser reescrito.')); + static::deleting(fn () => throw new LogicException('O histórico de recepção e stock não pode ser eliminado.')); + } +} diff --git a/app/Models/CriticalityLevel.php b/app/Models/CriticalityLevel.php new file mode 100644 index 0000000..659acfd --- /dev/null +++ b/app/Models/CriticalityLevel.php @@ -0,0 +1,23 @@ + */ + protected function casts(): array + { + return ['is_critical' => 'boolean']; + } +} diff --git a/app/Models/Equipment.php b/app/Models/Equipment.php new file mode 100644 index 0000000..72bf625 --- /dev/null +++ b/app/Models/Equipment.php @@ -0,0 +1,13 @@ + $snapshot + */ +class GoodsReceipt extends Model +{ + use AppendOnlyInventoryRecord, BelongsToTenant; + + public $timestamps = false; + + protected $guarded = ['id']; + + protected function casts(): array + { + return ['snapshot' => 'array', 'received_on' => 'date:Y-m-d', 'confirmed_at' => 'datetime']; + } + + /** @return HasMany */ + public function items(): HasMany + { + return $this->hasMany(GoodsReceiptItem::class)->orderBy('id'); + } +} diff --git a/app/Models/GoodsReceiptItem.php b/app/Models/GoodsReceiptItem.php new file mode 100644 index 0000000..f9d2bec --- /dev/null +++ b/app/Models/GoodsReceiptItem.php @@ -0,0 +1,21 @@ + 'array']; + } +} diff --git a/app/Models/PurchaseOrderItem.php b/app/Models/PurchaseOrderItem.php index b788b83..5a1f2a3 100644 --- a/app/Models/PurchaseOrderItem.php +++ b/app/Models/PurchaseOrderItem.php @@ -6,6 +6,11 @@ use App\Models\Concerns\BelongsToTenant; use App\Models\Concerns\ImmutablePurchaseRecord; use Illuminate\Database\Eloquent\Model; +/** + * @property string $quantity + * @property int|null $equipment_id + * @property int|null $spare_part_id + */ class PurchaseOrderItem extends Model { use BelongsToTenant, ImmutablePurchaseRecord; diff --git a/app/Models/RequisitionItem.php b/app/Models/RequisitionItem.php index 805f204..f8e7c1a 100644 --- a/app/Models/RequisitionItem.php +++ b/app/Models/RequisitionItem.php @@ -10,6 +10,8 @@ use Illuminate\Database\Eloquent\Model; * * @property string $quantity * @property string $tax_rate + * @property int|null $equipment_id + * @property int|null $spare_part_id */ class RequisitionItem extends Model { diff --git a/app/Models/SparePart.php b/app/Models/SparePart.php new file mode 100644 index 0000000..48c5f71 --- /dev/null +++ b/app/Models/SparePart.php @@ -0,0 +1,13 @@ + 'array', 'moved_at' => 'datetime']; + } +} diff --git a/app/Models/StockOperation.php b/app/Models/StockOperation.php new file mode 100644 index 0000000..13d2748 --- /dev/null +++ b/app/Models/StockOperation.php @@ -0,0 +1,27 @@ + $snapshot + * @property int|null $equipment_id + * @property int|null $spare_part_id + */ +class StockOperation extends Model +{ + use AppendOnlyInventoryRecord, BelongsToTenant; + + public $timestamps = false; + + protected $guarded = ['id']; + + /** @return array */ + protected function casts(): array + { + return ['snapshot' => 'array', 'recorded_at' => 'datetime', 'quantity_milli' => 'integer', 'theoretical_milli' => 'integer', 'ledger_version' => 'integer']; + } +} diff --git a/app/Models/TechnicalReview.php b/app/Models/TechnicalReview.php new file mode 100644 index 0000000..b040771 --- /dev/null +++ b/app/Models/TechnicalReview.php @@ -0,0 +1,23 @@ + */ + protected function casts(): array + { + return ['snapshot' => 'array', 'reviewed_at' => 'datetime']; + } +} diff --git a/app/Models/Warehouse.php b/app/Models/Warehouse.php new file mode 100644 index 0000000..fb315b7 --- /dev/null +++ b/app/Models/Warehouse.php @@ -0,0 +1,13 @@ +tenant_id !== null && in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::Warehouse, PurchasingRole::DepartmentHead]); + } + + public function viewOrder(User $user, PurchaseOrder $order): bool + { + return $this->viewAny($user) && $user->tenant_id === $order->tenant_id && + ($user->purchasing_role !== PurchasingRole::DepartmentHead || Requisition::query()->whereKey($order->requisition_id)->where('department_id', $user->department_id)->exists()); + } + + public function view(User $user, GoodsReceipt $receipt): bool + { + $order = PurchaseOrder::query()->find($receipt->purchase_order_id); + + return $order !== null && $this->viewOrder($user, $order); + } + + public function create(User $user, PurchaseOrder $order): bool + { + return $user->tenant_id === $order->tenant_id && $user->purchasing_role === PurchasingRole::Warehouse && $user->id !== $order->issued_by; + } +} diff --git a/app/Services/ConflictGuard.php b/app/Services/ConflictGuard.php index 2aa27fb..9c212d6 100644 --- a/app/Services/ConflictGuard.php +++ b/app/Services/ConflictGuard.php @@ -84,6 +84,7 @@ class ConflictGuard 'requisition.created', 'requisition.updated', 'requisition.submitted', 'approval.approved', 'approval.rejected', 'quotation.created', 'quotation.evaluated', 'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued', + 'equipment.technical_approved', 'equipment.technical_rejected', ])->pluck('user_id')) ->unique()->values()->all(); $ids = []; diff --git a/app/Services/EquipmentWorkflow.php b/app/Services/EquipmentWorkflow.php new file mode 100644 index 0000000..7270275 --- /dev/null +++ b/app/Services/EquipmentWorkflow.php @@ -0,0 +1,96 @@ +findOrFail($partId); + $asset = Equipment::query()->findOrFail($equipmentId); + if (($departmentId !== null && $asset->department_id !== $departmentId) || ! EquipmentSparePart::query()->where('equipment_id', $asset->id)->where('spare_part_id', $part->id)->exists()) { + throw ValidationException::withMessages(['equipment_id' => 'A peça deve estar associada a um equipamento da área de destino e da mesma empresa.']); + } + + return $part; + } + + /** @param array $line */ + public function line(array $line, int $departmentId, string $priority): void + { + if ($line['purchase_type'] === 'equipment' && empty(trim($line['production_area'] ?? ''))) { + throw ValidationException::withMessages(['items' => 'Indique a área ou linha de produção do equipamento novo.']); + } + if ($line['purchase_type'] !== 'spare_part') { + return; + } + $part = $this->linkedPart((int) ($line['spare_part_id'] ?? 0), (int) ($line['equipment_id'] ?? 0), $departmentId); + $stock = StockItem::query()->findOrFail($part->stock_item_id); + if (mb_strtolower(trim($stock->unit)) !== mb_strtolower(trim($line['unit']))) { + throw ValidationException::withMessages(['items' => 'A unidade da peça deve coincidir com o artigo de stock, sem conversões automáticas.']); + } + $level = CriticalityLevel::query()->findOrFail($part->criticality_level_id); + if ($priority === 'urgent' && $level->is_critical && empty($line['downtime_started_at'])) { + throw ValidationException::withMessages(['items' => 'Peça crítica urgente: indique o início da paragem do equipamento (RF-AF-07).']); + } + } + + public function basis(Requisition $requisition): string + { + return hash('sha256', json_encode([$requisition->only(['id', 'tenant_id', 'department_id', 'requester_id', 'priority', 'needed_by', 'justification', 'total_minor']), $requisition->items()->orderBy('id')->get()->toArray()], JSON_THROW_ON_ERROR)); + } + + public function canReview(User $user, Requisition $requisition): bool + { + return $requisition->status === 'draft' && $requisition->items()->where('purchase_type', 'equipment')->exists() && $this->eligible($user, $requisition); + } + + private function eligible(User $user, Requisition $requisition): bool + { + return $requisition->tenant_id === $user->tenant_id && $user->purchasing_role === PurchasingRole::DepartmentHead && $user->department_id === $requisition->department_id && $user->id !== $requisition->requester_id && ! app(ConflictGuard::class)->participationBlocked($user, $requisition); + } + + public function review(User $user, Requisition $requisition, string $basis, string $decision, string $reason): TechnicalReview + { + return DB::transaction(function () use ($user, $requisition, $basis, $decision, $reason) { + app(ConflictGuard::class)->lockCompany($user); + $requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id); + abort_unless($this->canReview($user, $requisition), 403); + app(ConflictGuard::class)->assertParticipation($user, $requisition); + if ($basis !== $this->basis($requisition)) { + throw ValidationException::withMessages(['basis_hash' => 'O pedido mudou. Reveja a versão actual antes de decidir.']); + } + if (TechnicalReview::query()->where('requisition_id', $requisition->id)->where('basis_hash', $basis)->exists()) { + throw ValidationException::withMessages(['technical' => 'Esta versão já tem uma decisão técnica. A decisão anterior permanece no histórico.']); + } + $review = TechnicalReview::query()->create(['tenant_id' => $user->tenant_id, 'requisition_id' => $requisition->id, 'reviewed_by' => $user->id, 'basis_hash' => $basis, 'decision' => $decision, 'reason' => $reason, 'reviewed_at' => now(), 'snapshot' => ['reviewer' => $user->only(['id', 'name', 'department_id']), 'items' => $requisition->items()->get()->toArray(), 'development_only' => true, 'budget_checked' => false]]); + app(RequisitionWorkflow::class)->audit($user, 'equipment.technical_'.$decision, $requisition, null, $review->toArray()); + + return $review; + }); + } + + public function assertTechnical(Requisition $requisition): void + { + if (! $requisition->items()->where('purchase_type', 'equipment')->exists()) { + return; + } + $review = TechnicalReview::query()->where('requisition_id', $requisition->id)->where('basis_hash', $this->basis($requisition))->first(); + $user = $review ? User::query()->where('tenant_id', $requisition->tenant_id)->find($review->reviewed_by) : null; + if (! $review || $review->decision !== 'approved' || ! $user || ! $this->eligible($user, $requisition)) { + throw ValidationException::withMessages(['technical' => 'É necessária validação técnica válida do responsável da área, distinto do solicitante, para a versão actual.']); + } + } +} diff --git a/app/Services/PurchaseWorkflow.php b/app/Services/PurchaseWorkflow.php index e558df9..3c5e6c3 100644 --- a/app/Services/PurchaseWorkflow.php +++ b/app/Services/PurchaseWorkflow.php @@ -3,12 +3,14 @@ namespace App\Services; use App\Enums\PurchasingRole; +use App\Models\Equipment; use App\Models\ProposalEvaluation; use App\Models\PurchaseAward; use App\Models\PurchaseOrder; use App\Models\Quotation; use App\Models\QuotationItem; use App\Models\Requisition; +use App\Models\SparePart; use App\Models\Supplier; use App\Models\Tenant; use App\Models\User; @@ -30,8 +32,8 @@ class PurchaseWorkflow 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); + $this->sourceApproved($requisition, $rules); $quote = Quotation::query()->where('requisition_id', $requisition->id)->lockForUpdate()->findOrFail($data['quotation_id']); $result = $this->checkComparison($user, $requisition, $quote); $award = PurchaseAward::query()->create([ @@ -113,6 +115,7 @@ class PurchaseWorkflow $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, + 'equipment_id' => $source->equipment_id, 'spare_part_id' => $source->spare_part_id, ]; } $validUntil = $quote->attributesToArray()['valid_until'] ?? null; @@ -134,6 +137,7 @@ class PurchaseWorkflow 'estimate_approvals' => $requisition->steps()->get()->toArray(), 'final_approvals' => $award->approvals()->get()->toArray(), 'decision_matrix' => $this->comparison->inspect($requisition)['rows'], 'items' => $lines, + 'equipment_context' => $requisition->items()->whereNotNull('equipment_id')->get()->map(fn ($line) => ['requisition_item_id' => $line->id, 'equipment' => Equipment::query()->findOrFail($line->equipment_id)->toArray(), 'spare_part' => SparePart::query()->findOrFail($line->spare_part_id)->toArray(), 'downtime_started_at' => $line->downtime_started_at])->all(), ]; $order = PurchaseOrder::query()->create([ 'tenant_id' => $user->tenant_id, 'requisition_id' => $requisition->id, 'purchase_award_id' => $award->id, @@ -154,8 +158,8 @@ class PurchaseWorkflow private function currentBasis(User $user, PurchaseAward $award, Requisition $requisition): Quotation { $rules = $this->rules($requisition); - $this->sourceApproved($requisition, $rules); $this->supportedItems($requisition); + $this->sourceApproved($requisition, $rules); $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 || @@ -258,8 +262,14 @@ class PurchaseWorkflow 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.']); + if ($requisition->items()->where('purchase_type', 'equipment')->exists()) { + throw ValidationException::withMessages(['purchase_type' => 'O limite próprio de investimento e a sua regra de aplicação ainda não foram definidos pela empresa. A validação técnica e Finanças não dispensam essa decisão; adjudicação e emissão de equipamento bloqueadas.']); + } + foreach ($requisition->items()->get() as $line) { + if (! in_array($line->purchase_type, ['consumable', 'service', 'other', 'spare_part']) || ($line->purchase_type === 'spare_part' && (! $line->equipment_id || ! $line->spare_part_id))) { + throw ValidationException::withMessages(['purchase_type' => 'Tipo não suportado ou peça sem equipamento/artigo associado. Complete o cadastro antes da compra.']); + } + app(EquipmentWorkflow::class)->line($line->toArray(), $requisition->department_id, $requisition->priority); } } } diff --git a/app/Services/ReceiptWorkflow.php b/app/Services/ReceiptWorkflow.php new file mode 100644 index 0000000..dbe243a --- /dev/null +++ b/app/Services/ReceiptWorkflow.php @@ -0,0 +1,198 @@ +multipliedBy(1000)->toScale(0)->toInt(); + } + + private function recordedQuantity(int $lineId, string $column): int + { + $sum = GoodsReceiptItem::query()->where('purchase_order_item_id', $lineId)->sum($column); + if (! is_int($sum) && ! is_string($sum)) { + throw new \LogicException('A soma das quantidades deve ser um inteiro exacto.'); + } + + return BigDecimal::of($sum)->toInt(); + } + + /** @return array{lines: list>, state: string, has_divergences: bool} */ + public function progress(PurchaseOrder $order): array + { + $lines = []; + $complete = true; + $delivered = true; + $started = false; + $divergences = false; + foreach ($order->items()->orderBy('id')->get() as $item) { + $ordered = self::milli($item->quantity); + $received = $this->recordedQuantity($item->id, 'received_milli'); + $accepted = $this->recordedQuantity($item->id, 'accepted_milli'); + $lines[] = [ + ...$item->only(['id', 'description', 'unit', 'purchase_type']), + 'ordered_milli' => $ordered, 'received_milli' => $received, 'accepted_milli' => $accepted, + 'rejected_milli' => $received - $accepted, 'delivery_pending_milli' => $ordered - $received, 'acceptance_pending_milli' => $ordered - $accepted, + ]; + $complete = $complete && $accepted === $ordered; + $delivered = $delivered && $received === $ordered; + $started = $started || $received > 0; + $divergences = $divergences || GoodsReceiptItem::query()->where('purchase_order_item_id', $item->id)->where('quality_status', '!=', 'compliant')->exists(); + } + + return ['lines' => $lines, 'state' => ! $started ? 'not_received' : ($complete ? 'complete' : ($delivered ? 'quality_pending' : 'partial')), 'has_divergences' => $divergences]; + } + + /** @param array $data */ + public function confirm(User $user, PurchaseOrder $order, array $data): GoodsReceipt + { + return DB::transaction(function () use ($user, $order, $data) { + $this->conflicts->lockCompany($user); + $order = PurchaseOrder::query()->lockForUpdate()->findOrFail($order->id); + Gate::authorize('create', [GoodsReceipt::class, $order]); + $deliveryNote = Str::squish(Str::upper($data['delivery_note'])); + if ($deliveryNote === '') { + throw ValidationException::withMessages(['delivery_note' => 'Indique a guia de remessa do fornecedor.']); + } + $inputs = []; + /** @var list $requested */ + $requested = $data['items']; + foreach ($requested as $line) { + $inputs[] = [ + 'purchase_order_item_id' => $line['purchase_order_item_id'], + 'received_milli' => self::milli($line['received_quantity']), 'accepted_milli' => self::milli($line['accepted_quantity']), + 'quality_status' => $line['quality_status'], 'stock_item_id' => $line['stock_item_id'] ?? null, + 'notes' => isset($line['notes']) ? trim($line['notes']) : null, + ]; + } + usort($inputs, fn ($a, $b) => $a['purchase_order_item_id'] <=> $b['purchase_order_item_id']); + $canonical = ['delivery_note' => $deliveryNote, 'received_on' => $data['received_on'], 'warehouse_id' => $data['warehouse_id'] ?? null, 'items' => $inputs]; + // Normalize form-encoded identifiers to the same representation used by JSON requests. + $canonical['warehouse_id'] = $canonical['warehouse_id'] === null ? null : (string) $canonical['warehouse_id']; + foreach ($canonical['items'] as &$line) { + $line['purchase_order_item_id'] = (string) $line['purchase_order_item_id']; + $line['stock_item_id'] = $line['stock_item_id'] === null ? null : (string) $line['stock_item_id']; + } + unset($line); + $hash = hash('sha256', json_encode($canonical, JSON_THROW_ON_ERROR)); + $existing = GoodsReceipt::query()->where('purchase_order_id', $order->id)->where('idempotency_key', $data['idempotency_key'])->first(); + if ($existing) { + if ($existing->payload_hash !== $hash) { + throw ValidationException::withMessages(['idempotency_key' => 'Esta chave já confirmou uma recepção com dados diferentes.']); + } + + return $existing; + } + if (GoodsReceipt::query()->where('purchase_order_id', $order->id)->where('delivery_note', $deliveryNote)->exists()) { + throw ValidationException::withMessages(['delivery_note' => 'Esta guia já foi confirmada nesta ordem. Consulte o histórico em vez de repetir a recepção.']); + } + if ($order->status !== 'issued' || $data['received_on'] < substr((string) $order->getRawOriginal('issued_at'), 0, 10)) { + throw ValidationException::withMessages(['received_on' => 'A recepção deve referir uma ordem emitida e não pode anteceder a sua emissão.']); + } + $requisition = Requisition::query()->findOrFail($order->requisition_id); + $this->conflicts->assertParticipation($user, $requisition); + $this->conflicts->assertProcess($requisition, $order->supplier_id, $user); + $warehouse = ! empty($data['warehouse_id']) ? Warehouse::query()->whereKey($data['warehouse_id'])->lockForUpdate()->firstOrFail() : null; + $ordered = $order->items()->orderBy('id')->lockForUpdate()->get()->keyBy('id'); + $lines = []; + $seen = []; + $acceptedTotal = 0; + foreach ($inputs as $input) { + $source = $ordered->get($input['purchase_order_item_id']); + if (! $source || in_array($source->id, $seen, true)) { + throw ValidationException::withMessages(['items' => 'As linhas devem ser distintas e pertencer à ordem de compra.']); + } + $seen[] = $source->id; + $received = $input['received_milli']; + $accepted = $input['accepted_milli']; + $previous = $this->recordedQuantity($source->id, 'received_milli'); + if ($received <= 0 || $accepted < 0 || $accepted > $received || $previous + $received > self::milli($source->quantity)) { + throw ValidationException::withMessages(['items' => 'Quantidade inválida ou superior à quantidade pendente autorizada na ordem.']); + } + if (($input['quality_status'] === 'compliant' && $accepted !== $received) || ($input['quality_status'] === 'nonconforming' && $accepted !== 0)) { + throw ValidationException::withMessages(['items' => 'Conforme exige aceitar a quantidade recebida; Não conforme exige rejeitá-la integralmente. Use Conforme com reservas para aceitação parcial.']); + } + if ($input['quality_status'] !== 'compliant' && empty($input['notes'])) { + throw ValidationException::withMessages(['items' => 'Descreva as reservas, danos ou divergências de qualidade.']); + } + $stockItem = ! empty($input['stock_item_id']) ? StockItem::query()->lockForUpdate()->findOrFail($input['stock_item_id']) : null; + $physical = in_array($source->purchase_type, ['consumable', 'other', 'spare_part']); + if ($source->purchase_type === 'spare_part') { + if (! $source->spare_part_id || ! $source->equipment_id) { + throw ValidationException::withMessages(['items' => 'Peça sem associação autorizada na ordem. A recepção está bloqueada.']); + } + $part = app(EquipmentWorkflow::class)->linkedPart((int) $source->spare_part_id, (int) $source->equipment_id, $requisition->department_id); + if ($accepted > 0 && $stockItem?->id !== $part->stock_item_id) { + throw ValidationException::withMessages(['items' => 'A peça recebida deve usar o artigo de stock autorizado na ordem.']); + } + } + if (! $physical && $source->purchase_type !== 'service') { + throw ValidationException::withMessages(['items' => 'Este tipo de artigo exige controlos adicionais ainda não implementados.']); + } + if ($physical && $accepted > 0 && (! $warehouse || ! $stockItem || Str::lower(trim($stockItem->unit)) !== Str::lower(trim($source->unit)))) { + throw ValidationException::withMessages(['items' => 'Para bens aceites, indique armazém e artigo de stock com a mesma unidade da ordem. Não há conversões automáticas.']); + } + if (! $physical && $stockItem !== null) { + throw ValidationException::withMessages(['items' => 'Serviços não geram existências nem recebem artigo de stock.']); + } + $acceptedTotal += $accepted; + $lines[] = [ + 'tenant_id' => $user->tenant_id, 'purchase_order_item_id' => $source->id, + 'stock_item_id' => $stockItem?->id, 'received_milli' => $received, 'accepted_milli' => $accepted, + 'rejected_milli' => $received - $accepted, 'quality_status' => $input['quality_status'], 'notes' => $input['notes'], + 'snapshot' => [ + 'order_item' => $source->only(['id', 'description', 'unit', 'purchase_type', 'quantity']), + 'stock_item' => $stockItem?->only(['id', 'code', 'name', 'unit']), + 'warehouse' => $warehouse?->only(['id', 'code', 'name']), + 'physical' => $physical, + 'equipment_id' => $source->equipment_id, 'spare_part_id' => $source->spare_part_id, + ], + ]; + } + $receipt = GoodsReceipt::query()->create([ + 'tenant_id' => $user->tenant_id, 'purchase_order_id' => $order->id, 'received_by' => $user->id, 'warehouse_id' => $warehouse?->id, + 'idempotency_key' => $data['idempotency_key'], 'payload_hash' => $hash, 'delivery_note' => $deliveryNote, + 'received_on' => $data['received_on'], 'status' => 'partial', 'confirmed_at' => now(), + 'snapshot' => ['order' => $order->only(['id', 'number']), 'supplier' => $order->snapshot['supplier'], 'receiver' => $user->only(['id', 'name']), 'warehouse' => $warehouse?->only(['id', 'code', 'name']), 'development_only' => true, 'budget_checked' => false], + ]); + foreach ($lines as $line) { + $item = $receipt->items()->create($line); + if ($line['accepted_milli'] > 0 && $line['snapshot']['physical']) { + StockMovement::query()->create([ + 'tenant_id' => $user->tenant_id, 'goods_receipt_item_id' => $item->id, 'stock_item_id' => $line['stock_item_id'], + 'warehouse_id' => $warehouse?->id, 'moved_by' => $user->id, 'quantity_milli' => $line['accepted_milli'], 'moved_at' => now(), + 'equipment_id' => $line['snapshot']['equipment_id'], 'spare_part_id' => $line['snapshot']['spare_part_id'], + 'snapshot' => [...$line['snapshot'], 'receipt_id' => $receipt->id, 'order_number' => $order->number, 'delivery_note' => $deliveryNote], + ]); + } + } + $progress = $this->progress($order); + $status = $acceptedTotal === 0 ? 'rejected' : ($progress['state'] === 'complete' ? 'complete' : 'partial'); + // Complete the reference/status before exposing this append-only confirmation. + DB::table('goods_receipts')->where('id', $receipt->id)->update(['number' => sprintf('REC-%s-%06d', now()->format('Y'), $receipt->id), 'status' => $status]); + $receipt->refresh(); + $receipt->load('items'); + $this->audit->audit($user, 'receipt.confirmed', $requisition, null, ['receipt' => $receipt->toArray(), 'order_progress' => $progress]); + + return $receipt; + }); + } +} diff --git a/app/Services/RequisitionWorkflow.php b/app/Services/RequisitionWorkflow.php index 5616557..8c856aa 100644 --- a/app/Services/RequisitionWorkflow.php +++ b/app/Services/RequisitionWorkflow.php @@ -27,9 +27,10 @@ class RequisitionWorkflow $items = []; $subtotal = 0; $tax = 0; - /** @var list $lines */ + /** @var list $lines */ $lines = $data['items']; foreach ($lines as $line) { + app(EquipmentWorkflow::class)->line($line, (int) $user->department_id, $data['priority']); $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(); @@ -37,6 +38,7 @@ class RequisitionWorkflow 'tenant_id' => $user->tenant_id, 'description' => $line['description'], 'category' => $line['category'], 'purchase_type' => $line['purchase_type'], 'quantity' => $line['quantity'], 'unit' => $line['unit'], + 'equipment_id' => $line['equipment_id'] ?? null, 'spare_part_id' => $line['spare_part_id'] ?? null, 'production_area' => $line['production_area'] ?? null, 'downtime_started_at' => $line['downtime_started_at'] ?? null, 'unit_price_minor' => $price, 'tax_rate' => $line['tax_rate'], 'subtotal_minor' => $lineSubtotal, 'tax_minor' => $lineTax, 'total_minor' => $lineSubtotal + $lineTax, ]; @@ -71,6 +73,10 @@ class RequisitionWorkflow $requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id); abort_unless($requisition->requester_id === $user->id && $requisition->status === 'draft', 403); app(ConflictGuard::class)->assertParticipation($user, $requisition); + foreach ($requisition->items()->get() as $item) { + app(EquipmentWorkflow::class)->line($item->toArray(), $requisition->department_id, $requisition->priority); + } + app(EquipmentWorkflow::class)->assertTechnical($requisition); if ($requisition->needed_by < today()) { throw ValidationException::withMessages(['submission' => 'Actualize a data de entrega antes de submeter.']); } @@ -80,7 +86,7 @@ class RequisitionWorkflow if ($amount > $tenant->first_limit_minor) { $roles[] = 'procurement'; } - if ($amount > $tenant->second_limit_minor) { + if ($amount > $tenant->second_limit_minor || $requisition->items()->where('purchase_type', 'equipment')->exists()) { $roles[] = 'finance'; } foreach ($roles as $position => $role) { @@ -99,6 +105,9 @@ class RequisitionWorkflow $requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id); $requisition->load('steps'); abort_unless($requisition->canDecide($user), 403); + if ($decision === 'approved') { + app(EquipmentWorkflow::class)->assertTechnical($requisition); + } $step = $requisition->steps->firstWhere('decision', 'pending'); abort_if($step === null, 409); $step->update(['decision' => $decision, 'approver_id' => $user->id, 'comment' => $comment, 'decided_at' => now()]); diff --git a/app/Services/StockWorkflow.php b/app/Services/StockWorkflow.php new file mode 100644 index 0000000..e026d73 --- /dev/null +++ b/app/Services/StockWorkflow.php @@ -0,0 +1,110 @@ +where('stock_item_id', $item)->where('warehouse_id', $warehouse); + + return ['quantity' => BigDecimal::of((string) (clone $query)->sum('quantity_milli'))->toInt(), 'version' => (int) $query->max('id')]; + } + + /** @param array{kind: string, reference: string, idempotency_key: string, responsible_id: int, warehouse_id: int, stock_item_id: int, destination_warehouse_id?: int, quantity: string, reason: string, destination?: string, count_id?: int, ledger_version?: int, equipment_id?: int} $data */ + public function record(User $actor, array $data): StockOperation + { + abort_unless($actor->tenant_id !== null && $actor->purchasing_role === PurchasingRole::Warehouse && auth()->id() === $actor->id, 403); + + return DB::transaction(function () use ($actor, $data) { + // Same first lock as receipts: includes empty balances and concurrent receipts. + app(ConflictGuard::class)->lockCompany($actor); + $hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR)); + $existing = StockOperation::query()->where('idempotency_key', $data['idempotency_key'])->first(); + if ($existing) { + if ($existing->payload_hash !== $hash) { + throw ValidationException::withMessages(['idempotency_key' => 'Chave já utilizada com dados diferentes.']); + } + + return $existing; + } + if (StockOperation::query()->where('reference', $data['reference'])->exists()) { + throw ValidationException::withMessages(['reference' => 'Referência já registada. Consulte o histórico.']); + } + $responsible = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($data['responsible_id']); + $warehouse = Warehouse::query()->findOrFail($data['warehouse_id']); + $item = StockItem::query()->findOrFail($data['stock_item_id']); + $equipment = $data['kind'] === 'issue' && isset($data['equipment_id']) ? Equipment::query()->findOrFail($data['equipment_id']) : null; + $part = $data['kind'] === 'issue' ? SparePart::query()->where('stock_item_id', $item->id)->first() : null; + if ($part) { + if (! $equipment) { + throw ValidationException::withMessages(['equipment_id' => 'Seleccione o equipamento de destino associado à peça.']); + } + app(EquipmentWorkflow::class)->linkedPart($part->id, $equipment->id); + } + $destination = $data['kind'] === 'transfer' ? Warehouse::query()->findOrFail($data['destination_warehouse_id'] ?? 0) : null; + if ($destination?->id === $warehouse->id) { + throw ValidationException::withMessages(['destination_warehouse_id' => 'Escolha outro armazém da mesma empresa.']); + } + $balance = $this->balance($item->id, $warehouse->id); + $quantity = ReceiptWorkflow::milli($data['quantity']); + $delta = -$quantity; + $count = null; + if ($data['kind'] === 'count') { + if (($data['ledger_version'] ?? -1) !== $balance['version']) { + throw ValidationException::withMessages(['ledger_version' => 'Existem movimentos posteriores. Actualize o saldo e repita a contagem física.']); + } + $delta = 0; + } elseif ($data['kind'] === 'adjustment') { + $count = StockOperation::query()->where('kind', 'count')->findOrFail($data['count_id'] ?? 0); + if ($count->stock_item_id !== $item->id || $count->warehouse_id !== $warehouse->id || $count->ledger_version !== $balance['version'] || StockOperation::query()->where('count_id', $count->id)->exists()) { + throw ValidationException::withMessages(['count_id' => 'Contagem já ajustada, incompatível ou desactualizada. Faça nova contagem após movimentos.']); + } + $quantity = $count->quantity_milli; + $delta = $quantity - $balance['quantity']; + if ($delta === 0) { + throw ValidationException::withMessages(['count_id' => 'Contagem sem divergência; não há ajuste a efectuar.']); + } + } + if ($balance['quantity'] + $delta < 0) { + throw ValidationException::withMessages(['quantity' => 'Stock insuficiente. A operação não pode produzir saldo negativo.']); + } + $operation = StockOperation::query()->create([ + 'tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'responsible_id' => $responsible->id, + 'warehouse_id' => $warehouse->id, 'destination_warehouse_id' => $destination?->id, 'stock_item_id' => $item->id, + 'count_id' => $count?->id, 'kind' => $data['kind'], 'reference' => $data['reference'], 'idempotency_key' => $data['idempotency_key'], + 'equipment_id' => $equipment?->id, 'spare_part_id' => $part?->id, + 'payload_hash' => $hash, 'destination' => $data['destination'] ?? null, 'reason' => $data['reason'], + 'quantity_milli' => $quantity, 'theoretical_milli' => $balance['quantity'], 'ledger_version' => $balance['version'], 'recorded_at' => now(), + 'snapshot' => ['actor' => $actor->only(['id', 'name']), 'responsible' => $responsible->only(['id', 'name']), 'stock_item' => $item->only(['id', 'code', 'name', 'unit']), 'warehouse' => $warehouse->only(['id', 'code', 'name']), 'destination_warehouse' => $destination?->only(['id', 'code', 'name']), 'equipment' => $equipment?->toArray(), 'spare_part' => $part?->toArray(), 'difference_milli' => $quantity - $balance['quantity'], 'budget_checked' => false, 'development_only' => true], + ]); + if ($delta !== 0) { + $this->movement($actor, $operation, $warehouse->id, $delta, $data['kind'] === 'transfer' ? 'transfer_out' : $data['kind']); + if ($destination) { + $this->movement($actor, $operation, $destination->id, $quantity, 'transfer_in'); + } + } + app(RequisitionWorkflow::class)->audit($actor, 'stock.'.$data['kind'], null, null, $operation->toArray()); + + return $operation; + }); + } + + private function movement(User $actor, StockOperation $operation, int $warehouse, int $quantity, string $type): void + { + StockMovement::query()->create(['tenant_id' => $actor->tenant_id, 'stock_operation_id' => $operation->id, 'stock_item_id' => $operation->stock_item_id, 'warehouse_id' => $warehouse, 'equipment_id' => $operation->equipment_id, 'spare_part_id' => $operation->spare_part_id, 'moved_by' => $actor->id, 'movement_type' => $type, 'quantity_milli' => $quantity, 'snapshot' => [...$operation->snapshot, 'warehouse' => Warehouse::query()->findOrFail($warehouse)->only(['id', 'code', 'name']), 'reference' => $operation->reference, 'reason' => $operation->reason], 'moved_at' => now()]); + } +} diff --git a/database/migrations/2026_10_28_000006_create_receipts_and_stock.php b/database/migrations/2026_10_28_000006_create_receipts_and_stock.php new file mode 100644 index 0000000..b58c960 --- /dev/null +++ b/database/migrations/2026_10_28_000006_create_receipts_and_stock.php @@ -0,0 +1,84 @@ +id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('created_by')->constrained('users')->restrictOnDelete(); + $table->string('code', 30); + $table->string('name', 200); + $table->timestamps(); + $table->unique(['tenant_id', 'code']); + }); + Schema::create('stock_items', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('created_by')->constrained('users')->restrictOnDelete(); + $table->string('code', 30); + $table->string('name', 200); + $table->string('unit', 30); + $table->timestamps(); + $table->unique(['tenant_id', 'code']); + }); + Schema::create('goods_receipts', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('purchase_order_id')->constrained()->restrictOnDelete(); + $table->foreignId('received_by')->constrained('users')->restrictOnDelete(); + $table->foreignId('warehouse_id')->nullable()->constrained()->restrictOnDelete(); + $table->string('number')->nullable()->unique(); + $table->uuid('idempotency_key'); + $table->string('payload_hash', 64); + $table->string('delivery_note', 100); + $table->date('received_on'); + $table->string('status'); + $table->json('snapshot'); + $table->timestamp('confirmed_at'); + $table->unique(['purchase_order_id', 'idempotency_key']); + $table->unique(['purchase_order_id', 'delivery_note']); + }); + Schema::create('goods_receipt_items', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('goods_receipt_id')->constrained()->restrictOnDelete(); + $table->foreignId('purchase_order_item_id')->constrained()->restrictOnDelete(); + $table->foreignId('stock_item_id')->nullable()->constrained()->restrictOnDelete(); + $table->unsignedBigInteger('received_milli'); + $table->unsignedBigInteger('accepted_milli'); + $table->unsignedBigInteger('rejected_milli'); + $table->string('quality_status'); + $table->text('notes')->nullable(); + $table->json('snapshot'); + $table->unique(['goods_receipt_id', 'purchase_order_item_id']); + }); + Schema::create('stock_movements', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('goods_receipt_item_id')->unique()->constrained()->restrictOnDelete(); + $table->foreignId('stock_item_id')->constrained()->restrictOnDelete(); + $table->foreignId('warehouse_id')->constrained()->restrictOnDelete(); + $table->foreignId('moved_by')->constrained('users')->restrictOnDelete(); + $table->string('movement_type')->default('receipt_in'); + $table->unsignedBigInteger('quantity_milli'); + $table->json('snapshot'); + $table->timestamp('moved_at'); + $table->index(['tenant_id', 'stock_item_id', 'warehouse_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('stock_movements'); + Schema::dropIfExists('goods_receipt_items'); + Schema::dropIfExists('goods_receipts'); + Schema::dropIfExists('stock_items'); + Schema::dropIfExists('warehouses'); + } +}; diff --git a/database/migrations/2026_10_28_000007_create_stock_operations.php b/database/migrations/2026_10_28_000007_create_stock_operations.php new file mode 100644 index 0000000..88f84dd --- /dev/null +++ b/database/migrations/2026_10_28_000007_create_stock_operations.php @@ -0,0 +1,47 @@ +id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('created_by')->constrained('users')->restrictOnDelete(); + $table->foreignId('responsible_id')->constrained('users')->restrictOnDelete(); + $table->foreignId('warehouse_id')->constrained()->restrictOnDelete(); + $table->foreignId('destination_warehouse_id')->nullable()->constrained('warehouses')->restrictOnDelete(); + $table->foreignId('stock_item_id')->constrained()->restrictOnDelete(); + $table->foreignId('count_id')->nullable()->unique()->constrained('stock_operations')->restrictOnDelete(); + $table->string('kind'); + $table->string('reference', 100); + $table->uuid('idempotency_key'); + $table->string('payload_hash', 64); + $table->string('destination', 200)->nullable(); + $table->text('reason'); + $table->bigInteger('quantity_milli'); + $table->bigInteger('theoretical_milli')->nullable(); + $table->unsignedBigInteger('ledger_version')->nullable(); + $table->json('snapshot'); + $table->timestamp('recorded_at'); + $table->unique(['tenant_id', 'reference']); + $table->unique(['tenant_id', 'idempotency_key']); + }); + Schema::table('stock_movements', function (Blueprint $table) { + $table->unsignedBigInteger('goods_receipt_item_id')->nullable()->change(); + $table->bigInteger('quantity_milli')->change(); + $table->foreignId('stock_operation_id')->nullable()->constrained()->restrictOnDelete(); + $table->unique(['stock_operation_id', 'warehouse_id']); + }); + } + + public function down(): void + { + // Signed ledger entries must never be coerced back to unsigned quantities. + throw new LogicException('Migração de histórico irreversível; preservar movimentos e usar migrações aditivas.'); + } +}; diff --git a/database/migrations/2026_10_28_000008_create_equipment_and_spare_parts.php b/database/migrations/2026_10_28_000008_create_equipment_and_spare_parts.php new file mode 100644 index 0000000..a4466c0 --- /dev/null +++ b/database/migrations/2026_10_28_000008_create_equipment_and_spare_parts.php @@ -0,0 +1,85 @@ +id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('created_by')->constrained('users')->restrictOnDelete(); + $table->string('code', 30); + $table->string('name', 150); + $table->text('criteria'); + $table->boolean('is_critical'); + $table->timestamp('created_at'); + $table->unique(['tenant_id', 'code']); + }); + Schema::create('equipment', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('created_by')->constrained('users')->restrictOnDelete(); + $table->foreignId('department_id')->constrained()->restrictOnDelete(); + $table->foreignId('stock_item_id')->nullable()->constrained()->restrictOnDelete(); + $table->foreignId('criticality_level_id')->constrained()->restrictOnDelete(); + $table->string('code', 30); + $table->string('description', 255); + $table->string('area', 200); + $table->string('serial_number', 100)->nullable(); + $table->string('status', 30); + $table->unsignedInteger('version')->default(1); + $table->timestamps(); + $table->unique(['tenant_id', 'code']); + }); + Schema::create('spare_parts', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('created_by')->constrained('users')->restrictOnDelete(); + $table->foreignId('stock_item_id')->unique()->constrained()->restrictOnDelete(); + $table->foreignId('criticality_level_id')->constrained()->restrictOnDelete(); + $table->unsignedBigInteger('minimum_milli'); + $table->unsignedInteger('version')->default(1); + $table->timestamps(); + }); + Schema::create('equipment_spare_parts', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('equipment_id')->constrained('equipment')->restrictOnDelete(); + $table->foreignId('spare_part_id')->constrained()->restrictOnDelete(); + $table->foreignId('created_by')->constrained('users')->restrictOnDelete(); + $table->timestamp('created_at'); + $table->unique(['equipment_id', 'spare_part_id']); + }); + Schema::create('technical_reviews', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('requisition_id')->constrained()->restrictOnDelete(); + $table->foreignId('reviewed_by')->constrained('users')->restrictOnDelete(); + $table->string('basis_hash', 64); + $table->string('decision', 30); + $table->text('reason'); + $table->json('snapshot'); + $table->timestamp('reviewed_at'); + $table->unique(['requisition_id', 'basis_hash']); + }); + foreach (['stock_operations', 'stock_movements', 'requisition_items', 'purchase_order_items'] as $name) { + Schema::table($name, function (Blueprint $table) { + $table->foreignId('equipment_id')->nullable()->constrained('equipment')->restrictOnDelete(); + $table->foreignId('spare_part_id')->nullable()->constrained()->restrictOnDelete(); + }); + } + Schema::table('requisition_items', function (Blueprint $table) { + $table->string('production_area', 200)->nullable(); + $table->timestamp('downtime_started_at')->nullable(); + }); + } + + public function down(): void + { + throw new LogicException('Preservar cadastro, associações e decisões técnicas; usar migrações aditivas.'); + } +}; diff --git a/docs/compatibilidade.md b/docs/compatibilidade.md index 77e7384..28140f4 100644 --- a/docs/compatibilidade.md +++ b/docs/compatibilidade.md @@ -1,76 +1,33 @@ -# Consolidação do ProcuraFlow +# Compatibilidade e estado do SGC -Estado verificado em 28 de Outubro de 2026. +Estado actualizado em 28 de Outubro de 2026. Projecto activo: C:\laragon\www\compras, Laravel + React + PostgreSQL, http://127.0.0.1:8015. Preservar conta, dados, configuração, cookie e porta. SGF Tempo e Investmoz são independentes. A implementação Python separada fica apenas como referência; não importar contas, sessões ou bases experimentais. -## Projecto activo +| Área | Implementado no Laravel | Pendente | +| -------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| Acesso e organização | Fortify, conta, empresa, equipa, departamentos e centros de custo | MFA obrigatório e ciclo completo de acessos | +| Requisições e aprovações | Rascunhos, cálculos exactos, etapas, segregação e snapshots | Validação empresarial das regras provisórias | +| Fornecedores | Cadastro, edição, renovação, validação independente e anexos privados | Dados bancários com dupla validação | +| Cotações | Propostas, avaliações e matriz comparativa | Multimoeda e revisão das propostas | +| RF-AF-01 | Declarações pessoais, histórico e ConflictGuard no servidor | Política de periodicidade, validade e levantamento | +| Adjudicação | Escolha fundamentada, revalidação e aprovação do valor final | Excepções, revisão e cancelamento sem política ficam bloqueados | +| Ordens | Emissão única/idempotente, snapshots, consulta e impressão | Orçamento não validado; documentos de desenvolvimento | +| Recepção | Parcial/total, qualidade, divergências, pendentes, segregação do emitente e auditoria | Substituições e anexos; rejeições não autorizam receber acima do total | +| Stock | Artigos, armazéns, entradas, saídas fundamentadas, transferências atómicas, contagens e ajustes auditados por empresa | Revisão visual; mínimos, reposição, equipamento/peças e conversões | +| Equipamentos e peças | Tipos não suportados ficam bloqueados | Aprovação técnica e regras específicas | +| Orçamento, facturas e pagamentos | Ainda não implementados | Definir regras antes do próximo incremento | +| Início e navegação | Página inicial própria, menus por área/perfil e consulta pública por módulo | Revisão visual | +| Auditoria | Histórico das operações implementadas | Exportações, retenção e protecção contra SQL administrativo | -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. +Preservar enums, políticas, modelos relacionais, Brick Math e isolamento por empresa. As regras guardadas nos snapshots não são substituídas pela configuração actual. Ausência de conflito não anula conflito registado. Exemplos públicos fictícios não dão acesso aos dados de negócio. -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. +## Verificação actual -## Compatibilidade funcional +Na implementação, 233 testes passaram, 3 ignorados, 2.163 asserções em SQLite em memória; PHPStan, Pint, frontend, TypeScript e compilação aprovados. **Migrações até 000007 efectivamente aplicadas na base actual; 000007 no lote 8, sem migrações pendentes.** Antes da aplicação foi criado backup custom PostgreSQL e restaurado com sucesso numa base temporária exclusiva, depois eliminada. Contagens e hashes das 31 tabelas anteriores coincidiram antes/depois; restrições existentes preservadas. Mantêm-se 1 utilizador, 1 empresa, 1 departamento e 1 centro de custo. Sem seeds, recriação de contas ou movimentos fictícios; `stock_movements` e `stock_operations` vazias. `.env` inalterado. [Evidência da migração e recuperação](stock-migracao-000007.json). -| Á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 | +Backup privado: `storage/app/private/backups/stock-000007-20261028-111943-4deef2/`, com `database.dump`, manifesto, cópia do ambiente e instruções de recuperação para uma base nova. Nenhuma restauração foi feita sobre a base actual. A migração acrescenta a tabela e ligação de operações, relaxa a obrigatoriedade da recepção e preserva os valores existentes; o PostgreSQL já utilizava `bigint` assinado. `down()` permanece bloqueado para proteger o histórico. -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. +Quatro cenários simultâneos de recepção passaram numa base PostgreSQL separada, eliminada no final: excesso, repetição, quantidades complementares e guia duplicada. [Evidência](recepcao-concorrencia-postgresql.json). Não constitui teste de carga ou de concorrência dos outros módulos. -## Diferenças a resolver em cada adaptação +Seis cenários simultâneos de stock aprovados em PostgreSQL separado, eliminado no final: excesso de saídas, repetição de saída/transferência/ajuste, transferências opostas e ajuste desactualizado. [Evidência](stock-concorrencia-postgresql.json). Apenas Armazém executa; Administração, Procurement e Finanças consultam. Contagens não movimentam stock; ajustes fundamentados exigem contagem compatível e sem movimentos posteriores. Periodicidade, tolerâncias e investigação/resolução das divergências exigem decisão da empresa; não foi inventado um circuito de aprovação. -- 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. +Após a aplicação, `/consulta/operacoes-stock` respondeu HTTP 200 e `/stock/operacoes` redireccionou para login (302) no cliente sem sessão. Revisão visual pública e autenticada pendentes: a nova tentativa devolveu `The browser access host is unavailable`, impedindo também verificar a sessão existente. A revisão de impressão continua pendente. Os resultados de testes acima são os da implementação, não uma nova execução nesta aplicação. Resultados históricos, âmbito e limitações em [desenvolvimento.md](desenvolvimento.md). **O orçamento continua sem validação.** Equipamentos não foram iniciados. diff --git a/docs/consulta-livre.md b/docs/consulta-livre.md index 196b599..ce46c38 100644 --- a/docs/consulta-livre.md +++ b/docs/consulta-livre.md @@ -1,21 +1,20 @@ -# Consulta livre para revisão visual +# Página inicial e consulta livre -A entrada `/` encaminha para `/consulta`, uma demonstração pública sem login, -criada a pedido do utilizador para avaliar navegação, ecrãs e cores. +A entrada `/` apresenta uma página inicial própria, com acesso à demonstração e ao login. A área autenticada tem menus agrupados por área e perfil. -O ecrã usa exclusivamente exemplos fictícios locais e não consulta registos de -negócio. Não permite gravar, aprovar, adjudicar ou descarregar documentos reais. -As rotas operacionais mantêm autenticação e as permissões existentes. A conta, -os dados e a configuração da empresa foram preservados. +A demonstração sem login usa endereços próprios, permitindo actualizar e usar o histórico do navegador: -Implementação: `routes/preview.php` e `resources/js/pages/preview/index.tsx`. -O login oferece também uma ligação à demonstração. A página não usa o layout -autenticado e não recebe a identidade do utilizador nas propriedades públicas. +- `/consulta`: visão geral. +- `/consulta/requisicoes`, `/consulta/fornecedores` e `/consulta/cotacoes`. +- `/consulta/conflitos` e `/consulta/ordens`. +- `/consulta/recepcoes`: entregas parciais, qualidade, divergências e pendentes fictícios. +- `/consulta/stock`: existências, pesquisa e detalhe de movimentos fictícios. +- `/consulta/operacoes-stock`: saídas, transferências, contagens físicas e ajustes fictícios; formulário apenas de simulação. +- `/consulta/equipamentos`: equipamentos, peças, criticidade e mínimos fictícios, compatibilidades, saída rastreável e formulários sem escrita. +- `/consulta/aparencia`: apresentação visual. -Ao desenvolver novos módulos, manter a demonstração identificada como fictícia. -Os exemplos de ordens são ilustrativos; não constituem prova de implementação -do fluxo operacional. Não substituir os controlos de acesso por acesso anónimo. +Todos os exemplos são locais e fictícios. Os formulários demonstrativos não gravam nem consultam dados de negócio. As rotas operacionais mantêm autenticação e permissões. As propriedades públicas não incluem a identidade do utilizador, mesmo quando já tem sessão iniciada. -Validação: quatro testes dirigidos passaram, com 41 asserções, incluindo a -entrada livre, ausência de identidade e manutenção da autenticação nas rotas -de negócio. Revisão visual no navegador pendente por falha da ligação de controlo. +Implementação em routes/preview.php, resources/js/pages/home.tsx e resources/js/pages/preview/. A conta, dados e configuração foram preservados. Ordens, recepção e stock reais existem separadamente; exemplos públicos não constituem validação do fluxo ou do orçamento. + +Os testes HTTP/Inertia verificam a página inicial, os endereços por módulo, ausência de identidade e protecção das operações. Resultados em [desenvolvimento.md](desenvolvimento.md). Após aplicação da migração 000007, `/consulta/operacoes-stock` voltou a responder HTTP 200. Revisão visual pública e autenticada continuam pendentes: a tentativa de acesso ao navegador devolveu `The browser access host is unavailable`, sem permitir verificar uma sessão autenticada. HTTP 200 não constitui revisão visual. diff --git a/docs/desenvolvimento.md b/docs/desenvolvimento.md index ee63ba3..8e9b9d1 100644 --- a/docs/desenvolvimento.md +++ b/docs/desenvolvimento.md @@ -1,4 +1,130 @@ -# Desenvolvimento do ProcuraFlow +# Desenvolvimento do SGC + +## Revisão e consolidação — 28 de Outubro de 2026 + +- Recolhido o resultado final da execução da suite: **262 testes aprovados, + 3 ignorados e 2.327 asserções**, em 71,928 segundos. Os testes usam SQLite + em memória; esta revisão não repetiu os ensaios PostgreSQL históricos. +- PHPStan e Pint aprovados. TypeScript e compilação de produção aprovados. + Corrigida a formatação da evidência JSON da migração 000008. +- Confirmada no código a marca Sistema de Gestão de Compras (SGC) na página + inicial e SGC no logótipo, demonstração, painel, títulos e impressão. + A configuração existente e o cookie de sessão foram preservados. +- Revistos os diffs da integração e os controlos de perfis, isolamento, + validação técnica, recepção e movimentos. Actualizados README e continuidade + para distinguir funcionalidades entregues de instruções históricas. +- **Revisão visual não concluída**: o controlo do computador foi interrompido + porque não conseguiu determinar com confiança o URL actual do navegador. + Não foi validado o aspecto dos ecrãs públicos, autenticados ou da impressão. + A compilação e os testes não substituem essa revisão. + +## Incremento de equipamentos e peças — 28 de Outubro de 2026 + +Implementado no mesmo projecto `C:\laragon\www\compras`, porta 8015, após consulta de procurement.docx (2.2, 2.3, 3.0, RF-AF-07/08, RNF-17/18, 6.6 e decisões de 10.0), documentação e módulos existentes. Preservadas as alterações locais, incluindo a actualização paralela da marca visível para SGC. Não foram alterados outros projectos nem recriadas contas. + +### Entregue + +- `/equipamentos`: cadastro de código, descrição, área/linha, departamento, série, estado, criticidade e artigo de stock opcional. Fichas de equipamento e peças podem ser actualizadas com versão obrigatória para impedir sobrescrita de uma ficha entretanto alterada. Cada criação/alteração gera auditoria com antes/depois. Não há eliminação pela interface. +- Criticidade configurada explicitamente por Administração: código, nome, critério escrito e indicação expressa de ser crítica ou não. **Sem categorias, critérios, escalões ou valores de mínimo predefinidos.** Os critérios são de acréscimo; criar outra categoria preserva o significado da anterior. Esta configuração não altera limites financeiros ou regras de aprovação. RF-AF-08 não foi dispensado: não foi disponibilizada edição de limites financeiros sem dupla aprovação administrativa. +- Cada peça reutiliza exactamente um artigo de stock, sem saldo separado. Referência, descrição e unidade vêm desse artigo; o artigo de uma peça existente não pode ser substituído. Mínimo explícito em milésimos, incluindo zero, e alerta visual quando a soma dos movimentos desse artigo nos armazéns da empresa fica **abaixo** do mínimo. Não é um mínimo por máquina/armazém. Estar abaixo do mínimo alerta, sem inventar uma proibição de consumo. +- Associações peça/equipamento dentro da empresa, permitindo a mesma peça em várias máquinas sem duplicar existências. Associações são de acréscimo e únicas por par; não há remoção destrutiva. Administração, Procurement e Armazém mantêm o cadastro; só Administração define categorias. Finanças consulta; chefes consultam os equipamentos, associações e saídas do seu departamento. As opções de requisição também respeitam o departamento. Só Armazém continua a executar movimentos. +- Nas saídas de artigos cadastrados como peça, o servidor exige equipamento de destino associado. A operação e o movimento guardam identificadores e snapshots de equipamento/peça no momento da saída. Alterar depois nome, mínimo ou criticidade não reescreve o movimento. Para outros artigos, equipamento de destino é opcional. Operações anteriores mantêm associações nulas, sem atribuições retroactivas inventadas. +- Requisições distinguem `equipment` e `spare_part`. Equipamento novo exige área/linha de produção. Peça exige cadastro, equipamento associado do departamento e unidade igual à do artigo. Peça crítica urgente exige início da paragem, registado explicitamente em UTC. Continua o circuito normal de três cotações; não foi criada dispensa automática por urgência. +- Validação técnica de equipamento na versão do rascunho, pelo responsável da área representado pelo perfil Chefe do departamento, distinto do solicitante. Aprovar ou rejeitar exige fundamento e fica num registo imutável, com hash da base, autor e snapshot. Pedido alterado invalida o aproveitamento da decisão, sem a apagar; formulário de decisão sobre versão antiga é recusado. Confirma-se novamente a elegibilidade e conflitos do revisor antes da submissão e das aprovações. Uma rejeição não permite submeter. Finanças integra o ciclo de aprovação de equipamento **para qualquer valor**, além dos escalões existentes. A validação técnica não substitui a aprovação financeira. +- **Adjudicação e emissão de equipamento continuam bloqueadas** porque o limite próprio de investimento e a sua regra de aplicação não foram definidos pela empresa. Não se inventou um valor nem se interpretou um limite indefinido como tecto ou autorização irrestrita. Nem validação técnica nem aprovação de Finanças removem este impedimento. As mensagens e ecrãs explicam o motivo. +- Peças com associações válidas seguem os escalões normais e o fluxo existente de cotações, adjudicação e ordens. A ordem congela os destinos e contexto de peça/equipamento; a recepção exige o artigo de stock autorizado e transmite os identificadores aos movimentos. A recepção não consome a peça na máquina: o consumo continua a exigir uma saída própria. O ensaio completo de compra, recepção e saída foi realizado apenas em fixtures isoladas. +- `/consulta/equipamentos`: equipamentos, peça crítica, alerta de mínimo, associações, histórico de saída e formulários fictícios de simulação. Não consulta nem grava dados de negócio. As novas opções de navegação preservam as entradas públicas e autenticadas anteriores. + +### Verificação e aplicação da migração + +- **262 testes aprovados, 3 ignorados, 2.327 asserções** na suite completa SQLite em memória. Inclui 28 casos de equipamentos/peças e um caso integrado adicional no conjunto de adjudicação/ordens: perfis, autoria, isolamento, associações, critérios/mínimos explícitos, versões, rastreabilidade congelada, validação técnica, rejeição, alteração de base, Finanças em qualquer valor, investimento bloqueado, início de paragem, rollback de auditoria e recepção/saída de peças. +- PHPStan, Pint, TypeScript e compilação de produção aprovados. Formatação/lint frontend e `git diff --check` verificados no fecho. Os resultados anteriores de outras entregas permanecem como histórico abaixo. +- **Sete cenários simultâneos PostgreSQL isolados aprovados**, incluindo o novo `spare_part_issue`: duas saídas para o equipamento associado disputam o saldo; uma confirma e outra é recusada por falta de stock. Os seis cenários anteriores também passaram com o novo esquema. Duas ligações independentes foram observadas simultaneamente à espera de bloqueio por cenário. A base temporária foi eliminada. Não é teste de carga nem validação de concorrência de todas as alterações do cadastro/decisões técnicas. [Evidência actualizada](stock-concorrencia-postgresql.json). +- Migração **`2026_10_28_000008_create_equipment_and_spare_parts` aplicada à base actual `compras_dev`, lote 9**. Todas as onze migrações executadas, sem pendentes. SQL revisto: apenas cria cinco tabelas novas e acrescenta colunas opcionais e chaves estrangeiras às tabelas existentes; não elimina, recria ou converte dados anteriores. `down()` recusa reversão destrutiva para preservar associações e decisões. +- Antes da aplicação: backup custom PostgreSQL 17 em `storage/app/private/backups/equipment-000008-20261028-113652-8b46ef/`, **177.950 bytes**, SHA-256 `3ff9f9a0f16975465ff008ff6ffaf34d9798d36e37fa946e44c6de39e62d6fdb`. Dump consistente, manifesto privado com hashes/contagens, cópia de ambiente e `RECUPERACAO.txt`. Restaurado com sucesso numa base nova `compras_restore_check_*`; as **32 tabelas** coincidiram em contagens e hashes. Base de verificação eliminada. Não havia documentos de fornecedor para copiar. O backup é privado, fora de `public/` e do Git; não publicar o seu conteúdo. +- Comparação imediatamente antes/depois da migração: todos os registos e colunas anteriores das 32 tabelas, e todas as restrições anteriores do esquema público, preservados. Mantêm-se 1 utilizador, 1 empresa, 1 departamento, 1 centro de custo e 1 registo de auditoria. A única nova linha foi a migração. As cinco tabelas novas e os movimentos/operações de stock permanecem vazios. **Sem seeds ou movimentos fictícios na base real.** [Evidência sem credenciais](equipamentos-migracao-000008.json). +- `.env` inalterado, SHA-256 `C9EE71EDBF3B4D303203DC851B9A15A9B687DEA1E83DD06B7B04B23054CA5BC5`. Conta, credenciais, configurações e porta preservadas. Os acessos HTTP normais podem criar/actualizar sessões; não são movimentos de negócio. +- `/consulta/equipamentos` respondeu HTTP 200; `/equipamentos` sem sessão redireccionou para login (302). **Revisão visual pública e autenticada continuam pendentes**: nova descoberta do navegador devolveu `The browser access host is unavailable`. Não foi possível inspeccionar a sessão existente nem validar visualmente os ecrãs. Testes HTTP/Inertia e compilação não são apresentados como revisão visual. + +### Limitações e decisões ainda necessárias + +**O orçamento ainda não é validado; os documentos de desenvolvimento não autorizam compras reais.** O limite próprio de investimento, a sua aplicação e eventuais escalões/documentos próprios para peças críticas precisam de decisão empresarial antes de desbloquear os caminhos correspondentes. Não há configuração fictícia de limite com semântica presumida. Regras de urgência/fornecedor único em escalão superior continuam sem excepção implementada; pedidos urgentes não dispensam os controlos normais. + +Os critérios e mínimos têm de ser registados pelos perfis autorizados com base nas decisões da empresa. Não há classificação automática por texto ou correspondência automática de descrições de compras a artigos; a associação é explícita. O cadastro não cria entradas de stock, não capitaliza activos e não comprova a aquisição de um equipamento existente. Não há remoção/revisão de compatibilidades, reconciliação automática do legado, manutenção preventiva, notificações externas de mínimo, configuração de períodos de reposição/consumo anómalo ou relatório completo dos indicadores financeiros/paragens de 6.6. O alerta de mínimo é consultado no ecrã de equipamentos. Não foi acrescentada uma separação entre revisor técnico e aprovador departamental que a especificação não exige; mantêm-se a exclusão do solicitante e a segregação entre pessoas nas etapas financeiras existentes. + +## Actualização da marca — 28 de Outubro de 2026 + +Nome visível actualizado para **Sistema de Gestão de Compras (SGC)** no início, entrada, navegação, demonstração, títulos e impressão. A documentação foi alinhada. Preservados os identificadores técnicos, incluindo `procuraflow_session`, a conta, porta 8015, base de dados, APP_KEY e `.env`. Esta alteração não aplica migrações nem modifica dados de negócio. Revisão visual continua pendente. + +Verificação desta correcção: 13 testes dirigidos aprovados, 185 asserções; frontend sem erros de lint, TypeScript e compilação aprovados; `git diff --check` aprovado. Pesquisa das fontes e documentação não encontrou a marca antiga em textos visíveis; só permanece o identificador técnico de sessão. SHA-256 do `.env` inalterado. + +## Fecho da entrega de stock: migração aplicada — 28 de Outubro de 2026 + +A migração `2026_10_28_000007_create_stock_operations` foi aplicada à base actual `compras_dev`, **lote 8**, após autorização expressa para concluir a entrega. `migrate:status` confirma todas as dez migrações executadas, sem pendentes. Não se avançou para equipamentos. As alterações locais sem commit foram preservadas. + +- Revisão do PHP, do SQL de `migrate --pretend` e do esquema real: cria apenas a nova tabela `stock_operations`; acrescenta `stock_operation_id` opcional e respectivas restrições; torna opcional `goods_receipt_item_id`. No PostgreSQL, `quantity_milli` já era `bigint` assinado e permanece desse tipo, sem conversão estreitante. As cláusulas geradas de remoção de default/identity/comentário para essas duas colunas não removem metadados existentes: ambos já eram nulos/ausentes. A chave primária e a sua sequência não são alteradas. Todas as chaves estrangeiras e a unicidade da linha de recepção foram confirmadas após a aplicação. Não houve DROP/TRUNCATE/DELETE de tabelas ou registos existentes, nem recriação de tabelas. A restrição NOT NULL da recepção é relaxada para permitir os novos movimentos. A reversão `down()` continua bloqueada para preservar histórico. +- **Backup recuperável preparado e ensaiado antes da aplicação**, em `storage/app/private/backups/stock-000007-20261028-111943-4deef2/`. `database.dump`, formato custom PostgreSQL 17, 169.365 bytes; SHA-256 `5df038437e94870e80d1fab61e7d53223043b4167ded71eb52703db1c4d47942`. O dump usa um snapshot exportado consistente. A pasta privada contém também `manifest.json`, cópia do ambiente original e `RECUPERACAO.txt`; está excluída do Git e fora de `public/`. Não publicar o seu conteúdo. Não existiam documentos de fornecedor para copiar. +- O dump foi restaurado com `pg_restore --exit-on-error` numa base nova e exclusiva `compras_restore_check_*`. Contagens e hashes SHA-256 dos registos das **31 tabelas** coincidiram com a origem. A base de verificação foi eliminada; nenhuma restauração foi executada sobre a base actual. O dump inclui esquema, dados, sequências e ownership/ACL; as roles globais são as existentes no servidor. As instruções locais explicam a recuperação numa base nova, sem `--clean` e sem reversão destrutiva da base actual. +- Imediatamente antes e depois da migração, comparação de todas as linhas e colunas anteriores das 31 tabelas: **preservação integral**, incluindo utilizador, credenciais, empresa, equipa, auditoria, sessões e configuração persistida. A única linha adicional nesse momento foi o registo da migração, passando de 9 para 10. Mantiveram-se 1 utilizador, 1 empresa, 1 departamento, 1 centro de custo e 1 entrada de auditoria. Requisições, fornecedores, ordens, recepções, artigos e armazéns mantiveram zero registos; `stock_movements` e a nova `stock_operations` continuam com **zero movimentos/operações**. Não houve seeds ou dados fictícios. Os acessos HTTP subsequentes podem criar/actualizar sessões normais; não são movimentos de negócio. +- `.env` original inalterado, SHA-256 `C9EE71EDBF3B4D303203DC851B9A15A9B687DEA1E83DD06B7B04B23054CA5BC5`. Não se alteraram contas, credenciais, porta 8015 ou outros projectos. A aplicação usou apenas a migração 000007, com limites de espera de bloqueio e execução no processo de manutenção. +- `/consulta/operacoes-stock` respondeu HTTP 200. A rota operacional mantém redireccionamento para login quando não há sessão no cliente HTTP. **Revisão visual pública e autenticada continuam pendentes**: nesta tentativa, a descoberta do navegador devolveu `The browser access host is unavailable`, sem acesso às abas ou à sessão autenticada. Não foi apresentada a resposta HTTP como revisão visual. Não se efectuou login nem se alteraram credenciais. + +Evidência sem credenciais em [stock-migracao-000007.json](stock-migracao-000007.json). O manifesto privado conserva os hashes e contagens por tabela. Os resultados anteriores de 233 testes, análise estática e seis cenários PostgreSQL continuam documentados abaixo; não foram repetidos nesta aplicação, que não alterou código funcional. A entrega está disponível no esquema actual, sujeita às permissões existentes; a aceitação visual permanece pendente. + +**O orçamento continua sem validação.** Permanecem por decidir periodicidade, tolerâncias, investigação/resolução de divergências e eventual aprovação de ajustes. Equipamentos não foram iniciados. + +## Implementação de saídas, transferências e inventários — 28 de Outubro de 2026 + +Consultadas as secções 2.2, 4.0, RF-AF-11, 5.3, RNF-18 e decisões da secção 10.0 de procurement.docx, README, compatibilidade e orientações de continuidade. Implementação no mesmo Laravel, porta 8015, sobre o catálogo, armazéns, movimentos e auditoria existentes. As alterações locais anteriores permanecem sem commit; recepção não foi reimplementada. As secções seguintes conservam o histórico das entregas anteriores. + +- `/stock/operacoes`: saídas com destino, responsável da mesma empresa, documento único e fundamento obrigatório; transferências entre armazéns distintos da mesma empresa com débito e crédito atómicos. O saldo continua a ser a soma exacta dos movimentos em milésimos, agora com sinais. A listagem de stock apresenta os sinais, referências e ligações adequados para todos os movimentos. +- Inventários por artigo/armazém: contagem física, incluindo zero, saldo teórico, versão do histórico e diferença preservados. A contagem não altera existências. Um ajuste separado exige fundamento e contagem compatível ainda não ajustada, e lança exactamente a diferença. Divergência zero não gera movimento. Movimentos posteriores, incluindo recepções, invalidam a contagem para ajuste; o operador deve actualizar e repetir a contagem física. Formulários de contagem sobre uma versão antiga também são recusados. +- Apenas Recepção / Armazém executa estas operações, conforme a especificação; Administração, Procurement e Finanças consultam na própria empresa. Administração não substitui o perfil operacional. Autor real e responsável indicado ficam separados no snapshot. Não foi criada aprovação por Finanças, dupla contagem obrigatória ou proibição de o operador ser responsável: essas regras não constam da especificação para estes movimentos. As segregações de requisição/aprovação, emissão/recepção e recepção/pagamento dos módulos existentes mantêm-se. +- Bloqueio inicial da empresa partilhado com recepções, transacção única, unicidade de referência por empresa, chave idempotente com comparação de conteúdo e unicidade da contagem ajustada. Repetições idênticas devolvem o mesmo registo; referência repetida com nova chave ou chave com conteúdo diferente são recusadas. Falhar o crédito da transferência ou a auditoria reverte também o débito e a operação. Os modelos impedem edição/eliminação do histórico; SQL administrativo continua dependente das permissões da base. +- Migração aditiva `2026_10_28_000007_create_stock_operations`: nova tabela de operações, ligação opcional ao movimento, recepção opcional para novos tipos e quantidades assinadas. Conserva linhas existentes e as restrições de unicidade de recepção. Inicialmente testada apenas em bases isoladas; **aplicada posteriormente à base actual, lote 8, após backup com recuperação ensaiada**, conforme o fecho acima. A reversão destrutiva é explicitamente bloqueada para não converter movimentos negativos em quantidades sem sinal. +- `/consulta/operacoes-stock`: exemplos fictícios de saída, transferência, contagem e ajuste, formulário com alternância entre operações e simulação sem escrita ou leitura de negócio. Reutiliza a interface operacional. Ligação nos menus de armazém e na listagem de stock. + +### Verificação efectuada neste incremento + +- Suite completa: **233 testes aprovados, 3 ignorados, 2.163 asserções**, SQLite em memória. Os **25 testes novos** cobrem quantidades exactas, falta de saldo, idempotência incluindo formulários, referências repetidas, transferências, ajustes positivos/negativos/zero, contagens desactualizadas, perfis, recursos e consultas entre empresas, imutabilidade, demonstração e rollback de auditoria e da segunda metade da transferência. +- PHPStan, Pint e TypeScript aprovados; compilação de produção aprovada. `npm run check` aprovado em todos os ficheiros verificados, sem avisos ou erros; `git diff --check` sem erros. A formatação da evidência anterior de recepção foi normalizada, preservando o seu conteúdo. +- **Seis cenários simultâneos em PostgreSQL isolado aprovados**: excesso de saídas, mesma saída, mesma transferência, transferências opostas, mesmo ajuste e contagem desactualizada. Dois processos PHP independentes por cenário executam o kernel HTTP com duas ligações observadas simultaneamente à espera de bloqueio. Validam saldos e integridade das duas pernas das transferências. Isto não é teste de carga ou ensaio TCP. Evidência: [stock-concorrencia-postgresql.json](stock-concorrencia-postgresql.json); execução: `php tests/Concurrency/run-stock.php`. +- O ensaio reutiliza fixtures protegidas pelo prefixo `compras_receipts_test_*`, verifica a base e o alias de esquema antes de migrar, cria uma base exclusiva e elimina-a no final. A primeira tentativa revelou interferência entre dois aliases do processo de preparação; foi cancelada apenas a consulta nessa base temporária, que foi eliminada. A preparação usa agora um único alias, mantendo processos independentes para a concorrência. Outra tentativa identificou reutilização indevida da chave da contagem na fixture de ajuste; corrigida antes da execução aprovada. Nenhum destes resultados incompletos é apresentado como aprovação. +- HTTP 200 em `http://127.0.0.1:8015/consulta/operacoes-stock`. Testes Inertia confirmam propriedades públicas sem identidade nem dados de negócio. **Revisão visual pública não concluída:** a skill computer-use foi tentada, mas o serviço interrompeu o controlo por não conseguir determinar o URL do navegador com confiança. Revisão visual autenticada continua separadamente pendente; não foram pedidas nem alteradas credenciais. +- Sem seeds, migrações ou fixtures na base real. Contagem de utilizadores reais inalterada durante o ensaio. `.env` conserva SHA-256 `C9EE71EDBF3B4D303203DC851B9A15A9B687DEA1E83DD06B7B04B23054CA5BC5`; conta, configuração e outros projectos preservados. + +### Limitações e decisões necessárias + +**O orçamento ainda não é validado.** A interface mantém o aviso de desenvolvimento, sem autorizar compras reais. A migração 000007 já está aplicada; a revisão visual autenticada permanece pendente. + +A empresa deve definir periodicidade de inventários, tolerâncias, quem investiga e resolve divergências e eventual circuito de aprovação dos ajustes. O ajuste aqui regista uma correcção fundamentada por Armazém; não equivale a aprovação financeira nem ao encerramento de uma investigação. Todas as diferenças são mostradas sem tolerância presumida. Não há aprovação automática por escalão, alertas de consumo anómalo com limites inventados, mínimos, reposição, equipamento/peça associado, conversões, devoluções, cancelamento ou edição de movimentos. O destino da saída é texto obrigatório; não valida uma requisição/equipamento nem substitui os futuros alertas RNF-18 para saídas sem essas ligações. Inventários são contagens pontuais por artigo/local, sem sessões de contagem em lote ou congelamento do armazém. Referências distintas e chaves distintas representam operações distintas; não há deduplicação heurística de documentos externos. Bloqueios serializam escritas por empresa; desempenho e carga não foram medidos. + +## Incremento actual: recepção, stock e navegação + +Implementado em 28 de Outubro de 2026, consultando procurement.docx, o mapa de compatibilidade e as orientações de continuidade. Os incrementos anteriores abaixo ficam como registo histórico; os seus números de testes e pendências descrevem a data respectiva. + +- Página inicial própria em `/`, com acesso à demonstração e à entrada. A navegação autenticada está agrupada por área e perfil. A consulta pública passou a ter endereços por módulo, incluindo `/consulta/recepcoes` e `/consulta/stock`, preservando a página ao actualizar e navegar para trás. Os exemplos são fictícios, sem consultas a dados de negócio ou gravações. +- Recepções parciais e totais de ordens emitidas, quantidades recebidas, aceites, rejeitadas e pendentes por linha. Conferência conforme, com reservas ou não conforme; divergências exigem fundamento. O estado é derivado das recepções, sem reescrever a ordem congelada. +- Apenas Armazém confirma; o emitente da ordem fica impedido mesmo após mudar de perfil. `ConflictGuard` revalida a participação e os intervenientes anteriores. Consultas respeitam empresa, perfil e, para chefes, departamento; o formulário de Armazém não expõe os valores financeiros da ordem. +- Quantidades exactas em milésimos, sem aritmética de ponto flutuante. O recebido acumulado, incluindo rejeitados, não ultrapassa a quantidade autorizada. Uma rejeição não autoriza automaticamente uma substituição: esta política permanece por definir. O ecrã distingue falta de entrega de falta de aceitação. +- Catálogo de artigos e armazéns por empresa. Só as quantidades físicas aceites originam entradas, com unidade compatível e ligação única à linha da recepção. Serviços não movimentam stock. Existências calculadas a partir dos movimentos, com pesquisa e histórico. +- Transacções, bloqueios, chave de confirmação e unicidade da guia por ordem evitam duplicações. Repetir uma confirmação idêntica devolve o registo existente; alterar o conteúdo da mesma chave é bloqueado. Falhar a auditoria reverte toda a operação. Não existem rotas de edição/eliminação do histórico; os modelos também recusam essas alterações. A protecção contra SQL administrativo directo depende das permissões da base de dados. +- A migração aditiva `2026_10_28_000006_create_receipts_and_stock` está aplicada. Conta, configuração e módulos anteriores preservados; `.env` mantém o SHA-256 `C9EE71EDBF3B4D303203DC851B9A15A9B687DEA1E83DD06B7B04B23054CA5BC5`. Não foram executados seeds na base real, recriadas contas ou alterados SGF Tempo/Investmoz. + +### Resultados verificados + +- Suite Laravel: **208 passaram, 3 ignorados, 2.038 asserções**, em SQLite em memória. Inclui permissões, isolamento, segregação, limites, qualidade, repetição, auditoria e navegação pública. +- PHPStan sem erros; Pint, verificação/formatação frontend, TypeScript e compilação aprovados. `/`, `/consulta/recepcoes` e `/consulta/stock` responderam HTTP 200 na porta 8015. +- **Quatro cenários de concorrência real PostgreSQL passaram**: excesso acumulado, repetição da mesma confirmação, quantidades complementares e guia repetida. Processos PHP independentes executaram pedidos pelo kernel HTTP Laravel, com duas ligações simultaneamente à espera de bloqueio em cada cenário. Não se trata de teste de carga TCP. Evidência: [recepcao-concorrencia-postgresql.json](recepcao-concorrencia-postgresql.json). +- A base temporária foi criada com nome exclusivo `compras_receipts_test_*`, verificada antes das migrações e eliminada no final. Fixtures recusam bases de negócio. Uma primeira execução detectou uma ligação Schema em cache e falhou por tabela já existente; o isolamento foi corrigido para ambas as ligações e verificado antes de migrar, sem recriar tabelas existentes. A contagem de utilizadores reais permaneceu igual. + +### Limitações e próximo passo + +**O orçamento ainda não é validado.** Ordens e recepções continuam identificadas como desenvolvimento e não autorizam compras reais. + +Revisão visual pública, autenticada e de impressão continua pendente: o serviço de controlo do navegador está indisponível. Os quatro cenários de concorrência de recepção foram efectivamente realizados; concorrência dos outros módulos e testes de carga permanecem pendentes. + +Não foram implementados saídas, transferências, inventários, mínimos, devoluções/substituições, conversões de unidade ou anexos da recepção. Equipamentos e peças continuam dependentes das regras técnicas aprovadas. Próximo incremento funcional: orçamento e facturas, após definição das respectivas regras; completar os controlos de stock conforme prioridade da empresa. Não foram inventadas tolerâncias, excepções ou autorizações de substituição. ## Incremento actual de adjudicação e ordens de compra @@ -36,7 +162,7 @@ Implementado em 28 de Outubro de 2026 no projecto Laravel existente, segundo a s - PHPStan aprovado, sem erros; Pint aprovado em todos os ficheiros PHP; verificação frontend sem avisos ou erros; TypeScript aprovado; compilação aprovada; `git diff --check` sem erros. - Migração aditiva `2026_10_28_000005_create_purchase_awards_and_orders` aplicada no PostgreSQL local após os testes. Contagens antes/depois iguais: 1 utilizador, 1 empresa e 0 requisições, fornecedores, cotações, avaliações, documentos e declarações de conflito. Novas tabelas de adjudicações, aprovações finais, ordens e linhas vazias. Não foram criados registos sintéticos na base real. - O SHA-256 do `.env` manteve-se igual antes e depois. Não houve seeds, alterações de credenciais ou configuração, nem reinicialização de dados. -- As rotas de ordens/adjudicação estão registadas. O acesso sem autenticação a `/ordens` redireccionou para a entrada ProcuraFlow em 8015, com HTTP 200. As páginas autenticadas e o conteúdo da impressão foram verificados em testes HTTP/Inertia, não visualmente. +- As rotas de ordens/adjudicação estão registadas. O acesso sem autenticação a `/ordens` redireccionou para a entrada SGC em 8015, com HTTP 200. As páginas autenticadas e o conteúdo da impressão foram verificados em testes HTTP/Inertia, não visualmente. Foram detectadas alterações paralelas na pré-visualização pública, na entrada e nas respectivas rotas/testes; foram preservadas e incluídas na verificação final, sem as apresentar como parte desta implementação. As alterações funcionais deste incremento permanecem limitadas à adjudicação/ordens e à integração necessária com os módulos anteriores. @@ -83,7 +209,7 @@ Declarações pessoais de conflito de interesses na base Laravel e React existen - 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. +- As quatro rotas de conflitos estão registadas. O acesso HTTP sem sessão a `/conflitos` redireccionou para a entrada SGC na porta 8015, que respondeu HTTP 200. Isto não constitui revisão visual autenticada. ### Pendências e próximo passo @@ -124,7 +250,7 @@ Migração `2026_10_28_000003_add_supplier_maintenance_and_documents`; `Supplier - 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. +- Entrada em http://127.0.0.1:8015/login confirmada com HTTP 200 e identificação SGC; 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. diff --git a/docs/equipamentos-migracao-000008.json b/docs/equipamentos-migracao-000008.json new file mode 100644 index 0000000..c1cbed9 --- /dev/null +++ b/docs/equipamentos-migracao-000008.json @@ -0,0 +1,64 @@ +{ + "verified_at_utc": "2026-10-28T11:38:12+00:00", + "database": "compras_dev", + "migration": { + "migration": "2026_10_28_000008_create_equipment_and_spare_parts", + "batch": 9 + }, + "backup_directory": "storage\/app\/private\/backups\/equipment-000008-20261028-113652-8b46ef", + "backup_bytes": 177950, + "backup_sha256": "3ff9f9a0f16975465ff008ff6ffaf34d9798d36e37fa946e44c6de39e62d6fdb", + "restore_verified": true, + "restored_tables": 32, + "restore_database_dropped": true, + "existing_records_unchanged": true, + "existing_constraints_preserved": true, + "env_unchanged": true, + "stock_operations_count": 0, + "stock_movements_count": 0, + "existing_table_counts": { + "approval_steps": 0, + "audit_logs": 1, + "cache": 6, + "cache_locks": 0, + "conflict_declarations": 0, + "cost_centers": 1, + "departments": 1, + "failed_jobs": 0, + "goods_receipt_items": 0, + "goods_receipts": 0, + "job_batches": 0, + "jobs": 0, + "migrations": 10, + "password_reset_tokens": 0, + "proposal_evaluations": 0, + "purchase_award_approvals": 0, + "purchase_awards": 0, + "purchase_order_items": 0, + "purchase_orders": 0, + "quotation_items": 0, + "quotations": 0, + "requisition_items": 0, + "requisitions": 0, + "sessions": 16, + "stock_items": 0, + "stock_movements": 0, + "stock_operations": 0, + "supplier_documents": 0, + "suppliers": 0, + "tenants": 1, + "users": 1, + "warehouses": 0 + }, + "migration_rows_added": 1, + "new_table_counts": { + "equipment": 0, + "spare_parts": 0, + "criticality_levels": 0, + "equipment_spare_parts": 0, + "technical_reviews": 0 + }, + "budget_checked": false, + "visual_public_review": "pending_browser_access_host_unavailable", + "visual_authenticated_review": "pending_no_access_to_browser_session" +} diff --git a/docs/orientacoes-continuidade.md b/docs/orientacoes-continuidade.md index 45700bd..7096313 100644 --- a/docs/orientacoes-continuidade.md +++ b/docs/orientacoes-continuidade.md @@ -1,4 +1,33 @@ -# Continuidade do desenvolvimento do ProcuraFlow +# Continuidade do desenvolvimento do SGC + +## Estado actual após equipamentos e peças + +Recepção, stock, saídas, transferências, contagens, ajustes, equipamentos, +peças e validação técnica já estão implementados. As instruções anteriores +abaixo são históricas e não devem originar implementação duplicada. +O relatório actual está em `docs/desenvolvimento.md`. A revisão visual pública, +autenticada e de impressão continua pendente por indisponibilidade do controlo +do navegador. Antes de avançar para orçamento e facturas, definir as regras +empresariais correspondentes. Preservar as contas e os dados existentes. + +## Próximo incremento após adjudicação e ordens + +O utilizador já recebeu o relatório de adjudicação e ordens (165 testes +reportados como aprovados). Não repetir essa implementação. As secções +anteriores de adjudicação abaixo ficam como histórico. + +Continuar com recepção e stock segundo procurement.docx: recepções parciais e +totais, conferência de qualidade, divergências, quantidades pendentes e movimentos +auditados. Aplicar segregação do emitente, permissões e isolamento por empresa; +impedir duplicação e quantidades superiores às autorizadas. Não inventar +políticas comerciais, tolerâncias ou autorizações de excepção. + +Validar concorrência PostgreSQL em ambiente de teste separado, sem recriar a +base real. Actualizar a consulta livre com exemplos fictícios dos novos ecrãs. +Preservar conta, dados, configuração e módulos concluídos. Manter explícita a +ausência de validação orçamental e a restrição a documentos de desenvolvimento. +Actualizar o relatório e o mapa de compatibilidade, que ainda apresenta ordens +como ausentes. Se este incremento já estiver iniciado, continuar sem duplicação. ## Entrada livre para revisão visual diff --git a/docs/recepcao-concorrencia-postgresql.json b/docs/recepcao-concorrencia-postgresql.json new file mode 100644 index 0000000..0e1f22e --- /dev/null +++ b/docs/recepcao-concorrencia-postgresql.json @@ -0,0 +1,111 @@ +{ + "tested_at_utc": "2026-10-28T10:56:15+00:00", + "database": "compras_receipts_test_84e8492cd8f3", + "separate_database": true, + "transport": "Laravel HTTP kernel in independent PHP processes; real PostgreSQL connections", + "scenarios": [ + { + "name": "over_quantity", + "passed": true, + "overlapping_lock_waits": 2, + "responses": [ + { + "status": 201, + "id": 1, + "created": true, + "error_fields": [], + "backend_pid": 4688 + }, + { + "status": 422, + "id": null, + "created": null, + "error_fields": ["items"], + "backend_pid": 2068 + } + ], + "receipts": 1, + "stock_movements": 1, + "quantity_milli": 600, + "order_state": "partial" + }, + { + "name": "same_confirmation", + "passed": true, + "overlapping_lock_waits": 2, + "responses": [ + { + "status": 201, + "id": 2, + "created": true, + "error_fields": [], + "backend_pid": 9064 + }, + { + "status": 200, + "id": 2, + "created": false, + "error_fields": [], + "backend_pid": 1036 + } + ], + "receipts": 1, + "stock_movements": 1, + "quantity_milli": 600, + "order_state": "partial" + }, + { + "name": "complementary_quantities", + "passed": true, + "overlapping_lock_waits": 2, + "responses": [ + { + "status": 201, + "id": 4, + "created": true, + "error_fields": [], + "backend_pid": 3020 + }, + { + "status": 201, + "id": 3, + "created": true, + "error_fields": [], + "backend_pid": 24624 + } + ], + "receipts": 2, + "stock_movements": 2, + "quantity_milli": 1000, + "order_state": "complete" + }, + { + "name": "same_delivery_note", + "passed": true, + "overlapping_lock_waits": 2, + "responses": [ + { + "status": 422, + "id": null, + "created": null, + "error_fields": ["delivery_note"], + "backend_pid": 22120 + }, + { + "status": 201, + "id": 5, + "created": true, + "error_fields": [], + "backend_pid": 4720 + } + ], + "receipts": 1, + "stock_movements": 1, + "quantity_milli": 600, + "order_state": "partial" + } + ], + "passed": true, + "test_database_dropped": true, + "live_user_count_unchanged": true +} diff --git a/docs/stock-concorrencia-postgresql.json b/docs/stock-concorrencia-postgresql.json new file mode 100644 index 0000000..22b48bd --- /dev/null +++ b/docs/stock-concorrencia-postgresql.json @@ -0,0 +1,172 @@ +{ + "tested_at_utc": "2026-10-28T11:35:31+00:00", + "database": "compras_receipts_test_d97ad8ad1eb7", + "separate_database": true, + "transport": "Laravel HTTP kernel in independent PHP processes; real PostgreSQL connections", + "scenarios": [ + { + "name": "over_issue", + "passed": true, + "overlapping_lock_waits": 2, + "responses": [ + { + "status": 201, + "id": 1, + "created": true, + "error_fields": [], + "backend_pid": 5004 + }, + { + "status": 422, + "id": null, + "created": null, + "error_fields": ["quantity"], + "backend_pid": 27024 + } + ], + "source_milli": 400, + "destination_milli": 0 + }, + { + "name": "same_issue", + "passed": true, + "overlapping_lock_waits": 2, + "responses": [ + { + "status": 201, + "id": 2, + "created": true, + "error_fields": [], + "backend_pid": 7640 + }, + { + "status": 200, + "id": 2, + "created": false, + "error_fields": [], + "backend_pid": 22852 + } + ], + "source_milli": 400, + "destination_milli": 0 + }, + { + "name": "same_transfer", + "passed": true, + "overlapping_lock_waits": 2, + "responses": [ + { + "status": 201, + "id": 3, + "created": true, + "error_fields": [], + "backend_pid": 25232 + }, + { + "status": 200, + "id": 3, + "created": false, + "error_fields": [], + "backend_pid": 28856 + } + ], + "source_milli": 400, + "destination_milli": 600 + }, + { + "name": "opposing_transfers", + "passed": true, + "overlapping_lock_waits": 2, + "responses": [ + { + "status": 201, + "id": 4, + "created": true, + "error_fields": [], + "backend_pid": 5268 + }, + { + "status": 422, + "id": null, + "created": null, + "error_fields": ["quantity"], + "backend_pid": 4288 + } + ], + "source_milli": 400, + "destination_milli": 600 + }, + { + "name": "same_adjustment", + "passed": true, + "overlapping_lock_waits": 2, + "responses": [ + { + "status": 200, + "id": 6, + "created": false, + "error_fields": [], + "backend_pid": 13608 + }, + { + "status": 201, + "id": 6, + "created": true, + "error_fields": [], + "backend_pid": 2320 + } + ], + "source_milli": 800, + "destination_milli": 0 + }, + { + "name": "stale_adjustment", + "passed": true, + "overlapping_lock_waits": 2, + "responses": [ + { + "status": 422, + "id": null, + "created": null, + "error_fields": ["count_id"], + "backend_pid": 25516 + }, + { + "status": 422, + "id": null, + "created": null, + "error_fields": ["count_id"], + "backend_pid": 23640 + } + ], + "source_milli": 900, + "destination_milli": 0 + }, + { + "name": "spare_part_issue", + "passed": true, + "overlapping_lock_waits": 2, + "responses": [ + { + "status": 201, + "id": 9, + "created": true, + "error_fields": [], + "backend_pid": 18244 + }, + { + "status": 422, + "id": null, + "created": null, + "error_fields": ["quantity"], + "backend_pid": 2452 + } + ], + "source_milli": 400, + "destination_milli": 0 + } + ], + "passed": true, + "test_database_dropped": true, + "live_user_count_unchanged": true +} diff --git a/docs/stock-migracao-000007.json b/docs/stock-migracao-000007.json new file mode 100644 index 0000000..701d783 --- /dev/null +++ b/docs/stock-migracao-000007.json @@ -0,0 +1,56 @@ +{ + "verified_at_utc": "2026-10-28T11:21:06+00:00", + "database": "compras_dev", + "migration": { + "migration": "2026_10_28_000007_create_stock_operations", + "batch": 8 + }, + "backup_directory": "storage\/app\/private\/backups\/stock-000007-20261028-111943-4deef2", + "backup_bytes": 169365, + "backup_sha256": "5df038437e94870e80d1fab61e7d53223043b4167ded71eb52703db1c4d47942", + "restore_verified": true, + "restored_tables": 31, + "restore_database_dropped": true, + "existing_records_unchanged": true, + "existing_constraints_preserved": true, + "env_unchanged": true, + "stock_operations_count": 0, + "stock_movements_count": 0, + "existing_table_counts": { + "approval_steps": 0, + "audit_logs": 1, + "cache": 6, + "cache_locks": 0, + "conflict_declarations": 0, + "cost_centers": 1, + "departments": 1, + "failed_jobs": 0, + "goods_receipt_items": 0, + "goods_receipts": 0, + "job_batches": 0, + "jobs": 0, + "migrations": 9, + "password_reset_tokens": 0, + "proposal_evaluations": 0, + "purchase_award_approvals": 0, + "purchase_awards": 0, + "purchase_order_items": 0, + "purchase_orders": 0, + "quotation_items": 0, + "quotations": 0, + "requisition_items": 0, + "requisitions": 0, + "sessions": 18, + "stock_items": 0, + "stock_movements": 0, + "supplier_documents": 0, + "suppliers": 0, + "tenants": 1, + "users": 1, + "warehouses": 0 + }, + "migration_rows_added": 1, + "budget_checked": false, + "visual_public_review": "pending_browser_access_host_unavailable", + "visual_authenticated_review": "pending_no_access_to_browser_session" +} diff --git a/resources/js/app.tsx b/resources/js/app.tsx index e42ad38..9282318 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -6,13 +6,14 @@ import AppLayout from '@/layouts/app-layout'; import AuthLayout from '@/layouts/auth-layout'; import SettingsLayout from '@/layouts/settings/layout'; -const appName = 'ProcuraFlow'; +const appName = 'SGC'; void createInertiaApp({ title: (title) => (title ? `${title} - ${appName}` : appName), layout: (name) => { switch (true) { case name === 'welcome': + case name === 'home': case name.startsWith('preview/'): return null; case name.startsWith('auth/'): diff --git a/resources/js/components/app-logo.tsx b/resources/js/components/app-logo.tsx index 9e19c41..d76a0a7 100644 --- a/resources/js/components/app-logo.tsx +++ b/resources/js/components/app-logo.tsx @@ -8,7 +8,7 @@ export default function AppLogo() {
- ProcuraFlow + SGC
diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 9d2c814..a828fd2 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -7,6 +7,8 @@ import { Scale, ShieldAlert, FileCheck2, + PackageCheck, + Warehouse, } from 'lucide-react'; import AppLogo from '@/components/app-logo'; import { NavMain } from '@/components/nav-main'; @@ -23,42 +25,89 @@ import { import { dashboard } from '@/routes'; import type { NavItem } from '@/types'; -const mainNavItems: NavItem[] = [ +const generalNavItems: NavItem[] = [ { title: 'Visão geral', href: dashboard(), icon: LayoutGrid, }, - { title: 'Requisições', href: '/requisicoes', icon: ClipboardList }, { title: 'Conflitos de interesses', href: '/conflitos', icon: ShieldAlert }, ]; export function AppSidebar() { const { auth } = usePage().props; - const purchasingItems = [ + const canReadPurchases = [ '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 = + ].includes(auth.user.purchasing_role ?? ''); + const purchasingItems: NavItem[] = [ + { title: 'Requisições', href: '/requisicoes', icon: ClipboardList }, + ...(canReadPurchases + ? [ + { title: 'Fornecedores', href: '/fornecedores', icon: Users }, + { title: 'Cotações', href: '/cotacoes', icon: Scale }, + { + title: 'Ordens de compra', + href: '/ordens', + icon: FileCheck2, + }, + ] + : []), + ]; + const inventoryItems = [ + ...([ + 'admin', + 'procurement', + 'finance', + 'warehouse', + 'department_head', + ].includes(auth.user.purchasing_role ?? '') + ? [{ title: 'Recepção', href: '/recepcoes', icon: PackageCheck }] + : []), + ...([ + 'admin', + 'procurement', + 'finance', + 'warehouse', + 'department_head', + ].includes(auth.user.purchasing_role ?? '') + ? [ + { + title: 'Equipamentos e peças', + href: '/equipamentos', + icon: Warehouse, + }, + ] + : []), + ...(['admin', 'procurement', 'finance', 'warehouse'].includes( + auth.user.purchasing_role ?? '', + ) + ? [ + { + title: 'Stock e movimentos', + href: '/stock', + icon: Warehouse, + }, + { + title: 'Saídas e inventários', + href: '/stock/operacoes', + icon: ClipboardList, + }, + ] + : []), + ]; + const administrationItems = auth.user.purchasing_role === 'admin' ? [ - ...purchasingItems, { title: 'Empresa e equipa', href: '/empresa', icon: Building2, }, ] - : purchasingItems; + : []; return ( @@ -74,10 +123,26 @@ export function AppSidebar() { - + + + {inventoryItems.length > 0 && ( + + )} + {administrationItems.length > 0 && ( + + )} + + Página inicial + diff --git a/resources/js/components/inventory.tsx b/resources/js/components/inventory.tsx new file mode 100644 index 0000000..d86f869 --- /dev/null +++ b/resources/js/components/inventory.tsx @@ -0,0 +1,85 @@ +import { Link } from '@inertiajs/react'; +import type { Page, ReceiptProgress } from '@/types/inventory'; +import { quantity, receiptStates } from '@/types/inventory'; + +export function InventoryNotice() { + return ( +
+ Ambiente de desenvolvimento. Não autoriza compras reais.{' '} + O orçamento ainda não é validado. +
+ ); +} +export function ReceiptProgressTable({ + progress, +}: { + progress: ReceiptProgress; +}) { + return ( + <> +

+ {receiptStates[progress.state]} + {progress.has_divergences + ? ' · Existem divergências de qualidade' + : ''} +

+
+ + + + + + + + + + + + + + {progress.lines.map((line) => ( + + + {[ + line.ordered_milli, + line.received_milli, + line.accepted_milli, + line.rejected_milli, + line.delivery_pending_milli, + line.acceptance_pending_milli, + ].map((value, index) => ( + + ))} + + ))} + +
ArtigoAutorizadoRecebidoAceiteRejeitadoPor entregarPor aceitar
+ {line.description} +

+ {line.unit} +

+
+ {quantity(value)} +
+
+ + ); +} +export function InventoryPagination({ page }: { page: Page }) { + return ( +
+ {page.prev_page_url && ( + Anterior + )} + {page.next_page_url && ( + Seguinte + )} +
+ ); +} diff --git a/resources/js/components/nav-main.tsx b/resources/js/components/nav-main.tsx index 62fcc6f..80fbd29 100644 --- a/resources/js/components/nav-main.tsx +++ b/resources/js/components/nav-main.tsx @@ -7,20 +7,36 @@ import { SidebarMenuItem, } from '@/components/ui/sidebar'; import { useCurrentUrl } from '@/hooks/use-current-url'; +import { toUrl } from '@/lib/utils'; import type { NavItem } from '@/types'; -export function NavMain({ items }: { items: NavItem[] }) { - const { isCurrentUrl } = useCurrentUrl(); +export function NavMain({ + items, + title = 'Navegação', +}: { + items: NavItem[]; + title?: string; +}) { + const { isCurrentOrParentUrl, currentUrl } = useCurrentUrl(); + const receivingOrder = /^\/ordens\/\d+\/recepcoes/.test(currentUrl); return ( - Platform + {title} {items.map((item) => ( diff --git a/resources/js/lib/preview-navigation.ts b/resources/js/lib/preview-navigation.ts new file mode 100644 index 0000000..2907aff --- /dev/null +++ b/resources/js/lib/preview-navigation.ts @@ -0,0 +1,13 @@ +export const previewPaths: Record = { + overview: '/consulta', + requests: '/consulta/requisicoes', + suppliers: '/consulta/fornecedores', + quotes: '/consulta/cotacoes', + conflicts: '/consulta/conflitos', + orders: '/consulta/ordens', + receipts: '/consulta/recepcoes', + stock: '/consulta/stock', + operations: '/consulta/operacoes-stock', + equipment: '/consulta/equipamentos', + appearance: '/consulta/aparencia', +}; diff --git a/resources/js/pages/auth/login.tsx b/resources/js/pages/auth/login.tsx index 6e1375a..2ebda70 100644 --- a/resources/js/pages/auth/login.tsx +++ b/resources/js/pages/auth/login.tsx @@ -117,6 +117,6 @@ export default function Login({ status, canResetPassword }: Props) { } Login.layout = { - title: 'Entrar no ProcuraFlow', + title: 'Entrar no Sistema de Gestão de Compras', description: 'Introduza o seu email e a palavra-passe', }; diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index 741193b..5fdf395 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -61,7 +61,7 @@ export default function Dashboard({
; + canManage: boolean; + canConfigure: boolean; + demo?: boolean; +}; +function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( + + ); +} +const statuses: Record = { + active: 'Em serviço', + stopped: 'Parado', + retired: 'Retirado', +}; +export default function EquipmentPage({ + equipment, + parts, + levels, + links, + items, + departments, + history, + canManage, + canConfigure, + demo = false, +}: Props) { + const [message, setMessage] = useState(''); + const category = useForm({ + code: '', + name: '', + criteria: '', + is_critical: '', + }); + const asset = useForm({ + id: '', + version: '', + code: '', + description: '', + area: '', + serial_number: '', + department_id: '', + stock_item_id: '', + criticality_level_id: '', + status: 'active', + }); + const part = useForm({ + id: '', + version: '', + stock_item_id: '', + criticality_level_id: '', + minimum_quantity: '', + }); + const association = useForm({ equipment_id: '', spare_part_id: '' }); + const stock = (id: number) => items.find((i) => i.id === id); + const criticality = (id: number) => levels.find((l) => l.id === id); + function simulate() { + setMessage( + 'Simulação revista. Nenhum cadastro ou movimento real foi criado.', + ); + } + return ( + <> + +
+ + + {demo && ( +

+ Dados fictícios para revisão sem login. Formulários + apenas de simulação. +

+ )} + +

+ O responsável da área (chefe do departamento), distinto + do solicitante, valida tecnicamente a versão do pedido + de equipamento. Finanças é obrigatória para qualquer + valor. A adjudicação e emissão de equipamento permanecem + bloqueadas: o limite próprio de investimento e a sua + aplicação exigem decisão da empresa. Uma aprovação + técnica não resolve essa pendência. +

+ + Consultar requisições → + +
+ +
+ {equipment.map((e) => ( +
+ + {e.code} · {e.description} + +

+ {e.area} ·{' '} + { + departments.find( + (d) => d.id === e.department_id, + )?.name + }{' '} + · {statuses[e.status]} +

+

+ Série: {e.serial_number || 'Não indicada'} · + Criticidade:{' '} + {criticality(e.criticality_level_id)?.name} +

+ {e.stock_item_id && ( +

+ Artigo: {stock(e.stock_item_id)?.code} +

+ )} +

+ Peças associadas:{' '} + {links + .filter((l) => l.equipment_id === e.id) + .map( + (l) => + stock( + parts.find( + (p) => + p.id === + l.spare_part_id, + )?.stock_item_id ?? 0, + )?.code, + ) + .join(', ') || 'Nenhuma'} +

+ {canManage && ( + + )} +
+ ))} +
+ {equipment.length === 0 && ( +

+ Sem equipamentos. Configure a criticidade antes do + cadastro. +

+ )} +
+ +
+ {parts.map((p) => ( +
+ + {stock(p.stock_item_id)?.code} ·{' '} + {stock(p.stock_item_id)?.name} + +

+ Stock: {quantity(p.balance_milli)} · Mínimo + definido: {quantity(p.minimum_milli)}{' '} + {stock(p.stock_item_id)?.unit} ·{' '} + {criticality(p.criticality_level_id)?.name} +

+

+ {p.below_minimum + ? 'Abaixo do mínimo — rever reposição' + : 'Mínimo assegurado'} +

+

+ Critério:{' '} + { + criticality(p.criticality_level_id) + ?.criteria + } +

+ {canManage && ( + + )} +
+ ))} +
+ {parts.length === 0 && ( +

Sem peças registadas.

+ )} +
+ {canManage && ( +
+ +
{ + e.preventDefault(); + if (demo) { + simulate(); + return; + } + asset.post('/equipamentos/activos', { + onSuccess: () => asset.reset(), + }); + }} + > + + {( + [ + 'code', + 'description', + 'area', + 'serial_number', + ] as const + ).map((k) => ( + + + asset.setData(k, e.target.value) + } + /> + + ))} + + + + + + + + + + + + + + {asset.data.id && ( + + )} +

+ O cadastro não cria entradas nem retira + existências. Os movimentos usam o fluxo de + stock. +

+ +
+
+ +
{ + e.preventDefault(); + if (demo) { + simulate(); + return; + } + part.post('/equipamentos/pecas', { + onSuccess: () => part.reset(), + }); + }} + > + + + + + + + + + + part.setData( + 'minimum_quantity', + e.target.value, + ) + } + /> + + + {part.data.id && ( + + )} + +
+ +
{ + e.preventDefault(); + if (demo) { + simulate(); + return; + } + association.post( + '/equipamentos/associacoes', + { + onSuccess: () => + association.reset(), + }, + ); + }} + > + + + + + + + + + +
+
+
+ )} + {canConfigure && ( + +
{ + e.preventDefault(); + if (demo) { + simulate(); + return; + } + category.post('/equipamentos/categorias', { + onSuccess: () => category.reset(), + }); + }} + > + + + category.setData('code', e.target.value) + } + /> + + + + category.setData('name', e.target.value) + } + /> + + +