Bläddra i källkod

implemented stock module

Alexander Musikhin 3 dagar sedan
förälder
incheckning
36e324de52

+ 154 - 0
app/Http/Controllers/StockController.php

@@ -0,0 +1,154 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Controllers;
+
+use App\Http\Requests\ReserveStockRequest;
+use App\Models\CommonCatalogItem;
+use App\Models\StockOrder;
+use App\Models\StockReservation;
+use App\Models\User;
+use App\Services\StockInventoryService;
+use DomainException;
+use Illuminate\Contracts\View\View;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+
+class StockController extends Controller
+{
+    public function __construct(private readonly StockInventoryService $inventoryService) {}
+
+    public function index(Request $request): View
+    {
+        $search = trim((string) $request->query('search', ''));
+        $kind = trim((string) $request->query('kind', ''));
+        $status = trim((string) $request->query('status', ''));
+        $perPage = $this->perPage($request);
+        $items = CommonCatalogItem::query()
+            ->whereHas('stockOrders')
+            ->with(['imageFile', 'latestStockOrder'])
+            ->withSum([
+                'stockOrders as physical_quantity' => fn (Builder $query) => $query->where('status', StockOrder::STATUS_IN_STOCK),
+            ], 'available_quantity')
+            ->withSum([
+                'stockReservations as reserved_quantity' => fn (Builder $query) => $query->active(),
+            ], 'quantity')
+            ->withSum([
+                'stockOrders as ordered_stock_quantity' => fn (Builder $query) => $query->where('status', StockOrder::STATUS_ORDERED),
+            ], 'ordered_quantity')
+            ->when($search !== '', function (Builder $query) use ($search): void {
+                $query->where(function (Builder $query) use ($search): void {
+                    $query->where('article', 'like', "%{$search}%")
+                        ->orWhere('calculator_name', 'like', "%{$search}%")
+                        ->orWhere('kind', 'like', "%{$search}%");
+                });
+            })
+            ->when($kind !== '', fn (Builder $query) => $query->where('kind', $kind))
+            ->when(array_key_exists($status, StockOrder::STATUS_NAMES), fn (Builder $query) => $query
+                ->whereHas('stockOrders', fn (Builder $query) => $query->where('status', $status)))
+            ->orderBy('article')
+            ->paginate($perPage)
+            ->withQueryString();
+
+        return view('stock.index', [
+            'active' => 'stock',
+            'items' => $items,
+            'search' => $search,
+            'kind' => $kind,
+            'status' => $status,
+            'kinds' => CommonCatalogItem::query()
+                ->whereHas('stockOrders')
+                ->whereNotNull('kind')
+                ->where('kind', '<>', '')
+                ->distinct()
+                ->orderBy('kind')
+                ->pluck('kind'),
+            'statuses' => StockOrder::STATUS_NAMES,
+            'per_page' => $perPage,
+        ]);
+    }
+
+    public function show(CommonCatalogItem $item): View
+    {
+        $item->load([
+            'imageFile',
+            'stockOrders' => fn ($query) => $query
+                ->withSum(['reservations as active_reservations_sum_quantity' => fn ($query) => $query->active()], 'quantity')
+                ->orderByDesc('created_at'),
+            'stockReservations' => fn ($query) => $query
+                ->with(['manager', 'order'])
+                ->orderByDesc('created_at'),
+            'stockMovements' => fn ($query) => $query
+                ->with(['user', 'order'])
+                ->orderByDesc('created_at'),
+        ]);
+
+        return view('stock.show', [
+            'active' => 'stock',
+            'item' => $item,
+            'summary' => $this->inventoryService->summary($item),
+            'managers' => User::query()->orderBy('name')->get(),
+        ]);
+    }
+
+    public function reserve(ReserveStockRequest $request, CommonCatalogItem $item): RedirectResponse
+    {
+        /** @var User $actor */
+        $actor = $request->user();
+        $manager = User::query()->findOrFail((int) $request->validated('manager_id'));
+
+        try {
+            $this->inventoryService->reserve(
+                $item,
+                $manager,
+                (int) $request->validated('quantity'),
+                $request->validated('note'),
+                $actor,
+            );
+
+            return back()->with('success', 'Позиция забронирована.');
+        } catch (DomainException $exception) {
+            return back()->withErrors(['quantity' => $exception->getMessage()])->withInput();
+        }
+    }
+
+    public function issue(Request $request, StockReservation $reservation): RedirectResponse
+    {
+        /** @var User $actor */
+        $actor = $request->user();
+
+        try {
+            $this->inventoryService->issue($reservation, $actor, $request->string('note')->toString());
+
+            return back()->with('success', 'Бронь списана.');
+        } catch (DomainException $exception) {
+            return back()->with('error', $exception->getMessage());
+        }
+    }
+
+    public function cancel(Request $request, StockReservation $reservation): RedirectResponse
+    {
+        /** @var User $actor */
+        $actor = $request->user();
+
+        try {
+            $this->inventoryService->cancel($reservation, $actor, $request->string('note')->toString());
+
+            return back()->with('success', 'Бронь отменена.');
+        } catch (DomainException $exception) {
+            return back()->with('error', $exception->getMessage());
+        }
+    }
+
+    private function perPage(Request $request): int
+    {
+        $allowed = [50, 100, 200, 500, 2000];
+        $perPage = (int) $request->query('per_page', session('per_page', 50));
+        $perPage = in_array($perPage, $allowed, true) ? $perPage : 50;
+        session(['per_page' => $perPage]);
+
+        return $perPage;
+    }
+}

+ 153 - 0
app/Http/Controllers/StockOrderController.php

@@ -0,0 +1,153 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Controllers;
+
+use App\Http\Requests\StoreStockOrderRequest;
+use App\Jobs\Import\ImportJob;
+use App\Models\CommonCatalogItem;
+use App\Models\Import;
+use App\Models\StockOrder;
+use App\Models\User;
+use App\Services\StockInventoryService;
+use DomainException;
+use Illuminate\Contracts\View\View;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Storage;
+
+class StockOrderController extends Controller
+{
+    public function __construct(private readonly StockInventoryService $inventoryService) {}
+
+    public function index(Request $request): View
+    {
+        $search = trim((string) $request->query('search', ''));
+        $status = trim((string) $request->query('status', ''));
+        $perPage = $this->perPage($request);
+        $orders = StockOrder::query()
+            ->with(['item.imageFile', 'user'])
+            ->withSum(['reservations as active_reservations_sum_quantity' => fn ($query) => $query->active()], 'quantity')
+            ->when($search !== '', function (Builder $query) use ($search): void {
+                $query->where(function (Builder $query) use ($search): void {
+                    $query->where('order_number', 'like', "%{$search}%")
+                        ->orWhere('note', 'like', "%{$search}%")
+                        ->orWhereHas('item', fn (Builder $query) => $query
+                            ->where('article', 'like', "%{$search}%")
+                            ->orWhere('calculator_name', 'like', "%{$search}%"));
+                });
+            })
+            ->when(array_key_exists($status, StockOrder::STATUS_NAMES), fn (Builder $query) => $query->where('status', $status))
+            ->orderByDesc('created_at')
+            ->paginate($perPage)
+            ->withQueryString();
+
+        return view('stock.orders', [
+            'active' => 'stock_orders',
+            'orders' => $orders,
+            'items' => CommonCatalogItem::query()->orderBy('article')->get(['id', 'article', 'calculator_name']),
+            'orderedOrderNumbers' => StockOrder::query()
+                ->where('status', StockOrder::STATUS_ORDERED)
+                ->whereNotNull('order_number')
+                ->where('order_number', '<>', '')
+                ->distinct()
+                ->orderBy('order_number')
+                ->pluck('order_number'),
+            'statuses' => StockOrder::STATUS_NAMES,
+            'search' => $search,
+            'status' => $status,
+            'per_page' => $perPage,
+        ]);
+    }
+
+    public function store(StoreStockOrderRequest $request): RedirectResponse
+    {
+        /** @var User $actor */
+        $actor = $request->user();
+        $this->inventoryService->createOrder($request->validated(), $actor);
+
+        return back()->with('success', 'Складской заказ создан.');
+    }
+
+    public function update(StoreStockOrderRequest $request, StockOrder $stockOrder): RedirectResponse
+    {
+        /** @var User $actor */
+        $actor = $request->user();
+
+        try {
+            $this->inventoryService->updateOrder($stockOrder, $request->validated(), $actor);
+
+            return back()->with('success', 'Складской заказ обновлён.');
+        } catch (DomainException $exception) {
+            return back()->with('error', $exception->getMessage());
+        }
+    }
+
+    public function setOrderInStock(Request $request): RedirectResponse
+    {
+        $validated = $request->validate([
+            'bulk_order_number' => ['required', 'string', 'max:255'],
+        ]);
+        /** @var User $actor */
+        $actor = $request->user();
+
+        try {
+            $count = $this->inventoryService->setOrderInStock(
+                trim((string) $validated['bulk_order_number']),
+                $actor,
+            );
+
+            return back()->with('success', "Позиции заказа переведены в статус «На складе»: {$count}.");
+        } catch (DomainException $exception) {
+            return back()
+                ->withErrors(['bulk_order_number' => $exception->getMessage()])
+                ->withInput();
+        }
+    }
+
+    public function destroy(StockOrder $stockOrder): RedirectResponse
+    {
+        if ($stockOrder->reservations()->exists()) {
+            return back()->with('error', 'Нельзя удалить заказ с историей бронирований.');
+        }
+
+        $stockOrder->delete();
+
+        return back()->with('success', 'Складской заказ удалён.');
+    }
+
+    public function import(Request $request): RedirectResponse
+    {
+        $validated = $request->validate([
+            'import_file' => ['required', 'file', 'mimes:xlsx', 'max:10240'],
+        ]);
+        /** @var UploadedFile $uploadedFile */
+        $uploadedFile = $validated['import_file'];
+        $filename = Storage::disk('upload')->putFile('stock_orders', $uploadedFile);
+        $import = Import::query()->create([
+            'type' => 'stock_orders',
+            'year' => null,
+            'filename' => $filename,
+            'status' => 'new',
+            'user_id' => $request->user()?->id,
+            'file_path' => $filename,
+            'original_filename' => $uploadedFile->getClientOriginalName(),
+        ]);
+        ImportJob::dispatch($import, (int) $request->user()?->id);
+
+        return back()->with('success', 'Импорт складских заказов поставлен в очередь.');
+    }
+
+    private function perPage(Request $request): int
+    {
+        $allowed = [50, 100, 200, 500, 2000];
+        $perPage = (int) $request->query('per_page', session('per_page', 50));
+        $perPage = in_array($perPage, $allowed, true) ? $perPage : 50;
+        session(['per_page' => $perPage]);
+
+        return $perPage;
+    }
+}

+ 25 - 0
app/Http/Requests/ReserveStockRequest.php

@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class ReserveStockRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /** @return array<string, list<string>> */
+    public function rules(): array
+    {
+        return [
+            'manager_id' => ['required', 'integer', 'exists:users,id'],
+            'quantity' => ['required', 'integer', 'min:1'],
+            'note' => ['nullable', 'string', 'max:2000'],
+        ];
+    }
+}

+ 40 - 0
app/Http/Requests/StoreStockOrderRequest.php

@@ -0,0 +1,40 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Requests;
+
+use App\Models\StockOrder;
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+class StoreStockOrderRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /** @return array<string, mixed> */
+    public function rules(): array
+    {
+        /** @var StockOrder|null $stockOrder */
+        $stockOrder = $this->route('stockOrder');
+
+        return [
+            'common_catalog_item_id' => ['required', 'integer', 'exists:common_catalog_items,id'],
+            'order_number' => [
+                'required',
+                'string',
+                'max:255',
+                Rule::unique('stock_orders', 'order_number')
+                    ->where('common_catalog_item_id', $this->integer('common_catalog_item_id'))
+                    ->withoutTrashed()
+                    ->ignore($stockOrder),
+            ],
+            'status' => ['required', Rule::in(array_keys(StockOrder::STATUS_NAMES))],
+            'ordered_quantity' => ['required', 'integer', 'min:1'],
+            'note' => ['nullable', 'string', 'max:2000'],
+        ];
+    }
+}

+ 14 - 8
app/Jobs/Import/ImportJob.php

@@ -4,11 +4,11 @@ namespace App\Jobs\Import;
 
 use App\Events\SendWebSocketMessageEvent;
 use App\Models\Import;
-use App\Services\Import\ImportMafOrdersService;
 use App\Services\Import\ImportCommonCatalogService;
+use App\Services\Import\ImportMafOrdersService;
 use App\Services\Import\ImportSparePartOrdersService;
+use App\Services\Import\ImportStockOrdersService;
 use App\Services\ImportCatalogService;
-use Illuminate\Support\Facades\Storage;
 use App\Services\ImportMafsService;
 use App\Services\ImportOrdersService;
 use App\Services\ImportReclamationsService;
