Przeglądaj źródła

Unit тесты (9 файлов):
┌─────────────────────────────────────────────────────────┬────────┬───────────────────────────────────────────┐
│ Файл │ Тестов │ Описание │
├─────────────────────────────────────────────────────────┼────────┼───────────────────────────────────────────┤
│ tests/Unit/Helpers/DateHelperTest.php │ 12 │ Конвертация дат Excel/ISO, форматирование │
├─────────────────────────────────────────────────────────┼────────┼───────────────────────────────────────────┤
│ tests/Unit/Helpers/PriceHelperTest.php │ 7 │ Форматирование цен │
├─────────────────────────────────────────────────────────┼────────┼───────────────────────────────────────────┤
│ tests/Unit/Helpers/CountHelperTest.php │ 4 │ Склонение слов │
├─────────────────────────────────────────────────────────┼────────┼───────────────────────────────────────────┤
│ tests/Unit/Models/SparePartTest.php │ 20 │ Остатки, резервы, дефициты │
├─────────────────────────────────────────────────────────┼────────┼───────────────────────────────────────────┤
│ tests/Unit/Models/SparePartOrderTest.php │ 18 │ Статусы, scopes, резервирование │
├─────────────────────────────────────────────────────────┼────────┼───────────────────────────────────────────┤
│ tests/Unit/Models/OrderTest.php │ 14 │ Статусы, связи, валидации │
├─────────────────────────────────────────────────────────┼────────┼───────────────────────────────────────────┤
│ tests/Unit/Services/SparePartReservationServiceTest.php │ 15 │ FIFO резервирование, дефициты │
├─────────────────────────────────────────────────────────┼────────┼───────────────────────────────────────────┤
│ tests/Unit/Services/ShortageServiceTest.php │ 14 │ Покрытие дефицитов, FIFO │
└─────────────────────────────────────────────────────────┴────────┴───────────────────────────────────────────┘
Обновлены модели (12 файлов):
Добавлен trait HasFactory к моделям: User, SparePart, SparePartOrder, Order, Reclamation, Reservation, Shortage, InventoryMovement, Product, District, Area, MafOrder, ProductSKU.

Alexander Musikhin 4 dni temu
rodzic
commit
3157867be8
34 zmienionych plików z 2923 dodań i 11 usunięć
  1. 2 1
      app/Models/Dictionary/Area.php
  2. 2 1
      app/Models/Dictionary/District.php
  3. 3 0
      app/Models/InventoryMovement.php
  4. 2 1
      app/Models/MafOrder.php
  5. 2 3
      app/Models/Order.php
  6. 2 1
      app/Models/Product.php
  7. 2 1
      app/Models/ProductSKU.php
  8. 3 0
      app/Models/Reclamation.php
  9. 3 0
      app/Models/Reservation.php
  10. 3 0
      app/Models/Shortage.php
  11. 2 1
      app/Models/SparePart.php
  12. 2 1
      app/Models/SparePartOrder.php
  13. 2 1
      app/Models/User.php
  14. 24 0
      database/factories/AreaFactory.php
  15. 23 0
      database/factories/DistrictFactory.php
  16. 77 0
      database/factories/InventoryMovementFactory.php
  17. 48 0
      database/factories/MafOrderFactory.php
  18. 75 0
      database/factories/OrderFactory.php
  19. 33 0
      database/factories/ProductFactory.php
  20. 69 0
      database/factories/ProductSKUFactory.php
  21. 64 0
      database/factories/ReclamationFactory.php
  22. 88 0
      database/factories/ReservationFactory.php
  23. 83 0
      database/factories/ShortageFactory.php
  24. 44 0
      database/factories/SparePartFactory.php
  25. 77 0
      database/factories/SparePartOrderFactory.php
  26. 23 0
      database/factories/UserFactory.php
  27. 50 0
      tests/Unit/Helpers/CountHelperTest.php
  28. 120 0
      tests/Unit/Helpers/DateHelperTest.php
  29. 64 0
      tests/Unit/Helpers/PriceHelperTest.php
  30. 228 0
      tests/Unit/Models/OrderTest.php
  31. 296 0
      tests/Unit/Models/SparePartOrderTest.php
  32. 392 0
      tests/Unit/Models/SparePartTest.php
  33. 494 0
      tests/Unit/Services/ShortageServiceTest.php
  34. 521 0
      tests/Unit/Services/SparePartReservationServiceTest.php

+ 2 - 1
app/Models/Dictionary/Area.php

@@ -3,13 +3,14 @@
 namespace App\Models\Dictionary;
 namespace App\Models\Dictionary;
 
 
 use App\Models\Responsible;
 use App\Models\Responsible;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\SoftDeletes;
 use Illuminate\Database\Eloquent\SoftDeletes;
 
 
 class Area extends Model
 class Area extends Model
 {
 {
-    use SoftDeletes;
+    use HasFactory, SoftDeletes;
 
 
     protected $fillable = [
     protected $fillable = [
         'responsible_id',
         'responsible_id',

+ 2 - 1
app/Models/Dictionary/District.php

@@ -2,13 +2,14 @@
 
 
 namespace App\Models\Dictionary;
 namespace App\Models\Dictionary;
 
 
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Database\Eloquent\SoftDeletes;
 use Illuminate\Database\Eloquent\SoftDeletes;
 
 
 class District extends Model
 class District extends Model
 {
 {
-    use SoftDeletes;
+    use HasFactory, SoftDeletes;
 
 
     protected $fillable = [
     protected $fillable = [
         'shortname',
         'shortname',

+ 3 - 0
app/Models/InventoryMovement.php

@@ -2,6 +2,7 @@
 
 
 namespace App\Models;
 namespace App\Models;
 
 
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 
 
@@ -17,6 +18,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
  */
  */
 class InventoryMovement extends Model
 class InventoryMovement extends Model
 {
 {
+    use HasFactory;
+
     // Типы движений
     // Типы движений
     const TYPE_RECEIPT = 'receipt';
     const TYPE_RECEIPT = 'receipt';
     const TYPE_RESERVE = 'reserve';
     const TYPE_RESERVE = 'reserve';

+ 2 - 1
app/Models/MafOrder.php

@@ -4,6 +4,7 @@ namespace App\Models;
 
 
 use App\Models\Scopes\YearScope;
 use App\Models\Scopes\YearScope;
 use Illuminate\Database\Eloquent\Attributes\ScopedBy;
 use Illuminate\Database\Eloquent\Attributes\ScopedBy;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
 #[ScopedBy([YearScope::class])]
 #[ScopedBy([YearScope::class])]
 class MafOrder extends Model
 class MafOrder extends Model
 {
 {
-    use SoftDeletes;
+    use HasFactory, SoftDeletes;
     protected static function boot(): void
     protected static function boot(): void
     {
     {
         parent::boot();
         parent::boot();

+ 2 - 3
app/Models/Order.php

@@ -9,6 +9,7 @@ use App\Models\Dictionary\District;
 use App\Models\Scopes\YearScope;
 use App\Models\Scopes\YearScope;
 use Illuminate\Database\Eloquent\Attributes\ScopedBy;
 use Illuminate\Database\Eloquent\Attributes\ScopedBy;
 use Illuminate\Database\Eloquent\Casts\Attribute;
 use Illuminate\Database\Eloquent\Casts\Attribute;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -20,9 +21,7 @@ use Illuminate\Support\Facades\DB;
 #[ScopedBy([YearScope::class])]
 #[ScopedBy([YearScope::class])]
 class Order extends Model
 class Order extends Model
 {
 {
-
-
-    use SoftDeletes;
+    use HasFactory, SoftDeletes;
     const DEFAULT_SORT_BY = 'created_at';
     const DEFAULT_SORT_BY = 'created_at';
 
 
     const STATUS_NEW = 1;
     const STATUS_NEW = 1;

+ 2 - 1
app/Models/Product.php

@@ -6,6 +6,7 @@ use App\Helpers\Price;
 use App\Models\Scopes\YearScope;
 use App\Models\Scopes\YearScope;
 use Illuminate\Database\Eloquent\Attributes\ScopedBy;
 use Illuminate\Database\Eloquent\Attributes\ScopedBy;
 use Illuminate\Database\Eloquent\Casts\Attribute;
 use Illuminate\Database\Eloquent\Casts\Attribute;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -16,7 +17,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
 #[ScopedBy([YearScope::class])]
 #[ScopedBy([YearScope::class])]
 class Product extends Model
 class Product extends Model
 {
 {
-    use SoftDeletes;
+    use HasFactory, SoftDeletes;
 
 
     const DEFAULT_SORT_BY = 'article';
     const DEFAULT_SORT_BY = 'article';
 
 

+ 2 - 1
app/Models/ProductSKU.php

@@ -4,6 +4,7 @@ namespace App\Models;
 
 
 use App\Models\Scopes\YearScope;
 use App\Models\Scopes\YearScope;
 use Illuminate\Database\Eloquent\Attributes\ScopedBy;
 use Illuminate\Database\Eloquent\Attributes\ScopedBy;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\SoftDeletes;
 use Illuminate\Database\Eloquent\SoftDeletes;
@@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
 #[ScopedBy([YearScope::class])]
 #[ScopedBy([YearScope::class])]
 class ProductSKU extends Model
 class ProductSKU extends Model
 {
 {
-    use SoftDeletes;
+    use HasFactory, SoftDeletes;
 
 
     const DEFAULT_SORT_BY = 'created_at';
     const DEFAULT_SORT_BY = 'created_at';
     protected $fillable = [
     protected $fillable = [

+ 3 - 0
app/Models/Reclamation.php

@@ -2,6 +2,7 @@
 
 
 namespace App\Models;
 namespace App\Models;
 
 
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -9,6 +10,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
 
 
 class Reclamation extends Model
 class Reclamation extends Model
 {
 {
+    use HasFactory;
+
     const DEFAULT_SORT_BY = 'created_at';
     const DEFAULT_SORT_BY = 'created_at';
 
 
     const STATUS_NEW = 1;
     const STATUS_NEW = 1;

+ 3 - 0
app/Models/Reservation.php

@@ -3,6 +3,7 @@
 namespace App\Models;
 namespace App\Models;
 
 
 use App\Models\Scopes\YearScope;
 use App\Models\Scopes\YearScope;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\HasOne;
 use Illuminate\Database\Eloquent\Relations\HasOne;
@@ -20,6 +21,8 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
  */
  */
 class Reservation extends Model
 class Reservation extends Model
 {
 {
+    use HasFactory;
+
     const STATUS_ACTIVE = 'active';
     const STATUS_ACTIVE = 'active';
     const STATUS_ISSUED = 'issued';
     const STATUS_ISSUED = 'issued';
     const STATUS_CANCELLED = 'cancelled';
     const STATUS_CANCELLED = 'cancelled';

+ 3 - 0
app/Models/Shortage.php

@@ -2,6 +2,7 @@
 
 
 namespace App\Models;
 namespace App\Models;
 
 
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -17,6 +18,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
  */
  */
 class Shortage extends Model
 class Shortage extends Model
 {
 {
+    use HasFactory;
+
     const STATUS_OPEN = 'open';
     const STATUS_OPEN = 'open';
     const STATUS_CLOSED = 'closed';
     const STATUS_CLOSED = 'closed';
 
 

+ 2 - 1
app/Models/SparePart.php

@@ -4,6 +4,7 @@ namespace App\Models;
 
 
 use App\Helpers\Price;
 use App\Helpers\Price;
 use Illuminate\Database\Eloquent\Casts\Attribute;
 use Illuminate\Database\Eloquent\Casts\Attribute;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -22,7 +23,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
  */
  */
 class SparePart extends Model
 class SparePart extends Model
 {
 {
-    use SoftDeletes;
+    use HasFactory, SoftDeletes;
 
 
     const DEFAULT_SORT_BY = 'article';
     const DEFAULT_SORT_BY = 'article';
 
 

+ 2 - 1
app/Models/SparePartOrder.php

@@ -4,6 +4,7 @@ namespace App\Models;
 
 
 use App\Models\Scopes\YearScope;
 use App\Models\Scopes\YearScope;
 use Illuminate\Database\Eloquent\Attributes\ScopedBy;
 use Illuminate\Database\Eloquent\Attributes\ScopedBy;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -24,7 +25,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
 #[ScopedBy([YearScope::class])]
 #[ScopedBy([YearScope::class])]
 class SparePartOrder extends Model
 class SparePartOrder extends Model
 {
 {
-    use SoftDeletes;
+    use HasFactory, SoftDeletes;
 
 
     const STATUS_ORDERED = 'ordered';
     const STATUS_ORDERED = 'ordered';
     const STATUS_IN_STOCK = 'in_stock';
     const STATUS_IN_STOCK = 'in_stock';

+ 2 - 1
app/Models/User.php

@@ -3,13 +3,14 @@
 namespace App\Models;
 namespace App\Models;
 
 
 use Illuminate\Contracts\Auth\MustVerifyEmail;
 use Illuminate\Contracts\Auth\MustVerifyEmail;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\SoftDeletes;
 use Illuminate\Database\Eloquent\SoftDeletes;
 use Illuminate\Foundation\Auth\User as Authenticatable;
 use Illuminate\Foundation\Auth\User as Authenticatable;
 use Illuminate\Notifications\Notifiable;
 use Illuminate\Notifications\Notifiable;
 
 
 class User extends Authenticatable implements MustVerifyEmail
 class User extends Authenticatable implements MustVerifyEmail
 {
 {
-    use Notifiable, SoftDeletes;
+    use HasFactory, Notifiable, SoftDeletes;
 
 
     const DEFAULT_SORT_BY = 'created_at';
     const DEFAULT_SORT_BY = 'created_at';
 
 

+ 24 - 0
database/factories/AreaFactory.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace Database\Factories;
+
+use App\Models\Dictionary\Area;
+use App\Models\Dictionary\District;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends Factory<Area>
+ */
+class AreaFactory extends Factory
+{
+    protected $model = Area::class;
+
+    public function definition(): array
+    {
+        return [
+            'name' => fake()->streetName(),
+            'district_id' => District::factory(),
+            'responsible_id' => null,
+        ];
+    }
+}

+ 23 - 0
database/factories/DistrictFactory.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace Database\Factories;
+
+use App\Models\Dictionary\District;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends Factory<District>
+ */
+class DistrictFactory extends Factory
+{
+    protected $model = District::class;
+
+    public function definition(): array
+    {
+        $name = fake()->city();
+        return [
+            'name' => $name,
+            'shortname' => mb_substr($name, 0, 3),
+        ];
+    }
+}

+ 77 - 0
database/factories/InventoryMovementFactory.php

@@ -0,0 +1,77 @@
+<?php
+
+namespace Database\Factories;
+
+use App\Models\InventoryMovement;
+use App\Models\SparePart;
+use App\Models\SparePartOrder;
+use App\Models\User;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends Factory<InventoryMovement>
+ */
+class InventoryMovementFactory extends Factory
+{
+    protected $model = InventoryMovement::class;
+
+    public function definition(): array
+    {
+        return [
+            'spare_part_order_id' => SparePartOrder::factory(),
+            'spare_part_id' => SparePart::factory(),
+            'qty' => fake()->numberBetween(1, 20),
+            'movement_type' => InventoryMovement::TYPE_RECEIPT,
+            'source_type' => InventoryMovement::SOURCE_MANUAL,
+            'source_id' => null,
+            'with_documents' => fake()->boolean(),
+            'user_id' => User::factory(),
+            'note' => fake()->optional()->sentence(),
+        ];
+    }
+
+    public function receipt(): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'movement_type' => InventoryMovement::TYPE_RECEIPT,
+        ]);
+    }
+
+    public function reserve(): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'movement_type' => InventoryMovement::TYPE_RESERVE,
+        ]);
+    }
+
+    public function issue(): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'movement_type' => InventoryMovement::TYPE_ISSUE,
+        ]);
+    }
+
+    public function reserveCancel(): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'movement_type' => InventoryMovement::TYPE_RESERVE_CANCEL,
+        ]);
+    }
+
+    public function forReclamation(int $reclamationId): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'source_type' => InventoryMovement::SOURCE_RECLAMATION,
+            'source_id' => $reclamationId,
+        ]);
+    }
+
+    public function fromOrder(SparePartOrder $order): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'spare_part_order_id' => $order->id,
+            'spare_part_id' => $order->spare_part_id,
+            'with_documents' => $order->with_documents,
+        ]);
+    }
+}

+ 48 - 0
database/factories/MafOrderFactory.php

@@ -0,0 +1,48 @@
+<?php
+
+namespace Database\Factories;
+
+use App\Models\MafOrder;
+use App\Models\Product;
+use App\Models\User;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends Factory<MafOrder>
+ */
+class MafOrderFactory extends Factory
+{
+    protected $model = MafOrder::class;
+
+    public function definition(): array
+    {
+        $quantity = fake()->numberBetween(1, 20);
+
+        return [
+            'year' => (int) date('Y'),
+            'order_number' => fake()->unique()->bothify('MO-####'),
+            'status' => 'active',
+            'user_id' => User::factory(),
+            'product_id' => Product::factory(),
+            'quantity' => $quantity,
+            'in_stock' => fake()->numberBetween(0, $quantity),
+        ];
+    }
+
+    public function forProduct(Product $product): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'product_id' => $product->id,
+        ]);
+    }
+
+    public function fullyInStock(): static
+    {
+        return $this->state(function (array $attributes) {
+            $qty = $attributes['quantity'] ?? 10;
+            return [
+                'in_stock' => $qty,
+            ];
+        });
+    }
+}

+ 75 - 0
database/factories/OrderFactory.php

@@ -0,0 +1,75 @@
+<?php
+
+namespace Database\Factories;
+
+use App\Models\Dictionary\Area;
+use App\Models\Dictionary\District;
+use App\Models\Order;
+use App\Models\User;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends Factory<Order>
+ */
+class OrderFactory extends Factory
+{
+    protected $model = Order::class;
+
+    public function definition(): array
+    {
+        return [
+            'year' => (int) date('Y'),
+            'name' => fake()->bothify('Заказ-####'),
+            'user_id' => User::factory(),
+            'district_id' => District::factory(),
+            'area_id' => Area::factory(),
+            'object_address' => fake()->address(),
+            'object_type_id' => null,
+            'comment' => fake()->optional()->sentence(),
+            'installation_date' => fake()->optional()->dateTimeBetween('now', '+3 months')?->format('Y-m-d'),
+            'ready_date' => fake()->optional()->dateTimeBetween('-1 month', 'now')?->format('Y-m-d'),
+            'brigadier_id' => null,
+            'order_status_id' => Order::STATUS_NEW,
+            'tg_group_name' => null,
+            'tg_group_link' => null,
+            'ready_to_mount' => 'Нет',
+            'install_days' => fake()->numberBetween(1, 14),
+        ];
+    }
+
+    public function withStatus(int $status): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'order_status_id' => $status,
+        ]);
+    }
+
+    public function readyToMount(): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'order_status_id' => Order::STATUS_READY_TO_MOUNT,
+            'ready_to_mount' => 'Да',
+        ]);
+    }
+
+    public function inMount(): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'order_status_id' => Order::STATUS_IN_MOUNT,
+        ]);
+    }
+
+    public function handedOver(): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'order_status_id' => Order::STATUS_HANDED_OVER,
+        ]);
+    }
+
+    public function withBrigadier(User $brigadier = null): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'brigadier_id' => $brigadier?->id ?? User::factory(),
+        ]);
+    }
+}

