| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232 |
- <?php
- namespace App\Services;
- use App\Models\InventoryMovement;
- use App\Models\Reservation;
- use App\Models\SparePartOrder;
- use Illuminate\Support\Facades\DB;
- /**
- * Сервис списания (отгрузки) запчастей.
- *
- * Списание возможно ТОЛЬКО при наличии активного резерва.
- * При списании:
- * - Уменьшается available_qty партии
- * - Закрывается резерв (статус = issued)
- * - Создаётся движение типа issue
- */
- class SparePartIssueService
- {
- /**
- * Списать запчасть по резерву
- *
- * @param Reservation $reservation Резерв для списания
- * @param string $note Примечание
- * @return IssueResult
- * @throws \Exception
- */
- public function issueReservation(Reservation $reservation, string $note = ''): IssueResult
- {
- if (!$reservation->isActive()) {
- throw new \InvalidArgumentException("Резерв не активен, списание невозможно");
- }
- return DB::transaction(function () use ($reservation, $note) {
- // Блокируем партию для update
- $order = SparePartOrder::lockForUpdate()->findOrFail($reservation->spare_part_order_id);
- // Проверка наличия (на случай ошибки в данных)
- if ($order->available_qty < $reservation->reserved_qty) {
- throw new \RuntimeException(
- "Недостаточно товара в партии. Доступно: {$order->available_qty}, " .
- "требуется: {$reservation->reserved_qty}"
- );
- }
- // 1. Уменьшение остатка партии
- $order->available_qty -= $reservation->reserved_qty;
- // 2. Смена статуса партии если полностью отгружена
- if ($order->available_qty === 0) {
- $order->status = SparePartOrder::STATUS_SHIPPED;
- }
- $order->save();
- // 3. Закрытие резерва
- $reservation->status = Reservation::STATUS_ISSUED;
- $reservation->save();
- // 4. Создание движения типа issue
- $movement = InventoryMovement::create([
- 'spare_part_order_id' => $order->id,
- 'spare_part_id' => $reservation->spare_part_id,
- 'qty' => $reservation->reserved_qty,
- 'movement_type' => InventoryMovement::TYPE_ISSUE,
- 'source_type' => InventoryMovement::SOURCE_RECLAMATION,
- 'source_id' => $reservation->reclamation_id,
- 'with_documents' => $reservation->with_documents,
- 'user_id' => auth()->id(),
- 'note' => $note ?: "Списание по резерву для рекламации #{$reservation->reclamation_id}",
- ]);
- return new IssueResult(
- issued: $reservation->reserved_qty,
- reservation: $reservation,
- movement: $movement,
- order: $order
- );
- });
- }
- /**
- * Списать все активные резервы для рекламации
- *
- * @param int $reclamationId ID рекламации
- * @param int|null $sparePartId ID запчасти (опционально)
- * @param bool|null $withDocuments Тип документов (опционально)
- * @return array<IssueResult>
- */
- public function issueForReclamation(
- int $reclamationId,
- ?int $sparePartId = null,
- ?bool $withDocuments = null
- ): array {
- $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->get();
- $results = [];
- foreach ($reservations as $reservation) {
- $results[] = $this->issueReservation($reservation);
- }
- return $results;
- }
- /**
- * Прямое списание без резерва (для ручных операций)
- *
- * ВНИМАНИЕ: Использовать только для ручных корректировок!
- * Для рекламаций всегда использовать резервирование.
- *
- * @param SparePartOrder $order Партия
- * @param int $quantity Количество
- * @param string $note Примечание
- * @return IssueResult
- */
- public function directIssue(SparePartOrder $order, int $quantity, string $note): IssueResult
- {
- if ($order->available_qty < $quantity) {
- throw new \InvalidArgumentException(
- "Недостаточно товара в партии. Доступно: {$order->available_qty}, " .
- "запрошено: {$quantity}"
- );
- }
- return DB::transaction(function () use ($order, $quantity, $note) {
- // Блокируем для update
- $order = SparePartOrder::lockForUpdate()->find($order->id);
- $order->available_qty -= $quantity;
- if ($order->available_qty === 0) {
- $order->status = SparePartOrder::STATUS_SHIPPED;
- }
- $order->save();
- $movement = InventoryMovement::create([
- 'spare_part_order_id' => $order->id,
- 'spare_part_id' => $order->spare_part_id,
- 'qty' => $quantity,
- 'movement_type' => InventoryMovement::TYPE_ISSUE,
- 'source_type' => InventoryMovement::SOURCE_MANUAL,
- 'source_id' => null,
- 'with_documents' => $order->with_documents,
- 'user_id' => auth()->id(),
- 'note' => $note,
- ]);
- return new IssueResult(
- issued: $quantity,
- reservation: null,
- movement: $movement,
- order: $order
- );
- });
- }
- /**
- * Коррекция остатка партии (инвентаризация)
- *
- * @param SparePartOrder $order Партия
- * @param int $newQuantity Новый физический остаток
- * @param string $note Причина коррекции
- * @return InventoryMovement
- */
- public function correctInventory(SparePartOrder $order, int $newQuantity, string $note): InventoryMovement
- {
- if ($newQuantity < 0) {
- throw new \InvalidArgumentException("Остаток не может быть отрицательным");
- }
- return DB::transaction(function () use ($order, $newQuantity, $note) {
- $order = SparePartOrder::lockForUpdate()->find($order->id);
- $diff = $newQuantity - $order->available_qty;
- if ($diff === 0) {
- throw new \InvalidArgumentException("Количество не изменилось");
- }
- $order->available_qty = $newQuantity;
- if ($order->available_qty === 0 && $order->status === SparePartOrder::STATUS_IN_STOCK) {
- $order->status = SparePartOrder::STATUS_SHIPPED;
- } elseif ($order->available_qty > 0 && $order->status === SparePartOrder::STATUS_SHIPPED) {
- $order->status = SparePartOrder::STATUS_IN_STOCK;
- }
- $order->save();
- $movementType = $diff > 0
- ? InventoryMovement::TYPE_CORRECTION_PLUS
- : InventoryMovement::TYPE_CORRECTION_MINUS;
- return InventoryMovement::create([
- 'spare_part_order_id' => $order->id,
- 'spare_part_id' => $order->spare_part_id,
- 'qty' => abs($diff),
- 'movement_type' => $movementType,
- 'source_type' => InventoryMovement::SOURCE_INVENTORY,
- 'source_id' => null,
- 'with_documents' => $order->with_documents,
- 'user_id' => auth()->id(),
- 'note' => $note,
- ]);
- });
- }
- }
- /**
- * Результат операции списания
- */
- class IssueResult
- {
- public function __construct(
- public readonly int $issued,
- public readonly ?Reservation $reservation,
- public readonly InventoryMovement $movement,
- public readonly SparePartOrder $order
- ) {}
- }
|