рубли) protected function purchasePrice(): Attribute { return Attribute::make( get: fn($value) => $value ? $value / 100 : null, set: fn($value) => $value ? round($value * 100) : null, ); } protected function customerPrice(): Attribute { return Attribute::make( get: fn($value) => $value ? $value / 100 : null, set: fn($value) => $value ? round($value * 100) : null, ); } protected function expertisePrice(): Attribute { return Attribute::make( get: fn($value) => $value ? $value / 100 : null, set: fn($value) => $value ? round($value * 100) : null, ); } // Текстовое представление цен protected function purchasePriceTxt(): Attribute { return Attribute::make( get: fn() => isset($this->attributes['purchase_price']) && $this->attributes['purchase_price'] !== null ? Price::format($this->attributes['purchase_price'] / 100) : '-', ); } protected function customerPriceTxt(): Attribute { return Attribute::make( get: fn() => isset($this->attributes['customer_price']) && $this->attributes['customer_price'] !== null ? Price::format($this->attributes['customer_price'] / 100) : '-', ); } protected function expertisePriceTxt(): Attribute { return Attribute::make( get: fn() => isset($this->attributes['expertise_price']) && $this->attributes['expertise_price'] !== null ? Price::format($this->attributes['expertise_price'] / 100) : '-', ); } // Атрибут для картинки public function image(): Attribute { $path = ''; if (file_exists(public_path() . '/images/spare_parts/' . $this->article . '.jpg')) { $path = url('/images/spare_parts/' . $this->article . '.jpg'); } return Attribute::make(get: fn() => $path); } // ========== ОТНОШЕНИЯ ========== public function product(): BelongsTo { return $this->belongsTo(Product::class); } public function orders(): HasMany { return $this->hasMany(SparePartOrder::class); } public function reclamations(): BelongsToMany { return $this->belongsToMany(Reclamation::class, 'reclamation_spare_part') ->withPivot('quantity', 'with_documents', 'status', 'reserved_qty', 'issued_qty') ->withTimestamps(); } public function pricingCodes(): BelongsToMany { return $this->belongsToMany(PricingCode::class, 'spare_part_pricing_code') ->withTimestamps(); } public function reservations(): HasMany { return $this->hasMany(Reservation::class); } public function shortages(): HasMany { return $this->hasMany(Shortage::class); } public function movements(): HasMany { return $this->hasMany(InventoryMovement::class); } // ========== ВЫЧИСЛЯЕМЫЕ ПОЛЯ ОСТАТКОВ ========== // Примечание: YearScope автоматически применяется к SparePartOrder /** * Физический остаток БЕЗ документов */ public function getPhysicalStockWithoutDocsAttribute(): int { return (int) ($this->orders() ->where('status', SparePartOrder::STATUS_IN_STOCK) ->where('with_documents', false) ->sum('available_qty') ?? 0); } /** * Физический остаток С документами */ public function getPhysicalStockWithDocsAttribute(): int { return (int) ($this->orders() ->where('status', SparePartOrder::STATUS_IN_STOCK) ->where('with_documents', true) ->sum('available_qty') ?? 0); } /** * Зарезервировано БЕЗ документов */ public function getReservedWithoutDocsAttribute(): int { return (int) (Reservation::query() ->where('spare_part_id', $this->id) ->where('with_documents', false) ->where('status', Reservation::STATUS_ACTIVE) ->sum('reserved_qty') ?? 0); } /** * Зарезервировано С документами */ public function getReservedWithDocsAttribute(): int { return (int) (Reservation::query() ->where('spare_part_id', $this->id) ->where('with_documents', true) ->where('status', Reservation::STATUS_ACTIVE) ->sum('reserved_qty') ?? 0); } /** * Свободный остаток БЕЗ документов */ public function getFreeStockWithoutDocsAttribute(): int { return $this->physical_stock_without_docs - $this->reserved_without_docs; } /** * Свободный остаток С документами */ public function getFreeStockWithDocsAttribute(): int { return $this->physical_stock_with_docs - $this->reserved_with_docs; } /** * Общий физический остаток */ public function getTotalPhysicalStockAttribute(): int { return $this->physical_stock_without_docs + $this->physical_stock_with_docs; } /** * Общее зарезервировано */ public function getTotalReservedAttribute(): int { return $this->reserved_without_docs + $this->reserved_with_docs; } /** * Общий свободный остаток */ public function getTotalFreeStockAttribute(): int { return $this->total_physical_stock - $this->total_reserved; } // ========== ОБРАТНАЯ СОВМЕСТИМОСТЬ ========== // Старые атрибуты для совместимости с существующим кодом public function getQuantityWithoutDocsAttribute(): int { return $this->free_stock_without_docs; } public function getQuantityWithDocsAttribute(): int { return $this->free_stock_with_docs; } public function getTotalQuantityAttribute(): int { return $this->total_free_stock; } // ========== МЕТОДЫ ПРОВЕРКИ ========== /** * Есть ли открытые дефициты? */ public function hasOpenShortages(): bool { return $this->shortages()->open()->exists(); } /** * Количество в открытых дефицитах */ public function getOpenShortagesQtyAttribute(): int { return (int) ($this->shortages()->open()->sum('missing_qty') ?? 0); } /** * Ниже минимального остатка? */ public function isBelowMinStock(): bool { return $this->total_free_stock < $this->min_stock; } /** * @deprecated Используйте hasOpenShortages() */ public function hasCriticalShortage(): bool { return $this->hasOpenShortages(); } // ========== РАСШИФРОВКИ ========== protected function tsnNumberDescription(): Attribute { return Attribute::make( get: fn() => PricingCode::getTsnDescription($this->tsn_number) ); } protected function pricingCodeDescription(): Attribute { return Attribute::make( get: function () { $codes = $this->pricingCodes; if ($codes->isEmpty()) { return null; } return $codes->map(fn($code) => $code->code . ($code->description ? ': ' . $code->description : ''))->implode("\n"); } ); } public function getPricingCodesListAttribute(): string { return $this->pricingCodes->pluck('code')->implode(', '); } // ========== ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ========== /** * Получить свободный остаток для конкретного типа документов */ public function getFreeStock(bool $withDocuments): int { return $withDocuments ? $this->free_stock_with_docs : $this->free_stock_without_docs; } /** * Получить физический остаток для конкретного типа документов */ public function getPhysicalStock(bool $withDocuments): int { return $withDocuments ? $this->physical_stock_with_docs : $this->physical_stock_without_docs; } /** * Получить зарезервировано для конкретного типа документов */ public function getReserved(bool $withDocuments): int { return $withDocuments ? $this->reserved_with_docs : $this->reserved_without_docs; } }