+ 33 - 0
database/factories/ProductFactory.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace Database\Factories;
+
+use App\Models\Product;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends Factory<Product>
+ */
+class ProductFactory extends Factory
+{
+    protected $model = Product::class;
+
+    public function definition(): array
+    {
+        return [
+            'year' => (int) date('Y'),
+            'article' => fake()->unique()->bothify('MAF-####'),
+            'name_tz' => fake()->sentence(3),
+            'type_tz' => fake()->randomElement(['Горка', 'Качели', 'Песочница', 'Карусель']),
+            'nomenclature_number' => fake()->numerify('######'),
+            'sizes' => fake()->numerify('####x####x####'),
+            'manufacturer' => fake()->company(),
+            'unit' => 'шт',
+            'type' => fake()->randomElement(['standard', 'custom']),
+            'product_price' => fake()->randomFloat(2, 10000, 500000),
+            'installation_price' => fake()->randomFloat(2, 5000, 50000),
+            'total_price' => fake()->randomFloat(2, 15000, 550000),
+            'service_life' => fake()->numberBetween(5, 15),
+        ];
+    }
+}

+ 69 - 0
database/factories/ProductSKUFactory.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace Database\Factories;
+
+use App\Models\MafOrder;
+use App\Models\Order;
+use App\Models\Product;
+use App\Models\ProductSKU;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends Factory<ProductSKU>
+ */
+class ProductSKUFactory extends Factory
+{
+    protected $model = ProductSKU::class;
+
+    public function definition(): array
+    {
+        return [
+            'year' => (int) date('Y'),
+            'product_id' => Product::factory(),
+            'order_id' => Order::factory(),
+            'maf_order_id' => null,
+            'status' => 'needs',
+            'rfid' => fake()->optional()->numerify('RFID-########'),
+            'factory_number' => fake()->optional()->numerify('FN-######'),
+            'manufacture_date' => fake()->optional()->date(),
+            'statement_number' => null,
+            'statement_date' => null,
+            'upd_number' => null,
+            'comment' => fake()->optional()->sentence(),
+            'passport_id' => null,
+        ];
+    }
+
+    public function withMafOrder(MafOrder $mafOrder = null): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'maf_order_id' => $mafOrder?->id ?? MafOrder::factory(),
+            'status' => 'related',
+        ]);
+    }
+
+    public function forOrder(Order $order): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'order_id' => $order->id,
+        ]);
+    }
+
+    public function forProduct(Product $product): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'product_id' => $product->id,
+        ]);
+    }
+
+    public function withAllDetails(): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'rfid' => fake()->numerify('RFID-########'),
+            'factory_number' => fake()->numerify('FN-######'),
+            'manufacture_date' => fake()->date(),
+            'maf_order_id' => MafOrder::factory(),
+            'status' => 'related',
+        ]);
+    }
+}

+ 64 - 0
database/factories/ReclamationFactory.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace Database\Factories;
+
+use App\Models\Order;
+use App\Models\Reclamation;
+use App\Models\User;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends Factory<Reclamation>
+ */
+class ReclamationFactory extends Factory
+{
+    protected $model = Reclamation::class;
+
+    public function definition(): array
+    {
+        return [
+            'order_id' => Order::factory(),
+            'user_id' => User::factory(),
+            'status_id' => Reclamation::STATUS_NEW,
+            'reason' => fake()->sentence(),
+            'guarantee' => fake()->boolean(),
+            'whats_done' => fake()->optional()->sentence(),
+            'create_date' => fake()->dateTimeBetween('-1 month', 'now')->format('Y-m-d'),
+            'finish_date' => null,
+            'start_work_date' => null,
+            'work_days' => null,
+            'brigadier_id' => null,
+            'comment' => fake()->optional()->sentence(),
+        ];
+    }
+
+    public function withStatus(int $status): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'status_id' => $status,
+        ]);
+    }
+
+    public function inWork(): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'status_id' => Reclamation::STATUS_IN_WORK,
+            'start_work_date' => now()->format('Y-m-d'),
+        ]);
+    }
+
+    public function done(): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'status_id' => Reclamation::STATUS_DONE,
+            'finish_date' => now()->format('Y-m-d'),
+        ]);
+    }
+
+    public function forOrder(Order $order): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'order_id' => $order->id,
+        ]);
+    }
+}

+ 88 - 0
database/factories/ReservationFactory.php

@@ -0,0 +1,88 @@
+<?php
+
+namespace Database\Factories;
+
+use App\Models\Reclamation;
+use App\Models\Reservation;
+use App\Models\SparePart;
+use App\Models\SparePartOrder;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends Factory<Reservation>
+ */
+class ReservationFactory extends Factory
+{
+    protected $model = Reservation::class;
+
+    public function definition(): array
+    {
+        return [
+            'spare_part_id' => SparePart::factory(),
+            'spare_part_order_id' => SparePartOrder::factory(),
+            'reclamation_id' => Reclamation::factory(),
+            'reserved_qty' => fake()->numberBetween(1, 10),
+            'with_documents' => fake()->boolean(),
+            'status' => Reservation::STATUS_ACTIVE,
+            'movement_id' => null,
+        ];
+    }
+
+    public function active(): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'status' => Reservation::STATUS_ACTIVE,
+        ]);
+    }
+
+    public function issued(): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'status' => Reservation::STATUS_ISSUED,
+        ]);
+    }
+
+    public function cancelled(): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'status' => Reservation::STATUS_CANCELLED,
+        ]);
+    }
+
+    public function withDocuments(bool $withDocs = true): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'with_documents' => $withDocs,
+        ]);
+    }
+
+    public function withQuantity(int $quantity): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'reserved_qty' => $quantity,
+        ]);
+    }
+
+    public function forReclamation(Reclamation $reclamation): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'reclamation_id' => $reclamation->id,
+        ]);
+    }
+
+    public function forSparePart(SparePart $sparePart): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'spare_part_id' => $sparePart->id,
+        ]);
+    }
+
+    public function fromOrder(SparePartOrder $order): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'spare_part_order_id' => $order->id,
+            'spare_part_id' => $order->spare_part_id,
+            'with_documents' => $order->with_documents,
+        ]);
+    }
+}

