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\Model;
  6. use Illuminate\Database\Eloquent\Relations\BelongsTo;
  7. use Illuminate\Database\Eloquent\Relations\BelongsToMany;
  8. use Illuminate\Database\Eloquent\Relations\HasMany;
  9. use Illuminate\Database\Eloquent\SoftDeletes;
  10. /**
  11. * Каталог запчастей — СПРАВОЧНИК.
  12. *
  13. * ВАЖНО: Эта модель НЕ хранит остатки!
  14. * Остатки рассчитываются на основе:
  15. * - SparePartOrder.available_qty (физический остаток)
  16. * - Reservation (активные резервы)
  17. *
  18. * Свободный остаток = Физический - Зарезервировано
  19. */
  20. class SparePart extends Model
  21. {
  22. use SoftDeletes;
  23. const DEFAULT_SORT_BY = 'article';
  24. protected $fillable = [
  25. // БЕЗ year! Каталог общий для всех лет
  26. 'article',
  27. 'used_in_maf',
  28. 'product_id',
  29. 'note',
  30. 'purchase_price',
  31. 'customer_price',
  32. 'expertise_price',
  33. 'tsn_number',
  34. 'min_stock',
  35. ];
  36. /**
  37. * Атрибуты для автоматической сериализации.
  38. *
  39. * ВАЖНО: Подробные остатки (physical_stock_*, reserved_*, free_stock_*) НЕ включены сюда,
  40. * т.к. согласно спецификации каталог не хранит остатки.
  41. * Для получения детальных остатков используйте SparePartInventoryService::getStockInfo()
  42. * или явно вызывайте соответствующие аксессоры.
  43. *
  44. * Атрибуты quantity_* оставлены для обратной совместимости с UI.
  45. */
  46. protected $appends = [
  47. 'purchase_price',
  48. 'customer_price',
  49. 'expertise_price',
  50. 'purchase_price_txt',
  51. 'customer_price_txt',
  52. 'expertise_price_txt',
  53. 'image',
  54. 'tsn_number_description',
  55. 'pricing_code_description',
  56. 'pricing_codes_list',
  57. // Обратная совместимость для UI (отображаются как свободный остаток)
  58. 'quantity_without_docs',
  59. 'quantity_with_docs',
  60. 'total_quantity',
  61. ];
  62. // Аксессоры для цен (копейки -> рубли)
  63. protected function purchasePrice(): Attribute
  64. {
  65. return Attribute::make(
  66. get: fn($value) => $value ? $value / 100 : null,
  67. set: fn($value) => $value ? round($value * 100) : null,
  68. );
  69. }
  70. protected function customerPrice(): Attribute
  71. {
  72. return Attribute::make(
  73. get: fn($value) => $value ? $value / 100 : null,
  74. set: fn($value) => $value ? round($value * 100) : null,
  75. );
  76. }
  77. protected function expertisePrice(): Attribute
  78. {
  79. return Attribute::make(
  80. get: fn($value) => $value ? $value / 100 : null,
  81. set: fn($value) => $value ? round($value * 100) : null,
  82. );
  83. }
  84. // Текстовое представление цен
  85. protected function purchasePriceTxt(): Attribute
  86. {
  87. return Attribute::make(
  88. get: fn() => isset($this->attributes['purchase_price']) && $this->attributes['purchase_price'] !== null
  89. ? Price::format($this->attributes['purchase_price'] / 100)
  90. : '-',
  91. );
  92. }
  93. protected function customerPriceTxt(): Attribute
  94. {
  95. return Attribute::make(
  96. get: fn() => isset($this->attributes['customer_price']) && $this->attributes['customer_price'] !== null
  97. ? Price::format($this->attributes['customer_price'] / 100)
  98. : '-',
  99. );
  100. }
  101. protected function expertisePriceTxt(): Attribute
  102. {
  103. return Attribute::make(
  104. get: fn() => isset($this->attributes['expertise_price']) && $this->attributes['expertise_price'] !== null
  105. ? Price::format($this->attributes['expertise_price'] / 100)
  106. : '-',
  107. );
  108. }
  109. // Атрибут для картинки
  110. public function image(): Attribute
  111. {
  112. $path = '';
  113. if (file_exists(public_path() . '/images/spare_parts/' . $this->article . '.jpg')) {
  114. $path = url('/images/spare_parts/' . $this->article . '.jpg');
  115. }
  116. return Attribute::make(get: fn() => $path);
  117. }
  118. // ========== ОТНОШЕНИЯ ==========
  119. public function product(): BelongsTo
  120. {
  121. return $this->belongsTo(Product::class);
  122. }
  123. public function orders(): HasMany
  124. {
  125. return $this->hasMany(SparePartOrder::class);
  126. }
  127. public function reclamations(): BelongsToMany
  128. {
  129. return $this->belongsToMany(Reclamation::class, 'reclamation_spare_part')
  130. ->withPivot('quantity', 'with_documents', 'status', 'reserved_qty', 'issued_qty')
  131. ->withTimestamps();
  132. }
  133. public function pricingCodes(): BelongsToMany
  134. {
  135. return $this->belongsToMany(PricingCode::class, 'spare_part_pricing_code')
  136. ->withTimestamps();
  137. }
  138. public function reservations(): HasMany
  139. {
  140. return $this->hasMany(Reservation::class);
  141. }
  142. public function shortages(): HasMany
  143. {
  144. return $this->hasMany(Shortage::class);
  145. }
  146. public function movements(): HasMany
  147. {
  148. return $this->hasMany(InventoryMovement::class);
  149. }
  150. // ========== ВЫЧИСЛЯЕМЫЕ ПОЛЯ ОСТАТКОВ ==========
  151. // Примечание: YearScope автоматически применяется к SparePartOrder
  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. }