Files
compras/app/Services/StockWorkflow.php
T

111 lines
7.4 KiB
PHP

<?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()]);
}
}