@@ -16,6 +16,7 @@ use Exception;
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Foundation\Queue\Queueable;
 use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Storage;
 
 class ImportJob implements ShouldQueue
 {
@@ -27,8 +28,7 @@ class ImportJob implements ShouldQueue
     public function __construct(
         private readonly Import $import,
         private readonly int $userId,
-    )
-    {}
+    ) {}
 
     /**
      * Execute the job.
@@ -36,7 +36,7 @@ class ImportJob implements ShouldQueue
     public function handle(): void
     {
         try {
-            switch($this->import->type) {
+            switch ($this->import->type) {
                 case 'orders':
                     (new ImportOrdersService($this->import))->handle();
                     break;
@@ -52,6 +52,12 @@ class ImportJob implements ShouldQueue
                 case 'common_catalog':
                     (new ImportCommonCatalogService($this->import, $this->userId))->handle();
                     break;
+                case 'stock_orders':
+                    app(ImportStockOrdersService::class, [
+                        'import' => $this->import,
+                        'userId' => $this->userId,
+                    ])->handle();
+                    break;
                 case 'spare_part_orders':
                     $this->handleSparePartOrdersImport();
                     break;
@@ -59,11 +65,11 @@ class ImportJob implements ShouldQueue
                     $this->handleMafOrdersImport();
                     break;
             }
-            Log::info('Import ' . $this->import->type. ' job done!');
+            Log::info('Import '.$this->import->type.' job done!');
             event(new SendWebSocketMessageEvent('Импорт завершён!', $this->userId, ['path' => $this->import->filename, 'type' => $this->import->type]));
         } catch (Exception $e) {
-            Log::info('Import ' . $this->import->type. ' job failed! ERROR: ' . $e->getMessage());
-            event(new SendWebSocketMessageEvent('Ошибка импорта! ' . $e->getMessage(), $this->userId, ['error' => $e->getMessage()]));
+            Log::info('Import '.$this->import->type.' job failed! ERROR: '.$e->getMessage());
+            event(new SendWebSocketMessageEvent('Ошибка импорта! '.$e->getMessage(), $this->userId, ['error' => $e->getMessage()]));
         }
     }
 

+ 22 - 0
app/Models/CommonCatalogItem.php

@@ -10,6 +10,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\Relations\HasOne;
 use Illuminate\Database\Eloquent\SoftDeletes;
 
 class CommonCatalogItem extends Model
@@ -68,6 +70,26 @@ class CommonCatalogItem extends Model
         )->withTimestamps();
     }
 
+    public function stockOrders(): HasMany
+    {
+        return $this->hasMany(StockOrder::class);
+    }
+
+    public function latestStockOrder(): HasOne
+    {
+        return $this->hasOne(StockOrder::class)->latestOfMany();
+    }
+
+    public function stockReservations(): HasMany
+    {
+        return $this->hasMany(StockReservation::class);
+    }
+
+    public function stockMovements(): HasMany
+    {
+        return $this->hasMany(StockMovement::class);
+    }
+
     protected function image(): Attribute
     {
         return Attribute::make(

+ 7 - 1
app/Models/Import.php

@@ -8,11 +8,15 @@ use Illuminate\Database\Eloquent\Model;
 class Import extends Model
 {
     use HasFactory;
+
     const DEFAULT_SORT_BY = 'created_at';
+
     const DEFAULT_ORDER_BY = 'desc';
 
     const STATUS_PENDING = 'pending';
+
     const STATUS_COMPLETED = 'completed';
+
     const STATUS_FAILED = 'failed';
 
     const TYPE_LABELS = [
@@ -21,6 +25,7 @@ class Import extends Model
         'mafs' => 'МАФы',
         'catalog' => 'Каталог',
         'common_catalog' => 'Каталог общий',
+        'stock_orders' => 'Заказы склада',
         'spare_part_orders' => 'Заказы запчастей',
         'maf_orders' => 'Заказы МАФ',
     ];
@@ -50,7 +55,8 @@ class Import extends Model
     {
         $d = date('Y-m-d H:i:s', strtotime('now'));
         $string = "$d\t$level:\t$message\n";
-        $this->result = $this->result . $string;
+        $this->result = $this->result.$string;
+
         return $string;
     }
 

+ 72 - 0
app/Models/StockMovement.php

@@ -0,0 +1,72 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+class StockMovement extends Model
+{
+    public const TYPE_RECEIPT = 'receipt';
+
+    public const TYPE_RESERVE = 'reserve';
+
+    public const TYPE_ISSUE = 'issue';
+
+    public const TYPE_RESERVE_CANCEL = 'reserve_cancel';
+
+    public const TYPE_NAMES = [
+        self::TYPE_RECEIPT => 'Поступление',
+        self::TYPE_RESERVE => 'Бронирование',
+        self::TYPE_ISSUE => 'Списание',
+        self::TYPE_RESERVE_CANCEL => 'Отмена брони',
+    ];
+
+    protected $fillable = [
+        'common_catalog_item_id',
+        'stock_order_id',
+        'stock_reservation_id',
+        'movement_type',
+        'quantity',
+        'user_id',
+        'note',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'common_catalog_item_id' => 'integer',
+            'stock_order_id' => 'integer',
+            'stock_reservation_id' => 'integer',
+            'quantity' => 'integer',
+            'user_id' => 'integer',
+        ];
+    }
+
+    public function item(): BelongsTo
+    {
+        return $this->belongsTo(CommonCatalogItem::class, 'common_catalog_item_id');
+    }
+
+    public function order(): BelongsTo
+    {
+        return $this->belongsTo(StockOrder::class, 'stock_order_id');
+    }
+
+    public function reservation(): BelongsTo
+    {
+        return $this->belongsTo(StockReservation::class, 'stock_reservation_id');
+    }
+
+    public function user(): BelongsTo
+    {
+        return $this->belongsTo(User::class);
+    }
+
+    public function getTypeNameAttribute(): string
+    {
+        return self::TYPE_NAMES[$this->movement_type] ?? $this->movement_type;
+    }
+}

+ 87 - 0
app/Models/StockOrder.php

@@ -0,0 +1,87 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+class StockOrder extends Model
+{
+    use HasFactory, SoftDeletes;
+
+    public const STATUS_ORDERED = 'ordered';
+
+    public const STATUS_IN_STOCK = 'in_stock';
+
+    public const STATUS_SHIPPED = 'shipped';
+
+    public const STATUS_NAMES = [
+        self::STATUS_ORDERED => 'Заказан',
+        self::STATUS_IN_STOCK => 'На складе',
+        self::STATUS_SHIPPED => 'Отгружено',
+    ];
+
+    protected $fillable = [
+        'common_catalog_item_id',
+        'order_number',
+        'status',
+        'ordered_quantity',
+        'available_quantity',
+        'note',
+        'user_id',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'common_catalog_item_id' => 'integer',
+            'ordered_quantity' => 'integer',
+            'available_quantity' => 'integer',
+            'user_id' => 'integer',
+        ];
+    }
+
+    public function item(): BelongsTo
+    {
+        return $this->belongsTo(CommonCatalogItem::class, 'common_catalog_item_id');
+    }
+
+    public function user(): BelongsTo
+    {
+        return $this->belongsTo(User::class);
+    }
+
+    public function reservations(): HasMany
+    {
+        return $this->hasMany(StockReservation::class);
+    }
+
+    public function movements(): HasMany
+    {
+        return $this->hasMany(StockMovement::class);
+    }
+
+    public function getStatusNameAttribute(): string
+    {
+        return self::STATUS_NAMES[$this->status] ?? $this->status;
+    }
+
+    public function getReservedQuantityAttribute(): int
+    {
+        if (array_key_exists('active_reservations_sum_quantity', $this->attributes)) {
+            return (int) $this->attributes['active_reservations_sum_quantity'];
+        }
+
+        return (int) $this->reservations()->active()->sum('quantity');
+    }
+
+    public function getFreeQuantityAttribute(): int
+    {
+        return max(0, $this->available_quantity - $this->reserved_quantity);
+    }
+}

+ 79 - 0
app/Models/StockReservation.php

@@ -0,0 +1,79 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+class StockReservation extends Model
+{
+    public const STATUS_ACTIVE = 'active';
+
+    public const STATUS_ISSUED = 'issued';
+
+    public const STATUS_CANCELLED = 'cancelled';
+
+    public const STATUS_NAMES = [
+        self::STATUS_ACTIVE => 'Забронировано',
+        self::STATUS_ISSUED => 'Списано',
+        self::STATUS_CANCELLED => 'Отменено',
+    ];
+
+    protected $fillable = [
+        'common_catalog_item_id',
+        'stock_order_id',
+        'manager_id',
+        'quantity',
+        'status',
+        'note',
+        'created_by',
+        'issued_at',
+        'cancelled_at',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'common_catalog_item_id' => 'integer',
+            'stock_order_id' => 'integer',
+            'manager_id' => 'integer',
+            'quantity' => 'integer',
+            'created_by' => 'integer',
+            'issued_at' => 'datetime',
+            'cancelled_at' => 'datetime',
+        ];
+    }
+
+    public function item(): BelongsTo
+    {
+        return $this->belongsTo(CommonCatalogItem::class, 'common_catalog_item_id');
+    }
+
+    public function order(): BelongsTo
+    {
+        return $this->belongsTo(StockOrder::class, 'stock_order_id');
+    }
+
+    public function manager(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'manager_id');
+    }
+
+    public function creator(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'created_by');
+    }
+
+    public function scopeActive(Builder $query): Builder
+    {
+        return $query->where('status', self::STATUS_ACTIVE);
+    }
+
+    public function isActive(): bool
+    {
+        return $this->status === self::STATUS_ACTIVE;
+    }
+}

+ 135 - 0
app/Services/Import/ImportStockOrdersService.php

@@ -0,0 +1,135 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services\Import;
+
+use App\Models\CommonCatalogItem;
+use App\Models\Import;
+use App\Models\StockOrder;
+use App\Models\User;
+use App\Services\StockInventoryService;
+use Illuminate\Support\Facades\Storage;
+use PhpOffice\PhpSpreadsheet\Reader\Xlsx;
+use RuntimeException;
+use Throwable;
+
+class ImportStockOrdersService
+{
+    public const HEADERS = [
+        'Номер заказа',
+        'Артикул',
+        'Статус',
+        'Кол-во заказано',
+        'Примечание',
+    ];
+
+    public function __construct(
+        private readonly Import $import,
+        private readonly int $userId,
+        private readonly StockInventoryService $inventoryService,
+    ) {}
+
+    public function handle(): bool
+    {
+        try {
+            $actor = User::query()->findOrFail($this->userId);
+            $spreadsheet = (new Xlsx)->load(Storage::disk('upload')->path((string) $this->import->filename));
+            $sheet = $spreadsheet->getActiveSheet();
+            $headers = $sheet->rangeToArray('A1:E1', null, true, true, false)[0];
+            if (array_map($this->normalize(...), $headers) !== array_map($this->normalize(...), self::HEADERS)) {
+                throw new RuntimeException('Некорректные заголовки файла складских заказов.');
+            }
+
+            $created = 0;
+            $updated = 0;
+            $errors = 0;
+
+            for ($row = 2; $row <= $sheet->getHighestDataRow(); $row++) {
+                $orderNumber = trim((string) $sheet->getCell("A{$row}")->getFormattedValue());
+                $article = trim((string) $sheet->getCell("B{$row}")->getFormattedValue());
+                if ($orderNumber === '' && $article === '') {
+                    continue;
+                }
+
+                try {
+                    if ($orderNumber === '') {
+                        throw new RuntimeException('Не указан номер заказа.');
+                    }
+                    if ($article === '') {
+                        throw new RuntimeException('Не указан артикул.');
+                    }
+
+                    $item = CommonCatalogItem::query()->where('article', $article)->firstOrFail();
+                    $status = $this->status((string) $sheet->getCell("C{$row}")->getFormattedValue());
+                    $quantity = filter_var(
+                        $sheet->getCell("D{$row}")->getCalculatedValue(),
+                        FILTER_VALIDATE_INT,
+                        ['options' => ['min_range' => 1]],
+                    );
+                    if ($quantity === false) {
+                        throw new RuntimeException('Количество должно быть целым числом больше нуля.');
+                    }
+
+                    $data = [
+                        'common_catalog_item_id' => $item->id,
+                        'order_number' => $orderNumber,
+                        'status' => $status,
+                        'ordered_quantity' => $quantity,
+                        'note' => $this->nullable((string) $sheet->getCell("E{$row}")->getValue()),
+                    ];
+                    $order = StockOrder::withTrashed()
+                        ->where('order_number', $orderNumber)
+                        ->where('common_catalog_item_id', $item->id)
+                        ->first();
+
+                    if ($order) {
+                        if ($order->trashed()) {
+                            $order->restore();
+                        }
+                        $this->inventoryService->updateOrder($order, $data, $actor);
+                        $updated++;
+                    } else {
+                        $this->inventoryService->createOrder($data, $actor);
+                        $created++;
+                    }
+                } catch (Throwable $exception) {
+                    $errors++;
+                    $this->import->log("Строка {$row}: {$exception->getMessage()}", 'WARNING');
+                }
+            }
+
+            $spreadsheet->disconnectWorksheets();
+            $this->import->log("Создано: {$created}; обновлено: {$updated}; ошибок: {$errors}.");
+            $this->import->update(['status' => 'DONE']);
+
+            return true;
+        } catch (Throwable $exception) {
+            $this->import->log($exception->getMessage(), 'ERROR');
+            $this->import->update(['status' => 'ERROR']);
+            throw $exception;
+        }
+    }
+
+    private function status(string $value): string
+    {
+        return match (mb_strtolower(trim($value))) {
+            'заказан', 'заказано', StockOrder::STATUS_ORDERED => StockOrder::STATUS_ORDERED,
+            'на складе', StockOrder::STATUS_IN_STOCK => StockOrder::STATUS_IN_STOCK,
+            'отгружено', 'отгружен', StockOrder::STATUS_SHIPPED => StockOrder::STATUS_SHIPPED,
+            default => throw new RuntimeException("Неизвестный статус: {$value}."),
+        };
+    }
+
+    private function normalize(mixed $value): string
+    {
+        return mb_strtolower(trim((string) preg_replace('/\s+/u', ' ', (string) $value)));
+    }
+
+    private function nullable(string $value): ?string
+    {
+        $value = trim($value);
+
+        return $value === '' ? null : $value;
+    }
+}

+ 321 - 0
app/Services/StockInventoryService.php

@@ -0,0 +1,321 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services;
+
+use App\Models\CommonCatalogItem;
+use App\Models\StockMovement;
+use App\Models\StockOrder;
+use App\Models\StockReservation;
+use App\Models\User;
+use DomainException;
+use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Support\Facades\DB;
+
+class StockInventoryService
+{
+    /** @return array{physical: int, reserved: int, available: int, ordered: int} */
+    public function summary(CommonCatalogItem $item): array
+    {
+        $physical = (int) $item->stockOrders()
+            ->where('status', StockOrder::STATUS_IN_STOCK)
+            ->sum('available_quantity');
+        $reserved = (int) $item->stockReservations()->active()->sum('quantity');
+        $ordered = (int) $item->stockOrders()
+            ->where('status', StockOrder::STATUS_ORDERED)
+            ->sum('ordered_quantity');
+
+        return [
+            'physical' => $physical,
+            'reserved' => $reserved,
+            'available' => max(0, $physical - $reserved),
+            'ordered' => $ordered,
+        ];
+    }
+
+    /** @return Collection<int, StockReservation> */
+    public function reserve(
+        CommonCatalogItem $item,
+        User $manager,
+        int $quantity,
+        ?string $note,
+        User $actor,
+    ): Collection {
+        return DB::transaction(function () use ($item, $manager, $quantity, $note, $actor): Collection {
+            $orders = StockOrder::query()
+                ->where('common_catalog_item_id', $item->id)
+                ->where('status', StockOrder::STATUS_IN_STOCK)
+                ->where('available_quantity', '>', 0)
+                ->withSum(['reservations as active_reservations_sum_quantity' => fn ($query) => $query->active()], 'quantity')
+                ->orderBy('created_at')
+                ->orderBy('id')
+                ->lockForUpdate()
+                ->get();
+
+            $available = $orders->sum(fn (StockOrder $order): int => $order->free_quantity);
+            if ($quantity > $available) {
+                throw new DomainException('Такого кол-ва нет в наличии');
+            }
+
+            $remaining = $quantity;
+            $reservations = new Collection;
+
+            foreach ($orders as $order) {
+                if ($remaining === 0) {
+                    break;
+                }
+
+                $reservedQuantity = min($remaining, $order->free_quantity);
+                if ($reservedQuantity === 0) {
+                    continue;
+                }
+
+                $reservation = StockReservation::query()->create([
+                    'common_catalog_item_id' => $item->id,
+                    'stock_order_id' => $order->id,
+                    'manager_id' => $manager->id,
+                    'quantity' => $reservedQuantity,
+                    'status' => StockReservation::STATUS_ACTIVE,
+                    'note' => $note,
+                    'created_by' => $actor->id,
+                ]);
+                $this->movement(
+                    StockMovement::TYPE_RESERVE,
+                    $item->id,
+                    $order->id,
+                    $reservation->id,
+                    $reservedQuantity,
+                    $actor->id,
+                    $note,
+                );
+
+                $reservations->push($reservation);
+                $remaining -= $reservedQuantity;
+            }
+
+            return $reservations;
+        });
+    }
+
+    public function issue(StockReservation $reservation, User $actor, ?string $note = null): void
+    {
+        DB::transaction(function () use ($reservation, $actor, $note): void {
+            $lockedReservation = StockReservation::query()->lockForUpdate()->findOrFail($reservation->id);
+            if (! $lockedReservation->isActive()) {
+                throw new DomainException('Бронь не активна');
+            }
+
+            $order = StockOrder::query()->lockForUpdate()->findOrFail($lockedReservation->stock_order_id);
+            if ($order->available_quantity < $lockedReservation->quantity) {
+                throw new DomainException('Недостаточно остатка в заказе');
+            }
+
+            $order->available_quantity -= $lockedReservation->quantity;
+            if ($order->available_quantity === 0) {
+                $order->status = StockOrder::STATUS_SHIPPED;
+            }
+            $order->save();
+
+            $lockedReservation->update([
+                'status' => StockReservation::STATUS_ISSUED,
+                'issued_at' => now(),
+            ]);
+            $this->movement(
+                StockMovement::TYPE_ISSUE,
+                $lockedReservation->common_catalog_item_id,
+                $order->id,
+                $lockedReservation->id,
+                $lockedReservation->quantity,
+                $actor->id,
+                $note ?: $lockedReservation->note,
+            );
+        });
+    }
+
+    public function cancel(StockReservation $reservation, User $actor, ?string $note = null): void
+    {
+        DB::transaction(function () use ($reservation, $actor, $note): void {
+            $lockedReservation = StockReservation::query()->lockForUpdate()->findOrFail($reservation->id);
+            if (! $lockedReservation->isActive()) {
+                throw new DomainException('Бронь не активна');
+            }
+
+            $lockedReservation->update([
+                'status' => StockReservation::STATUS_CANCELLED,
+                'cancelled_at' => now(),
+            ]);
+            $this->movement(
+                StockMovement::TYPE_RESERVE_CANCEL,
+                $lockedReservation->common_catalog_item_id,
+                $lockedReservation->stock_order_id,
+                $lockedReservation->id,
+                $lockedReservation->quantity,
+                $actor->id,
+                $note ?: $lockedReservation->note,
+            );
+        });
+    }
+
+    /** @param array<string, mixed> $data */
+    public function createOrder(array $data, User $actor): StockOrder
+    {
+        return DB::transaction(function () use ($data, $actor): StockOrder {
+            $order = StockOrder::query()->create([
+                ...$data,
+                'available_quantity' => $data['status'] === StockOrder::STATUS_IN_STOCK
+                    ? (int) $data['ordered_quantity']
+                    : 0,
+                'user_id' => $actor->id,
+            ]);
+
+            if ($order->status === StockOrder::STATUS_IN_STOCK) {
+                $this->movement(
+                    StockMovement::TYPE_RECEIPT,
+                    $order->common_catalog_item_id,
+                    $order->id,
+                    null,
+                    $order->ordered_quantity,
+                    $actor->id,
+                    $order->note,
+                );
+            }
+
+            return $order;
+        });
+    }
+
+    /** @param array<string, mixed> $data */
+    public function updateOrder(StockOrder $order, array $data, User $actor): StockOrder
+    {
+        return DB::transaction(function () use ($order, $data, $actor): StockOrder {
+            $lockedOrder = StockOrder::query()->lockForUpdate()->findOrFail($order->id);
+            $hasReservations = $lockedOrder->reservations()->exists();
+            $hasIssues = $lockedOrder->movements()->where('movement_type', StockMovement::TYPE_ISSUE)->exists();
+            $hasOperations = $hasReservations || $hasIssues;
+
+            if ($hasOperations && (
+                (int) $data['common_catalog_item_id'] !== $lockedOrder->common_catalog_item_id
+                || (int) $data['ordered_quantity'] !== $lockedOrder->ordered_quantity
+            )) {
+                throw new DomainException('Нельзя изменить позицию или количество заказа с историей операций');
+            }
+
+            $oldStatus = $lockedOrder->status;
+            $newStatus = (string) $data['status'];
+            $oldAvailable = $lockedOrder->available_quantity;
+
+            if ($oldStatus === StockOrder::STATUS_SHIPPED && $newStatus !== StockOrder::STATUS_SHIPPED) {
+                throw new DomainException('Отгруженный заказ нельзя вернуть в работу. Создайте новый заказ.');
+            }
+
+            if ($lockedOrder->reservations()->active()->exists() && $newStatus !== StockOrder::STATUS_IN_STOCK) {
+                throw new DomainException('Нельзя изменить статус заказа с активной бронью');
+            }
+
+            if ($newStatus === StockOrder::STATUS_IN_STOCK) {
+                if ($oldStatus !== StockOrder::STATUS_IN_STOCK || ! $hasOperations) {
+                    $data['available_quantity'] = (int) $data['ordered_quantity'];
+                }
+            } else {
+                $data['available_quantity'] = 0;
+            }
+
+            $lockedOrder->update($data);
+
+            if ($oldStatus !== StockOrder::STATUS_IN_STOCK && $lockedOrder->status === StockOrder::STATUS_IN_STOCK) {
+                $this->movement(
+                    StockMovement::TYPE_RECEIPT,
+                    $lockedOrder->common_catalog_item_id,
+                    $lockedOrder->id,
+                    null,
+                    $lockedOrder->available_quantity,
+                    $actor->id,
+                    $lockedOrder->note,
+                );
+            } elseif ($oldStatus === StockOrder::STATUS_IN_STOCK
+                && $lockedOrder->status === StockOrder::STATUS_IN_STOCK
+                && ! $hasOperations
+                && $lockedOrder->available_quantity !== $oldAvailable) {
+                $difference = $lockedOrder->available_quantity - $oldAvailable;
+                $this->movement(
+                    $difference > 0 ? StockMovement::TYPE_RECEIPT : StockMovement::TYPE_ISSUE,
+                    $lockedOrder->common_catalog_item_id,
+                    $lockedOrder->id,
+                    null,
+                    abs($difference),
+                    $actor->id,
+                    'Корректировка количества складского заказа',
+                );
+            } elseif ($oldStatus === StockOrder::STATUS_IN_STOCK
+                && $lockedOrder->status === StockOrder::STATUS_SHIPPED
+                && $oldAvailable > 0) {
+                $this->movement(
+                    StockMovement::TYPE_ISSUE,
+                    $lockedOrder->common_catalog_item_id,
+                    $lockedOrder->id,
+                    null,
+                    $oldAvailable,
+                    $actor->id,
+                    'Заказ отмечен как отгруженный',
+                );
+            }
+
+            return $lockedOrder;
+        });
+    }
+
+    public function setOrderInStock(string $orderNumber, User $actor): int
+    {
+        return DB::transaction(function () use ($orderNumber, $actor): int {
+            $orders = StockOrder::query()
+                ->where('order_number', $orderNumber)
+                ->where('status', StockOrder::STATUS_ORDERED)
+                ->orderBy('id')
+                ->lockForUpdate()
+                ->get();
+
+            if ($orders->isEmpty()) {
+                throw new DomainException('Заказ со статусом «Заказан» не найден');
+            }
+
+            foreach ($orders as $order) {
+                $order->update([
+                    'status' => StockOrder::STATUS_IN_STOCK,
+                    'available_quantity' => $order->ordered_quantity,
+                ]);
+                $this->movement(
+                    StockMovement::TYPE_RECEIPT,
+                    $order->common_catalog_item_id,
+                    $order->id,
+                    null,
+                    $order->ordered_quantity,
+                    $actor->id,
+                    'Весь заказ отмечен как поступивший на склад',
+                );
+            }
+
+            return $orders->count();
+        });
+    }
+
+    private function movement(
+        string $type,
+        int $itemId,
+        ?int $orderId,
+        ?int $reservationId,
+        int $quantity,
+        int $userId,
+        ?string $note,
+    ): StockMovement {
+        return StockMovement::query()->create([
+            'common_catalog_item_id' => $itemId,
+            'stock_order_id' => $orderId,
+            'stock_reservation_id' => $reservationId,
+            'movement_type' => $type,
+            'quantity' => $quantity,
+            'user_id' => $userId,
+            'note' => $note,
+        ]);
+    }
+}

