Răsfoiți Sursa

improved catalog and stock column filters

Alexander Musikhin 6 zile în urmă
părinte
comite
018001f4ca

+ 154 - 33
app/Http/Controllers/FilterController.php

@@ -2,27 +2,33 @@
 
 namespace App\Http\Controllers;
 
-use App\Models\SparePartsView;
 use App\Http\Requests\FilterRequest;
+use App\Models\CommonCatalogItem;
+use App\Models\SparePartsView;
+use App\Models\StockOrder;
+use Illuminate\Http\JsonResponse;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Schema;
 
 class FilterController extends Controller
 {
     const DB_TABLES = [
-        'orders'        => 'orders_view',
-        'product_sku'   => 'mafs_view',
-        'products'      => 'products',
-        'reclamations'  => 'reclamations_view',
-        'maf_order'     => 'maf_orders_view',
-        'import'        => 'imports',
-        'responsibles'  => 'responsibles_view',
-        'users'         => 'users',
-        'contracts'     => 'contracts',
-        'spare_parts'   => 'spare_parts_view',
+        'orders' => 'orders_view',
+        'product_sku' => 'mafs_view',
+        'products' => 'products',
+        'reclamations' => 'reclamations_view',
+        'maf_order' => 'maf_orders_view',
+        'import' => 'imports',
+        'responsibles' => 'responsibles_view',
+        'users' => 'users',
+        'contracts' => 'contracts',
+        'spare_parts' => 'spare_parts_view',
         'spare_part_orders' => 'spare_part_orders_view',
         'notifications' => 'user_notifications',
         'notification_logs' => 'notification_delivery_logs',
+        'common_catalog_items' => 'common_catalog_items',
+        'stock_availability' => 'common_catalog_items',
+        'stock_orders' => 'stock_orders',
     ];
 
     const SKIP_YEAR_FILTER = [
@@ -37,7 +43,7 @@ class FilterController extends Controller
      */
     const COLUMN_MAP = [
         'spare_part_orders' => [
-            'status_name'         => 'status',
+            'status_name' => 'status',
             'with_documents_text' => 'with_documents',
         ],
         'spare_parts' => [
@@ -53,6 +59,20 @@ class FilterController extends Controller
         'responsibles' => [
             'area-name' => 'area_name',
         ],
+        'common_catalog_items' => [
+            'calculator_enabled_txt' => 'calculator_enabled',
+            'builders_price_txt' => 'builders_price',
+            'wholesale_price_txt' => 'wholesale_price',
+            'recommended_price_txt' => 'recommended_price',
+            'retail_price_txt' => 'retail_price',
+            'project_price_txt' => 'project_price',
+            'project_with_installation_price_txt' => 'project_with_installation_price',
+            'pik_price_txt' => 'pik_price',
+            'recommended_plus_10_price_txt' => 'recommended_plus_10_price',
+        ],
+        'stock_orders' => [
+            'status_name' => 'status',
+        ],
     ];
 
     /**
@@ -81,35 +101,62 @@ class FilterController extends Controller
                 1 => 'Да',
             ],
             'status' => [
-                'ordered'  => 'Заказано',
+                'ordered' => 'Заказано',
                 'in_stock' => 'На складе',
-                'shipped'  => 'Отгружено',
+                'shipped' => 'Отгружено',
             ],
         ],
         'notification_logs' => [
             'channel' => [
-                'in_app'  => 'Браузер',
+                'in_app' => 'Браузер',
                 'browser' => 'Браузер',
-                'push'    => 'Android/iOS',
-                'email'   => 'Email',
+                'push' => 'Android/iOS',
+                'email' => 'Email',
             ],
             'status' => [
-                'sent'        => 'Отправлено',
-                'failed'      => 'Ошибка',
-                'skipped'     => 'Пропущено',
+                'sent' => 'Отправлено',
+                'failed' => 'Ошибка',
+                'skipped' => 'Пропущено',
                 'dead_letter' => 'Dead letter',
             ],
         ],
+        'common_catalog_items' => [
+            'calculator_enabled' => [
+                0 => 'нет',
+                1 => 'да',
+            ],
+        ],
+        'stock_orders' => [
+            'status' => StockOrder::STATUS_NAMES,
+        ],
+    ];
+
+    private const FIELD_ACCESS_MODULES = [
+        'common_catalog_items' => 'common-catalog',
+    ];
+
+    private const SESSION_KEYS = [
+        'common_catalog_items' => 'gp_common_catalog',
     ];
 
-    public function getFilters(FilterRequest $request)
+    public function getFilters(FilterRequest $request): JsonResponse
     {
         $table = $request->validated('table');
         $column = $request->validated('column');
-        if(!array_key_exists($table, self::DB_TABLES)) {
+        if (! array_key_exists($table, self::DB_TABLES)) {
             abort(400, 'Table not found');
         }
-        $gp = session('gp_' . $table);
+
+        $this->assertCanViewColumn($request, $table, $column);
+
+        if ($table === 'stock_availability') {
+            return $this->stockAvailabilityFilters($column);
+        }
+        if ($table === 'stock_orders') {
+            return $this->stockOrderFilters($column);
+        }
+
+        $gp = session(self::SESSION_KEYS[$table] ?? 'gp_'.$table);
 
         if ($table === 'spare_parts' && $column === 'pricing_codes_list') {
             $result = DB::table('pricing_codes as pc')
@@ -128,7 +175,7 @@ class FilterController extends Controller
                 array_unshift($result, '-пусто-');
             }
 
-            return response()->json($result, 200, [], JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT);
+            return response()->json($result, 200, [], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
         }
 
         $dbTable = self::DB_TABLES[$table];
@@ -138,22 +185,26 @@ class FilterController extends Controller
 
         if ($dbColumn && Schema::hasColumn($dbTable, $dbColumn)) {
             $normalizedColumn = self::normalizedSelectExpression($dbColumn);
-            $q = DB::table($dbTable)->selectRaw($normalizedColumn . ' as filter_value')->distinct();
-            if (!in_array($table, self::SKIP_YEAR_FILTER) && Schema::hasColumn($dbTable, 'year')) {
-                $q->where('year' , year());
+            $q = DB::table($dbTable)->selectRaw($normalizedColumn.' as filter_value')->distinct();
+            if (! in_array($table, self::SKIP_YEAR_FILTER) && Schema::hasColumn($dbTable, 'year')) {
+                $q->where('year', year());
             }
             if (Schema::hasColumn($dbTable, 'deleted_at')) {
                 $q->whereNull('deleted_at');
             }
 
-            if(isset($gp['filters']) && is_array($gp['filters']) && count($gp['filters'])) {
+            if (isset($gp['filters']) && is_array($gp['filters']) && count($gp['filters'])) {
                 foreach ($gp['filters'] as $colName => $vals) {
-                    if ($colName === $column) continue;
+                    if ($colName === $column) {
+                        continue;
+                    }
                     $filterDbColumn = self::resolveDbColumn($table, $dbTable, $colName);
-                    if (!$filterDbColumn || !Schema::hasColumn($dbTable, $filterDbColumn)) continue;
+                    if (! $filterDbColumn || ! Schema::hasColumn($dbTable, $filterDbColumn)) {
+                        continue;
+                    }
                     $q->where(function ($query) use ($filterDbColumn, $vals) {
                         foreach (explode('||', $vals) as $val) {
-                            if($val == '-пусто-') {
+                            if ($val == '-пусто-') {
                                 self::applyEmptyFilterConditionForFilterQuery($query, $filterDbColumn);
                             } else {
                                 $query->orWhere($filterDbColumn, '=', $val);
@@ -171,6 +222,7 @@ class FilterController extends Controller
                     if ($val === null || $val === '-пусто-') {
                         return $val;
                     }
+
                     return $val / 100;
                 }, $result);
             }
@@ -178,13 +230,13 @@ class FilterController extends Controller
             // Применяем маппинг значений, если есть
             if (isset(self::VALUE_MAP[$table][$dbColumn])) {
                 $map = self::VALUE_MAP[$table][$dbColumn];
-                $result = array_map(fn($val) => $map[$val] ?? $val, $result);
+                $result = array_map(fn ($val) => $map[$val] ?? $val, $result);
             }
         } else {
             $result = [];
         }
 
-        return response()->json($result, 200, [], JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT);
+        return response()->json($result, 200, [], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
     }
 
     private static function normalizedSelectExpression(string $column): string
@@ -198,6 +250,75 @@ class FilterController extends Controller
             ->orWhereRaw("TRIM(CAST({$column} AS CHAR)) = ''");
     }
 
+    private function assertCanViewColumn(FilterRequest $request, string $table, string $column): void
+    {
+        if ($table === 'stock_availability') {
+            abort_unless($request->user()->hasPermission('stock.view'), 403);
+
+            return;
+        }
+        if ($table === 'stock_orders') {
+            abort_unless($request->user()->hasPermission('stock.orders.view'), 403);
+
+            return;
+        }
+
+        $module = self::FIELD_ACCESS_MODULES[$table] ?? null;
+        if ($module === null) {
+            return;
+        }
+
+        $field = self::COLUMN_MAP[$table][$column] ?? $column;
+        abort_unless($request->user()->canViewField($module, $field), 403);
+    }
+
+    private function stockAvailabilityFilters(string $column): JsonResponse
+    {
+        $values = match ($column) {
+            'article', 'calculator_name', 'kind', 'unit' => CommonCatalogItem::query()
+                ->whereHas('stockOrders')
+                ->pluck($column),
+            'latest_order_note' => CommonCatalogItem::query()
+                ->whereHas('stockOrders')
+                ->with('latestStockOrder:id,common_catalog_item_id,note')
+                ->get()
+                ->pluck('latestStockOrder.note'),
+            default => [],
+        };
+
+        return $this->filterValuesResponse($values);
+    }
+
+    private function stockOrderFilters(string $column): JsonResponse
+    {
+        $values = match ($column) {
+            'order_number', 'note' => StockOrder::query()->pluck($column),
+            'item_article' => StockOrder::query()
+                ->join('common_catalog_items', 'common_catalog_items.id', '=', 'stock_orders.common_catalog_item_id')
+                ->pluck('common_catalog_items.article'),
+            'status_name' => StockOrder::query()
+                ->pluck('status')
+                ->map(fn (string $status): string => StockOrder::STATUS_NAMES[$status] ?? $status),
+            default => [],
+        };
+
+        return $this->filterValuesResponse($values);
+    }
+
+    private function filterValuesResponse(iterable $values): JsonResponse
+    {
+        $normalized = collect($values)
+            ->map(static fn (mixed $value): string => $value === null || trim((string) $value) === ''
+                ? '-пусто-'
+                : (string) $value)
+            ->unique()
+            ->sort(static fn (string $left, string $right): int => strnatcasecmp($left, $right))
+            ->values()
+            ->all();
+
+        return response()->json($normalized, 200, [], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
+    }
+
     /**
      * Определяет реальный столбец БД по имени столбца из заголовка.
      * Приоритет: прямое совпадение в БД → COLUMN_MAP для конкретной таблицы.

+ 179 - 13
app/Http/Controllers/StockController.php

@@ -13,11 +13,25 @@ use App\Services\StockInventoryService;
 use DomainException;
 use Illuminate\Contracts\View\View;
 use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Query\Builder as QueryBuilder;
 use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
 
 class StockController extends Controller
 {
+    private const SORT_COLUMNS = [
+        'article',
+        'calculator_name',
+        'kind',
+        'unit',
+        'physical_quantity',
+        'reserved_quantity',
+        'available_stock_quantity',
+        'ordered_stock_quantity',
+        'latest_order_note',
+    ];
+
     public function __construct(private readonly StockInventoryService $inventoryService) {}
 
     public function index(Request $request): View
@@ -26,18 +40,34 @@ class StockController extends Controller
         $kind = trim((string) $request->query('kind', ''));
         $status = trim((string) $request->query('status', ''));
         $perPage = $this->perPage($request);
-        $items = CommonCatalogItem::query()
+        $sortBy = in_array($request->string('sortBy')->toString(), self::SORT_COLUMNS, true)
+            ? $request->string('sortBy')->toString()
+            : 'article';
+        $orderBy = $request->query('order') === 'desc' ? 'desc' : 'asc';
+        session(['gp_stock_availability' => $request->query()]);
+
+        $physicalQuantity = $this->stockOrderQuantitySubquery(
+            StockOrder::STATUS_IN_STOCK,
+            'available_quantity',
+        );
+        $reservedQuantity = $this->reservedQuantitySubquery();
+        $orderedQuantity = $this->stockOrderQuantitySubquery(
+            StockOrder::STATUS_ORDERED,
+            'ordered_quantity',
+        );
+        $availableQuantity = $this->availableQuantitySubquery();
+
+        $query = CommonCatalogItem::query()
+            ->select('common_catalog_items.*')
             ->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')
+            ->with('imageFile')
+            ->addSelect([
+                'physical_quantity' => $physicalQuantity,
+                'reserved_quantity' => $reservedQuantity,
+                'available_stock_quantity' => $availableQuantity,
+                'ordered_stock_quantity' => $orderedQuantity,
+                'latest_order_note' => $this->latestOrderNoteSubquery(),
+            ])
             ->when($search !== '', function (Builder $query) use ($search): void {
                 $query->where(function (Builder $query) use ($search): void {
                     $query->where('article', 'like', "%{$search}%")
@@ -47,8 +77,12 @@ class StockController extends Controller
             })
             ->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')
+                ->whereHas('stockOrders', fn (Builder $query) => $query->where('status', $status)));
+
+        $this->applyColumnFilters($query, $request);
+        $query->orderBy($sortBy, $orderBy)->orderBy('id');
+
+        $items = $query
             ->paginate($perPage)
             ->withQueryString();
 
@@ -67,6 +101,20 @@ class StockController extends Controller
                 ->pluck('kind'),
             'statuses' => StockOrder::STATUS_NAMES,
             'per_page' => $perPage,
+            'sortBy' => $sortBy,
+            'orderBy' => $orderBy,
+            'tableId' => 'stock_availability',
+            'columnFilters' => [
+                'article' => [],
+                'calculator_name' => [],
+                'kind' => [],
+                'unit' => [],
+                'physical_quantity' => ['type' => 'ranges'],
+                'reserved_quantity' => ['type' => 'ranges'],
+                'available_stock_quantity' => ['type' => 'ranges'],
+                'ordered_stock_quantity' => ['type' => 'ranges'],
+                'latest_order_note' => [],
+            ],
         ]);
     }
 
@@ -151,4 +199,122 @@ class StockController extends Controller
 
         return $perPage;
     }
+
+    private function applyColumnFilters(Builder $query, Request $request): void
+    {
+        foreach (['article', 'calculator_name', 'kind', 'unit'] as $column) {
+            $values = $this->filterValues($request, $column);
+            if ($values !== []) {
+                $query->where(function (Builder $query) use ($column, $values): void {
+                    $this->applyValuesCondition($query, $column, $values);
+                });
+            }
+        }
+
+        $latestNotes = $this->filterValues($request, 'latest_order_note');
+        if ($latestNotes !== []) {
+            $nonEmpty = array_values(array_diff($latestNotes, ['-пусто-']));
+            $includeEmpty = in_array('-пусто-', $latestNotes, true);
+            $query->where(function (Builder $query) use ($nonEmpty, $includeEmpty): void {
+                if ($nonEmpty !== []) {
+                    $query->whereHas('latestStockOrder', fn (Builder $query) => $query->whereIn('note', $nonEmpty));
+                }
+                if ($includeEmpty) {
+                    $method = $nonEmpty === [] ? 'where' : 'orWhere';
+                    $query->{$method}(function (Builder $query): void {
+                        $query->whereDoesntHave('latestStockOrder')
+                            ->orWhereHas('latestStockOrder', function (Builder $query): void {
+                                $query->whereNull('note')->orWhereRaw("TRIM(note) = ''");
+                            });
+                    });
+                }
+            });
+        }
+
+        $this->applySubqueryRange($query, $request, 'physical_quantity', $this->stockOrderQuantitySubquery(
+            StockOrder::STATUS_IN_STOCK,
+            'available_quantity',
+        ));
+        $this->applySubqueryRange($query, $request, 'reserved_quantity', $this->reservedQuantitySubquery());
+        $this->applySubqueryRange($query, $request, 'available_stock_quantity', $this->availableQuantitySubquery());
+        $this->applySubqueryRange($query, $request, 'ordered_stock_quantity', $this->stockOrderQuantitySubquery(
+            StockOrder::STATUS_ORDERED,
+            'ordered_quantity',
+        ));
+    }
+
+    /** @return array<int, string> */
+    private function filterValues(Request $request, string $column): array
+    {
+        $value = $request->input("filters.{$column}");
+
+        return is_string($value) && $value !== '' ? explode('||', $value) : [];
+    }
+
+    /** @param array<int, string> $values */
+    private function applyValuesCondition(Builder $query, string $column, array $values): void
+    {
+        $nonEmpty = array_values(array_diff($values, ['-пусто-']));
+        if ($nonEmpty !== []) {
+            $query->orWhereIn($column, $nonEmpty);
+        }
+        if (in_array('-пусто-', $values, true)) {
+            $this->applyEmptyFilterCondition($query, $column);
+        }
+    }
+
+    private function applySubqueryRange(
+        Builder $query,
+        Request $request,
+        string $column,
+        QueryBuilder $subquery,
+    ): void {
+        $from = $request->input("filters.{$column}_from");
+        $to = $request->input("filters.{$column}_to");
+
+        if (is_numeric($from)) {
+            $query->where(clone $subquery, '>=', (float) $from);
+        }
+        if (is_numeric($to)) {
+            $query->where(clone $subquery, '<=', (float) $to);
+        }
+    }
+
+    private function stockOrderQuantitySubquery(string $status, string $column): QueryBuilder
+    {
+        return DB::table('stock_orders')
+            ->selectRaw("COALESCE(SUM({$column}), 0)")
+            ->whereColumn('stock_orders.common_catalog_item_id', 'common_catalog_items.id')
+            ->where('stock_orders.status', $status)
+            ->whereNull('stock_orders.deleted_at');
+    }
+
+    private function reservedQuantitySubquery(): QueryBuilder
+    {
+        return DB::table('stock_reservations')
+            ->selectRaw('COALESCE(SUM(quantity), 0)')
+            ->whereColumn('stock_reservations.common_catalog_item_id', 'common_catalog_items.id')
+            ->where('stock_reservations.status', StockReservation::STATUS_ACTIVE);
+    }
+
+    private function availableQuantitySubquery(): QueryBuilder
+    {
+        $physical = $this->stockOrderQuantitySubquery(StockOrder::STATUS_IN_STOCK, 'available_quantity');
+        $reserved = $this->reservedQuantitySubquery();
+
+        return DB::query()->selectRaw(
+            'GREATEST(0, ('.$physical->toSql().') - ('.$reserved->toSql().'))',
+            array_merge($physical->getBindings(), $reserved->getBindings()),
+        );
+    }
+
+    private function latestOrderNoteSubquery(): QueryBuilder
+    {
+        return DB::table('stock_orders')
+            ->select('note')
+            ->whereColumn('stock_orders.common_catalog_item_id', 'common_catalog_items.id')
+            ->whereNull('stock_orders.deleted_at')
+            ->orderByDesc('stock_orders.id')
+            ->limit(1);
+    }
 }

+ 159 - 4
app/Http/Controllers/StockOrderController.php

@@ -9,18 +9,32 @@ use App\Jobs\Import\ImportJob;
 use App\Models\CommonCatalogItem;
 use App\Models\Import;
 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\Database\Query\Builder as QueryBuilder;
 use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
 use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Storage;
 
 class StockOrderController extends Controller
 {
+    private const SORT_COLUMNS = [
+        'order_number',
+        'item_article',
+        'status_name',
+        'ordered_quantity',
+        'reserved_quantity',
+        'free_quantity',
+        'note',
+        'created_at',
+    ];
+
     public function __construct(private readonly StockInventoryService $inventoryService) {}
 
     public function index(Request $request): View
@@ -28,9 +42,24 @@ class StockOrderController extends Controller
         $search = trim((string) $request->query('search', ''));
         $status = trim((string) $request->query('status', ''));
         $perPage = $this->perPage($request);
-        $orders = StockOrder::query()
+        $requestedSort = $request->string('sortBy')->toString();
+        $sortBy = in_array($requestedSort, self::SORT_COLUMNS, true) ? $requestedSort : 'created_at';
+        $orderBy = $requestedSort !== ''
+            ? ($request->query('order') === 'desc' ? 'desc' : 'asc')
+            : 'desc';
+        session(['gp_stock_orders' => $request->query()]);
+
+        $query = StockOrder::query()
+            ->select('stock_orders.*')
             ->with(['item.imageFile', 'user'])
-            ->withSum(['reservations as active_reservations_sum_quantity' => fn ($query) => $query->active()], 'quantity')
+            ->addSelect([
+                'active_reservations_sum_quantity' => $this->reservedQuantitySubquery(),
+                'free_quantity_value' => $this->freeQuantitySubquery(),
+                'item_article_value' => CommonCatalogItem::query()
+                    ->select('article')
+                    ->whereColumn('common_catalog_items.id', 'stock_orders.common_catalog_item_id')
+                    ->limit(1),
+            ])
             ->when($search !== '', function (Builder $query) use ($search): void {
                 $query->where(function (Builder $query) use ($search): void {
                     $query->where('order_number', 'like', "%{$search}%")
@@ -40,8 +69,19 @@ class StockOrderController extends Controller
                             ->orWhere('calculator_name', 'like', "%{$search}%"));
                 });
             })
-            ->when(array_key_exists($status, StockOrder::STATUS_NAMES), fn (Builder $query) => $query->where('status', $status))
-            ->orderByDesc('created_at')
+            ->when(array_key_exists($status, StockOrder::STATUS_NAMES), fn (Builder $query) => $query->where('status', $status));
+
+        $this->applyColumnFilters($query, $request);
+        $sortColumn = match ($sortBy) {
+            'item_article' => 'item_article_value',
+            'status_name' => 'status',
+            'reserved_quantity' => 'active_reservations_sum_quantity',
+            'free_quantity' => 'free_quantity_value',
+            default => $sortBy,
+        };
+        $query->orderBy($sortColumn, $orderBy)->orderBy('stock_orders.id');
+
+        $orders = $query
             ->paginate($perPage)
             ->withQueryString();
 
@@ -63,6 +103,19 @@ class StockOrderController extends Controller
             'search' => $search,
             'status' => $status,
             'per_page' => $perPage,
+            'sortBy' => $sortBy,
+            'orderBy' => $orderBy,
+            'tableId' => 'stock_orders',
+            'columnFilters' => [
+                'order_number' => [],
+                'item_article' => [],
+                'status_name' => [],
+                'ordered_quantity' => ['type' => 'ranges'],
+                'reserved_quantity' => ['type' => 'ranges'],
+                'free_quantity' => ['type' => 'ranges'],
+                'note' => [],
+                'created_at' => ['type' => 'dates'],
+            ],
         ]);
     }
 