+ 83 - 0
database/factories/ShortageFactory.php

@@ -0,0 +1,83 @@
+<?php
+
+namespace Database\Factories;
+
+use App\Models\Reclamation;
+use App\Models\Shortage;
+use App\Models\SparePart;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends Factory<Shortage>
+ */
+class ShortageFactory extends Factory
+{
+    protected $model = Shortage::class;
+
+    public function definition(): array
+    {
+        $required = fake()->numberBetween(5, 20);
+        $reserved = fake()->numberBetween(0, $required - 1);
+
+        return [
+            'spare_part_id' => SparePart::factory(),
+            'reclamation_id' => Reclamation::factory(),
+            'with_documents' => fake()->boolean(),
+            'required_qty' => $required,
+            'reserved_qty' => $reserved,
+            'missing_qty' => $required - $reserved,
+            'status' => Shortage::STATUS_OPEN,
+            'note' => fake()->optional()->sentence(),
+        ];
+    }
+
+    public function open(): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'status' => Shortage::STATUS_OPEN,
+        ]);
+    }
+
+    public function closed(): static
+    {
+        return $this->state(function (array $attributes) {
+            $required = $attributes['required_qty'] ?? 10;
+            return [
+                'status' => Shortage::STATUS_CLOSED,
+                'reserved_qty' => $required,
+                'missing_qty' => 0,
+            ];
+        });
+    }
+
+    public function withDocuments(bool $withDocs = true): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'with_documents' => $withDocs,
+        ]);
+    }
+
+    public function withQuantities(int $required, int $reserved): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'required_qty' => $required,
+            'reserved_qty' => $reserved,
+            'missing_qty' => max(0, $required - $reserved),
+            'status' => ($required <= $reserved) ? Shortage::STATUS_CLOSED : Shortage::STATUS_OPEN,
+        ]);
+    }
+
+    public function forSparePart(SparePart $sparePart): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'spare_part_id' => $sparePart->id,
+        ]);
+    }
+
+    public function forReclamation(Reclamation $reclamation): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'reclamation_id' => $reclamation->id,
+        ]);
+    }
+}

+ 44 - 0
database/factories/SparePartFactory.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace Database\Factories;
+
+use App\Models\Product;
+use App\Models\SparePart;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends Factory<SparePart>
+ */
+class SparePartFactory extends Factory
+{
+    protected $model = SparePart::class;
+
+    public function definition(): array
+    {
+        return [
+            'article' => fake()->unique()->bothify('SP-####'),
+            'used_in_maf' => fake()->sentence(2),
+            'product_id' => null,
+            'note' => fake()->optional()->sentence(),
+            'purchase_price' => fake()->randomFloat(2, 100, 10000),
+            'customer_price' => fake()->randomFloat(2, 150, 15000),
+            'expertise_price' => fake()->randomFloat(2, 200, 20000),
+            'tsn_number' => fake()->optional()->numerify('TSN-####'),
+            'min_stock' => fake()->numberBetween(0, 10),
+        ];
+    }
+
+    public function withProduct(): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'product_id' => Product::factory(),
+        ]);
+    }
+
+    public function withMinStock(int $minStock): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'min_stock' => $minStock,
+        ]);
+    }
+}

+ 77 - 0
database/factories/SparePartOrderFactory.php

@@ -0,0 +1,77 @@
+<?php
+
+namespace Database\Factories;
+
+use App\Models\SparePart;
+use App\Models\SparePartOrder;
+use App\Models\User;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends Factory<SparePartOrder>
+ */
+class SparePartOrderFactory extends Factory
+{
+    protected $model = SparePartOrder::class;
+
+    public function definition(): array
+    {
+        $quantity = fake()->numberBetween(5, 100);
+
+        return [
+            'year' => (int) date('Y'),
+            'spare_part_id' => SparePart::factory(),
+            'source_text' => fake()->company(),
+            'status' => SparePartOrder::STATUS_IN_STOCK,
+            'ordered_quantity' => $quantity,
+            'available_qty' => $quantity,
+            'with_documents' => fake()->boolean(),
+            'note' => fake()->optional()->sentence(),
+            'user_id' => User::factory(),
+        ];
+    }
+
+    public function ordered(): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'status' => SparePartOrder::STATUS_ORDERED,
+        ]);
+    }
+
+    public function inStock(): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'status' => SparePartOrder::STATUS_IN_STOCK,
+        ]);
+    }
+
+    public function shipped(): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'status' => SparePartOrder::STATUS_SHIPPED,
+            'available_qty' => 0,
+        ]);
+    }
+
+    public function withDocuments(bool $withDocs = true): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'with_documents' => $withDocs,
+        ]);
+    }
+
+    public function withQuantity(int $quantity): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'ordered_quantity' => $quantity,
+            'available_qty' => $quantity,
+        ]);
+    }
+
+    public function forSparePart(SparePart $sparePart): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'spare_part_id' => $sparePart->id,
+        ]);
+    }
+}

+ 23 - 0
database/factories/UserFactory.php

@@ -29,9 +29,32 @@ class UserFactory extends Factory
             'email_verified_at' => now(),
             'email_verified_at' => now(),
             'password' => static::$password ??= Hash::make('password'),
             'password' => static::$password ??= Hash::make('password'),
             'remember_token' => Str::random(10),
             'remember_token' => Str::random(10),
+            'role' => 'user',
+            'phone' => fake()->optional()->phoneNumber(),
         ];
         ];
     }
     }
 
 
+    public function admin(): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'role' => 'admin',
+        ]);
+    }
+
+    public function manager(): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'role' => 'manager',
+        ]);
+    }
+
+    public function brigadier(): static
+    {
+        return $this->state(fn (array $attributes) => [
+            'role' => 'brigadier',
+        ]);
+    }
+
     /**
     /**
      * Indicate that the model's email address should be unverified.
      * Indicate that the model's email address should be unverified.
      */
      */

+ 50 - 0
tests/Unit/Helpers/CountHelperTest.php

@@ -0,0 +1,50 @@
+<?php
+
+namespace Tests\Unit\Helpers;
+
+use App\Helpers\CountHelper;
+use PHPUnit\Framework\TestCase;
+
+class CountHelperTest extends TestCase
+{
+    public function test_human_count_returns_one_form_for_1(): void
+    {
+        $result = CountHelper::humanCount(1, 'год', 'года', 'лет');
+
+        $this->assertEquals('1 год', $result);
+    }
+
+    public function test_human_count_returns_two_form_for_2_to_4(): void
+    {
+        $this->assertEquals('2 года', CountHelper::humanCount(2, 'год', 'года', 'лет'));
+        $this->assertEquals('3 года', CountHelper::humanCount(3, 'год', 'года', 'лет'));
+        $this->assertEquals('4 года', CountHelper::humanCount(4, 'год', 'года', 'лет'));
+    }
+
+    public function test_human_count_returns_many_form_for_0_and_5_plus(): void
+    {
+        $this->assertEquals('0 лет', CountHelper::humanCount(0, 'год', 'года', 'лет'));
+        $this->assertEquals('5 лет', CountHelper::humanCount(5, 'год', 'года', 'лет'));
+        $this->assertEquals('10 лет', CountHelper::humanCount(10, 'год', 'года', 'лет'));
+        $this->assertEquals('21 лет', CountHelper::humanCount(21, 'год', 'года', 'лет'));
+        $this->assertEquals('100 лет', CountHelper::humanCount(100, 'год', 'года', 'лет'));
+    }
+
+    public function test_human_count_works_with_different_words(): void
+    {
+        // Days
+        $this->assertEquals('1 день', CountHelper::humanCount(1, 'день', 'дня', 'дней'));
+        $this->assertEquals('2 дня', CountHelper::humanCount(2, 'день', 'дня', 'дней'));
+        $this->assertEquals('5 дней', CountHelper::humanCount(5, 'день', 'дня', 'дней'));
+
+        // Months
+        $this->assertEquals('1 месяц', CountHelper::humanCount(1, 'месяц', 'месяца', 'месяцев'));
+        $this->assertEquals('3 месяца', CountHelper::humanCount(3, 'месяц', 'месяца', 'месяцев'));
+        $this->assertEquals('12 месяцев', CountHelper::humanCount(12, 'месяц', 'месяца', 'месяцев'));
+
+        // Items
+        $this->assertEquals('1 штука', CountHelper::humanCount(1, 'штука', 'штуки', 'штук'));
+        $this->assertEquals('4 штуки', CountHelper::humanCount(4, 'штука', 'штуки', 'штук'));
+        $this->assertEquals('7 штук', CountHelper::humanCount(7, 'штука', 'штуки', 'штук'));
+    }
+}

+ 120 - 0
tests/Unit/Helpers/DateHelperTest.php

@@ -0,0 +1,120 @@
+<?php
+
+namespace Tests\Unit\Helpers;
+
+use App\Helpers\DateHelper;
+use Carbon\Exceptions\InvalidDateException;
+use PHPUnit\Framework\TestCase;
+
+class DateHelperTest extends TestCase
+{
+    public function test_excel_date_to_iso_date_converts_correctly(): void
+    {
+        // Excel date 44927 = 2023-01-01
+        $this->assertEquals('2023-01-01', DateHelper::excelDateToISODate(44927));
+
+        // Excel date 45292 = 2024-01-01
+        $this->assertEquals('2024-01-01', DateHelper::excelDateToISODate(45292));
+
+        // Excel date 25569 = 1970-01-01 (Unix epoch)
+        $this->assertEquals('1970-01-01', DateHelper::excelDateToISODate(25569));
+    }
+
+    public function test_iso_date_to_excel_date_converts_correctly(): void
+    {
+        $this->assertEquals(44927, DateHelper::ISODateToExcelDate('2023-01-01'));
+        $this->assertEquals(45292, DateHelper::ISODateToExcelDate('2024-01-01'));
+        $this->assertEquals(25569, DateHelper::ISODateToExcelDate('1970-01-01'));
+    }
+
+    public function test_excel_date_conversion_is_reversible(): void
+    {
+        $excelDate = 45000;
+        $isoDate = DateHelper::excelDateToISODate($excelDate);
+        $backToExcel = DateHelper::ISODateToExcelDate($isoDate);
+
+        $this->assertEquals($excelDate, $backToExcel);
+    }
+
+    public function test_get_human_date_returns_text_format_by_default(): void
+    {
+        $result = DateHelper::getHumanDate('2024-03-15');
+
+        // Should contain month name in Russian
+        $this->assertStringContainsString('марта', $result);
+        $this->assertStringContainsString('15', $result);
+        $this->assertStringContainsString('2024', $result);
+    }
+
+    public function test_get_human_date_returns_numeric_format_when_requested(): void
+    {
+        $result = DateHelper::getHumanDate('2024-03-15', true);
+
+        $this->assertEquals('15.03.2024', $result);
+    }
+
+    public function test_is_date_validates_correct_dates(): void
+    {
+        $this->assertTrue(DateHelper::isDate('2024-01-01'));
+        $this->assertTrue(DateHelper::isDate('2023-12-31'));
+        $this->assertTrue(DateHelper::isDate('1990-06-15'));
+    }
+
+    public function test_is_date_rejects_invalid_dates(): void
+    {
+        $this->assertFalse(DateHelper::isDate('2024-13-01')); // Invalid month
+        $this->assertFalse(DateHelper::isDate('2024-00-01')); // Invalid month
+        $this->assertFalse(DateHelper::isDate('2024-01-32')); // Invalid day
+        $this->assertFalse(DateHelper::isDate('24-01-01'));   // Short year
+        $this->assertFalse(DateHelper::isDate('01-01-2024')); // Wrong format
+        $this->assertFalse(DateHelper::isDate('not-a-date'));
+    }
+
+    public function test_get_date_for_db_formats_correctly(): void
+    {
+        $this->assertEquals('2024-03-15', DateHelper::getDateForDB('March 15, 2024'));
+        $this->assertEquals('2024-03-15', DateHelper::getDateForDB('15.03.2024'));
+    }
+
+    public function test_add_months_works_correctly(): void
+    {
+        $this->assertEquals('2024-04-01', DateHelper::addMonths('2024-01-01', 3));
+        $this->assertEquals('2025-01-01', DateHelper::addMonths('2024-01-01', 12));
+        $this->assertEquals('2023-10-01', DateHelper::addMonths('2024-01-01', -3));
+    }
+
+    public function test_get_date_of_week_returns_correct_date(): void
+    {
+        // Week 1 of 2024, Monday
+        $result = DateHelper::getDateOfWeek(2024, 1, 1);
+        $this->assertEquals('2024-01-01', $result);
+
+        // Week 1 of 2024, Sunday
+        $result = DateHelper::getDateOfWeek(2024, 1, 7);
+        $this->assertEquals('2024-01-07', $result);
+    }
+
+    public function test_get_random_date_returns_valid_date(): void
+    {
+        $result = DateHelper::getRandomDate(1);
+
+        $this->assertTrue(DateHelper::isDate($result));
+    }
+
+    public function test_get_random_date_throws_exception_for_invalid_years(): void
+    {
+        $this->expectException(InvalidDateException::class);
+        DateHelper::getRandomDate(-1);
+    }
+
+    public function test_get_human_day_of_week_returns_russian_day_name(): void
+    {
+        // 2024-01-01 is Monday
+        $result = DateHelper::getHumanDayOfWeek('2024-01-01');
+        $this->assertEquals('Понедельник', $result);
+
+        // 2024-01-07 is Sunday
+        $result = DateHelper::getHumanDayOfWeek('2024-01-07');
+        $this->assertEquals('Воскресенье', $result);
+    }
+}