+ 13 - 0
config/access.php

@@ -135,6 +135,19 @@ return [
             'update' => 'Управление корневыми папками и правами',
         ],
     ],
+    'stock' => [
+        'name' => 'Склад',
+        'entity' => 'stock_order',
+        'actions' => [
+            'view' => 'Просмотр наличия',
+            'reserve' => 'Бронирование',
+            'issue' => 'Списание брони',
+            'cancel' => 'Отмена брони',
+            'orders.view' => 'Просмотр заказов',
+            'orders.manage' => 'Управление заказами',
+            'orders.import' => 'Импорт заказов',
+        ],
+    ],
     'maf' => [
         'name' => 'МАФ',
         'entity' => 'product_sku',

+ 13 - 2
config/access_routes.php

@@ -14,8 +14,6 @@ return [
         'pricing_codes.search' => 'pricing_codes.search',
         'schedule.orders' => true,
         'schedule.deliveries' => true,
-        'stock.index' => true,
-        'stock.orders' => true,
         'user.profile' => 'profile.view',
         'profile.store' => 'profile.update',
         'profile.delete' => 'profile.delete',
@@ -80,6 +78,19 @@ return [
             'folders.permissions.update' => 'documents.update',
             '*' => 'documents.view',
         ],
+        'stock.' => [
+            'index' => 'stock.view',
+            'show' => 'stock.view',
+            'reserve' => 'stock.reserve',
+            'reservations.issue' => 'stock.issue',
+            'reservations.cancel' => 'stock.cancel',
+            'orders' => 'stock.orders.view',
+            'orders.store' => 'stock.orders.manage',
+            'orders.set-in-stock' => 'stock.orders.manage',
+            'orders.update' => 'stock.orders.manage',
+            'orders.destroy' => 'stock.orders.manage',
+            'orders.import' => 'stock.orders.import',
+        ],
         'contract.' => [
             'index' => 'contracts.view',
             'show' => 'contracts.view',

+ 68 - 0
database/migrations/2026_08_03_000003_create_stock_tables.php

@@ -0,0 +1,68 @@
+<?php
+
+declare(strict_types=1);
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('stock_orders', function (Blueprint $table): void {
+            $table->id();
+            $table->foreignId('common_catalog_item_id')->constrained('common_catalog_items')->restrictOnDelete();
+            $table->string('order_number');
+            $table->string('status', 32)->default('ordered');
+            $table->unsignedInteger('ordered_quantity');
+            $table->unsignedInteger('available_quantity');
+            $table->text('note')->nullable();
+            $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
+            $table->timestamps();
+            $table->softDeletes();
+
+            $table->unique(['order_number', 'common_catalog_item_id']);
+            $table->index(['common_catalog_item_id', 'status', 'available_quantity'], 'stock_order_item_status_available_index');
+        });
+
+        Schema::create('stock_reservations', function (Blueprint $table): void {
+            $table->id();
+            $table->foreignId('common_catalog_item_id')->constrained('common_catalog_items')->restrictOnDelete();
+            $table->foreignId('stock_order_id')->constrained('stock_orders')->restrictOnDelete();
+            $table->foreignId('manager_id')->nullable()->constrained('users')->nullOnDelete();
+            $table->unsignedInteger('quantity');
+            $table->string('status', 32)->default('active');
+            $table->text('note')->nullable();
+            $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
+            $table->timestamp('issued_at')->nullable();
+            $table->timestamp('cancelled_at')->nullable();
+            $table->timestamps();
+
+            $table->index(['common_catalog_item_id', 'status']);
+            $table->index(['stock_order_id', 'status']);
+        });
+
+        Schema::create('stock_movements', function (Blueprint $table): void {
+            $table->id();
+            $table->foreignId('common_catalog_item_id')->constrained('common_catalog_items')->restrictOnDelete();
+            $table->foreignId('stock_order_id')->nullable()->constrained('stock_orders')->nullOnDelete();
+            $table->foreignId('stock_reservation_id')->nullable()->constrained('stock_reservations')->nullOnDelete();
+            $table->string('movement_type', 32);
+            $table->unsignedInteger('quantity');
+            $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
+            $table->text('note')->nullable();
+            $table->timestamps();
+
+            $table->index(['common_catalog_item_id', 'movement_type']);
+            $table->index(['stock_order_id', 'movement_type']);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('stock_movements');
+        Schema::dropIfExists('stock_reservations');
+        Schema::dropIfExists('stock_orders');
+    }
+};

+ 6 - 2
database/seeders/RbacSeeder.php

@@ -78,7 +78,7 @@ class RbacSeeder extends Seeder
                             'field' => $field,
                             'action' => $action,
                             'type' => Permission::TYPE_FIELD,
-                            'group' => ($definition['name'] ?? $module) . ' / Поля',
+                            'group' => ($definition['name'] ?? $module).' / Поля',
                             'sort' => $sort++,
                             'is_system' => true,
                         ],
@@ -122,6 +122,10 @@ class RbacSeeder extends Seeder
             'catalog.search',
             'common-catalog.view',
             'documents.view',
+            'stock.view',
+            'stock.reserve',
+            'stock.issue',
+            'stock.cancel',
             'pricing_codes.search',
             'filters.view',
         ];
@@ -202,7 +206,7 @@ class RbacSeeder extends Seeder
 
         foreach ($rolePermissions as $roleSlug => $permissionSlugs) {
             $role = $roles[$roleSlug] ?? null;
-            if (!$role) {
+            if (! $role) {
                 continue;
             }
 

+ 6 - 8
docs/refactor/menu.md

@@ -33,7 +33,7 @@ Manager
 │   ├── Контроль наличия
 │   ├── Справочник расшифровок
 │   └── Справка
-├── Склад наличие
+├── Склад
 │   ├── Наличие
 │   └── Заказы
 ├── Каталог общий
@@ -114,18 +114,16 @@ Manager
 | Справочник расшифровок | Есть | `pricing_codes.index` | Показывать всем пользователям с доступом к модулю `Запчасти`. |
 | Справка | Есть | `spare_parts.help` | Текущая справочная страница раздела. |
 
-### Склад наличие
+### Склад
 
-Статус: **Нужно реализовать**.
+Статус: **ядро реализовано, ожидает пользовательской проверки**.
 
-Новый раздел по ТЗ. Должен быть реализован полностью по образцу раздела `Запчасти`: структура вкладок, карточки, заказы, контроль остатков, бронирование, списание, отмена брони, история движений, импорт/экспорт и права доступа берутся как базовая модель поведения и адаптируются под МАФ/позиции из `Каталог общий`.
-
-На первом этапе нужны страницы-заглушки, затем отдельное ТЗ на модуль с привязкой к существующей логике `Запчасти`.
+Раздел реализован по образцу `Запчасти` и использует позиции из `Каталог общий`. Остатки рассчитываются из складских заказов, активных броней и списаний. PDF-экспорт остаётся открытым до получения отдельного шаблона.
 
 | Пункт | Статус | Текущий маршрут | Комментарий |
 |---|---|---|---|
-| Наличие | Нужно реализовать | - | Видно всем пользователям. Список МАФ/позиций с остатком на складе больше 0. |
-| Заказы | Нужно реализовать | - | Доступ только админу и роли `assistant_head`. Ручное добавление заказов или импорт. |
+| Наличие | Реализовано | `stock.index`, `stock.show` | Доступно всем штатным ролям; список, поиск, карточка, остатки, брони, списание, отмена и история. |
+| Заказы | Реализовано | `stock.orders` | Доступ только админу и роли `assistant_head`; ручное создание/редактирование и XLSX-импорт. |
 
 Отдельно в разделе потребуется PDF-экспорт наличия по шаблону.
 

+ 22 - 22
docs/refactor/plan.md

@@ -11,7 +11,7 @@
 - [ТЗ: Графики](tz-schedules.md)
 - [ТЗ: Рекламации](tz-reclamations.md)
 - [ТЗ: Запчасти](tz-spare-parts.md)
-- [ТЗ: Склад наличие](tz-stock-availability.md)
+- [ТЗ: Склад](tz-stock-availability.md)
 - [ТЗ: Документация](tz-documents.md)
 - [ТЗ: Технич. описание](tz-technical-description.md)
 - [ТЗ: Калькуляции](tz-calculations.md)
@@ -28,7 +28,7 @@
 - [x] Каталог ДКР остается внутри меню ДКР.
 - [x] `Каталог общий` реализуется отдельно как общий справочник платформы.
 - [x] `Запчасти` — общий модуль для всей платформы, включая ДКР.
-- [x] `Склад наличие` должен быть реализован полностью по образцу раздела `Запчасти`, с адаптацией под МАФ/позиции из `Каталог общий`.
+- [x] `Склад` должен быть реализован полностью по образцу раздела `Запчасти`, с адаптацией под МАФ/позиции из `Каталог общий`.
 - [x] Новые модули на первом этапе получают страницы-заглушки `В разработке`.
 - [x] Основная карта меню хранится в [docs/refactor/menu.md](menu.md).
 - [x] Текущие рабочие маршруты оставляем без переименования.
@@ -45,7 +45,7 @@
 - [x] Документация для оплаты доступна только для рекламаций типа `ДКР`.
 - [x] Маршруты `Каталог общий` используют префикс `common-catalog/...`.
 - [x] Таблицы и права `Каталог общий` используют техническое имя `common-catalog`.
-- [x] Техническое имя модуля `Склад наличие`: `stock`.
+- [x] Техническое имя модуля `Склад`: `stock`.
 - [x] Роль `помощник руководителя`: `assistant_head`.
 
 ## Этап 0. Документация
@@ -59,7 +59,7 @@
 - [x] Написать отдельное ТЗ для модуля `Графики`: [tz-schedules.md](tz-schedules.md).
 - [x] Написать отдельное ТЗ для модуля `Рекламации`: [tz-reclamations.md](tz-reclamations.md).
 - [x] Написать отдельное ТЗ для модуля `Запчасти`: [tz-spare-parts.md](tz-spare-parts.md).
-- [x] Написать отдельное ТЗ для модуля `Склад наличие`: [tz-stock-availability.md](tz-stock-availability.md).
+- [x] Написать отдельное ТЗ для модуля `Склад`: [tz-stock-availability.md](tz-stock-availability.md).
 - [x] Написать отдельное ТЗ для модуля `Документация`: [tz-documents.md](tz-documents.md).
 - [x] Написать отдельное ТЗ для модуля `Технич. описание`: [tz-technical-description.md](tz-technical-description.md).
 - [x] Написать отдельное ТЗ для модуля `Калькуляции`: [tz-calculations.md](tz-calculations.md).
@@ -74,7 +74,7 @@
 | 3. Рекламации: вкладки и тип | Реализован, ожидает пользовательской проверки | Добавлены вкладки, справочник типов, перенос существующих данных и запрет платежных документов для `Прочее`; создание из графика остается в этапе 10. |
 | 4. Общий каталог: ядро | Реализован и проверен | Пользователь проверил создание, редактирование, импорт и экспорт; отдельная модель данных, права, фото и документы покрыты автоматическими тестами. |
 | 5. Документация | Реализован и проверен | Пользователь проверил дерево папок, работу с документами и версиями; права и приватное хранение дополнительно покрыты автоматическими тестами. |
-| 6. Склад наличие: ядро | Готов к реализации | Ядро общего каталога доступно как источник позиций; PDF-экспорт остается отдельным открытым подпунктом. |
+| 6. Склад: ядро | Реализован, ожидает пользовательской проверки | Наличие, заказы, карточка, бронирование, списание, отмена, история, импорт и RBAC реализованы; PDF-экспорт ожидает отдельного шаблона. |
 | 7. Техническое описание | Готово к анализу Laravel-модуля | Место в карточке общего каталога подготовлено, но нужно получить модуль и форматы выгрузки. |
 | 8. Калькуляции | Не готово к полной реализации | Нужен контракт внешнего API. Можно сделать только место/кнопку в карточке общего каталога. |
 | 9. Графики заказов и доставок | Не готово к полной реализации | Нужны формат обмена с 1С, поля, статусы и правила доставок/отгрузок. |
@@ -96,7 +96,7 @@
 - [x] Добавить заглушки `График заказов` и `График доставок`.
 - [x] Разложить `Рекламации` на `Все` и `ДКР`.
 - [x] Оставить `Запчасти` отдельной верхней группой с текущими вкладками.
-- [x] Добавить заглушки `Склад наличие -> Наличие` и `Склад наличие -> Заказы`.
+- [x] Добавить заглушки `Склад -> Наличие` и `Склад -> Заказы`.
 - [x] Добавить заглушки `Документация`, `Каталог общий`, `Калькуляции`.
 - [x] Проверить, что выбранный год не скрывает и не фильтрует новые модули.
 - [x] Переименовать группу `Администрирование` в `Администратор`.
@@ -170,25 +170,25 @@
 - [x] Реализовать полное удаление отдельных версий.
 - [x] Использовать текущий механизм хранения файлов; сами файлы документации хранить на приватном диске и выдавать только после проверки ACL.
 
-## Этап 6. Склад наличие: ядро
+## Этап 6. Склад: ядро
 
-Статус: **готов после этапа 4**.
+Статус: **реализован, ожидает пользовательской проверки**.
 
 Ядро склада можно реализовывать после появления позиций общего каталога. PDF-экспорт по шаблону вынесен отдельным подпунктом, потому что точный шаблон еще не согласован.
 
-- [ ] Разобрать текущую реализацию `Запчасти` как эталон.
-- [ ] Зафиксировать соответствие сущностей `Запчасти` -> `Склад наличие`.
-- [ ] Спроектировать таблицы/модели `stock` для заказов, остатков, броней, списаний и истории.
-- [ ] Реализовать подвкладку `Наличие`.
-- [ ] Реализовать подвкладку `Заказы`.
-- [ ] Реализовать карточку позиции.
-- [ ] Реализовать бронирование.
-- [ ] Реализовать списание с конкретных заказов.
-- [ ] Реализовать отмену брони.
-- [ ] Реализовать историю движений.
-- [ ] Реализовать импорт заказов/остатков.
-- [ ] Настроить права: `Наличие` всем, `Заказы` админу и роли `assistant_head`.
-- [ ] Покрыть критичную логику расчетов тестами.
+- [x] Разобрать текущую реализацию `Запчасти` как эталон.
+- [x] Зафиксировать соответствие сущностей `Запчасти` -> `Склад`.
+- [x] Спроектировать таблицы/модели `stock` для заказов, остатков, броней, списаний и истории.
+- [x] Реализовать подвкладку `Наличие`.
+- [x] Реализовать подвкладку `Заказы`.
+- [x] Реализовать карточку позиции.
+- [x] Реализовать бронирование.
+- [x] Реализовать списание с конкретных заказов.
+- [x] Реализовать отмену брони.
+- [x] Реализовать историю движений.
+- [x] Реализовать импорт заказов/остатков.
+- [x] Настроить права: `Наличие` всем, `Заказы` админу и роли `assistant_head`.
+- [x] Покрыть критичную логику расчетов тестами.
 - [ ] После согласования шаблона реализовать экспорт наличия в PDF.
 
 ## Этап 7. Техническое описание в общем каталоге
@@ -258,7 +258,7 @@
 
 - [ ] Какой точный контракт внешнего API калькуляций?
 - [ ] Где находится готовый Laravel-модуль техописаний и какие форматы выгрузки из него переносим?
-- [ ] Какой точный PDF-шаблон нужен для экспорта `Склад наличие`?
+- [ ] Какой точный PDF-шаблон нужен для экспорта `Склад`?
 - [ ] Какой формат обмена с 1С для `График заказов`?
 - [ ] Какие поля, статусы и правила ручного редактирования нужны для `График заказов`?
 - [ ] Какие поля, статусы, правила отгрузок и связи с заказами нужны для `График доставок`?

+ 5 - 5
docs/refactor/tz-catalog-common.md

@@ -15,7 +15,7 @@
 
 - `Графики`;
 - `Рекламации`;
-- `Склад наличие`;
+- `Склад`;
 - `Технич. описание`;
 - `Калькуляции`.
 
@@ -95,7 +95,7 @@ SQL-таблицы названы `common_catalog_items` и `common_catalog_item
 Целевая роль позиции:
 
 - единый справочник номенклатуры;
-- источник изображения, артикула, наименования, характеристик и единицы измерения для склада наличия;
+- источник изображения, артикула, наименования, характеристик и единицы измерения для склада;
 - источник данных для графиков и рекламаций;
 - точка перехода в техническое описание;
 - точка перехода в калькуляцию.
@@ -195,7 +195,7 @@ SQL-таблицы названы `common_catalog_items` и `common_catalog_item
 Фото:
 
 - хранится у позиции общего каталога;
-- используется в `Склад наличие`;
+- используется в `Склад`;
 - используется в карточке позиции;
 - должно быть доступно для отображения в таблицах, где нужна картинка позиции.
 
@@ -223,7 +223,7 @@ SQL-таблицы названы `common_catalog_items` и `common_catalog_item
 | Модуль | Связь | Требование |
 |---|---|---|
 | ДКР | Имеет собственный каталог | Не смешивать каталог ДКР с общим каталогом. |
-| Склад наличие | Использует картинку, артикул, наименование, характеристики, ед. изм. | Склад должен брать справочные поля из общего каталога. |
+| Склад | Использует картинку, артикул, наименование, характеристики, ед. изм. | Склад должен брать справочные поля из общего каталога. |
 | Графики | Используют данные по позициям | При разработке графиков опираться на общий каталог. |
 | Рекламации | Используют данные по позициям | Сохранить/добавить связь рекламаций с позициями общего каталога. |
 | Технич. описание | Является частью общего каталога | Использовать готовый Laravel-модуль как источник экспорта/формата. |
@@ -236,7 +236,7 @@ SQL-таблицы названы `common_catalog_items` и `common_catalog_item
 - переименование текущих маршрутов `catalog.*`;
 - полная переработка модели `Product`;
 - внедрение новых прав доступа;
-- разработка модуля `Склад наличие`;
+- разработка модуля `Склад`;
 - отдельный модуль `Технич. описание`;
 - разработка интеграции с калькуляциями до получения подробного описания API;
 - изменение бизнес-логики ДКР.

+ 2 - 2
docs/refactor/tz-dkr.md

@@ -172,7 +172,7 @@
 | График монтажей | Использует площадки ДКР | Сохранить текущую связь при переносе графика в группу `Графики`. |
 | Каталог ДКР | Используется МАФ и связанными позициями ДКР | Сохранить текущий каталог внутри ДКР. |
 | Каталог общий | Отдельный общий справочник платформы | Не смешивать с каталогом ДКР без отдельной миграции/синхронизации. |
-| Склад наличие | Будет использовать МАФ/позиции из общего каталога | Прямую зависимость склада от каталога ДКР не делать. |
+| Склад | Будет использовать МАФ/позиции из общего каталога | Прямую зависимость склада от каталога ДКР не делать. |
 
 ## 12. Права доступа
 
@@ -203,7 +203,7 @@
 - перенос текущих URL на новые адреса;
 - перенос каталога ДКР в `Каталог общий`;
 - разработка нового общего каталога;
-- разработка склада наличия;
+- разработка склада;
 - разработка графика заказов и графика доставок.
 
 ## 14. Этапы реализации

+ 6 - 6
docs/refactor/tz-spare-parts.md

@@ -6,7 +6,7 @@
 - [Карта нового меню](menu.md)
 - [Исходное ТЗ](source-tz.md)
 - [ТЗ: Рекламации](tz-reclamations.md)
-- [ТЗ: Склад наличие](tz-stock-availability.md)
+- [ТЗ: Склад](tz-stock-availability.md)
 
 ## 1. Назначение
 
@@ -62,7 +62,7 @@
 - текущие маршруты не переименовывать;
 - бизнес-логику не менять;
 - считать модуль общим для всех разделов Manager 2.0, включая ДКР;
-- использовать этот модуль как эталон для реализации `Склад наличие`.
+- использовать этот модуль как эталон для реализации `Склад`.
 
 ## 4. Права доступа
 
@@ -139,9 +139,9 @@
 - дефициты должны продолжить отображаться в рекламации.
 - рекламации ДКР используют общий модуль `Запчасти`, без отдельного контура запчастей внутри ДКР.
 
-## 10. Эталон для склада наличия
+## 10. Эталон для склада
 
-Раздел `Склад наличие` должен строиться по образцу `Запчасти`.
+Раздел `Склад` должен строиться по образцу `Запчасти`.
 
 Используемые паттерны:
 
@@ -158,7 +158,7 @@
 
 Отличие:
 
-- в `Склад наличие` вместо запчастей используются МАФ/позиции из `Каталог общий`.
+- в `Склад` вместо запчастей используются МАФ/позиции из `Каталог общий`.
 
 ## 11. Что не входит в первый этап
 
@@ -183,7 +183,7 @@
 - [x] Проверить контроль наличия.
 - [x] Проверить справочник расшифровок.
 - [x] Проверить связь с рекламациями.
-- [x] Зафиксировать логику модуля как основу для `Склад наличие`.
+- [x] Зафиксировать логику модуля как основу для `Склад`.
 
 ## 13. Критерии приемки
 

+ 38 - 57
docs/refactor/tz-stock-availability.md

@@ -1,4 +1,4 @@
-# ТЗ: Модуль Склад наличие
+# ТЗ: Модуль Склад
 
 Связанные документы:
 
@@ -10,36 +10,31 @@
 
 ## 1. Назначение
 
-`Склад наличие` — новый модуль для учета МАФ/позиций, которые есть на складе или заказаны.
+`Склад` — новый модуль для учета МАФ/позиций, которые есть на складе или заказаны.
 
 Модуль должен быть реализован полностью по образцу раздела `Запчасти`, с адаптацией под МАФ/позиции из `Каталог общий`.
 
 ## 2. Статус
 
-Статус модуля: **нужно реализовать**.
+Статус модуля: **ядро реализовано, ожидает пользовательской проверки**.
 
-На первом этапе в меню добавляются заглушки:
-
-- `Наличие`;
-- `Заказы`.
-
-Полная реализация выполняется после согласования модели данных и переноса паттернов из `Запчасти`.
+Реализованы `Наличие`, `Заказы`, карточка позиции, бронирование, списание, отмена, история движений, импорт и постоянные права. PDF-экспорт ожидает согласованного шаблона.
 
 ## 3. Место в меню
 
 Целевое меню:
 
 ```text
-Склад наличие
+Склад
 ├── Наличие
 └── Заказы
 ```
 
 Требования:
 
-- добавить верхний пункт `Склад наличие`;
+- добавить верхний пункт `Склад`;
 - добавить пункты `Наличие` и `Заказы`;
-- для первого этапа добавить страницы `В разработке`;
+- рабочие страницы размещаются под префиксом маршрутов `stock`;
 - выбранный год в CRM не влияет на отображение и данные модуля;
 - техническое имя маршрутов, таблиц и прав модуля: `stock`.
 
@@ -52,11 +47,10 @@
 | Наличие | Видно всем пользователям |
 | Заказы | Видит только админ и помощник руководителя (`assistant_head`) |
 
-На первом этапе:
+Реализованные права:
 
-- текущую систему прав не перерабатывать;
-- заглушки `Наличие` и `Заказы` показывать всем авторизованным пользователям;
-- при реализации добавить/назначить права согласно таблице выше.
+- штатные авторизованные роли получают просмотр наличия, бронирование, списание и отмену;
+- просмотр и управление заказами, а также импорт доступны администратору и `assistant_head`;
 - роль `помощник руководителя` в системе соответствует `assistant_head`.
 
 ## 5. Подраздел `Наличие`
@@ -193,6 +187,7 @@
 - редактирование заказа;
 - импорт;
 - изменение статуса;
+- массовый перевод всех заказанных позиций с выбранным номером заказа в статус `на складе`;
 - связь с позицией общего каталога;
 - расчет остатка по заказу;
 - отображение активных броней.
@@ -217,60 +212,46 @@
 
 ## 13. Модель данных
 
-Проектируется по образцу `Запчасти`.
+Реализация использует следующие соответствия:
 
-Предварительные сущности:
-
-- складская позиция наличия;
-- складской заказ;
-- бронь складской позиции;
-- история движений;
-- импорт складских заказов;
-- PDF-экспорт.
-
-Открыто:
+| `Запчасти` | `Склад` | Таблица |
+|---|---|---|
+| `SparePart` | `CommonCatalogItem` | `common_catalog_items` |
+| `SparePartOrder` | `StockOrder` | `stock_orders` |
+| `Reservation` | `StockReservation` | `stock_reservations` |
+| `InventoryMovement` | `StockMovement` | `stock_movements` |
 
-- точные имена таблиц и моделей;
-- использовать ли отдельную сущность наличия или рассчитывать ее из заказов;
-- как связать заказ склада с позицией общего каталога: по `product_id`, артикулу или обоим полям.
+Отдельная таблица остатков не создаётся: физическое наличие, активная бронь, доступный остаток и заказанное количество рассчитываются из заказов и броней. Заказ связан с общей позицией по `common_catalog_item_id`; при импорте позиция разрешается по уникальному артикулу общего каталога.
 
-## 14. Что не входит в первый этап
+## 14. Отложенная функциональность
 
-- полная реализация склада;
-- проектирование финальной БД;
 - PDF-шаблон;
 - интеграция с графиком отгрузок;
-- новые постоянные permissions.
-
-Первый этап:
 
-- добавить меню;
-- добавить заглушки, доступные всем авторизованным пользователям;
-- подготовить ТЗ;
-- использовать `Запчасти` как эталон.
+Ядро склада, финальная схема таблиц и постоянные permissions входят в текущую реализацию.
 
 ## 15. Этапы реализации
 
-- [ ] Разобрать текущие модели и сервисы `Запчасти`.
-- [ ] Составить карту соответствия сущностей `Запчасти` -> `Склад наличие`.
-- [ ] Согласовать имя модуля в маршрутах и таблицах.
-- [ ] Спроектировать таблицы.
-- [ ] Добавить модели.
-- [ ] Реализовать `Заказы`.
-- [ ] Реализовать `Наличие`.
-- [ ] Реализовать карточку позиции.
-- [ ] Реализовать бронирование.
-- [ ] Реализовать списание.
-- [ ] Реализовать отмену брони.
-- [ ] Реализовать историю.
-- [ ] Реализовать импорт.
+- [x] Разобрать текущие модели и сервисы `Запчасти`.
+- [x] Составить карту соответствия сущностей `Запчасти` -> `Склад`.
+- [x] Согласовать имя модуля в маршрутах и таблицах.
+- [x] Спроектировать таблицы.
+- [x] Добавить модели.
+- [x] Реализовать `Заказы`.
+- [x] Реализовать `Наличие`.
+- [x] Реализовать карточку позиции.
+- [x] Реализовать бронирование.
+- [x] Реализовать списание.
+- [x] Реализовать отмену брони.
+- [x] Реализовать историю.
+- [x] Реализовать импорт.
 - [ ] Реализовать PDF-экспорт.
-- [ ] Настроить права доступа.
-- [ ] Покрыть расчеты остатков тестами.
+- [x] Настроить права доступа.
+- [x] Покрыть расчеты остатков тестами.
 
 ## 16. Критерии приемки
 
-- В меню есть `Склад наличие`.
+- В меню есть `Склад`.
 - Есть подвкладки `Наличие` и `Заказы`.
 - `Наличие` доступно всем пользователям.
 - `Заказы` доступны админу и помощнику руководителя.
@@ -280,7 +261,7 @@
 - Списание уменьшает остаток соответствующего заказа.
 - Отмена брони возвращает доступный остаток.
 - История операций сохраняется.
-- PDF-экспорт формируется по шаблону.
+- PDF-экспорт формируется по шаблону после его согласования и передачи в проект.
 
 ## 17. Открытые вопросы
 

+ 15 - 9
resources/views/layouts/menu.blade.php

@@ -103,15 +103,21 @@
         </li>
     @endif
 
-    <li class="nav-item dropdown">
-        <a class="nav-link dropdown-toggle @if($stockActive) active @endif" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
-            Склад наличие
-        </a>
-        <ul class="dropdown-menu">
-            <li><a class="dropdown-item @if(($active ?? '') === 'stock') active @endif" href="{{ route('stock.index') }}">Наличие</a></li>
-            <li><a class="dropdown-item @if(($active ?? '') === 'stock_orders') active @endif" href="{{ route('stock.orders') }}">Заказы</a></li>
-        </ul>
-    </li>
+    @if(hasAnyPermission(['stock.view', 'stock.orders.view']))
+        <li class="nav-item dropdown">
+            <a class="nav-link dropdown-toggle @if($stockActive) active @endif" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
+                Склад
+            </a>
+            <ul class="dropdown-menu">
+                @if(hasPermission('stock.view'))
+                    <li><a class="dropdown-item @if(($active ?? '') === 'stock') active @endif" href="{{ route('stock.index') }}">Наличие</a></li>
+                @endif
+                @if(hasPermission('stock.orders.view'))
+                    <li><a class="dropdown-item @if(($active ?? '') === 'stock_orders') active @endif" href="{{ route('stock.orders') }}">Заказы</a></li>
+                @endif
+            </ul>
+        </li>
+    @endif
 
     @if(hasPermission('common-catalog.view'))
         <li class="nav-item">

+ 88 - 0
resources/views/stock/index.blade.php

@@ -0,0 +1,88 @@
+@extends('layouts.app')
+
+@section('content')
+    <div class="row mb-3 align-items-center">
+        <div class="col-md-5"><h3 class="mb-0">Склад</h3></div>
+        <div class="col-md-7">
+            <form method="GET" action="{{ route('stock.index') }}" class="d-flex flex-wrap gap-2 justify-content-md-end">
+                <input type="search" name="search" value="{{ $search }}" class="form-control form-control-sm"
+                       style="max-width: 280px" placeholder="Артикул, наименование или вид">
+                <select name="kind" class="form-select form-select-sm" style="max-width: 210px">
+                    <option value="">Все виды</option>
+                    @foreach($kinds as $kindOption)<option value="{{ $kindOption }}" @selected($kind === $kindOption)>{{ $kindOption }}</option>@endforeach
+                </select>
+                <select name="status" class="form-select form-select-sm" style="max-width: 150px">
+                    <option value="">Все статусы</option>
+                    @foreach($statuses as $value => $label)<option value="{{ $value }}" @selected($status === $value)>{{ $label }}</option>@endforeach
+                </select>
+                <button class="btn btn-sm btn-outline-primary" type="submit"><i class="bi bi-search"></i> Найти</button>
+                @if($search !== '' || $kind !== '' || $status !== '')
+                    <a class="btn btn-sm btn-outline-secondary" href="{{ route('stock.index') }}">Сбросить</a>
+                @endif
+            </form>
+        </div>
+    </div>
+
+    <ul class="nav nav-tabs mb-3">
+        <li class="nav-item"><a class="nav-link active" href="{{ route('stock.index') }}">Наличие</a></li>
+        @if(hasPermission('stock.orders.view'))
+            <li class="nav-item"><a class="nav-link" href="{{ route('stock.orders') }}">Заказы</a></li>
+        @endif
+    </ul>
+
+    <div class="table-responsive">
+        <table class="table table-sm table-hover table-bordered align-middle">
+            <thead class="table-light">
+            <tr>
+                <th>Картинка</th>
+                <th>Артикул</th>
+                <th>Наименование</th>
+                <th>Характеристики</th>
+                <th>Ед.</th>
+                <th class="text-end">На складе</th>
+                <th class="text-end">Забронировано</th>
+                <th class="text-end">Остаток</th>
+                <th class="text-end">Заказано</th>
+                <th>Примечание</th>
+            </tr>
+            </thead>
+            <tbody>
+            @forelse($items as $item)
+                @php
+                    $physical = (int) ($item->physical_quantity ?? 0);
+                    $reserved = (int) ($item->reserved_quantity ?? 0);
+                @endphp
+                <tr role="button" ondblclick="window.location='{{ route('stock.show', $item) }}'"
+                    title="Двойной клик — открыть карточку">
+                    <td style="width: 72px">
+                        @if($item->imageFile)
+                            <a href="{{ $item->imageFile->link }}" data-toggle="lightbox" data-gallery="stock">
+                                <img src="{{ $item->imageFile->thumbnail_link }}" alt="{{ $item->article }}"
+                                     class="img-thumbnail" style="width: 56px; height: 56px; object-fit: contain">
+                            </a>
+                        @else
+                            <span class="text-muted"><i class="bi bi-image"></i></span>
+                        @endif
+                    </td>
+                    <td>{{ $item->article }}</td>
+                    <td>{{ $item->calculator_name }}</td>
+                    <td>
+                        {{ $item->kind }}
+                        @if($item->dimensions)<div class="small text-muted">{{ $item->dimensions }}</div>@endif
+                    </td>
+                    <td>{{ $item->unit }}</td>
+                    <td class="text-end">{{ $physical }}</td>
+                    <td class="text-end">{{ $reserved }}</td>
+                    <td class="text-end fw-semibold">{{ max(0, $physical - $reserved) }}</td>
+                    <td class="text-end">{{ (int) ($item->ordered_stock_quantity ?? 0) }}</td>
+                    <td>{{ $item->latestStockOrder?->note }}</td>
+                </tr>
+            @empty
+                <tr><td colspan="10" class="text-center text-muted py-4">Складские позиции не найдены.</td></tr>
+            @endforelse
+            </tbody>
+        </table>
+    </div>
+
+    @include('partials.pagination', ['items' => $items])
+@endsection

+ 143 - 0
resources/views/stock/orders.blade.php

@@ -0,0 +1,143 @@
+@extends('layouts.app')
+
+@section('content')
+    <div class="row mb-3 align-items-center">
+        <div class="col-md-4"><h3 class="mb-0">Заказы склада</h3></div>
+        <div class="col-md-8 text-md-end">
+            @if(hasPermission('stock.orders.manage'))
+                <button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#stockOrderSetInStockModal">
+                    <i class="bi bi-box-seam"></i> Весь заказ на складе
+                </button>
+            @endif
+            @if(hasPermission('stock.orders.import'))
+                <button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#stockImportModal">
+                    <i class="bi bi-upload"></i> Импорт
+                </button>
+            @endif
+            @if(hasPermission('stock.orders.manage'))
+                <button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#stockOrderCreateModal">
+                    <i class="bi bi-plus-lg"></i> Добавить
+                </button>
+            @endif
+        </div>
+    </div>
+
+    <ul class="nav nav-tabs mb-3">
+        <li class="nav-item"><a class="nav-link" href="{{ route('stock.index') }}">Наличие</a></li>
+        <li class="nav-item"><a class="nav-link active" href="{{ route('stock.orders') }}">Заказы</a></li>
+    </ul>
+
+    <form method="GET" action="{{ route('stock.orders') }}" class="d-flex flex-wrap gap-2 mb-3">
+        <input type="search" name="search" value="{{ $search }}" class="form-control form-control-sm"
+               style="max-width: 420px" placeholder="Номер заказа, артикул, наименование">
+        <select name="status" class="form-select form-select-sm" style="max-width: 180px">
+            <option value="">Все статусы</option>
+            @foreach($statuses as $value => $label)<option value="{{ $value }}" @selected($status === $value)>{{ $label }}</option>@endforeach
+        </select>
+        <button class="btn btn-sm btn-outline-primary" type="submit"><i class="bi bi-search"></i> Найти</button>
+        @if($search !== '' || $status !== '')<a class="btn btn-sm btn-outline-secondary" href="{{ route('stock.orders') }}">Сбросить</a>@endif
+    </form>
+
+    <div class="table-responsive">
+        <table class="table table-sm table-bordered table-hover align-middle">
+            <thead class="table-light">
+            <tr>
+                <th>№ заказа</th><th>Артикул / наименование</th><th>Статус</th>
+                <th class="text-end">Заказано</th><th class="text-end">Бронь</th><th class="text-end">Остаток</th>
+                <th>Примечание</th><th>Добавлен</th><th></th>
+            </tr>
+            </thead>
+            <tbody>
+            @forelse($orders as $order)
+                <tr>
+                    <td>{{ $order->order_number }}</td>
+                    <td><a href="{{ route('stock.show', $order->item) }}">{{ $order->item->article }}</a><div class="small text-muted">{{ $order->item->calculator_name }}</div></td>
+                    <td>{{ $order->status_name }}</td>
+                    <td class="text-end">{{ $order->ordered_quantity }}</td>
+                    <td class="text-end">{{ $order->reserved_quantity }}</td>
+                    <td class="text-end fw-semibold">{{ $order->free_quantity }}</td>
+                    <td>{{ $order->note }}</td>
+                    <td class="text-nowrap">{{ $order->created_at?->format('d.m.Y H:i') }}</td>
+                    <td class="text-nowrap">
+                        @if(hasPermission('stock.orders.manage'))
+                            <button type="button" class="btn btn-sm btn-outline-primary" data-bs-toggle="modal"
+                                    data-bs-target="#stockOrderEdit{{ $order->id }}" title="Редактировать"><i class="bi bi-pencil"></i></button>
+                            <form action="{{ route('stock.orders.destroy', $order) }}" method="POST" class="d-inline"
+                                  onsubmit="return confirm('Удалить складской заказ?')">
+                                @csrf @method('DELETE')
+                                <button class="btn btn-sm btn-outline-danger" title="Удалить"><i class="bi bi-trash"></i></button>
+                            </form>
+                        @endif
+                    </td>
+                </tr>
+            @empty
+                <tr><td colspan="9" class="text-center text-muted py-4">Заказы не найдены.</td></tr>
+            @endforelse
+            </tbody>
+        </table>
+    </div>
+    @include('partials.pagination', ['items' => $orders])
+
+    @if(hasPermission('stock.orders.manage'))
+        <div class="modal fade" id="stockOrderSetInStockModal" tabindex="-1" aria-hidden="true">
+            <div class="modal-dialog"><div class="modal-content">
+                <div class="modal-header"><h5 class="modal-title">Отметить весь заказ как «На складе»</h5><button class="btn-close" data-bs-dismiss="modal"></button></div>
+                <form action="{{ route('stock.orders.set-in-stock') }}" method="POST">
+                    @csrf
+                    <div class="modal-body">
+                        <label for="bulk_order_number" class="form-label small">Номер заказа</label>
+                        <select id="bulk_order_number" name="bulk_order_number" class="form-select form-select-sm @error('bulk_order_number') is-invalid @enderror" required>
+                            <option value="">Выберите номер заказа</option>
+                            @foreach($orderedOrderNumbers as $orderNumber)
+                                <option value="{{ $orderNumber }}" @selected(old('bulk_order_number') === $orderNumber)>{{ $orderNumber }}</option>
+                            @endforeach
+                        </select>
+                        <div class="form-text">Доступны только номера с позициями в статусе «Заказан».</div>
+                        @error('bulk_order_number')<div class="invalid-feedback">{{ $message }}</div>@enderror
+                    </div>
+                    <div class="modal-footer"><button class="btn btn-sm btn-secondary" type="button" data-bs-dismiss="modal">Отмена</button><button class="btn btn-sm btn-primary" @disabled($orderedOrderNumbers->isEmpty())>На склад</button></div>
+                </form>
+            </div></div>
+        </div>
+
+        <div class="modal fade" id="stockOrderCreateModal" tabindex="-1" aria-hidden="true">
+            <div class="modal-dialog"><div class="modal-content">
+                <div class="modal-header"><h5 class="modal-title">Новый складской заказ</h5><button class="btn-close" data-bs-dismiss="modal"></button></div>
+                <form action="{{ route('stock.orders.store') }}" method="POST">
+                    @csrf
+                    <div class="modal-body">@include('stock.partials.order-form', ['order' => null])</div>
+                    <div class="modal-footer"><button class="btn btn-sm btn-secondary" type="button" data-bs-dismiss="modal">Отмена</button><button class="btn btn-sm btn-primary">Создать</button></div>
+                </form>
+            </div></div>
+        </div>
+
+        @foreach($orders as $order)
+            <div class="modal fade" id="stockOrderEdit{{ $order->id }}" tabindex="-1" aria-hidden="true">
+                <div class="modal-dialog"><div class="modal-content">
+                    <div class="modal-header"><h5 class="modal-title">Заказ {{ $order->order_number }}</h5><button class="btn-close" data-bs-dismiss="modal"></button></div>
+                    <form action="{{ route('stock.orders.update', $order) }}" method="POST">
+                        @csrf @method('PUT')
+                        <div class="modal-body">@include('stock.partials.order-form', ['order' => $order])</div>
+                        <div class="modal-footer"><button class="btn btn-sm btn-secondary" type="button" data-bs-dismiss="modal">Отмена</button><button class="btn btn-sm btn-primary">Сохранить</button></div>
+                    </form>
+                </div></div>
+            </div>
+        @endforeach
+    @endif
+
+    @if(hasPermission('stock.orders.import'))
+        <div class="modal fade" id="stockImportModal" tabindex="-1" aria-hidden="true">
+            <div class="modal-dialog"><div class="modal-content">
+                <div class="modal-header"><h5 class="modal-title">Импорт складских заказов</h5><button class="btn-close" data-bs-dismiss="modal"></button></div>
+                <form action="{{ route('stock.orders.import') }}" method="POST" enctype="multipart/form-data">
+                    @csrf
+                    <div class="modal-body">
+                        <p class="small text-muted">XLSX: Номер заказа, Артикул, Статус, Кол-во заказано, Примечание. Повторная строка обновляет заказ по номеру и артикулу.</p>
+                        <input class="form-control form-control-sm" type="file" name="import_file" accept=".xlsx" required>
+                    </div>
+                    <div class="modal-footer"><button class="btn btn-sm btn-secondary" type="button" data-bs-dismiss="modal">Отмена</button><button class="btn btn-sm btn-primary">Импортировать</button></div>
+                </form>
+            </div></div>
+        </div>
+    @endif
+@endsection

+ 17 - 0
resources/views/stock/partials/order-form.blade.php

@@ -0,0 +1,17 @@
+<div class="mb-3">
+    <label class="form-label small">Позиция общего каталога</label>
+    <select name="common_catalog_item_id" class="form-select form-select-sm" required>
+        <option value="">Выберите позицию</option>
+        @foreach($items as $catalogItem)
+            <option value="{{ $catalogItem->id }}" @selected((int) old('common_catalog_item_id', $order?->common_catalog_item_id) === $catalogItem->id)>
+                {{ $catalogItem->article }} — {{ $catalogItem->calculator_name }}
+            </option>
+        @endforeach
+    </select>
+</div>
+<div class="mb-3"><label class="form-label small">Номер заказа</label><input class="form-control form-control-sm" name="order_number" value="{{ old('order_number', $order?->order_number) }}" maxlength="255" required></div>
+<div class="row">
+    <div class="col-6 mb-3"><label class="form-label small">Статус</label><select name="status" class="form-select form-select-sm" required>@foreach($statuses as $value => $label)<option value="{{ $value }}" @selected(old('status', $order?->status ?? 'ordered') === $value)>{{ $label }}</option>@endforeach</select></div>
+    <div class="col-6 mb-3"><label class="form-label small">Кол-во заказано</label><input class="form-control form-control-sm" type="number" name="ordered_quantity" min="1" value="{{ old('ordered_quantity', $order?->ordered_quantity) }}" required></div>
+</div>
+<div class="mb-3"><label class="form-label small">Примечание</label><textarea class="form-control form-control-sm" name="note" rows="3" maxlength="2000">{{ old('note', $order?->note) }}</textarea></div>

+ 80 - 0
resources/views/stock/show.blade.php

@@ -0,0 +1,80 @@
+@extends('layouts.app')
+
+@section('content')
+    <div class="d-flex justify-content-between align-items-center mb-3">
+        <h3 class="mb-0">Склад: {{ $item->article }}</h3>
+        <a href="{{ route('stock.index') }}" class="btn btn-sm btn-outline-secondary">Назад к наличию</a>
+    </div>
+
+    <div class="card mb-3"><div class="card-body">
+        <div class="row g-3">
+            <div class="col-md-2">
+                @if($item->imageFile)
+                    <a href="{{ $item->imageFile->link }}" data-toggle="lightbox" data-gallery="stock-card"><img src="{{ $item->imageFile->thumbnail_link }}" alt="{{ $item->article }}" class="img-thumbnail w-100"></a>
+                @else
+                    <div class="border rounded text-muted d-flex align-items-center justify-content-center" style="min-height: 140px"><i class="bi bi-image fs-1"></i></div>
+                @endif
+            </div>
+            <div class="col-md-5">
+                <dl class="row mb-0 small">
+                    <dt class="col-4">Артикул</dt><dd class="col-8">{{ $item->article }}</dd>
+                    <dt class="col-4">Наименование</dt><dd class="col-8">{{ $item->calculator_name }}</dd>
+                    <dt class="col-4">Вид</dt><dd class="col-8">{{ $item->kind }}</dd>
+                    <dt class="col-4">Габариты</dt><dd class="col-8">{{ $item->dimensions }}</dd>
+                    <dt class="col-4">Ед.</dt><dd class="col-8">{{ $item->unit }}</dd>
+                    <dt class="col-4">Примечание</dt><dd class="col-8">{{ $item->latestStockOrder?->note }}</dd>
+                </dl>
+            </div>
+            <div class="col-md-5">
+                <div class="row text-center g-2">
+                    @foreach(['physical' => 'На складе', 'reserved' => 'Забронировано', 'available' => 'Остаток', 'ordered' => 'Заказано'] as $key => $label)
+                        <div class="col-6"><div class="border rounded p-3"><div class="small text-muted">{{ $label }}</div><div class="fs-4 fw-semibold">{{ $summary[$key] }}</div></div></div>
+                    @endforeach
+                </div>
+            </div>
+        </div>
+    </div></div>
+
+    @if(hasPermission('stock.reserve'))
+        <div class="card mb-3">
+            <div class="card-header"><strong>Забронировать</strong></div>
+            <div class="card-body">
+                <form action="{{ route('stock.reserve', $item) }}" method="POST" class="row g-2 align-items-end">
+                    @csrf
+                    <div class="col-md-4"><label class="form-label small">Менеджер</label><select name="manager_id" class="form-select form-select-sm" required><option value="">Выберите</option>@foreach($managers as $manager)<option value="{{ $manager->id }}" @selected((int) old('manager_id') === $manager->id)>{{ $manager->name }}</option>@endforeach</select></div>
+                    <div class="col-md-2"><label class="form-label small">Количество</label><input type="number" min="1" max="{{ $summary['available'] }}" name="quantity" value="{{ old('quantity') }}" class="form-control form-control-sm @error('quantity') is-invalid @enderror" required>@error('quantity')<div class="invalid-feedback">{{ $message }}</div>@enderror</div>
+                    <div class="col-md-4"><label class="form-label small">Примечание</label><input name="note" value="{{ old('note') }}" maxlength="2000" class="form-control form-control-sm"></div>
+                    <div class="col-md-2"><button class="btn btn-sm btn-primary w-100" @disabled($summary['available'] === 0)>Забронировать</button></div>
+                </form>
+            </div>
+        </div>
+    @endif
+
+    <div class="card mb-3">
+        <div class="card-header"><strong>Забронировано</strong></div>
+        <div class="table-responsive"><table class="table table-sm table-bordered mb-0 align-middle">
+            <thead class="table-light"><tr><th>Менеджер</th><th>Количество</th><th>№ заказа</th><th>Примечание</th><th>Дата</th><th></th></tr></thead>
+            <tbody>
+            @forelse($item->stockReservations->where('status', \App\Models\StockReservation::STATUS_ACTIVE) as $reservation)
+                <tr><td>{{ $reservation->manager?->name }}</td><td>{{ $reservation->quantity }}</td><td>{{ $reservation->order?->order_number }}</td><td>{{ $reservation->note }}</td><td>{{ $reservation->created_at?->format('d.m.Y H:i') }}</td>
+                    <td class="text-nowrap">
+                        @if(hasPermission('stock.issue'))<form action="{{ route('stock.reservations.issue', $reservation) }}" method="POST" class="d-inline" onsubmit="return confirm('Списать забронированную позицию?')">@csrf<button class="btn btn-sm btn-outline-success">Списать</button></form>@endif
+                        @if(hasPermission('stock.cancel'))<form action="{{ route('stock.reservations.cancel', $reservation) }}" method="POST" class="d-inline" onsubmit="return confirm('Отменить бронь?')">@csrf<button class="btn btn-sm btn-outline-danger">Отменить</button></form>@endif
+                    </td></tr>
+            @empty
+                <tr><td colspan="6" class="text-center text-muted py-3">Активных броней нет.</td></tr>
+            @endforelse
+            </tbody>
+        </table></div>
+    </div>
+
+    <div class="card mb-3"><div class="card-header"><strong>Заказы</strong></div><div class="table-responsive"><table class="table table-sm table-bordered mb-0">
+        <thead class="table-light"><tr><th>№</th><th>Статус</th><th>Заказано</th><th>Бронь</th><th>Остаток</th><th>Примечание</th></tr></thead><tbody>
+        @forelse($item->stockOrders as $order)<tr><td>{{ $order->order_number }}</td><td>{{ $order->status_name }}</td><td>{{ $order->ordered_quantity }}</td><td>{{ $order->reserved_quantity }}</td><td>{{ $order->free_quantity }}</td><td>{{ $order->note }}</td></tr>@empty<tr><td colspan="6" class="text-center text-muted py-3">Заказов нет.</td></tr>@endforelse
+        </tbody></table></div></div>
+
+    <div class="card"><div class="card-header"><strong>История движений</strong></div><div class="table-responsive"><table class="table table-sm table-bordered mb-0">
+        <thead class="table-light"><tr><th>Дата</th><th>Операция</th><th>Количество</th><th>№ заказа</th><th>Пользователь</th><th>Примечание</th></tr></thead><tbody>
+        @forelse($item->stockMovements as $movement)<tr><td>{{ $movement->created_at?->format('d.m.Y H:i') }}</td><td>{{ $movement->type_name }}</td><td>{{ $movement->quantity }}</td><td>{{ $movement->order?->order_number }}</td><td>{{ $movement->user?->name }}</td><td>{{ $movement->note }}</td></tr>@empty<tr><td colspan="6" class="text-center text-muted py-3">Операций нет.</td></tr>@endforelse
+        </tbody></table></div></div>
+@endsection

+ 29 - 36
routes/web.php

@@ -3,16 +3,15 @@
 use App\Http\Controllers\Admin\AdminAreaController;
 use App\Http\Controllers\Admin\AdminDistrictController;
 use App\Http\Controllers\Admin\AdminNotificationLogController;
-use App\Http\Controllers\Admin\RoleController;
 use App\Http\Controllers\Admin\AdminSettingsController;
-use App\Http\Controllers\ChatMessageController;
+use App\Http\Controllers\Admin\RoleController;
 use App\Http\Controllers\AreaController;
+use App\Http\Controllers\ChatMessageController;
 use App\Http\Controllers\ClearDataController;
 use App\Http\Controllers\CommonCatalogController;
+use App\Http\Controllers\ContractController;
 use App\Http\Controllers\ContractorController;
 use App\Http\Controllers\DocumentationController;
-use App\Http\Controllers\YearDataController;
-use App\Http\Controllers\ContractController;
 use App\Http\Controllers\FilterController;
 use App\Http\Controllers\ImportController;
 use App\Http\Controllers\MafOrderController;
@@ -28,9 +27,12 @@ use App\Http\Controllers\SparePartController;
 use App\Http\Controllers\SparePartInventoryController;
 use App\Http\Controllers\SparePartOrderController;
 use App\Http\Controllers\SparePartReservationController;
+use App\Http\Controllers\StockController;
+use App\Http\Controllers\StockOrderController;
 use App\Http\Controllers\UnderDevelopmentController;
 use App\Http\Controllers\UserController;
 use App\Http\Controllers\UserNotificationController;
+use App\Http\Controllers\YearDataController;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\Route;
@@ -41,7 +43,7 @@ Route::get('/', function () {
 
 Auth::routes(['register' => false, 'reset' => false, 'verify' => false, 'confirm' => false]);
 
-Route::post('keep-token-alive', function() {
+Route::post('keep-token-alive', function () {
     return 'Session extended.';
 });
 
@@ -62,7 +64,7 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
         $targetHost = parse_url((string) $targetUrl, PHP_URL_HOST);
         $currentHost = $request->getHost();
 
-        if (!empty($targetUrl) && $targetUrl !== $currentUrl && (!$targetHost || $targetHost === $currentHost)) {
+        if (! empty($targetUrl) && $targetUrl !== $currentUrl && (! $targetHost || $targetHost === $currentHost)) {
             return redirect()->to($targetUrl);
         }
 
@@ -70,14 +72,14 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
     })->name('set-year');
 
     // admin routes
-    Route::prefix('admin')->group(function (){
-        Route::prefix('users')->group(function (){
+    Route::prefix('admin')->group(function () {
+        Route::prefix('users')->group(function () {
             Route::get('', [UserController::class, 'index'])->name('user.index');
             Route::get('create', [UserController::class, 'create'])->name('user.create');
             Route::get('{user}', [UserController::class, 'show'])->name('user.show');
             Route::post('', [UserController::class, 'store'])->name('user.store');
             Route::post('{user}/impersonate', [UserController::class, 'impersonate'])->name('user.impersonate');
-//            Route::put('{user}', [UserController::class, 'update'])->name('user.update');
+            //            Route::put('{user}', [UserController::class, 'update'])->name('user.update');
             Route::delete('{user}', [UserController::class, 'destroy'])->name('user.destroy');
             Route::post('undelete/{user}', [UserController::class, 'undelete'])->name('user.undelete');
         });
@@ -93,19 +95,19 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
             Route::delete('{role}', [RoleController::class, 'destroy'])->name('destroy');
         });
 
-        Route::prefix('import')->group(function (){
+        Route::prefix('import')->group(function () {
             Route::get('', [ImportController::class, 'index'])->name('import.index');
             Route::get('{import}', [ImportController::class, 'show'])->name('import.show');
             Route::post('', [ImportController::class, 'store'])->name('import.create');
         });
 
-        Route::prefix('clear-data')->group(function (){
+        Route::prefix('clear-data')->group(function () {
             Route::get('', [ClearDataController::class, 'index'])->name('clear-data.index');
             Route::get('stats', [ClearDataController::class, 'stats'])->name('clear-data.stats');
             Route::delete('', [ClearDataController::class, 'destroy'])->name('clear-data.destroy');
         });
 
-        Route::prefix('year-data')->group(function (){
+        Route::prefix('year-data')->group(function () {
             Route::get('', [YearDataController::class, 'index'])->name('year-data.index');
             Route::get('stats', [YearDataController::class, 'stats'])->name('year-data.stats');
             Route::post('export', [YearDataController::class, 'export'])->name('year-data.export');
@@ -120,7 +122,7 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
         Route::get('notifications/log', [AdminNotificationLogController::class, 'index'])->name('admin.notifications.log');
 
         // Справочник округов
-        Route::prefix('districts')->group(function (){
+        Route::prefix('districts')->group(function () {
             Route::get('', [AdminDistrictController::class, 'index'])->name('admin.district.index');
             Route::post('', [AdminDistrictController::class, 'store'])->name('admin.district.store');
             Route::post('export', [AdminDistrictController::class, 'export'])->name('admin.district.export');
@@ -131,7 +133,7 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
         });
 
         // Справочник районов
-        Route::prefix('areas-dict')->group(function (){
+        Route::prefix('areas-dict')->group(function () {
             Route::get('', [AdminAreaController::class, 'index'])->name('admin.area.index');
             Route::post('', [AdminAreaController::class, 'store'])->name('admin.area.store');
             Route::post('export', [AdminAreaController::class, 'export'])->name('admin.area.export');
@@ -181,14 +183,17 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
     });
 
     Route::prefix('stock')->name('stock.')->group(function () {
-        Route::get('', UnderDevelopmentController::class)
-            ->defaults('title', 'Склад наличие')
-            ->defaults('active', 'stock')
-            ->name('index');
-        Route::get('orders', UnderDevelopmentController::class)
-            ->defaults('title', 'Заказы склада')
-            ->defaults('active', 'stock_orders')
-            ->name('orders');
+        Route::get('', [StockController::class, 'index'])->name('index');
+        Route::get('items/{item}', [StockController::class, 'show'])->name('show');
+        Route::post('items/{item}/reservations', [StockController::class, 'reserve'])->name('reserve');
+        Route::post('reservations/{reservation}/issue', [StockController::class, 'issue'])->name('reservations.issue');
+        Route::post('reservations/{reservation}/cancel', [StockController::class, 'cancel'])->name('reservations.cancel');
+        Route::get('orders', [StockOrderController::class, 'index'])->name('orders');
+        Route::post('orders', [StockOrderController::class, 'store'])->name('orders.store');
+        Route::post('orders/set-in-stock', [StockOrderController::class, 'setOrderInStock'])->name('orders.set-in-stock');
+        Route::post('orders/import', [StockOrderController::class, 'import'])->name('orders.import');
+        Route::put('orders/{stockOrder}', [StockOrderController::class, 'update'])->name('orders.update');
+        Route::delete('orders/{stockOrder}', [StockOrderController::class, 'destroy'])->name('orders.destroy');
     });
 
     Route::prefix('documents')->name('documents.')->group(function () {
@@ -212,7 +217,7 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
         ->name('calculations.index');
 
     Route::group([], function () {
-        Route::prefix('responsibles')->group(function (){
+        Route::prefix('responsibles')->group(function () {
             Route::get('', [ResponsibleController::class, 'index'])->name('responsible.index');
             Route::get('{responsible}', [ResponsibleController::class, 'show'])->name('responsible.show');
             Route::post('store', [ResponsibleController::class, 'store'])->name('responsible.store');
@@ -222,7 +227,7 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
 
         // catalog
         Route::get('catalog', [ProductController::class, 'index'])->name('catalog.index');
-        Route::get('catalog/{product}', [ProductController::class, 'show'])->name('catalog.show')->where('product', '[0-9]+')   ;
+        Route::get('catalog/{product}', [ProductController::class, 'show'])->name('catalog.show')->where('product', '[0-9]+');
 
         // contracts
         Route::get('contract', [ContractController::class, 'index'])->name('contract.index');
@@ -289,13 +294,11 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
 
         Route::get('reports', [ReportController::class, 'index'])->name('reports.index');
 
-
     });
 
     Route::post('mafs-registry', [ProductSKUController::class, 'exportMafRegistry'])
         ->name('mafs.registry');
 
-
     // orders for all
     Route::get('order', [OrderController::class, 'index'])->name('order.index');
     Route::get('order/show/{order}', [OrderController::class, 'show'])->name('order.show');
@@ -321,8 +324,6 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
         Route::post('mafs-import', [ProductSKUController::class, 'importMaf'])->name('mafs.import');
         Route::post('mafs-export', [ProductSKUController::class, 'exportMaf'])->name('mafs.export');
 
-
-
         // Склад заказы МАФ
         Route::get('maf_orders', [MafOrderController::class, 'index'])->name('maf_order.index');
         Route::get('maf_orders/{maf_order}', [MafOrderController::class, 'show'])->name('maf_order.show');
@@ -347,7 +348,6 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
         Route::post('order/move-maf', [OrderController::class, 'moveMaf'])->name('order.move-maf');
         Route::post('order/create-ttn', [OrderController::class, 'createTtn'])->name('order.create-ttn');
 
-
         Route::delete('order/delete-all-photos/{order}', [OrderController::class, 'deleteAllPhotos'])->name('order.delete-all-photos');
         Route::delete('order/delete-all-documents/{order}', [OrderController::class, 'deleteAllDocuments'])->name('order.delete-all-documents');
         Route::delete('order/delete-all-statements/{order}', [OrderController::class, 'deleteAllStatements'])->name('order.delete-all-statements');
@@ -359,18 +359,12 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
     Route::post('reclamations/export', [ReclamationController::class, 'export'])
         ->name('reclamations.export');
 
-
-
-
-
-
     Route::get('reclamations', [ReclamationController::class, 'index'])->name('reclamations.index');
     Route::get('reclamations/show/{reclamation}', [ReclamationController::class, 'show'])->name('reclamations.show');
     Route::post('reclamations/{reclamation}/chat-messages', [ChatMessageController::class, 'storeForReclamation'])->name('reclamations.chat-messages.store');
     Route::delete('reclamations/{reclamation}/chat-messages/{chatMessage}', [ChatMessageController::class, 'destroyForReclamation'])
         ->name('reclamations.chat-messages.delete');
 
-
     Route::post('reclamations/{reclamation}/upload-photo-before', [ReclamationController::class, 'uploadPhotoBefore'])->name('reclamations.upload-photo-before');
     Route::post('reclamations/{reclamation}/upload-photo-after', [ReclamationController::class, 'uploadPhotoAfter'])->name('reclamations.upload-photo-after');
     Route::post('reclamations/{reclamation}/upload-act', [ReclamationController::class, 'uploadAct'])
@@ -382,7 +376,6 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
     Route::post('order/export', [OrderController::class, 'export'])
         ->name('order.export');
 
-
     // ajax search products
     Route::get('product/search', [ProductController::class, 'search'])->name('product.search');
 

+ 326 - 0
tests/Feature/StockControllerTest.php

@@ -0,0 +1,326 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Feature;
+
+use App\Jobs\Import\ImportJob;
+use App\Models\CommonCatalogItem;
+use App\Models\Role;
+use App\Models\StockMovement;
+use App\Models\StockOrder;
+use App\Models\StockReservation;
+use App\Models\User;
+use App\Services\StockInventoryService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Bus;
+use Illuminate\Support\Facades\Storage;
+use Tests\TestCase;
+
+class StockControllerTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected bool $seed = true;
+
+    private User $admin;
+
+    private User $manager;
+
+    private User $assistant;
+
+    private StockInventoryService $inventory;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->admin = User::factory()->create(['role' => Role::ADMIN]);
+        $this->manager = User::factory()->create(['role' => Role::MANAGER]);
+        $this->assistant = User::factory()->create(['role' => Role::ASSISTANT_HEAD]);
+        $this->inventory = app(StockInventoryService::class);
+    }
+
+    public function test_guest_cannot_open_stock(): void
+    {
+        $this->get(route('stock.index'))->assertRedirect(route('login'));
+    }
+
+    public function test_availability_is_year_independent_and_visible_to_regular_users(): void
+    {
+        $item = CommonCatalogItem::factory()->create(['article' => 'STOCK-001']);
+        $this->createOrder($item, 'A-1', 4);
+
+        $this->actingAs($this->manager)
+            ->withSession(['year' => 2019])
+            ->get(route('stock.index'))
+            ->assertOk()
+            ->assertViewIs('stock.index')
+            ->assertSeeText('Склад')
+            ->assertDontSeeText('Склад наличие')
+            ->assertSeeText('STOCK-001');
+    }
+
+    public function test_orders_are_available_only_to_admin_and_assistant_head(): void
+    {
+        $this->actingAs($this->manager)->get(route('stock.orders'))->assertForbidden();
+        $this->actingAs($this->admin)->get(route('stock.orders'))->assertOk();
+        $this->actingAs($this->assistant)->get(route('stock.orders'))->assertOk();
+    }
+
+    public function test_admin_can_create_order_linked_to_common_catalog(): void
+    {
+        $item = CommonCatalogItem::factory()->create();
+
+        $this->actingAs($this->admin)->post(route('stock.orders.store'), [
+            'common_catalog_item_id' => $item->id,
+            'order_number' => 'WH-100',
+            'status' => StockOrder::STATUS_IN_STOCK,
+            'ordered_quantity' => 8,
+            'note' => 'Первая партия',
+        ])->assertRedirect();
+
+        $this->assertDatabaseHas('stock_orders', [
+            'common_catalog_item_id' => $item->id,
+            'order_number' => 'WH-100',
+            'available_quantity' => 8,
+        ]);
+        $this->assertDatabaseHas('stock_movements', [
+            'common_catalog_item_id' => $item->id,
+            'movement_type' => StockMovement::TYPE_RECEIPT,
+            'quantity' => 8,
+        ]);
+    }
+
+    public function test_ordered_status_does_not_increase_physical_stock_until_receipt(): void
+    {
+        $item = CommonCatalogItem::factory()->create();
+        $order = $this->createOrder($item, 'WH-ORDERED', 9, StockOrder::STATUS_ORDERED);
+
+        $this->assertSame([
+            'physical' => 0,
+            'reserved' => 0,
+            'available' => 0,
+            'ordered' => 9,
+        ], $this->inventory->summary($item));
+
+        $this->actingAs($this->admin)->put(route('stock.orders.update', $order), [
+            'common_catalog_item_id' => $item->id,
+            'order_number' => $order->order_number,
+            'status' => StockOrder::STATUS_IN_STOCK,
+            'ordered_quantity' => 9,
+            'note' => null,
+        ])->assertRedirect();
+
+        $this->assertSame(9, $this->inventory->summary($item)['available']);
+    }
+
+    public function test_editing_order_casts_form_foreign_key_before_creating_receipt_movement(): void
+    {
+        $item = CommonCatalogItem::factory()->create();
+        $order = $this->inventory->createOrder([
+            'common_catalog_item_id' => (string) $item->id,
+            'order_number' => 'WH-STRING-ID',
+            'status' => StockOrder::STATUS_ORDERED,
+            'ordered_quantity' => '6',
+            'note' => null,
+        ], $this->admin);
+
+        $this->assertIsInt($order->common_catalog_item_id);
+
+        $this->actingAs($this->admin)->put(route('stock.orders.update', $order), [
+            'common_catalog_item_id' => (string) $item->id,
+            'order_number' => $order->order_number,
+            'status' => StockOrder::STATUS_IN_STOCK,
+            'ordered_quantity' => '6',
+            'note' => null,
+        ])->assertRedirect()->assertSessionHasNoErrors();
+
+        $this->assertDatabaseHas('stock_movements', [
+            'stock_order_id' => $order->id,
+            'common_catalog_item_id' => $item->id,
+            'movement_type' => StockMovement::TYPE_RECEIPT,
+            'quantity' => 6,
+        ]);
+    }
+
+    public function test_admin_can_set_all_ordered_positions_with_same_number_in_stock(): void
+    {
+        $firstItem = CommonCatalogItem::factory()->create();
+        $secondItem = CommonCatalogItem::factory()->create();
+        $alreadyReceivedItem = CommonCatalogItem::factory()->create();
+        $unrelatedItem = CommonCatalogItem::factory()->create();
+        $first = $this->createOrder($firstItem, 'WH-BULK', 2, StockOrder::STATUS_ORDERED);
+        $second = $this->createOrder($secondItem, 'WH-BULK', 3, StockOrder::STATUS_ORDERED);
+        $alreadyReceived = $this->createOrder($alreadyReceivedItem, 'WH-BULK', 4);
+        $unrelated = $this->createOrder($unrelatedItem, 'WH-OTHER', 5, StockOrder::STATUS_ORDERED);
+
+        $this->actingAs($this->admin)
+            ->post(route('stock.orders.set-in-stock'), ['bulk_order_number' => 'WH-BULK'])
+            ->assertRedirect()
+            ->assertSessionHas('success', 'Позиции заказа переведены в статус «На складе»: 2.');
+
+        $this->assertSame(StockOrder::STATUS_IN_STOCK, $first->refresh()->status);
+        $this->assertSame(2, $first->available_quantity);
+        $this->assertSame(StockOrder::STATUS_IN_STOCK, $second->refresh()->status);
+        $this->assertSame(3, $second->available_quantity);
+        $this->assertSame(StockOrder::STATUS_IN_STOCK, $alreadyReceived->refresh()->status);
+        $this->assertSame(StockOrder::STATUS_ORDERED, $unrelated->refresh()->status);
+        $this->assertDatabaseHas('stock_movements', [
+            'stock_order_id' => $first->id,
+            'movement_type' => StockMovement::TYPE_RECEIPT,
+            'quantity' => 2,
+        ]);
+        $this->assertDatabaseHas('stock_movements', [
+            'stock_order_id' => $second->id,
+            'movement_type' => StockMovement::TYPE_RECEIPT,
+            'quantity' => 3,
+        ]);
+        $this->assertSame(1, StockMovement::query()->where('stock_order_id', $alreadyReceived->id)->count());
+    }
+
+    public function test_bulk_receipt_button_lists_only_ordered_order_numbers(): void
+    {
+        $orderedItem = CommonCatalogItem::factory()->create();
+        $receivedItem = CommonCatalogItem::factory()->create();
+        $this->createOrder($orderedItem, 'WH-AVAILABLE', 2, StockOrder::STATUS_ORDERED);
+        $this->createOrder($receivedItem, 'WH-RECEIVED', 2);
+
+        $response = $this->actingAs($this->admin)
+            ->get(route('stock.orders'))
+            ->assertOk()
+            ->assertSeeText('Весь заказ на складе');
+
+        $response->assertViewHas('orderedOrderNumbers', fn ($numbers): bool => $numbers->all() === ['WH-AVAILABLE']);
+    }
+
+    public function test_manager_cannot_set_whole_stock_order_in_stock(): void
+    {
+        $this->actingAs($this->manager)
+            ->post(route('stock.orders.set-in-stock'), ['bulk_order_number' => 'WH-BULK'])
+            ->assertForbidden();
+    }
+
+    public function test_bulk_receipt_reports_unknown_order_number(): void
+    {
+        $this->actingAs($this->admin)
+            ->post(route('stock.orders.set-in-stock'), ['bulk_order_number' => 'WH-MISSING'])
+            ->assertSessionHasErrors([
+                'bulk_order_number' => 'Заказ со статусом «Заказан» не найден',
+            ]);
+    }
+
+    public function test_reservation_is_all_or_nothing_and_cannot_exceed_available_stock(): void
+    {
+        $item = CommonCatalogItem::factory()->create();
+        $this->createOrder($item, 'WH-1', 5);
+
+        $this->actingAs($this->manager)->post(route('stock.reserve', $item), [
+            'manager_id' => $this->manager->id,
+            'quantity' => 6,
+            'note' => 'Объект 15',
+        ])->assertSessionHasErrors(['quantity' => 'Такого кол-ва нет в наличии']);
+
+        $this->assertDatabaseCount('stock_reservations', 0);
+        $this->assertSame(5, $this->inventory->summary($item)['available']);
+    }
+
+    public function test_reservation_uses_fifo_orders_and_issue_and_cancel_keep_history(): void
+    {
+        $item = CommonCatalogItem::factory()->create();
+        $first = $this->createOrder($item, 'WH-1', 5);
+        $second = $this->createOrder($item, 'WH-2', 5);
+
+        $reservations = $this->inventory->reserve($item, $this->manager, 7, 'Объект 21', $this->admin);
+
+        $this->assertCount(2, $reservations);
+        $this->assertSame([$first->id, $second->id], $reservations->pluck('stock_order_id')->all());
+        $this->assertSame([5, 2], $reservations->pluck('quantity')->all());
+        $this->assertSame(3, $this->inventory->summary($item)['available']);
+
+        $this->inventory->issue($reservations[0], $this->admin);
+        $first->refresh();
+        $this->assertSame(StockOrder::STATUS_SHIPPED, $first->status);
+        $this->assertSame(StockReservation::STATUS_ISSUED, $reservations[0]->refresh()->status);
+        $this->assertSame(3, $this->inventory->summary($item)['available']);
+
+        $this->inventory->cancel($reservations[1], $this->admin, 'Заказ клиента отменён');
+        $this->assertSame(StockReservation::STATUS_CANCELLED, $reservations[1]->refresh()->status);
+        $this->assertSame(5, $this->inventory->summary($item)['available']);
+        $this->assertDatabaseHas('stock_movements', [
+            'stock_order_id' => $first->id,
+            'movement_type' => StockMovement::TYPE_ISSUE,
+            'quantity' => 5,
+        ]);
+        $this->assertDatabaseHas('stock_movements', [
+            'stock_order_id' => $second->id,
+            'movement_type' => StockMovement::TYPE_RESERVE_CANCEL,
+            'quantity' => 2,
+        ]);
+    }
+
+    public function test_order_with_active_reservation_cannot_be_manually_shipped(): void
+    {
+        $item = CommonCatalogItem::factory()->create();
+        $order = $this->createOrder($item, 'WH-LOCKED', 4);
+        $this->inventory->reserve($item, $this->manager, 1, null, $this->admin);
+
+        $this->actingAs($this->admin)->put(route('stock.orders.update', $order), [
+            'common_catalog_item_id' => $item->id,
+            'order_number' => $order->order_number,
+            'status' => StockOrder::STATUS_SHIPPED,
+            'ordered_quantity' => 4,
+            'note' => null,
+        ])->assertSessionHas('error', 'Нельзя изменить статус заказа с активной бронью');
+
+        $this->assertSame(StockOrder::STATUS_IN_STOCK, $order->refresh()->status);
+        $this->assertSame(3, $this->inventory->summary($item)['available']);
+    }
+
+    public function test_stock_card_displays_orders_reservations_and_movement_history(): void
+    {
+        $item = CommonCatalogItem::factory()->create(['article' => 'CARD-001']);
+        $this->createOrder($item, 'CARD-ORDER', 3);
+        $this->inventory->reserve($item, $this->manager, 1, 'Бронь карточки', $this->admin);
+
+        $this->actingAs($this->manager)
+            ->get(route('stock.show', $item))
+            ->assertOk()
+            ->assertSeeText('CARD-001')
+            ->assertSeeText('CARD-ORDER')
+            ->assertSeeText('Бронь карточки')
+            ->assertSeeText('Бронирование');
+    }
+
+    public function test_admin_can_queue_xlsx_import(): void
+    {
+        Bus::fake();
+        Storage::fake('upload');
+
+        $this->actingAs($this->admin)->post(route('stock.orders.import'), [
+            'import_file' => UploadedFile::fake()->create('stock.xlsx', 20, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'),
+        ])->assertRedirect();
+
+        $this->assertDatabaseHas('imports', [
+            'type' => 'stock_orders',
+            'original_filename' => 'stock.xlsx',
+        ]);
+        Bus::assertDispatched(ImportJob::class);
+    }
+
+    private function createOrder(
+        CommonCatalogItem $item,
+        string $number,
+        int $quantity,
+        string $status = StockOrder::STATUS_IN_STOCK,
+    ): StockOrder {
+        return $this->inventory->createOrder([
+            'common_catalog_item_id' => $item->id,
+            'order_number' => $number,
+            'status' => $status,
+            'ordered_quantity' => $quantity,
+            'note' => null,
+        ], $this->admin);
+    }
+}

+ 90 - 0
tests/Unit/Services/Import/ImportStockOrdersServiceTest.php

@@ -0,0 +1,90 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Unit\Services\Import;
+
+use App\Models\CommonCatalogItem;
+use App\Models\Import;
+use App\Models\StockMovement;
+use App\Models\StockOrder;
+use App\Models\User;
+use App\Services\Import\ImportStockOrdersService;
+use App\Services\StockInventoryService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Storage;
+use PhpOffice\PhpSpreadsheet\Cell\DataType;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+use Tests\TestCase;
+
+class ImportStockOrdersServiceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected bool $seed = true;
+
+    public function test_import_creates_and_updates_order_by_number_and_article(): void
+    {
+        Storage::fake('upload');
+        $user = User::factory()->create();
+        CommonCatalogItem::factory()->create(['article' => '00125']);
+
+        $firstImport = $this->createImport($user, 'Заказан', 5, 'Ожидаем поставку');
+        $this->assertTrue($this->service($firstImport, $user)->handle());
+        $this->assertDatabaseHas('stock_orders', [
+            'order_number' => 'СКЛ-100',
+            'status' => StockOrder::STATUS_ORDERED,
+            'ordered_quantity' => 5,
+            'available_quantity' => 0,
+        ]);
+
+        $secondImport = $this->createImport($user, 'На складе', 7, 'Поставка принята');
+        $this->assertTrue($this->service($secondImport, $user)->handle());
+
+        $this->assertDatabaseCount('stock_orders', 1);
+        $this->assertDatabaseHas('stock_orders', [
+            'order_number' => 'СКЛ-100',
+            'status' => StockOrder::STATUS_IN_STOCK,
+            'ordered_quantity' => 7,
+            'available_quantity' => 7,
+            'note' => 'Поставка принята',
+        ]);
+        $this->assertDatabaseHas('stock_movements', [
+            'movement_type' => StockMovement::TYPE_RECEIPT,
+            'quantity' => 7,
+        ]);
+        $this->assertSame('DONE', $secondImport->refresh()->status);
+    }
+
+    private function createImport(User $user, string $status, int $quantity, string $note): Import
+    {
+        $spreadsheet = new Spreadsheet;
+        $sheet = $spreadsheet->getActiveSheet();
+        $sheet->fromArray(ImportStockOrdersService::HEADERS, null, 'A1');
+        $sheet->setCellValue('A2', 'СКЛ-100');
+        $sheet->setCellValueExplicit('B2', '00125', DataType::TYPE_STRING);
+        $sheet->setCellValue('C2', $status);
+        $sheet->setCellValue('D2', $quantity);
+        $sheet->setCellValue('E2', $note);
+
+        $filename = 'stock_orders_'.uniqid().'.xlsx';
+        (new Xlsx($spreadsheet))->save(Storage::disk('upload')->path($filename));
+
+        return Import::query()->create([
+            'type' => 'stock_orders',
+            'filename' => $filename,
+            'status' => 'new',
+            'user_id' => $user->id,
+        ]);
+    }
+
+    private function service(Import $import, User $user): ImportStockOrdersService
+    {
+        return new ImportStockOrdersService(
+            $import,
+            $user->id,
+            app(StockInventoryService::class),
+        );
+    }
+}