Shortage.php 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. <?php
  2. namespace App\Models;
  3. use Illuminate\Database\Eloquent\Factories\HasFactory;
  4. use Illuminate\Database\Eloquent\Model;
  5. use Illuminate\Database\Eloquent\Relations\BelongsTo;
  6. use Illuminate\Database\Eloquent\Relations\HasMany;
  7. /**
  8. * Дефицит (нехватка) запчасти.
  9. *
  10. * Создаётся когда при резервировании недостаточно свободного остатка.
  11. * НЕ влияет на физический остаток (вместо отрицательных значений).
  12. *
  13. * При поступлении новой партии система автоматически
  14. * резервирует под открытые дефициты (FIFO по дате создания).
  15. */
  16. class Shortage extends Model
  17. {
  18. use HasFactory;
  19. const STATUS_OPEN = 'open';
  20. const STATUS_CLOSED = 'closed';
  21. const STATUS_NAMES = [
  22. self::STATUS_OPEN => 'Открыт',
  23. self::STATUS_CLOSED => 'Закрыт',
  24. ];
  25. protected $fillable = [
  26. 'spare_part_id',
  27. 'reclamation_id',
  28. 'with_documents',
  29. 'required_qty',
  30. 'reserved_qty',
  31. 'missing_qty',
  32. 'status',
  33. 'note',
  34. ];
  35. protected $casts = [
  36. 'required_qty' => 'integer',
  37. 'reserved_qty' => 'integer',
  38. 'missing_qty' => 'integer',
  39. 'with_documents' => 'boolean',
  40. ];
  41. // Отношения
  42. public function sparePart(): BelongsTo
  43. {
  44. return $this->belongsTo(SparePart::class);
  45. }
  46. public function reclamation(): BelongsTo
  47. {
  48. return $this->belongsTo(Reclamation::class);
  49. }
  50. /**
  51. * Получить резервы, связанные с этим дефицитом
  52. *
  53. * Примечание: Не использовать для eager loading!
  54. * Для получения резервов используйте метод getRelatedReservations()
  55. */
  56. public function getRelatedReservations(): \Illuminate\Database\Eloquent\Collection
  57. {
  58. return Reservation::query()
  59. ->where('reclamation_id', $this->reclamation_id)
  60. ->where('spare_part_id', $this->spare_part_id)
  61. ->where('with_documents', $this->with_documents)
  62. ->get();
  63. }
  64. /**
  65. * Резервы для той же рекламации (для базовой связи)
  66. */
  67. public function reclamationReservations(): HasMany
  68. {
  69. return $this->hasMany(Reservation::class, 'reclamation_id', 'reclamation_id');
  70. }
  71. // Аксессоры
  72. public function getStatusNameAttribute(): string
  73. {
  74. return self::STATUS_NAMES[$this->status] ?? $this->status;
  75. }
  76. public function isOpen(): bool
  77. {
  78. return $this->status === self::STATUS_OPEN;
  79. }
  80. public function isClosed(): bool
  81. {
  82. return $this->status === self::STATUS_CLOSED;
  83. }
  84. /**
  85. * Процент покрытия дефицита
  86. */
  87. public function getCoveragePercentAttribute(): float
  88. {
  89. if ($this->required_qty === 0) {
  90. return 100;
  91. }
  92. return round(($this->reserved_qty / $this->required_qty) * 100, 1);
  93. }
  94. // Scopes
  95. public function scopeOpen($query)
  96. {
  97. return $query->where('status', self::STATUS_OPEN);
  98. }
  99. public function scopeClosed($query)
  100. {
  101. return $query->where('status', self::STATUS_CLOSED);
  102. }
  103. public function scopeForSparePart($query, int $sparePartId)
  104. {
  105. return $query->where('spare_part_id', $sparePartId);
  106. }
  107. public function scopeWithDocuments($query, bool $withDocs = true)
  108. {
  109. return $query->where('with_documents', $withDocs);
  110. }
  111. public function scopeForReclamation($query, int $reclamationId)
  112. {
  113. return $query->where('reclamation_id', $reclamationId);
  114. }
  115. /**
  116. * Дефициты с недостачей (для автозакрытия при поступлении)
  117. */
  118. public function scopeWithMissing($query)
  119. {
  120. return $query->where('missing_qty', '>', 0);
  121. }
  122. /**
  123. * FIFO сортировка для обработки
  124. */
  125. public function scopeOldestFirst($query)
  126. {
  127. return $query->orderBy('created_at', 'asc');
  128. }
  129. // Методы
  130. /**
  131. * Добавить резерв к дефициту (при поступлении партии)
  132. */
  133. public function addReserved(int $qty): void
  134. {
  135. $this->reserved_qty += $qty;
  136. $this->missing_qty = max(0, $this->required_qty - $this->reserved_qty);
  137. if ($this->missing_qty === 0) {
  138. $this->status = self::STATUS_CLOSED;
  139. }
  140. $this->save();
  141. }
  142. /**
  143. * Пересчитать дефицит на основе резервов
  144. */
  145. public function recalculate(): void
  146. {
  147. $totalReserved = Reservation::query()
  148. ->where('reclamation_id', $this->reclamation_id)
  149. ->where('spare_part_id', $this->spare_part_id)
  150. ->where('with_documents', $this->with_documents)
  151. ->whereIn('status', [Reservation::STATUS_ACTIVE, Reservation::STATUS_ISSUED])
  152. ->sum('reserved_qty');
  153. $this->reserved_qty = $totalReserved;
  154. $this->missing_qty = max(0, $this->required_qty - $this->reserved_qty);
  155. if ($this->missing_qty === 0) {
  156. $this->status = self::STATUS_CLOSED;
  157. }
  158. $this->save();
  159. }
  160. }