= 0 (enforced CHECK constraint). * * Жизненный цикл: * 1. ordered - заказано у поставщика * 2. in_stock - получено на склад * 3. shipped - полностью отгружено (available_qty = 0) */ #[ScopedBy([YearScope::class])] class SparePartOrder extends Model { use SoftDeletes; const STATUS_ORDERED = 'ordered'; const STATUS_IN_STOCK = 'in_stock'; const STATUS_SHIPPED = 'shipped'; const STATUS_NAMES = [ self::STATUS_ORDERED => 'Заказано', self::STATUS_IN_STOCK => 'На складе', self::STATUS_SHIPPED => 'Отгружено', ]; const DEFAULT_SORT_BY = 'created_at'; protected $fillable = [ 'year', 'spare_part_id', 'source_text', 'sourceable_id', 'sourceable_type', 'status', 'ordered_quantity', 'available_qty', 'with_documents', 'note', 'user_id', ]; protected $casts = [ 'with_documents' => 'boolean', 'ordered_quantity' => 'integer', 'available_qty' => 'integer', 'year' => 'integer', ]; protected static function boot(): void { parent::boot(); static::creating(function ($model) { if (!isset($model->year)) { $model->year = year(); } if (!isset($model->available_qty)) { $model->available_qty = $model->ordered_quantity; } }); // Автосмена статуса при полной отгрузке static::updating(function ($model) { if ($model->available_qty === 0 && $model->status === self::STATUS_IN_STOCK) { $model->status = self::STATUS_SHIPPED; } }); } // ========== ОТНОШЕНИЯ ========== public function sparePart(): BelongsTo { return $this->belongsTo(SparePart::class); } public function user(): BelongsTo { return $this->belongsTo(User::class); } public function sourceable(): MorphTo { return $this->morphTo(); } /** * Резервы из этой партии */ public function reservations(): HasMany { return $this->hasMany(Reservation::class, 'spare_part_order_id'); } /** * Движения по этой партии */ public function movements(): HasMany { return $this->hasMany(InventoryMovement::class, 'spare_part_order_id'); } /** * @deprecated Используйте movements() */ public function shipments(): HasMany { return $this->hasMany(SparePartOrderShipment::class); } // ========== SCOPES ========== public function scopeInStock($query) { return $query->where('status', self::STATUS_IN_STOCK); } public function scopeWithAvailable($query) { return $query->where('available_qty', '>', 0); } public function scopeWithDocuments($query, bool $withDocs = true) { return $query->where('with_documents', $withDocs); } public function scopeForSparePart($query, int $sparePartId) { return $query->where('spare_part_id', $sparePartId); } /** * Партии доступные для резервирования (FIFO) */ public function scopeAvailableForReservation($query, int $sparePartId, bool $withDocuments) { return $query->where('spare_part_id', $sparePartId) ->where('with_documents', $withDocuments) ->where('status', self::STATUS_IN_STOCK) ->where('available_qty', '>', 0) ->orderBy('created_at', 'asc'); } // ========== ВЫЧИСЛЯЕМЫЕ ПОЛЯ ========== public function getStatusNameAttribute(): string { return self::STATUS_NAMES[$this->status] ?? $this->status; } /** * Сколько зарезервировано из этой партии */ public function getReservedQtyAttribute(): int { return (int) ($this->reservations() ->where('status', Reservation::STATUS_ACTIVE) ->sum('reserved_qty') ?? 0); } /** * Свободно для резервирования (физический остаток минус активные резервы) */ public function getFreeQtyAttribute(): int { return max(0, $this->available_qty - $this->reserved_qty); } /** * Сколько было списано (через движения issue) */ public function getIssuedQtyAttribute(): int { return (int) ($this->movements() ->where('movement_type', InventoryMovement::TYPE_ISSUE) ->sum('qty') ?? 0); } // ========== МЕТОДЫ ========== /** * Можно ли зарезервировать указанное количество? */ public function canReserve(int $quantity): bool { return $this->free_qty >= $quantity; } /** * Проверка статуса */ public function isInStock(): bool { return $this->status === self::STATUS_IN_STOCK; } public function isOrdered(): bool { return $this->status === self::STATUS_ORDERED; } public function isShipped(): bool { return $this->status === self::STATUS_SHIPPED; } // ========== ОБРАТНАЯ СОВМЕСТИМОСТЬ ========== /** * @deprecated Поле переименовано в available_qty */ public function getRemainingQuantityAttribute(): int { return $this->available_qty; } /** * @deprecated Используйте SparePartIssueService::issue() */ public function shipQuantity(int $quantity, string $note, ?int $reclamationId = null, ?int $userId = null): bool { // Оставляем для обратной совместимости, но рекомендуется использовать сервис if ($quantity > $this->available_qty) { return false; } $this->available_qty -= $quantity; $this->save(); // Создаём движение для аудита InventoryMovement::create([ 'spare_part_order_id' => $this->id, 'spare_part_id' => $this->spare_part_id, 'qty' => $quantity, 'movement_type' => InventoryMovement::TYPE_ISSUE, 'source_type' => $reclamationId ? InventoryMovement::SOURCE_RECLAMATION : InventoryMovement::SOURCE_MANUAL, 'source_id' => $reclamationId, 'with_documents' => $this->with_documents, 'user_id' => $userId ?? auth()->id(), 'note' => $note, ]); return true; } }