| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220 |
- <?php
- namespace App\Services;
- use App\Models\InventoryMovement;
- use App\Models\Reservation;
- use App\Models\Shortage;
- use App\Models\SparePart;
- use App\Models\SparePartOrder;
- use Illuminate\Support\Collection;
- use Illuminate\Support\Facades\DB;
- /**
- * Сервис управления дефицитами запчастей.
- *
- * Дефицит создаётся когда при резервировании недостаточно свободного остатка.
- * При поступлении новой партии система автоматически резервирует под открытые дефициты.
- */
- class ShortageService
- {
- /**
- * Обработать поступление партии — закрыть дефициты
- *
- * Вызывается при изменении статуса партии на in_stock.
- * Автоматически резервирует под открытые дефициты (FIFO по дате).
- *
- * @param SparePartOrder $order Партия
- * @return array Информация о закрытых дефицитах
- */
- public function processPartOrderReceipt(SparePartOrder $order): array
- {
- if ($order->status !== SparePartOrder::STATUS_IN_STOCK) {
- return [];
- }
- return DB::transaction(function () use ($order) {
- $results = [];
- // Поиск открытых дефицитов для этой запчасти
- $shortages = Shortage::query()
- ->where('spare_part_id', $order->spare_part_id)
- ->where('with_documents', $order->with_documents)
- ->where('status', Shortage::STATUS_OPEN)
- ->where('missing_qty', '>', 0)
- ->orderBy('created_at', 'asc') // FIFO по дате дефицита
- ->lockForUpdate()
- ->get();
- if ($shortages->isEmpty()) {
- return [];
- }
- // Сколько свободно в партии (с учётом уже созданных резервов)
- $alreadyReserved = Reservation::query()
- ->where('spare_part_order_id', $order->id)
- ->where('status', Reservation::STATUS_ACTIVE)
- ->sum('reserved_qty');
- $availableQty = $order->available_qty - $alreadyReserved;
- foreach ($shortages as $shortage) {
- if ($availableQty <= 0) break;
- // Сколько можем покрыть
- $toCover = min($availableQty, $shortage->missing_qty);
- // Создаём движение резерва
- $movement = InventoryMovement::create([
- 'spare_part_order_id' => $order->id,
- 'spare_part_id' => $order->spare_part_id,
- 'qty' => $toCover,
- 'movement_type' => InventoryMovement::TYPE_RESERVE,
- 'source_type' => InventoryMovement::SOURCE_SHORTAGE_FULFILLMENT,
- 'source_id' => $shortage->id,
- 'with_documents' => $order->with_documents,
- 'user_id' => auth()->id(),
- 'note' => "Автоматическое резервирование при поступлении для закрытия дефицита #{$shortage->id}",
- ]);
- // Создаём резерв под дефицит
- $reservation = Reservation::create([
- 'spare_part_id' => $order->spare_part_id,
- 'spare_part_order_id' => $order->id,
- 'reclamation_id' => $shortage->reclamation_id,
- 'reserved_qty' => $toCover,
- 'with_documents' => $order->with_documents,
- 'status' => Reservation::STATUS_ACTIVE,
- 'movement_id' => $movement->id,
- ]);
- // Обновляем дефицит
- $shortage->addReserved($toCover);
- $results[] = [
- 'shortage_id' => $shortage->id,
- 'reclamation_id' => $shortage->reclamation_id,
- 'covered_qty' => $toCover,
- 'remaining_missing' => $shortage->missing_qty,
- 'shortage_closed' => $shortage->isClosed(),
- 'reservation_id' => $reservation->id,
- ];
- $availableQty -= $toCover;
- }
- return $results;
- });
- }
- /**
- * Получить все открытые дефициты
- */
- public function getOpenShortages(): Collection
- {
- return Shortage::query()
- ->where('status', Shortage::STATUS_OPEN)
- ->with(['sparePart', 'reclamation'])
- ->orderBy('created_at', 'asc')
- ->get();
- }
- /**
- * Получить открытые дефициты для конкретной запчасти
- */
- public function getShortagesForSparePart(int $sparePartId, ?bool $withDocuments = null): Collection
- {
- $query = Shortage::query()
- ->where('spare_part_id', $sparePartId)
- ->where('status', Shortage::STATUS_OPEN);
- if ($withDocuments !== null) {
- $query->where('with_documents', $withDocuments);
- }
- return $query->orderBy('created_at', 'asc')->get();
- }
- /**
- * Получить сумму недостачи по запчасти
- */
- public function getTotalMissing(int $sparePartId, ?bool $withDocuments = null): int
- {
- $query = Shortage::query()
- ->where('spare_part_id', $sparePartId)
- ->where('status', Shortage::STATUS_OPEN);
- if ($withDocuments !== null) {
- $query->where('with_documents', $withDocuments);
- }
- return (int) $query->sum('missing_qty');
- }
- /**
- * Получить запчасти с критическими дефицитами (для контроля наличия)
- */
- public function getCriticalShortages(): Collection
- {
- return SparePart::query()
- ->whereHas('shortages', function ($query) {
- $query->where('status', Shortage::STATUS_OPEN)
- ->where('missing_qty', '>', 0);
- })
- ->with(['shortages' => function ($query) {
- $query->where('status', Shortage::STATUS_OPEN);
- }])
- ->get()
- ->map(function ($sparePart) {
- $sparePart->shortage_details = $sparePart->shortages->map(function ($shortage) {
- return [
- 'reclamation_id' => $shortage->reclamation_id,
- 'with_documents' => $shortage->with_documents,
- 'missing_qty' => $shortage->missing_qty,
- 'created_at' => $shortage->created_at,
- ];
- });
- return $sparePart;
- });
- }
- /**
- * Закрыть дефицит вручную (например, при отмене рекламации)
- */
- public function closeShortage(Shortage $shortage, string $reason = ''): bool
- {
- if ($shortage->isClosed()) {
- return false;
- }
- $shortage->status = Shortage::STATUS_CLOSED;
- $shortage->note = ($shortage->note ? $shortage->note . "\n" : '') .
- "Закрыто вручную: " . ($reason ?: 'без указания причины');
- $shortage->save();
- return true;
- }
- /**
- * Пересчитать все дефициты для рекламации
- */
- public function recalculateForReclamation(int $reclamationId): void
- {
- $shortages = Shortage::query()
- ->where('reclamation_id', $reclamationId)
- ->where('status', Shortage::STATUS_OPEN)
- ->get();
- foreach ($shortages as $shortage) {
- $shortage->recalculate();
- }
- }
- /**
- * Рассчитать сколько нужно заказать для покрытия всех дефицитов
- */
- public function calculateOrderQuantity(int $sparePartId, bool $withDocuments): int
- {
- return $this->getTotalMissing($sparePartId, $withDocuments);
- }
- }
|