+ 64 - 0
tests/Unit/Helpers/PriceHelperTest.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace Tests\Unit\Helpers;
+
+use App\Helpers\Price;
+use PHPUnit\Framework\TestCase;
+
+class PriceHelperTest extends TestCase
+{
+    public function test_format_adds_currency_symbol(): void
+    {
+        $result = Price::format(100.00);
+
+        $this->assertStringContainsString('₽', $result);
+    }
+
+    public function test_format_shows_two_decimal_places(): void
+    {
+        $result = Price::format(100);
+
+        $this->assertStringContainsString('.00', $result);
+    }
+
+    public function test_format_handles_decimal_prices(): void
+    {
+        $result = Price::format(123.45);
+
+        $this->assertStringContainsString('123.45', $result);
+    }
+
+    public function test_format_uses_nbsp_as_thousands_separator(): void
+    {
+        $result = Price::format(1234567.89);
+
+        // &nbsp; is used as thousands separator
+        $this->assertStringContainsString('&nbsp;', $result);
+        $this->assertStringContainsString('1', $result);
+        $this->assertStringContainsString('234', $result);
+        $this->assertStringContainsString('567', $result);
+        $this->assertStringContainsString('.89', $result);
+    }
+
+    public function test_format_handles_zero(): void
+    {
+        $result = Price::format(0);
+
+        $this->assertEquals('0.00₽', $result);
+    }
+
+    public function test_format_handles_small_amounts(): void
+    {
+        $result = Price::format(0.01);
+
+        $this->assertEquals('0.01₽', $result);
+    }
+
+    public function test_format_handles_large_amounts(): void
+    {
+        $result = Price::format(999999999.99);
+
+        $this->assertStringContainsString('₽', $result);
+        $this->assertStringContainsString('.99', $result);
+    }
+}

+ 228 - 0
tests/Unit/Models/OrderTest.php

@@ -0,0 +1,228 @@
+<?php
+
+namespace Tests\Unit\Models;
+
+use App\Models\Dictionary\Area;
+use App\Models\Dictionary\District;
+use App\Models\File;
+use App\Models\MafOrder;
+use App\Models\Order;
+use App\Models\Product;
+use App\Models\ProductSKU;
+use App\Models\Reclamation;
+use App\Models\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class OrderTest extends TestCase
+{
+    use RefreshDatabase;
+
+    public function test_status_constants_exist(): void
+    {
+        $this->assertEquals(1, Order::STATUS_NEW);
+        $this->assertEquals(2, Order::STATUS_NOT_READY);
+        $this->assertEquals(3, Order::STATUS_READY_NO_MAF);
+        $this->assertEquals(4, Order::STATUS_READY_TO_MOUNT);
+        $this->assertEquals(5, Order::STATUS_IN_MOUNT);
+        $this->assertEquals(6, Order::STATUS_DUTY);
+        $this->assertEquals(7, Order::STATUS_READY_TO_HAND_OVER);
+        $this->assertEquals(8, Order::STATUS_NOT_HANDED_OVER_WITH_NOTES);
+        $this->assertEquals(9, Order::STATUS_HANDED_OVER_WITH_NOTES);
+        $this->assertEquals(10, Order::STATUS_HANDED_OVER);
+        $this->assertEquals(11, Order::STATUS_NO_MAF);
+        $this->assertEquals(12, Order::STATUS_PROBLEM);
+    }
+
+    public function test_status_names_array_contains_all_statuses(): void
+    {
+        $this->assertArrayHasKey(Order::STATUS_NEW, Order::STATUS_NAMES);
+        $this->assertArrayHasKey(Order::STATUS_HANDED_OVER, Order::STATUS_NAMES);
+        $this->assertArrayHasKey(Order::STATUS_PROBLEM, Order::STATUS_NAMES);
+
+        $this->assertEquals('Новая', Order::STATUS_NAMES[Order::STATUS_NEW]);
+        $this->assertEquals('Сдана', Order::STATUS_NAMES[Order::STATUS_HANDED_OVER]);
+    }
+
+    public function test_status_colors_array_exists(): void
+    {
+        $this->assertArrayHasKey(Order::STATUS_NEW, Order::STATUS_COLOR);
+        $this->assertArrayHasKey(Order::STATUS_HANDED_OVER, Order::STATUS_COLOR);
+        $this->assertEquals('success', Order::STATUS_COLOR[Order::STATUS_HANDED_OVER]);
+        $this->assertEquals('danger', Order::STATUS_COLOR[Order::STATUS_PROBLEM]);
+    }
+
+    public function test_year_defaults_to_current_year_on_create(): void
+    {
+        $district = District::factory()->create();
+        $area = Area::factory()->create(['district_id' => $district->id]);
+        $user = User::factory()->create();
+
+        $order = Order::create([
+            'name' => 'Test Order',
+            'user_id' => $user->id,
+            'district_id' => $district->id,
+            'area_id' => $area->id,
+            'object_address' => 'Test Address',
+            'order_status_id' => Order::STATUS_NEW,
+        ]);
+
+        $this->assertEquals((int) date('Y'), $order->year);
+    }
+
+    public function test_common_name_attribute_combines_address_area_district(): void
+    {
+        $district = District::factory()->create(['shortname' => 'ЦАО']);
+        $area = Area::factory()->create([
+            'name' => 'Тверской',
+            'district_id' => $district->id,
+        ]);
+
+        $order = Order::factory()->create([
+            'object_address' => 'ул. Пушкина, д. 10',
+            'district_id' => $district->id,
+            'area_id' => $area->id,
+        ]);
+
+        $this->assertStringContainsString('ул. Пушкина, д. 10', $order->common_name);
+        $this->assertStringContainsString('Тверской', $order->common_name);
+        $this->assertStringContainsString('ЦАО', $order->common_name);
+    }
+
+    public function test_relations_exist(): void
+    {
+        $order = Order::factory()->create();
+
+        $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\BelongsTo::class, $order->user());
+        $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\BelongsTo::class, $order->district());
+        $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\BelongsTo::class, $order->area());
+        $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\BelongsTo::class, $order->brigadier());
+        $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class, $order->products_sku());
+        $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class, $order->reclamations());
+        $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class, $order->photos());
+        $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class, $order->documents());
+    }
+
+    public function test_is_all_maf_connected_returns_empty_when_all_connected(): void
+    {
+        $order = Order::factory()->create();
+        $product = Product::factory()->create();
+        $mafOrder = MafOrder::factory()->create(['product_id' => $product->id]);
+
+        ProductSKU::factory()->create([
+            'order_id' => $order->id,
+            'product_id' => $product->id,
+            'maf_order_id' => $mafOrder->id,
+        ]);
+
+        $errors = $order->isAllMafConnected();
+
+        $this->assertEmpty($errors);
+    }
+
+    public function test_is_all_maf_connected_returns_errors_when_not_connected(): void
+    {
+        $order = Order::factory()->create();
+        $product = Product::factory()->create(['article' => 'TEST-001']);
+
+        ProductSKU::factory()->create([
+            'order_id' => $order->id,
+            'product_id' => $product->id,
+            'maf_order_id' => null, // Not connected
+        ]);
+
+        $errors = $order->isAllMafConnected();
+
+        $this->assertNotEmpty($errors);
+        $this->assertStringContainsString('TEST-001', $errors[0]);
+    }
+
+    public function test_can_create_handover_checks_required_fields(): void
+    {
+        $order = Order::factory()->create();
+        $product = Product::factory()->create([
+            'article' => 'TEST-001',
+            'passport_name' => null, // Missing
+        ]);
+
+        ProductSKU::factory()->create([
+            'order_id' => $order->id,
+            'product_id' => $product->id,
+            'maf_order_id' => null,
+            'passport_id' => null,
+            'rfid' => null,
+        ]);
+
+        $errors = $order->canCreateHandover();
+
+        $this->assertNotEmpty($errors);
+        // Should have multiple errors for missing fields
+        $this->assertGreaterThan(1, count($errors));
+    }
+
+    public function test_can_create_handover_requires_photos(): void
+    {
+        $order = Order::factory()->create();
+        // Order without photos
+
+        $errors = $order->canCreateHandover();
+
+        $this->assertContains('Не загружены фотографии!', $errors);
+    }
+
+    public function test_fillable_attributes(): void
+    {
+        $order = new Order();
+        $fillable = $order->getFillable();
+
+        $this->assertContains('year', $fillable);
+        $this->assertContains('name', $fillable);
+        $this->assertContains('user_id', $fillable);
+        $this->assertContains('district_id', $fillable);
+        $this->assertContains('area_id', $fillable);
+        $this->assertContains('object_address', $fillable);
+        $this->assertContains('order_status_id', $fillable);
+        $this->assertContains('installation_date', $fillable);
+        $this->assertContains('brigadier_id', $fillable);
+    }
+
+    public function test_appends_include_required_attributes(): void
+    {
+        $order = new Order();
+
+        $this->assertContains('common_name', $order->appends);
+        $this->assertContains('products_with_count', $order->appends);
+    }
+
+    public function test_soft_deletes_trait_is_used(): void
+    {
+        $order = Order::factory()->create();
+
+        $order->delete();
+
+        $this->assertSoftDeleted($order);
+        $this->assertNotNull($order->deleted_at);
+    }
+
+    public function test_order_can_have_reclamations(): void
+    {
+        $order = Order::factory()->create();
+
+        Reclamation::factory()->count(3)->create([
+            'order_id' => $order->id,
+        ]);
+
+        $this->assertCount(3, $order->reclamations);
+    }
+
+    public function test_order_can_have_brigadier(): void
+    {
+        $brigadier = User::factory()->brigadier()->create();
+        $order = Order::factory()->withBrigadier($brigadier)->create();
+
+        $order->refresh();
+
+        $this->assertEquals($brigadier->id, $order->brigadier_id);
+        $this->assertEquals($brigadier->id, $order->brigadier->id);
+    }
+}

+ 296 - 0
tests/Unit/Models/SparePartOrderTest.php