@@ -153,4 +206,106 @@ class StockOrderController extends Controller
 
         return $perPage;
     }
+
+    private function applyColumnFilters(Builder $query, Request $request): void
+    {
+        foreach (['order_number', 'note'] as $column) {
+            $values = $this->filterValues($request, $column);
+            if ($values !== []) {
+                $query->where(function (Builder $query) use ($column, $values): void {
+                    $this->applyValuesCondition($query, $column, $values);
+                });
+            }
+        }
+
+        $articles = $this->filterValues($request, 'item_article');
+        if ($articles !== []) {
+            $query->whereHas('item', fn (Builder $query) => $query->whereIn('article', $articles));
+        }
+
+        $statuses = $this->filterValues($request, 'status_name');
+        if ($statuses !== []) {
+            $statusCodes = array_intersect_key(array_flip(StockOrder::STATUS_NAMES), array_flip($statuses));
+            $query->whereIn('status', array_values($statusCodes));
+        }
+
+        $this->applyColumnRange($query, $request, 'ordered_quantity');
+        $this->applySubqueryRange($query, $request, 'reserved_quantity', $this->reservedQuantitySubquery());
+        $this->applySubqueryRange($query, $request, 'free_quantity', $this->freeQuantitySubquery());
+
+        $from = $request->input('filters.created_at_from');
+        $to = $request->input('filters.created_at_to');
+        if (is_string($from) && $from !== '') {
+            $query->whereDate('stock_orders.created_at', '>=', $from);
+        }
+        if (is_string($to) && $to !== '') {
+            $query->whereDate('stock_orders.created_at', '<=', $to);
+        }
+    }
+
+    /** @return array<int, string> */
+    private function filterValues(Request $request, string $column): array
+    {
+        $value = $request->input("filters.{$column}");
+
+        return is_string($value) && $value !== '' ? explode('||', $value) : [];
+    }
+
+    /** @param array<int, string> $values */
+    private function applyValuesCondition(Builder $query, string $column, array $values): void
+    {
+        $nonEmpty = array_values(array_diff($values, ['-пусто-']));
+        if ($nonEmpty !== []) {
+            $query->orWhereIn($column, $nonEmpty);
+        }
+        if (in_array('-пусто-', $values, true)) {
+            $this->applyEmptyFilterCondition($query, $column);
+        }
+    }
+
+    private function applyColumnRange(Builder $query, Request $request, string $column): void
+    {
+        $from = $request->input("filters.{$column}_from");
+        $to = $request->input("filters.{$column}_to");
+        if (is_numeric($from)) {
+            $query->where("stock_orders.{$column}", '>=', (float) $from);
+        }
+        if (is_numeric($to)) {
+            $query->where("stock_orders.{$column}", '<=', (float) $to);
+        }
+    }
+
+    private function applySubqueryRange(
+        Builder $query,
+        Request $request,
+        string $column,
+        QueryBuilder $subquery,
+    ): void {
+        $from = $request->input("filters.{$column}_from");
+        $to = $request->input("filters.{$column}_to");
+        if (is_numeric($from)) {
+            $query->where(clone $subquery, '>=', (float) $from);
+        }
+        if (is_numeric($to)) {
+            $query->where(clone $subquery, '<=', (float) $to);
+        }
+    }
+
+    private function reservedQuantitySubquery(): QueryBuilder
+    {
+        return DB::table('stock_reservations')
+            ->selectRaw('COALESCE(SUM(quantity), 0)')
+            ->whereColumn('stock_reservations.stock_order_id', 'stock_orders.id')
+            ->where('stock_reservations.status', StockReservation::STATUS_ACTIVE);
+    }
+
+    private function freeQuantitySubquery(): QueryBuilder
+    {
+        $reserved = $this->reservedQuantitySubquery();
+
+        return DB::query()->selectRaw(
+            'GREATEST(0, stock_orders.available_quantity - ('.$reserved->toSql().'))',
+            $reserved->getBindings(),
+        );
+    }
 }

