Shortage.php 5.3 KB

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