@@ -0,0 +1,296 @@
+<?php
+
+namespace Tests\Unit\Models;
+
+use App\Models\Reservation;
+use App\Models\SparePart;
+use App\Models\SparePartOrder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class SparePartOrderTest extends TestCase
+{
+    use RefreshDatabase;
+
+    public function test_status_names_constant_exists(): void
+    {
+        $this->assertArrayHasKey(SparePartOrder::STATUS_ORDERED, SparePartOrder::STATUS_NAMES);
+        $this->assertArrayHasKey(SparePartOrder::STATUS_IN_STOCK, SparePartOrder::STATUS_NAMES);
+        $this->assertArrayHasKey(SparePartOrder::STATUS_SHIPPED, SparePartOrder::STATUS_NAMES);
+    }
+
+    public function test_status_name_attribute_returns_correct_name(): void
+    {
+        $order = SparePartOrder::factory()
+            ->inStock()
+            ->create();
+
+        $this->assertEquals('На складе', $order->status_name);
+    }
+
+    public function test_available_qty_defaults_to_ordered_quantity_on_create(): void
+    {
+        $sparePart = SparePart::factory()->create();
+
+        $order = SparePartOrder::create([
+            'spare_part_id' => $sparePart->id,
+            'ordered_quantity' => 15,
+            'status' => SparePartOrder::STATUS_IN_STOCK,
+            'with_documents' => false,
+        ]);
+
+        $this->assertEquals(15, $order->available_qty);
+    }
+
+    public function test_year_defaults_to_current_year_on_create(): void
+    {
+        $sparePart = SparePart::factory()->create();
+
+        $order = SparePartOrder::create([
+            'spare_part_id' => $sparePart->id,
+            'ordered_quantity' => 10,
+            'status' => SparePartOrder::STATUS_IN_STOCK,
+            'with_documents' => false,
+        ]);
+
+        $this->assertEquals((int) date('Y'), $order->year);
+    }
+
+    public function test_reserved_qty_calculates_active_reservations(): void
+    {
+        $order = SparePartOrder::factory()
+            ->inStock()
+            ->withQuantity(20)
+            ->create();
+
+        Reservation::factory()
+            ->active()
+            ->withQuantity(5)
+            ->fromOrder($order)
+            ->create();
+
+        Reservation::factory()
+            ->active()
+            ->withQuantity(3)
+            ->fromOrder($order)
+            ->create();
+
+        // Cancelled should not count
+        Reservation::factory()
+            ->cancelled()
+            ->withQuantity(10)
+            ->fromOrder($order)
+            ->create();
+
+        $this->assertEquals(8, $order->reserved_qty);
+    }
+
+    public function test_free_qty_calculates_available_minus_reserved(): void
+    {
+        $order = SparePartOrder::factory()
+            ->inStock()
+            ->withQuantity(15)
+            ->create();
+
+        Reservation::factory()
+            ->active()
+            ->withQuantity(6)
+            ->fromOrder($order)
+            ->create();
+
+        // available: 15, reserved: 6, free: 9
+        $this->assertEquals(9, $order->free_qty);
+    }
+
+    public function test_free_qty_never_goes_negative(): void
+    {
+        $order = SparePartOrder::factory()
+            ->inStock()
+            ->withQuantity(5)
+            ->create();
+
+        // Create reservation larger than available (edge case)
+        Reservation::factory()
+            ->active()
+            ->withQuantity(10)
+            ->fromOrder($order)
+            ->create();
+
+        $this->assertEquals(0, $order->free_qty);
+    }
+
+    public function test_can_reserve_returns_true_when_sufficient_qty(): void
+    {
+        $order = SparePartOrder::factory()
+            ->inStock()
+            ->withQuantity(10)
+            ->create();
+
+        $this->assertTrue($order->canReserve(5));
+        $this->assertTrue($order->canReserve(10));
+    }
+
+    public function test_can_reserve_returns_false_when_insufficient_qty(): void
+    {
+        $order = SparePartOrder::factory()
+            ->inStock()
+            ->withQuantity(10)
+            ->create();
+
+        Reservation::factory()
+            ->active()
+            ->withQuantity(8)
+            ->fromOrder($order)
+            ->create();
+
+        // Only 2 free, trying to reserve 5
+        $this->assertFalse($order->canReserve(5));
+    }
+
+    public function test_is_in_stock_method(): void
+    {
+        $orderInStock = SparePartOrder::factory()->inStock()->create();
+        $orderOrdered = SparePartOrder::factory()->ordered()->create();
+
+        $this->assertTrue($orderInStock->isInStock());
+        $this->assertFalse($orderOrdered->isInStock());
+    }
+
+    public function test_is_ordered_method(): void
+    {
+        $orderOrdered = SparePartOrder::factory()->ordered()->create();
+        $orderInStock = SparePartOrder::factory()->inStock()->create();
+
+        $this->assertTrue($orderOrdered->isOrdered());
+        $this->assertFalse($orderInStock->isOrdered());
+    }
+
+    public function test_is_shipped_method(): void
+    {
+        $orderShipped = SparePartOrder::factory()->shipped()->create();
+        $orderInStock = SparePartOrder::factory()->inStock()->create();
+
+        $this->assertTrue($orderShipped->isShipped());
+        $this->assertFalse($orderInStock->isShipped());
+    }
+
+    public function test_scope_in_stock(): void
+    {
+        SparePartOrder::factory()->inStock()->create();
+        SparePartOrder::factory()->inStock()->create();
+        SparePartOrder::factory()->ordered()->create();
+        SparePartOrder::factory()->shipped()->create();
+
+        $inStockOrders = SparePartOrder::inStock()->get();
+
+        $this->assertCount(2, $inStockOrders);
+    }
+
+    public function test_scope_with_available(): void
+    {
+        SparePartOrder::factory()->inStock()->withQuantity(10)->create();
+        SparePartOrder::factory()->shipped()->create(); // available_qty = 0
+
+        $withAvailable = SparePartOrder::withAvailable()->get();
+
+        $this->assertCount(1, $withAvailable);
+    }
+
+    public function test_scope_with_documents(): void
+    {
+        SparePartOrder::factory()->withDocuments(true)->create();
+        SparePartOrder::factory()->withDocuments(true)->create();
+        SparePartOrder::factory()->withDocuments(false)->create();
+
+        $withDocs = SparePartOrder::withDocuments(true)->get();
+        $withoutDocs = SparePartOrder::withDocuments(false)->get();
+
+        $this->assertCount(2, $withDocs);
+        $this->assertCount(1, $withoutDocs);
+    }
+
+    public function test_scope_for_spare_part(): void
+    {
+        $sparePart1 = SparePart::factory()->create();
+        $sparePart2 = SparePart::factory()->create();
+
+        SparePartOrder::factory()->forSparePart($sparePart1)->create();
+        SparePartOrder::factory()->forSparePart($sparePart1)->create();
+        SparePartOrder::factory()->forSparePart($sparePart2)->create();
+
+        $ordersForPart1 = SparePartOrder::forSparePart($sparePart1->id)->get();
+
+        $this->assertCount(2, $ordersForPart1);
+    }
+
+    public function test_scope_available_for_reservation(): void
+    {
+        $sparePart = SparePart::factory()->create();
+
+        $availableOrder = SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(false)
+            ->withQuantity(10)
+            ->forSparePart($sparePart)
+            ->create(['created_at' => now()->subDay()]);
+
+        // Not matching conditions
+        SparePartOrder::factory()
+            ->ordered() // wrong status
+            ->withDocuments(false)
+            ->forSparePart($sparePart)
+            ->create();
+
+        SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(true) // wrong docs type
+            ->forSparePart($sparePart)
+            ->create();
+
+        SparePartOrder::factory()
+            ->shipped() // no available qty
+            ->withDocuments(false)
+            ->forSparePart($sparePart)
+            ->create();
+
+        $available = SparePartOrder::availableForReservation($sparePart->id, false)->get();
+
+        $this->assertCount(1, $available);
+        $this->assertEquals($availableOrder->id, $available->first()->id);
+    }
+
+    public function test_casts_are_correct(): void
+    {
+        $order = SparePartOrder::factory()
+            ->withDocuments(true)
+            ->withQuantity(10)
+            ->create();
+
+        $order->refresh();
+
+        $this->assertIsBool($order->with_documents);
+        $this->assertIsInt($order->ordered_quantity);
+        $this->assertIsInt($order->available_qty);
+        $this->assertIsInt($order->year);
+    }
+
+    public function test_remaining_quantity_backward_compatibility(): void
+    {
+        $order = SparePartOrder::factory()
+            ->withQuantity(15)
+            ->create();
+
+        // Deprecated attribute should still work
+        $this->assertEquals($order->available_qty, $order->remaining_quantity);
+    }
+
+    public function test_relations_exist(): void
+    {
+        $order = SparePartOrder::factory()->create();
+
+        $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\BelongsTo::class, $order->sparePart());
+        $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\BelongsTo::class, $order->user());
+        $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class, $order->reservations());
+        $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class, $order->movements());
+    }
+}

+ 392 - 0
tests/Unit/Models/SparePartTest.php