+ 31 - 9
resources/views/partials/newFilterElement.blade.php

@@ -1,13 +1,13 @@
 <div class="dropdown-menu filter-menu-wide" aria-labelledby="{{$id}}">
     <div class="px-1">
-        <div class="d-flex mb-2 {{ $isSort ? '' : 'd-none' }}">
-            <div class="me-3">Сортировка</div>
-            <div class="d-flex filter-sort-controls">
-                <button type="button" class="btn btn-outline-secondary btn-sm w-100" id="sort-by-asc-{{$id}}"><i class="bi bi-arrow-up"></i>ASC</button>
-                <button type="button" class="btn btn-outline-secondary btn-sm w-100" id="sort-by-desc-{{$id}}"><i class="bi bi-arrow-down"></i>DESC</button>
+        <div class="d-flex align-items-center gap-2 mb-2 {{ $isSort ? '' : 'd-none' }}">
+            <div>Сортировка</div>
+            <div class="d-flex flex-grow-1 gap-1 filter-sort-controls">
+                <button type="button" class="btn btn-outline-secondary btn-sm flex-fill text-nowrap" id="sort-by-asc-{{$id}}"><i class="bi bi-arrow-up me-1"></i>ASC</button>
+                <button type="button" class="btn btn-outline-secondary btn-sm flex-fill text-nowrap" id="sort-by-desc-{{$id}}"><i class="bi bi-arrow-down me-1"></i>DESC</button>
             </div>
         </div>
