'Открыт', self::STATUS_CLOSED => 'Закрыт', ]; protected $fillable = [ 'spare_part_id', 'reclamation_id', 'with_documents', 'required_qty', 'reserved_qty', 'missing_qty', 'status', 'note', ]; protected $casts = [ 'required_qty' => 'integer', 'reserved_qty' => 'integer', 'missing_qty' => 'integer', 'with_documents' => 'boolean', ]; // Отношения public function sparePart(): BelongsTo { return $this->belongsTo(SparePart::class); } public function reclamation(): BelongsTo { return $this->belongsTo(Reclamation::class); } /** * Получить резервы, связанные с этим дефицитом * * Примечание: Не использовать для eager loading! * Для получения резервов используйте метод getRelatedReservations() */ public function getRelatedReservations(): \Illuminate\Database\Eloquent\Collection { return Reservation::query() ->where('reclamation_id', $this->reclamation_id) ->where('spare_part_id', $this->spare_part_id) ->where('with_documents', $this->with_documents) ->get(); } /** * Резервы для той же рекламации (для базовой связи) */ public function reclamationReservations(): HasMany { return $this->hasMany(Reservation::class, 'reclamation_id', 'reclamation_id'); } // Аксессоры public function getStatusNameAttribute(): string { return self::STATUS_NAMES[$this->status] ?? $this->status; } public function isOpen(): bool { return $this->status === self::STATUS_OPEN; } public function isClosed(): bool { return $this->status === self::STATUS_CLOSED; } /** * Процент покрытия дефицита */ public function getCoveragePercentAttribute(): float { if ($this->required_qty === 0) { return 100; } return round(($this->reserved_qty / $this->required_qty) * 100, 1); } // Scopes public function scopeOpen($query) { return $query->where('status', self::STATUS_OPEN); } public function scopeClosed($query) { return $query->where('status', self::STATUS_CLOSED); } public function scopeForSparePart($query, int $sparePartId) { return $query->where('spare_part_id', $sparePartId); } public function scopeWithDocuments($query, bool $withDocs = true) { return $query->where('with_documents', $withDocs); } public function scopeForReclamation($query, int $reclamationId) { return $query->where('reclamation_id', $reclamationId); } /** * Дефициты с недостачей (для автозакрытия при поступлении) */ public function scopeWithMissing($query) { return $query->where('missing_qty', '>', 0); } /** * FIFO сортировка для обработки */ public function scopeOldestFirst($query) { return $query->orderBy('created_at', 'asc'); } // Методы /** * Добавить резерв к дефициту (при поступлении партии) */ public function addReserved(int $qty): void { $this->reserved_qty += $qty; $this->missing_qty = max(0, $this->required_qty - $this->reserved_qty); if ($this->missing_qty === 0) { $this->status = self::STATUS_CLOSED; } $this->save(); } /** * Пересчитать дефицит на основе резервов */ public function recalculate(): void { $totalReserved = Reservation::query() ->where('reclamation_id', $this->reclamation_id) ->where('spare_part_id', $this->spare_part_id) ->where('with_documents', $this->with_documents) ->whereIn('status', [Reservation::STATUS_ACTIVE, Reservation::STATUS_ISSUED]) ->sum('reserved_qty'); $this->reserved_qty = $totalReserved; $this->missing_qty = max(0, $this->required_qty - $this->reserved_qty); if ($this->missing_qty === 0) { $this->status = self::STATUS_CLOSED; } $this->save(); } }