@@ -0,0 +1,392 @@
+<?php
+
+namespace Tests\Unit\Models;
+
+use App\Models\Reservation;
+use App\Models\Shortage;
+use App\Models\SparePart;
+use App\Models\SparePartOrder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class SparePartTest extends TestCase
+{
+    use RefreshDatabase;
+
+    public function test_price_accessors_convert_from_kopeks_to_rubles(): void
+    {
+        $sparePart = SparePart::factory()->create([
+            'purchase_price' => 150.50,  // Will be stored as 15050 kopeks
+            'customer_price' => 200.00,
+            'expertise_price' => 180.25,
+        ]);
+
+        $sparePart->refresh();
+
+        $this->assertEquals(150.50, $sparePart->purchase_price);
+        $this->assertEquals(200.00, $sparePart->customer_price);
+        $this->assertEquals(180.25, $sparePart->expertise_price);
+    }
+
+    public function test_price_txt_accessors_format_prices(): void
+    {
+        $sparePart = SparePart::factory()->create([
+            'purchase_price' => 1500.50,
+        ]);
+
+        $sparePart->refresh();
+
+        $this->assertStringContainsString('₽', $sparePart->purchase_price_txt);
+        $this->assertStringContainsString('1', $sparePart->purchase_price_txt);
+        $this->assertStringContainsString('500', $sparePart->purchase_price_txt);
+    }
+
+    public function test_price_txt_returns_dash_for_null_prices(): void
+    {
+        $sparePart = SparePart::factory()->create([
+            'purchase_price' => null,
+        ]);
+
+        $sparePart->refresh();
+
+        $this->assertEquals('-', $sparePart->purchase_price_txt);
+    }
+
+    public function test_physical_stock_without_docs_calculates_correctly(): void
+    {
+        $sparePart = SparePart::factory()->create();
+
+        // Create orders WITHOUT documents
+        SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(false)
+            ->withQuantity(10)
+            ->forSparePart($sparePart)
+            ->create();
+
+        SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(false)
+            ->withQuantity(5)
+            ->forSparePart($sparePart)
+            ->create();
+
+        // Create order WITH documents (should not count)
+        SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(true)
+            ->withQuantity(20)
+            ->forSparePart($sparePart)
+            ->create();
+
+        // Create shipped order (should not count)
+        SparePartOrder::factory()
+            ->shipped()
+            ->withDocuments(false)
+            ->forSparePart($sparePart)
+            ->create();
+
+        $this->assertEquals(15, $sparePart->physical_stock_without_docs);
+    }
+
+    public function test_physical_stock_with_docs_calculates_correctly(): void
+    {
+        $sparePart = SparePart::factory()->create();
+
+        SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(true)
+            ->withQuantity(7)
+            ->forSparePart($sparePart)
+            ->create();
+
+        SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(false)
+            ->withQuantity(10)
+            ->forSparePart($sparePart)
+            ->create();
+
+        $this->assertEquals(7, $sparePart->physical_stock_with_docs);
+    }
+
+    public function test_reserved_without_docs_calculates_correctly(): void
+    {
+        $sparePart = SparePart::factory()->create();
+        $order = SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(false)
+            ->forSparePart($sparePart)
+            ->create();
+
+        // Active reservations WITHOUT documents
+        Reservation::factory()
+            ->active()
+            ->withQuantity(3)
+            ->withDocuments(false)
+            ->fromOrder($order)
+            ->forSparePart($sparePart)
+            ->create();
+
+        Reservation::factory()
+            ->active()
+            ->withQuantity(2)
+            ->withDocuments(false)
+            ->fromOrder($order)
+            ->forSparePart($sparePart)
+            ->create();
+
+        // Cancelled reservation (should not count)
+        Reservation::factory()
+            ->cancelled()
+            ->withQuantity(10)
+            ->withDocuments(false)
+            ->fromOrder($order)
+            ->forSparePart($sparePart)
+            ->create();
+
+        // Reservation WITH documents (should not count)
+        Reservation::factory()
+            ->active()
+            ->withQuantity(5)
+            ->withDocuments(true)
+            ->fromOrder($order)
+            ->forSparePart($sparePart)
+            ->create();
+
+        $this->assertEquals(5, $sparePart->reserved_without_docs);
+    }
+
+    public function test_free_stock_without_docs_calculates_correctly(): void
+    {
+        $sparePart = SparePart::factory()->create();
+        $order = SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(false)
+            ->withQuantity(10)
+            ->forSparePart($sparePart)
+            ->create();
+
+        Reservation::factory()
+            ->active()
+            ->withQuantity(3)
+            ->withDocuments(false)
+            ->fromOrder($order)
+            ->forSparePart($sparePart)
+            ->create();
+
+        // Physical: 10, Reserved: 3, Free: 7
+        $this->assertEquals(7, $sparePart->free_stock_without_docs);
+    }
+
+    public function test_total_physical_stock_sums_both_types(): void
+    {
+        $sparePart = SparePart::factory()->create();
+
+        SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(false)
+            ->withQuantity(10)
+            ->forSparePart($sparePart)
+            ->create();
+
+        SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(true)
+            ->withQuantity(5)
+            ->forSparePart($sparePart)
+            ->create();
+
+        $this->assertEquals(15, $sparePart->total_physical_stock);
+    }
+
+    public function test_total_free_stock_calculates_correctly(): void
+    {
+        $sparePart = SparePart::factory()->create();
+
+        $orderNoDocs = SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(false)
+            ->withQuantity(10)
+            ->forSparePart($sparePart)
+            ->create();
+
+        $orderWithDocs = SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(true)
+            ->withQuantity(8)
+            ->forSparePart($sparePart)
+            ->create();
+
+        Reservation::factory()
+            ->active()
+            ->withQuantity(3)
+            ->withDocuments(false)
+            ->fromOrder($orderNoDocs)
+            ->forSparePart($sparePart)
+            ->create();
+
+        Reservation::factory()
+            ->active()
+            ->withQuantity(2)
+            ->withDocuments(true)
+            ->fromOrder($orderWithDocs)
+            ->forSparePart($sparePart)
+            ->create();
+
+        // Total physical: 18, Total reserved: 5, Total free: 13
+        $this->assertEquals(13, $sparePart->total_free_stock);
+    }
+
+    public function test_quantity_backward_compatibility_attributes(): void
+    {
+        $sparePart = SparePart::factory()->create();
+        $order = SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(false)
+            ->withQuantity(10)
+            ->forSparePart($sparePart)
+            ->create();
+
+        Reservation::factory()
+            ->active()
+            ->withQuantity(3)
+            ->withDocuments(false)
+            ->fromOrder($order)
+            ->forSparePart($sparePart)
+            ->create();
+
+        // Old attributes should return free stock
+        $this->assertEquals($sparePart->free_stock_without_docs, $sparePart->quantity_without_docs);
+        $this->assertEquals($sparePart->free_stock_with_docs, $sparePart->quantity_with_docs);
+        $this->assertEquals($sparePart->total_free_stock, $sparePart->total_quantity);
+    }
+
+    public function test_has_open_shortages_returns_true_when_shortages_exist(): void
+    {
+        $sparePart = SparePart::factory()->create();
+
+        Shortage::factory()
+            ->open()
+            ->forSparePart($sparePart)
+            ->create();
+
+        $this->assertTrue($sparePart->hasOpenShortages());
+    }
+
+    public function test_has_open_shortages_returns_false_when_no_shortages(): void
+    {
+        $sparePart = SparePart::factory()->create();
+
+        $this->assertFalse($sparePart->hasOpenShortages());
+    }
+
+    public function test_has_open_shortages_ignores_closed_shortages(): void
+    {
+        $sparePart = SparePart::factory()->create();
+
+        Shortage::factory()
+            ->closed()
+            ->forSparePart($sparePart)
+            ->create();
+
+        $this->assertFalse($sparePart->hasOpenShortages());
+    }
+
+    public function test_open_shortages_qty_calculates_correctly(): void
+    {
+        $sparePart = SparePart::factory()->create();
+
+        Shortage::factory()
+            ->open()
+            ->withQuantities(10, 3) // missing 7
+            ->forSparePart($sparePart)
+            ->create();
+
+        Shortage::factory()
+            ->open()
+            ->withQuantities(5, 2) // missing 3
+            ->forSparePart($sparePart)
+            ->create();
+
+        Shortage::factory()
+            ->closed()
+            ->withQuantities(20, 0) // missing 20 but closed
+            ->forSparePart($sparePart)
+            ->create();
+
+        $this->assertEquals(10, $sparePart->open_shortages_qty);
+    }
+
+    public function test_is_below_min_stock_returns_true_when_below(): void
+    {
+        $sparePart = SparePart::factory()
+            ->withMinStock(10)
+            ->create();
+
+        SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(false)
+            ->withQuantity(5)
+            ->forSparePart($sparePart)
+            ->create();
+
+        $this->assertTrue($sparePart->isBelowMinStock());
+    }
+
+    public function test_is_below_min_stock_returns_false_when_at_or_above(): void
+    {
+        $sparePart = SparePart::factory()
+            ->withMinStock(10)
+            ->create();
+
+        SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(false)
+            ->withQuantity(15)
+            ->forSparePart($sparePart)
+            ->create();
+
+        $this->assertFalse($sparePart->isBelowMinStock());
+    }
+
+    public function test_get_free_stock_helper_method(): void
+    {
+        $sparePart = SparePart::factory()->create();
+
+        SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(false)
+            ->withQuantity(10)
+            ->forSparePart($sparePart)
+            ->create();
+
+        SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(true)
+            ->withQuantity(5)
+            ->forSparePart($sparePart)
+            ->create();
+
+        $this->assertEquals(10, $sparePart->getFreeStock(false));
+        $this->assertEquals(5, $sparePart->getFreeStock(true));
+    }
+
+    public function test_pricing_codes_list_attribute(): void
+    {
+        $sparePart = SparePart::factory()->create();
+
+        // Assuming pricing codes relationship works
+        $this->assertIsString($sparePart->pricing_codes_list);
+    }
+
+    public function test_relations_exist(): void
+    {
+        $sparePart = SparePart::factory()->create();
+
+        $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class, $sparePart->orders());
+        $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class, $sparePart->reservations());
+        $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class, $sparePart->shortages());
+        $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class, $sparePart->movements());
+    }
+}

+ 494 - 0
tests/Unit/Services/ShortageServiceTest.php

