SparePart.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  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. /**
  153. * Физический остаток БЕЗ документов
  154. */
  155. public function getPhysicalStockWithoutDocsAttribute(): int
  156. {
  157. return (int) ($this->orders()
  158. ->where('status', SparePartOrder::STATUS_IN_STOCK)
  159. ->where('with_documents', false)
  160. ->sum('available_qty') ?? 0);
  161. }
  162. /**
  163. * Физический остаток С документами
  164. */
  165. public function getPhysicalStockWithDocsAttribute(): int
  166. {
  167. return (int) ($this->orders()
  168. ->where('status', SparePartOrder::STATUS_IN_STOCK)
  169. ->where('with_documents', true)
  170. ->sum('available_qty') ?? 0);
  171. }
  172. /**
  173. * Зарезервировано БЕЗ документов
  174. */
  175. public function getReservedWithoutDocsAttribute(): int
  176. {
  177. return (int) (Reservation::query()
  178. ->where('spare_part_id', $this->id)
  179. ->where('with_documents', false)
  180. ->where('status', Reservation::STATUS_ACTIVE)
  181. ->sum('reserved_qty') ?? 0);
  182. }
  183. /**
  184. * Зарезервировано С документами
  185. */
  186. public function getReservedWithDocsAttribute(): int
  187. {
  188. return (int) (Reservation::query()
  189. ->where('spare_part_id', $this->id)
  190. ->where('with_documents', true)
  191. ->where('status', Reservation::STATUS_ACTIVE)
  192. ->sum('reserved_qty') ?? 0);
  193. }
  194. /**
  195. * Свободный остаток БЕЗ документов
  196. */
  197. public function getFreeStockWithoutDocsAttribute(): int
  198. {
  199. return $this->physical_stock_without_docs - $this->reserved_without_docs;
  200. }
  201. /**
  202. * Свободный остаток С документами
  203. */
  204. public function getFreeStockWithDocsAttribute(): int
  205. {
  206. return $this->physical_stock_with_docs - $this->reserved_with_docs;
  207. }
  208. /**
  209. * Общий физический остаток
  210. */
  211. public function getTotalPhysicalStockAttribute(): int
  212. {
  213. return $this->physical_stock_without_docs + $this->physical_stock_with_docs;
  214. }
  215. /**
  216. * Общее зарезервировано
  217. */
  218. public function getTotalReservedAttribute(): int
  219. {
  220. return $this->reserved_without_docs + $this->reserved_with_docs;
  221. }
  222. /**
  223. * Общий свободный остаток
  224. */
  225. public function getTotalFreeStockAttribute(): int
  226. {
  227. return $this->total_physical_stock - $this->total_reserved;
  228. }
  229. // ========== ОБРАТНАЯ СОВМЕСТИМОСТЬ ==========
  230. // Старые атрибуты для совместимости с существующим кодом
  231. public function getQuantityWithoutDocsAttribute(): int
  232. {
  233. return $this->free_stock_without_docs;
  234. }
  235. public function getQuantityWithDocsAttribute(): int
  236. {
  237. return $this->free_stock_with_docs;
  238. }
  239. public function getTotalQuantityAttribute(): int
  240. {
  241. return $this->total_free_stock;
  242. }
  243. // ========== МЕТОДЫ ПРОВЕРКИ ==========
  244. /**
  245. * Есть ли открытые дефициты?
  246. */
  247. public function hasOpenShortages(): bool
  248. {
  249. return $this->shortages()->open()->exists();
  250. }
  251. /**
  252. * Количество в открытых дефицитах
  253. */
  254. public function getOpenShortagesQtyAttribute(): int
  255. {
  256. return (int) ($this->shortages()->open()->sum('missing_qty') ?? 0);
  257. }
  258. /**
  259. * Ниже минимального остатка?
  260. */
  261. public function isBelowMinStock(): bool
  262. {
  263. return $this->total_free_stock < $this->min_stock;
  264. }
  265. /**
  266. * @deprecated Используйте hasOpenShortages()
  267. */
  268. public function hasCriticalShortage(): bool
  269. {
  270. return $this->hasOpenShortages();
  271. }
  272. // ========== РАСШИФРОВКИ ==========
  273. protected function tsnNumberDescription(): Attribute
  274. {
  275. return Attribute::make(
  276. get: fn() => PricingCode::getTsnDescription($this->tsn_number)
  277. );
  278. }
  279. protected function pricingCodeDescription(): Attribute
  280. {
  281. return Attribute::make(
  282. get: function () {
  283. $codes = $this->pricingCodes;
  284. if ($codes->isEmpty()) {
  285. return null;
  286. }
  287. return $codes->map(fn($code) => $code->code . ($code->description ? ': ' . $code->description : ''))->implode("\n");
  288. }
  289. );
  290. }
  291. public function getPricingCodesListAttribute(): string
  292. {
  293. return $this->pricingCodes->pluck('code')->implode(', ');
  294. }
  295. // ========== ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ==========
  296. /**
  297. * Получить свободный остаток для конкретного типа документов
  298. */
  299. public function getFreeStock(bool $withDocuments): int
  300. {
  301. return $withDocuments
  302. ? $this->free_stock_with_docs
  303. : $this->free_stock_without_docs;
  304. }
  305. /**
  306. * Получить физический остаток для конкретного типа документов
  307. */
  308. public function getPhysicalStock(bool $withDocuments): int
  309. {
  310. return $withDocuments
  311. ? $this->physical_stock_with_docs
  312. : $this->physical_stock_without_docs;
  313. }
  314. /**
  315. * Получить зарезервировано для конкретного типа документов
  316. */
  317. public function getReserved(bool $withDocuments): int
  318. {
  319. return $withDocuments
  320. ? $this->reserved_with_docs
  321. : $this->reserved_without_docs;
  322. }
  323. }