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

262 lines
20 KiB
PHP

<?php
namespace App\Services;
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 App\Models\WarehouseRequest;
use App\Models\WarehouseRequestEvent;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/** Native internal issue -> unused material declaration -> independently received physical return. */
final class WarehouseReturns
{
public function allows(User $actor, string $action): bool
{
$access = app(OrganizationAccess::class);
$scope = app(WorkContext::class)->enforced($actor) ? $this->context() : null;
$permissions = $access->permissions($actor, $scope);
return ! $access->maintenanceOnly($actor) && in_array('warehouse_return_'.$action, $permissions, true) && ($action !== 'confirm' || in_array('warehouse', $permissions, true));
}
public function authorize(User $actor, string $action): User
{
abort_unless(config('stock_returns.enabled'), 503, 'Devoluções de excedentes ainda não activadas.');
abort_unless(DB::connection()->getDriverName() === 'pgsql', 503, 'Percurso de devoluções ainda não validado neste driver.');
abort_unless(auth()->id() === $actor->id && $actor->tenant_id !== null, 403);
$fresh = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($actor->id);
abort_unless($this->allows($fresh, $action), 403);
abort_unless(Schema::hasTable('supply_records'), 503, 'Histórico administrativo ainda não instalado.');
return $fresh;
}
/** @param array<array-key,mixed> $v */
private function hash(array $v): string
{
return hash('sha256', json_encode($v, JSON_THROW_ON_ERROR));
}
private function lock(User $actor, string $action): User
{
$timeout=config('stock_returns.lock_timeout_ms',2000);
abort_unless(is_int($timeout)&&$timeout>=100&&$timeout<=10000,503,'Tempo de espera de conferência inválido.');
DB::select("SELECT set_config('lock_timeout',?,true)",[$timeout.'ms']);
app(ConflictGuard::class)->lockCompany($actor);
$fresh = app(CurrentActor::class)->lock($actor);
return $this->authorize($fresh, $action);
}
private function context(): string
{
$actor = auth()->user();
abort_unless($actor instanceof User, 403);
if (! app(WorkContext::class)->enforced($actor)) {
return 'procurement.internal';
}
abort_unless(request()->hasSession(), 403);
$key = app(WorkContext::class)->active(request());
abort_unless($key === 'procurement.internal', 403);
return $key;
}
private function request(int $id): WarehouseRequest
{
$r = WarehouseRequest::query()->findOrFail($id);
abort_unless(($r->work_context_key ?? 'procurement.internal') === $this->context(), 403);
return $r;
}
/** @return array<string,mixed> */
public function availableDeliveries(User $actor, int $requestId, int $page): array
{
$this->authorize($actor, 'read');
$r = $this->request($requestId);
abort_unless(app(WarehouseWorkdesk::class)->canView($actor, $r), 403);
$events = WarehouseRequestEvent::query()->where('warehouse_request_id', $r->id)->where('action', 'deliver')->whereExists(function ($q) use ($actor, $r) {
$q->selectRaw('1')->from('warehouse_request_events AS ack')->where('ack.tenant_id', $actor->tenant_id)->where('ack.warehouse_request_id', $r->id)->where('ack.action', 'receive')->whereRaw("(ack.data->>'delivery_event_id')::bigint=warehouse_request_events.id");
})->orderByDesc('id')->paginate(25, ['id', 'data'], 'source_page', $page);
$options = [];
foreach ($events as $event) {
foreach ($event->data['lines'] as $line) {
$basis = $r->snapshot['lines'][$line['index']] ?? null;
if ($basis && $basis['use'] === 'internal' && $basis['equipment'] === null) {
$options[] = ['delivery_event_id' => $event->id, 'line_index' => $line['index'], 'item' => $basis['item'], 'delivered_milli' => (string) $line['quantity_milli']];
}
}
}
return ['request_id' => $r->id, 'can_prepare' => $r->created_by === $actor->id && $this->allows($actor, 'prepare'), 'options' => $options, 'pagination' => ['page' => $events->currentPage(), 'last_page' => $events->lastPage(), 'total' => $events->total()]];
}
/**
* @param list<array<string,mixed>> $lines
* @return array<string,mixed>|null
*/
private function deliveryLine(array $lines, int $index): ?array
{
foreach ($lines as $line) {
if ($line['index'] === $index) {
return $line;
}
}
return null;
}
/**
* @param array<string,mixed> $d
* @return array<string,mixed>
*/
private function source(User $actor, array $d): array
{
$event = WarehouseRequestEvent::query()->where('action', 'deliver')->whereKey($d['delivery_event_id'])->lockForUpdate()->firstOrFail();
$request = $this->request($event->warehouse_request_id);
$line = $this->deliveryLine($event->data['lines'], $d['line_index']);
$basis = $request->snapshot['lines'][$d['line_index']] ?? null;
abort_unless($line && $basis && $basis['use'] === 'internal' && $basis['equipment'] === null, 409, 'Apenas excedente de entrega interna simples, sem equipamento ou venda.');
$acknowledgement = WarehouseRequestEvent::query()->where('warehouse_request_id', $request->id)->where('action', 'receive')->where('data->delivery_event_id', $event->id)->lockForUpdate()->first();
abort_unless($acknowledgement !== null, 409, 'A entrega original ainda não foi recebida pelo destinatário.');
$operation = DB::table('stock_operations')->where(['tenant_id' => $actor->tenant_id, 'id' => $line['stock_operation_id'], 'kind' => 'issue'])->lockForUpdate()->first();
abort_unless($operation && (int) $operation->stock_item_id === $basis['item']['id'] && (int) $operation->warehouse_id === $request->warehouse_id && $operation->equipment_id === null && $operation->spare_part_id === null, 409, 'Entrega incompatível com o documento interno.');
$movement = DB::table('stock_movements')->where(['tenant_id' => $actor->tenant_id, 'stock_operation_id' => $operation->id, 'movement_type' => 'issue'])->lockForUpdate()->first();
abort_unless($movement && (int) $movement->quantity_milli === -(int) $line['quantity_milli'], 409, 'Quantidade da entrega incompatível.');
$snapshot = json_decode($movement->snapshot, true, 512, JSON_THROW_ON_ERROR);
abort_unless(($snapshot['inventory_operation_hash'] ?? null) === $this->hash((array) $operation), 409, 'Entrega sem fotografia original íntegra.');
abort_if(SupplyRecord::query()->where('kind', 'stock_reversal')->where('state', 'executed')->where('data->source_kind', 'operation')->where('data->source_operation_id', $operation->id)->exists(), 409, 'Entrega estornada; exige reconciliação própria.');
abort_if(app(InventoryLots::class)->setup($operation->stock_item_id, $operation->warehouse_id) !== null || app(WarehouseReservations::class)->committed($operation->stock_item_id, $operation->warehouse_id) > 0, 409, 'Lotes ou reservas exigem percurso próprio.');
$item = StockItem::query()->whereKey($operation->stock_item_id)->firstOrFail();
$warehouse = Warehouse::query()->whereKey($operation->warehouse_id)->firstOrFail();
abort_unless($item->unit === $basis['item']['unit'], 409, 'Unidade do artigo mudou; exige reconciliação própria, sem conversão automática.');
$returned = (int) StockMovement::query()->where('movement_type', 'workshop_return_in')->where('snapshot->delivery_movement_id', $movement->id)->sum('quantity_milli');
$remaining = (int) $line['quantity_milli'] - $returned;
abort_unless($d['quantity_milli'] > 0 && $d['quantity_milli'] <= $remaining, 409, 'Quantidade excede a entrega ainda não devolvida.');
$book = app(StockWorkflow::class)->balance($operation->stock_item_id, $operation->warehouse_id);
return ['contract_version' => 'WAREHOUSE-UNUSED-RETURN-v1', 'writer_manifest_sha256' => WarehouseReturnMovement::MANIFEST_SHA256, 'context_revision' => app(WorkContext::class)->policy($actor)['revision'] ?? null, 'request_id' => $request->id, 'requester_id' => $request->created_by, 'work_context_key' => $request->work_context_key ?? 'procurement.internal', 'delivery' => (array) $event->getAttributes(), 'acknowledgement' => (array) $acknowledgement->getAttributes(), 'line_index' => $d['line_index'], 'operation' => (array) $operation, 'movement' => (array) $movement, 'item' => $item->only(['id', 'code', 'name', 'unit']), 'original_item' => $basis['item'], 'warehouse' => $warehouse->only(['id', 'code', 'name']), 'quantity_milli' => $d['quantity_milli'], 'remaining_milli' => $remaining, 'book' => $book];
}
/**
* @param array<string,mixed> $s
* @return array<string,mixed>
*/
private function impact(array $s): array
{
return ['physical_before' => (string) $s['book']['quantity'], 'physical_after' => (string) ($s['book']['quantity'] + $s['quantity_milli']), 'return_quantity_milli' => (string) $s['quantity_milli'], 'not_returned_before' => (string) $s['remaining_milli'], 'not_returned_after' => (string) ($s['remaining_milli'] - $s['quantity_milli']), 'original_movement_id' => (int) $s['movement']['id'], 'item' => $s['item'], 'warehouse' => $s['warehouse']];
}
/**
* @param array<string,mixed> $d
* @return array<string,mixed>
*/
public function prepare(User $actor, array $d): array
{
abort_unless(($d['declared_unused'] ?? false) === true, 422, 'Declare expressamente que o material não foi consumido.');
$this->authorize($actor, 'prepare');
return DB::transaction(function () use ($actor, $d) {
$actor = $this->lock($actor, 'prepare');
$hash = $this->hash($d);
$old = SupplyRecord::query()->where('kind', 'warehouse_return')->where('operation_key', $d['operation_key'])->first();
if ($old) {
abort_unless($old->created_by === $actor->id && hash_equals($old->input_hash, $hash), 409, 'Chave utilizada com outros dados.');
$this->request($old->snapshot['source']['request_id']);
$state=SupplyRecord::query()->where('kind','warehouse_return')->where('record_key',$old->record_key)->orderByDesc('version')->value('state');
return ['id' => $old->id, 'state'=>$state, 'impact' => $this->impact($old->snapshot['source']), 'replayed' => true];
}
$source = $this->source($actor, $d);
abort_unless($source['requester_id'] === $actor->id, 403, 'A declaração de excedente pertence ao destinatário original.');
$record = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => 'warehouse_return', 'record_key' => $d['operation_key'], 'version' => 1, 'state' => 'prepared', 'stock_item_id' => $source['item']['id'], 'warehouse_id' => $source['warehouse']['id'], 'created_by' => $actor->id, 'data' => $d, 'snapshot' => ['source' => $source, 'source_hash' => $this->hash($source), 'declared_unused' => true], 'reason' => $d['reason'], 'operation_key' => $d['operation_key'], 'input_hash' => $hash, 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'warehouse_return.prepared', null, null, ['record_id' => $record->id, 'request_id' => $source['request_id'], 'impact' => $this->impact($source), 'declared_unused' => true]);
return ['id' => $record->id, 'state'=>'prepared', 'impact' => $this->impact($source), 'replayed' => false];
});
}
/**
* @param array<string,mixed> $d
* @return array<string,mixed>
*/
public function confirm(User $actor, int $id, array $d): array
{
$this->authorize($actor, 'confirm');
return DB::transaction(function () use ($actor, $id, $d) {
$actor = $this->lock($actor, 'confirm');
$p = SupplyRecord::query()->where('kind', 'warehouse_return')->where('version', 1)->lockForUpdate()->findOrFail($id);
$this->request($p->snapshot['source']['request_id']);
abort_if($p->created_by === $actor->id, 403, 'Recepção deve ser independente da declaração.');
$done = SupplyRecord::query()->where('kind', 'warehouse_return')->where('record_key', $p->record_key)->where('version', 2)->first();
if ($done) {
abort_unless($done->state==='confirmed',409,'Proposta encerrada sem movimento. Prepare uma nova proposta se necessário.');
return ['id' => $done->id, 'movement_id' => $done->data['movement_id'], 'replayed' => true];
}
abort_unless($d['version'] === 1 && $d['received_in_warehouse'] === true, 422, 'Confirme a recepção física da versão conferida.');
$source = $this->source($actor, $p->data);
abort_unless(hash_equals($p->snapshot['source_hash'], $this->hash($source)), 409, 'A origem, configuração ou livro mudou. Prepare nova conferência.');
$operation = StockOperation::query()->create(['tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'responsible_id' => $actor->id, 'warehouse_id' => $source['warehouse']['id'], 'stock_item_id' => $source['item']['id'], 'kind' => 'workshop_return', 'reference' => 'EXCEDENTE-'.$p->record_key, 'idempotency_key' => $p->record_key, 'payload_hash' => $p->snapshot['source_hash'], 'destination' => 'Devolução interna RA-'.$source['request_id'], 'reason' => $d['reason'], 'quantity_milli' => $source['quantity_milli'], 'theoretical_milli' => $source['book']['quantity'], 'ledger_version' => $source['book']['version'], 'snapshot' => ['actor' => $actor->only(['id', 'name']), 'responsible' => $actor->only(['id', 'name']), 'stock_item' => $source['item'], 'warehouse' => $source['warehouse'], 'destination_warehouse' => null, 'equipment' => null, 'spare_part' => null, 'difference_milli' => $source['quantity_milli'], 'budget_checked' => false, 'delivery_movement_id' => (int) $source['movement']['id'], 'delivery_movement_hash' => $this->hash($source['movement']), 'received_in_warehouse' => true, 'native_request_id' => $source['request_id'], 'declaration_id' => $p->id, 'declared_unused_by' => $p->created_by, 'evidence' => $p->data['evidence']], 'recorded_at' => now()]);
$writer = app(WarehouseReturnMovement::class)->for($actor);
$out = $writer->write($operation->id, $writer->preview($operation->id));
$done = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => 'warehouse_return', 'record_key' => $p->record_key, 'version' => 2, 'state' => 'confirmed', 'stock_item_id' => $p->stock_item_id, 'warehouse_id' => $p->warehouse_id, 'created_by' => $actor->id, 'data' => ['movement_id' => $out['movement_id'], 'operation_id' => $operation->id, 'prepared_id' => $p->id, 'received_in_warehouse' => true, 'reason' => $d['reason']], 'snapshot' => ['source' => $source, 'before' => $source['book']['quantity'], 'after' => $source['book']['quantity'] + $source['quantity_milli'], 'preparer_id' => $p->created_by], 'reason' => $d['reason'], 'operation_key' => (string) Str::uuid(), 'input_hash' => $this->hash($d), 'created_at' => now()]);
app(RequisitionWorkflow::class)->audit($actor, 'warehouse_return.confirmed', null, null, ['record_id' => $done->id, 'declaration_id' => $p->id, 'movement_id' => $out['movement_id'], 'impact' => $this->impact($source), 'reason' => $d['reason']]);
// Reuse the existing configured replenishment queue; replay never signals again.
app(AutomaticPurchases::class)->signal($actor, $source['item']['id'], $source['warehouse']['id']);
return ['id' => $done->id, 'movement_id' => $out['movement_id'], 'replayed' => false];
});
}
/**
* @param array<string,mixed> $d
* @return array<string,mixed>
*/
public function close(User $actor,int $id,array $d,string $state): array
{
abort_unless(in_array($state,['withdrawn','rejected'],true),422);
$action=$state==='withdrawn'?'prepare':'confirm';$this->authorize($actor,$action);
return DB::transaction(function()use($actor,$id,$d,$state,$action){
$actor=$this->lock($actor,$action);$p=SupplyRecord::query()->where('kind','warehouse_return')->where('version',1)->lockForUpdate()->findOrFail($id);$this->request($p->snapshot['source']['request_id']);
abort_unless($state==='withdrawn'?$p->created_by===$actor->id:$p->created_by!==$actor->id,403,'Retirada pertence ao preparador; recusa exige conferência independente.');
$done=SupplyRecord::query()->where('kind','warehouse_return')->where('record_key',$p->record_key)->where('version',2)->first();
if($done){abort_unless($done->state===$state,409,'A proposta já tem outra decisão terminal.');return ['id'=>$done->id,'state'=>$done->state,'replayed'=>true];}
abort_unless($d['version']===1,409,'Versão de proposta desactualizada.');$book=app(StockWorkflow::class)->balance($p->stock_item_id,$p->warehouse_id);
$closed=SupplyRecord::query()->create(['tenant_id'=>$actor->tenant_id,'kind'=>'warehouse_return','record_key'=>$p->record_key,'version'=>2,'state'=>$state,'stock_item_id'=>$p->stock_item_id,'warehouse_id'=>$p->warehouse_id,'created_by'=>$actor->id,'data'=>['prepared_id'=>$p->id,'action'=>$state,'reason'=>$d['reason']],'snapshot'=>['source'=>$p->snapshot['source'],'before'=>$book['quantity'],'after'=>$book['quantity'],'preparer_id'=>$p->created_by],'reason'=>$d['reason'],'operation_key'=>(string)Str::uuid(),'input_hash'=>$this->hash($d+['action'=>$state]),'created_at'=>now()]);
app(RequisitionWorkflow::class)->audit($actor,'warehouse_return.'.$state,null,null,['record_id'=>$closed->id,'declaration_id'=>$p->id,'reason'=>$d['reason'],'before'=>$book['quantity'],'after'=>$book['quantity'],'physical_effect'=>0]);
return ['id'=>$closed->id,'state'=>$state,'replayed'=>false];
});
}
/** @return array<string,mixed> */
public function listing(User $actor, int $page, string $state): array
{
$this->authorize($actor, 'read');
$query = app(SupplyPlanning::class)->latest('warehouse_return');
if ($state !== 'all') {
$query->where('state', $state);
}
// Legacy and explicitly selected contexts are never mixed in this new history.
$context = $this->context();
$query->where('snapshot->source->work_context_key', $context);
$rows = $query->orderByDesc('id')->paginate(25, ['id', 'record_key', 'version', 'state', 'reason', 'created_by', 'created_at', 'snapshot']);
$prepared = SupplyRecord::query()->where('kind', 'warehouse_return')->where('version', 1)->whereIn('record_key', $rows->getCollection()->pluck('record_key'))->get()->keyBy('record_key');
$items = $rows->getCollection()->map(function ($r) use ($prepared) {
$p = $prepared->get($r->record_key);
return ['id' => $r->id, 'prepared_id' => $p->id, 'version' => $r->version, 'state' => $r->state, 'reason' => $r->reason, 'evidence' => $p->data['evidence'], 'preparer_id' => $p->created_by, 'author_id' => $r->created_by, 'created_at' => $r->created_at->toIso8601String(), 'impact' => $this->impact($r->snapshot['source'])];
});
return ['rows' => $items, 'pagination' => ['page' => $rows->currentPage(), 'last_page' => $rows->lastPage(), 'total' => $rows->total()]];
}
}