@@ -0,0 +1,494 @@
+<?php
+
+namespace Tests\Unit\Services;
+
+use App\Models\InventoryMovement;
+use App\Models\Reclamation;
+use App\Models\Reservation;
+use App\Models\Shortage;
+use App\Models\SparePart;
+use App\Models\SparePartOrder;
+use App\Services\ShortageService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class ShortageServiceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    private ShortageService $service;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        $this->service = new ShortageService();
+    }
+
+    public function test_process_part_order_receipt_covers_open_shortages(): void
+    {
+        // Arrange
+        $sparePart = SparePart::factory()->create();
+        $reclamation = Reclamation::factory()->create();
+
+        // Create open shortage
+        $shortage = Shortage::factory()
+            ->open()
+            ->withQuantities(10, 3) // required 10, reserved 3, missing 7
+            ->withDocuments(false)
+            ->forSparePart($sparePart)
+            ->forReclamation($reclamation)
+            ->create();
+
+        // Create new order arriving in stock
+        $order = SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(false)
+            ->withQuantity(5)
+            ->forSparePart($sparePart)
+            ->create();
+
+        // Act
+        $results = $this->service->processPartOrderReceipt($order);
+
+        // Assert
+        $this->assertCount(1, $results);
+        $this->assertEquals($shortage->id, $results[0]['shortage_id']);
+        $this->assertEquals($reclamation->id, $results[0]['reclamation_id']);
+        $this->assertEquals(5, $results[0]['covered_qty']);
+        $this->assertEquals(2, $results[0]['remaining_missing']); // 7 - 5 = 2
+
+        // Check reservation was created
+        $this->assertDatabaseHas('reservations', [
+            'spare_part_id' => $sparePart->id,
+            'spare_part_order_id' => $order->id,
+            'reclamation_id' => $reclamation->id,
+            'reserved_qty' => 5,
+            'status' => Reservation::STATUS_ACTIVE,
+        ]);
+
+        // Check shortage was updated
+        $shortage->refresh();
+        $this->assertEquals(8, $shortage->reserved_qty); // 3 + 5
+        $this->assertEquals(2, $shortage->missing_qty);
+        $this->assertEquals(Shortage::STATUS_OPEN, $shortage->status);
+    }
+
+    public function test_process_part_order_receipt_closes_shortage_when_fully_covered(): void
+    {
+        // Arrange
+        $sparePart = SparePart::factory()->create();
+        $reclamation = Reclamation::factory()->create();
+
+        $shortage = Shortage::factory()
+            ->open()
+            ->withQuantities(10, 7) // required 10, reserved 7, missing 3
+            ->withDocuments(false)
+            ->forSparePart($sparePart)
+            ->forReclamation($reclamation)
+            ->create();
+
+        $order = SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(false)
+            ->withQuantity(5) // More than enough
+            ->forSparePart($sparePart)
+            ->create();
+
+        // Act
+        $results = $this->service->processPartOrderReceipt($order);
+
+        // Assert
+        $this->assertEquals(3, $results[0]['covered_qty']); // Only needed 3
+        $this->assertEquals(0, $results[0]['remaining_missing']);
+        $this->assertTrue($results[0]['shortage_closed']);
+
+        $shortage->refresh();
+        $this->assertEquals(Shortage::STATUS_CLOSED, $shortage->status);
+        $this->assertEquals(0, $shortage->missing_qty);
+    }
+
+    public function test_process_part_order_receipt_respects_fifo_for_multiple_shortages(): void
+    {
+        // Arrange
+        $sparePart = SparePart::factory()->create();
+        $reclamation1 = Reclamation::factory()->create();
+        $reclamation2 = Reclamation::factory()->create();
+
+        // Older shortage
+        $shortage1 = Shortage::factory()
+            ->open()
+            ->withQuantities(5, 0)
+            ->withDocuments(false)
+            ->forSparePart($sparePart)
+            ->forReclamation($reclamation1)
+            ->create(['created_at' => now()->subDays(2)]);
+
+        // Newer shortage
+        $shortage2 = Shortage::factory()
+            ->open()
+            ->withQuantities(5, 0)
+            ->withDocuments(false)
+            ->forSparePart($sparePart)
+            ->forReclamation($reclamation2)
+            ->create(['created_at' => now()->subDay()]);
+
+        // Order with 7 items (enough for first, partial for second)
+        $order = SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(false)
+            ->withQuantity(7)
+            ->forSparePart($sparePart)
+            ->create();
+
+        // Act
+        $results = $this->service->processPartOrderReceipt($order);
+
+        // Assert
+        $this->assertCount(2, $results);
+
+        // First (older) shortage should be fully covered
+        $this->assertEquals($shortage1->id, $results[0]['shortage_id']);
+        $this->assertEquals(5, $results[0]['covered_qty']);
+        $this->assertTrue($results[0]['shortage_closed']);
+
+        // Second (newer) shortage should be partially covered
+        $this->assertEquals($shortage2->id, $results[1]['shortage_id']);
+        $this->assertEquals(2, $results[1]['covered_qty']);
+        $this->assertFalse($results[1]['shortage_closed']);
+    }
+
+    public function test_process_part_order_receipt_respects_with_documents_flag(): void
+    {
+        // Arrange
+        $sparePart = SparePart::factory()->create();
+        $reclamation = Reclamation::factory()->create();
+
+        // Shortage WITHOUT documents
+        $shortageNoDocs = Shortage::factory()
+            ->open()
+            ->withQuantities(5, 0)
+            ->withDocuments(false)
+            ->forSparePart($sparePart)
+            ->forReclamation($reclamation)
+            ->create();
+
+        // Shortage WITH documents
+        $shortageWithDocs = Shortage::factory()
+            ->open()
+            ->withQuantities(5, 0)
+            ->withDocuments(true)
+            ->forSparePart($sparePart)
+            ->forReclamation($reclamation)
+            ->create();
+
+        // Order WITH documents
+        $order = SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(true)
+            ->withQuantity(10)
+            ->forSparePart($sparePart)
+            ->create();
+
+        // Act
+        $results = $this->service->processPartOrderReceipt($order);
+
+        // Assert - should only cover shortage WITH documents
+        $this->assertCount(1, $results);
+        $this->assertEquals($shortageWithDocs->id, $results[0]['shortage_id']);
+
+        $shortageNoDocs->refresh();
+        $this->assertEquals(5, $shortageNoDocs->missing_qty); // Unchanged
+    }
+
+    public function test_process_part_order_receipt_ignores_non_in_stock_orders(): void
+    {
+        // Arrange
+        $sparePart = SparePart::factory()->create();
+        $reclamation = Reclamation::factory()->create();
+
+        Shortage::factory()
+            ->open()
+            ->withQuantities(5, 0)
+            ->withDocuments(false)
+            ->forSparePart($sparePart)
+            ->forReclamation($reclamation)
+            ->create();
+
+        $order = SparePartOrder::factory()
+            ->ordered() // Not in stock yet
+            ->withDocuments(false)
+            ->withQuantity(10)
+            ->forSparePart($sparePart)
+            ->create();
+
+        // Act
+        $results = $this->service->processPartOrderReceipt($order);
+
+        // Assert
+        $this->assertEmpty($results);
+    }
+
+    public function test_get_open_shortages_returns_only_open(): void
+    {
+        // Arrange
+        $sparePart = SparePart::factory()->create();
+        $reclamation = Reclamation::factory()->create();
+
+        Shortage::factory()
+            ->open()
+            ->forSparePart($sparePart)
+            ->forReclamation($reclamation)
+            ->create();
+
+        Shortage::factory()
+            ->closed()
+            ->forSparePart($sparePart)
+            ->forReclamation($reclamation)
+            ->create();
+
+        // Act
+        $shortages = $this->service->getOpenShortages();
+
+        // Assert
+        $this->assertCount(1, $shortages);
+        $this->assertEquals(Shortage::STATUS_OPEN, $shortages->first()->status);
+    }
+
+    public function test_get_shortages_for_spare_part_filters_correctly(): void
+    {
+        // Arrange
+        $sparePart1 = SparePart::factory()->create();
+        $sparePart2 = SparePart::factory()->create();
+        $reclamation = Reclamation::factory()->create();
+
+        Shortage::factory()
+            ->open()
+            ->withDocuments(false)
+            ->forSparePart($sparePart1)
+            ->forReclamation($reclamation)
+            ->create();
+
+        Shortage::factory()
+            ->open()
+            ->withDocuments(true)
+            ->forSparePart($sparePart1)
+            ->forReclamation($reclamation)
+            ->create();
+
+        Shortage::factory()
+            ->open()
+            ->forSparePart($sparePart2)
+            ->forReclamation($reclamation)
+            ->create();
+
+        // Act
+        $allForPart1 = $this->service->getShortagesForSparePart($sparePart1->id);
+        $withDocsOnly = $this->service->getShortagesForSparePart($sparePart1->id, true);
+
+        // Assert
+        $this->assertCount(2, $allForPart1);
+        $this->assertCount(1, $withDocsOnly);
+    }
+
+    public function test_get_total_missing_calculates_correctly(): void
+    {
+        // Arrange
+        $sparePart = SparePart::factory()->create();
+        $reclamation1 = Reclamation::factory()->create();
+        $reclamation2 = Reclamation::factory()->create();
+
+        Shortage::factory()
+            ->open()
+            ->withQuantities(10, 3) // missing 7
+            ->withDocuments(false)
+            ->forSparePart($sparePart)
+            ->forReclamation($reclamation1)
+            ->create();
+
+        Shortage::factory()
+            ->open()
+            ->withQuantities(8, 5) // missing 3
+            ->withDocuments(false)
+            ->forSparePart($sparePart)
+            ->forReclamation($reclamation2)
+            ->create();
+
+        Shortage::factory()
+            ->open()
+            ->withQuantities(5, 0) // missing 5 but WITH documents
+            ->withDocuments(true)
+            ->forSparePart($sparePart)
+            ->forReclamation($reclamation1)
+            ->create();
+
+        // Act
+        $totalAll = $this->service->getTotalMissing($sparePart->id);
+        $totalNoDocs = $this->service->getTotalMissing($sparePart->id, false);
+        $totalWithDocs = $this->service->getTotalMissing($sparePart->id, true);
+
+        // Assert
+        $this->assertEquals(15, $totalAll); // 7 + 3 + 5
+        $this->assertEquals(10, $totalNoDocs); // 7 + 3
+        $this->assertEquals(5, $totalWithDocs);
+    }
+
+    public function test_get_critical_shortages_returns_spare_parts_with_shortages(): void
+    {
+        // Arrange
+        $sparePartWithShortage = SparePart::factory()->create();
+        $sparePartWithoutShortage = SparePart::factory()->create();
+        $reclamation = Reclamation::factory()->create();
+
+        Shortage::factory()
+            ->open()
+            ->withQuantities(10, 3)
+            ->forSparePart($sparePartWithShortage)
+            ->forReclamation($reclamation)
+            ->create();
+
+        // Act
+        $criticalShortages = $this->service->getCriticalShortages();
+
+        // Assert
+        $this->assertCount(1, $criticalShortages);
+        $this->assertEquals($sparePartWithShortage->id, $criticalShortages->first()->id);
+        $this->assertNotNull($criticalShortages->first()->shortage_details);
+    }
+
+    public function test_close_shortage_manually(): void
+    {
+        // Arrange
+        $shortage = Shortage::factory()
+            ->open()
+            ->create();
+
+        // Act
+        $result = $this->service->closeShortage($shortage, 'Cancelled by user');
+
+        // Assert
+        $this->assertTrue($result);
+        $shortage->refresh();
+        $this->assertEquals(Shortage::STATUS_CLOSED, $shortage->status);
+        $this->assertStringContainsString('Cancelled by user', $shortage->note);
+    }
+
+    public function test_close_shortage_returns_false_if_already_closed(): void
+    {
+        // Arrange
+        $shortage = Shortage::factory()
+            ->closed()
+            ->create();
+
+        // Act
+        $result = $this->service->closeShortage($shortage);
+
+        // Assert
+        $this->assertFalse($result);
+    }
+
+    public function test_calculate_order_quantity_returns_total_missing(): void
+    {
+        // Arrange
+        $sparePart = SparePart::factory()->create();
+        $reclamation = Reclamation::factory()->create();
+
+        Shortage::factory()
+            ->open()
+            ->withQuantities(10, 3) // missing 7
+            ->withDocuments(false)
+            ->forSparePart($sparePart)
+            ->forReclamation($reclamation)
+            ->create();
+
+        Shortage::factory()
+            ->open()
+            ->withQuantities(5, 2) // missing 3
+            ->withDocuments(false)
+            ->forSparePart($sparePart)
+            ->forReclamation($reclamation)
+            ->create();
+
+        // Act
+        $orderQty = $this->service->calculateOrderQuantity($sparePart->id, false);
+
+        // Assert
+        $this->assertEquals(10, $orderQty); // 7 + 3
+    }
+
+    public function test_process_part_order_receipt_considers_existing_reservations(): void
+    {
+        // Arrange
+        $sparePart = SparePart::factory()->create();
+        $reclamation1 = Reclamation::factory()->create();
+        $reclamation2 = Reclamation::factory()->create();
+
+        $shortage = Shortage::factory()
+            ->open()
+            ->withQuantities(5, 0)
+            ->withDocuments(false)
+            ->forSparePart($sparePart)
+            ->forReclamation($reclamation2)
+            ->create();
+
+        $order = SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(false)
+            ->withQuantity(10)
+            ->forSparePart($sparePart)
+            ->create();
+
+        // Create existing reservation from same order (uses 7 of 10)
+        Reservation::factory()
+            ->active()
+            ->withQuantity(7)
+            ->fromOrder($order)
+            ->forReclamation($reclamation1)
+            ->create();
+
+        // Act - only 3 should be available for shortage
+        $results = $this->service->processPartOrderReceipt($order);
+
+        // Assert
+        $this->assertCount(1, $results);
+        $this->assertEquals(3, $results[0]['covered_qty']); // Only 3 available
+        $this->assertEquals(2, $results[0]['remaining_missing']);
+    }
+
+    public function test_recalculate_for_reclamation_updates_all_shortages(): void
+    {
+        // Arrange
+        $sparePart = SparePart::factory()->create();
+        $reclamation = Reclamation::factory()->create();
+        $order = SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(false)
+            ->forSparePart($sparePart)
+            ->create();
+
+        $shortage = Shortage::factory()
+            ->open()
+            ->withQuantities(10, 0)
+            ->withDocuments(false)
+            ->forSparePart($sparePart)
+            ->forReclamation($reclamation)
+            ->create();
+
+        // Create reservation that shortage doesn't know about
+        Reservation::factory()
+            ->active()
+            ->withQuantity(5)
+            ->withDocuments(false)
+            ->fromOrder($order)
+            ->forSparePart($sparePart)
+            ->forReclamation($reclamation)
+            ->create();
+
+        // Act
+        $this->service->recalculateForReclamation($reclamation->id);
+
+        // Assert
+        $shortage->refresh();
+        $this->assertEquals(5, $shortage->reserved_qty);
+        $this->assertEquals(5, $shortage->missing_qty);
+    }
+}

+ 521 - 0
tests/Unit/Services/SparePartReservationServiceTest.php

