288 lines
21 KiB
PHP
288 lines
21 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\SupplyRecord;
|
|
use App\Models\User;
|
|
use App\Models\Warehouse;
|
|
use Brick\Math\BigDecimal;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
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,lot_counts?:list<array<string,mixed>>,lot_version?:string} $data */
|
|
public function record(User $actor, array $data): StockOperation
|
|
{
|
|
if (! in_array($data['kind'], ['issue', 'transfer', 'count', 'adjustment'], true)) {
|
|
throw ValidationException::withMessages(['kind' => 'Esta operação exige o seu próprio percurso de preparação e aprovação.']);
|
|
}
|
|
$roles = $data['kind'] === 'adjustment' ? [PurchasingRole::Warehouse, 'inventory_adjust_review'] : [PurchasingRole::Warehouse];
|
|
abort_unless($actor->tenant_id !== null && $actor->hasFunctions($roles) && 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);
|
|
$actor = app(CurrentActor::class)->lock($actor);
|
|
abort_unless($actor->hasFunctions($data['kind'] === 'adjustment' ? [PurchasingRole::Warehouse, 'inventory_adjust_review'] : [PurchasingRole::Warehouse]), 403);
|
|
$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);
|
|
$reservations = app(WarehouseReservations::class);
|
|
$reservationBefore = $reservations->committed($item->id, $warehouse->id) > 0 ? $reservations->free($item->id, $warehouse->id) : null;
|
|
$quantity = ReceiptWorkflow::milli($data['quantity']);
|
|
if (($data['kind'] === 'count' && $quantity < 0) || (in_array($data['kind'], ['issue', 'transfer'], true) && $quantity <= 0)) {
|
|
throw ValidationException::withMessages(['quantity' => 'Use uma quantidade física válida; correcções exigem contagem ou estorno ligado à origem.']);
|
|
}
|
|
$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 && ! isset($count->snapshot['lot_count'])) {
|
|
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.']);
|
|
}
|
|
$lotService = app(InventoryLots::class);
|
|
$tracked = $lotService->setup($item->id, $warehouse->id) !== null;
|
|
$lotCount = $data['kind'] === 'count' ? app(InventoryCounts::class)->prepare($item->id, $warehouse->id, $quantity, $data) : null;
|
|
$adjustment = null;
|
|
if ($data['kind'] === 'adjustment') {
|
|
if ($tracked) {
|
|
$adjustment = app(InventoryCounts::class)->adjustment($actor, $count);
|
|
} else {
|
|
abort_unless($actor->hasFunctions([PurchasingRole::Warehouse]), 403);
|
|
}
|
|
}
|
|
$lotPlan = $data['kind'] === 'issue' ? $lotService->issuePlan($item->id, $warehouse->id, $quantity) : [];
|
|
if ($destination !== null) {
|
|
$lotPlan = $lotService->transferPlan($item->id, $warehouse->id, $destination->id, $quantity);
|
|
}
|
|
$lotSnapshot = $lotPlan === [] ? [] : ['lot_partition' => ['plan' => $lotService->partitionSnapshot($lotPlan, $warehouse->id),
|
|
'source_setup_id' => $lotService->setup($item->id, $warehouse->id)?->id, 'destination_setup_id' => $destination === null ? null : $lotService->setup($item->id, $destination->id)?->id]];
|
|
if ($lotCount !== null) {
|
|
$lotSnapshot['lot_count'] = $lotCount;
|
|
}
|
|
if ($adjustment !== null) {
|
|
$lotSnapshot['lot_adjustment'] = $adjustment;
|
|
}
|
|
$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' => [...$lotSnapshot, '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 ($adjustment !== null) {
|
|
foreach ([['decrease_milli', -1, 'adjustment_out'], ['increase_milli', 1, 'adjustment_in']] as [$key, $sign, $type]) {
|
|
if ($adjustment[$key] > 0) {
|
|
$movement = $this->movement($actor, $operation, $warehouse->id, $sign * $adjustment[$key], $type);
|
|
$lotService->adjusted($actor, $count, $movement, $adjustment['rows'], $responsible->id);
|
|
}
|
|
}
|
|
if (! $lotService->context($item->id, $warehouse->id)['reconciled']) {
|
|
throw ValidationException::withMessages(['count_id' => 'O ajuste não reconciliou a repartição por lotes.']);
|
|
}
|
|
} elseif ($delta !== 0) {
|
|
$movement = $this->movement($actor, $operation, $warehouse->id, $delta, $data['kind'] === 'transfer' ? 'transfer_out' : $data['kind']);
|
|
foreach ($lotPlan as $allocation) {
|
|
$lotService->allocate($allocation['lot_id'], $warehouse->id, -$allocation['quantity'], $movement);
|
|
}
|
|
if ($destination) {
|
|
$arrival = $this->movement($actor, $operation, $destination->id, $quantity, 'transfer_in');
|
|
if ($lotPlan !== []) {
|
|
$lotService->transferred($actor, $arrival, $lotPlan, $responsible->id);
|
|
}
|
|
}
|
|
}
|
|
app(RequisitionWorkflow::class)->audit($actor, 'stock.'.$data['kind'], null, null, $operation->toArray());
|
|
if ($reservationBefore !== null) {
|
|
$reservations->protect($item->id, $warehouse->id, $reservationBefore);
|
|
}
|
|
app(AutomaticPurchases::class)->signal($actor, $item->id, $warehouse->id);
|
|
if ($destination !== null) {
|
|
app(AutomaticPurchases::class)->signal($actor, $item->id, $destination->id);
|
|
}
|
|
|
|
return $operation;
|
|
});
|
|
}
|
|
|
|
/** Executes an approved physical disposition through the same signed ledger. */
|
|
public function disposition(User $actor, SupplyRecord $approved): StockOperation
|
|
{
|
|
return DB::transaction(function () use ($actor, $approved) {
|
|
app(ConflictGuard::class)->lockCompany($actor);
|
|
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Warehouse]);
|
|
$current = app(SupplyPlanning::class)->latest('stock_event')->where('record_key', $approved->record_key)->firstOrFail();
|
|
if ($current->id !== $approved->id || $current->state !== 'approved' || $actor->id === $current->snapshot['reviewed_by']) {
|
|
throw ValidationException::withMessages(['action' => 'A saída exige a aprovação actual e operador independente da decisão.']);
|
|
}
|
|
$basis = app(InventoryDispositions::class)->basis($current->data);
|
|
if ($basis['source'] !== $current->snapshot['source']) {
|
|
throw ValidationException::withMessages(['action' => 'A origem mudou. Actualize a preparação.']);
|
|
}
|
|
$data = $current->data;
|
|
$quantity = $data['quantity_milli'];
|
|
if (! is_int($quantity) || $quantity < 1) {
|
|
throw new \LogicException('A saída física exige quantidade positiva em milésimos exactos.');
|
|
}
|
|
if (StockOperation::query()->where('reference', $data['reference'])->exists()) {
|
|
throw ValidationException::withMessages(['data.reference' => 'Já existe uma operação de stock com esta referência.']);
|
|
}
|
|
$balance = $this->balance($data['stock_item_id'], $data['warehouse_id']);
|
|
$reservations = app(WarehouseReservations::class);
|
|
$reservationBefore = $reservations->committed($data['stock_item_id'], $data['warehouse_id']) > 0 ? $reservations->free($data['stock_item_id'], $data['warehouse_id']) : null;
|
|
$snapshot = ['actor' => $actor->only(['id', 'name']), 'responsible' => $actor->only(['id', 'name']),
|
|
'stock_item' => $basis['source']['item'], 'warehouse' => $basis['source']['warehouse'], 'destination_warehouse' => null,
|
|
'disposition' => ['approved_id' => $current->id, 'record_key' => $current->record_key, 'source' => $basis['source'], 'financial_correction_created' => false],
|
|
'budget_checked' => false];
|
|
$operation = StockOperation::query()->create(['tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'responsible_id' => $actor->id,
|
|
'warehouse_id' => $data['warehouse_id'], 'stock_item_id' => $data['stock_item_id'], 'kind' => $data['kind'], 'reference' => $data['reference'],
|
|
'idempotency_key' => $current->record_key, 'payload_hash' => hash('sha256', json_encode([$current->id, $data, $basis['source']], JSON_THROW_ON_ERROR)),
|
|
'quantity_milli' => $data['quantity_milli'], 'theoretical_milli' => $balance['quantity'], 'ledger_version' => $balance['version'], 'recorded_at' => now(),
|
|
'destination' => Str::limit($basis['source']['receipt']['supplier_name'] ?? 'Abate físico autorizado', 200, ''), 'reason' => $current->reason, 'snapshot' => $snapshot]);
|
|
$movement = $this->movement($actor, $operation, $data['warehouse_id'], -$quantity, $data['kind']);
|
|
if ($basis['source']['lot'] !== null) {
|
|
app(InventoryLots::class)->allocate($data['lot_id'], $data['warehouse_id'], -$quantity, $movement);
|
|
if (! app(InventoryLots::class)->context($data['stock_item_id'], $data['warehouse_id'])['reconciled']) {
|
|
throw ValidationException::withMessages(['action' => 'A saída não reconciliou o lote.']);
|
|
}
|
|
}
|
|
app(RequisitionWorkflow::class)->audit($actor, 'stock.'.$data['kind'], null, null, $operation->toArray());
|
|
if ($reservationBefore !== null) {
|
|
$reservations->protect($data['stock_item_id'], $data['warehouse_id'], $reservationBefore);
|
|
}
|
|
app(AutomaticPurchases::class)->signal($actor, $data['stock_item_id'], $data['warehouse_id']);
|
|
|
|
return $operation;
|
|
});
|
|
}
|
|
|
|
/** @return array{operation:StockOperation,lots:list<array{movement_id:int,lot_id:int,quantity:int}>} */
|
|
public function reverse(User $actor, SupplyRecord $approved): array
|
|
{
|
|
return DB::transaction(function () use ($actor, $approved) {
|
|
app(ConflictGuard::class)->lockCompany($actor);
|
|
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Warehouse]);
|
|
$current = app(SupplyPlanning::class)->latest('stock_reversal')->where('record_key', $approved->record_key)->firstOrFail();
|
|
if ($current->id !== $approved->id || $current->state !== 'approved' || $actor->id === $current->snapshot['reviewed_by']) {
|
|
throw ValidationException::withMessages(['action' => 'O estorno exige a aprovação actual e execução independente da decisão.']);
|
|
}
|
|
$basis = app(InventoryReversals::class)->basis($current->data);
|
|
if ($basis['source'] !== $current->snapshot['source'] || StockOperation::query()->where('reference', $current->data['reference'])->exists()) {
|
|
throw ValidationException::withMessages(['action' => 'A origem mudou ou a referência já foi utilizada. Actualize a preparação.']);
|
|
}
|
|
$source = $basis['source'];
|
|
$reservations = app(WarehouseReservations::class);
|
|
$reservationBefore = [];
|
|
foreach ($source['rows'] as $row) {
|
|
if ($reservations->committed($source['item']['id'], $row['warehouse_id']) > 0) {
|
|
$reservationBefore[$row['warehouse_id']] = $reservations->free($source['item']['id'], $row['warehouse_id']);
|
|
}
|
|
}
|
|
$warehouseId = $source['rows'][0]['warehouse_id'];
|
|
$balance = $this->balance($source['item']['id'], $warehouseId);
|
|
$gross = 0;
|
|
foreach ($source['rows'] as $row) {
|
|
$gross = BigDecimal::of($gross)->plus(BigDecimal::of($row['quantity_milli'])->abs())->toInt();
|
|
}
|
|
$operation = StockOperation::query()->create(['tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'responsible_id' => $actor->id, 'stock_item_id' => $source['item']['id'],
|
|
'equipment_id' => $source['rows'][0]['equipment_id'], 'spare_part_id' => $source['rows'][0]['spare_part_id'],
|
|
'warehouse_id' => $warehouseId, 'kind' => 'reversal', 'reference' => $current->data['reference'], 'idempotency_key' => $current->record_key,
|
|
'payload_hash' => hash('sha256', json_encode([$current->id, $source], JSON_THROW_ON_ERROR)), 'quantity_milli' => $gross,
|
|
'theoretical_milli' => $balance['quantity'], 'ledger_version' => $balance['version'], 'reason' => $current->reason, 'recorded_at' => now(),
|
|
'snapshot' => ['actor' => $actor->only(['id', 'name']), 'responsible' => $actor->only(['id', 'name']), 'stock_item' => $source['item'],
|
|
'warehouse' => Warehouse::query()->whereKey($warehouseId)->firstOrFail()->only(['id', 'code', 'name']), 'destination_warehouse' => null, 'equipment' => $source['rows'][0]['equipment'],
|
|
'reversal' => ['record_key' => $current->record_key, 'approved_id' => $current->id, 'source' => $source, 'quantity_basis' => 'gross_absolute_movement_quantities', 'financial_correction_created' => false]]]);
|
|
$links = [];
|
|
foreach ($source['rows'] as $row) {
|
|
$quantity = $row['quantity_milli'];
|
|
if (! is_int($quantity) || $quantity === 0) {
|
|
throw new \LogicException('O estorno exige quantidades inteiras exactas em milésimos.');
|
|
}
|
|
$movement = $this->movement($actor, $operation, $row['warehouse_id'], $quantity, $quantity > 0 ? 'reversal_in' : 'reversal_out');
|
|
$links = [...$links, ...app(InventoryLots::class)->reverseMovement($actor, $current, $movement, $row['lots'], $row['source_movement_id'])];
|
|
}
|
|
foreach (array_unique(array_column($source['rows'], 'warehouse_id')) as $warehouse) {
|
|
$context = app(InventoryLots::class)->context($source['item']['id'], $warehouse);
|
|
if ($context !== null && ! $context['reconciled']) {
|
|
throw ValidationException::withMessages(['action' => 'O estorno não reconciliou a repartição por lotes.']);
|
|
}
|
|
}
|
|
app(RequisitionWorkflow::class)->audit($actor, 'stock.reversal', null, null, $operation->toArray());
|
|
foreach ($reservationBefore as $location => $before) {
|
|
$reservations->protect($source['item']['id'], $location, $before);
|
|
}
|
|
foreach ($source['rows'] as $row) {
|
|
app(AutomaticPurchases::class)->signal($actor, $source['item']['id'], $row['warehouse_id']);
|
|
}
|
|
|
|
return ['operation' => $operation, 'lots' => $links];
|
|
});
|
|
}
|
|
|
|
private function movement(User $actor, StockOperation $operation, int $warehouse, int $quantity, string $type): StockMovement
|
|
{
|
|
if($type==='issue'&&$operation->kind==='issue'){
|
|
$writer=app(StockIssueMovement::class)->for($actor);$result=$writer->write($operation->id,$writer->preview($operation->id));
|
|
return StockMovement::query()->findOrFail($result['movement_id']);
|
|
}
|
|
return 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()]);
|
|
}
|
|
}
|