SparePart.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. <?php
  2. namespace App\Models;
  3. use App\Helpers\Price;
  4. use Illuminate\Database\Eloquent\Casts\Attribute;
  5. use Illuminate\Database\Eloquent\Factories\HasFactory;
  6. use Illuminate\Database\Eloquent\Model;
  7. use Illuminate\Database\Eloquent\Relations\BelongsTo;
  8. use Illuminate\Database\Eloquent\Relations\BelongsToMany;
  9. use Illuminate\Database\Eloquent\Relations\HasMany;
  10. use Illuminate\Database\Eloquent\SoftDeletes;
  11. /**
  12. * Каталог запчастей — СПРАВОЧНИК.
  13. *
  14. * ВАЖНО: Эта модель НЕ хранит остатки!
  15. * Остатки рассчитываются на основе:
  16. * - SparePartOrder.available_qty (физический остаток)
  17. * - Reservation (активные резервы)
  18. *
  19. * Свободный остаток = Физический - Зарезервировано
  20. */
  21. class SparePart extends Model
  22. {
  23. use HasFactory, SoftDeletes;
  24. const DEFAULT_SORT_BY = 'article';
  25. protected $fillable = [
  26. // БЕЗ year! Каталог общий для всех лет
  27. 'article',
  28. 'used_in_maf',
  29. 'product_id',
  30. 'note',
  31. 'purchase_price',
  32. 'customer_price',
  33. 'expertise_price',
  34. 'tsn_number',
  35. 'min_stock',
  36. ];
  37. /**
  38. * Атрибуты для автоматической сериализации.
  39. *
  40. * ВАЖНО: Подробные остатки (physical_stock_*, reserved_*, free_stock_*) НЕ включены сюда,
  41. * т.к. согласно спецификации каталог не хранит остатки.
  42. * Для получения детальных остатков используйте SparePartInventoryService::getStockInfo()
  43. * или явно вызывайте соответствующие аксессоры.
  44. *
  45. * Атрибуты quantity_* оставлены для обратной совместимости с UI.
  46. */
  47. protected $appends = [
  48. 'purchase_price',
  49. 'customer_price',
  50. 'expertise_price',
  51. 'purchase_price_txt',
  52. 'customer_price_txt',
  53. 'expertise_price_txt',
  54. 'image',
  55. 'tsn_number_description',
  56. 'pricing_code_description',
  57. 'pricing_codes_list',
  58. // Обратная совместимость для UI (отображаются как свободный остаток)
  59. 'quantity_without_docs',
  60. 'quantity_with_docs',
  61. 'total_quantity',
  62. ];
  63. // Аксессоры для цен (копейки -> рубли)
  64. protected function purchasePrice(): Attribute
  65. {
  66. return Attribute::make(
  67. get: fn($value) => $value ? $value / 100 : null,
  68. set: fn($value) => $value ? round($value * 100) : null,
  69. );
  70. }
  71. protected function customerPrice(): Attribute
  72. {
  73. return Attribute::make(
  74. get: fn($value) => $value ? $value / 100 : null,
  75. set: fn($value) => $value ? round($value * 100) : null,
  76. );
  77. }
  78. protected function expertisePrice(): Attribute
  79. {
  80. return Attribute::make(
  81. get: fn($value) => $value ? $value / 100 : null,
  82. set: fn($value) => $value ? round($value * 100) : null,
  83. );
  84. }
  85. // Текстовое представление цен
  86. protected function purchasePriceTxt(): Attribute
  87. {
  88. return Attribute::make(
  89. get: fn() => isset($this->attributes['purchase_price']) && $this->attributes['purchase_price'] !== null
  90. ? Price::format($this->attributes['purchase_price'] / 100)
  91. : '-',
  92. );
  93. }
  94. protected function customerPriceTxt(): Attribute
  95. {
  96. return Attribute::make(
  97. get: fn() => isset($this->attributes['customer_price']) && $this->attributes['customer_price'] !== null
  98. ? Price::format($this->attributes['customer_price'] / 100)
  99. : '-',
  100. );
  101. }
  102. protected function expertisePriceTxt(): Attribute
  103. {
  104. return Attribute::make(
  105. get: fn() => isset($this->attributes['expertise_price']) && $this->attributes['expertise_price'] !== null
  106. ? Price::format($this->attributes['expertise_price'] / 100)
  107. : '-',
  108. );
  109. }
  110. // Атрибут для картинки
  111. public function image(): Attribute
  112. {
  113. $path = '';
  114. if (file_exists(public_path() . '/images/spare_parts/' . $this->article . '.jpg')) {
  115. $path = url('/images/spare_parts/' . $this->article . '.jpg');
  116. }
  117. return Attribute::make(get: fn() => $path);
  118. }
  119. // ========== ОТНОШЕНИЯ ==========
  120. public function product(): BelongsTo
  121. {
  122. return $this->belongsTo(Product::class);
  123. }
  124. public function orders(): HasMany
  125. {
  126. return $this->hasMany(SparePartOrder::class);
  127. }
  128. public function reclamations(): BelongsToMany
  129. {
  130. return $this->belongsToMany(Reclamation::class, 'reclamation_spare_part')
  131. ->withPivot('quantity', 'with_documents', 'status', 'reserved_qty', 'issued_qty')
  132. ->withTimestamps();
  133. }
  134. public function pricingCodes(): BelongsToMany
  135. {
  136. return $this->belongsToMany(PricingCode::class, 'spare_part_pricing_code')
  137. ->withTimestamps();
  138. }
  139. public function reservations(): HasMany
  140. {
  141. return $this->hasMany(Reservation::class);
  142. }
  143. public function shortages(): HasMany
  144. {
  145. return $this->hasMany(Shortage::class);
  146. }
  147. public function movements(): HasMany
  148. {
  149. return $this->hasMany(InventoryMovement::class);
  150. }
  151. // ========== ВЫЧИСЛЯЕМЫЕ ПОЛЯ ОСТАТКОВ ==========
  152. // Примечание: YearScope автоматически применяется к SparePartOrder
  153. /**
  154. * Физический остаток БЕЗ документов
  155. */
  156. public function getPhysicalStockWithoutDocsAttribute(): int
  157. {
  158. return (int) ($this->orders()
  159. ->where('status', SparePartOrder::STATUS_IN_STOCK)
  160. ->where('with_documents', false)
  161. ->sum('available_qty') ?? 0);
  162. }
  163. /**
  164. * Физический остаток С документами
  165. */
  166. public function getPhysicalStockWithDocsAttribute(): int
  167. {
  168. return (int) ($this->orders()
  169. ->where('status', SparePartOrder::STATUS_IN_STOCK)
  170. ->where('with_documents', true)
  171. ->sum('available_qty') ?? 0);
  172. }
  173. /**
  174. * Зарезервировано БЕЗ документов
  175. */
  176. public function getReservedWithoutDocsAttribute(): int
  177. {
  178. return (int) (Reservation::query()
  179. ->where('spare_part_id', $this->id)
  180. ->where('with_documents', false)
  181. ->where('status', Reservation::STATUS_ACTIVE)
  182. ->sum('reserved_qty') ?? 0);
  183. }
  184. /**
  185. * Зарезервировано С документами
  186. */
  187. public function getReservedWithDocsAttribute(): int
  188. {
  189. return (int) (Reservation::query()
  190. ->where('spare_part_id', $this->id)
  191. ->where('with_documents', true)
  192. ->where('status', Reservation::STATUS_ACTIVE)
  193. ->sum('reserved_qty') ?? 0);
  194. }
  195. /**
  196. * Свободный остаток БЕЗ документов
  197. */
  198. public function getFreeStockWithoutDocsAttribute(): int
  199. {
  200. return $this->physical_stock_without_docs - $this->reserved_without_docs;
  201. }
  202. /**
  203. * Свободный остаток С документами
  204. */
  205. public function getFreeStockWithDocsAttribute(): int
  206. {
  207. return $this->physical_stock_with_docs - $this->reserved_with_docs;
  208. }
  209. /**
  210. * Общий физический остаток
  211. */
  212. public function getTotalPhysicalStockAttribute(): int
  213. {
  214. return $this->physical_stock_without_docs + $this->physical_stock_with_docs;
  215. }
  216. /**
  217. * Общее зарезервировано
  218. */
  219. public function getTotalReservedAttribute(): int
  220. {
  221. return $this->reserved_without_docs + $this->reserved_with_docs;
  222. }
  223. /**
  224. * Общий свободный остаток
  225. */
  226. public function getTotalFreeStockAttribute(): int
  227. {
  228. return $this->total_physical_stock - $this->total_reserved;
  229. }
  230. // ========== ОБРАТНАЯ СОВМЕСТИМОСТЬ ==========
  231. // Старые атрибуты для совместимости с существующим кодом
  232. public function getQuantityWithoutDocsAttribute(): int
  233. {
  234. return $this->free_stock_without_docs;
  235. }
  236. public function getQuantityWithDocsAttribute(): int
  237. {
  238. return $this->free_stock_with_docs;
  239. }
  240. public function getTotalQuantityAttribute(): int
  241. {
  242. return $this->total_free_stock;
  243. }
  244. // ========== МЕТОДЫ ПРОВЕРКИ ==========
  245. /**
  246. * Есть ли открытые дефициты?
  247. */
  248. public function hasOpenShortages(): bool
  249. {
  250. return $this->shortages()->open()->exists();
  251. }
  252. /**
  253. * Количество в открытых дефицитах
  254. */
  255. public function getOpenShortagesQtyAttribute(): int
  256. {
  257. return (int) ($this->shortages()->open()->sum('missing_qty') ?? 0);
  258. }
  259. /**
  260. * Ниже минимального остатка?
  261. */
  262. public function isBelowMinStock(): bool
  263. {
  264. return $this->total_free_stock < $this->min_stock;
  265. }
  266. /**
  267. * @deprecated Используйте hasOpenShortages()
  268. */
  269. public function hasCriticalShortage(): bool
  270. {
  271. return $this->hasOpenShortages();
  272. }
  273. // ========== РАСШИФРОВКИ ==========
  274. protected function tsnNumberDescription(): Attribute
  275. {
  276. return Attribute::make(
  277. get: fn() => PricingCode::getTsnDescription($this->tsn_number)
  278. );
  279. }
  280. protected function pricingCodeDescription(): Attribute
  281. {
  282. return Attribute::make(
  283. get: function () {
  284. $codes = $this->pricingCodes;
  285. if ($codes->isEmpty()) {
  286. return null;
  287. }
  288. return $codes->map(fn($code) => $code->code . ($code->description ? ': ' . $code->description : ''))->implode("\n");
  289. }
  290. );
  291. }
  292. public function getPricingCodesListAttribute(): string
  293. {
  294. return $this->pricingCodes->pluck('code')->implode(', ');
  295. }
  296. // ========== ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ==========
  297. /**
  298. * Получить свободный остаток для конкретного типа документов
  299. */
  300. public function getFreeStock(bool $withDocuments): int
  301. {
  302. return $withDocuments
  303. ? $this->free_stock_with_docs
  304. : $this->free_stock_without_docs;
  305. }
  306. /**
  307. * Получить физический остаток для конкретного типа документов
  308. */
  309. public function getPhysicalStock(bool $withDocuments): int
  310. {
  311. return $withDocuments
  312. ? $this->physical_stock_with_docs
  313. : $this->physical_stock_without_docs;
  314. }
  315. /**
  316. * Получить зарезервировано для конкретного типа документов
  317. */
  318. public function getReserved(bool $withDocuments): int
  319. {
  320. return $withDocuments
  321. ? $this->reserved_with_docs
  322. : $this->reserved_without_docs;
  323. }
  324. }