-        @if($type === 'ranges')
+        @if(in_array($type, ['ranges', 'dates'], true))
             @php
                 $inputType = $type === 'dates' ? 'date' : 'number';
                 $fromKey = $id . '_from';
@@ -116,6 +116,8 @@
 
                 let filterData = @json(isset($data['values']) ? array_values(array_keys($data['values'])) : []);
                 let sortAsc = true;
+                let filterDataLoaded = filterData.length > 0;
+                let filterDataLoading = false;
 
                 function renderFilterList(data) {
                     const html = data.map(item => `
@@ -146,13 +148,23 @@
                     });
                 }
 
-                if (filterData.length) {
-                    renderFilterList(sortData(sortAsc));
-                } else {
+                async function loadFilterData() {
+                    if (filterDataLoaded || filterDataLoading) {
+                        return;
+                    }
+
+                    filterDataLoading = true;
+                    $container.html('<div class="text-muted">Загрузка...</div>');
+
                     try {
                         const response = await fetch(`{!! route('getFilters', ['column' => $id, 'table' => $table]) !!}`);
+                        if (!response.ok) {
+                            throw new Error(`HTTP ${response.status}`);
+                        }
                         const data = await response.json();
 
+                        filterDataLoaded = true;
+
                         if (Array.isArray(data) && data.length) {
                             if(data[0] === null) data[0] = '-пусто-';
                             filterData = data;
@@ -168,9 +180,19 @@
                     } catch (error) {
                         console.error("Ошибка при загрузке фильтров:", error);
                         $container.html('<div class="text-danger">Ошибка загрузки</div>');
+                    } finally {
+                        filterDataLoading = false;
                     }
                 }
 
+                if (filterDataLoaded) {
+                    renderFilterList(sortData(sortAsc));
+                } else {
+                    const $filterToggle = $("#{{$id}}");
+                    $filterToggle.on("click", loadFilterData);
+                    $filterToggle.closest(".dropdown").on("show.bs.dropdown", loadFilterData);
+                }
+
                 $sortBtn.on("click", function (e) {
                     e.preventDefault();
                     sortAsc = !sortAsc;

+ 10 - 10
resources/views/stock/index.blade.php

@@ -35,15 +35,15 @@
             <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>
+                @include('stock.partials.column-header', ['name' => 'article', 'title' => 'Артикул'])
+                @include('stock.partials.column-header', ['name' => 'calculator_name', 'title' => 'Наименование'])
+                @include('stock.partials.column-header', ['name' => 'kind', 'title' => 'Характеристики'])
+                @include('stock.partials.column-header', ['name' => 'unit', 'title' => 'Ед.'])
+                @include('stock.partials.column-header', ['name' => 'physical_quantity', 'title' => 'На складе', 'class' => 'text-end'])
+                @include('stock.partials.column-header', ['name' => 'reserved_quantity', 'title' => 'Забронировано', 'class' => 'text-end'])
+                @include('stock.partials.column-header', ['name' => 'available_stock_quantity', 'title' => 'Остаток', 'class' => 'text-end'])
+                @include('stock.partials.column-header', ['name' => 'ordered_stock_quantity', 'title' => 'Заказано', 'class' => 'text-end'])
+                @include('stock.partials.column-header', ['name' => 'latest_order_note', 'title' => 'Примечание'])
             </tr>
             </thead>
             <tbody>
@@ -75,7 +75,7 @@
                     <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>
+                    <td>{{ $item->latest_order_note }}</td>
                 </tr>
             @empty
                 <tr><td colspan="10" class="text-center text-muted py-4">Складские позиции не найдены.</td></tr>

+ 9 - 3
resources/views/stock/orders.blade.php

@@ -42,9 +42,15 @@
         <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>
+                @include('stock.partials.column-header', ['name' => 'order_number', 'title' => '№ заказа'])
+                @include('stock.partials.column-header', ['name' => 'item_article', 'title' => 'Артикул / наименование'])
+                @include('stock.partials.column-header', ['name' => 'status_name', 'title' => 'Статус'])
+                @include('stock.partials.column-header', ['name' => 'ordered_quantity', 'title' => 'Заказано', 'class' => 'text-end'])
+                @include('stock.partials.column-header', ['name' => 'reserved_quantity', 'title' => 'Бронь', 'class' => 'text-end'])
+                @include('stock.partials.column-header', ['name' => 'free_quantity', 'title' => 'Остаток', 'class' => 'text-end'])
+                @include('stock.partials.column-header', ['name' => 'note', 'title' => 'Примечание'])
+                @include('stock.partials.column-header', ['name' => 'created_at', 'title' => 'Добавлен'])
+                <th></th>
             </tr>
             </thead>
             <tbody>

+ 33 - 0
resources/views/stock/partials/column-header.blade.php

@@ -0,0 +1,33 @@
+@php
+    $isCurrentSortColumn = $sortBy === $name;
+    $isFiltered = request()->filled("filters.{$name}")
+        || request()->filled("filters.{$name}_from")
+        || request()->filled("filters.{$name}_to");
+    $filter = $columnFilters[$name] ?? [];
+@endphp
+
+<th @class([$class ?? null])>
+    <div class="d-flex align-items-center justify-content-between gap-1">
+        <span>{{ $title }}</span>
+        <div class="d-flex align-items-center gap-1 text-nowrap">
+            @if($isCurrentSortColumn)
+                <i class="bi {{ $orderBy === 'desc' ? 'bi-arrow-up-square-fill' : 'bi-arrow-down-square-fill' }} text-primary"></i>
+            @endif
+            <div class="dropdown d-inline-block" data-bs-auto-close="outside">
+                <i id="{{ $name }}"
+                   class="dropdown-toggle bi {{ $isFiltered ? 'bi-funnel-fill text-danger' : 'bi-funnel' }} cursor-pointer"
+                   data-bs-toggle="dropdown"
+                   data-bs-auto-close="outside"
+                   aria-expanded="false"></i>
+                @include('partials.newFilterElement', [
+                    'id' => $name,
+                    'data' => $filter['data'] ?? null,
+                    'type' => $filter['type'] ?? null,
+                    'table' => $tableId,
+                    'isSort' => true,
+                    'orderBy' => $orderBy,
+                ])
+            </div>
+        </div>
+    </div>
+</th>

+ 70 - 1
tests/Feature/CommonCatalogControllerTest.php

@@ -8,8 +8,8 @@ use App\Jobs\Export\ExportCommonCatalogJob;
 use App\Jobs\Export\ExportTechnicalDescriptionsJob;
 use App\Jobs\Import\ImportJob;
 use App\Models\CommonCatalogItem;
-use App\Models\Role;
 use App\Models\File;
+use App\Models\Role;
 use App\Models\User;
 use Illuminate\Foundation\Testing\RefreshDatabase;
 use Illuminate\Http\UploadedFile;
@@ -58,6 +58,75 @@ class CommonCatalogControllerTest extends TestCase
             ->assertSee($item->calculator_name);
     }
 
+    public function test_common_catalog_column_filter_values_are_available(): void
+    {
+        CommonCatalogItem::factory()->create([
+            'article' => 'FILTER-001',
+            'builders_price' => 123456.78,
+        ]);
+        $deletedItem = CommonCatalogItem::factory()->create(['article' => 'FILTER-DELETED']);
+        $deletedItem->delete();
+
+        $this->actingAs($this->admin)
+            ->getJson(route('getFilters', [
+                'table' => 'common_catalog_items',
+                'column' => 'article',
+            ]))
+            ->assertOk()
+            ->assertJsonFragment(['FILTER-001'])
+            ->assertJsonMissing(['FILTER-DELETED']);
+
+        $this->actingAs($this->admin)
+            ->getJson(route('getFilters', [
+                'table' => 'common_catalog_items',
+                'column' => 'builders_price_txt',
+            ]))
+            ->assertOk()
+            ->assertJsonFragment([123456.78]);
+    }
+
+    public function test_common_catalog_column_filter_is_applied(): void
+    {
+        CommonCatalogItem::factory()->create([
+            'article' => 'FILTER-MATCH',
+            'calculator_name' => 'Нужная позиция',
+            'builders_price' => 123456.78,
+        ]);
+        CommonCatalogItem::factory()->create([
+            'article' => 'FILTER-OTHER',
+            'calculator_name' => 'Другая позиция',
+            'builders_price' => 987654.32,
+        ]);
+
+        $this->actingAs($this->admin)
+            ->get(route('common-catalog.index', [
+                'filters' => ['article' => 'FILTER-MATCH'],
+            ]))
+            ->assertOk()
+            ->assertSee('Нужная позиция')
+            ->assertDontSee('Другая позиция');
+
+        $this->actingAs($this->admin)
+            ->get(route('common-catalog.index', [
+                'filters' => ['builders_price_txt' => '123456.78'],
+            ]))
+            ->assertOk()
+            ->assertSee('Нужная позиция')
+            ->assertDontSee('Другая позиция');
+    }
+
+    public function test_common_catalog_filter_values_respect_field_access(): void
+    {
+        CommonCatalogItem::factory()->create(['builders_price' => 123456.78]);
+
+        $this->actingAs($this->manager)
+            ->getJson(route('getFilters', [
+                'table' => 'common_catalog_items',
+                'column' => 'builders_price_txt',
+            ]))
+            ->assertForbidden();
+    }
+
     public function test_manager_cannot_mutate_common_catalog(): void
     {
         $this->actingAs($this->manager)

+ 128 - 0
tests/Feature/StockControllerTest.php

@@ -62,6 +62,76 @@ class StockControllerTest extends TestCase
             ->assertSeeText('STOCK-001');
     }
 
+    public function test_availability_supports_column_filters_and_computed_sorting(): void
+    {
+        $smallerItem = CommonCatalogItem::factory()->create(['article' => 'STOCK-SMALL']);
+        $largerItem = CommonCatalogItem::factory()->create(['article' => 'STOCK-LARGE']);
+        $smallerOrder = $this->createOrder($smallerItem, 'SMALL-ORDER', 2);
+        $this->createOrder($largerItem, 'LARGE-ORDER', 7);
+        $smallerOrder->update(['note' => 'Примечание малой партии']);
+        $this->inventory->reserve($largerItem, $this->manager, 2, null, $this->admin);
+
+        $this->actingAs($this->manager)
+            ->get(route('stock.index', [
+                'filters' => ['article' => 'STOCK-SMALL'],
+            ]))
+            ->assertOk()
+            ->assertViewHas('items', fn ($items): bool => $items->pluck('article')->all() === ['STOCK-SMALL']);
+
+        $this->actingAs($this->manager)
+            ->get(route('stock.index', [
+                'filters' => ['physical_quantity_from' => 5],
+            ]))
+            ->assertOk()
+            ->assertViewHas('items', fn ($items): bool => $items->pluck('article')->all() === ['STOCK-LARGE']);
+
+        $this->actingAs($this->manager)
+            ->get(route('stock.index', [
+                'filters' => ['latest_order_note' => 'Примечание малой партии'],
+            ]))
+            ->assertOk()
+            ->assertViewHas('items', fn ($items): bool => $items->pluck('article')->all() === ['STOCK-SMALL']);
+
+        $this->actingAs($this->manager)
+            ->get(route('stock.index', [
+                'filters' => ['reserved_quantity_from' => 1, 'available_stock_quantity_from' => 5],
+            ]))
+            ->assertOk()
+            ->assertViewHas('items', fn ($items): bool => $items->pluck('article')->all() === ['STOCK-LARGE']);
+
+        $this->actingAs($this->manager)
+            ->get(route('stock.index', [
+                'sortBy' => 'physical_quantity',
+                'order' => 'desc',
+            ]))
+            ->assertOk()
+            ->assertViewHas('items', fn ($items): bool => $items->pluck('article')->all() === [
+                'STOCK-LARGE',
+                'STOCK-SMALL',
+            ]);
+    }
+
+    public function test_stock_column_filter_values_are_loaded_lazily(): void
+    {
+        $item = CommonCatalogItem::factory()->create(['article' => 'STOCK-FILTER-VALUE']);
+        $this->createOrder($item, 'FILTER-ORDER', 1);
+
+        $this->actingAs($this->manager)
+            ->getJson(route('getFilters', [
+                'table' => 'stock_availability',
+                'column' => 'article',
+            ]))
+            ->assertOk()
+            ->assertJsonFragment(['STOCK-FILTER-VALUE']);
+
+        $this->actingAs($this->manager)
+            ->getJson(route('getFilters', [
+                'table' => 'stock_orders',
+                'column' => 'order_number',
+            ]))
+            ->assertForbidden();
+    }
+
     public function test_orders_are_available_only_to_admin_and_assistant_head(): void
     {
         $this->actingAs($this->manager)->get(route('stock.orders'))->assertForbidden();
@@ -69,6 +139,64 @@ class StockControllerTest extends TestCase
         $this->actingAs($this->assistant)->get(route('stock.orders'))->assertOk();
     }
 
+    public function test_stock_orders_support_column_filters_and_related_sorting(): void
+    {
+        $firstItem = CommonCatalogItem::factory()->create(['article' => 'ORDER-ARTICLE-A']);
+        $secondItem = CommonCatalogItem::factory()->create(['article' => 'ORDER-ARTICLE-B']);
+        $firstOrder = $this->createOrder($firstItem, 'ORDER-A', 2, StockOrder::STATUS_ORDERED);
+        $secondOrder = $this->createOrder($secondItem, 'ORDER-B', 5);
+        $firstOrder->forceFill(['created_at' => '2026-01-10 10:00:00'])->save();
+        $secondOrder->forceFill(['created_at' => '2026-02-10 10:00:00'])->save();
+        $this->inventory->reserve($secondItem, $this->manager, 2, null, $this->admin);
+
+        $this->actingAs($this->admin)
+            ->get(route('stock.orders', [
+                'filters' => ['status_name' => 'Заказан'],
+            ]))
+            ->assertOk()
+            ->assertViewHas('orders', fn ($orders): bool => $orders->pluck('order_number')->all() === ['ORDER-A']);
+
+        $this->actingAs($this->admin)
+            ->get(route('stock.orders', [
+                'filters' => ['ordered_quantity_from' => 4],
+            ]))
+            ->assertOk()
+            ->assertViewHas('orders', fn ($orders): bool => $orders->pluck('order_number')->all() === ['ORDER-B']);
+
+        $this->actingAs($this->admin)
+            ->get(route('stock.orders', [
+                'filters' => ['created_at_from' => '2026-02-01', 'created_at_to' => '2026-02-28'],
+            ]))
+            ->assertOk()
+            ->assertViewHas('orders', fn ($orders): bool => $orders->pluck('order_number')->all() === ['ORDER-B']);
+
+        $this->actingAs($this->admin)
+            ->get(route('stock.orders', [
+                'filters' => ['reserved_quantity_from' => 1, 'free_quantity_from' => 3],
+            ]))
+            ->assertOk()
+            ->assertViewHas('orders', fn ($orders): bool => $orders->pluck('order_number')->all() === ['ORDER-B']);
+
+        $this->actingAs($this->admin)
+            ->get(route('stock.orders', [
+                'sortBy' => 'item_article',
+                'order' => 'desc',
+            ]))
+            ->assertOk()
+            ->assertViewHas('orders', fn ($orders): bool => $orders->pluck('order_number')->all() === [
+                'ORDER-B',
+                'ORDER-A',
+            ]);
+
+        $this->actingAs($this->admin)
+            ->getJson(route('getFilters', [
+                'table' => 'stock_orders',
+                'column' => 'status_name',
+            ]))
+            ->assertOk()
+            ->assertExactJson(['Заказан', 'На складе']);
+    }
+
     public function test_admin_can_create_order_linked_to_common_catalog(): void
     {
         $item = CommonCatalogItem::factory()->create();