Files
compras/app/Services/InventoryReversals.php
T
2026-11-02 20:27:11 +02:00

240 lines
16 KiB
PHP

<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\Equipment;
use App\Models\GoodsReceipt;
use App\Models\GoodsReceiptItem;
use App\Models\InventoryLotAllocation;
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\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
/** Full corrective reversals; original documents and financial facts remain immutable. */
class InventoryReversals
{
public function allows(User $actor, string $action): bool
{
return ! app(OrganizationAccess::class)->maintenanceOnly($actor) && $actor->hasFunctions(match ($action) {
'read' => ['inventory_reversal_read', 'admin', PurchasingRole::Warehouse], 'execute' => [PurchasingRole::Warehouse], default => ['inventory_reversal_'.$action],
});
}
/** @return array<int,SupplyRecord> */
public function corrections(string $sourceKind): array
{
if (! Schema::hasTable('supply_records')) {
return [];
}
$key = $sourceKind === 'operation' ? 'source_operation_id' : 'receipt_item_id';
$result = [];
foreach (app(SupplyPlanning::class)->latest('stock_reversal')->where('state', 'executed')->whereJsonContains('data->source_kind', $sourceKind)->get() as $record) {
$result[$record->data[$key]] = $record;
}
return $result;
}
public function operationReversed(int $id): bool
{
return isset($this->corrections('operation')[$id]);
}
public function receiptReversed(int $id): bool
{
return isset($this->corrections('receipt')[$id]);
}
/** @return list<array<string,mixed>> */
public function financialNotices(): array
{
if (! Schema::hasTable('supply_records')) {
return [];
}
return array_values(app(SupplyPlanning::class)->latest('stock_reversal')->where('state', 'executed')->latest('id')->limit(100)->get()
->filter(fn ($r) => $r->data['source_kind'] === 'receipt' || in_array($r->snapshot['source']['document']['operation_kind'] ?? '', ['supplier_return', 'write_off', 'adjustment'], true))
->map(fn ($r) => ['reference' => $r->data['reference'], 'source_reference' => $r->snapshot['source']['document']['reference'], 'item_name' => $r->snapshot['source']['item']['name'], 'event_on' => $r->data['event']['event_on']])->all());
}
public function voidedReceiptQuantity(int $line, string $column): int
{
if (! in_array($column, ['received_milli', 'accepted_milli'], true)) {
throw new \InvalidArgumentException('Quantidade de recepção desconhecida.');
}
$sum = 0;
foreach ($this->corrections('receipt') as $record) {
$source = $record->snapshot['source']['receipt'];
if ($source['purchase_order_item_id'] === $line) {
$sum += $source[$column];
}
}
return $sum;
}
/** @param array<string,mixed> $data
* @return array{source:array<string,mixed>,observed:list<array<string,mixed>>}
*/
public function basis(array $data): array
{
$receiptSource = null;
if ($data['source_kind'] === 'operation') {
$operation = StockOperation::query()->whereKey($data['source_operation_id'])->firstOrFail();
$sourceMovementIds=StockMovement::query()->where('stock_operation_id',$operation->id)->pluck('id');
$this->check(!StockMovement::query()->where('movement_type','workshop_return_in')->whereIn('snapshot->delivery_movement_id',$sourceMovementIds)->exists(),'action','A entrega já tem devolução física de excedente. Preserve o original e confira a correcção própria.');
$this->check(in_array($operation->kind, ['issue', 'transfer', 'adjustment', 'supplier_return', 'write_off'], true), 'data.source_operation_id', 'Seleccione uma operação física; contagens e outros estornos não são estornados por este percurso.');
$this->check(! $this->operationReversed($operation->id), 'action', 'Esta operação já tem estorno integral.');
$movements = StockMovement::query()->where('stock_operation_id', $operation->id)->orderBy('id')->get();
$itemId = $operation->stock_item_id;
$sourceDocument = ['kind' => 'operation', 'id' => $operation->id, 'reference' => $operation->reference, 'operation_kind' => $operation->kind];
} else {
$line = GoodsReceiptItem::query()->whereKey($data['receipt_item_id'])->firstOrFail();
$receipt = GoodsReceipt::query()->whereKey($line->goods_receipt_id)->firstOrFail();
$this->check($line->accepted_milli > 0 && $line->stock_item_id !== null, 'data.receipt_item_id', 'Este percurso estorna uma linha física aceite com movimento de stock.');
$this->check(! $this->receiptReversed($line->id), 'action', 'Esta linha de recepção já foi integralmente estornada.');
$this->check(app(InventoryDispositions::class)->returnedForReceipt($line->id) === 0, 'action', 'Existem devoluções efectivas desta origem. Reconcilie-as antes de estornar a recepção inteira.');
$movements = StockMovement::query()->where('goods_receipt_item_id', $line->id)->orderBy('id')->get();
$itemId = $line->stock_item_id;
$sourceDocument = ['kind' => 'receipt', 'id' => $line->id, 'reference' => $receipt->number];
$receiptSource = ['item_id' => $line->id, 'receipt_id' => $receipt->id, 'purchase_order_item_id' => $line->purchase_order_item_id,
'purchase_order_id' => $receipt->purchase_order_id, 'received_milli' => $line->received_milli, 'accepted_milli' => $line->accepted_milli];
}
$this->check($movements->isNotEmpty(), 'action', 'A origem não tem movimentos físicos comprovados.');
$item = StockItem::query()->whereKey($itemId)->firstOrFail();
$rows = [];
$observed = [];
foreach ($movements as $movement) {
$originalUnit = $movement->snapshot['stock_item']['unit'] ?? null;
$this->check($movement->stock_item_id === $item->id && is_string($originalUnit) && Str::lower(trim($originalUnit)) === Str::lower(trim($item->unit)), 'action', 'A unidade ou artigo mudou ou falta na origem. Reconcilie sem conversões presumidas.');
$inverse = BigDecimal::of($movement->quantity_milli)->negated()->toInt();
$this->check($inverse !== 0, 'action', 'A origem contém um movimento físico nulo; confira a integridade do registo.');
$warehouse = Warehouse::query()->whereKey($movement->warehouse_id)->firstOrFail();
$balance = app(StockWorkflow::class)->balance($item->id, $warehouse->id);
$this->check($inverse > 0 || $balance['quantity'] >= -$inverse, 'action', 'A quantidade a retirar no estorno já não existe neste local. Não há saldo negativo nem compensação com outro local.');
$setup = app(InventoryLots::class)->setup($item->id, $warehouse->id);
$parts = [];
$allocations = Schema::hasTable('inventory_lot_allocations') ? InventoryLotAllocation::query()->where('stock_movement_id', $movement->id)->orderBy('lot_id')->get() : collect();
$this->check($setup !== null || $allocations->isEmpty(), 'action', 'Preserve o controlo activo dos lotes da origem.');
if ($setup !== null) {
$context = app(InventoryLots::class)->context($item->id, $warehouse->id);
$this->check($context !== null && $context['reconciled'], 'action', 'Confira a repartição por lotes antes do estorno.');
if ($allocations->isEmpty()) {
$this->check($inverse > 0, 'action', 'A entrada original não tinha repartição por lote. Não presuma que o saldo inicial corresponde a essa recepção; reconcilie a origem.');
$parts[] = ['lot_id' => null, 'metadata_id' => null, 'quantity' => $inverse, 'code' => null];
} else {
$total = 0;
foreach ($allocations as $allocation) {
$quantity = BigDecimal::of($allocation->quantity_milli)->negated()->toInt();
$lot = collect($context['lots'])->firstWhere('id', $allocation->lot_id);
$this->check($lot !== null && ($quantity > 0 || $lot['quantity_milli'] >= -$quantity), 'action', 'A quantidade do lote original já não existe neste local. Não substitua por outro lote.');
$parts[] = ['lot_id' => $lot['id'], 'metadata_id' => $lot['metadata_id'], 'quantity' => $quantity, 'code' => $lot['data']['code']];
$total += $quantity;
}
$this->check($total === $inverse, 'action', 'A repartição original não corresponde ao movimento integral.');
}
}
$rows[] = ['source_movement_id' => $movement->id, 'warehouse_id' => $warehouse->id, 'warehouse_name' => $warehouse->name,
'equipment_id' => $movement->equipment_id, 'spare_part_id' => $movement->spare_part_id,
'equipment' => $movement->snapshot['equipment'] ?? ($movement->equipment_id === null ? null : Equipment::query()->whereKey($movement->equipment_id)->firstOrFail()->only(['id', 'code', 'description'])),
'original_quantity_milli' => -$inverse, 'quantity_milli' => $inverse, 'original_type' => $movement->movement_type, 'setup_id' => $setup?->id, 'lots' => $parts];
$observed[] = ['warehouse_id' => $warehouse->id, 'balance' => $balance];
}
usort($rows, fn ($a, $b) => [$a['quantity_milli'] > 0, $a['source_movement_id']] <=> [$b['quantity_milli'] > 0, $b['source_movement_id']]);
return ['source' => ['document' => $sourceDocument, 'item' => $item->only(['id', 'code', 'name', 'unit']), 'rows' => $rows, 'receipt' => $receiptSource], 'observed' => $observed];
}
/** @param array<string,mixed> $input */
public function save(User $actor, array $input): SupplyRecord
{
return DB::transaction(function () use ($actor, $input) {
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor);
$v = Validator::make($input, ['operation_key' => 'required|uuid', 'record_key' => 'nullable|uuid', 'version' => 'required|integer|min:0', 'action' => 'required|in:save,approve,return,execute',
'reason' => 'required|string|min:10|max:4000', 'data' => 'present|array'])->validate();
abort_unless($this->allows($actor, match ($v['action']) {
'save' => 'prepare', 'execute' => 'execute', default => 'review'
}), 403);
$hash = hash('sha256', json_encode($v, JSON_THROW_ON_ERROR));
$retry = SupplyRecord::query()->where('operation_key', $v['operation_key'])->first();
if ($retry) {
$this->check($retry->input_hash === $hash, 'operation_key', 'Chave já usada com outros dados.');
return $retry;
}
$old = empty($v['record_key']) ? null : app(SupplyPlanning::class)->latest('stock_reversal')->where('record_key', $v['record_key'])->firstOrFail();
$this->check((int) $v['version'] === ($old->version ?? 0), 'version', 'O processo mudou. Actualize a página.');
if ($v['action'] === 'save') {
$this->check($old === null || in_array($old->state, ['draft', 'returned'], true), 'action', 'Devolva para actualização antes de alterar a decisão.');
$data = Validator::make($v['data'], ['source_kind' => 'required|in:operation,receipt', 'source_operation_id' => 'required_if:source_kind,operation|nullable|integer|min:1',
'receipt_item_id' => 'required_if:source_kind,receipt|nullable|integer|min:1', 'reference' => 'required|string|max:100'])->validate();
$data['source_operation_id'] = $data['source_kind'] === 'operation' ? (int) $data['source_operation_id'] : null;
$data['receipt_item_id'] = $data['source_kind'] === 'receipt' ? (int) $data['receipt_item_id'] : null;
$data['reference'] = Str::upper(Str::squish($data['reference']));
$this->check($data['reference'] !== '' && ! StockOperation::query()->where('reference', $data['reference'])->exists(), 'data.reference', 'Indique referência única ainda não usada no stock.');
foreach (['stock_event', 'stock_reversal'] as $kind) {
$clash = app(SupplyPlanning::class)->latest($kind)->whereJsonContains('data->reference', $data['reference']);
if ($old !== null) {
$clash->where('record_key', '!=', $old->record_key);
}
$this->check(! $clash->exists(), 'data.reference', 'Referência já usada noutro processo de stock.');
}
$snapshot = $this->basis($data);
$snapshot['contributors'] = array_values(array_unique([...($old?->snapshot['contributors'] ?? []), $actor->id]));
$state = 'draft';
} else {
$this->check($old !== null, 'action', 'Prepare primeiro o estorno.');
$data = $old->data;
$snapshot = $old->snapshot;
if ($v['action'] === 'execute') {
$this->check($old->state === 'approved' && $actor->id !== $snapshot['reviewed_by'], 'action', 'Exige aprovação actual e execução por pessoa diferente de quem aprovou.');
$event = Validator::make($v['data'], ['event_on' => 'required|date_format:Y-m-d|before_or_equal:today', 'evidence' => 'required|string|min:10|max:3000'])->validate();
$this->check($event['event_on'] >= $old->created_at->toDateString(), 'data.event_on', 'O estorno não pode anteceder a aprovação.');
$basis = $this->basis($data);
$this->check($basis['source'] === $snapshot['source'], 'action', 'A origem, identificação ou configuração mudou. Devolva para nova preparação.');
$result = app(StockWorkflow::class)->reverse($actor, $old);
$data['stock_operation_id'] = $result['operation']->id;
$data['event'] = $event;
$snapshot['execution_lots'] = $result['lots'];
$snapshot['execution_observed'] = $basis['observed'];
$state = 'executed';
} else {
$this->check(! in_array($actor->id, $snapshot['contributors'], true), 'action', 'A decisão exige pessoa independente de todos os preparadores.');
$this->check($v['action'] === 'return' ? in_array($old->state, ['draft', 'returned', 'approved'], true) : in_array($old->state, ['draft', 'returned'], true), 'action', 'Estado incompatível.');
$state = $v['action'] === 'approve' ? 'approved' : 'returned';
if ($state === 'approved') {
$basis = $this->basis($data);
$this->check($basis['source'] === $snapshot['source'], 'action', 'A origem mudou; actualize a preparação.');
$snapshot['reviewed_by'] = $actor->id;
$snapshot['review_observed'] = $basis['observed'];
}
}
}
$record = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => 'stock_reversal', 'record_key' => $old->record_key ?? (string) Str::uuid(), 'version' => ($old->version ?? 0) + 1,
'state' => $state, 'stock_item_id' => $snapshot['source']['item']['id'], 'warehouse_id' => $snapshot['source']['rows'][0]['warehouse_id'], 'created_by' => $actor->id,
'data' => $data, 'snapshot' => [...$snapshot, 'actor' => $actor->only(['id', 'name'])], 'reason' => $v['reason'], 'operation_key' => $v['operation_key'], 'input_hash' => $hash, 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'inventory.reversal.'.$v['action'], null, $old?->toArray(), $record->toArray());
return $record;
});
}
private function check(bool $condition, string $field, string $message): void
{
if (! $condition) {
throw ValidationException::withMessages([$field => $message]);
}
}
}