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 $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 */ 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> $lines * @return array|null */ private function deliveryLine(array $lines, int $index): ?array { foreach ($lines as $line) { if ($line['index'] === $index) { return $line; } } return null; } /** * @param array $d * @return array */ 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 $s * @return array */ 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 $d * @return array */ 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 $d * @return array */ 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 $d * @return array */ 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 */ 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()]]; } }