Consolida recepcoes, stock, equipamentos e identidade SGC
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\CriticalityLevel;
|
||||
use App\Models\Department;
|
||||
use App\Models\Equipment;
|
||||
use App\Models\EquipmentSparePart;
|
||||
use App\Models\SparePart;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\StockOperation;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class EquipmentController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
abort_unless(in_array($request->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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Http\Requests\ConfirmReceiptRequest;
|
||||
use App\Models\GoodsReceipt;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\Warehouse;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class GoodsReceiptController extends Controller
|
||||
{
|
||||
/** @return Builder<PurchaseOrder> */
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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<string,mixed> */
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\Warehouse;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class StockController extends Controller
|
||||
{
|
||||
private function access(Request $request, bool $manage = false): void
|
||||
{
|
||||
$roles = $manage ? [PurchasingRole::Admin, PurchasingRole::Warehouse] : [PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Finance];
|
||||
abort_unless(in_array($request->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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Equipment;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\StockOperation;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use App\Services\StockWorkflow;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class StockOperationController extends Controller
|
||||
{
|
||||
public function index(Request $request, StockWorkflow $workflow): Response
|
||||
{
|
||||
abort_unless(in_array($request->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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\GoodsReceipt;
|
||||
use App\Models\PurchaseOrder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ConfirmReceiptRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
$order = $this->route('order');
|
||||
|
||||
return $order instanceof PurchaseOrder && ($this->user()?->can('create', [GoodsReceipt::class, $order]) ?? false);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
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<string, string> */
|
||||
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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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})?$/'],
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use LogicException;
|
||||
|
||||
trait AppendOnlyInventoryRecord
|
||||
{
|
||||
protected static function bootAppendOnlyInventoryRecord(): void
|
||||
{
|
||||
static::updating(fn () => 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.'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AppendOnlyInventoryRecord;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CriticalityLevel extends Model
|
||||
{
|
||||
use AppendOnlyInventoryRecord;
|
||||
use BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
/** @return array<string,string> */
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_critical' => 'boolean'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Equipment extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AppendOnlyInventoryRecord;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class EquipmentSparePart extends Model
|
||||
{
|
||||
use AppendOnlyInventoryRecord;
|
||||
use BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AppendOnlyInventoryRecord;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property int $purchase_order_id
|
||||
* @property int $received_by
|
||||
* @property string $payload_hash
|
||||
* @property array<string, mixed> $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<GoodsReceiptItem, $this> */
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(GoodsReceiptItem::class)->orderBy('id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AppendOnlyInventoryRecord;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class GoodsReceiptItem extends Model
|
||||
{
|
||||
use AppendOnlyInventoryRecord, BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['snapshot' => 'array'];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SparePart extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class StockItem extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AppendOnlyInventoryRecord;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class StockMovement extends Model
|
||||
{
|
||||
use AppendOnlyInventoryRecord, BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['snapshot' => 'array', 'moved_at' => 'datetime'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AppendOnlyInventoryRecord;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property array<string, mixed> $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<string, string> */
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['snapshot' => 'array', 'recorded_at' => 'datetime', 'quantity_milli' => 'integer', 'theoretical_milli' => 'integer', 'ledger_version' => 'integer'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AppendOnlyInventoryRecord;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TechnicalReview extends Model
|
||||
{
|
||||
use AppendOnlyInventoryRecord;
|
||||
use BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
/** @return array<string,string> */
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['snapshot' => 'array', 'reviewed_at' => 'datetime'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Warehouse extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\GoodsReceipt;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\User;
|
||||
|
||||
class GoodsReceiptPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->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;
|
||||
}
|
||||
}
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\CriticalityLevel;
|
||||
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\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class EquipmentWorkflow
|
||||
{
|
||||
public function linkedPart(int $partId, int $equipmentId, ?int $departmentId = null): SparePart
|
||||
{
|
||||
$part = SparePart::query()->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<string,mixed> $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.']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\GoodsReceipt;
|
||||
use App\Models\GoodsReceiptItem;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ReceiptWorkflow
|
||||
{
|
||||
public function __construct(private ConflictGuard $conflicts, private RequisitionWorkflow $audit) {}
|
||||
|
||||
public static function milli(string $quantity): int
|
||||
{
|
||||
return BigDecimal::of($quantity)->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<array<string, mixed>>, 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<string, mixed> $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<array{purchase_order_item_id: int, received_quantity: string, accepted_quantity: string, quality_status: string, stock_item_id?: int|null, notes?: string|null}> $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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,10 @@ class RequisitionWorkflow
|
||||
$items = [];
|
||||
$subtotal = 0;
|
||||
$tax = 0;
|
||||
/** @var list<array{description: string, category: string, purchase_type: string, quantity: string, unit: string, unit_price: string, tax_rate: string}> $lines */
|
||||
/** @var list<array{description: string, category: string, purchase_type: string, quantity: string, unit: string, unit_price: string, tax_rate: string, equipment_id?: int, spare_part_id?: int, production_area?: string, downtime_started_at?: string|null}> $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()]);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Equipment;
|
||||
use App\Models\SparePart;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\StockOperation;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class StockWorkflow
|
||||
{
|
||||
/** @return array{quantity: int, version: int} */
|
||||
public function balance(int $item, int $warehouse): array
|
||||
{
|
||||
$query = StockMovement::query()->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()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('warehouses', 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->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('stock_operations', function (Blueprint $table) {
|
||||
$table->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.');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('criticality_levels', 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', 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.');
|
||||
}
|
||||
};
|
||||
+24
-67
@@ -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.
|
||||
|
||||
+15
-16
@@ -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.
|
||||
|
||||
+130
-4
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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/'):
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function AppLogo() {
|
||||
</div>
|
||||
<div className="ml-1 grid flex-1 text-left text-sm">
|
||||
<span className="mb-0.5 truncate leading-tight font-semibold">
|
||||
ProcuraFlow
|
||||
SGC
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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 (
|
||||
<Sidebar collapsible="icon" variant="inset">
|
||||
<SidebarHeader>
|
||||
@@ -74,10 +123,26 @@ export function AppSidebar() {
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<NavMain items={items} />
|
||||
<NavMain title="Geral" items={generalNavItems} />
|
||||
<NavMain title="Compras" items={purchasingItems} />
|
||||
{inventoryItems.length > 0 && (
|
||||
<NavMain title="Armazém" items={inventoryItems} />
|
||||
)}
|
||||
{administrationItems.length > 0 && (
|
||||
<NavMain
|
||||
title="Administração"
|
||||
items={administrationItems}
|
||||
/>
|
||||
)}
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<Link
|
||||
href="/"
|
||||
className="px-3 py-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Página inicial
|
||||
</Link>
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
|
||||
@@ -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 (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
|
||||
Ambiente de desenvolvimento. Não autoriza compras reais.{' '}
|
||||
<strong>O orçamento ainda não é validado.</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function ReceiptProgressTable({
|
||||
progress,
|
||||
}: {
|
||||
progress: ReceiptProgress;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<p className="mb-4 text-sm font-semibold">
|
||||
{receiptStates[progress.state]}
|
||||
{progress.has_divergences
|
||||
? ' · Existem divergências de qualidade'
|
||||
: ''}
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b text-muted-foreground">
|
||||
<tr>
|
||||
<th className="pr-4 pb-3">Artigo</th>
|
||||
<th className="pr-4">Autorizado</th>
|
||||
<th className="pr-4">Recebido</th>
|
||||
<th className="pr-4">Aceite</th>
|
||||
<th className="pr-4">Rejeitado</th>
|
||||
<th className="pr-4">Por entregar</th>
|
||||
<th>Por aceitar</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{progress.lines.map((line) => (
|
||||
<tr
|
||||
key={line.id}
|
||||
className="border-b last:border-0"
|
||||
>
|
||||
<td className="py-4 pr-4">
|
||||
<strong>{line.description}</strong>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{line.unit}
|
||||
</p>
|
||||
</td>
|
||||
{[
|
||||
line.ordered_milli,
|
||||
line.received_milli,
|
||||
line.accepted_milli,
|
||||
line.rejected_milli,
|
||||
line.delivery_pending_milli,
|
||||
line.acceptance_pending_milli,
|
||||
].map((value, index) => (
|
||||
<td
|
||||
key={index}
|
||||
className="pr-4 whitespace-nowrap"
|
||||
>
|
||||
{quantity(value)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export function InventoryPagination<T>({ page }: { page: Page<T> }) {
|
||||
return (
|
||||
<div className="mt-4 flex gap-4 text-sm text-teal-700 dark:text-teal-400">
|
||||
{page.prev_page_url && (
|
||||
<Link href={page.prev_page_url}>Anterior</Link>
|
||||
)}
|
||||
{page.next_page_url && (
|
||||
<Link href={page.next_page_url}>Seguinte</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<SidebarGroup className="px-2 py-0">
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarGroupLabel>{title}</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={isCurrentUrl(item.href)}
|
||||
isActive={
|
||||
(toUrl(item.href) === '/recepcoes' &&
|
||||
receivingOrder) ||
|
||||
(isCurrentOrParentUrl(item.href) &&
|
||||
!(
|
||||
toUrl(item.href) === '/ordens' &&
|
||||
receivingOrder
|
||||
))
|
||||
}
|
||||
tooltip={{ children: item.title }}
|
||||
>
|
||||
<Link href={item.href} prefetch>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export const previewPaths: Record<string, string> = {
|
||||
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',
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function Dashboard({
|
||||
<Head title="Visão geral" />
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-7 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow={company?.name ?? 'Bem-vindo ao ProcuraFlow'}
|
||||
eyebrow={company?.name ?? 'Bem-vindo ao SGC'}
|
||||
title="Visão geral das compras"
|
||||
description="Acompanhe os pedidos, as aprovações e a actividade da sua empresa."
|
||||
action={
|
||||
|
||||
@@ -0,0 +1,859 @@
|
||||
import { Head, useForm, Link } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { InventoryNotice, InventoryPagination } from '@/components/inventory';
|
||||
import {
|
||||
PageTitle,
|
||||
Panel,
|
||||
FormErrors,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { quantity } from '@/types/inventory';
|
||||
import type { StockItem, Page } from '@/types/inventory';
|
||||
|
||||
type Level = {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
criteria: string;
|
||||
is_critical: boolean;
|
||||
};
|
||||
type Asset = {
|
||||
id: number;
|
||||
code: string;
|
||||
description: string;
|
||||
area: string;
|
||||
serial_number: string | null;
|
||||
status: string;
|
||||
department_id: number;
|
||||
stock_item_id: number | null;
|
||||
criticality_level_id: number;
|
||||
version: number;
|
||||
};
|
||||
type Part = {
|
||||
id: number;
|
||||
stock_item_id: number;
|
||||
criticality_level_id: number;
|
||||
minimum_milli: number;
|
||||
balance_milli: number;
|
||||
below_minimum: boolean;
|
||||
version: number;
|
||||
};
|
||||
type History = {
|
||||
id: number;
|
||||
reference: string;
|
||||
quantity_milli: number;
|
||||
snapshot: {
|
||||
equipment?: { code: string; description: string };
|
||||
stock_item: { name: string; unit: string };
|
||||
reason?: string;
|
||||
};
|
||||
};
|
||||
type Props = {
|
||||
equipment: Asset[];
|
||||
parts: Part[];
|
||||
levels: Level[];
|
||||
links: { equipment_id: number; spare_part_id: number }[];
|
||||
items: StockItem[];
|
||||
departments: { id: number; name: string }[];
|
||||
history: Page<History>;
|
||||
canManage: boolean;
|
||||
canConfigure: boolean;
|
||||
demo?: boolean;
|
||||
};
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="grid gap-2 text-sm">
|
||||
{label}
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
const statuses: Record<string, string> = {
|
||||
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 (
|
||||
<>
|
||||
<Head title="Equipamentos e peças" />
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow="Produção e armazém"
|
||||
title="Equipamentos e peças sobressalentes"
|
||||
description="Cadastro, peças compatíveis, mínimos explícitos e rastreabilidade das saídas."
|
||||
/>
|
||||
<InventoryNotice />
|
||||
{demo && (
|
||||
<p className="rounded-lg border bg-teal-50 p-4 text-sm text-teal-900">
|
||||
Dados fictícios para revisão sem login. Formulários
|
||||
apenas de simulação.
|
||||
</p>
|
||||
)}
|
||||
<Panel
|
||||
title="Regras e validação técnica"
|
||||
description="Os critérios de criticidade e mínimos são definidos explicitamente pela empresa, sem valores predefinidos."
|
||||
>
|
||||
<p className="text-sm">
|
||||
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.
|
||||
</p>
|
||||
<Link
|
||||
className="mt-3 inline-block text-sm font-semibold text-teal-700"
|
||||
href={demo ? '/consulta/requisicoes' : '/requisicoes'}
|
||||
>
|
||||
Consultar requisições →
|
||||
</Link>
|
||||
</Panel>
|
||||
<Panel title="Equipamentos">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{equipment.map((e) => (
|
||||
<article
|
||||
className="rounded-lg border p-4 text-sm"
|
||||
key={e.id}
|
||||
>
|
||||
<strong>
|
||||
{e.code} · {e.description}
|
||||
</strong>
|
||||
<p className="mt-2">
|
||||
{e.area} ·{' '}
|
||||
{
|
||||
departments.find(
|
||||
(d) => d.id === e.department_id,
|
||||
)?.name
|
||||
}{' '}
|
||||
· {statuses[e.status]}
|
||||
</p>
|
||||
<p>
|
||||
Série: {e.serial_number || 'Não indicada'} ·
|
||||
Criticidade:{' '}
|
||||
{criticality(e.criticality_level_id)?.name}
|
||||
</p>
|
||||
{e.stock_item_id && (
|
||||
<p>
|
||||
Artigo: {stock(e.stock_item_id)?.code}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2">
|
||||
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'}
|
||||
</p>
|
||||
{canManage && (
|
||||
<Button
|
||||
className="mt-3"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
asset.setData({
|
||||
id: String(e.id),
|
||||
version: String(e.version),
|
||||
code: e.code,
|
||||
description: e.description,
|
||||
area: e.area,
|
||||
serial_number:
|
||||
e.serial_number ?? '',
|
||||
department_id: String(
|
||||
e.department_id,
|
||||
),
|
||||
stock_item_id: e.stock_item_id
|
||||
? String(e.stock_item_id)
|
||||
: '',
|
||||
criticality_level_id: String(
|
||||
e.criticality_level_id,
|
||||
),
|
||||
status: e.status,
|
||||
})
|
||||
}
|
||||
>
|
||||
Editar ficha
|
||||
</Button>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{equipment.length === 0 && (
|
||||
<p className="text-sm">
|
||||
Sem equipamentos. Configure a criticidade antes do
|
||||
cadastro.
|
||||
</p>
|
||||
)}
|
||||
</Panel>
|
||||
<Panel
|
||||
title="Peças e alertas de mínimo"
|
||||
description="Saldo total por artigo nos armazéns da empresa. Cada peça reutiliza um artigo existente; associá-la a várias máquinas não duplica o stock."
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{parts.map((p) => (
|
||||
<article
|
||||
key={p.id}
|
||||
className={`rounded-lg border p-4 text-sm ${p.below_minimum ? 'border-amber-300 bg-amber-50 text-amber-950' : ''}`}
|
||||
>
|
||||
<strong>
|
||||
{stock(p.stock_item_id)?.code} ·{' '}
|
||||
{stock(p.stock_item_id)?.name}
|
||||
</strong>
|
||||
<p className="mt-2">
|
||||
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>
|
||||
<p className="mt-1 font-semibold">
|
||||
{p.below_minimum
|
||||
? 'Abaixo do mínimo — rever reposição'
|
||||
: 'Mínimo assegurado'}
|
||||
</p>
|
||||
<p>
|
||||
Critério:{' '}
|
||||
{
|
||||
criticality(p.criticality_level_id)
|
||||
?.criteria
|
||||
}
|
||||
</p>
|
||||
{canManage && (
|
||||
<Button
|
||||
className="mt-3"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
part.setData({
|
||||
id: String(p.id),
|
||||
version: String(p.version),
|
||||
stock_item_id: String(
|
||||
p.stock_item_id,
|
||||
),
|
||||
criticality_level_id: String(
|
||||
p.criticality_level_id,
|
||||
),
|
||||
minimum_quantity: (
|
||||
p.minimum_milli / 1000
|
||||
).toFixed(3),
|
||||
})
|
||||
}
|
||||
>
|
||||
Editar mínimo e criticidade
|
||||
</Button>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{parts.length === 0 && (
|
||||
<p className="text-sm">Sem peças registadas.</p>
|
||||
)}
|
||||
</Panel>
|
||||
{canManage && (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Panel
|
||||
title={
|
||||
asset.data.id
|
||||
? 'Editar equipamento'
|
||||
: 'Cadastrar equipamento'
|
||||
}
|
||||
>
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (demo) {
|
||||
simulate();
|
||||
return;
|
||||
}
|
||||
asset.post('/equipamentos/activos', {
|
||||
onSuccess: () => asset.reset(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={asset.errors} />
|
||||
{(
|
||||
[
|
||||
'code',
|
||||
'description',
|
||||
'area',
|
||||
'serial_number',
|
||||
] as const
|
||||
).map((k) => (
|
||||
<Field
|
||||
key={k}
|
||||
label={
|
||||
{
|
||||
code: 'Código',
|
||||
description: 'Descrição',
|
||||
area: 'Área / linha de produção',
|
||||
serial_number:
|
||||
'Número de série',
|
||||
}[k]
|
||||
}
|
||||
>
|
||||
<Input
|
||||
required={k !== 'serial_number'}
|
||||
value={asset.data[k]}
|
||||
onChange={(e) =>
|
||||
asset.setData(k, e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
<Field label="Departamento">
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={asset.data.department_id}
|
||||
onChange={(e) =>
|
||||
asset.setData(
|
||||
'department_id',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Seleccionar</option>
|
||||
{departments.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Artigo de stock (opcional)">
|
||||
<select
|
||||
className={controlClass}
|
||||
value={asset.data.stock_item_id}
|
||||
onChange={(e) =>
|
||||
asset.setData(
|
||||
'stock_item_id',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
Sem artigo associado
|
||||
</option>
|
||||
{items.map((i) => (
|
||||
<option key={i.id} value={i.id}>
|
||||
{i.code} · {i.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Criticidade definida pela empresa">
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={asset.data.criticality_level_id}
|
||||
onChange={(e) =>
|
||||
asset.setData(
|
||||
'criticality_level_id',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Seleccionar</option>
|
||||
{levels.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Estado">
|
||||
<select
|
||||
className={controlClass}
|
||||
value={asset.data.status}
|
||||
onChange={(e) =>
|
||||
asset.setData(
|
||||
'status',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
{Object.entries(statuses).map(
|
||||
([k, v]) => (
|
||||
<option key={k} value={k}>
|
||||
{v}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</Field>
|
||||
<Button
|
||||
disabled={
|
||||
asset.processing || levels.length === 0
|
||||
}
|
||||
>
|
||||
Guardar equipamento
|
||||
</Button>
|
||||
{asset.data.id && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => asset.reset()}
|
||||
>
|
||||
Novo cadastro
|
||||
</Button>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
O cadastro não cria entradas nem retira
|
||||
existências. Os movimentos usam o fluxo de
|
||||
stock.
|
||||
</p>
|
||||
</form>
|
||||
</Panel>
|
||||
<div className="space-y-6">
|
||||
<Panel
|
||||
title={
|
||||
part.data.id
|
||||
? 'Editar peça'
|
||||
: 'Cadastrar peça sobre artigo existente'
|
||||
}
|
||||
>
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (demo) {
|
||||
simulate();
|
||||
return;
|
||||
}
|
||||
part.post('/equipamentos/pecas', {
|
||||
onSuccess: () => part.reset(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={part.errors} />
|
||||
<Field label="Artigo / referência">
|
||||
<select
|
||||
required
|
||||
disabled={!!part.data.id}
|
||||
className={controlClass}
|
||||
value={part.data.stock_item_id}
|
||||
onChange={(e) =>
|
||||
part.setData(
|
||||
'stock_item_id',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
Seleccionar
|
||||
</option>
|
||||
{items.map((i) => (
|
||||
<option key={i.id} value={i.id}>
|
||||
{i.code} · {i.name} (
|
||||
{i.unit})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Criticidade">
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={
|
||||
part.data.criticality_level_id
|
||||
}
|
||||
onChange={(e) =>
|
||||
part.setData(
|
||||
'criticality_level_id',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
Seleccionar
|
||||
</option>
|
||||
{levels.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Quantidade mínima explícita (pode ser zero)">
|
||||
<Input
|
||||
required
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.001"
|
||||
value={part.data.minimum_quantity}
|
||||
onChange={(e) =>
|
||||
part.setData(
|
||||
'minimum_quantity',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Button
|
||||
disabled={
|
||||
part.processing ||
|
||||
levels.length === 0
|
||||
}
|
||||
>
|
||||
Guardar peça
|
||||
</Button>
|
||||
{part.data.id && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => part.reset()}
|
||||
>
|
||||
Nova peça
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</Panel>
|
||||
<Panel title="Associar peça a equipamento">
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (demo) {
|
||||
simulate();
|
||||
return;
|
||||
}
|
||||
association.post(
|
||||
'/equipamentos/associacoes',
|
||||
{
|
||||
onSuccess: () =>
|
||||
association.reset(),
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={association.errors} />
|
||||
<Field label="Equipamento">
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={
|
||||
association.data.equipment_id
|
||||
}
|
||||
onChange={(e) =>
|
||||
association.setData(
|
||||
'equipment_id',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
Seleccionar
|
||||
</option>
|
||||
{equipment.map((e) => (
|
||||
<option key={e.id} value={e.id}>
|
||||
{e.code} · {e.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Peça">
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={
|
||||
association.data.spare_part_id
|
||||
}
|
||||
onChange={(e) =>
|
||||
association.setData(
|
||||
'spare_part_id',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
Seleccionar
|
||||
</option>
|
||||
{parts.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{
|
||||
stock(p.stock_item_id)
|
||||
?.code
|
||||
}{' '}
|
||||
·{' '}
|
||||
{
|
||||
stock(p.stock_item_id)
|
||||
?.name
|
||||
}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Button disabled={association.processing}>
|
||||
Associar sem duplicar stock
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{canConfigure && (
|
||||
<Panel
|
||||
title="Configurar critérios de criticidade"
|
||||
description="Sem categorias automáticas. Registe a decisão da empresa; os critérios guardados não são reescritos. Para novos critérios, crie uma nova categoria."
|
||||
>
|
||||
<form
|
||||
className="grid gap-4 sm:grid-cols-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (demo) {
|
||||
simulate();
|
||||
return;
|
||||
}
|
||||
category.post('/equipamentos/categorias', {
|
||||
onSuccess: () => category.reset(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Field label="Código">
|
||||
<Input
|
||||
required
|
||||
value={category.data.code}
|
||||
onChange={(e) =>
|
||||
category.setData('code', e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Nome da categoria">
|
||||
<Input
|
||||
required
|
||||
value={category.data.name}
|
||||
onChange={(e) =>
|
||||
category.setData('name', e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Critério e fundamento da classificação">
|
||||
<textarea
|
||||
required
|
||||
className={controlClass}
|
||||
value={category.data.criteria}
|
||||
onChange={(e) =>
|
||||
category.setData(
|
||||
'criteria',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="É crítica para produção segundo este critério?">
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={category.data.is_critical}
|
||||
onChange={(e) =>
|
||||
category.setData(
|
||||
'is_critical',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Decisão explícita</option>
|
||||
<option value="1">Sim</option>
|
||||
<option value="0">Não</option>
|
||||
</select>
|
||||
</Field>
|
||||
<FormErrors errors={category.errors} />
|
||||
<Button disabled={category.processing}>
|
||||
Guardar critério
|
||||
</Button>
|
||||
</form>
|
||||
<div className="mt-4 space-y-2 text-sm">
|
||||
{levels.map((l) => (
|
||||
<p key={l.id}>
|
||||
<strong>
|
||||
{l.code} · {l.name}
|
||||
</strong>
|
||||
: {l.criteria} ·{' '}
|
||||
{l.is_critical ? 'Crítica' : 'Não crítica'}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
<p role="status" className="text-sm">
|
||||
{message}
|
||||
</p>
|
||||
<Panel title="Saídas rastreáveis por equipamento">
|
||||
<Link
|
||||
href={
|
||||
demo
|
||||
? '/consulta/operacoes-stock'
|
||||
: '/stock/operacoes'
|
||||
}
|
||||
className="text-sm font-semibold text-teal-700"
|
||||
>
|
||||
Registar saída com equipamento de destino →
|
||||
</Link>
|
||||
<div className="mt-4 space-y-3">
|
||||
{history.data.map((h) => (
|
||||
<article
|
||||
key={h.id}
|
||||
className="rounded-lg border p-4 text-sm"
|
||||
>
|
||||
<strong>
|
||||
{h.reference} · {h.snapshot.equipment?.code}
|
||||
</strong>
|
||||
<p>
|
||||
{h.snapshot.equipment?.description} ·{' '}
|
||||
{h.snapshot.stock_item.name} ·{' '}
|
||||
{quantity(h.quantity_milli)}{' '}
|
||||
{h.snapshot.stock_item.unit}
|
||||
</p>
|
||||
<p>{h.snapshot.reason}</p>
|
||||
</article>
|
||||
))}
|
||||
{history.data.length === 0 && (
|
||||
<p className="text-sm">
|
||||
Sem saídas associadas a equipamentos.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<InventoryPagination page={history} />
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export function EquipmentDemo() {
|
||||
return (
|
||||
<EquipmentPage
|
||||
demo
|
||||
canManage
|
||||
canConfigure
|
||||
equipment={[
|
||||
{
|
||||
id: 1,
|
||||
code: 'EQ-DEMO-01',
|
||||
description: 'Compressor fictício',
|
||||
area: 'Linha de produção fictícia A',
|
||||
serial_number: 'SERIE-DEMO',
|
||||
status: 'stopped',
|
||||
department_id: 1,
|
||||
stock_item_id: null,
|
||||
criticality_level_id: 1,
|
||||
version: 1,
|
||||
},
|
||||
]}
|
||||
parts={[
|
||||
{
|
||||
id: 1,
|
||||
stock_item_id: 1,
|
||||
criticality_level_id: 1,
|
||||
minimum_milli: 3000,
|
||||
balance_milli: 1000,
|
||||
below_minimum: true,
|
||||
version: 1,
|
||||
},
|
||||
]}
|
||||
links={[{ equipment_id: 1, spare_part_id: 1 }]}
|
||||
levels={[
|
||||
{
|
||||
id: 1,
|
||||
code: 'CRIT-DEMO',
|
||||
name: 'Crítica (exemplo fictício)',
|
||||
criteria:
|
||||
'Exemplo apenas: falha interrompe a linha fictícia. A empresa deve definir os seus critérios.',
|
||||
is_critical: true,
|
||||
},
|
||||
]}
|
||||
items={[
|
||||
{
|
||||
id: 1,
|
||||
code: 'ROL-DEMO',
|
||||
name: 'Rolamento fictício',
|
||||
unit: 'un',
|
||||
},
|
||||
]}
|
||||
departments={[{ id: 1, name: 'Produção fictícia' }]}
|
||||
history={{
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
reference: 'SAI-DEMO-PECA',
|
||||
quantity_milli: 1000,
|
||||
snapshot: {
|
||||
equipment: {
|
||||
code: 'EQ-DEMO-01',
|
||||
description:
|
||||
'Compressor fictício no momento da saída',
|
||||
},
|
||||
stock_item: {
|
||||
name: 'Rolamento fictício',
|
||||
unit: 'un',
|
||||
},
|
||||
reason: 'Substituição de peça numa paragem fictícia.',
|
||||
},
|
||||
},
|
||||
],
|
||||
prev_page_url: null,
|
||||
next_page_url: null,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import {
|
||||
ArrowRight,
|
||||
ClipboardList,
|
||||
FileCheck2,
|
||||
PackageCheck,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
Warehouse,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const areas = [
|
||||
{
|
||||
title: 'Pedidos e aprovações',
|
||||
description:
|
||||
'Registe necessidades e acompanhe as decisões de cada departamento.',
|
||||
icon: ClipboardList,
|
||||
href: '/consulta/requisicoes',
|
||||
label: 'Explorar requisições',
|
||||
step: '01',
|
||||
},
|
||||
{
|
||||
title: 'Fornecedores e compras',
|
||||
description:
|
||||
'Compare propostas, fundamente a escolha e acompanhe as ordens de compra.',
|
||||
icon: Users,
|
||||
href: '/consulta/fornecedores',
|
||||
label: 'Explorar fornecedores',
|
||||
step: '02',
|
||||
},
|
||||
{
|
||||
title: 'Recepção e armazém',
|
||||
description:
|
||||
'Confira as entregas, registe a qualidade e consulte as existências aceites.',
|
||||
icon: PackageCheck,
|
||||
href: '/consulta/recepcoes',
|
||||
label: 'Explorar recepção',
|
||||
step: '03',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<Head title="Gestão de compras" />
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<header className="border-b">
|
||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-6 py-5 lg:px-10">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<span className="grid size-10 place-items-center rounded-xl bg-teal-800 text-lg font-semibold text-white">
|
||||
SGC
|
||||
</span>
|
||||
<div>
|
||||
<span className="block text-lg font-semibold tracking-tight">
|
||||
SGC
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Sistema de Gestão de Compras
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
<nav
|
||||
aria-label="Navegação principal"
|
||||
className="flex items-center gap-5 text-sm"
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
aria-current="page"
|
||||
className="hidden font-semibold text-teal-700 sm:block dark:text-teal-400"
|
||||
>
|
||||
Início
|
||||
</Link>
|
||||
<Link
|
||||
href="/consulta"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Demonstração
|
||||
</Link>
|
||||
<Button asChild>
|
||||
<Link href="/login">
|
||||
Entrar na aplicação{' '}
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-7xl px-6 lg:px-10">
|
||||
<section className="grid items-center gap-12 py-16 lg:grid-cols-[1.1fr_1fr] lg:py-24">
|
||||
<div>
|
||||
<p className="mb-5 text-xs font-semibold tracking-[0.2em] text-teal-700 uppercase dark:text-teal-400">
|
||||
Cada etapa no seu lugar
|
||||
</p>
|
||||
<h1 className="max-w-2xl text-4xl leading-tight font-semibold tracking-tight sm:text-5xl">
|
||||
Gestão de compras,
|
||||
<br />
|
||||
<span className="text-teal-800 dark:text-teal-400">
|
||||
do pedido ao stock.
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-6 max-w-xl text-base leading-7 text-muted-foreground">
|
||||
Um percurso organizado para a sua equipa:
|
||||
necessidades, fornecedores, decisões, entregas e
|
||||
armazém. Escolha a área em que quer trabalhar ou
|
||||
conheça primeiro os exemplos de demonstração.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="bg-teal-800 text-white hover:bg-teal-900"
|
||||
>
|
||||
<Link href="/consulta">
|
||||
Explorar a demonstração{' '}
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="lg" variant="outline">
|
||||
<Link href="/login">
|
||||
Aceder ao espaço de trabalho
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-5 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<ShieldCheck className="size-4 shrink-0" />
|
||||
Demonstração com dados fictícios. O espaço de
|
||||
trabalho exige autenticação.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border bg-muted/25 p-6 sm:p-8">
|
||||
<div className="mb-7 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold">
|
||||
O percurso da compra
|
||||
</p>
|
||||
<span className="rounded-full bg-teal-100 px-3 py-1 text-xs font-medium text-teal-900 dark:bg-teal-950 dark:text-teal-200">
|
||||
Por etapas
|
||||
</span>
|
||||
</div>
|
||||
<ol className="space-y-4">
|
||||
{[
|
||||
{
|
||||
title: 'Requisitar e aprovar',
|
||||
note: 'Necessidade, centro de custo e decisões',
|
||||
icon: ClipboardList,
|
||||
},
|
||||
{
|
||||
title: 'Seleccionar e emitir',
|
||||
note: 'Propostas, adjudicação e ordem de compra',
|
||||
icon: FileCheck2,
|
||||
},
|
||||
{
|
||||
title: 'Receber e conferir',
|
||||
note: 'Entregas parciais, qualidade e divergências',
|
||||
icon: PackageCheck,
|
||||
},
|
||||
{
|
||||
title: 'Consultar existências',
|
||||
note: 'Entradas aceites e histórico por armazém',
|
||||
icon: Warehouse,
|
||||
},
|
||||
].map((step, index) => (
|
||||
<li
|
||||
key={step.title}
|
||||
className="flex items-center gap-4 rounded-xl border bg-background p-4"
|
||||
>
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-lg bg-muted text-teal-800 dark:text-teal-400">
|
||||
<step.icon className="size-5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold">
|
||||
{step.title}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{step.note}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
0{index + 1}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
className="border-t py-12"
|
||||
aria-labelledby="areas-title"
|
||||
>
|
||||
<div className="mb-7 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold tracking-widest text-teal-700 uppercase dark:text-teal-400">
|
||||
Navegação por áreas
|
||||
</p>
|
||||
<h2
|
||||
id="areas-title"
|
||||
className="text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
Por onde quer começar?
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
href="/consulta"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-teal-700 dark:text-teal-400"
|
||||
>
|
||||
Ver todos os módulos{' '}
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
{areas.map((area) => (
|
||||
<Link
|
||||
href={area.href}
|
||||
key={area.title}
|
||||
className="group flex flex-col rounded-xl border bg-card p-6 transition-colors hover:border-teal-600"
|
||||
>
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<area.icon className="size-6 text-teal-800 dark:text-teal-400" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{area.step}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-semibold">
|
||||
{area.title}
|
||||
</h3>
|
||||
<p className="mt-3 flex-1 text-sm leading-6 text-muted-foreground">
|
||||
{area.description}
|
||||
</p>
|
||||
<span className="mt-7 inline-flex items-center gap-2 text-sm font-medium text-teal-700 dark:text-teal-400">
|
||||
{area.label}
|
||||
<ArrowRight className="size-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="mb-12 flex flex-wrap items-center justify-between gap-5 rounded-xl bg-teal-900 p-6 text-white sm:p-8">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
Já faz parte da equipa?
|
||||
</h2>
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-teal-100">
|
||||
Entre com a sua conta para aceder aos módulos e
|
||||
dados autorizados para o seu perfil.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="secondary">
|
||||
<Link href="/login">
|
||||
Entrar no espaço de trabalho{' '}
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</section>
|
||||
</main>
|
||||
<footer className="border-t">
|
||||
<div className="mx-auto flex max-w-7xl flex-wrap justify-between gap-4 px-6 py-6 text-xs leading-5 text-muted-foreground lg:px-10">
|
||||
<span>SGC · Ambiente de desenvolvimento</span>
|
||||
<span>
|
||||
O orçamento ainda não é validado. Os documentos não
|
||||
autorizam compras reais.
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ArrowRight,
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
PackageCheck,
|
||||
Warehouse,
|
||||
} from 'lucide-react';
|
||||
import { PageTitle, Panel, Status } from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -27,6 +29,10 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { money } from '@/types/procurement';
|
||||
import { previewPaths } from '@/lib/preview-navigation';
|
||||
import InventoryDemo from './inventory-demo';
|
||||
import { OperationsDemo } from '../stock/operations';
|
||||
import { EquipmentDemo } from '../equipment';
|
||||
|
||||
// Static examples only: this page never loads or submits business data.
|
||||
const requests = [
|
||||
@@ -108,19 +114,34 @@ const navigation = [
|
||||
{ id: 'quotes', title: 'Cotações', icon: Scale },
|
||||
{ id: 'conflicts', title: 'Conflitos de interesses', icon: ShieldCheck },
|
||||
{ id: 'orders', title: 'Ordens de compra', icon: FileText },
|
||||
{ id: 'receipts', title: 'Recepção e qualidade', icon: PackageCheck },
|
||||
{ id: 'stock', title: 'Stock e movimentos', icon: Warehouse },
|
||||
{ id: 'operations', title: 'Saídas e inventários', icon: Warehouse },
|
||||
{ id: 'equipment', title: 'Equipamentos e peças', icon: Warehouse },
|
||||
{ id: 'appearance', title: 'Cores e componentes', icon: Palette },
|
||||
];
|
||||
type Detail = { title: string; subtitle: string; rows: [string, string][] };
|
||||
export default function Preview() {
|
||||
const [screen, setScreen] = useState('overview');
|
||||
const navigationGroups = [
|
||||
{ title: 'Geral', ids: ['overview'] },
|
||||
{ title: 'Compras', ids: ['requests', 'suppliers', 'quotes', 'orders'] },
|
||||
{ title: 'Armazém', ids: ['receipts', 'stock', 'operations', 'equipment'] },
|
||||
{ title: 'Controlo', ids: ['conflicts'] },
|
||||
{ title: 'Apresentação', ids: ['appearance'] },
|
||||
];
|
||||
export default function Preview({
|
||||
initialScreen = 'overview',
|
||||
}: {
|
||||
initialScreen?: string;
|
||||
}) {
|
||||
const screen = initialScreen;
|
||||
const [search, setSearch] = useState('');
|
||||
const [menu, setMenu] = useState(false);
|
||||
const [detail, setDetail] = useState<Detail | null>(null);
|
||||
const title = navigation.find((n) => n.id === screen)?.title ?? 'Consulta';
|
||||
function navigate(id: string) {
|
||||
setScreen(id);
|
||||
setSearch('');
|
||||
setMenu(false);
|
||||
router.get(previewPaths[id] ?? '/consulta');
|
||||
}
|
||||
const filteredRequests = requests.filter((r) =>
|
||||
`${r.number} ${r.title} ${r.department}`
|
||||
@@ -205,13 +226,13 @@ export default function Preview() {
|
||||
className={`${menu ? 'flex' : 'hidden'} fixed inset-y-0 left-0 z-30 w-64 flex-col border-r bg-background p-5 lg:flex`}
|
||||
>
|
||||
<Link
|
||||
href="/consulta"
|
||||
href="/"
|
||||
className="mb-8 flex items-center gap-3 text-xl font-semibold"
|
||||
>
|
||||
<span className="grid size-9 place-items-center rounded-xl bg-teal-700 text-sm text-white">
|
||||
PF
|
||||
SGC
|
||||
</span>
|
||||
ProcuraFlow
|
||||
SGC
|
||||
</Link>
|
||||
<div className="mb-7 rounded-xl border bg-muted/30 p-4">
|
||||
<p className="flex items-center gap-2 text-sm font-semibold">
|
||||
@@ -222,20 +243,34 @@ export default function Preview() {
|
||||
Consulta livre · sem autenticação
|
||||
</p>
|
||||
</div>
|
||||
<nav className="space-y-2">
|
||||
{navigation.map((n) => (
|
||||
<button
|
||||
type="button"
|
||||
key={n.id}
|
||||
onClick={() => navigate(n.id)}
|
||||
aria-current={
|
||||
screen === n.id ? 'page' : undefined
|
||||
}
|
||||
className={`flex w-full items-center gap-3 rounded-lg px-3 py-3 text-left text-sm transition-colors ${screen === n.id ? 'bg-teal-50 font-medium text-teal-800 dark:bg-teal-950 dark:text-teal-200' : 'text-muted-foreground hover:bg-muted'}`}
|
||||
>
|
||||
<n.icon className="size-4" />
|
||||
{n.title}
|
||||
</button>
|
||||
<nav
|
||||
aria-label="Áreas da demonstração"
|
||||
className="space-y-4"
|
||||
>
|
||||
{navigationGroups.map((group) => (
|
||||
<div key={group.title}>
|
||||
<p className="mb-1 px-3 text-[10px] font-semibold tracking-widest text-muted-foreground uppercase">
|
||||
{group.title}
|
||||
</p>
|
||||
{navigation
|
||||
.filter((n) => group.ids.includes(n.id))
|
||||
.map((n) => (
|
||||
<Link
|
||||
href={previewPaths[n.id]}
|
||||
key={n.id}
|
||||
onClick={() => setMenu(false)}
|
||||
aria-current={
|
||||
screen === n.id
|
||||
? 'page'
|
||||
: undefined
|
||||
}
|
||||
className={`flex w-full items-center gap-3 rounded-lg px-3 py-3 text-left text-sm transition-colors ${screen === n.id ? 'bg-teal-50 font-medium text-teal-800 dark:bg-teal-950 dark:text-teal-200' : 'text-muted-foreground hover:bg-muted'}`}
|
||||
>
|
||||
<n.icon className="size-4" />
|
||||
{n.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<p className="mt-auto pt-8 text-xs leading-5 text-muted-foreground">
|
||||
@@ -287,7 +322,7 @@ export default function Preview() {
|
||||
</span>
|
||||
</div>
|
||||
<PageTitle
|
||||
eyebrow="ProcuraFlow · Demonstração"
|
||||
eyebrow="SGC · Demonstração"
|
||||
title={title}
|
||||
description="Explore os exemplos para verificar a organização, os ecrãs, as cores e a navegação. Os dados apresentados não pertencem à sua empresa."
|
||||
/>
|
||||
@@ -621,6 +656,11 @@ export default function Preview() {
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
{(screen === 'receipts' || screen === 'stock') && (
|
||||
<InventoryDemo key={screen} screen={screen} />
|
||||
)}
|
||||
{screen === 'operations' && <OperationsDemo />}
|
||||
{screen === 'equipment' && <EquipmentDemo />}
|
||||
{screen === 'appearance' && (
|
||||
<>
|
||||
<Panel
|
||||
@@ -708,8 +748,8 @@ export default function Preview() {
|
||||
</>
|
||||
)}
|
||||
<footer className="py-4 text-xs text-muted-foreground">
|
||||
ProcuraFlow · Consulta pública de exemplos · Os
|
||||
processos reais permanecem no acesso autenticado.
|
||||
SGC · Consulta pública de exemplos · Os processos
|
||||
reais permanecem no acesso autenticado.
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import { useState } from 'react';
|
||||
import { InventoryNotice, ReceiptProgressTable } from '@/components/inventory';
|
||||
import { Panel, controlClass } from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { quantity } from '@/types/inventory';
|
||||
import type { ReceiptProgress } from '@/types/inventory';
|
||||
|
||||
const progress: ReceiptProgress = {
|
||||
state: 'partial',
|
||||
has_divergences: true,
|
||||
lines: [
|
||||
{
|
||||
id: 1,
|
||||
description: 'Papel A4 de demonstração',
|
||||
unit: 'cx',
|
||||
purchase_type: 'consumable',
|
||||
ordered_milli: 10000,
|
||||
received_milli: 6000,
|
||||
accepted_milli: 5000,
|
||||
rejected_milli: 1000,
|
||||
delivery_pending_milli: 4000,
|
||||
acceptance_pending_milli: 5000,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
description: 'Tinteiro de demonstração',
|
||||
unit: 'un',
|
||||
purchase_type: 'consumable',
|
||||
ordered_milli: 3000,
|
||||
received_milli: 3000,
|
||||
accepted_milli: 3000,
|
||||
rejected_milli: 0,
|
||||
delivery_pending_milli: 0,
|
||||
acceptance_pending_milli: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
const examples = [
|
||||
{
|
||||
code: 'DEMO-A4',
|
||||
name: 'Papel A4 de demonstração',
|
||||
unit: 'cx',
|
||||
quantity_milli: 5000,
|
||||
warehouse: 'Armazém de demonstração',
|
||||
receipt: 'REC-DEMO-001',
|
||||
note: 'Uma caixa rejeitada por danos; só cinco caixas aceites entraram em stock.',
|
||||
},
|
||||
{
|
||||
code: 'DEMO-TINTA',
|
||||
name: 'Tinteiro de demonstração',
|
||||
unit: 'un',
|
||||
quantity_milli: 3000,
|
||||
warehouse: 'Armazém de demonstração',
|
||||
receipt: 'REC-DEMO-001',
|
||||
note: 'Três unidades recebidas e aceites integralmente.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function InventoryDemo({
|
||||
screen,
|
||||
}: {
|
||||
screen: 'receipts' | 'stock';
|
||||
}) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [detail, setDetail] = useState<(typeof examples)[number] | null>(
|
||||
null,
|
||||
);
|
||||
const [quality, setQuality] = useState('reservations');
|
||||
const [notice, setNotice] = useState(false);
|
||||
const visible = examples.filter((example) =>
|
||||
`${example.code} ${example.name}`
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase()),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<InventoryNotice />
|
||||
{screen === 'receipts' ? (
|
||||
<>
|
||||
<Panel
|
||||
title="OC-DEMO-001 · Recepção parcial"
|
||||
description="Exemplo fictício: seis caixas conferidas, cinco aceites e uma rejeitada. Existem quatro caixas por entregar."
|
||||
>
|
||||
<ReceiptProgressTable progress={progress} />
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
Emitente: Comprador de exemplo · Recepção: Armazém
|
||||
de exemplo. São pessoas distintas.
|
||||
</p>
|
||||
</Panel>
|
||||
<Panel
|
||||
title="Conferência de qualidade"
|
||||
description="Experimente os campos. Este formulário é local, não é enviado e não confirma recepções reais."
|
||||
>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setNotice(true);
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="demo-guide">
|
||||
Guia fictícia
|
||||
</Label>
|
||||
<Input
|
||||
id="demo-guide"
|
||||
defaultValue="GR-DEMO-001"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="demo-received">
|
||||
Quantidade recebida
|
||||
</Label>
|
||||
<Input
|
||||
id="demo-received"
|
||||
type="number"
|
||||
min="0.001"
|
||||
step="0.001"
|
||||
defaultValue="6"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="demo-quality">
|
||||
Qualidade
|
||||
</Label>
|
||||
<select
|
||||
id="demo-quality"
|
||||
className={controlClass}
|
||||
value={quality}
|
||||
onChange={(event) =>
|
||||
setQuality(event.target.value)
|
||||
}
|
||||
>
|
||||
<option value="compliant">
|
||||
Conforme
|
||||
</option>
|
||||
<option value="reservations">
|
||||
Conforme com reservas
|
||||
</option>
|
||||
<option value="nonconforming">
|
||||
Não conforme
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="demo-accepted">
|
||||
Quantidade aceite
|
||||
</Label>
|
||||
<Input
|
||||
id="demo-accepted"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.001"
|
||||
defaultValue="5"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="demo-location">
|
||||
Destino fictício
|
||||
</Label>
|
||||
<select
|
||||
id="demo-location"
|
||||
className={controlClass}
|
||||
>
|
||||
<option>Armazém de demonstração</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="demo-article">
|
||||
Artigo fictício
|
||||
</Label>
|
||||
<select
|
||||
id="demo-article"
|
||||
className={controlClass}
|
||||
>
|
||||
<option>
|
||||
DEMO-A4 · Papel A4 de demonstração
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="demo-divergence">
|
||||
Divergências
|
||||
</Label>
|
||||
<textarea
|
||||
id="demo-divergence"
|
||||
className={`${controlClass} h-24 py-3`}
|
||||
defaultValue="Uma caixa com danos na embalagem e humidade. Rejeitada e sem entrada em stock."
|
||||
/>
|
||||
</div>
|
||||
<Button>Pré-visualizar conferência fictícia</Button>
|
||||
{notice && (
|
||||
<p
|
||||
role="status"
|
||||
className="text-sm text-teal-700 dark:text-teal-400"
|
||||
>
|
||||
Demonstração local: nenhum dado foi
|
||||
guardado. No módulo autenticado, o servidor
|
||||
verifica quantidades, segregação e
|
||||
duplicações.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</Panel>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Panel
|
||||
title="Existências fictícias"
|
||||
description="Somente quantidades aceites. Pesquise e abra os detalhes para rever a apresentação."
|
||||
>
|
||||
<Input
|
||||
aria-label="Pesquisar artigos fictícios"
|
||||
placeholder="Pesquisar código ou artigo de demonstração"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="mb-5 max-w-sm"
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b text-muted-foreground">
|
||||
<tr>
|
||||
<th className="pr-4 pb-3">Artigo</th>
|
||||
<th className="pr-4">Armazém</th>
|
||||
<th className="pr-4">Em stock</th>
|
||||
<th>Histórico</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visible.map((example) => (
|
||||
<tr
|
||||
className="border-b last:border-0"
|
||||
key={example.code}
|
||||
>
|
||||
<td className="py-4 pr-4">
|
||||
<strong>{example.code}</strong>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{example.name}
|
||||
</p>
|
||||
</td>
|
||||
<td className="pr-4">
|
||||
{example.warehouse}
|
||||
</td>
|
||||
<td className="pr-4">
|
||||
{quantity(
|
||||
example.quantity_milli,
|
||||
)}{' '}
|
||||
{example.unit}
|
||||
</td>
|
||||
<td>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setDetail(example)
|
||||
}
|
||||
>
|
||||
Ver movimentos
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{visible.length === 0 && (
|
||||
<p className="py-5 text-sm text-muted-foreground">
|
||||
Nenhum exemplo corresponde à pesquisa.
|
||||
</p>
|
||||
)}
|
||||
</Panel>
|
||||
<Panel title="Histórico de entradas fictícias">
|
||||
<div className="space-y-3">
|
||||
{visible.map((example) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetail(example)}
|
||||
key={example.code}
|
||||
className="w-full rounded-lg border p-4 text-left text-sm"
|
||||
>
|
||||
<strong>
|
||||
+{quantity(example.quantity_milli)}{' '}
|
||||
{example.unit} · {example.code}
|
||||
</strong>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{example.receipt} · OC-DEMO-001 · Guia
|
||||
GR-DEMO-001
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-muted-foreground">
|
||||
Os exemplos não correspondem a existências da
|
||||
empresa. Consulte Saídas e inventários para rever os
|
||||
novos exemplos fictícios.
|
||||
</p>
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
<Dialog
|
||||
open={detail !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDetail(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{detail?.code} · Entrada fictícia
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Histórico ilustrativo, sem acesso aos registos
|
||||
reais.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{detail && (
|
||||
<div className="space-y-3 text-sm">
|
||||
<p>
|
||||
{detail.name} · +
|
||||
{quantity(detail.quantity_milli)} {detail.unit}
|
||||
</p>
|
||||
<p>{detail.receipt} · OC-DEMO-001 · GR-DEMO-001</p>
|
||||
<p>{detail.note}</p>
|
||||
<p>
|
||||
Confirmado por Armazém de exemplo, distinto do
|
||||
emitente.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,12 @@ export default function OrderShow({
|
||||
return (
|
||||
<>
|
||||
<Head title={order.number} />
|
||||
<Link
|
||||
href={`/ordens/${order.id}/recepcoes`}
|
||||
className="mx-auto mt-4 w-full max-w-6xl px-5 text-sm font-medium text-teal-700 dark:text-teal-400"
|
||||
>
|
||||
Recepções, qualidade e quantidades pendentes
|
||||
</Link>
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-7 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow={snapshot.company.name}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { InventoryNotice, InventoryPagination } from '@/components/inventory';
|
||||
import { PageTitle, Panel } from '@/components/procurement';
|
||||
import { date } from '@/types/procurement';
|
||||
import { receiptStates } from '@/types/inventory';
|
||||
import type { Page, Receipt, ReceiptProgress } from '@/types/inventory';
|
||||
|
||||
type IncomingOrder = {
|
||||
id: number;
|
||||
number: string;
|
||||
supplier_name: string;
|
||||
progress: ReceiptProgress;
|
||||
};
|
||||
export default function Receipts({
|
||||
orders,
|
||||
receipts,
|
||||
}: {
|
||||
orders: Page<IncomingOrder>;
|
||||
receipts: Page<Receipt>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Head title="Recepção de bens" />
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-7 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow="Conferência e armazém"
|
||||
title="Recepção de bens e serviços"
|
||||
description="Recepções parciais e totais, conferência de qualidade e quantidades pendentes por ordem emitida."
|
||||
/>
|
||||
<InventoryNotice />
|
||||
<Panel title="Ordens para conferência">
|
||||
<div className="divide-y">
|
||||
{orders.data.map((order) => (
|
||||
<Link
|
||||
key={order.id}
|
||||
href={`/ordens/${order.id}/recepcoes`}
|
||||
className="flex flex-wrap items-center justify-between gap-3 py-4 text-sm"
|
||||
>
|
||||
<div>
|
||||
<strong>{order.number}</strong>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{order.supplier_name}
|
||||
</p>
|
||||
</div>
|
||||
<span>
|
||||
{receiptStates[order.progress.state]}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{orders.data.length === 0 && (
|
||||
<p className="py-6 text-sm text-muted-foreground">
|
||||
Ainda não existem ordens emitidas disponíveis para o
|
||||
seu perfil.
|
||||
</p>
|
||||
)}
|
||||
<InventoryPagination page={orders} />
|
||||
</Panel>
|
||||
<Panel title="Histórico de confirmações">
|
||||
<div className="divide-y">
|
||||
{receipts.data.map((receipt) => (
|
||||
<Link
|
||||
key={receipt.id}
|
||||
href={`/recepcoes/${receipt.id}`}
|
||||
className="flex flex-wrap justify-between gap-3 py-4 text-sm"
|
||||
>
|
||||
<div>
|
||||
<strong>{receipt.number}</strong> ·{' '}
|
||||
{receipt.snapshot.order.number}
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Guia {receipt.delivery_note} ·{' '}
|
||||
{receipt.snapshot.receiver.name}
|
||||
</p>
|
||||
</div>
|
||||
<span>
|
||||
{date(receipt.received_on)} ·{' '}
|
||||
{receiptStates[receipt.status]}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{receipts.data.length === 0 && (
|
||||
<p className="py-6 text-sm text-muted-foreground">
|
||||
As recepções confirmadas ficam preservadas neste
|
||||
histórico.
|
||||
</p>
|
||||
)}
|
||||
<InventoryPagination page={receipts} />
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Receipts.layout = { breadcrumbs: [{ title: 'Recepção', href: '/recepcoes' }] };
|
||||
@@ -0,0 +1,562 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { InventoryNotice, ReceiptProgressTable } from '@/components/inventory';
|
||||
import {
|
||||
FormErrors,
|
||||
PageTitle,
|
||||
Panel,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { quantity, qualityStates, receiptStates } from '@/types/inventory';
|
||||
import type {
|
||||
Receipt,
|
||||
ReceiptProgress,
|
||||
StockItem,
|
||||
Warehouse,
|
||||
} from '@/types/inventory';
|
||||
|
||||
type Line = {
|
||||
include: boolean;
|
||||
purchase_order_item_id: number;
|
||||
received_quantity: string;
|
||||
accepted_quantity: string;
|
||||
quality_status: string;
|
||||
notes: string;
|
||||
stock_item_id: string;
|
||||
};
|
||||
type Props = {
|
||||
order: { id: number; number: string };
|
||||
supplier_name: string;
|
||||
progress: ReceiptProgress;
|
||||
receipts: Receipt[];
|
||||
warehouses: Warehouse[];
|
||||
stockItems: StockItem[];
|
||||
canConfirm: boolean;
|
||||
};
|
||||
export default function ReceiveOrder({
|
||||
order,
|
||||
supplier_name,
|
||||
progress,
|
||||
receipts,
|
||||
warehouses,
|
||||
stockItems,
|
||||
canConfirm,
|
||||
}: Props) {
|
||||
const form = useForm({
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
delivery_note: '',
|
||||
received_on: '',
|
||||
warehouse_id: '',
|
||||
items: progress.lines.map((line) => ({
|
||||
include: false,
|
||||
purchase_order_item_id: line.id,
|
||||
received_quantity: '',
|
||||
accepted_quantity: '',
|
||||
quality_status: 'compliant',
|
||||
notes: '',
|
||||
stock_item_id: '',
|
||||
})),
|
||||
});
|
||||
const change = (index: number, updates: Partial<Line>) =>
|
||||
form.setData(
|
||||
'items',
|
||||
form.data.items.map((line, i) =>
|
||||
i === index ? { ...line, ...updates } : line,
|
||||
),
|
||||
);
|
||||
const outstanding = progress.lines.some(
|
||||
(line) => line.delivery_pending_milli > 0,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Head title={`Receber ${order.number}`} />
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-7 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow={order.number}
|
||||
title="Conferir entrega"
|
||||
description={supplier_name}
|
||||
/>
|
||||
<InventoryNotice />
|
||||
<Panel title="Quantidades da ordem">
|
||||
<ReceiptProgressTable progress={progress} />
|
||||
<p className="mt-4 text-xs text-muted-foreground">
|
||||
As quantidades rejeitadas contam como conferidas, mas
|
||||
não entram em stock. Substituições ou reposições após
|
||||
rejeição não são autorizadas automaticamente.
|
||||
</p>
|
||||
</Panel>
|
||||
{canConfirm && outstanding ? (
|
||||
<Panel
|
||||
title="Confirmar recepção"
|
||||
description="A confirmação é definitiva e auditada. O emitente da ordem não pode confirmar a recepção."
|
||||
>
|
||||
<form
|
||||
className="space-y-5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.transform((data) => ({
|
||||
...data,
|
||||
warehouse_id: data.warehouse_id || null,
|
||||
items: data.items
|
||||
.filter((line) => line.include)
|
||||
.map(
|
||||
({
|
||||
include: _include,
|
||||
...line
|
||||
}) => ({
|
||||
...line,
|
||||
stock_item_id:
|
||||
line.stock_item_id || null,
|
||||
}),
|
||||
),
|
||||
}));
|
||||
form.post(`/ordens/${order.id}/recepcoes`);
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="receipt-note">
|
||||
Guia de remessa
|
||||
</Label>
|
||||
<Input
|
||||
id="receipt-note"
|
||||
required
|
||||
maxLength={100}
|
||||
value={form.data.delivery_note}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'delivery_note',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="receipt-date">
|
||||
Data da recepção
|
||||
</Label>
|
||||
<Input
|
||||
id="receipt-date"
|
||||
type="date"
|
||||
required
|
||||
value={form.data.received_on}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'received_on',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="receipt-warehouse">
|
||||
Armazém (para bens aceites)
|
||||
</Label>
|
||||
<select
|
||||
id="receipt-warehouse"
|
||||
className={controlClass}
|
||||
value={form.data.warehouse_id}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'warehouse_id',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Seleccione</option>
|
||||
{warehouses.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.code} · {w.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
{progress.lines.map(
|
||||
(source, index) =>
|
||||
source.delivery_pending_milli > 0 && (
|
||||
<fieldset
|
||||
className="rounded-xl border p-4"
|
||||
key={source.id}
|
||||
>
|
||||
<legend className="px-2 text-sm font-semibold">
|
||||
{source.description} · Por
|
||||
entregar{' '}
|
||||
{quantity(
|
||||
source.delivery_pending_milli,
|
||||
)}{' '}
|
||||
{source.unit}
|
||||
</legend>
|
||||
<label className="mb-4 flex items-center gap-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
form.data.items[
|
||||
index
|
||||
].include
|
||||
}
|
||||
onChange={(e) =>
|
||||
change(index, {
|
||||
include:
|
||||
e.target
|
||||
.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
Incluir nesta recepção
|
||||
</label>
|
||||
{form.data.items[index]
|
||||
.include && (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor={`received-${source.id}`}
|
||||
>
|
||||
Quantidade
|
||||
recebida
|
||||
</Label>
|
||||
<Input
|
||||
id={`received-${source.id}`}
|
||||
type="number"
|
||||
required
|
||||
min="0.001"
|
||||
step="0.001"
|
||||
max={
|
||||
source.delivery_pending_milli /
|
||||
1000
|
||||
}
|
||||
value={
|
||||
form.data
|
||||
.items[
|
||||
index
|
||||
]
|
||||
.received_quantity
|
||||
}
|
||||
onChange={(e) =>
|
||||
change(
|
||||
index,
|
||||
{
|
||||
received_quantity:
|
||||
e
|
||||
.target
|
||||
.value,
|
||||
...(form
|
||||
.data
|
||||
.items[
|
||||
index
|
||||
]
|
||||
.quality_status ===
|
||||
'compliant'
|
||||
? {
|
||||
accepted_quantity:
|
||||
e
|
||||
.target
|
||||
.value,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor={`quality-${source.id}`}
|
||||
>
|
||||
Qualidade
|
||||
</Label>
|
||||
<select
|
||||
id={`quality-${source.id}`}
|
||||
className={
|
||||
controlClass
|
||||
}
|
||||
value={
|
||||
form.data
|
||||
.items[
|
||||
index
|
||||
]
|
||||
.quality_status
|
||||
}
|
||||
onChange={(e) =>
|
||||
change(
|
||||
index,
|
||||
{
|
||||
quality_status:
|
||||
e
|
||||
.target
|
||||
.value,
|
||||
...(e
|
||||
.target
|
||||
.value ===
|
||||
'nonconforming'
|
||||
? {
|
||||
accepted_quantity:
|
||||
'0',
|
||||
stock_item_id:
|
||||
'',
|
||||
}
|
||||
: e
|
||||
.target
|
||||
.value ===
|
||||
'compliant'
|
||||
? {
|
||||
accepted_quantity:
|
||||
form
|
||||
.data
|
||||
.items[
|
||||
index
|
||||
]
|
||||
.received_quantity,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{Object.entries(
|
||||
qualityStates,
|
||||
).map(
|
||||
([
|
||||
value,
|
||||
label,
|
||||
]) => (
|
||||
<option
|
||||
key={
|
||||
value
|
||||
}
|
||||
value={
|
||||
value
|
||||
}
|
||||
>
|
||||
{
|
||||
label
|
||||
}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor={`accepted-${source.id}`}
|
||||
>
|
||||
Quantidade
|
||||
aceite
|
||||
</Label>
|
||||
<Input
|
||||
id={`accepted-${source.id}`}
|
||||
type="number"
|
||||
required
|
||||
min="0"
|
||||
step="0.001"
|
||||
readOnly={
|
||||
form.data
|
||||
.items[
|
||||
index
|
||||
]
|
||||
.quality_status !==
|
||||
'reservations'
|
||||
}
|
||||
value={
|
||||
form.data
|
||||
.items[
|
||||
index
|
||||
]
|
||||
.accepted_quantity
|
||||
}
|
||||
onChange={(e) =>
|
||||
change(
|
||||
index,
|
||||
{
|
||||
accepted_quantity:
|
||||
e
|
||||
.target
|
||||
.value,
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{source.purchase_type !==
|
||||
'service' ? (
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor={`stock-${source.id}`}
|
||||
>
|
||||
Artigo de
|
||||
stock ·
|
||||
unidade{' '}
|
||||
{
|
||||
source.unit
|
||||
}
|
||||
</Label>
|
||||
<select
|
||||
id={`stock-${source.id}`}
|
||||
className={
|
||||
controlClass
|
||||
}
|
||||
value={
|
||||
form
|
||||
.data
|
||||
.items[
|
||||
index
|
||||
]
|
||||
.stock_item_id
|
||||
}
|
||||
onChange={(
|
||||
e,
|
||||
) =>
|
||||
change(
|
||||
index,
|
||||
{
|
||||
stock_item_id:
|
||||
e
|
||||
.target
|
||||
.value,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
Seleccione
|
||||
para a
|
||||
quantidade
|
||||
aceite
|
||||
</option>
|
||||
{stockItems
|
||||
.filter(
|
||||
(
|
||||
item,
|
||||
) =>
|
||||
item.unit.toLowerCase() ===
|
||||
source.unit.toLowerCase(),
|
||||
)
|
||||
.map(
|
||||
(
|
||||
item,
|
||||
) => (
|
||||
<option
|
||||
key={
|
||||
item.id
|
||||
}
|
||||
value={
|
||||
item.id
|
||||
}
|
||||
>
|
||||
{
|
||||
item.code
|
||||
}{' '}
|
||||
·{' '}
|
||||
{
|
||||
item.name
|
||||
}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<p className="self-center text-sm text-muted-foreground">
|
||||
Serviço: a
|
||||
aceitação não
|
||||
cria existências
|
||||
físicas.
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label
|
||||
htmlFor={`notes-${source.id}`}
|
||||
>
|
||||
Observações e
|
||||
divergências
|
||||
</Label>
|
||||
<textarea
|
||||
id={`notes-${source.id}`}
|
||||
className={`${controlClass} h-24 py-3`}
|
||||
required={
|
||||
form.data
|
||||
.items[
|
||||
index
|
||||
]
|
||||
.quality_status !==
|
||||
'compliant'
|
||||
}
|
||||
maxLength={3000}
|
||||
value={
|
||||
form.data
|
||||
.items[
|
||||
index
|
||||
].notes
|
||||
}
|
||||
onChange={(e) =>
|
||||
change(
|
||||
index,
|
||||
{
|
||||
notes: e
|
||||
.target
|
||||
.value,
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure previamente os armazéns e artigos em
|
||||
Stock. Não existem conversões automáticas de
|
||||
unidade, tolerâncias de excesso ou saldo inicial
|
||||
manual.
|
||||
</p>
|
||||
<Button disabled={form.processing}>
|
||||
Confirmar recepção e entradas aceites
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{outstanding
|
||||
? 'A confirmação exige o perfil Recepção / Armazém e uma pessoa distinta do emitente.'
|
||||
: 'Não existem quantidades de entrega pendentes nesta ordem.'}
|
||||
</p>
|
||||
)}
|
||||
<Panel title="Recepções anteriores">
|
||||
<div className="divide-y">
|
||||
{receipts.map((receipt) => (
|
||||
<Link
|
||||
key={receipt.id}
|
||||
href={`/recepcoes/${receipt.id}`}
|
||||
className="flex flex-wrap justify-between gap-3 py-3 text-sm"
|
||||
>
|
||||
<span>
|
||||
{receipt.number} · Guia{' '}
|
||||
{receipt.delivery_note}
|
||||
</span>
|
||||
<span>{receiptStates[receipt.status]}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{receipts.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ainda não há recepções confirmadas.
|
||||
</p>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
ReceiveOrder.layout = {
|
||||
breadcrumbs: [
|
||||
{ title: 'Recepção', href: '/recepcoes' },
|
||||
{ title: 'Conferir ordem', href: '#' },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { InventoryNotice } from '@/components/inventory';
|
||||
import { PageTitle, Panel } from '@/components/procurement';
|
||||
import { date } from '@/types/procurement';
|
||||
import { quantity, qualityStates, receiptStates } from '@/types/inventory';
|
||||
import type { Receipt, StockMovement } from '@/types/inventory';
|
||||
|
||||
export default function ReceiptShow({
|
||||
receipt,
|
||||
movements,
|
||||
}: {
|
||||
receipt: Receipt;
|
||||
movements: StockMovement[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Head title={receipt.number} />
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-7 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow={receipt.snapshot.order.number}
|
||||
title={receipt.number}
|
||||
description={`${receipt.snapshot.supplier.name} · ${receiptStates[receipt.status]}`}
|
||||
/>
|
||||
<InventoryNotice />
|
||||
<Panel title="Confirmação preservada">
|
||||
<dl className="grid gap-5 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
Guia de remessa
|
||||
</dt>
|
||||
<dd>{receipt.delivery_note}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
Recebido em / por
|
||||
</dt>
|
||||
<dd>
|
||||
{date(receipt.received_on)} ·{' '}
|
||||
{receipt.snapshot.receiver.name}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Armazém</dt>
|
||||
<dd>
|
||||
{receipt.snapshot.warehouse?.name ??
|
||||
'Sem entrada física'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Panel>
|
||||
<Panel title="Quantidade, qualidade e divergências">
|
||||
<div className="space-y-5">
|
||||
{receipt.items.map((line) => (
|
||||
<div
|
||||
className="rounded-lg border p-4 text-sm"
|
||||
key={line.id}
|
||||
>
|
||||
<h3 className="font-semibold">
|
||||
{line.snapshot.order_item.description} ·{' '}
|
||||
{qualityStates[line.quality_status]}
|
||||
</h3>
|
||||
<p className="mt-2">
|
||||
Recebido: {quantity(line.received_milli)} ·
|
||||
Aceite: {quantity(line.accepted_milli)} ·
|
||||
Rejeitado: {quantity(line.rejected_milli)}{' '}
|
||||
{line.snapshot.order_item.unit}
|
||||
</p>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{line.snapshot.stock_item
|
||||
? `${line.snapshot.stock_item.code} · ${line.snapshot.stock_item.name}`
|
||||
: line.snapshot.physical
|
||||
? 'Sem quantidade aceite em stock.'
|
||||
: 'Serviço, sem movimento físico.'}
|
||||
</p>
|
||||
{line.notes && (
|
||||
<p className="mt-3 whitespace-pre-wrap">
|
||||
{line.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel title="Entradas ligadas a esta recepção">
|
||||
<div className="space-y-3">
|
||||
{movements.map((movement) => (
|
||||
<p className="text-sm" key={movement.id}>
|
||||
Movimento #{movement.id} · +
|
||||
{quantity(movement.quantity_milli)}{' '}
|
||||
{movement.snapshot.stock_item.unit} ·{' '}
|
||||
{movement.snapshot.stock_item.code} ·{' '}
|
||||
{movement.snapshot.warehouse.name}
|
||||
</p>
|
||||
))}
|
||||
{movements.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Não houve entradas de bens aceites nesta
|
||||
confirmação.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-muted-foreground">
|
||||
A confirmação e os movimentos não podem ser editados ou
|
||||
eliminados. Repetir a mesma confirmação não cria novas
|
||||
entradas.
|
||||
</p>
|
||||
</Panel>
|
||||
<Link
|
||||
href={`/ordens/${receipt.purchase_order_id}/recepcoes`}
|
||||
className="text-sm text-teal-700 dark:text-teal-400"
|
||||
>
|
||||
Consultar a ordem e as quantidades pendentes
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
ReceiptShow.layout = {
|
||||
breadcrumbs: [
|
||||
{ title: 'Recepção', href: '/recepcoes' },
|
||||
{ title: 'Confirmação', href: '#' },
|
||||
],
|
||||
};
|
||||
@@ -21,6 +21,10 @@ type Line = {
|
||||
unit: string;
|
||||
unit_price: string;
|
||||
tax_rate: string;
|
||||
equipment_id: string;
|
||||
spare_part_id: string;
|
||||
production_area: string;
|
||||
downtime_started_at: string;
|
||||
};
|
||||
const emptyLine = (): Line => ({
|
||||
key: crypto.randomUUID(),
|
||||
@@ -31,6 +35,10 @@ const emptyLine = (): Line => ({
|
||||
unit: 'un',
|
||||
unit_price: '',
|
||||
tax_rate: '0',
|
||||
equipment_id: '',
|
||||
spare_part_id: '',
|
||||
production_area: '',
|
||||
downtime_started_at: '',
|
||||
});
|
||||
const lineFields = [
|
||||
{ key: 'description', label: 'Descrição', type: 'text', maxLength: 255 },
|
||||
@@ -65,9 +73,15 @@ const lineFields = [
|
||||
export default function RequisitionForm({
|
||||
costCenters,
|
||||
requisition,
|
||||
equipment = [],
|
||||
spareParts = [],
|
||||
stockItems = [],
|
||||
}: {
|
||||
costCenters: CostCenter[];
|
||||
requisition: Requisition | null;
|
||||
equipment?: { id: number; code: string; description: string }[];
|
||||
spareParts?: { id: number; stock_item_id: number }[];
|
||||
stockItems?: { id: number; code: string; name: string; unit: string }[];
|
||||
}) {
|
||||
const form = useForm({
|
||||
cost_center_id: requisition ? String(requisition.cost_center_id) : '',
|
||||
@@ -83,6 +97,10 @@ export default function RequisitionForm({
|
||||
unit: i.unit,
|
||||
unit_price: (i.unit_price_minor / 100).toFixed(2),
|
||||
tax_rate: String(i.tax_rate),
|
||||
equipment_id: i.equipment_id ? String(i.equipment_id) : '',
|
||||
spare_part_id: i.spare_part_id ? String(i.spare_part_id) : '',
|
||||
production_area: i.production_area ?? '',
|
||||
downtime_started_at: i.downtime_started_at?.slice(0, 16) ?? '',
|
||||
})) ?? [emptyLine()],
|
||||
});
|
||||
const changeLine = (index: number, field: keyof Line, value: string) =>
|
||||
@@ -276,6 +294,14 @@ export default function RequisitionForm({
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="equipment">
|
||||
Equipamento (emissão
|
||||
bloqueada até definição da
|
||||
regra de investimento)
|
||||
</option>
|
||||
<option value="spare_part">
|
||||
Peça sobressalente
|
||||
</option>
|
||||
<option value="consumable">
|
||||
Insumos / matéria-prima
|
||||
</option>
|
||||
@@ -286,6 +312,160 @@ export default function RequisitionForm({
|
||||
Outro
|
||||
</option>
|
||||
</select>
|
||||
{line.purchase_type ===
|
||||
'equipment' && (
|
||||
<label className="mt-3 grid gap-2 text-sm">
|
||||
Área / linha de produção
|
||||
<Input
|
||||
required
|
||||
value={
|
||||
line.production_area
|
||||
}
|
||||
onChange={(e) =>
|
||||
changeLine(
|
||||
index,
|
||||
'production_area',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{line.purchase_type ===
|
||||
'spare_part' && (
|
||||
<div className="mt-3 grid gap-3">
|
||||
<label className="grid gap-2 text-sm">
|
||||
Equipamento do
|
||||
departamento
|
||||
<select
|
||||
required
|
||||
className={
|
||||
controlClass
|
||||
}
|
||||
value={
|
||||
line.equipment_id
|
||||
}
|
||||
onChange={(e) =>
|
||||
changeLine(
|
||||
index,
|
||||
'equipment_id',
|
||||
e.target
|
||||
.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
Seleccionar
|
||||
</option>
|
||||
{equipment.map(
|
||||
(a) => (
|
||||
<option
|
||||
key={
|
||||
a.id
|
||||
}
|
||||
value={
|
||||
a.id
|
||||
}
|
||||
>
|
||||
{a.code}{' '}
|
||||
·{' '}
|
||||
{
|
||||
a.description
|
||||
}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm">
|
||||
Peça cadastrada
|
||||
<select
|
||||
required
|
||||
className={
|
||||
controlClass
|
||||
}
|
||||
value={
|
||||
line.spare_part_id
|
||||
}
|
||||
onChange={(e) =>
|
||||
changeLine(
|
||||
index,
|
||||
'spare_part_id',
|
||||
e.target
|
||||
.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
Seleccionar
|
||||
</option>
|
||||
{spareParts.map(
|
||||
(p) => (
|
||||
<option
|
||||
key={
|
||||
p.id
|
||||
}
|
||||
value={
|
||||
p.id
|
||||
}
|
||||
>
|
||||
{
|
||||
stockItems.find(
|
||||
(
|
||||
s,
|
||||
) =>
|
||||
s.id ===
|
||||
p.stock_item_id,
|
||||
)
|
||||
?.code
|
||||
}{' '}
|
||||
·{' '}
|
||||
{
|
||||
stockItems.find(
|
||||
(
|
||||
s,
|
||||
) =>
|
||||
s.id ===
|
||||
p.stock_item_id,
|
||||
)
|
||||
?.name
|
||||
}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm">
|
||||
Início da paragem
|
||||
(obrigatório para peça
|
||||
crítica urgente; hora
|
||||
UTC)
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={
|
||||
line.downtime_started_at
|
||||
}
|
||||
onChange={(e) =>
|
||||
changeLine(
|
||||
index,
|
||||
'downtime_started_at',
|
||||
e.target
|
||||
.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use a unidade do artigo.
|
||||
A associação
|
||||
peça/equipamento é
|
||||
revalidada no servidor.
|
||||
Urgência não dispensa
|
||||
três cotações ou
|
||||
aprovações.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end">
|
||||
|
||||
@@ -32,14 +32,31 @@ export default function Show({
|
||||
canDecide,
|
||||
conflictBlocked,
|
||||
audit,
|
||||
technical,
|
||||
}: {
|
||||
requisition: Requisition;
|
||||
canEdit: boolean;
|
||||
canDecide: boolean;
|
||||
conflictBlocked: boolean;
|
||||
audit: Audit[];
|
||||
technical?: {
|
||||
canReview: boolean;
|
||||
basis_hash: string;
|
||||
reviews: {
|
||||
id: number;
|
||||
decision: string;
|
||||
reason: string;
|
||||
basis_hash: string;
|
||||
snapshot: { reviewer: { name: string } };
|
||||
}[];
|
||||
};
|
||||
}) {
|
||||
const submission = useForm({});
|
||||
const review = useForm({
|
||||
basis_hash: technical?.basis_hash ?? '',
|
||||
decision: 'approved',
|
||||
reason: '',
|
||||
});
|
||||
const decision = useForm<{ decision: string; comment: string }>({
|
||||
decision: 'approved',
|
||||
comment: '',
|
||||
@@ -55,6 +72,86 @@ export default function Show({
|
||||
action={<Status value={r.status} />}
|
||||
/>
|
||||
<FormErrors errors={submission.errors} />
|
||||
{r.items.some((i) => i.purchase_type === 'equipment') && (
|
||||
<Panel title="Validação técnica do equipamento">
|
||||
<p className="mb-4 text-sm">
|
||||
Responsável da área, distinto do solicitante.
|
||||
Finanças é obrigatória para qualquer valor. A
|
||||
adjudicação e a emissão permanecem bloqueadas
|
||||
enquanto o limite próprio de investimento e a sua
|
||||
regra de aplicação não forem definidos. O orçamento
|
||||
ainda não é validado; documentos de desenvolvimento
|
||||
não autorizam compras reais.
|
||||
</p>
|
||||
{technical?.reviews.map((t) => (
|
||||
<p
|
||||
key={t.id}
|
||||
className="mb-3 rounded-lg border p-3 text-sm"
|
||||
>
|
||||
{t.decision === 'approved'
|
||||
? 'Validado'
|
||||
: 'Rejeitado'}{' '}
|
||||
por {t.snapshot.reviewer.name} ·{' '}
|
||||
{t.basis_hash === technical.basis_hash
|
||||
? 'Versão actual'
|
||||
: 'Versão anterior, decisão preservada'}{' '}
|
||||
· {t.reason}
|
||||
</p>
|
||||
))}
|
||||
{technical?.canReview && (
|
||||
<form
|
||||
className="grid gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
review.transform((d) => ({
|
||||
...d,
|
||||
basis_hash: technical.basis_hash,
|
||||
}));
|
||||
review.post(
|
||||
`/requisicoes/${r.id}/validacao-tecnica`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<select
|
||||
aria-label="Decisão técnica"
|
||||
className={controlClass}
|
||||
value={review.data.decision}
|
||||
onChange={(e) =>
|
||||
review.setData(
|
||||
'decision',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="approved">
|
||||
Validar tecnicamente
|
||||
</option>
|
||||
<option value="rejected">
|
||||
Rejeitar tecnicamente
|
||||
</option>
|
||||
</select>
|
||||
<label className="grid gap-2 text-sm">
|
||||
Fundamento técnico
|
||||
<textarea
|
||||
required
|
||||
className={controlClass}
|
||||
value={review.data.reason}
|
||||
onChange={(e) =>
|
||||
review.setData(
|
||||
'reason',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<FormErrors errors={review.errors} />
|
||||
<Button disabled={review.processing}>
|
||||
Registar decisão técnica
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</Panel>
|
||||
)}
|
||||
{conflictBlocked && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
|
||||
Existe um conflito de interesses activo que impede a
|
||||
@@ -161,6 +258,22 @@ export default function Show({
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{i.category}
|
||||
</p>
|
||||
{i.equipment_id && (
|
||||
<p className="mt-1 text-xs">
|
||||
Equipamento #
|
||||
{i.equipment_id} ·
|
||||
Peça #
|
||||
{i.spare_part_id}
|
||||
{i.downtime_started_at &&
|
||||
` · Paragem: ${i.downtime_started_at} UTC`}
|
||||
</p>
|
||||
)}
|
||||
{i.production_area && (
|
||||
<p className="mt-1 text-xs">
|
||||
Área de destino:{' '}
|
||||
{i.production_area}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="pr-3 whitespace-nowrap">
|
||||
{Number(i.quantity)}{' '}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { InventoryNotice, InventoryPagination } from '@/components/inventory';
|
||||
import {
|
||||
FormErrors,
|
||||
PageTitle,
|
||||
Panel,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { date } from '@/types/procurement';
|
||||
import { quantity } from '@/types/inventory';
|
||||
import type {
|
||||
Page,
|
||||
StockItem,
|
||||
StockMovement,
|
||||
Warehouse,
|
||||
} from '@/types/inventory';
|
||||
|
||||
type Props = {
|
||||
items: StockItem[];
|
||||
warehouses: Warehouse[];
|
||||
balances: {
|
||||
stock_item_id: number;
|
||||
warehouse_id: number;
|
||||
quantity_milli: number;
|
||||
}[];
|
||||
movements: Page<StockMovement>;
|
||||
filters: { search?: string; warehouse_id?: number };
|
||||
canManage: boolean;
|
||||
};
|
||||
export default function Stock({
|
||||
items,
|
||||
warehouses,
|
||||
balances,
|
||||
movements,
|
||||
filters,
|
||||
canManage,
|
||||
}: Props) {
|
||||
const search = useForm({
|
||||
search: filters.search ?? '',
|
||||
warehouse_id: filters.warehouse_id ? String(filters.warehouse_id) : '',
|
||||
});
|
||||
const item = useForm({ code: '', name: '', unit: '' });
|
||||
const warehouse = useForm({ code: '', name: '' });
|
||||
return (
|
||||
<>
|
||||
<Head title="Stock e movimentos" />
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-7 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow="Armazém"
|
||||
title="Existências e movimentos"
|
||||
description="Saldo por artigo e armazém, calculado a partir de entradas, saídas, transferências e ajustes confirmados. O histórico permanece preservado."
|
||||
/>
|
||||
<InventoryNotice />
|
||||
<Link
|
||||
href="/stock/operacoes"
|
||||
className="font-semibold text-teal-700"
|
||||
>
|
||||
Saídas, transferências e inventários →
|
||||
</Link>
|
||||
<Panel title="Existências">
|
||||
<form
|
||||
className="mb-5 flex flex-wrap gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
search.get('/stock', { preserveState: true });
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
className="max-w-sm"
|
||||
aria-label="Pesquisar artigo"
|
||||
placeholder="Código ou nome do artigo"
|
||||
value={search.data.search}
|
||||
onChange={(e) =>
|
||||
search.setData('search', e.target.value)
|
||||
}
|
||||
/>
|
||||
<select
|
||||
className={`${controlClass} max-w-xs`}
|
||||
aria-label="Filtrar armazém"
|
||||
value={search.data.warehouse_id}
|
||||
onChange={(e) =>
|
||||
search.setData('warehouse_id', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Todos os armazéns</option>
|
||||
{warehouses.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.code} · {w.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button variant="outline">Filtrar</Button>
|
||||
</form>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b text-muted-foreground">
|
||||
<tr>
|
||||
<th className="pr-4 pb-3">Artigo</th>
|
||||
<th className="pr-4">Armazém</th>
|
||||
<th>Quantidade em stock</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{balances.map((balance) => {
|
||||
const article = items.find(
|
||||
(i) => i.id === balance.stock_item_id,
|
||||
);
|
||||
return (
|
||||
<tr
|
||||
className="border-b last:border-0"
|
||||
key={`${balance.stock_item_id}-${balance.warehouse_id}`}
|
||||
>
|
||||
<td className="py-4 pr-4">
|
||||
<strong>{article?.code}</strong>{' '}
|
||||
· {article?.name}
|
||||
</td>
|
||||
<td className="pr-4">
|
||||
{
|
||||
warehouses.find(
|
||||
(w) =>
|
||||
w.id ===
|
||||
balance.warehouse_id,
|
||||
)?.name
|
||||
}
|
||||
</td>
|
||||
<td className="font-medium">
|
||||
{quantity(
|
||||
balance.quantity_milli,
|
||||
)}{' '}
|
||||
{article?.unit}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{balances.length === 0 && (
|
||||
<p className="py-6 text-sm text-muted-foreground">
|
||||
Não há entradas confirmadas para estes filtros.
|
||||
Criar um artigo não cria stock.
|
||||
</p>
|
||||
)}
|
||||
</Panel>
|
||||
<Panel title="Histórico de entradas">
|
||||
<div className="space-y-4">
|
||||
{movements.data.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex flex-wrap justify-between gap-3 rounded-lg border p-4 text-sm"
|
||||
>
|
||||
<div>
|
||||
<strong>
|
||||
{m.quantity_milli > 0 ? '+' : ''}
|
||||
{quantity(m.quantity_milli)}{' '}
|
||||
{m.snapshot.stock_item.unit} ·{' '}
|
||||
{m.snapshot.stock_item.code}
|
||||
</strong>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{m.snapshot.stock_item.name} ·{' '}
|
||||
{m.snapshot.warehouse.name}
|
||||
{m.snapshot.equipment &&
|
||||
` · Equipamento ${m.snapshot.equipment.code}`}
|
||||
</p>
|
||||
<p className="mt-1 text-xs">
|
||||
{date(m.moved_at)} · Utilizador #
|
||||
{m.moved_by} ·{' '}
|
||||
{m.snapshot.reference ??
|
||||
m.snapshot.order_number}{' '}
|
||||
· {m.movement_type}
|
||||
{m.snapshot.delivery_note &&
|
||||
` · Guia ${m.snapshot.delivery_note}`}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={
|
||||
m.snapshot.receipt_id
|
||||
? `/recepcoes/${m.snapshot.receipt_id}`
|
||||
: '/stock/operacoes'
|
||||
}
|
||||
className="text-teal-700 dark:text-teal-400"
|
||||
>
|
||||
{m.snapshot.receipt_id
|
||||
? 'Abrir recepção'
|
||||
: 'Abrir operações'}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{movements.data.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ainda não há movimentos disponíveis.
|
||||
</p>
|
||||
)}
|
||||
<InventoryPagination page={movements} />
|
||||
<p className="mt-4 text-xs text-muted-foreground">
|
||||
Movimentos confirmados preservados. As transferências
|
||||
têm duas linhas ligadas à mesma operação.
|
||||
</p>
|
||||
</Panel>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Panel
|
||||
title="Catálogo de artigos"
|
||||
description="Identificação explícita dos artigos: descrições iguais não são agregadas automaticamente."
|
||||
>
|
||||
<div className="mb-5 space-y-2 text-sm">
|
||||
{items.map((i) => (
|
||||
<p key={i.id}>
|
||||
<strong>{i.code}</strong> · {i.name} ·{' '}
|
||||
{i.unit}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
{canManage && (
|
||||
<form
|
||||
className="space-y-4 border-t pt-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
item.post('/stock/artigos', {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => item.reset(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={item.errors} />
|
||||
{(['code', 'name', 'unit'] as const).map(
|
||||
(key) => (
|
||||
<div className="space-y-2" key={key}>
|
||||
<Label htmlFor={`stock-${key}`}>
|
||||
{key === 'code'
|
||||
? 'Código'
|
||||
: key === 'name'
|
||||
? 'Designação'
|
||||
: 'Unidade (igual à ordem)'}
|
||||
</Label>
|
||||
<Input
|
||||
id={`stock-${key}`}
|
||||
required
|
||||
value={item.data[key]}
|
||||
onChange={(e) =>
|
||||
item.setData(
|
||||
key,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<Button disabled={item.processing}>
|
||||
Criar artigo sem saldo inicial
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</Panel>
|
||||
<Panel title="Armazéns">
|
||||
<div className="mb-5 space-y-2 text-sm">
|
||||
{warehouses.map((w) => (
|
||||
<p key={w.id}>
|
||||
<strong>{w.code}</strong> · {w.name}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
{canManage && (
|
||||
<form
|
||||
className="space-y-4 border-t pt-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
warehouse.post('/stock/armazens', {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => warehouse.reset(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={warehouse.errors} />
|
||||
{(['code', 'name'] as const).map((key) => (
|
||||
<div className="space-y-2" key={key}>
|
||||
<Label htmlFor={`warehouse-${key}`}>
|
||||
{key === 'code'
|
||||
? 'Código'
|
||||
: 'Nome do armazém'}
|
||||
</Label>
|
||||
<Input
|
||||
id={`warehouse-${key}`}
|
||||
required
|
||||
value={warehouse.data[key]}
|
||||
onChange={(e) =>
|
||||
warehouse.setData(
|
||||
key,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button disabled={warehouse.processing}>
|
||||
Criar armazém
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Stock.layout = {
|
||||
breadcrumbs: [{ title: 'Stock e movimentos', href: '/stock' }],
|
||||
};
|
||||
@@ -0,0 +1,541 @@
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { InventoryNotice, InventoryPagination } from '@/components/inventory';
|
||||
import {
|
||||
PageTitle,
|
||||
Panel,
|
||||
FormErrors,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { quantity } from '@/types/inventory';
|
||||
import type { StockItem, Warehouse, Page } from '@/types/inventory';
|
||||
|
||||
type Operation = {
|
||||
id: number;
|
||||
kind: string;
|
||||
reference: string;
|
||||
reason: string;
|
||||
destination?: string;
|
||||
quantity_milli: number;
|
||||
theoretical_milli: number;
|
||||
stock_item_id: number;
|
||||
warehouse_id: number;
|
||||
recorded_at: string;
|
||||
snapshot: {
|
||||
actor: { name: string };
|
||||
responsible: { name: string };
|
||||
stock_item: { name: string; unit: string };
|
||||
warehouse: { name: string };
|
||||
destination_warehouse?: { name: string } | null;
|
||||
equipment?: { code: string; description: string } | null;
|
||||
};
|
||||
};
|
||||
type Props = {
|
||||
items: StockItem[];
|
||||
warehouses: Warehouse[];
|
||||
users: { id: number; name: string }[];
|
||||
balances: {
|
||||
stock_item_id: number;
|
||||
warehouse_id: number;
|
||||
quantity: number;
|
||||
version: number;
|
||||
}[];
|
||||
operations: Page<Operation>;
|
||||
counts: Operation[];
|
||||
canOperate: boolean;
|
||||
demo?: boolean;
|
||||
equipment?: { id: number; code: string; description: string }[];
|
||||
};
|
||||
const kinds: Record<string, string> = {
|
||||
issue: 'Saída',
|
||||
transfer: 'Transferência',
|
||||
count: 'Contagem física',
|
||||
adjustment: 'Ajuste de inventário',
|
||||
};
|
||||
export default function Operations({
|
||||
items,
|
||||
warehouses,
|
||||
users,
|
||||
balances,
|
||||
operations,
|
||||
counts,
|
||||
canOperate,
|
||||
demo = false,
|
||||
equipment = [],
|
||||
}: Props) {
|
||||
const [message, setMessage] = useState('');
|
||||
const form = useForm({
|
||||
kind: 'issue',
|
||||
reference: '',
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
stock_item_id: String(items[0]?.id ?? ''),
|
||||
warehouse_id: String(warehouses[0]?.id ?? ''),
|
||||
destination_warehouse_id: '',
|
||||
responsible_id: '',
|
||||
destination: '',
|
||||
reason: '',
|
||||
quantity: '',
|
||||
count_id: '',
|
||||
ledger_version: 0,
|
||||
equipment_id: '',
|
||||
});
|
||||
const balance = balances.find(
|
||||
(b) =>
|
||||
String(b.stock_item_id) === form.data.stock_item_id &&
|
||||
String(b.warehouse_id) === form.data.warehouse_id,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Head title="Operações de stock" />
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow="Armazém"
|
||||
title="Saídas, transferências e inventários"
|
||||
description="Movimentos com fundamento, contagens físicas e histórico preservado."
|
||||
/>
|
||||
<InventoryNotice />
|
||||
{canOperate && form.data.kind === 'issue' && (
|
||||
<label className="grid gap-2 text-sm">
|
||||
Equipamento de destino (obrigatório para peças
|
||||
associadas)
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.equipment_id}
|
||||
onChange={(e) =>
|
||||
form.setData('equipment_id', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Sem equipamento</option>
|
||||
{equipment.map((e) => (
|
||||
<option key={e.id} value={e.id}>
|
||||
{e.code} · {e.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{demo && (
|
||||
<p className="rounded-lg border bg-teal-50 p-4 text-sm text-teal-900">
|
||||
Demonstração pública com dados fictícios. O formulário
|
||||
apenas simula a revisão.
|
||||
</p>
|
||||
)}
|
||||
<Panel
|
||||
title="Registar operação"
|
||||
description="Só Recepção / Armazém executa movimentos. Administração, Procurement e Finanças consultam. Periodicidade, tolerâncias e investigação de divergências aguardam decisão da empresa."
|
||||
>
|
||||
{canOperate ? (
|
||||
<form
|
||||
className="grid gap-4 sm:grid-cols-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (demo) {
|
||||
setMessage(
|
||||
'Simulação revista. Nenhum movimento real foi criado.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
form.transform((d) => ({
|
||||
...d,
|
||||
ledger_version: balance?.version ?? 0,
|
||||
}));
|
||||
form.post('/stock/operacoes', {
|
||||
onSuccess: () => {
|
||||
form.setData({
|
||||
...form.data,
|
||||
idempotency_key:
|
||||
crypto.randomUUID(),
|
||||
reference: '',
|
||||
quantity: '',
|
||||
reason: '',
|
||||
});
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm">
|
||||
Operação
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.kind}
|
||||
onChange={(e) =>
|
||||
form.setData('kind', e.target.value)
|
||||
}
|
||||
>
|
||||
{Object.entries(kinds).map(([k, v]) => (
|
||||
<option key={k} value={k}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm">
|
||||
Referência única do documento
|
||||
<Input
|
||||
required
|
||||
value={form.data.reference}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'reference',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm">
|
||||
Artigo
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.stock_item_id}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'stock_item_id',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
{items.map((i) => (
|
||||
<option key={i.id} value={i.id}>
|
||||
{i.code} · {i.name} ({i.unit})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm">
|
||||
Armazém de origem / contagem
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.warehouse_id}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'warehouse_id',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
{warehouses.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.code} · {w.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-sm font-semibold sm:col-span-2">
|
||||
Stock teórico:{' '}
|
||||
{quantity(balance?.quantity ?? 0)} · versão{' '}
|
||||
{balance?.version ?? 0}. Confirme a contagem
|
||||
física sobre este saldo; movimentos posteriores
|
||||
exigem nova contagem.
|
||||
</p>
|
||||
{form.data.kind === 'transfer' && (
|
||||
<label className="grid gap-2 text-sm">
|
||||
Armazém de destino
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={
|
||||
form.data.destination_warehouse_id
|
||||
}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'destination_warehouse_id',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Seleccionar</option>
|
||||
{warehouses
|
||||
.filter(
|
||||
(w) =>
|
||||
String(w.id) !==
|
||||
form.data.warehouse_id,
|
||||
)
|
||||
.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{form.data.kind === 'issue' && (
|
||||
<label className="grid gap-2 text-sm">
|
||||
Destino (departamento, requisição ou
|
||||
utilização)
|
||||
<Input
|
||||
required
|
||||
value={form.data.destination}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'destination',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{form.data.kind === 'adjustment' ? (
|
||||
<label className="grid gap-2 text-sm">
|
||||
Contagem a ajustar
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={form.data.count_id}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'count_id',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
Seleccionar contagem
|
||||
</option>
|
||||
{counts
|
||||
.filter(
|
||||
(c) =>
|
||||
String(c.warehouse_id) ===
|
||||
form.data
|
||||
.warehouse_id &&
|
||||
String(c.stock_item_id) ===
|
||||
form.data.stock_item_id,
|
||||
)
|
||||
.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.reference}: contado{' '}
|
||||
{quantity(c.quantity_milli)}
|
||||
, teórico{' '}
|
||||
{quantity(
|
||||
c.theoretical_milli,
|
||||
)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<label className="grid gap-2 text-sm">
|
||||
{form.data.kind === 'count'
|
||||
? 'Quantidade física contada (inclui zero)'
|
||||
: 'Quantidade'}
|
||||
<Input
|
||||
required
|
||||
type="number"
|
||||
min={
|
||||
form.data.kind === 'count'
|
||||
? 0
|
||||
: 0.001
|
||||
}
|
||||
step="0.001"
|
||||
value={form.data.quantity}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'quantity',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="grid gap-2 text-sm">
|
||||
Responsável
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={form.data.responsible_id}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'responsible_id',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Seleccionar</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm sm:col-span-2">
|
||||
Fundamento / explicação da divergência
|
||||
<textarea
|
||||
required
|
||||
className={controlClass}
|
||||
value={form.data.reason}
|
||||
onChange={(e) =>
|
||||
form.setData('reason', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className="sm:col-span-2">
|
||||
<FormErrors errors={form.errors} />
|
||||
<Button disabled={form.processing}>
|
||||
{demo
|
||||
? 'Rever simulação'
|
||||
: 'Confirmar operação'}
|
||||
</Button>
|
||||
<p role="status" className="mt-3 text-sm">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<p className="text-sm">
|
||||
Consulta do histórico. O seu perfil não executa
|
||||
operações de armazém.
|
||||
</p>
|
||||
)}
|
||||
</Panel>
|
||||
<Panel
|
||||
title="Histórico auditado"
|
||||
description="Contagens não alteram o saldo. Ajustes usam a diferença exacta de uma contagem válida; transferências registam saída e entrada na mesma transacção."
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{operations.data.map((o) => (
|
||||
<article
|
||||
key={o.id}
|
||||
className="rounded-lg border p-4 text-sm"
|
||||
>
|
||||
<div className="flex flex-wrap justify-between gap-2">
|
||||
<strong>
|
||||
{o.reference} · {kinds[o.kind]}
|
||||
</strong>
|
||||
<time>
|
||||
{new Date(o.recorded_at).toLocaleString(
|
||||
'pt-MZ',
|
||||
)}
|
||||
</time>
|
||||
</div>
|
||||
<p className="mt-2">
|
||||
{o.snapshot.stock_item.name} ·{' '}
|
||||
{quantity(o.quantity_milli)}{' '}
|
||||
{o.snapshot.stock_item.unit} ·{' '}
|
||||
{o.snapshot.warehouse.name}
|
||||
{o.snapshot.destination_warehouse &&
|
||||
` → ${o.snapshot.destination_warehouse.name}`}
|
||||
{o.destination && ` → ${o.destination}`}
|
||||
</p>
|
||||
{(o.kind === 'count' ||
|
||||
o.kind === 'adjustment') && (
|
||||
<p>
|
||||
Teórico: {quantity(o.theoretical_milli)}{' '}
|
||||
· Contado: {quantity(o.quantity_milli)}{' '}
|
||||
· Divergência:{' '}
|
||||
{quantity(
|
||||
o.quantity_milli -
|
||||
o.theoretical_milli,
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2">{o.reason}</p>
|
||||
{o.snapshot.equipment && (
|
||||
<p className="mt-2">
|
||||
Equipamento no momento da saída:{' '}
|
||||
{o.snapshot.equipment.code} ·{' '}
|
||||
{o.snapshot.equipment.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Registado por {o.snapshot.actor.name} ·
|
||||
Responsável: {o.snapshot.responsible.name}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
{operations.data.length === 0 && (
|
||||
<p className="text-sm">Sem operações registadas.</p>
|
||||
)}
|
||||
</div>
|
||||
<InventoryPagination page={operations} />
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function OperationsDemo() {
|
||||
const snapshot = {
|
||||
actor: { name: 'Operador fictício de armazém' },
|
||||
responsible: { name: 'Responsável fictício de produção' },
|
||||
stock_item: { name: 'Papel A4 de exemplo', unit: 'resma' },
|
||||
warehouse: { name: 'Central fictício' },
|
||||
destination_warehouse: null,
|
||||
};
|
||||
const base = {
|
||||
stock_item_id: 1,
|
||||
warehouse_id: 1,
|
||||
recorded_at: '2026-10-28T10:00:00+02:00',
|
||||
snapshot,
|
||||
theoretical_milli: 12000,
|
||||
};
|
||||
const data: Operation[] = [
|
||||
{
|
||||
...base,
|
||||
id: 3,
|
||||
kind: 'count',
|
||||
reference: 'INV-DEMO-003',
|
||||
quantity_milli: 11000,
|
||||
reason: 'Contagem física fictícia: menos uma resma que o saldo teórico.',
|
||||
},
|
||||
{
|
||||
...base,
|
||||
id: 2,
|
||||
kind: 'transfer',
|
||||
reference: 'TRF-DEMO-002',
|
||||
quantity_milli: 3000,
|
||||
reason: 'Reposição do armazém secundário.',
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
destination_warehouse: { name: 'Secundário fictício' },
|
||||
},
|
||||
},
|
||||
{
|
||||
...base,
|
||||
id: 1,
|
||||
kind: 'issue',
|
||||
reference: 'SAI-DEMO-001',
|
||||
quantity_milli: 2000,
|
||||
destination: 'Produção fictícia · REQ-DEMO-001',
|
||||
reason: 'Consumo de material na produção.',
|
||||
},
|
||||
{
|
||||
...base,
|
||||
id: 4,
|
||||
kind: 'adjustment',
|
||||
reference: 'AJ-DEMO-004',
|
||||
quantity_milli: 11000,
|
||||
reason: 'Exemplo independente de ajuste de −1 resma: divergência física confirmada e fundamentada na contagem INV-DEMO-004.',
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Operations
|
||||
demo
|
||||
canOperate
|
||||
items={[
|
||||
{
|
||||
id: 1,
|
||||
code: 'PAP-DEMO',
|
||||
name: 'Papel A4 fictício',
|
||||
unit: 'resma',
|
||||
},
|
||||
]}
|
||||
warehouses={[
|
||||
{ id: 1, code: 'CENTRAL', name: 'Central fictício' },
|
||||
{ id: 2, code: 'SEC', name: 'Secundário fictício' },
|
||||
]}
|
||||
users={[{ id: 1, name: 'Responsável fictício' }]}
|
||||
balances={[
|
||||
{
|
||||
stock_item_id: 1,
|
||||
warehouse_id: 1,
|
||||
quantity: 12000,
|
||||
version: 3,
|
||||
},
|
||||
]}
|
||||
counts={[data[0]]}
|
||||
operations={{ data, prev_page_url: null, next_page_url: null }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
export type StockItem = {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
};
|
||||
export type Warehouse = { id: number; code: string; name: string };
|
||||
export type ReceiptLineProgress = {
|
||||
id: number;
|
||||
description: string;
|
||||
unit: string;
|
||||
purchase_type: string;
|
||||
ordered_milli: number;
|
||||
received_milli: number;
|
||||
accepted_milli: number;
|
||||
rejected_milli: number;
|
||||
delivery_pending_milli: number;
|
||||
acceptance_pending_milli: number;
|
||||
};
|
||||
export type ReceiptProgress = {
|
||||
lines: ReceiptLineProgress[];
|
||||
state: string;
|
||||
has_divergences: boolean;
|
||||
};
|
||||
export type ReceiptItem = {
|
||||
id: number;
|
||||
received_milli: number;
|
||||
accepted_milli: number;
|
||||
rejected_milli: number;
|
||||
quality_status: string;
|
||||
notes: string | null;
|
||||
snapshot: {
|
||||
order_item: { description: string; unit: string };
|
||||
stock_item: StockItem | null;
|
||||
warehouse: Warehouse | null;
|
||||
physical: boolean;
|
||||
};
|
||||
};
|
||||
export type Receipt = {
|
||||
id: number;
|
||||
number: string;
|
||||
purchase_order_id: number;
|
||||
delivery_note: string;
|
||||
received_on: string;
|
||||
confirmed_at: string;
|
||||
status: string;
|
||||
snapshot: {
|
||||
order: { id: number; number: string };
|
||||
supplier: { name: string };
|
||||
receiver: { name: string };
|
||||
warehouse: Warehouse | null;
|
||||
};
|
||||
items: ReceiptItem[];
|
||||
};
|
||||
export type StockMovement = {
|
||||
id: number;
|
||||
quantity_milli: number;
|
||||
moved_at: string;
|
||||
moved_by: number;
|
||||
stock_item_id: number;
|
||||
warehouse_id: number;
|
||||
movement_type: string;
|
||||
stock_operation_id?: number | null;
|
||||
snapshot: {
|
||||
stock_item: StockItem;
|
||||
warehouse: Warehouse;
|
||||
order_number?: string;
|
||||
delivery_note?: string;
|
||||
receipt_id?: number;
|
||||
reference?: string;
|
||||
reason?: string;
|
||||
equipment?: { id: number; code: string; description: string } | null;
|
||||
};
|
||||
};
|
||||
export type Page<T> = {
|
||||
data: T[];
|
||||
prev_page_url: string | null;
|
||||
next_page_url: string | null;
|
||||
};
|
||||
export const quantity = (milli: number) =>
|
||||
new Intl.NumberFormat('pt-MZ', { maximumFractionDigits: 3 }).format(
|
||||
milli / 1000,
|
||||
);
|
||||
export const receiptStates: Record<string, string> = {
|
||||
not_received: 'Sem recepção',
|
||||
partial: 'Recepção parcial',
|
||||
complete: 'Recepção total aceite',
|
||||
rejected: 'Recepção rejeitada',
|
||||
quality_pending: 'Entrega completa com divergências',
|
||||
};
|
||||
export const qualityStates: Record<string, string> = {
|
||||
compliant: 'Conforme',
|
||||
reservations: 'Conforme com reservas',
|
||||
nonconforming: 'Não conforme',
|
||||
};
|
||||
@@ -18,6 +18,10 @@ export type RequisitionItem = {
|
||||
description: string;
|
||||
category: string;
|
||||
purchase_type: string;
|
||||
equipment_id?: number | null;
|
||||
spare_part_id?: number | null;
|
||||
production_area?: string | null;
|
||||
downtime_started_at?: string | null;
|
||||
quantity: string;
|
||||
unit: string;
|
||||
unit_price_minor: number;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ $order->number }} · ProcuraFlow</title>
|
||||
<title>{{ $order->number }} · SGC</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 17mm; }
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
+19
-3
@@ -1,9 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
|
||||
// Public visual consultation. No business records or authenticated identities.
|
||||
Route::get('consulta', fn () => Inertia::render('preview/index', [
|
||||
'auth' => ['user' => null],
|
||||
]))->name('preview');
|
||||
Route::get('consulta', function (Request $request) {
|
||||
$screen = $request->query('ecra', 'overview');
|
||||
$allowed = ['overview', 'requests', 'suppliers', 'quotes', 'conflicts', 'orders', 'receipts', 'stock', 'operations', 'equipment', 'appearance'];
|
||||
|
||||
return Inertia::render('preview/index', [
|
||||
'auth' => ['user' => null],
|
||||
'initialScreen' => in_array($screen, $allowed, true) ? $screen : 'overview',
|
||||
]);
|
||||
})->name('preview');
|
||||
|
||||
Route::get('consulta/{screen}', function (string $screen) {
|
||||
$screens = ['requisicoes' => 'requests', 'fornecedores' => 'suppliers', 'cotacoes' => 'quotes', 'conflitos' => 'conflicts', 'ordens' => 'orders', 'recepcoes' => 'receipts', 'stock' => 'stock', 'aparencia' => 'appearance'];
|
||||
$screens['operacoes-stock'] = 'operations';
|
||||
$screens['equipamentos'] = 'equipment';
|
||||
abort_unless(isset($screens[$screen]), 404);
|
||||
|
||||
return Inertia::render('preview/index', ['auth' => ['user' => null], 'initialScreen' => $screens[$screen]]);
|
||||
})->name('preview.screen');
|
||||
|
||||
+17
-1
@@ -2,15 +2,19 @@
|
||||
|
||||
use App\Http\Controllers\CompanyController;
|
||||
use App\Http\Controllers\ConflictDeclarationController;
|
||||
use App\Http\Controllers\EquipmentController;
|
||||
use App\Http\Controllers\GoodsReceiptController;
|
||||
use App\Http\Controllers\PurchaseController;
|
||||
use App\Http\Controllers\QuotationController;
|
||||
use App\Http\Controllers\RequisitionController;
|
||||
use App\Http\Controllers\StockController;
|
||||
use App\Http\Controllers\StockOperationController;
|
||||
use App\Http\Controllers\SupplierController;
|
||||
use App\Http\Controllers\SupplierDocumentController;
|
||||
use App\Http\Middleware\EnsureCompanyIsConfigured;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::redirect('/', '/consulta')->name('home');
|
||||
Route::inertia('/', 'home', ['auth' => ['user' => null]])->name('home');
|
||||
require __DIR__.'/preview.php';
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
@@ -19,6 +23,18 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::post('empresa/iniciar', [CompanyController::class, 'store'])->name('company.store');
|
||||
|
||||
Route::middleware(EnsureCompanyIsConfigured::class)->group(function () {
|
||||
Route::get('equipamentos', [EquipmentController::class, 'index'])->name('equipment.index');
|
||||
Route::post('equipamentos/{kind}', [EquipmentController::class, 'store'])->name('equipment.store');
|
||||
Route::post('requisicoes/{requisition}/validacao-tecnica', [RequisitionController::class, 'technical'])->name('requisitions.technical');
|
||||
Route::get('recepcoes', [GoodsReceiptController::class, 'index'])->name('receipts.index');
|
||||
Route::get('ordens/{order}/recepcoes', [GoodsReceiptController::class, 'order'])->name('receipts.order');
|
||||
Route::post('ordens/{order}/recepcoes', [GoodsReceiptController::class, 'store'])->name('receipts.store');
|
||||
Route::get('recepcoes/{receipt}', [GoodsReceiptController::class, 'show'])->name('receipts.show');
|
||||
Route::get('stock', [StockController::class, 'index'])->name('stock.index');
|
||||
Route::get('stock/operacoes', [StockOperationController::class, 'index'])->name('stock.operations');
|
||||
Route::post('stock/operacoes', [StockOperationController::class, 'store'])->name('stock.operations.store');
|
||||
Route::post('stock/artigos', [StockController::class, 'item'])->name('stock.items');
|
||||
Route::post('stock/armazens', [StockController::class, 'warehouse'])->name('stock.warehouses');
|
||||
Route::get('requisicoes/{requisition}/adjudicacao', [PurchaseController::class, 'award'])->name('purchases.award');
|
||||
Route::post('requisicoes/{requisition}/adjudicacao', [PurchaseController::class, 'select'])->name('purchases.select');
|
||||
Route::post('adjudicacoes/{award}/decidir', [PurchaseController::class, 'decide'])->name('purchases.decide');
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Console\Kernel as ConsoleKernel;
|
||||
use Illuminate\Contracts\Http\Kernel as HttpKernel;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
require __DIR__.'/../../vendor/autoload.php';
|
||||
|
||||
$database = $argv[1] ?? '';
|
||||
if (! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) {
|
||||
fwrite(STDERR, 'Base de testes inválida.');
|
||||
exit(2);
|
||||
}
|
||||
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||
$app->make(ConsoleKernel::class)->bootstrap();
|
||||
if (! $app->environment('testing')) {
|
||||
throw new LogicException('O worker só pode executar em APP_ENV=testing.');
|
||||
}
|
||||
$config = config('database.connections.pgsql');
|
||||
$config['database'] = $database;
|
||||
$config['url'] = null;
|
||||
config(['database.default' => 'pgsql', 'database.connections.pgsql' => $config]);
|
||||
DB::purge('pgsql');
|
||||
if (DB::selectOne('select current_database() as name')->name !== $database) {
|
||||
throw new LogicException('A ligação não corresponde à base de testes.');
|
||||
}
|
||||
DB::select('select set_config(?, ?, false)', ['application_name', 'procura_receipt_'.$argv[4]]);
|
||||
$pid = DB::selectOne('select pg_backend_pid() as pid')->pid;
|
||||
auth()->guard()->setUser(User::query()->findOrFail($argv[2]));
|
||||
$payload = json_decode(stream_get_contents(STDIN), true, 512, JSON_THROW_ON_ERROR);
|
||||
$request = Request::create('/ordens/'.$argv[3].'/recepcoes', 'POST', $payload, server: ['HTTP_ACCEPT' => 'application/json']);
|
||||
$kernel = $app->make(HttpKernel::class);
|
||||
$response = $kernel->handle($request);
|
||||
$body = json_decode($response->getContent(), true);
|
||||
$kernel->terminate($request, $response);
|
||||
echo json_encode(['status' => $response->getStatusCode(), 'id' => $body['id'] ?? null, 'created' => $body['created'] ?? null, 'error_fields' => array_keys($body['errors'] ?? []), 'backend_pid' => $pid], JSON_THROW_ON_ERROR);
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
use App\Models\GoodsReceipt;
|
||||
use App\Models\StockMovement;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
require __DIR__.'/../../vendor/autoload.php';
|
||||
|
||||
// Process-only settings: the project's .env and live database are never rewritten.
|
||||
foreach (['APP_ENV' => 'testing', 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array', 'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array'] as $key => $value) {
|
||||
putenv($key.'='.$value);
|
||||
$_ENV[$key] = $_SERVER[$key] = $value;
|
||||
}
|
||||
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
require __DIR__.'/../Support/ReceiptFixture.php';
|
||||
|
||||
$original = DB::connection();
|
||||
if ($original->getDriverName() !== 'pgsql') {
|
||||
throw new LogicException('Este ensaio exige a ligação PostgreSQL local.');
|
||||
}
|
||||
$originalDatabase = $original->getDatabaseName();
|
||||
$effectiveConfig = $original->getConfig();
|
||||
$admin = $original->getPdo();
|
||||
$database = 'compras_receipts_test_'.bin2hex(random_bytes(6));
|
||||
if ($database === $originalDatabase || ! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) {
|
||||
throw new LogicException('Nome de base de testes não autorizado.');
|
||||
}
|
||||
$liveUsersBefore = $admin->query('SELECT COUNT(*) FROM users')->fetchColumn();
|
||||
$created = false;
|
||||
$processes = [];
|
||||
$report = ['tested_at_utc' => gmdate('c'), 'database' => $database, 'separate_database' => true, 'transport' => 'Laravel HTTP kernel in independent PHP processes; real PostgreSQL connections', 'scenarios' => []];
|
||||
try {
|
||||
$admin->exec('CREATE DATABASE "'.$database.'"');
|
||||
$created = true;
|
||||
$isolated = $effectiveConfig;
|
||||
$isolated['url'] = null;
|
||||
$isolated['database'] = $database;
|
||||
// Replace both PostgreSQL aliases in this process and discard any schema facade
|
||||
// resolved by providers while the live configuration was being read.
|
||||
config(['database.connections.receipt_concurrency' => $isolated, 'database.connections.pgsql' => $isolated]);
|
||||
DB::purge('pgsql');
|
||||
DB::setDefaultConnection('receipt_concurrency');
|
||||
Schema::clearResolvedInstance('db.schema');
|
||||
app()->forgetInstance('db.schema');
|
||||
if (DB::selectOne('select current_database() as name')->name !== $database) {
|
||||
throw new LogicException('Migração recusada: ligação não isolada.');
|
||||
}
|
||||
if (Schema::getConnection()->getDatabaseName() !== $database || DB::connection('pgsql')->getDatabaseName() !== $database) {
|
||||
throw new LogicException('Migração recusada: esquema ou alias PostgreSQL não isolado.');
|
||||
}
|
||||
Artisan::call('migrate', ['--database' => 'receipt_concurrency', '--force' => true]);
|
||||
$workerEnv = [
|
||||
'APP_ENV' => 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_URL' => '', 'DB_DATABASE' => $database,
|
||||
'DB_HOST' => $effectiveConfig['host'], 'DB_PORT' => (string) $effectiveConfig['port'],
|
||||
'DB_USERNAME' => $effectiveConfig['username'], 'DB_PASSWORD' => $effectiveConfig['password'],
|
||||
'DB_SSLMODE' => $effectiveConfig['sslmode'] ?? 'prefer',
|
||||
'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array', 'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array',
|
||||
];
|
||||
foreach (['over_quantity', 'same_confirmation', 'complementary_quantities', 'same_delivery_note'] as $scenario) {
|
||||
$fixture = receiptFixture();
|
||||
$first = receiptPayload($fixture, $scenario === 'complementary_quantities' ? '0.400' : '0.600');
|
||||
$second = receiptPayload($fixture, '0.600');
|
||||
if ($scenario === 'same_confirmation') {
|
||||
$second = $first;
|
||||
} elseif ($scenario === 'same_delivery_note') {
|
||||
$second['delivery_note'] = $first['delivery_note'];
|
||||
}
|
||||
$connection = DB::connection();
|
||||
$connection->beginTransaction();
|
||||
// Hold the order so both independent requests are demonstrably waiting for locks before release.
|
||||
$connection->table('purchase_orders')->where('id', $fixture['order']->id)->lockForUpdate()->first();
|
||||
$processes = [];
|
||||
foreach ([$first, $second] as $index => $payload) {
|
||||
$label = $index === 0 ? 'A' : 'B';
|
||||
$user = $index === 0 ? $fixture['receiver'] : $fixture['otherReceiver'];
|
||||
$process = new Process([PHP_BINARY, __DIR__.'/receipt-worker.php', $database, (string) $user->id, (string) $fixture['order']->id, $label], base_path(), $workerEnv);
|
||||
$process->setInput(json_encode($payload, JSON_THROW_ON_ERROR));
|
||||
$process->setTimeout(45);
|
||||
$process->start();
|
||||
$processes[] = $process;
|
||||
}
|
||||
$statement = $admin->prepare("SELECT count(*) FROM pg_stat_activity WHERE datname = ? AND application_name IN ('procura_receipt_A','procura_receipt_B') AND wait_event_type = 'Lock'");
|
||||
$deadline = microtime(true) + 20;
|
||||
$waits = 0;
|
||||
do {
|
||||
$statement->execute([$database]);
|
||||
$waits = (int) $statement->fetchColumn();
|
||||
if ($waits >= 2) {
|
||||
break;
|
||||
}
|
||||
foreach ($processes as $process) {
|
||||
if (! $process->isRunning()) {
|
||||
throw new RuntimeException('Worker terminou antes da sobreposição: '.$process->getOutput().' '.$process->getErrorOutput());
|
||||
}
|
||||
}
|
||||
usleep(20000);
|
||||
} while (microtime(true) < $deadline);
|
||||
$connection->commit();
|
||||
if ($waits < 2) {
|
||||
throw new RuntimeException('Não foi demonstrada espera simultânea pelos bloqueios.');
|
||||
}
|
||||
$results = [];
|
||||
foreach ($processes as $process) {
|
||||
$process->wait();
|
||||
if (! $process->isSuccessful()) {
|
||||
throw new RuntimeException('Falha do worker: '.$process->getErrorOutput());
|
||||
}
|
||||
$results[] = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
$statuses = array_column($results, 'status');
|
||||
sort($statuses);
|
||||
$expectedStatuses = $scenario === 'same_confirmation' ? [200, 201] : ($scenario === 'complementary_quantities' ? [201, 201] : [201, 422]);
|
||||
$expectedQuantity = $scenario === 'complementary_quantities' ? 1000 : 600;
|
||||
$expectedCount = $scenario === 'complementary_quantities' ? 2 : 1;
|
||||
$progress = app(ReceiptWorkflow::class)->progress($fixture['order']);
|
||||
if ($statuses !== $expectedStatuses || GoodsReceipt::query()->count() !== $expectedCount || StockMovement::query()->count() !== $expectedCount ||
|
||||
(int) StockMovement::query()->sum('quantity_milli') !== $expectedQuantity || $progress['lines'][0]['received_milli'] !== $expectedQuantity || count(array_unique(array_column($results, 'backend_pid'))) !== 2) {
|
||||
throw new RuntimeException('Resultado de concorrência inesperado: '.json_encode($results));
|
||||
}
|
||||
if ($scenario === 'complementary_quantities' && $progress['state'] !== 'complete') {
|
||||
throw new RuntimeException('A soma exacta em PostgreSQL não concluiu a recepção.');
|
||||
}
|
||||
$report['scenarios'][] = ['name' => $scenario, 'passed' => true, 'overlapping_lock_waits' => $waits, 'responses' => $results, 'receipts' => $expectedCount, 'stock_movements' => $expectedCount, 'quantity_milli' => $expectedQuantity, 'order_state' => $progress['state']];
|
||||
}
|
||||
$report['passed'] = true;
|
||||
} catch (Throwable $error) {
|
||||
$report['passed'] = false;
|
||||
$report['failure'] = $error->getMessage();
|
||||
} finally {
|
||||
foreach ($processes as $process) {
|
||||
if ($process->isRunning()) {
|
||||
$process->stop();
|
||||
}
|
||||
}
|
||||
if (isset($connection) && $connection->transactionLevel() > 0) {
|
||||
$connection->rollBack();
|
||||
}
|
||||
DB::disconnect('receipt_concurrency');
|
||||
DB::disconnect('pgsql');
|
||||
if ($created && preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database) && $database !== $originalDatabase) {
|
||||
$admin->exec('DROP DATABASE "'.$database.'"');
|
||||
$report['test_database_dropped'] = true;
|
||||
}
|
||||
$report['live_user_count_unchanged'] = $admin->query('SELECT COUNT(*) FROM users')->fetchColumn() === $liveUsersBefore;
|
||||
}
|
||||
file_put_contents(__DIR__.'/../../docs/recepcao-concorrencia-postgresql.json', json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL);
|
||||
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
exit(($report['passed'] ?? false) ? 0 : 1);
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\StockOperationController;
|
||||
use App\Models\CriticalityLevel;
|
||||
use App\Models\Equipment;
|
||||
use App\Models\EquipmentSparePart;
|
||||
use App\Models\SparePart;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\StockOperation;
|
||||
use App\Models\Warehouse;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\StockWorkflow;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
require __DIR__.'/../../vendor/autoload.php';
|
||||
|
||||
// Process-only settings: the project's .env and live database are never rewritten.
|
||||
foreach (['APP_ENV' => 'testing', 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array', 'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array'] as $key => $value) {
|
||||
putenv($key.'='.$value);
|
||||
$_ENV[$key] = $_SERVER[$key] = $value;
|
||||
}
|
||||
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
require __DIR__.'/../Support/ReceiptFixture.php';
|
||||
|
||||
$original = DB::connection();
|
||||
if ($original->getDriverName() !== 'pgsql') {
|
||||
throw new LogicException('Este ensaio exige a ligação PostgreSQL local.');
|
||||
}
|
||||
$originalDatabase = $original->getDatabaseName();
|
||||
$effectiveConfig = $original->getConfig();
|
||||
$admin = $original->getPdo();
|
||||
$database = 'compras_receipts_test_'.bin2hex(random_bytes(6));
|
||||
if ($database === $originalDatabase || ! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) {
|
||||
throw new LogicException('Nome de base de testes não autorizado.');
|
||||
}
|
||||
$liveUsersBefore = $admin->query('SELECT COUNT(*) FROM users')->fetchColumn();
|
||||
$created = false;
|
||||
$processes = [];
|
||||
$report = ['tested_at_utc' => gmdate('c'), 'database' => $database, 'separate_database' => true, 'transport' => 'Laravel HTTP kernel in independent PHP processes; real PostgreSQL connections', 'scenarios' => []];
|
||||
try {
|
||||
$admin->exec('CREATE DATABASE "'.$database.'"');
|
||||
$created = true;
|
||||
$isolated = $effectiveConfig;
|
||||
$isolated['url'] = null;
|
||||
$isolated['database'] = $database;
|
||||
// Replace both PostgreSQL aliases in this process and discard any schema facade
|
||||
// resolved by providers while the live configuration was being read.
|
||||
config(['database.connections.receipt_concurrency' => $isolated, 'database.connections.pgsql' => $isolated]);
|
||||
DB::purge('pgsql');
|
||||
DB::setDefaultConnection('receipt_concurrency');
|
||||
Schema::clearResolvedInstance('db.schema');
|
||||
app()->forgetInstance('db.schema');
|
||||
if (DB::selectOne('select current_database() as name')->name !== $database) {
|
||||
throw new LogicException('Migração recusada: ligação não isolada.');
|
||||
}
|
||||
if (Schema::getConnection()->getDatabaseName() !== $database || DB::connection('pgsql')->getDatabaseName() !== $database) {
|
||||
throw new LogicException('Migração recusada: esquema ou alias PostgreSQL não isolado.');
|
||||
}
|
||||
Artisan::call('migrate', ['--database' => 'receipt_concurrency', '--force' => true]);
|
||||
// Hydrated models retain their connection name. Use one alias for the entire
|
||||
// fixture/workflow transaction so FK checks never wait on our other connection.
|
||||
DB::disconnect('receipt_concurrency');
|
||||
DB::setDefaultConnection('pgsql');
|
||||
DB::statement("SET lock_timeout = '25s'");
|
||||
$workerEnv = [
|
||||
'APP_ENV' => 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_URL' => '', 'DB_DATABASE' => $database,
|
||||
'DB_HOST' => $effectiveConfig['host'], 'DB_PORT' => (string) $effectiveConfig['port'],
|
||||
'DB_USERNAME' => $effectiveConfig['username'], 'DB_PASSWORD' => $effectiveConfig['password'],
|
||||
'DB_SSLMODE' => $effectiveConfig['sslmode'] ?? 'prefer',
|
||||
'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array', 'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array',
|
||||
];
|
||||
foreach (['over_issue', 'same_issue', 'same_transfer', 'opposing_transfers', 'same_adjustment', 'stale_adjustment', 'spare_part_issue'] as $scenario) {
|
||||
$fixture = receiptFixture();
|
||||
$equipment = null;
|
||||
if ($scenario === 'spare_part_issue') {
|
||||
$level = CriticalityLevel::query()->create(['tenant_id' => $fixture['tenant']->id, 'created_by' => $fixture['receiver']->id, 'code' => 'CRIT-PG', 'name' => 'Critério fictício', 'criteria' => 'Somente para concorrência isolada', 'is_critical' => true, 'created_at' => now()]);
|
||||
$equipment = Equipment::query()->create(['tenant_id' => $fixture['tenant']->id, 'created_by' => $fixture['receiver']->id, 'department_id' => $fixture['department']->id, 'criticality_level_id' => $level->id, 'code' => 'EQ-PG', 'description' => 'Equipamento fictício', 'area' => 'Linha fictícia', 'status' => 'stopped']);
|
||||
$part = SparePart::query()->create(['tenant_id' => $fixture['tenant']->id, 'created_by' => $fixture['receiver']->id, 'stock_item_id' => $fixture['stock']->id, 'criticality_level_id' => $level->id, 'minimum_milli' => 1000]);
|
||||
EquipmentSparePart::query()->create(['tenant_id' => $fixture['tenant']->id, 'created_by' => $fixture['receiver']->id, 'equipment_id' => $equipment->id, 'spare_part_id' => $part->id, 'created_at' => now()]);
|
||||
}
|
||||
app(ReceiptWorkflow::class)->confirm($fixture['receiver'], $fixture['order'], receiptPayload($fixture, '1.000'));
|
||||
$destination = Warehouse::query()->create(['tenant_id' => $fixture['tenant']->id, 'created_by' => $fixture['receiver']->id, 'code' => 'DEST', 'name' => 'Destino fictício']);
|
||||
$first = ['kind' => 'issue', 'reference' => 'SAI-A', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $fixture['warehouse']->id, 'stock_item_id' => $fixture['stock']->id, 'responsible_id' => $fixture['issuer']->id, 'destination' => 'Produção fictícia', 'reason' => 'Ensaio isolado de concorrência', 'quantity' => '0.600'];
|
||||
if ($equipment) {
|
||||
$first['equipment_id'] = $equipment->id;
|
||||
}
|
||||
$second = [...$first, 'reference' => 'SAI-B', 'idempotency_key' => (string) Str::uuid()];
|
||||
if ($scenario === 'same_issue') {
|
||||
$second = $first;
|
||||
} elseif (in_array($scenario, ['same_transfer', 'opposing_transfers'])) {
|
||||
$first['kind'] = 'transfer';
|
||||
$first['destination_warehouse_id'] = $destination->id;
|
||||
$second = $first;
|
||||
if ($scenario === 'opposing_transfers') {
|
||||
$second['warehouse_id'] = $destination->id;
|
||||
$second['destination_warehouse_id'] = $fixture['warehouse']->id;
|
||||
$second['reference'] = 'TR-B';
|
||||
$second['idempotency_key'] = (string) Str::uuid();
|
||||
$second['quantity'] = '0.100';
|
||||
}
|
||||
} elseif (in_array($scenario, ['same_adjustment', 'stale_adjustment'])) {
|
||||
$countPayload = [...$first, 'kind' => 'count', 'quantity' => '0.800', 'ledger_version' => (int) StockMovement::query()->max('id')];
|
||||
// Use the HTTP validation/canonicalisation path for the count as well.
|
||||
$request = Request::create('/stock/operacoes', 'POST', $countPayload, server: ['HTTP_ACCEPT' => 'application/json']);
|
||||
$request->setUserResolver(fn () => $fixture['receiver']);
|
||||
$response = app(StockOperationController::class)->store($request, app(StockWorkflow::class));
|
||||
$countId = json_decode($response->getContent(), true)['id'];
|
||||
$first = [...$first, 'kind' => 'adjustment', 'reference' => 'AJ-A', 'count_id' => $countId, 'idempotency_key' => (string) Str::uuid()];
|
||||
$second = $first;
|
||||
if ($scenario === 'stale_adjustment') {
|
||||
$issue = [...$countPayload, 'kind' => 'issue', 'reference' => 'SAI-BEFORE', 'idempotency_key' => (string) Str::uuid(), 'quantity' => '0.100'];
|
||||
$request = Request::create('/stock/operacoes', 'POST', $issue, server: ['HTTP_ACCEPT' => 'application/json']);
|
||||
$request->setUserResolver(fn () => $fixture['receiver']);
|
||||
app(StockOperationController::class)->store($request, app(StockWorkflow::class));
|
||||
}
|
||||
}
|
||||
$connection = DB::connection();
|
||||
$connection->beginTransaction();
|
||||
$connection->table('tenants')->where('id', $fixture['tenant']->id)->lockForUpdate()->first();
|
||||
$processes = [];
|
||||
foreach ([$first, $second] as $index => $payload) {
|
||||
$label = $index === 0 ? 'A' : 'B';
|
||||
$user = $index === 0 ? $fixture['receiver'] : $fixture['otherReceiver'];
|
||||
$process = new Process([PHP_BINARY, __DIR__.'/stock-worker.php', $database, (string) $user->id, '0', $label], base_path(), $workerEnv);
|
||||
$process->setInput(json_encode($payload, JSON_THROW_ON_ERROR));
|
||||
$process->setTimeout(45);
|
||||
$process->start();
|
||||
$processes[] = $process;
|
||||
}
|
||||
$statement = $admin->prepare("SELECT count(*) FROM pg_stat_activity WHERE datname = ? AND application_name IN ('procura_stock_A','procura_stock_B') AND wait_event_type = 'Lock'");
|
||||
$deadline = microtime(true) + 20;
|
||||
$waits = 0;
|
||||
do {
|
||||
$statement->execute([$database]);
|
||||
$waits = (int) $statement->fetchColumn();
|
||||
if ($waits >= 2) {
|
||||
break;
|
||||
}
|
||||
foreach ($processes as $process) {
|
||||
if (! $process->isRunning()) {
|
||||
throw new RuntimeException('Worker terminou antes da sobreposição: '.$process->getOutput().' '.$process->getErrorOutput());
|
||||
}
|
||||
}
|
||||
usleep(20000);
|
||||
} while (microtime(true) < $deadline);
|
||||
$connection->commit();
|
||||
if ($waits < 2) {
|
||||
throw new RuntimeException('Sobreposição de bloqueios não demonstrada.');
|
||||
}
|
||||
$results = [];
|
||||
foreach ($processes as $process) {
|
||||
$process->wait();
|
||||
if (! $process->isSuccessful()) {
|
||||
throw new RuntimeException($process->getErrorOutput());
|
||||
}$results[] = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
$statuses = array_column($results, 'status');
|
||||
sort($statuses);
|
||||
$expected = match ($scenario) {
|
||||
'over_issue', 'spare_part_issue' => [201, 422],'stale_adjustment' => [422, 422],'opposing_transfers' => null,default => [200, 201]
|
||||
};
|
||||
if (($expected !== null && $statuses !== $expected) || count(array_unique(array_column($results, 'backend_pid'))) !== 2) {
|
||||
throw new RuntimeException('Respostas inesperadas: '.json_encode($results));
|
||||
}
|
||||
$source = app(StockWorkflow::class)->balance($fixture['stock']->id, $fixture['warehouse']->id)['quantity'];
|
||||
$target = app(StockWorkflow::class)->balance($fixture['stock']->id, $destination->id)['quantity'];
|
||||
$expectedSource = match ($scenario) {
|
||||
'same_adjustment' => 800,'stale_adjustment' => 900,default => 400
|
||||
};
|
||||
if ($scenario === 'opposing_transfers') {
|
||||
if (! in_array($statuses, [[201, 201], [201, 422]], true) || $source + $target !== 1000 || $source < 0 || $target < 0) {
|
||||
throw new RuntimeException('Transferências opostas inválidas.');
|
||||
}
|
||||
} elseif ($source !== $expectedSource || $target !== ($scenario === 'same_transfer' ? 600 : 0)) {
|
||||
throw new RuntimeException('Saldos inesperados.');
|
||||
}
|
||||
foreach (StockOperation::query()->where('kind', 'transfer')->get() as $transfer) {
|
||||
$moves = StockMovement::query()->where('stock_operation_id', $transfer->id);
|
||||
if ($moves->count() !== 2 || (int) $moves->sum('quantity_milli') !== 0) {
|
||||
throw new RuntimeException('Transferência incompleta.');
|
||||
}
|
||||
}
|
||||
$report['scenarios'][] = ['name' => $scenario, 'passed' => true, 'overlapping_lock_waits' => $waits, 'responses' => $results, 'source_milli' => $source, 'destination_milli' => $target];
|
||||
}
|
||||
$report['passed'] = true;
|
||||
} catch (Throwable $error) {
|
||||
$report['passed'] = false;
|
||||
$report['failure'] = $error->getMessage();
|
||||
} finally {
|
||||
foreach ($processes as $process) {
|
||||
if ($process->isRunning()) {
|
||||
$process->stop();
|
||||
}
|
||||
}
|
||||
if (isset($connection) && $connection->transactionLevel() > 0) {
|
||||
$connection->rollBack();
|
||||
}
|
||||
DB::disconnect('receipt_concurrency');
|
||||
DB::disconnect('pgsql');
|
||||
if ($created && preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database) && $database !== $originalDatabase) {
|
||||
$admin->exec('DROP DATABASE "'.$database.'"');
|
||||
$report['test_database_dropped'] = true;
|
||||
}
|
||||
$report['live_user_count_unchanged'] = $admin->query('SELECT COUNT(*) FROM users')->fetchColumn() === $liveUsersBefore;
|
||||
}
|
||||
file_put_contents(__DIR__.'/../../docs/stock-concorrencia-postgresql.json', json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL);
|
||||
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
exit(($report['passed'] ?? false) ? 0 : 1);
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Console\Kernel as ConsoleKernel;
|
||||
use Illuminate\Contracts\Http\Kernel as HttpKernel;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
require __DIR__.'/../../vendor/autoload.php';
|
||||
|
||||
$database = $argv[1] ?? '';
|
||||
if (! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) {
|
||||
fwrite(STDERR, 'Base de testes inválida.');
|
||||
exit(2);
|
||||
}
|
||||
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||
$app->make(ConsoleKernel::class)->bootstrap();
|
||||
if (! $app->environment('testing')) {
|
||||
throw new LogicException('O worker só pode executar em APP_ENV=testing.');
|
||||
}
|
||||
$config = config('database.connections.pgsql');
|
||||
$config['database'] = $database;
|
||||
$config['url'] = null;
|
||||
config(['database.default' => 'pgsql', 'database.connections.pgsql' => $config]);
|
||||
DB::purge('pgsql');
|
||||
if (DB::selectOne('select current_database() as name')->name !== $database) {
|
||||
throw new LogicException('A ligação não corresponde à base de testes.');
|
||||
}
|
||||
DB::select('select set_config(?, ?, false)', ['application_name', 'procura_stock_'.$argv[4]]);
|
||||
$pid = DB::selectOne('select pg_backend_pid() as pid')->pid;
|
||||
auth()->guard()->setUser(User::query()->findOrFail($argv[2]));
|
||||
$payload = json_decode(stream_get_contents(STDIN), true, 512, JSON_THROW_ON_ERROR);
|
||||
$request = Request::create('/stock/operacoes', 'POST', $payload, server: ['HTTP_ACCEPT' => 'application/json']);
|
||||
$kernel = $app->make(HttpKernel::class);
|
||||
$response = $kernel->handle($request);
|
||||
$body = json_decode($response->getContent(), true);
|
||||
$kernel->terminate($request, $response);
|
||||
echo json_encode(['status' => $response->getStatusCode(), 'id' => $body['id'] ?? null, 'created' => $body['created'] ?? null, 'error_fields' => array_keys($body['errors'] ?? []), 'backend_pid' => $pid], JSON_THROW_ON_ERROR);
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\CriticalityLevel;
|
||||
use App\Models\Department;
|
||||
use App\Models\Equipment;
|
||||
use App\Models\EquipmentSparePart;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\SparePart;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\StockOperation;
|
||||
use App\Models\TechnicalReview;
|
||||
use App\Models\User;
|
||||
use App\Services\EquipmentWorkflow;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
|
||||
function equipmentRequestData(array $f, string $type = 'equipment'): array
|
||||
{
|
||||
return ['cost_center_id' => $f['r']->cost_center_id, 'priority' => 'normal', 'needed_by' => today()->addWeek()->format('Y-m-d'), 'justification' => 'Equipamento para uma linha de produção de teste.', 'items' => [['description' => 'Máquina de teste', 'category' => 'Produção', 'purchase_type' => $type, 'quantity' => '1', 'unit' => 'cx', 'unit_price' => '1.00', 'tax_rate' => '0', 'production_area' => 'Linha fictícia', 'equipment_id' => $f['asset']->id, 'spare_part_id' => $f['part']->id]]];
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture();
|
||||
$f = &$this->f;
|
||||
$f['admin'] = User::factory()->create(['tenant_id' => $f['tenant']->id, 'department_id' => $f['department']->id, 'purchasing_role' => PurchasingRole::Admin]);
|
||||
$f['head'] = User::factory()->create(['tenant_id' => $f['tenant']->id, 'department_id' => $f['department']->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
||||
$f['finance'] = User::factory()->create(['tenant_id' => $f['tenant']->id, 'department_id' => $f['department']->id, 'purchasing_role' => PurchasingRole::Finance]);
|
||||
$f['level'] = CriticalityLevel::query()->create(['tenant_id' => $f['tenant']->id, 'created_by' => $f['admin']->id, 'code' => 'CRIT-TEST', 'name' => 'Categoria de teste', 'criteria' => 'Critério fictício definido explicitamente para teste', 'is_critical' => true, 'created_at' => now()]);
|
||||
$f['asset'] = Equipment::query()->create(['tenant_id' => $f['tenant']->id, 'created_by' => $f['receiver']->id, 'department_id' => $f['department']->id, 'criticality_level_id' => $f['level']->id, 'code' => 'EQ-TEST', 'description' => 'Compressor de teste', 'area' => 'Linha A', 'status' => 'stopped']);
|
||||
$f['part'] = SparePart::query()->create(['tenant_id' => $f['tenant']->id, 'created_by' => $f['receiver']->id, 'stock_item_id' => $f['stock']->id, 'criticality_level_id' => $f['level']->id, 'minimum_milli' => 2000]);
|
||||
EquipmentSparePart::query()->create(['tenant_id' => $f['tenant']->id, 'created_by' => $f['receiver']->id, 'equipment_id' => $f['asset']->id, 'spare_part_id' => $f['part']->id, 'created_at' => now()]);
|
||||
$this->actingAs($f['receiver']);
|
||||
});
|
||||
|
||||
test('criticality requires explicit company criteria and critical flag without defaults', function () {
|
||||
$this->actingAs($this->f['admin']);
|
||||
$this->postJson('/equipamentos/categorias', ['code' => 'NEW', 'name' => 'Nova categoria'])->assertUnprocessable()->assertJsonValidationErrors(['criteria', 'is_critical']);
|
||||
$this->post('/equipamentos/categorias', ['code' => 'NEW', 'name' => 'Nova categoria', 'criteria' => 'Critério decidido pela empresa de teste', 'is_critical' => false])->assertSessionHasNoErrors()->assertRedirect();
|
||||
$level = CriticalityLevel::query()->where('code', 'NEW')->firstOrFail();
|
||||
expect($level->is_critical)->toBeFalse();
|
||||
expect(fn () => $level->update(['criteria' => 'Rewrite']))->toThrow(LogicException::class);
|
||||
});
|
||||
|
||||
test('only administration configures criticality', function ($role) {
|
||||
$this->f['receiver']->forceFill(['purchasing_role' => $role])->save();
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$this->post('/equipamentos/categorias', ['code' => 'X', 'name' => 'X', 'criteria' => 'X', 'is_critical' => true])->assertForbidden();
|
||||
})->with([PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead, PurchasingRole::Requester]);
|
||||
|
||||
test('catalogue requires explicit minimum and supports optimistic audited updates', function () {
|
||||
$payload = ['id' => $this->f['part']->id, 'version' => 1, 'stock_item_id' => $this->f['stock']->id, 'criticality_level_id' => $this->f['level']->id];
|
||||
$this->postJson('/equipamentos/pecas', $payload)->assertUnprocessable()->assertJsonValidationErrors('minimum_quantity');
|
||||
$payload['minimum_quantity'] = '0';
|
||||
$this->post('/equipamentos/pecas', $payload)->assertSessionHasNoErrors();
|
||||
expect($this->f['part']->refresh()->minimum_milli)->toBe(0)->and($this->f['part']->version)->toBe(2);
|
||||
$this->postJson('/equipamentos/pecas', $payload)->assertUnprocessable()->assertJsonValidationErrors('version');
|
||||
});
|
||||
|
||||
test('warehouse stock drives minimum alert without duplicate inventory balances', function () {
|
||||
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '1.000'));
|
||||
$this->get('/equipamentos')->assertInertia(fn (Assert $p) => $p->component('equipment/index')->where('parts.0.balance_milli', 1000)->where('parts.0.below_minimum', true));
|
||||
$this->postJson('/equipamentos/associacoes', ['equipment_id' => $this->f['asset']->id, 'spare_part_id' => $this->f['part']->id])->assertUnprocessable();
|
||||
expect((int) StockMovement::query()->sum('quantity_milli'))->toBe(1000)->and(EquipmentSparePart::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('parts require compatible destination and snapshot equipment in issue and movement', function () {
|
||||
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '1.000'));
|
||||
$p = ['kind' => 'issue', 'reference' => 'SAI-EQ', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $this->f['warehouse']->id, 'stock_item_id' => $this->f['stock']->id, 'responsible_id' => $this->f['receiver']->id, 'destination' => 'Linha A', 'reason' => 'Substituição de rolamento', 'quantity' => '0.500'];
|
||||
$this->postJson('/stock/operacoes', $p)->assertUnprocessable()->assertJsonValidationErrors('equipment_id');
|
||||
$p['equipment_id'] = $this->f['asset']->id;
|
||||
$this->postJson('/stock/operacoes', $p)->assertCreated();
|
||||
$this->postJson('/stock/operacoes', $p)->assertOk();
|
||||
$op = StockOperation::query()->firstOrFail();
|
||||
expect($op->equipment_id)->toBe($this->f['asset']->id)->and($op->spare_part_id)->toBe($this->f['part']->id)->and($op->snapshot['equipment']['description'])->toBe('Compressor de teste');
|
||||
$this->f['asset']->update(['description' => 'Nome novo']);
|
||||
expect($op->refresh()->snapshot['equipment']['description'])->toBe('Compressor de teste');
|
||||
$movement = StockMovement::query()->where('stock_operation_id', $op->id)->firstOrFail();
|
||||
expect($movement->equipment_id)->toBe($op->equipment_id)->and($movement->snapshot['equipment']['description'])->toBe('Compressor de teste');
|
||||
});
|
||||
|
||||
test('cross company associations and catalogue references are rejected', function ($resource) {
|
||||
$other = receiptFixture();
|
||||
$this->actingAs($this->f['receiver']);
|
||||
if ($resource === 'equipment') {
|
||||
$this->postJson('/equipamentos/activos', ['code' => 'OTHER', 'description' => 'Test', 'area' => 'A', 'status' => 'active', 'department_id' => $other['department']->id, 'criticality_level_id' => $this->f['level']->id])->assertUnprocessable()->assertJsonValidationErrors('department_id');
|
||||
} else {
|
||||
$this->postJson('/equipamentos/pecas', ['stock_item_id' => $other['stock']->id, 'minimum_quantity' => '0', 'criticality_level_id' => $this->f['level']->id])->assertUnprocessable()->assertJsonValidationErrors('stock_item_id');
|
||||
}
|
||||
})->with(['equipment', 'stock']);
|
||||
|
||||
test('unrelated and foreign equipment cannot receive a designated spare part', function () {
|
||||
$asset = Equipment::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['receiver']->id, 'department_id' => $this->f['department']->id, 'criticality_level_id' => $this->f['level']->id, 'code' => 'UNLINKED', 'description' => 'Other', 'area' => 'A', 'status' => 'active']);
|
||||
expect(fn () => app(EquipmentWorkflow::class)->linkedPart($this->f['part']->id, $asset->id))->toThrow(ValidationException::class);
|
||||
$other = receiptFixture();
|
||||
$this->actingAs($other['receiver']);
|
||||
$this->get('/equipamentos')->assertInertia(fn (Assert $p) => $p->has('equipment', 0)->has('parts', 0)->has('history.data', 0));
|
||||
$this->postJson('/equipamentos/associacoes', ['equipment_id' => $this->f['asset']->id, 'spare_part_id' => $this->f['part']->id])->assertUnprocessable()->assertJsonValidationErrors(['equipment_id', 'spare_part_id']);
|
||||
});
|
||||
|
||||
test('equipment requisition demands technical validation and adds finance for every value', function () {
|
||||
$data = equipmentRequestData($this->f);
|
||||
$this->post('/requisicoes', $data)->assertSessionHasNoErrors();
|
||||
$r = Requisition::query()->latest('id')->firstOrFail();
|
||||
$this->postJson('/requisicoes/'.$r->id.'/submeter')->assertUnprocessable()->assertJsonValidationErrors('technical');
|
||||
$basis = app(EquipmentWorkflow::class)->basis($r);
|
||||
$this->actingAs($this->f['head'])->post('/requisicoes/'.$r->id.'/validacao-tecnica', ['basis_hash' => $basis, 'decision' => 'approved', 'reason' => 'Especificações adequadas à linha de teste'])->assertSessionHasNoErrors();
|
||||
$this->actingAs($this->f['receiver'])->post('/requisicoes/'.$r->id.'/submeter')->assertSessionHasNoErrors();
|
||||
expect($r->refresh()->status)->toBe('pending')->and($r->steps()->pluck('role')->all())->toBe(['department_head', 'finance']);
|
||||
$this->actingAs($this->f['head'])->post('/requisicoes/'.$r->id.'/decidir', ['decision' => 'approved'])->assertSessionHasNoErrors();
|
||||
expect($r->refresh()->status)->toBe('pending');
|
||||
$this->actingAs($this->f['finance'])->post('/requisicoes/'.$r->id.'/decidir', ['decision' => 'approved'])->assertSessionHasNoErrors();
|
||||
$this->actingAs($this->f['issuer'])->postJson('/requisicoes/'.$r->id.'/adjudicacao', ['quotation_id' => $this->f['order']->purchase_award_id, 'justification' => 'Justificação de teste', 'delivery_date' => today()->addWeek()->format('Y-m-d'), 'delivery_location' => 'Linha A'])->assertUnprocessable()->assertJsonValidationErrors('purchase_type');
|
||||
});
|
||||
|
||||
test('technical decision cannot be made by requester admin or warehouse', function ($role) {
|
||||
$r = app(RequisitionWorkflow::class)->save($this->f['receiver'], equipmentRequestData($this->f));
|
||||
$actor = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['department']->id, 'purchasing_role' => $role]);
|
||||
if ($role === PurchasingRole::DepartmentHead) {
|
||||
$r->update(['requester_id' => $actor->id]);
|
||||
}
|
||||
$this->actingAs($actor)->post('/requisicoes/'.$r->id.'/validacao-tecnica', ['basis_hash' => app(EquipmentWorkflow::class)->basis($r), 'decision' => 'approved', 'reason' => 'Teste'])->assertForbidden();
|
||||
})->with([PurchasingRole::DepartmentHead, PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Finance]);
|
||||
|
||||
test('technical decision is immutable and stale review cannot approve edited request', function () {
|
||||
$r = app(RequisitionWorkflow::class)->save($this->f['receiver'], equipmentRequestData($this->f));
|
||||
$basis = app(EquipmentWorkflow::class)->basis($r);
|
||||
$this->actingAs($this->f['head'])->post('/requisicoes/'.$r->id.'/validacao-tecnica', ['basis_hash' => $basis, 'decision' => 'approved', 'reason' => 'Validação do teste'])->assertSessionHasNoErrors();
|
||||
$review = TechnicalReview::query()->firstOrFail();
|
||||
expect(fn () => $review->delete())->toThrow(LogicException::class);
|
||||
$data = equipmentRequestData($this->f);
|
||||
$data['justification'] = 'Mudança da finalidade técnica do equipamento';
|
||||
$this->actingAs($this->f['receiver'])->put('/requisicoes/'.$r->id, $data)->assertSessionHasNoErrors();
|
||||
$this->postJson('/requisicoes/'.$r->id.'/submeter')->assertUnprocessable()->assertJsonValidationErrors('technical');
|
||||
$this->actingAs($this->f['head'])->postJson('/requisicoes/'.$r->id.'/validacao-tecnica', ['basis_hash' => $basis, 'decision' => 'approved', 'reason' => 'Teste'])->assertUnprocessable()->assertJsonValidationErrors('basis_hash');
|
||||
expect(TechnicalReview::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('rejected technical review does not permit submission', function () {
|
||||
$r = app(RequisitionWorkflow::class)->save($this->f['receiver'], equipmentRequestData($this->f));
|
||||
$this->actingAs($this->f['head'])->post('/requisicoes/'.$r->id.'/validacao-tecnica', ['basis_hash' => app(EquipmentWorkflow::class)->basis($r), 'decision' => 'rejected', 'reason' => 'Não cumpre a especificação técnica'])->assertSessionHasNoErrors();
|
||||
$this->actingAs($this->f['receiver'])->postJson('/requisicoes/'.$r->id.'/submeter')->assertUnprocessable()->assertJsonValidationErrors('technical');
|
||||
});
|
||||
|
||||
test('urgent critical spare parts require downtime without granting quotation exceptions', function () {
|
||||
$data = equipmentRequestData($this->f, 'spare_part');
|
||||
$data['priority'] = 'urgent';
|
||||
$this->postJson('/requisicoes', $data)->assertUnprocessable()->assertJsonValidationErrors('items');
|
||||
$data['items'][0]['downtime_started_at'] = now()->subHour()->format('Y-m-d\TH:i');
|
||||
$this->post('/requisicoes', $data)->assertSessionHasNoErrors();
|
||||
$r = Requisition::query()->latest('id')->firstOrFail();
|
||||
expect($r->items()->firstOrFail()->equipment_id)->toBe($this->f['asset']->id)->and($r->items()->firstOrFail()->downtime_started_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('spare receipts preserve authorised item and equipment traceability', function () {
|
||||
$f = receiptFixture('1.000', 'spare_part');
|
||||
$this->actingAs($f['receiver']);
|
||||
// Attach already authorised links only inside the isolated fixture.
|
||||
$level = CriticalityLevel::query()->create(['tenant_id' => $f['tenant']->id, 'created_by' => $f['receiver']->id, 'code' => 'PART', 'name' => 'Test', 'criteria' => 'Fixture', 'is_critical' => false, 'created_at' => now()]);
|
||||
$asset = Equipment::query()->create(['tenant_id' => $f['tenant']->id, 'created_by' => $f['receiver']->id, 'department_id' => $f['department']->id, 'criticality_level_id' => $level->id, 'code' => 'EQ', 'description' => 'Test', 'area' => 'A', 'status' => 'active']);
|
||||
$part = SparePart::query()->create(['tenant_id' => $f['tenant']->id, 'created_by' => $f['receiver']->id, 'stock_item_id' => $f['stock']->id, 'criticality_level_id' => $level->id, 'minimum_milli' => 1000]);
|
||||
EquipmentSparePart::query()->create(['tenant_id' => $f['tenant']->id, 'created_by' => $f['receiver']->id, 'equipment_id' => $asset->id, 'spare_part_id' => $part->id, 'created_at' => now()]);
|
||||
DB::table('purchase_order_items')->where('id', $f['line']->id)->update(['equipment_id' => $asset->id, 'spare_part_id' => $part->id]);
|
||||
$this->postJson('/ordens/'.$f['order']->id.'/recepcoes', receiptPayload($f, '1.000'))->assertCreated();
|
||||
$movement = StockMovement::query()->firstOrFail();
|
||||
expect($movement->equipment_id)->toBe($asset->id)->and($movement->spare_part_id)->toBe($part->id);
|
||||
});
|
||||
|
||||
test('public equipment page has fictional props only', function () {
|
||||
$this->get('/consulta/equipamentos')->assertInertia(fn (Assert $p) => $p->component('preview/index')->where('initialScreen', 'equipment')->where('auth.user', null)->missing('equipment')->missing('parts'));
|
||||
});
|
||||
|
||||
test('technical reviewer of another department and foreign company cannot decide', function () {
|
||||
$r = app(RequisitionWorkflow::class)->save($this->f['receiver'], equipmentRequestData($this->f));
|
||||
$dept = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outra área']);
|
||||
$this->f['head']->forceFill(['department_id' => $dept->id])->save();
|
||||
$this->actingAs($this->f['head'])->post('/requisicoes/'.$r->id.'/validacao-tecnica', ['basis_hash' => app(EquipmentWorkflow::class)->basis($r), 'decision' => 'approved', 'reason' => 'Teste'])->assertForbidden();
|
||||
$other = receiptFixture();
|
||||
$this->actingAs($other['receiver']);
|
||||
$this->post('/requisicoes/'.$r->id.'/validacao-tecnica', ['basis_hash' => app(EquipmentWorkflow::class)->basis($r), 'decision' => 'approved', 'reason' => 'Teste'])->assertNotFound();
|
||||
});
|
||||
|
||||
test('technical audit failure rolls back decision', function () {
|
||||
$r = app(RequisitionWorkflow::class)->save($this->f['receiver'], equipmentRequestData($this->f));
|
||||
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->andThrow(new RuntimeException('Audit failed'));
|
||||
$this->actingAs($this->f['head']);
|
||||
$this->withoutExceptionHandling();
|
||||
try {
|
||||
$this->post('/requisicoes/'.$r->id.'/validacao-tecnica', ['basis_hash' => app(EquipmentWorkflow::class)->basis($r), 'decision' => 'approved', 'reason' => 'Teste']);
|
||||
$this->fail('Expected exception');
|
||||
} catch (RuntimeException $e) {
|
||||
expect($e->getMessage())->toBe('Audit failed');
|
||||
}
|
||||
expect(TechnicalReview::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('foreign classifications cannot be used in equipment or part configuration', function () {
|
||||
$other = receiptFixture();
|
||||
$level = CriticalityLevel::query()->create(['tenant_id' => $other['tenant']->id, 'created_by' => $other['receiver']->id, 'code' => 'FOREIGN', 'name' => 'Outra', 'criteria' => 'Fixture', 'is_critical' => false, 'created_at' => now()]);
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$this->postJson('/equipamentos/activos', ['code' => 'FOREIGN', 'description' => 'Máquina', 'area' => 'Linha', 'status' => 'active', 'department_id' => $this->f['department']->id, 'criticality_level_id' => $level->id])->assertUnprocessable()->assertJsonValidationErrors('criticality_level_id');
|
||||
$this->postJson('/equipamentos/pecas', ['id' => $this->f['part']->id, 'version' => 1, 'stock_item_id' => $this->f['stock']->id, 'criticality_level_id' => $level->id, 'minimum_quantity' => '1'])->assertUnprocessable()->assertJsonValidationErrors('criticality_level_id');
|
||||
});
|
||||
|
||||
test('classification and minimum edits do not erase earlier movement snapshots', function () {
|
||||
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '1.000'));
|
||||
$p = ['kind' => 'issue', 'reference' => 'SNAP', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $this->f['warehouse']->id, 'stock_item_id' => $this->f['stock']->id, 'responsible_id' => $this->f['receiver']->id, 'destination' => 'Produção', 'reason' => 'Reposição', 'quantity' => '0.001', 'equipment_id' => $this->f['asset']->id];
|
||||
$this->postJson('/stock/operacoes', $p)->assertCreated();
|
||||
$this->post('/equipamentos/pecas', ['id' => $this->f['part']->id, 'version' => 1, 'stock_item_id' => $this->f['stock']->id, 'criticality_level_id' => $this->f['level']->id, 'minimum_quantity' => '5.000'])->assertSessionHasNoErrors();
|
||||
expect(StockOperation::query()->firstOrFail()->snapshot['spare_part']['minimum_milli'])->toBe(2000);
|
||||
});
|
||||
|
||||
test('finance can consult but cannot change equipment or parts', function () {
|
||||
$this->actingAs($this->f['finance'])->get('/equipamentos')->assertOk();
|
||||
$this->post('/equipamentos/pecas', ['stock_item_id' => $this->f['stock']->id, 'criticality_level_id' => $this->f['level']->id, 'minimum_quantity' => '1'])->assertForbidden();
|
||||
});
|
||||
|
||||
test('new catalogue forms accept empty optional identity and version fields', function () {
|
||||
$this->post('/equipamentos/activos', ['id' => '', 'version' => '', 'stock_item_id' => '', 'serial_number' => '', 'code' => 'NEW-FORM', 'description' => 'Equipamento novo', 'area' => 'Linha B', 'status' => 'active', 'department_id' => $this->f['department']->id, 'criticality_level_id' => $this->f['level']->id])->assertSessionHasNoErrors();
|
||||
expect(Equipment::query()->where('code', 'NEW-FORM')->count())->toBe(1);
|
||||
});
|
||||
@@ -3,5 +3,5 @@
|
||||
test('returns a successful response', function () {
|
||||
$response = $this->get(route('home'));
|
||||
|
||||
$response->assertRedirect('/consulta');
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
test('home is a distinct public entry point without business data', function () {
|
||||
$this->get('/')->assertOk()->assertInertia(fn (Assert $page) => $page->component('home')->where('auth.user', null)->missing('orders')->missing('receipts')->missing('balances'));
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
test('each demo area has a dedicated address without exposing operational data', function (string $path, string $screen) {
|
||||
$this->get($path)->assertOk()->assertInertia(fn (Assert $page) => $page->component('preview/index')->where('initialScreen', $screen)->where('auth.user', null)->missing('orders')->missing('receipts')->missing('movements')->missing('balances'));
|
||||
})->with([['/consulta', 'overview'], ['/consulta/requisicoes', 'requests'], ['/consulta/fornecedores', 'suppliers'], ['/consulta/cotacoes', 'quotes'], ['/consulta/ordens', 'orders'], ['/consulta/recepcoes', 'receipts'], ['/consulta/stock', 'stock'], ['/consulta/conflitos', 'conflicts'], ['/consulta/aparencia', 'appearance']]);
|
||||
|
||||
test('home and public navigation preserve authenticated accounts without exposing identity', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user)->get('/')->assertInertia(fn (Assert $page) => $page->where('auth.user', null));
|
||||
$this->get('/consulta/recepcoes')->assertInertia(fn (Assert $page) => $page->where('auth.user', null));
|
||||
$this->assertAuthenticatedAs($user);
|
||||
});
|
||||
|
||||
test('public receiving and stock demos provide no mutation endpoints', function () {
|
||||
$this->post('/consulta/recepcoes', [])->assertStatus(405);
|
||||
$this->post('/consulta/stock', [])->assertStatus(405);
|
||||
$this->get('/consulta/desconhecido')->assertNotFound();
|
||||
$this->get('/recepcoes')->assertRedirect('/login');
|
||||
$this->get('/stock')->assertRedirect('/login');
|
||||
});
|
||||
@@ -3,17 +3,25 @@
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\CostCenter;
|
||||
use App\Models\CriticalityLevel;
|
||||
use App\Models\Department;
|
||||
use App\Models\Equipment;
|
||||
use App\Models\EquipmentSparePart;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\SparePart;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
function awardPrices(object $test, int $firstPrice, int $taxRate = 0): void
|
||||
@@ -373,3 +381,24 @@ test('delivery dates that expire before emission are not silently replaced', fun
|
||||
$this->actingAs($this->buyer)->post('/adjudicacoes/'.$award->id.'/emitir')->assertSessionHasErrors('delivery_date');
|
||||
expect(PurchaseOrder::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('spare purchase flows through award order receipt and issue with equipment trace', function () {
|
||||
$level = CriticalityLevel::query()->create(['tenant_id' => $this->tenant->id, 'created_by' => $this->admin->id, 'code' => 'PART-TEST', 'name' => 'Normal de teste', 'criteria' => 'Critério explícito da fixture', 'is_critical' => false, 'created_at' => now()]);
|
||||
$asset = Equipment::query()->create(['tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'department_id' => $this->department->id, 'criticality_level_id' => $level->id, 'code' => 'EQ-FLOW', 'description' => 'Máquina da fixture', 'area' => 'Produção', 'status' => 'active']);
|
||||
$stock = StockItem::query()->create(['tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'code' => 'PART-FLOW', 'name' => 'Peça da fixture', 'unit' => 'un']);
|
||||
$part = SparePart::query()->create(['tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'stock_item_id' => $stock->id, 'criticality_level_id' => $level->id, 'minimum_milli' => 500]);
|
||||
EquipmentSparePart::query()->create(['tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'equipment_id' => $asset->id, 'spare_part_id' => $part->id, 'created_at' => now()]);
|
||||
$this->item->update(['purchase_type' => 'spare_part', 'spare_part_id' => $part->id, 'equipment_id' => $asset->id]);
|
||||
$award = selectPurchaseAward($this);
|
||||
$order = issuePurchaseOrder($this, $award);
|
||||
expect($order->items()->firstOrFail()->spare_part_id)->toBe($part->id)->and($order->snapshot['equipment_context'][0]['equipment']['description'])->toBe('Máquina da fixture');
|
||||
$asset->update(['description' => 'Nome actualizado']);
|
||||
expect($order->refresh()->snapshot['equipment_context'][0]['equipment']['description'])->toBe('Máquina da fixture');
|
||||
$receiver = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $this->department->id, 'purchasing_role' => PurchasingRole::Warehouse]);
|
||||
$this->actingAs($receiver);
|
||||
$warehouse = Warehouse::query()->create(['tenant_id' => $this->tenant->id, 'created_by' => $receiver->id, 'code' => 'FLOW', 'name' => 'Armazém de teste']);
|
||||
$receipt = ['idempotency_key' => (string) Str::uuid(), 'delivery_note' => 'GR-FLOW', 'received_on' => today()->format('Y-m-d'), 'warehouse_id' => $warehouse->id, 'items' => [['purchase_order_item_id' => $order->items()->firstOrFail()->id, 'received_quantity' => '1.000', 'accepted_quantity' => '1.000', 'quality_status' => 'compliant', 'stock_item_id' => $stock->id]]];
|
||||
$this->postJson('/ordens/'.$order->id.'/recepcoes', $receipt)->assertCreated();
|
||||
$this->postJson('/stock/operacoes', ['kind' => 'issue', 'reference' => 'SAI-FLOW', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $warehouse->id, 'stock_item_id' => $stock->id, 'responsible_id' => $receiver->id, 'destination' => 'Produção', 'reason' => 'Montagem no equipamento', 'quantity' => '0.500', 'equipment_id' => $asset->id])->assertCreated();
|
||||
expect((int) StockMovement::query()->where('stock_item_id', $stock->id)->sum('quantity_milli'))->toBe(500)->and(StockMovement::query()->where('equipment_id', $asset->id)->count())->toBe(2);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Department;
|
||||
use App\Models\GoodsReceipt;
|
||||
use App\Models\GoodsReceiptItem;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\User;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture();
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$this->url = '/ordens/'.$this->f['order']->id.'/recepcoes';
|
||||
$this->payload = receiptPayload($this->f);
|
||||
});
|
||||
|
||||
test('partial then total receipts preserve pending quantities and credit only accepted stock', function () {
|
||||
$this->postJson($this->url, receiptPayload($this->f, '0.400'))->assertCreated()->assertJsonPath('status', 'partial');
|
||||
$first = GoodsReceipt::query()->firstOrFail();
|
||||
$this->postJson($this->url, receiptPayload($this->f, '0.600'))->assertCreated()->assertJsonPath('status', 'complete');
|
||||
$progress = app(ReceiptWorkflow::class)->progress($this->f['order']);
|
||||
expect($progress['state'])->toBe('complete')->and($progress['lines'][0]['delivery_pending_milli'])->toBe(0)->and($progress['lines'][0]['accepted_milli'])->toBe(1000);
|
||||
expect(StockMovement::query()->count())->toBe(2)->and((int) StockMovement::query()->sum('quantity_milli'))->toBe(1000);
|
||||
expect($first->refresh()->status)->toBe('partial');
|
||||
expect(AuditLog::query()->where('action', 'receipt.confirmed')->count())->toBe(2);
|
||||
$this->get('/stock')->assertInertia(fn (Assert $page) => $page->component('stock/index')->where('balances.0.quantity_milli', 1000)->has('movements.data', 2));
|
||||
});
|
||||
|
||||
test('overreceipt is rejected against cumulative delivered quantities', function () {
|
||||
$this->postJson($this->url, receiptPayload($this->f, '0.700'))->assertCreated();
|
||||
$this->postJson($this->url, receiptPayload($this->f, '0.400'))->assertUnprocessable()->assertJsonValidationErrors('items');
|
||||
expect(GoodsReceipt::query()->count())->toBe(1)->and((int) StockMovement::query()->sum('quantity_milli'))->toBe(700);
|
||||
});
|
||||
|
||||
test('idempotent retries preserve one receipt and movement but changed payload cannot reuse the key', function () {
|
||||
$first = $this->postJson($this->url, $this->payload)->assertCreated()->json('id');
|
||||
$this->postJson($this->url, $this->payload)->assertOk()->assertJsonPath('id', $first)->assertJsonPath('created', false);
|
||||
$changed = $this->payload;
|
||||
$changed['items'][0]['received_quantity'] = '0.500';
|
||||
$changed['items'][0]['accepted_quantity'] = '0.500';
|
||||
$this->postJson($this->url, $changed)->assertUnprocessable()->assertJsonValidationErrors('idempotency_key');
|
||||
expect(GoodsReceipt::query()->count())->toBe(1)->and(StockMovement::query()->count())->toBe(1);
|
||||
expect(AuditLog::query()->where('action', 'receipt.confirmed')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('another token cannot duplicate the same normalized delivery note', function () {
|
||||
$this->payload['delivery_note'] = 'gr teste 001';
|
||||
$this->postJson($this->url, $this->payload)->assertCreated();
|
||||
$this->payload['idempotency_key'] = (string) Str::uuid();
|
||||
$this->payload['delivery_note'] = ' GR TESTE 001 ';
|
||||
$this->postJson($this->url, $this->payload)->assertUnprocessable()->assertJsonValidationErrors('delivery_note');
|
||||
expect(StockMovement::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('reservations and rejections record divergences without crediting rejected goods or authorizing replacements', function () {
|
||||
$body = receiptPayload($this->f, '0.600');
|
||||
$body['items'][0] = [...$body['items'][0], 'accepted_quantity' => '0.400', 'quality_status' => 'reservations', 'notes' => 'Duas décimas danificadas.'];
|
||||
$this->postJson($this->url, $body)->assertCreated();
|
||||
$rejected = receiptPayload($this->f, '0.400');
|
||||
$rejected['warehouse_id'] = null;
|
||||
$rejected['items'][0] = [...$rejected['items'][0], 'accepted_quantity' => '0', 'quality_status' => 'nonconforming', 'stock_item_id' => null, 'notes' => 'Material não conforme.'];
|
||||
$this->postJson($this->url, $rejected)->assertCreated()->assertJsonPath('status', 'rejected');
|
||||
expect(StockMovement::query()->count())->toBe(1)->and((int) StockMovement::query()->sum('quantity_milli'))->toBe(400);
|
||||
$progress = app(ReceiptWorkflow::class)->progress($this->f['order']);
|
||||
expect($progress['state'])->toBe('quality_pending')->and($progress['has_divergences'])->toBeTrue();
|
||||
expect($progress['lines'][0]['delivery_pending_milli'])->toBe(0)->and($progress['lines'][0]['acceptance_pending_milli'])->toBe(600);
|
||||
$this->postJson($this->url, receiptPayload($this->f, '0.100'))->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('quality and quantity inconsistencies cannot confirm stock', function (string $received, string $accepted, string $quality, ?string $notes) {
|
||||
$this->payload['items'][0] = [...$this->payload['items'][0], 'received_quantity' => $received, 'accepted_quantity' => $accepted, 'quality_status' => $quality, 'notes' => $notes];
|
||||
$this->postJson($this->url, $this->payload)->assertUnprocessable();
|
||||
expect(GoodsReceipt::query()->count())->toBe(0)->and(StockMovement::query()->count())->toBe(0);
|
||||
})->with([['0.500', '0.600', 'reservations', 'Excesso inválido'], ['0.500', '0.400', 'compliant', null], ['0.500', '0.100', 'nonconforming', 'Não conforme'], ['0.500', '0.400', 'reservations', null], ['-1', '0', 'nonconforming', 'Negativo'], ['0.0001', '0.0001', 'compliant', null], ['0', '0', 'compliant', null]]);
|
||||
|
||||
test('stock items require the correct warehouse and unit', function (string $missing) {
|
||||
if ($missing === 'warehouse') {
|
||||
$this->payload['warehouse_id'] = null;
|
||||
} elseif ($missing === 'item') {
|
||||
$this->payload['items'][0]['stock_item_id'] = null;
|
||||
} else {
|
||||
$this->f['stock']->update(['unit' => 'kg']);
|
||||
}
|
||||
$this->postJson($this->url, $this->payload)->assertUnprocessable()->assertJsonValidationErrors('items');
|
||||
expect(StockMovement::query()->count())->toBe(0);
|
||||
})->with(['warehouse', 'item', 'unit']);
|
||||
|
||||
test('services can be accepted without creating physical stock', function () {
|
||||
$f = receiptFixture('1.000', 'service');
|
||||
$this->actingAs($f['receiver']);
|
||||
$body = receiptPayload($f, '1.000');
|
||||
$body['warehouse_id'] = null;
|
||||
$body['items'][0]['stock_item_id'] = null;
|
||||
$this->postJson('/ordens/'.$f['order']->id.'/recepcoes', $body)->assertCreated()->assertJsonPath('status', 'complete');
|
||||
expect(StockMovement::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('an issuer cannot receive their order even after becoming warehouse staff', function () {
|
||||
$this->f['issuer']->forceFill(['purchasing_role' => PurchasingRole::Warehouse])->save();
|
||||
$this->actingAs($this->f['issuer'])->postJson($this->url, $this->payload)->assertForbidden();
|
||||
});
|
||||
|
||||
test('confirmation is exclusive to warehouse staff', function (PurchasingRole $role) {
|
||||
$user = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['department']->id, 'purchasing_role' => $role]);
|
||||
$this->actingAs($user)->postJson($this->url, $this->payload)->assertForbidden();
|
||||
})->with([PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement, PurchasingRole::Requester, PurchasingRole::DepartmentHead]);
|
||||
|
||||
test('warehouse order view exposes receiving quantities without the financial snapshot', function () {
|
||||
$this->get($this->url)->assertInertia(fn (Assert $page) => $page->component('receipts/order')->where('canConfirm', true)->where('progress.lines.0.ordered_milli', 1000)->missing('order.snapshot')->missing('order.total_minor')->missing('order.currency'));
|
||||
$this->get('/recepcoes')->assertInertia(fn (Assert $page) => $page->has('orders.data', 1));
|
||||
});
|
||||
|
||||
test('companies cannot consult or confirm another company receipt or stock', function () {
|
||||
$id = $this->postJson($this->url, $this->payload)->assertCreated()->json('id');
|
||||
$foreign = receiptFixture();
|
||||
$this->actingAs($foreign['receiver'])->get($this->url)->assertNotFound();
|
||||
$this->postJson($this->url, $this->payload)->assertNotFound();
|
||||
$this->get('/recepcoes/'.$id)->assertNotFound();
|
||||
$this->get('/stock')->assertInertia(fn (Assert $page) => $page->has('balances', 0)->has('movements.data', 0)->has('items', 1)->where('items.0.id', $foreign['stock']->id));
|
||||
$body = receiptPayload($foreign);
|
||||
$body['warehouse_id'] = $this->f['warehouse']->id;
|
||||
$body['items'][0]['stock_item_id'] = $this->f['stock']->id;
|
||||
$this->postJson('/ordens/'.$foreign['order']->id.'/recepcoes', $body)->assertUnprocessable()->assertJsonValidationErrors(['warehouse_id', 'items.0.stock_item_id']);
|
||||
});
|
||||
|
||||
test('department heads see only their department receipts and cannot inspect stock', function () {
|
||||
$id = $this->postJson($this->url, $this->payload)->assertCreated()->json('id');
|
||||
$dept = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outro departamento']);
|
||||
$head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $dept->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
||||
$this->actingAs($head)->get('/recepcoes')->assertInertia(fn (Assert $page) => $page->has('orders.data', 0)->has('receipts.data', 0));
|
||||
$this->get('/recepcoes/'.$id)->assertForbidden();
|
||||
$this->get('/stock')->assertForbidden();
|
||||
$head->forceFill(['department_id' => $this->f['department']->id])->save();
|
||||
$this->actingAs($head)->get('/recepcoes/'.$id)->assertOk();
|
||||
});
|
||||
|
||||
test('a line from another order and duplicate lines are rejected', function () {
|
||||
$foreign = receiptFixture();
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$this->payload['items'][0]['purchase_order_item_id'] = $foreign['line']->id;
|
||||
$this->postJson($this->url, $this->payload)->assertUnprocessable()->assertJsonValidationErrors('items');
|
||||
$body = receiptPayload($this->f);
|
||||
$body['items'][] = $body['items'][0];
|
||||
$this->postJson($this->url, $body)->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('receipts items and movements are append only and unsupported HTTP edits are absent', function () {
|
||||
$id = $this->postJson($this->url, $this->payload)->assertCreated()->json('id');
|
||||
$receipt = GoodsReceipt::query()->findOrFail($id);
|
||||
$item = GoodsReceiptItem::query()->firstOrFail();
|
||||
$movement = StockMovement::query()->firstOrFail();
|
||||
foreach ([$receipt, $item, $movement] as $record) {
|
||||
expect(fn () => $record->delete())->toThrow(LogicException::class);
|
||||
}
|
||||
expect(fn () => $receipt->update(['delivery_note' => 'Outra']))->toThrow(LogicException::class);
|
||||
expect(fn () => $item->update(['accepted_milli' => 1000]))->toThrow(LogicException::class);
|
||||
expect(fn () => $movement->update(['quantity_milli' => 1000]))->toThrow(LogicException::class);
|
||||
$this->put('/recepcoes/'.$id, [])->assertStatus(405);
|
||||
$this->delete('/recepcoes/'.$id)->assertStatus(405);
|
||||
});
|
||||
|
||||
test('stock is derived from receipts not initial balances supplied by browser', function () {
|
||||
$this->post('/stock/artigos', ['code' => ' novo-art ', 'name' => 'Novo artigo', 'unit' => ' CX ', 'quantity_milli' => 999999, 'tenant_id' => 999])->assertSessionHasNoErrors();
|
||||
$item = StockItem::query()->where('code', 'NOVO-ART')->firstOrFail();
|
||||
expect($item->tenant_id)->toBe($this->f['tenant']->id)->and($item->unit)->toBe('cx');
|
||||
expect(StockMovement::query()->count())->toBe(0);
|
||||
$this->post('/stock/artigos', ['code' => 'novo-art', 'name' => 'Duplicado', 'unit' => 'cx'])->assertSessionHasErrors('code');
|
||||
$this->post('/stock/movimentos', [])->assertNotFound();
|
||||
});
|
||||
|
||||
test('audit failure rolls back receipt lines and all stock entries', function () {
|
||||
$this->mock(RequisitionWorkflow::class, function ($mock) {
|
||||
$mock->shouldReceive('audit')->once()->andThrow(new RuntimeException('Falha simulada de auditoria da recepção.'));
|
||||
});
|
||||
$this->postJson($this->url, $this->payload)->assertStatus(500);
|
||||
expect(GoodsReceipt::query()->count())->toBe(0)->and(StockMovement::query()->count())->toBe(0)->and(GoodsReceiptItem::query()->count())->toBe(0);
|
||||
expect(app(ReceiptWorkflow::class)->progress($this->f['order'])['lines'][0]['delivery_pending_milli'])->toBe(1000);
|
||||
});
|
||||
|
||||
test('receipt dates and server controlled fields are enforced', function () {
|
||||
$this->postJson($this->url, [...$this->payload, 'received_on' => now()->subDay()->format('Y-m-d')])->assertUnprocessable()->assertJsonValidationErrors('received_on');
|
||||
$this->postJson($this->url, [...$this->payload, 'received_on' => now()->addDay()->format('Y-m-d')])->assertUnprocessable();
|
||||
$this->postJson($this->url, [...$this->payload, 'received_by' => $this->f['issuer']->id, 'status' => 'complete'])->assertUnprocessable()->assertJsonValidationErrors(['received_by', 'status']);
|
||||
});
|
||||
|
||||
test('guests cannot access receiving and stock operations', function () {
|
||||
auth()->logout();
|
||||
$this->get('/recepcoes')->assertRedirect('/login');
|
||||
$this->get('/stock')->assertRedirect('/login');
|
||||
$this->post($this->url, $this->payload)->assertRedirect('/login');
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\StockOperation;
|
||||
use App\Models\Warehouse;
|
||||
use App\Services\ReceiptWorkflow;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture();
|
||||
$this->actingAs($this->f['receiver']);
|
||||
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '1.000'));
|
||||
$this->dest = Warehouse::query()->create(['tenant_id' => $this->f['tenant']->id, 'created_by' => $this->f['receiver']->id, 'code' => 'DEST', 'name' => 'Destino']);
|
||||
$this->p = ['kind' => 'issue', 'reference' => 'SAI-001', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $this->f['warehouse']->id, 'stock_item_id' => $this->f['stock']->id, 'responsible_id' => $this->f['issuer']->id, 'destination' => 'Produção / REQ-001', 'reason' => 'Consumo de produção', 'quantity' => '0.600'];
|
||||
});
|
||||
|
||||
test('issues are exact, idempotent and cannot make stock negative', function () {
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertCreated();
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertOk()->assertJsonPath('created', false);
|
||||
$this->p['quantity'] = '0.500';
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertUnprocessable();
|
||||
$this->p['reference'] = 'SAI-002';
|
||||
$this->p['idempotency_key'] = (string) Str::uuid();
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertUnprocessable()->assertJsonValidationErrors('quantity');
|
||||
expect((int) StockMovement::query()->sum('quantity_milli'))->toBe(400)->and(StockOperation::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('transfer creates exactly two opposite ledger entries and rejects repeated references', function () {
|
||||
$this->p['kind'] = 'transfer';
|
||||
$this->p['destination_warehouse_id'] = $this->dest->id;
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertCreated();
|
||||
$op = StockOperation::query()->firstOrFail();
|
||||
$moves = StockMovement::query()->where('stock_operation_id', $op->id);
|
||||
expect($moves->count())->toBe(2)->and((int) $moves->sum('quantity_milli'))->toBe(0);
|
||||
expect((int) StockMovement::query()->where('warehouse_id', $this->dest->id)->sum('quantity_milli'))->toBe(600);
|
||||
$this->p['idempotency_key'] = (string) Str::uuid();
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertUnprocessable()->assertJsonValidationErrors('reference');
|
||||
$this->p['reference'] = 'OTHER';
|
||||
$this->p['destination_warehouse_id'] = $this->f['warehouse']->id;
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('counts preserve theoretical quantities and adjustments apply a justified difference once', function () {
|
||||
$this->p['kind'] = 'count';
|
||||
$this->p['quantity'] = '0';
|
||||
$this->p['ledger_version'] = StockMovement::query()->max('id');
|
||||
$id = $this->postJson('/stock/operacoes', $this->p)->assertCreated()->json('id');
|
||||
expect((int) StockMovement::query()->sum('quantity_milli'))->toBe(1000);
|
||||
$this->p['kind'] = 'adjustment';
|
||||
$this->p['count_id'] = $id;
|
||||
$this->p['reference'] = 'AJ-001';
|
||||
$this->p['idempotency_key'] = (string) Str::uuid();
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertCreated();
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertOk();
|
||||
expect((int) StockMovement::query()->sum('quantity_milli'))->toBe(0);
|
||||
$this->p['reference'] = 'AJ-002';
|
||||
$this->p['idempotency_key'] = (string) Str::uuid();
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertUnprocessable();
|
||||
expect(StockOperation::query()->findOrFail($id)->theoretical_milli)->toBe(1000);
|
||||
});
|
||||
|
||||
test('intervening receipts and issues invalidate counts and stale count forms', function () {
|
||||
$count = [...$this->p, 'kind' => 'count', 'quantity' => '0.800', 'ledger_version' => StockMovement::query()->max('id')];
|
||||
$id = $this->postJson('/stock/operacoes', $count)->assertCreated()->json('id');
|
||||
$this->p['reference'] = 'SAI-NEW';
|
||||
$this->p['idempotency_key'] = (string) Str::uuid();
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertCreated();
|
||||
$this->postJson('/stock/operacoes', [...$count, 'reference' => 'COUNT-NEW', 'idempotency_key' => (string) Str::uuid()])->assertUnprocessable()->assertJsonValidationErrors('ledger_version');
|
||||
$this->postJson('/stock/operacoes', [...$this->p, 'kind' => 'adjustment', 'reference' => 'AJ-NEW', 'count_id' => $id, 'idempotency_key' => (string) Str::uuid()])->assertUnprocessable()->assertJsonValidationErrors('count_id');
|
||||
});
|
||||
|
||||
test('operational role cannot be replaced by administration or other profiles', function ($role) {
|
||||
$this->f['receiver']->forceFill(['purchasing_role' => $role])->save();
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertForbidden();
|
||||
})->with([PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement, PurchasingRole::DepartmentHead, PurchasingRole::Requester]);
|
||||
|
||||
test('foreign company resources cannot be used', function ($field) {
|
||||
$other = receiptFixture();
|
||||
$this->actingAs($this->f['receiver']);
|
||||
$values = ['warehouse_id' => $other['warehouse']->id, 'destination_warehouse_id' => $other['warehouse']->id, 'stock_item_id' => $other['stock']->id, 'responsible_id' => $other['receiver']->id];
|
||||
if ($field === 'destination_warehouse_id') {
|
||||
$this->p['kind'] = 'transfer';
|
||||
}
|
||||
$this->p[$field] = $values[$field];
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertUnprocessable()->assertJsonValidationErrors($field);
|
||||
})->with(['warehouse_id', 'destination_warehouse_id', 'stock_item_id', 'responsible_id']);
|
||||
|
||||
test('audit failure rolls back both halves of transfer and operation', function () {
|
||||
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->andThrow(new RuntimeException('Audit unavailable'));
|
||||
$this->p['kind'] = 'transfer';
|
||||
$this->p['destination_warehouse_id'] = $this->dest->id;
|
||||
$this->withoutExceptionHandling();
|
||||
try {
|
||||
$this->postJson('/stock/operacoes', $this->p);
|
||||
$this->fail('Expected exception');
|
||||
} catch (RuntimeException $e) {
|
||||
expect($e->getMessage())->toBe('Audit unavailable');
|
||||
}
|
||||
expect(StockOperation::query()->count())->toBe(0)->and(StockMovement::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('operations are immutable and new public screen contains only demonstration props', function () {
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertCreated();
|
||||
$op = StockOperation::query()->firstOrFail();
|
||||
expect(fn () => $op->update(['reason' => 'rewrite']))->toThrow(LogicException::class);
|
||||
expect(fn () => $op->delete())->toThrow(LogicException::class);
|
||||
$this->get('/stock/operacoes')->assertInertia(fn (Assert $p) => $p->component('stock/operations')->has('operations.data', 1)->where('canOperate', true));
|
||||
$this->get('/consulta/operacoes-stock')->assertInertia(fn (Assert $p) => $p->component('preview/index')->where('initialScreen', 'operations')->missing('operations')->where('auth.user', null));
|
||||
});
|
||||
|
||||
test('invalid precision and missing justification or destination do not write', function ($field, $value) {
|
||||
$this->p[$field] = $value;
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertUnprocessable()->assertJsonValidationErrors($field);
|
||||
expect(StockOperation::query()->count())->toBe(0);
|
||||
})->with([['quantity', '-1'], ['quantity', '0'], ['quantity', '0.0001'], ['reason', ' '], ['destination', ''], ['responsible_id', null]]);
|
||||
|
||||
test('positive adjustments credit only the difference and matching counts create no movements', function () {
|
||||
$count = [...$this->p, 'kind' => 'count', 'quantity' => '1.250', 'ledger_version' => StockMovement::query()->max('id')];
|
||||
$id = $this->postJson('/stock/operacoes', $count)->assertCreated()->json('id');
|
||||
$adjustment = [...$this->p, 'kind' => 'adjustment', 'count_id' => $id, 'reference' => 'AJ-POS', 'idempotency_key' => (string) Str::uuid()];
|
||||
$this->postJson('/stock/operacoes', $adjustment)->assertCreated();
|
||||
expect((int) StockMovement::query()->sum('quantity_milli'))->toBe(1250);
|
||||
$id = $this->postJson('/stock/operacoes', [...$count, 'reference' => 'MATCH', 'idempotency_key' => (string) Str::uuid(), 'ledger_version' => StockMovement::query()->max('id')])->assertCreated()->json('id');
|
||||
$this->postJson('/stock/operacoes', [...$adjustment, 'reference' => 'NOOP', 'count_id' => $id, 'idempotency_key' => (string) Str::uuid()])->assertUnprocessable();
|
||||
expect(StockMovement::query()->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('failure creating transfer credit rolls back its debit', function () {
|
||||
StockMovement::creating(function (StockMovement $movement) {
|
||||
if ($movement->movement_type === 'transfer_in') {
|
||||
throw new RuntimeException('Credit failed');
|
||||
}
|
||||
});
|
||||
$this->withoutExceptionHandling();
|
||||
try {
|
||||
$this->postJson('/stock/operacoes', [...$this->p, 'kind' => 'transfer', 'destination_warehouse_id' => $this->dest->id]);
|
||||
$this->fail('Expected failure');
|
||||
} catch (RuntimeException $error) {
|
||||
expect($error->getMessage())->toBe('Credit failed');
|
||||
}
|
||||
expect(StockOperation::query()->count())->toBe(0)->and(StockMovement::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('stock views isolate operations and counts from other companies', function () {
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertCreated();
|
||||
$other = receiptFixture();
|
||||
$this->actingAs($other['receiver']);
|
||||
$this->get('/stock/operacoes')->assertInertia(fn (Assert $p) => $p->has('operations.data', 0)->has('counts', 0));
|
||||
});
|
||||
|
||||
test('form encoded retry identifiers and equivalent quantities retain idempotency', function () {
|
||||
$id = $this->postJson('/stock/operacoes', $this->p)->assertCreated()->json('id');
|
||||
foreach (['warehouse_id', 'stock_item_id', 'responsible_id'] as $key) {
|
||||
$this->p[$key] = (string) $this->p[$key];
|
||||
}
|
||||
$this->p['quantity'] = '0.6';
|
||||
$this->postJson('/stock/operacoes', $this->p)->assertOk()->assertJsonPath('id', $id);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\CostCenter;
|
||||
use App\Models\Department;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/** Synthetic fixture: callable only on in-memory SQLite or the dedicated PostgreSQL test database. */
|
||||
function receiptFixture(string $quantity = '1.000', string $purchaseType = 'consumable'): array
|
||||
{
|
||||
$connection = DB::connection();
|
||||
$allowed = ($connection->getDriverName() === 'sqlite' && $connection->getDatabaseName() === ':memory:') ||
|
||||
($connection->getDriverName() === 'pgsql' && preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $connection->getDatabaseName()));
|
||||
if (! app()->environment('testing') || ! $allowed) {
|
||||
throw new LogicException('Fixtures de recepção proibidas fora de uma base de testes isolada.');
|
||||
}
|
||||
$tenant = Tenant::query()->create(['name' => 'Empresa fictícia de recepção']);
|
||||
$department = Department::query()->create(['tenant_id' => $tenant->id, 'name' => 'Operações']);
|
||||
$center = CostCenter::query()->create(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'code' => 'OPS', 'name' => 'Operações']);
|
||||
$issuer = User::factory()->create(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Procurement]);
|
||||
$receiver = User::factory()->create(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Warehouse]);
|
||||
$otherReceiver = User::factory()->create(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Warehouse]);
|
||||
auth()->guard()->setUser($receiver);
|
||||
$r = Requisition::query()->create(['tenant_id' => $tenant->id, 'requester_id' => $issuer->id, 'department_id' => $department->id, 'cost_center_id' => $center->id, 'number' => 'REQ-TEST-'.Str::random(10), 'priority' => 'normal', 'needed_by' => now()->addWeek(), 'justification' => 'Fixture de ordem já emitida para testes de recepção.', 'status' => 'approved', 'subtotal_minor' => 10000, 'tax_minor' => 0, 'total_minor' => 10000]);
|
||||
$requested = $r->items()->create(['tenant_id' => $tenant->id, 'description' => 'Artigo fictício', 'category' => 'Material', 'purchase_type' => $purchaseType, 'quantity' => $quantity, 'unit' => 'cx', 'unit_price_minor' => 10000, 'tax_rate' => '0', 'subtotal_minor' => 10000, 'tax_minor' => 0, 'total_minor' => 10000]);
|
||||
$supplier = Supplier::query()->create(['tenant_id' => $tenant->id, 'created_by' => $issuer->id, 'name' => 'Fornecedor fictício', 'nuit' => '123456789', 'contact' => 'Contacto de teste', 'email' => 'fixture@example.invalid', 'phone' => '000000000', 'address' => 'Morada fictícia', 'category' => 'Material', 'beneficiaries' => 'Beneficiário fictício', 'document_references' => 'Documento fictício', 'diligence_notes' => 'Somente para a base de testes.', 'status' => 'active', 'next_review' => now()->addYear()]);
|
||||
$quote = Quotation::query()->create(['tenant_id' => $tenant->id, 'requisition_id' => $r->id, 'supplier_id' => $supplier->id, 'created_by' => $issuer->id, 'reference' => 'PROP-TEST', 'delivery_days' => 5, 'valid_until' => now()->addWeek(), 'payment_terms' => '30 dias', 'subtotal_minor' => 10000, 'tax_minor' => 0, 'total_minor' => 10000, 'weights' => ['price' => 40, 'quality' => 20, 'delivery' => 15, 'history' => 15, 'payment' => 10]]);
|
||||
$award = PurchaseAward::query()->create(['tenant_id' => $tenant->id, 'requisition_id' => $r->id, 'quotation_id' => $quote->id, 'created_by' => $issuer->id, 'justification' => 'Adjudicação de fixture, não repetir o fluxo anterior.', 'delivery_date' => now()->addWeek(), 'delivery_location' => 'Armazém fictício', 'status' => 'approved', 'subtotal_minor' => 10000, 'tax_minor' => 0, 'total_minor' => 10000, 'approval_snapshot' => [], 'decision_matrix' => [], 'comparison_hash' => str_repeat('0', 64)]);
|
||||
$order = PurchaseOrder::query()->create(['tenant_id' => $tenant->id, 'requisition_id' => $r->id, 'purchase_award_id' => $award->id, 'supplier_id' => $supplier->id, 'issued_by' => $issuer->id, 'number' => 'OC-TEST-'.Str::random(10), 'status' => 'issued', 'currency' => 'MZN', 'subtotal_minor' => 10000, 'tax_minor' => 0, 'total_minor' => 10000, 'issued_at' => now(), 'snapshot' => ['supplier' => $supplier->only(['name', 'nuit']), 'company' => ['name' => $tenant->name], 'development_only' => true, 'budget_checked' => false]]);
|
||||
$line = $order->items()->create(['tenant_id' => $tenant->id, 'requisition_item_id' => $requested->id, 'description' => 'Artigo fictício', 'category' => 'Material', 'purchase_type' => $purchaseType, 'unit' => 'cx', 'quantity' => $quantity, 'unit_price_minor' => 10000, 'tax_rate' => '0', 'subtotal_minor' => 10000, 'tax_minor' => 0, 'total_minor' => 10000]);
|
||||
$warehouse = Warehouse::query()->create(['tenant_id' => $tenant->id, 'created_by' => $receiver->id, 'code' => 'ARM-TEST', 'name' => 'Armazém fictício']);
|
||||
$stock = StockItem::query()->create(['tenant_id' => $tenant->id, 'created_by' => $receiver->id, 'code' => 'ART-TEST', 'name' => 'Artigo de stock fictício', 'unit' => 'cx']);
|
||||
|
||||
return compact('tenant', 'department', 'issuer', 'receiver', 'otherReceiver', 'r', 'order', 'line', 'warehouse', 'stock');
|
||||
}
|
||||
|
||||
function receiptPayload(array $fixture, string $quantity = '0.600'): array
|
||||
{
|
||||
return ['idempotency_key' => (string) Str::uuid(), 'delivery_note' => 'GR-'.Str::random(10), 'received_on' => now()->format('Y-m-d'), 'warehouse_id' => $fixture['warehouse']->id, 'items' => [['purchase_order_item_id' => $fixture['line']->id, 'received_quantity' => $quantity, 'accepted_quantity' => $quantity, 'quality_status' => 'compliant', 'stock_item_id' => $fixture['stock']->id, 'notes' => null]]];
|
||||
}
|
||||
Reference in New Issue
Block a user