@@ -0,0 +1,521 @@
+<?php
+
+namespace Tests\Unit\Services;
+
+use App\Models\InventoryMovement;
+use App\Models\Reclamation;
+use App\Models\Reservation;
+use App\Models\Shortage;
+use App\Models\SparePart;
+use App\Models\SparePartOrder;
+use App\Services\ReservationResult;
+use App\Services\ShortageService;
+use App\Services\SparePartReservationService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class SparePartReservationServiceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    private SparePartReservationService $service;
+    private ShortageService $shortageService;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->shortageService = new ShortageService();
+        $this->service = new SparePartReservationService($this->shortageService);
+    }
+
+    public function test_reserve_with_sufficient_stock_creates_reservation(): void
+    {
+        // Arrange
+        $sparePart = SparePart::factory()->create();
+        $reclamation = Reclamation::factory()->create();
+        $order = SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(false)
+            ->withQuantity(10)
+            ->forSparePart($sparePart)
+            ->create();
+
+        // Act
+        $result = $this->service->reserve(
+            sparePartId: $sparePart->id,
+            quantity: 5,
+            withDocuments: false,
+            reclamationId: $reclamation->id
+        );
+
+        // Assert
+        $this->assertInstanceOf(ReservationResult::class, $result);
+        $this->assertEquals(5, $result->reserved);
+        $this->assertEquals(0, $result->missing);
+        $this->assertTrue($result->isFullyReserved());
+        $this->assertFalse($result->hasShortage());
+        $this->assertCount(1, $result->reservations);
+
+        // Check reservation was created in DB
+        $this->assertDatabaseHas('reservations', [
+            'spare_part_id' => $sparePart->id,
+            'spare_part_order_id' => $order->id,
+            'reclamation_id' => $reclamation->id,
+            'reserved_qty' => 5,
+            'with_documents' => false,
+            'status' => Reservation::STATUS_ACTIVE,
+        ]);
+
+        // Check inventory movement was created
+        $this->assertDatabaseHas('inventory_movements', [
+            'spare_part_id' => $sparePart->id,
+            'spare_part_order_id' => $order->id,
+            'qty' => 5,
+            'movement_type' => InventoryMovement::TYPE_RESERVE,
+            'source_type' => InventoryMovement::SOURCE_RECLAMATION,
+            'source_id' => $reclamation->id,
+        ]);
+    }
+
+    public function test_reserve_with_partial_stock_creates_shortage(): void
+    {
+        // Arrange
+        $sparePart = SparePart::factory()->create();
+        $reclamation = Reclamation::factory()->create();
+        SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(false)
+            ->withQuantity(3)
+            ->forSparePart($sparePart)
+            ->create();
+
+        // Act
+        $result = $this->service->reserve(
+            sparePartId: $sparePart->id,
+            quantity: 5,
+            withDocuments: false,
+            reclamationId: $reclamation->id
+        );
+
+        // Assert
+        $this->assertEquals(3, $result->reserved);
+        $this->assertEquals(2, $result->missing);
+        $this->assertFalse($result->isFullyReserved());
+        $this->assertTrue($result->hasShortage());
+
+        // Check shortage was created
+        $this->assertDatabaseHas('shortages', [
+            'spare_part_id' => $sparePart->id,
+            'reclamation_id' => $reclamation->id,
+            'with_documents' => false,
+            'required_qty' => 5,
+            'reserved_qty' => 3,
+            'missing_qty' => 2,
+            'status' => Shortage::STATUS_OPEN,
+        ]);
+    }
+
+    public function test_reserve_with_no_stock_creates_full_shortage(): void
+    {
+        // Arrange
+        $sparePart = SparePart::factory()->create();
+        $reclamation = Reclamation::factory()->create();
+        // No orders created = no stock
+
+        // Act
+        $result = $this->service->reserve(
+            sparePartId: $sparePart->id,
+            quantity: 5,
+            withDocuments: false,
+            reclamationId: $reclamation->id
+        );
+
+        // Assert
+        $this->assertEquals(0, $result->reserved);
+        $this->assertEquals(5, $result->missing);
+        $this->assertFalse($result->isFullyReserved());
+        $this->assertTrue($result->hasShortage());
+        $this->assertEmpty($result->reservations);
+
+        // Check full shortage was created
+        $this->assertDatabaseHas('shortages', [
+            'spare_part_id' => $sparePart->id,
+            'reclamation_id' => $reclamation->id,
+            'required_qty' => 5,
+            'reserved_qty' => 0,
+            'missing_qty' => 5,
+            'status' => Shortage::STATUS_OPEN,
+        ]);
+    }
+
+    public function test_reserve_respects_fifo_order_for_multiple_batches(): void
+    {
+        // Arrange
+        $sparePart = SparePart::factory()->create();
+        $reclamation = Reclamation::factory()->create();
+
+        // Create first batch (older)
+        $olderOrder = SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(false)
+            ->withQuantity(3)
+            ->forSparePart($sparePart)
+            ->create(['created_at' => now()->subDays(2)]);
+
+        // Create second batch (newer)
+        $newerOrder = SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(false)
+            ->withQuantity(5)
+            ->forSparePart($sparePart)
+            ->create(['created_at' => now()->subDay()]);
+
+        // Act - reserve 5 items (should use 3 from older + 2 from newer)
+        $result = $this->service->reserve(
+            sparePartId: $sparePart->id,
+            quantity: 5,
+            withDocuments: false,
+            reclamationId: $reclamation->id
+        );
+
+        // Assert
+        $this->assertEquals(5, $result->reserved);
+        $this->assertCount(2, $result->reservations);
+
+        // Check FIFO: first batch fully used
+        $this->assertDatabaseHas('reservations', [
+            'spare_part_order_id' => $olderOrder->id,
+            'reserved_qty' => 3,
+        ]);
+
+        // Check FIFO: second batch partially used
+        $this->assertDatabaseHas('reservations', [
+            'spare_part_order_id' => $newerOrder->id,
+            'reserved_qty' => 2,
+        ]);
+    }
+
+    public function test_reserve_respects_with_documents_flag(): void
+    {
+        // Arrange
+        $sparePart = SparePart::factory()->create();
+        $reclamation = Reclamation::factory()->create();
+
+        // Create batch WITHOUT documents
+        SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(false)
+            ->withQuantity(10)
+            ->forSparePart($sparePart)
+            ->create();
+
+        // Create batch WITH documents
+        $orderWithDocs = SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(true)
+            ->withQuantity(10)
+            ->forSparePart($sparePart)
+            ->create();
+
+        // Act - reserve WITH documents
+        $result = $this->service->reserve(
+            sparePartId: $sparePart->id,
+            quantity: 5,
+            withDocuments: true,
+            reclamationId: $reclamation->id
+        );
+
+        // Assert - should only use batch with documents
+        $this->assertEquals(5, $result->reserved);
+        $this->assertCount(1, $result->reservations);
+
+        $this->assertDatabaseHas('reservations', [
+            'spare_part_order_id' => $orderWithDocs->id,
+            'with_documents' => true,
+        ]);
+    }
+
+    public function test_reserve_considers_existing_reservations(): void
+    {
+        // Arrange
+        $sparePart = SparePart::factory()->create();
+        $reclamation1 = Reclamation::factory()->create();
+        $reclamation2 = Reclamation::factory()->create();
+
+        $order = SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(false)
+            ->withQuantity(10)
+            ->forSparePart($sparePart)
+            ->create();
+
+        // Create existing active reservation for 7 items
+        Reservation::factory()
+            ->active()
+            ->withQuantity(7)
+            ->fromOrder($order)
+            ->forReclamation($reclamation1)
+            ->create();
+
+        // Act - try to reserve 5 more (only 3 available)
+        $result = $this->service->reserve(
+            sparePartId: $sparePart->id,
+            quantity: 5,
+            withDocuments: false,
+            reclamationId: $reclamation2->id
+        );
+
+        // Assert
+        $this->assertEquals(3, $result->reserved);
+        $this->assertEquals(2, $result->missing);
+        $this->assertTrue($result->hasShortage());
+    }
+
+    public function test_cancel_for_reclamation_cancels_all_reservations(): void
+    {
+        // Arrange
+        $sparePart = SparePart::factory()->create();
+        $reclamation = Reclamation::factory()->create();
+        $order = SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(false)
+            ->withQuantity(10)
+            ->forSparePart($sparePart)
+            ->create();
+
+        // Create reservations
+        Reservation::factory()
+            ->active()
+            ->withQuantity(3)
+            ->fromOrder($order)
+            ->forReclamation($reclamation)
+            ->create();
+
+        Reservation::factory()
+            ->active()
+            ->withQuantity(2)
+            ->fromOrder($order)
+            ->forReclamation($reclamation)
+            ->create();
+
+        // Create shortage
+        Shortage::factory()
+            ->open()
+            ->withQuantities(10, 5)
+            ->forSparePart($sparePart)
+            ->forReclamation($reclamation)
+            ->withDocuments(false)
+            ->create();
+
+        // Act
+        $cancelled = $this->service->cancelForReclamation($reclamation->id);
+
+        // Assert
+        $this->assertEquals(5, $cancelled);
+
+        // Check all reservations are cancelled
+        $this->assertDatabaseMissing('reservations', [
+            'reclamation_id' => $reclamation->id,
+            'status' => Reservation::STATUS_ACTIVE,
+        ]);
+
+        $this->assertEquals(2, Reservation::where('reclamation_id', $reclamation->id)
+            ->where('status', Reservation::STATUS_CANCELLED)->count());
+
+        // Check shortage is closed
+        $this->assertDatabaseHas('shortages', [
+            'reclamation_id' => $reclamation->id,
+            'status' => Shortage::STATUS_CLOSED,
+        ]);
+    }
+
+    public function test_cancel_for_reclamation_filters_by_spare_part(): void
+    {
+        // Arrange
+        $sparePart1 = SparePart::factory()->create();
+        $sparePart2 = SparePart::factory()->create();
+        $reclamation = Reclamation::factory()->create();
+
+        $order1 = SparePartOrder::factory()
+            ->inStock()
+            ->forSparePart($sparePart1)
+            ->create();
+
+        $order2 = SparePartOrder::factory()
+            ->inStock()
+            ->forSparePart($sparePart2)
+            ->create();
+
+        $reservation1 = Reservation::factory()
+            ->active()
+            ->withQuantity(3)
+            ->fromOrder($order1)
+            ->forReclamation($reclamation)
+            ->create();
+
+        $reservation2 = Reservation::factory()
+            ->active()
+            ->withQuantity(5)
+            ->fromOrder($order2)
+            ->forReclamation($reclamation)
+            ->create();
+
+        // Act - cancel only for sparePart1
+        $cancelled = $this->service->cancelForReclamation(
+            $reclamation->id,
+            sparePartId: $sparePart1->id
+        );
+
+        // Assert
+        $this->assertEquals(3, $cancelled);
+
+        $this->assertDatabaseHas('reservations', [
+            'id' => $reservation1->id,
+            'status' => Reservation::STATUS_CANCELLED,
+        ]);
+
+        $this->assertDatabaseHas('reservations', [
+            'id' => $reservation2->id,
+            'status' => Reservation::STATUS_ACTIVE,
+        ]);
+    }
+
+    public function test_get_reservations_for_reclamation_returns_active_only(): void
+    {
+        // Arrange
+        $sparePart = SparePart::factory()->create();
+        $reclamation = Reclamation::factory()->create();
+        $order = SparePartOrder::factory()
+            ->inStock()
+            ->forSparePart($sparePart)
+            ->create();
+
+        Reservation::factory()
+            ->active()
+            ->fromOrder($order)
+            ->forReclamation($reclamation)
+            ->create();
+
+        Reservation::factory()
+            ->cancelled()
+            ->fromOrder($order)
+            ->forReclamation($reclamation)
+            ->create();
+
+        Reservation::factory()
+            ->issued()
+            ->fromOrder($order)
+            ->forReclamation($reclamation)
+            ->create();
+
+        // Act
+        $reservations = $this->service->getReservationsForReclamation($reclamation->id);
+
+        // Assert
+        $this->assertCount(1, $reservations);
+        $this->assertEquals(Reservation::STATUS_ACTIVE, $reservations->first()->status);
+    }
+
+    public function test_adjust_reservation_increases_quantity(): void
+    {
+        // Arrange
+        $sparePart = SparePart::factory()->create();
+        $reclamation = Reclamation::factory()->create();
+        $order = SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(false)
+            ->withQuantity(20)
+            ->forSparePart($sparePart)
+            ->create();
+
+        // Initial reservation of 5
+        Reservation::factory()
+            ->active()
+            ->withQuantity(5)
+            ->withDocuments(false)
+            ->fromOrder($order)
+            ->forReclamation($reclamation)
+            ->create();
+
+        // Act - increase to 10
+        $result = $this->service->adjustReservation(
+            reclamationId: $reclamation->id,
+            sparePartId: $sparePart->id,
+            withDocuments: false,
+            newQuantity: 10
+        );
+
+        // Assert - should have reserved additional 5
+        $this->assertEquals(5, $result->reserved);
+        $this->assertEquals(0, $result->missing);
+
+        $totalReserved = Reservation::where('reclamation_id', $reclamation->id)
+            ->where('spare_part_id', $sparePart->id)
+            ->where('status', Reservation::STATUS_ACTIVE)
+            ->sum('reserved_qty');
+
+        $this->assertEquals(10, $totalReserved);
+    }
+
+    public function test_adjust_reservation_decreases_quantity(): void
+    {
+        // Arrange
+        $sparePart = SparePart::factory()->create();
+        $reclamation = Reclamation::factory()->create();
+        $order = SparePartOrder::factory()
+            ->inStock()
+            ->withDocuments(false)
+            ->withQuantity(20)
+            ->forSparePart($sparePart)
+            ->create();
+
+        // Initial reservation of 10
+        Reservation::factory()
+            ->active()
+            ->withQuantity(10)
+            ->withDocuments(false)
+            ->fromOrder($order)
+            ->forReclamation($reclamation)
+            ->create();
+
+        // Act - decrease to 3
+        $result = $this->service->adjustReservation(
+            reclamationId: $reclamation->id,
+            sparePartId: $sparePart->id,
+            withDocuments: false,
+            newQuantity: 3
+        );
+
+        // Assert
+        $this->assertEquals(3, $result->reserved);
+        $this->assertEquals(0, $result->missing);
+    }
+
+    public function test_cancel_reservation_does_not_cancel_non_active(): void
+    {
+        // Arrange
+        $sparePart = SparePart::factory()->create();
+        $reclamation = Reclamation::factory()->create();
+        $order = SparePartOrder::factory()
+            ->inStock()
+            ->forSparePart($sparePart)
+            ->create();
+
+        $reservation = Reservation::factory()
+            ->issued()
+            ->fromOrder($order)
+            ->forReclamation($reclamation)
+            ->create();
+
+        // Act
+        $result = $this->service->cancelReservation($reservation);
+
+        // Assert
+        $this->assertFalse($result);
+        $this->assertDatabaseHas('reservations', [
+            'id' => $reservation->id,
+            'status' => Reservation::STATUS_ISSUED,
+        ]);
+    }
+}