240 lines
16 KiB
PHP
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]);
|
|
}
|
|
}
|
|
}
|