| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386 |
- <?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;
- /**
- * Сервис резервирования запчастей.
- *
- * Реализует алгоритм резервирования с учётом:
- * - FIFO по партиям (первыми резервируются старые поступления)
- * - Разделения по типу документов (with_documents)
- * - Создания дефицитов при нехватке
- */
- class SparePartReservationService
- {
- /**
- * Результат резервирования
- */
- public function __construct(
- private readonly ShortageService $shortageService
- ) {}
- /**
- * Зарезервировать запчасть для рекламации
- *
- * @param int $sparePartId ID запчасти
- * @param int $quantity Требуемое количество
- * @param bool $withDocuments С документами или без
- * @param int $reclamationId ID рекламации
- * @return ReservationResult
- */
- public function reserve(
- int $sparePartId,
- int $quantity,
- bool $withDocuments,
- int $reclamationId
- ): ReservationResult {
- return DB::transaction(function () use ($sparePartId, $quantity, $withDocuments, $reclamationId) {
- $sparePart = SparePart::findOrFail($sparePartId);
- // 1. Расчёт свободного остатка
- $physicalStock = SparePartOrder::query()
- ->where('spare_part_id', $sparePartId)
- ->where('with_documents', $withDocuments)
- ->where('status', SparePartOrder::STATUS_IN_STOCK)
- ->sum('available_qty');
- $activeReserves = Reservation::query()
- ->where('spare_part_id', $sparePartId)
- ->where('with_documents', $withDocuments)
- ->where('status', Reservation::STATUS_ACTIVE)
- ->sum('reserved_qty');
- $freeStock = $physicalStock - $activeReserves;
- // 2. Определение сколько можем зарезервировать
- $canReserve = min($quantity, max(0, $freeStock));
- $missing = $quantity - $canReserve;
- $reservations = collect();
- $shortage = null;
- // 3. Резервирование доступного количества (FIFO по партиям)
- if ($canReserve > 0) {
- $remainingToReserve = $canReserve;
- $orders = SparePartOrder::query()
- ->where('spare_part_id', $sparePartId)
- ->where('with_documents', $withDocuments)
- ->where('status', SparePartOrder::STATUS_IN_STOCK)
- ->where('available_qty', '>', 0)
- ->orderBy('created_at', 'asc')
- ->lockForUpdate()
- ->get();
- foreach ($orders as $order) {
- if ($remainingToReserve <= 0) break;
- // Сколько уже зарезервировано из этой партии
- $alreadyReserved = Reservation::query()
- ->where('spare_part_order_id', $order->id)
- ->where('status', Reservation::STATUS_ACTIVE)
- ->sum('reserved_qty');
- $freeInOrder = $order->available_qty - $alreadyReserved;
- $toReserve = min($remainingToReserve, $freeInOrder);
- if ($toReserve > 0) {
- // Создаём движение типа reserve
- $movement = InventoryMovement::create([
- 'spare_part_order_id' => $order->id,
- 'spare_part_id' => $sparePartId,
- 'qty' => $toReserve,
- 'movement_type' => InventoryMovement::TYPE_RESERVE,
- 'source_type' => InventoryMovement::SOURCE_RECLAMATION,
- 'source_id' => $reclamationId,
- 'with_documents' => $withDocuments,
- 'user_id' => auth()->id(),
- 'note' => "Резервирование для рекламации #{$reclamationId}",
- ]);
- // Создаём резерв
- $reservation = Reservation::create([
- 'spare_part_id' => $sparePartId,
- 'spare_part_order_id' => $order->id,
- 'reclamation_id' => $reclamationId,
- 'reserved_qty' => $toReserve,
- 'with_documents' => $withDocuments,
- 'status' => Reservation::STATUS_ACTIVE,
- 'movement_id' => $movement->id,
- ]);
- $reservations->push($reservation);
- $remainingToReserve -= $toReserve;
- }
- }
- }
- // 4. Создание дефицита при нехватке
- if ($missing > 0) {
- $shortage = Shortage::create([
- 'spare_part_id' => $sparePartId,
- 'reclamation_id' => $reclamationId,
- 'with_documents' => $withDocuments,
- 'required_qty' => $quantity,
- 'reserved_qty' => $canReserve,
- 'missing_qty' => $missing,
- 'status' => Shortage::STATUS_OPEN,
- 'note' => "Дефицит при резервировании для рекламации #{$reclamationId}",
- ]);
- }
- return new ReservationResult(
- reserved: $canReserve,
- missing: $missing,
- reservations: $reservations,
- shortage: $shortage
- );
- });
- }
- /**
- * Отменить резервы для рекламации
- *
- * @param int $reclamationId ID рекламации
- * @param int|null $sparePartId ID запчасти (опционально, если нужно отменить для конкретной)
- * @param bool|null $withDocuments Тип документов (опционально)
- * @return int Количество отменённых единиц
- */
- public function cancelForReclamation(
- int $reclamationId,
- ?int $sparePartId = null,
- ?bool $withDocuments = null
- ): int {
- return DB::transaction(function () use ($reclamationId, $sparePartId, $withDocuments) {
- $query = Reservation::query()
- ->where('reclamation_id', $reclamationId)
- ->where('status', Reservation::STATUS_ACTIVE);
- if ($sparePartId !== null) {
- $query->where('spare_part_id', $sparePartId);
- }
- if ($withDocuments !== null) {
- $query->where('with_documents', $withDocuments);
- }
- $reservations = $query->lockForUpdate()->get();
- $totalCancelled = 0;
- foreach ($reservations as $reservation) {
- $this->cancelReservation($reservation, "Отмена резерва для рекламации #{$reclamationId}");
- $totalCancelled += $reservation->reserved_qty;
- }
- // Также закрываем связанные дефициты
- $shortageQuery = Shortage::query()
- ->where('reclamation_id', $reclamationId)
- ->where('status', Shortage::STATUS_OPEN);
- if ($sparePartId !== null) {
- $shortageQuery->where('spare_part_id', $sparePartId);
- }
- if ($withDocuments !== null) {
- $shortageQuery->where('with_documents', $withDocuments);
- }
- $shortageQuery->update(['status' => Shortage::STATUS_CLOSED]);
- return $totalCancelled;
- });
- }
- /**
- * Отменить конкретный резерв
- */
- public function cancelReservation(Reservation $reservation, string $reason = ''): bool
- {
- if (!$reservation->isActive()) {
- return false;
- }
- return DB::transaction(function () use ($reservation, $reason) {
- $reservation->status = Reservation::STATUS_CANCELLED;
- $reservation->save();
- // Создаём движение отмены
- InventoryMovement::create([
- 'spare_part_order_id' => $reservation->spare_part_order_id,
- 'spare_part_id' => $reservation->spare_part_id,
- 'qty' => $reservation->reserved_qty,
- 'movement_type' => InventoryMovement::TYPE_RESERVE_CANCEL,
- 'source_type' => InventoryMovement::SOURCE_RECLAMATION,
- 'source_id' => $reservation->reclamation_id,
- 'with_documents' => $reservation->with_documents,
- 'user_id' => auth()->id(),
- 'note' => $reason,
- ]);
- return true;
- });
- }
- /**
- * Изменить количество резерва для рекламации
- *
- * @param int $reclamationId ID рекламации
- * @param int $sparePartId ID запчасти
- * @param bool $withDocuments Тип документов
- * @param int $newQuantity Новое требуемое количество
- * @return ReservationResult
- */
- public function adjustReservation(
- int $reclamationId,
- int $sparePartId,
- bool $withDocuments,
- int $newQuantity
- ): ReservationResult {
- return DB::transaction(function () use ($reclamationId, $sparePartId, $withDocuments, $newQuantity) {
- // Получаем текущие резервы
- $currentReserved = Reservation::query()
- ->where('reclamation_id', $reclamationId)
- ->where('spare_part_id', $sparePartId)
- ->where('with_documents', $withDocuments)
- ->where('status', Reservation::STATUS_ACTIVE)
- ->sum('reserved_qty');
- $diff = $newQuantity - $currentReserved;
- if ($diff > 0) {
- // Нужно дорезервировать
- return $this->reserve($sparePartId, $diff, $withDocuments, $reclamationId);
- } elseif ($diff < 0) {
- // Нужно освободить часть резерва
- $toRelease = abs($diff);
- $released = 0;
- // Освобождаем в обратном порядке FIFO (сначала новые)
- $reservations = Reservation::query()
- ->where('reclamation_id', $reclamationId)
- ->where('spare_part_id', $sparePartId)
- ->where('with_documents', $withDocuments)
- ->where('status', Reservation::STATUS_ACTIVE)
- ->orderBy('created_at', 'desc')
- ->lockForUpdate()
- ->get();
- foreach ($reservations as $reservation) {
- if ($released >= $toRelease) break;
- $releaseFromThis = min($toRelease - $released, $reservation->reserved_qty);
- if ($releaseFromThis === $reservation->reserved_qty) {
- // Отменяем весь резерв
- $this->cancelReservation($reservation, "Уменьшение резерва");
- } else {
- // Частичная отмена — создаём новый резерв на остаток
- $reservation->reserved_qty -= $releaseFromThis;
- $reservation->save();
- // Движение частичной отмены
- InventoryMovement::create([
- 'spare_part_order_id' => $reservation->spare_part_order_id,
- 'spare_part_id' => $sparePartId,
- 'qty' => $releaseFromThis,
- 'movement_type' => InventoryMovement::TYPE_RESERVE_CANCEL,
- 'source_type' => InventoryMovement::SOURCE_RECLAMATION,
- 'source_id' => $reclamationId,
- 'with_documents' => $withDocuments,
- 'user_id' => auth()->id(),
- 'note' => "Частичная отмена резерва",
- ]);
- }
- $released += $releaseFromThis;
- }
- // Обновляем дефицит если есть
- $shortage = Shortage::query()
- ->where('reclamation_id', $reclamationId)
- ->where('spare_part_id', $sparePartId)
- ->where('with_documents', $withDocuments)
- ->where('status', Shortage::STATUS_OPEN)
- ->first();
- if ($shortage) {
- $shortage->required_qty = $newQuantity;
- $shortage->recalculate();
- }
- // Пересчитываем фактически зарезервированное количество после изменений
- $actualReserved = Reservation::query()
- ->where('reclamation_id', $reclamationId)
- ->where('spare_part_id', $sparePartId)
- ->where('with_documents', $withDocuments)
- ->where('status', Reservation::STATUS_ACTIVE)
- ->sum('reserved_qty');
- return new ReservationResult(
- reserved: (int) $actualReserved,
- missing: max(0, $newQuantity - $actualReserved),
- reservations: collect(),
- shortage: $shortage
- );
- }
- // diff = 0, ничего не делаем
- return new ReservationResult(
- reserved: $currentReserved,
- missing: 0,
- reservations: collect(),
- shortage: null
- );
- });
- }
- /**
- * Получить все активные резервы для рекламации
- */
- public function getReservationsForReclamation(int $reclamationId): Collection
- {
- return Reservation::query()
- ->where('reclamation_id', $reclamationId)
- ->where('status', Reservation::STATUS_ACTIVE)
- ->with(['sparePart', 'sparePartOrder'])
- ->get();
- }
- }
- /**
- * Результат операции резервирования
- */
- class ReservationResult
- {
- public function __construct(
- public readonly int $reserved,
- public readonly int $missing,
- public readonly Collection $reservations,
- public readonly ?Shortage $shortage
- ) {}
- public function isFullyReserved(): bool
- {
- return $this->missing === 0;
- }
- public function hasShortage(): bool
- {
- return $this->shortage !== null;
- }
- public function getTotalRequested(): int
- {
- return $this->reserved + $this->missing;
- }
- }
|