4 Commits d054b26e57 ... cc37675725

Author SHA1 Message Date
  Alexander Musikhin cc37675725 updated common catalog format and prices 1 month ago
  Alexander Musikhin 2c31115e28 refactored docs 1 month ago
  Alexander Musikhin 36e324de52 implemented stock module 1 month ago
  Alexander Musikhin bc7b821e2d implemented documentation module 1 month ago
64 changed files with 5553 additions and 572 deletions
  1. 138 24
      app/Http/Controllers/CommonCatalogController.php
  2. 269 0
      app/Http/Controllers/DocumentationController.php
  3. 154 0
      app/Http/Controllers/StockController.php
  4. 156 0
      app/Http/Controllers/StockOrderController.php
  5. 23 0
      app/Http/Requests/RenameDocumentationItemRequest.php
  6. 25 0
      app/Http/Requests/ReserveStockRequest.php
  7. 44 8
      app/Http/Requests/StoreCommonCatalogItemRequest.php
  8. 24 0
      app/Http/Requests/StoreDocumentationFileRequest.php
  9. 24 0
      app/Http/Requests/StoreDocumentationFolderRequest.php
  10. 40 0
      app/Http/Requests/StoreStockOrderRequest.php
  11. 33 0
      app/Http/Requests/UpdateDocumentationPermissionsRequest.php
  12. 14 8
      app/Jobs/Import/ImportJob.php
  13. 160 1
      app/Models/CommonCatalogItem.php
  14. 52 0
      app/Models/DocumentationDocument.php
  15. 65 0
      app/Models/DocumentationDocumentVersion.php
  16. 54 0
      app/Models/DocumentationFolder.php
  17. 49 0
      app/Models/DocumentationFolderPermission.php
  18. 7 1
      app/Models/Import.php
  19. 72 0
      app/Models/StockMovement.php
  20. 87 0
      app/Models/StockOrder.php
  21. 79 0
      app/Models/StockReservation.php
  22. 148 0
      app/Services/DocumentationAccessService.php
  23. 260 0
      app/Services/DocumentationService.php
  24. 160 51
      app/Services/Export/ExportCommonCatalogService.php
  25. 37 4
      app/Services/FileService.php
  26. 132 29
      app/Services/Import/ImportCommonCatalogService.php
  27. 147 0
      app/Services/Import/ImportStockOrdersService.php
  28. 321 0
      app/Services/StockInventoryService.php
  29. 54 0
      config/access.php
  30. 17 3
      config/access_routes.php
  31. 14 2
      database/factories/CommonCatalogItemFactory.php
  32. 73 0
      database/migrations/2026_08_03_000002_create_documentation_tables.php
  33. 68 0
      database/migrations/2026_08_03_000003_create_stock_tables.php
  34. 61 0
      database/migrations/2026_08_05_000001_expand_common_catalog_items_for_actual_catalog.php
  35. 24 0
      database/migrations/2026_08_05_000002_sync_common_catalog_field_permissions.php
  36. 24 0
      database/migrations/2026_08_05_000003_make_common_catalog_calculator_name_nullable.php
  37. 45 3
      database/seeders/RbacSeeder.php
  38. 20 18
      docs/refactor/menu.md
  39. 102 62
      docs/refactor/plan.md
  40. 124 84
      docs/refactor/tz-calculations.md
  41. 83 60
      docs/refactor/tz-catalog-common.md
  42. 2 2
      docs/refactor/tz-dkr.md
  43. 13 1
      docs/refactor/tz-documents.md
  44. 50 15
      docs/refactor/tz-schedules.md
  45. 6 6
      docs/refactor/tz-spare-parts.md
  46. 39 57
      docs/refactor/tz-stock-availability.md
  47. 58 17
      docs/refactor/tz-technical-description.md
  48. BIN
      docs/refactor/Каталог общий.xlsx
  49. 118 36
      resources/views/common_catalog/edit.blade.php
  50. 29 0
      resources/views/documents/_folder_tree.blade.php
  51. 356 0
      resources/views/documents/index.blade.php
  52. 20 12
      resources/views/layouts/menu.blade.php
  53. 88 0
      resources/views/stock/index.blade.php
  54. 143 0
      resources/views/stock/orders.blade.php
  55. 17 0
      resources/views/stock/partials/order-form.blade.php
  56. 80 0
      resources/views/stock/show.blade.php
  57. 44 40
      routes/web.php
  58. 110 4
      tests/Feature/CommonCatalogControllerTest.php
  59. 337 0
      tests/Feature/DocumentationControllerTest.php
  60. 1 1
      tests/Feature/ManagerMenuTest.php
  61. 326 0
      tests/Feature/StockControllerTest.php
  62. 12 3
      tests/Unit/Services/Export/ExportCommonCatalogServiceTest.php
  63. 107 20
      tests/Unit/Services/Import/ImportCommonCatalogServiceTest.php
  64. 114 0
      tests/Unit/Services/Import/ImportStockOrdersServiceTest.php

+ 138 - 24
app/Http/Controllers/CommonCatalogController.php

@@ -10,6 +10,7 @@ use App\Jobs\Import\ImportJob;
 use App\Models\CommonCatalogItem;
 use App\Models\File;
 use App\Models\Import;
+use App\Services\Access\FieldAccessService;
 use App\Services\FileService;
 use Illuminate\Contracts\View\View;
 use Illuminate\Http\RedirectResponse;
@@ -28,9 +29,13 @@ class CommonCatalogController extends Controller
             'id' => 'ID',
             'image' => 'Внешний вид',
             'article' => 'Артикул',
-            'calculator_name' => 'Наименование по калькулятору',
+            'calculator_name' => 'Наименование',
             'kind' => 'Вид',
-            'dimensions' => 'Габаритные размеры',
+            'dimension_length' => 'Габариты: длина',
+            'dimension_width' => 'Габариты: ширина',
+            'dimension_height' => 'Габариты: высота',
+            'site_length' => 'Участок: длина',
+            'site_width' => 'Участок: ширина',
             'fall_height' => 'Высота падения',
             'additional_info' => 'Дополнительные сведения',
             'dimension_unit' => 'Единица измерения габаритов',
@@ -43,43 +48,77 @@ class CommonCatalogController extends Controller
             'unit' => 'Ед.',
             'series' => 'Серия',
             'trademark' => 'ТМ',
-            'note' => 'Примечание',
+            'calculator_enabled_txt' => 'Калькулятор',
+            'builders_price_txt' => 'строители',
+            'wholesale_price_txt' => 'опт',
+            'recommended_price_txt' => 'рек',
+            'retail_price_txt' => 'розница',
+            'project_price_txt' => 'проект',
+            'project_with_installation_price_txt' => 'проект+м',
+            'pik_price_txt' => 'пик',
+            'recommended_plus_10_price_txt' => 'рек+10',
         ],
         'searchFields' => [
             'article',
             'calculator_name',
             'kind',
-            'dimensions',
+            'dimension_length',
+            'dimension_width',
+            'dimension_height',
+            'site_length',
+            'site_width',
             'additional_info',
             'composition',
             'series',
             'trademark',
-            'note',
         ],
     ];
 
-    public function index(Request $request): View
+    public function index(Request $request, FieldAccessService $fieldAccess): View
     {
         session(['gp_common_catalog' => $request->query()]);
         $nav = $this->startNavigationContext($request);
         $model = new CommonCatalogItem;
+        $readableFields = $this->readableFields($request, $fieldAccess);
+        $request = $this->sanitizeListRequest($request, $readableFields);
+        $this->data['header'] = $fieldAccess->visibleHeaders(
+            $request->user(),
+            'common-catalog',
+            $this->data['header'],
+        );
+        $this->data['searchFields'] = array_values(array_intersect(
+            $this->data['searchFields'],
+            $readableFields,
+        ));
 
         $this->createFilters(
             $model,
-            'kind',
-            'dimension_unit',
-            'age_group',
-            'unit',
-            'series',
-            'trademark',
+            ...array_values(array_intersect([
+                'kind',
+                'dimension_unit',
+                'age_group',
+                'unit',
+                'series',
+                'trademark',
+            ], $readableFields)),
         );
         $this->createRangeFilters(
             $model,
-            'fall_height',
-            'weight',
-            'volume',
-            'places',
-            'max_users',
+            ...array_values(array_intersect([
+                'fall_height',
+                'weight',
+                'volume',
+                'places',
+                'max_users',
+                'builders_price',
+                'wholesale_price',
+                'recommended_price',
+                'retail_price',
+                'project_price',
+                'project_with_installation_price',
+                'pik_price',
+                'recommended_plus_10_price',
+            ], $readableFields)),
         );
 
         $query = CommonCatalogItem::query()->with('imageFile');
@@ -94,22 +133,36 @@ class CommonCatalogController extends Controller
         return view('common_catalog.index', $this->data);
     }
 
-    public function create(Request $request): View
+    public function create(Request $request, FieldAccessService $fieldAccess): View
     {
-        return $this->itemView($request);
+        return $this->itemView($request, $fieldAccess);
     }
 
-    public function show(Request $request, CommonCatalogItem $commonCatalogItem): View
+    public function show(
+        Request $request,
+        CommonCatalogItem $commonCatalogItem,
+        FieldAccessService $fieldAccess,
+    ): View
     {
         return $this->itemView(
             $request,
+            $fieldAccess,
             $commonCatalogItem->load(['imageFile', 'documents']),
         );
     }
 
-    public function store(StoreCommonCatalogItemRequest $request): RedirectResponse
+    public function store(
+        StoreCommonCatalogItemRequest $request,
+        FieldAccessService $fieldAccess,
+    ): RedirectResponse
     {
-        $item = CommonCatalogItem::query()->create($request->validated());
+        $item = CommonCatalogItem::query()->create(
+            $fieldAccess->filterValidatedPayload(
+                $request->user(),
+                'common-catalog',
+                $request->validated(),
+            ),
+        );
 
         return redirect()
             ->route('common-catalog.show', $this->withNav(
@@ -122,8 +175,13 @@ class CommonCatalogController extends Controller
     public function update(
         StoreCommonCatalogItemRequest $request,
         CommonCatalogItem $commonCatalogItem,
+        FieldAccessService $fieldAccess,
     ): RedirectResponse {
-        $commonCatalogItem->update($request->validated());
+        $commonCatalogItem->update($fieldAccess->filterValidatedPayload(
+            $request->user(),
+            'common-catalog',
+            $request->validated(),
+        ));
 
         return $this->redirectToItem($request, $commonCatalogItem)
             ->with('success', 'Позиция общего каталога обновлена.');
@@ -278,7 +336,11 @@ class CommonCatalogController extends Controller
             ->with('success', 'Задача импорта общего каталога создана.');
     }
 
-    private function itemView(Request $request, ?CommonCatalogItem $item = null): View
+    private function itemView(
+        Request $request,
+        FieldAccessService $fieldAccess,
+        ?CommonCatalogItem $item = null,
+    ): View
     {
         $nav = $this->resolveNavToken($request);
         $this->rememberNavigation($request, $nav);
@@ -289,10 +351,62 @@ class CommonCatalogController extends Controller
             route('common-catalog.index', session('gp_common_catalog')),
         );
         $this->data['item'] = $item;
+        $fields = array_keys(config('access.common-catalog.fields', []));
+        $this->data['commonCatalogReadableFields'] = array_fill_keys(
+            $fieldAccess->filterReadableFields($request->user(), 'common-catalog', $fields),
+            true,
+        );
+        $this->data['commonCatalogWritableFields'] = [];
+        foreach ($fields as $field) {
+            $this->data['commonCatalogWritableFields'][$field] = $request->user()
+                ->canUpdateField('common-catalog', $field);
+        }
 
         return view('common_catalog.edit', $this->data);
     }
 
+    private function readableFields(Request $request, FieldAccessService $fieldAccess): array
+    {
+        return $fieldAccess->filterReadableFields(
+            $request->user(),
+            'common-catalog',
+            array_keys(config('access.common-catalog.fields', [])),
+        );
+    }
+
+    private function sanitizeListRequest(Request $request, array $readableFields): Request
+    {
+        if (
+            $request->filled('sortBy')
+            && ! in_array($this->normalizeField($request->string('sortBy')->toString()), $readableFields, true)
+        ) {
+            $request->merge(['sortBy' => CommonCatalogItem::DEFAULT_SORT_BY]);
+        }
+
+        if ($request->has('filters') && is_array($request->input('filters'))) {
+            $request->merge([
+                'filters' => array_filter(
+                    $request->input('filters'),
+                    fn (mixed $value, string $field): bool => in_array(
+                        $this->normalizeField($field),
+                        $readableFields,
+                        true,
+                    ),
+                    ARRAY_FILTER_USE_BOTH,
+                ),
+            ]);
+        }
+
+        return $request;
+    }
+
+    private function normalizeField(string $field): string
+    {
+        $field = preg_replace('/_(from|to)$/', '', $field) ?: $field;
+
+        return str_ends_with($field, '_txt') ? substr($field, 0, -4) : $field;
+    }
+
     private function redirectToItem(
         Request $request,
         CommonCatalogItem $commonCatalogItem,

+ 269 - 0
app/Http/Controllers/DocumentationController.php

@@ -0,0 +1,269 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Controllers;
+
+use App\Http\Requests\RenameDocumentationItemRequest;
+use App\Http\Requests\StoreDocumentationFileRequest;
+use App\Http\Requests\StoreDocumentationFolderRequest;
+use App\Http\Requests\UpdateDocumentationPermissionsRequest;
+use App\Models\DocumentationDocument;
+use App\Models\DocumentationDocumentVersion;
+use App\Models\DocumentationFolder;
+use App\Models\DocumentationFolderPermission;
+use App\Models\Role;
+use App\Models\User;
+use App\Services\DocumentationAccessService;
+use App\Services\DocumentationService;
+use Illuminate\Contracts\View\View;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Collection as SupportCollection;
+use Illuminate\Support\Facades\Storage;
+use Symfony\Component\HttpFoundation\BinaryFileResponse;
+use Symfony\Component\HttpFoundation\StreamedResponse;
+
+class DocumentationController extends Controller
+{
+    public function __construct(
+        private readonly DocumentationAccessService $accessService,
+        private readonly DocumentationService $documentationService,
+    ) {}
+
+    public function index(Request $request): View
+    {
+        $user = $this->user($request);
+        $folders = $this->accessService->navigationFolders($user);
+        $selectedFolder = $request->integer('folder')
+            ? DocumentationFolder::query()->with('permissionRules')->findOrFail($request->integer('folder'))
+            : $folders->first(fn (DocumentationFolder $folder): bool => (bool) $folder->getAttribute('can_read'));
+
+        if ($selectedFolder) {
+            abort_unless($this->accessService->canRead($user, $selectedFolder), 403);
+        }
+
+        $search = trim((string) $request->query('search', ''));
+        $documents = $selectedFolder
+            ? DocumentationDocument::query()
+                ->where('folder_id', $selectedFolder->id)
+                ->when($search !== '', fn ($query) => $query->where('name', 'like', "%{$search}%"))
+                ->with(['currentVersion.file', 'currentVersion.creator', 'versions.file', 'versions.creator'])
+                ->orderBy('name')
+                ->paginate(20)
+                ->withQueryString()
+            : null;
+
+        $canWrite = $selectedFolder && $this->accessService->canWrite($user, $selectedFolder);
+        $canManagePermissions = $selectedFolder
+            && $this->accessService->canManagePermissions($user, $selectedFolder);
+
+        $selectedFolder?->loadMissing('permissionRules');
+        $rules = $selectedFolder?->permissionRules ?? collect();
+
+        return view('documents.index', [
+            'active' => 'documents',
+            'folders' => $folders,
+            'foldersByParent' => $folders->groupBy(fn (DocumentationFolder $folder): int => $folder->parent_id ?? 0),
+            'selectedFolder' => $selectedFolder,
+            'documents' => $documents,
+            'search' => $search,
+            'canCreateRoot' => $this->accessService->isAdministrator($user),
+            'canWrite' => (bool) $canWrite,
+            'canManagePermissions' => (bool) $canManagePermissions,
+            'roles' => $canManagePermissions ? Role::query()->where('is_active', true)->orderBy('name')->get() : collect(),
+            'users' => $canManagePermissions ? User::query()->with('roleModel')->orderBy('name')->get() : collect(),
+            'permissionValues' => $this->permissionValues($rules),
+        ]);
+    }
+
+    public function storeFolder(StoreDocumentationFolderRequest $request): RedirectResponse
+    {
+        $user = $this->user($request);
+        $parentId = $request->validated('parent_id');
+        $parent = $parentId ? DocumentationFolder::query()->findOrFail((int) $parentId) : null;
+
+        if ($parent) {
+            abort_unless($this->accessService->canWrite($user, $parent), 403);
+        } else {
+            abort_unless($this->accessService->isAdministrator($user), 403);
+        }
+
+        $folder = $this->documentationService->createFolder($parent, (string) $request->validated('name'), $user);
+
+        return redirect()
+            ->route('documents.index', ['folder' => $folder->id])
+            ->with('success', 'Папка создана.');
+    }
+
+    public function updateFolder(
+        RenameDocumentationItemRequest $request,
+        DocumentationFolder $folder,
+    ): RedirectResponse {
+        abort_unless($this->accessService->canWrite($this->user($request), $folder), 403);
+        $this->documentationService->renameFolder($folder, (string) $request->validated('name'));
+
+        return $this->folderRedirect($folder, 'Папка переименована.');
+    }
+
+    public function destroyFolder(Request $request, DocumentationFolder $folder): RedirectResponse
+    {
+        abort_unless($this->accessService->canWrite($this->user($request), $folder), 403);
+        $parentId = $folder->parent_id;
+        $this->documentationService->deleteFolder($folder);
+
+        return redirect()
+            ->route('documents.index', $parentId ? ['folder' => $parentId] : [])
+            ->with('success', 'Папка и всё её содержимое удалены.');
+    }
+
+    public function updatePermissions(
+        UpdateDocumentationPermissionsRequest $request,
+        DocumentationFolder $folder,
+    ): RedirectResponse {
+        abort_unless($this->accessService->canManagePermissions($this->user($request), $folder), 403);
+        $this->documentationService->updatePermissions($folder, $request->validated());
+
+        return $this->folderRedirect($folder, 'Права папки обновлены.');
+    }
+
+    public function storeDocument(
+        StoreDocumentationFileRequest $request,
+        DocumentationFolder $folder,
+    ): RedirectResponse {
+        $user = $this->user($request);
+        abort_unless($this->accessService->canWrite($user, $folder), 403);
+
+        /** @var UploadedFile $file */
+        $file = $request->file('file');
+        $this->documentationService->createDocument(
+            $folder,
+            $request->validated('name'),
+            $file,
+            $user,
+        );
+
+        return $this->folderRedirect($folder, 'Документ загружен.');
+    }
+
+    public function updateDocument(
+        RenameDocumentationItemRequest $request,
+        DocumentationDocument $document,
+    ): RedirectResponse {
+        $document->loadMissing('folder');
+        abort_unless($this->accessService->canWrite($this->user($request), $document->folder), 403);
+        $this->documentationService->renameDocument($document, (string) $request->validated('name'));
+
+        return $this->folderRedirect($document->folder, 'Документ переименован.');
+    }
+
+    public function destroyDocument(Request $request, DocumentationDocument $document): RedirectResponse
+    {
+        $document->loadMissing('folder');
+        abort_unless($this->accessService->canWrite($this->user($request), $document->folder), 403);
+        $folder = $document->folder;
+        $this->documentationService->deleteDocument($document);
+
+        return $this->folderRedirect($folder, 'Документ и все его версии удалены.');
+    }
+
+    public function storeVersion(
+        StoreDocumentationFileRequest $request,
+        DocumentationDocument $document,
+    ): RedirectResponse {
+        $document->loadMissing('folder');
+        $user = $this->user($request);
+        abort_unless($this->accessService->canWrite($user, $document->folder), 403);
+
+        /** @var UploadedFile $file */
+        $file = $request->file('file');
+        $this->documentationService->addVersion($document, $file, $user);
+
+        return $this->folderRedirect($document->folder, 'Новая версия загружена.');
+    }
+
+    public function downloadVersion(Request $request, DocumentationDocumentVersion $version): StreamedResponse
+    {
+        $version->loadMissing(['document.folder', 'file']);
+        abort_unless($this->accessService->canRead($this->user($request), $version->document->folder), 403);
+        abort_unless($version->file && Storage::disk('local')->exists($version->file->path), 404);
+
+        return Storage::disk('local')->download($version->file->path, $version->file->original_name);
+    }
+
+    public function previewVersion(Request $request, DocumentationDocumentVersion $version): BinaryFileResponse
+    {
+        $version->loadMissing(['document.folder', 'file']);
+        abort_unless($this->accessService->canRead($this->user($request), $version->document->folder), 403);
+        abort_unless($version->file && Storage::disk('local')->exists($version->file->path), 404);
+
+        $mimeType = Storage::disk('local')->mimeType($version->file->path);
+        abort_unless(is_string($mimeType) && str_starts_with(strtolower($mimeType), 'image/'), 404);
+
+        return response()->file(Storage::disk('local')->path($version->file->path), [
+            'Content-Type' => $mimeType,
+            'Content-Security-Policy' => "sandbox; default-src 'none'; img-src 'self' data:",
+            'X-Content-Type-Options' => 'nosniff',
+        ]);
+    }
+
+    public function destroyVersion(Request $request, DocumentationDocumentVersion $version): RedirectResponse
+    {
+        $version->loadMissing('document.folder');
+        $folder = $version->document->folder;
+        abort_unless($this->accessService->canWrite($this->user($request), $folder), 403);
+        $this->documentationService->deleteVersion($version);
+
+        return $this->folderRedirect($folder, 'Версия полностью удалена.');
+    }
+
+    private function user(Request $request): User
+    {
+        /** @var User $user */
+        $user = $request->user();
+
+        return $user;
+    }
+
+    private function folderRedirect(DocumentationFolder $folder, string $message): RedirectResponse
+    {
+        return redirect()
+            ->route('documents.index', ['folder' => $folder->id])
+            ->with('success', $message);
+    }
+
+    /**
+     * @param  SupportCollection<int, DocumentationFolderPermission>  $rules
+     * @return array<string, mixed>
+     */
+    private function permissionValues(SupportCollection $rules): array
+    {
+        $all = $rules->firstWhere('subject_type', DocumentationFolderPermission::SUBJECT_ALL);
+
+        return [
+            'all_read' => (bool) ($all?->can_read || $all?->can_write),
+            'all_write' => (bool) $all?->can_write,
+            'role_read_ids' => $rules
+                ->where('subject_type', DocumentationFolderPermission::SUBJECT_ROLE)
+                ->filter(fn (DocumentationFolderPermission $rule): bool => $rule->can_read || $rule->can_write)
+                ->pluck('role_id')
+                ->all(),
+            'role_write_ids' => $rules
+                ->where('subject_type', DocumentationFolderPermission::SUBJECT_ROLE)
+                ->where('can_write', true)
+                ->pluck('role_id')
+                ->all(),
+            'user_read_ids' => $rules
+                ->where('subject_type', DocumentationFolderPermission::SUBJECT_USER)
+                ->filter(fn (DocumentationFolderPermission $rule): bool => $rule->can_read || $rule->can_write)
+                ->pluck('user_id')
+                ->all(),
+            'user_write_ids' => $rules
+                ->where('subject_type', DocumentationFolderPermission::SUBJECT_USER)
+                ->where('can_write', true)
+                ->pluck('user_id')
+                ->all(),
+        ];
+    }
+}

+ 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;
+    }
+}

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

@@ -0,0 +1,156 @@
+<?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')
+                ->orderBy('calculator_name')
+                ->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;
+    }
+}

+ 23 - 0
app/Http/Requests/RenameDocumentationItemRequest.php

@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class RenameDocumentationItemRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /** @return array<string, list<string>> */
+    public function rules(): array
+    {
+        return [
+            'name' => ['required', 'string', 'max:255'],
+        ];
+    }
+}

+ 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'],
+        ];
+    }
+}

+ 44 - 8
app/Http/Requests/StoreCommonCatalogItemRequest.php

@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace App\Http\Requests;
 
 use App\Models\CommonCatalogItem;
+use App\Services\Access\AccessService;
 use Illuminate\Foundation\Http\FormRequest;
 use Illuminate\Validation\Rule;
 
@@ -12,25 +13,43 @@ class StoreCommonCatalogItemRequest extends FormRequest
 {
     public function authorize(): bool
     {
-        return true;
+        if (! $this->user()) {
+            return false;
+        }
+
+        if (! $this->routeIs('common-catalog.store')) {
+            return true;
+        }
+
+        $access = app(AccessService::class);
+
+        return $access->canUpdateField($this->user(), 'common-catalog', 'article')
+            && $access->canUpdateField($this->user(), 'common-catalog', 'calculator_name');
     }
 
     public function rules(): array
     {
         $item = $this->route('commonCatalogItem');
 
-        return [
+        $rules = [
             'article' => [
                 'required',
                 'string',
                 'max:100',
-                Rule::unique('common_catalog_items', 'article')->ignore(
-                    $item instanceof CommonCatalogItem ? $item->getKey() : null,
-                ),
+                Rule::unique('common_catalog_items', 'article')
+                    ->where(fn ($query) => $query->where(
+                        'calculator_name',
+                        (string) $this->input('calculator_name'),
+                    ))
+                    ->ignore($item instanceof CommonCatalogItem ? $item->getKey() : null),
             ],
-            'calculator_name' => ['required', 'string'],
+            'calculator_name' => ['required', 'string', 'max:255'],
             'kind' => ['nullable', 'string', 'max:255'],
-            'dimensions' => ['nullable', 'string'],
+            'dimension_length' => ['nullable', 'string', 'max:255'],
+            'dimension_width' => ['nullable', 'string', 'max:255'],
+            'dimension_height' => ['nullable', 'string', 'max:255'],
+            'site_length' => ['nullable', 'string', 'max:255'],
+            'site_width' => ['nullable', 'string', 'max:255'],
             'fall_height' => ['nullable', 'numeric', 'min:0'],
             'additional_info' => ['nullable', 'string'],
             'dimension_unit' => ['nullable', 'string', 'max:50'],
@@ -43,7 +62,24 @@ class StoreCommonCatalogItemRequest extends FormRequest
             'unit' => ['nullable', 'string', 'max:50'],
             'series' => ['nullable', 'string', 'max:255'],
             'trademark' => ['nullable', 'string', 'max:255'],
-            'note' => ['nullable', 'string'],
+            'calculator_enabled' => ['nullable', 'boolean'],
+            'builders_price' => ['nullable', 'numeric', 'min:0'],
+            'wholesale_price' => ['nullable', 'numeric', 'min:0'],
+            'recommended_price' => ['nullable', 'numeric', 'min:0'],
+            'retail_price' => ['nullable', 'numeric', 'min:0'],
+            'project_price' => ['nullable', 'numeric', 'min:0'],
+            'project_with_installation_price' => ['nullable', 'numeric', 'min:0'],
+            'pik_price' => ['nullable', 'numeric', 'min:0'],
+            'recommended_plus_10_price' => ['nullable', 'numeric', 'min:0'],
         ];
+
+        $access = app(AccessService::class);
+        foreach (array_keys($rules) as $field) {
+            if (! $access->canUpdateField($this->user(), 'common-catalog', $field)) {
+                unset($rules[$field]);
+            }
+        }
+
+        return $rules;
     }
 }

+ 24 - 0
app/Http/Requests/StoreDocumentationFileRequest.php

@@ -0,0 +1,24 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class StoreDocumentationFileRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /** @return array<string, list<string>> */
+    public function rules(): array
+    {
+        return [
+            'name' => ['nullable', 'string', 'max:255'],
+            'file' => ['required', 'file', 'max:20480'],
+        ];
+    }
+}

+ 24 - 0
app/Http/Requests/StoreDocumentationFolderRequest.php

@@ -0,0 +1,24 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class StoreDocumentationFolderRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /** @return array<string, list<string>> */
+    public function rules(): array
+    {
+        return [
+            'parent_id' => ['nullable', 'integer', 'exists:documentation_folders,id'],
+            'name' => ['required', 'string', 'max:255'],
+        ];
+    }
+}

+ 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'],
+        ];
+    }
+}

+ 33 - 0
app/Http/Requests/UpdateDocumentationPermissionsRequest.php

@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class UpdateDocumentationPermissionsRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /** @return array<string, list<string>> */
+    public function rules(): array
+    {
+        return [
+            'inherit_permissions' => ['sometimes', 'boolean'],
+            'all_read' => ['sometimes', 'boolean'],
+            'all_write' => ['sometimes', 'boolean'],
+            'role_read_ids' => ['sometimes', 'array'],
+            'role_read_ids.*' => ['integer', 'exists:roles,id'],
+            'role_write_ids' => ['sometimes', 'array'],
+            'role_write_ids.*' => ['integer', 'exists:roles,id'],
+            'user_read_ids' => ['sometimes', 'array'],
+            'user_read_ids.*' => ['integer', 'exists:users,id'],
+            'user_write_ids' => ['sometimes', 'array'],
+            'user_write_ids.*' => ['integer', 'exists:users,id'],
+        ];
+    }
+}

+ 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()]));
         }
     }
 

+ 160 - 1
app/Models/CommonCatalogItem.php

@@ -4,12 +4,15 @@ declare(strict_types=1);
 
 namespace App\Models;
 
+use App\Helpers\Price;
 use Database\Factories\CommonCatalogItemFactory;
 use Illuminate\Database\Eloquent\Casts\Attribute;
 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
@@ -26,6 +29,11 @@ class CommonCatalogItem extends Model
         'article',
         'calculator_name',
         'kind',
+        'dimension_length',
+        'dimension_width',
+        'dimension_height',
+        'site_length',
+        'site_width',
         'dimensions',
         'fall_height',
         'additional_info',
@@ -39,10 +47,30 @@ class CommonCatalogItem extends Model
         'unit',
         'series',
         'trademark',
+        'calculator_enabled',
+        'builders_price',
+        'wholesale_price',
+        'recommended_price',
+        'retail_price',
+        'project_price',
+        'project_with_installation_price',
+        'pik_price',
+        'recommended_plus_10_price',
         'note',
     ];
 
-    protected $appends = ['image'];
+    protected $appends = [
+        'image',
+        'calculator_enabled_txt',
+        'builders_price_txt',
+        'wholesale_price_txt',
+        'recommended_price_txt',
+        'retail_price_txt',
+        'project_price_txt',
+        'project_with_installation_price_txt',
+        'pik_price_txt',
+        'recommended_plus_10_price_txt',
+    ];
 
     protected function casts(): array
     {
@@ -52,9 +80,101 @@ class CommonCatalogItem extends Model
             'volume' => 'decimal:3',
             'places' => 'integer',
             'max_users' => 'integer',
+            'calculator_enabled' => 'boolean',
         ];
     }
 
+    protected function buildersPrice(): Attribute
+    {
+        return $this->priceAttribute();
+    }
+
+    protected function wholesalePrice(): Attribute
+    {
+        return $this->priceAttribute();
+    }
+
+    protected function recommendedPrice(): Attribute
+    {
+        return $this->priceAttribute();
+    }
+
+    protected function retailPrice(): Attribute
+    {
+        return $this->priceAttribute();
+    }
+
+    protected function projectPrice(): Attribute
+    {
+        return $this->priceAttribute();
+    }
+
+    protected function projectWithInstallationPrice(): Attribute
+    {
+        return $this->priceAttribute();
+    }
+
+    protected function pikPrice(): Attribute
+    {
+        return $this->priceAttribute();
+    }
+
+    protected function recommendedPlus10Price(): Attribute
+    {
+        return $this->priceAttribute();
+    }
+
+    protected function calculatorEnabledTxt(): Attribute
+    {
+        return Attribute::make(
+            get: fn (): string => match ($this->calculator_enabled) {
+                true => 'да',
+                false => 'нет',
+                null => '',
+            },
+        );
+    }
+
+    protected function buildersPriceTxt(): Attribute
+    {
+        return $this->formattedPriceAttribute('builders_price');
+    }
+
+    protected function wholesalePriceTxt(): Attribute
+    {
+        return $this->formattedPriceAttribute('wholesale_price');
+    }
+
+    protected function recommendedPriceTxt(): Attribute
+    {
+        return $this->formattedPriceAttribute('recommended_price');
+    }
+
+    protected function retailPriceTxt(): Attribute
+    {
+        return $this->formattedPriceAttribute('retail_price');
+    }
+
+    protected function projectPriceTxt(): Attribute
+    {
+        return $this->formattedPriceAttribute('project_price');
+    }
+
+    protected function projectWithInstallationPriceTxt(): Attribute
+    {
+        return $this->formattedPriceAttribute('project_with_installation_price');
+    }
+
+    protected function pikPriceTxt(): Attribute
+    {
+        return $this->formattedPriceAttribute('pik_price');
+    }
+
+    protected function recommendedPlus10PriceTxt(): Attribute
+    {
+        return $this->formattedPriceAttribute('recommended_plus_10_price');
+    }
+
     public function imageFile(): BelongsTo
     {
         return $this->belongsTo(File::class, 'image_file_id');
@@ -68,10 +188,49 @@ 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(
             get: fn (): string => (string) ($this->imageFile?->thumbnail_link ?? ''),
         );
     }
+
+    private function priceAttribute(): Attribute
+    {
+        return Attribute::make(
+            get: fn (mixed $value): ?float => $value === null ? null : (int) $value / 100,
+            set: fn (mixed $value): ?int => $value === null || $value === ''
+                ? null
+                : (int) round((float) $value * 100),
+        );
+    }
+
+    private function formattedPriceAttribute(string $field): Attribute
+    {
+        return Attribute::make(
+            get: fn (): string => isset($this->attributes[$field]) && $this->attributes[$field] !== null
+                ? Price::format((int) $this->attributes[$field] / 100)
+                : '-',
+        );
+    }
 }

+ 52 - 0
app/Models/DocumentationDocument.php

@@ -0,0 +1,52 @@
+<?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\Relations\HasOne;
+
+class DocumentationDocument extends Model
+{
+    use HasFactory;
+
+    protected $fillable = [
+        'folder_id',
+        'name',
+        'last_version_number',
+        'created_by',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'last_version_number' => 'integer',
+        ];
+    }
+
+    public function folder(): BelongsTo
+    {
+        return $this->belongsTo(DocumentationFolder::class, 'folder_id');
+    }
+
+    public function creator(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'created_by');
+    }
+
+    public function versions(): HasMany
+    {
+        return $this->hasMany(DocumentationDocumentVersion::class, 'document_id')
+            ->orderByDesc('version');
+    }
+
+    public function currentVersion(): HasOne
+    {
+        return $this->hasOne(DocumentationDocumentVersion::class, 'document_id')
+            ->ofMany('version', 'max');
+    }
+}

+ 65 - 0
app/Models/DocumentationDocumentVersion.php

@@ -0,0 +1,65 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+class DocumentationDocumentVersion extends Model
+{
+    protected $fillable = [
+        'document_id',
+        'file_id',
+        'version',
+        'file_size',
+        'created_by',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'version' => 'integer',
+            'file_size' => 'integer',
+        ];
+    }
+
+    public function document(): BelongsTo
+    {
+        return $this->belongsTo(DocumentationDocument::class, 'document_id');
+    }
+
+    public function file(): BelongsTo
+    {
+        return $this->belongsTo(File::class);
+    }
+
+    public function creator(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'created_by');
+    }
+
+    public function isImage(): bool
+    {
+        return str_starts_with(strtolower((string) $this->file?->mime_type), 'image/');
+    }
+
+    public function iconClass(): string
+    {
+        $mimeType = strtolower((string) $this->file?->mime_type);
+
+        return match (true) {
+            str_starts_with($mimeType, 'image/') => 'bi bi-file-earmark-image',
+            $mimeType === 'application/pdf' => 'bi bi-file-earmark-pdf',
+            $mimeType === 'application/msword', str_contains($mimeType, 'wordprocessingml') => 'bi bi-file-earmark-word',
+            $mimeType === 'application/vnd.ms-excel', str_contains($mimeType, 'spreadsheetml') => 'bi bi-file-earmark-excel',
+            $mimeType === 'application/vnd.ms-powerpoint', str_contains($mimeType, 'presentationml') => 'bi bi-file-earmark-ppt',
+            str_starts_with($mimeType, 'audio/') => 'bi bi-file-earmark-music',
+            str_starts_with($mimeType, 'video/') => 'bi bi-file-earmark-play',
+            str_starts_with($mimeType, 'text/'), str_contains($mimeType, 'json'), str_contains($mimeType, 'xml') => 'bi bi-file-earmark-code',
+            str_contains($mimeType, 'zip'), str_contains($mimeType, 'rar'), str_contains($mimeType, '7z'), str_contains($mimeType, 'gzip') => 'bi bi-file-earmark-zip',
+            default => 'bi bi-file-earmark',
+        };
+    }
+}

+ 54 - 0
app/Models/DocumentationFolder.php

@@ -0,0 +1,54 @@
+<?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;
+
+class DocumentationFolder extends Model
+{
+    use HasFactory;
+
+    protected $fillable = [
+        'parent_id',
+        'name',
+        'inherits_permissions',
+        'created_by',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'inherits_permissions' => 'boolean',
+        ];
+    }
+
+    public function parent(): BelongsTo
+    {
+        return $this->belongsTo(self::class, 'parent_id');
+    }
+
+    public function children(): HasMany
+    {
+        return $this->hasMany(self::class, 'parent_id')->orderBy('name');
+    }
+
+    public function documents(): HasMany
+    {
+        return $this->hasMany(DocumentationDocument::class, 'folder_id')->orderBy('name');
+    }
+
+    public function permissionRules(): HasMany
+    {
+        return $this->hasMany(DocumentationFolderPermission::class, 'folder_id');
+    }
+
+    public function creator(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'created_by');
+    }
+}

+ 49 - 0
app/Models/DocumentationFolderPermission.php

@@ -0,0 +1,49 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+class DocumentationFolderPermission extends Model
+{
+    public const SUBJECT_ALL = 'all';
+
+    public const SUBJECT_ROLE = 'role';
+
+    public const SUBJECT_USER = 'user';
+
+    protected $fillable = [
+        'folder_id',
+        'subject_type',
+        'role_id',
+        'user_id',
+        'can_read',
+        'can_write',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'can_read' => 'boolean',
+            'can_write' => 'boolean',
+        ];
+    }
+
+    public function folder(): BelongsTo
+    {
+        return $this->belongsTo(DocumentationFolder::class, 'folder_id');
+    }
+
+    public function role(): BelongsTo
+    {
+        return $this->belongsTo(Role::class);
+    }
+
+    public function user(): BelongsTo
+    {
+        return $this->belongsTo(User::class);
+    }
+}

+ 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;
+    }
+}

+ 148 - 0
app/Services/DocumentationAccessService.php

@@ -0,0 +1,148 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services;
+
+use App\Models\DocumentationFolder;
+use App\Models\DocumentationFolderPermission;
+use App\Models\Role;
+use App\Models\User;
+use Illuminate\Database\Eloquent\Collection;
+
+class DocumentationAccessService
+{
+    /** @var array<int, DocumentationFolder> */
+    private array $folders = [];
+
+    /** @var array<int, Collection<int, DocumentationFolderPermission>> */
+    private array $effectiveRules = [];
+
+    public function isAdministrator(User $user): bool
+    {
+        return $user->resolvedRoleSlug() === Role::ADMIN;
+    }
+
+    public function canRead(User $user, DocumentationFolder $folder): bool
+    {
+        return $this->can($user, $folder, false);
+    }
+
+    public function canWrite(User $user, DocumentationFolder $folder): bool
+    {
+        return $this->can($user, $folder, true);
+    }
+
+    public function canManagePermissions(User $user, DocumentationFolder $folder): bool
+    {
+        return $this->isAdministrator($user)
+            || ($user->hasPermission('documents.update') && $this->canWrite($user, $folder));
+    }
+
+    /**
+     * Returns readable folders plus their ancestors required to render the tree.
+     * Ancestors without read access are navigation-only nodes.
+     *
+     * @return Collection<int, DocumentationFolder>
+     */
+    public function navigationFolders(User $user): Collection
+    {
+        $folders = DocumentationFolder::query()
+            ->with(['permissionRules.role', 'creator'])
+            ->orderBy('name')
+            ->get();
+
+        foreach ($folders as $folder) {
+            $this->folders[$folder->id] = $folder;
+        }
+
+        $included = [];
+        foreach ($folders as $folder) {
+            $canRead = $this->canRead($user, $folder);
+            $folder->setAttribute('can_read', $canRead);
+            $folder->setAttribute('can_write', $this->canWrite($user, $folder));
+
+            if (! $canRead) {
+                continue;
+            }
+
+            $current = $folder;
+            while ($current) {
+                $included[$current->id] = true;
+                $current = $current->parent_id ? ($this->folders[$current->parent_id] ?? null) : null;
+            }
+        }
+
+        return $folders
+            ->filter(fn (DocumentationFolder $folder): bool => isset($included[$folder->id]))
+            ->values();
+    }
+
+    public function forgetCachedRules(): void
+    {
+        $this->effectiveRules = [];
+        $this->folders = [];
+    }
+
+    private function can(User $user, DocumentationFolder $folder, bool $write): bool
+    {
+        if ($this->isAdministrator($user)) {
+            return true;
+        }
+
+        $this->folders[$folder->id] = $folder;
+
+        foreach ($this->rulesFor($folder) as $rule) {
+            if (! $this->matches($rule, $user)) {
+                continue;
+            }
+
+            if ($write && $rule->can_write) {
+                return true;
+            }
+
+            if (! $write && ($rule->can_read || $rule->can_write)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /** @return Collection<int, DocumentationFolderPermission> */
+    private function rulesFor(DocumentationFolder $folder): Collection
+    {
+        if (isset($this->effectiveRules[$folder->id])) {
+            return $this->effectiveRules[$folder->id];
+        }
+
+        if ($folder->inherits_permissions && $folder->parent_id) {
+            $parent = $this->folders[$folder->parent_id] ?? DocumentationFolder::query()
+                ->with('permissionRules.role')
+                ->find($folder->parent_id);
+
+            if ($parent) {
+                $this->folders[$parent->id] = $parent;
+
+                return $this->effectiveRules[$folder->id] = $this->rulesFor($parent);
+            }
+        }
+
+        $rules = $folder->relationLoaded('permissionRules')
+            ? $folder->permissionRules
+            : $folder->permissionRules()->with('role')->get();
+
+        return $this->effectiveRules[$folder->id] = $rules;
+    }
+
+    private function matches(DocumentationFolderPermission $rule, User $user): bool
+    {
+        return match ($rule->subject_type) {
+            DocumentationFolderPermission::SUBJECT_ALL => true,
+            DocumentationFolderPermission::SUBJECT_USER => $rule->user_id === $user->id,
+            DocumentationFolderPermission::SUBJECT_ROLE => $rule->role_id === $user->role_id
+                || $rule->role?->slug === $user->resolvedRoleSlug(),
+            default => false,
+        };
+    }
+}

+ 260 - 0
app/Services/DocumentationService.php

@@ -0,0 +1,260 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services;
+
+use App\Models\DocumentationDocument;
+use App\Models\DocumentationDocumentVersion;
+use App\Models\DocumentationFolder;
+use App\Models\DocumentationFolderPermission;
+use App\Models\File;
+use App\Models\User;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\DB;
+use Throwable;
+
+class DocumentationService
+{
+    public function __construct(
+        private readonly FileService $fileService,
+        private readonly DocumentationAccessService $accessService,
+    ) {}
+
+    public function createFolder(?DocumentationFolder $parent, string $name, User $user): DocumentationFolder
+    {
+        return DB::transaction(function () use ($parent, $name, $user): DocumentationFolder {
+            $folder = DocumentationFolder::query()->create([
+                'parent_id' => $parent?->id,
+                'name' => $name,
+                'inherits_permissions' => $parent !== null,
+                'created_by' => $user->id,
+            ]);
+
+            if (! $parent) {
+                $folder->permissionRules()->create([
+                    'subject_type' => DocumentationFolderPermission::SUBJECT_ALL,
+                    'can_read' => true,
+                    'can_write' => false,
+                ]);
+            }
+
+            $this->accessService->forgetCachedRules();
+
+            return $folder;
+        });
+    }
+
+    public function renameFolder(DocumentationFolder $folder, string $name): void
+    {
+        $folder->update(['name' => $name]);
+    }
+
+    /** @param array<string, mixed> $data */
+    public function updatePermissions(DocumentationFolder $folder, array $data): void
+    {
+        DB::transaction(function () use ($folder, $data): void {
+            $inherit = $folder->parent_id !== null && (bool) ($data['inherit_permissions'] ?? false);
+
+            $folder->permissionRules()->delete();
+            $folder->update(['inherits_permissions' => $inherit]);
+
+            if ($inherit) {
+                return;
+            }
+
+            $rules = [];
+            $this->mergeRule(
+                $rules,
+                DocumentationFolderPermission::SUBJECT_ALL,
+                null,
+                (bool) ($data['all_read'] ?? false),
+                (bool) ($data['all_write'] ?? false),
+            );
+
+            foreach ((array) ($data['role_read_ids'] ?? []) as $roleId) {
+                $this->mergeRule($rules, DocumentationFolderPermission::SUBJECT_ROLE, (int) $roleId, true, false);
+            }
+            foreach ((array) ($data['role_write_ids'] ?? []) as $roleId) {
+                $this->mergeRule($rules, DocumentationFolderPermission::SUBJECT_ROLE, (int) $roleId, true, true);
+            }
+            foreach ((array) ($data['user_read_ids'] ?? []) as $userId) {
+                $this->mergeRule($rules, DocumentationFolderPermission::SUBJECT_USER, (int) $userId, true, false);
+            }
+            foreach ((array) ($data['user_write_ids'] ?? []) as $userId) {
+                $this->mergeRule($rules, DocumentationFolderPermission::SUBJECT_USER, (int) $userId, true, true);
+            }
+
+            foreach ($rules as $rule) {
+                $folder->permissionRules()->create($rule);
+            }
+        });
+
+        $this->accessService->forgetCachedRules();
+    }
+
+    public function createDocument(
+        DocumentationFolder $folder,
+        ?string $name,
+        UploadedFile $uploadedFile,
+        User $user,
+    ): DocumentationDocument {
+        $file = $this->fileService->saveUploadedPrivateFile("documentation/{$folder->id}", $uploadedFile, $user->id);
+
+        try {
+            return DB::transaction(function () use ($folder, $name, $uploadedFile, $user, $file): DocumentationDocument {
+                $document = DocumentationDocument::query()->create([
+                    'folder_id' => $folder->id,
+                    'name' => $name ?: $file->original_name,
+                    'last_version_number' => 1,
+                    'created_by' => $user->id,
+                ]);
+
+                $document->versions()->create([
+                    'file_id' => $file->id,
+                    'version' => 1,
+                    'file_size' => max(0, (int) $uploadedFile->getSize()),
+                    'created_by' => $user->id,
+                ]);
+
+                return $document;
+            });
+        } catch (Throwable $exception) {
+            $this->deleteStoredFile($file);
+            throw $exception;
+        }
+    }
+
+    public function renameDocument(DocumentationDocument $document, string $name): void
+    {
+        $document->update(['name' => $name]);
+    }
+
+    public function addVersion(
+        DocumentationDocument $document,
+        UploadedFile $uploadedFile,
+        User $user,
+    ): DocumentationDocumentVersion {
+        $file = $this->fileService->saveUploadedPrivateFile("documentation/{$document->folder_id}", $uploadedFile, $user->id);
+
+        try {
+            return DB::transaction(function () use ($document, $uploadedFile, $user, $file): DocumentationDocumentVersion {
+                $lockedDocument = DocumentationDocument::query()->lockForUpdate()->findOrFail($document->id);
+                $nextVersion = $lockedDocument->last_version_number + 1;
+
+                $version = $lockedDocument->versions()->create([
+                    'file_id' => $file->id,
+                    'version' => $nextVersion,
+                    'file_size' => max(0, (int) $uploadedFile->getSize()),
+                    'created_by' => $user->id,
+                ]);
+                $lockedDocument->update(['last_version_number' => $nextVersion]);
+
+                return $version;
+            });
+        } catch (Throwable $exception) {
+            $this->deleteStoredFile($file);
+            throw $exception;
+        }
+    }
+
+    public function deleteVersion(DocumentationDocumentVersion $version): void
+    {
+        $version->loadMissing('file');
+        $file = $version->file;
+        $documentId = $version->document_id;
+
+        DB::transaction(function () use ($version, $documentId): void {
+            $version->delete();
+
+            $document = DocumentationDocument::query()->lockForUpdate()->find($documentId);
+            if ($document && ! $document->versions()->exists()) {
+                $document->delete();
+            }
+        });
+
+        if ($file) {
+            $this->deleteStoredFile($file);
+        }
+    }
+
+    public function deleteDocument(DocumentationDocument $document): void
+    {
+        $document->loadMissing('versions.file');
+        $files = $document->versions->pluck('file')->filter();
+
+        DB::transaction(function () use ($document): void {
+            $document->versions()->delete();
+            $document->delete();
+        });
+
+        foreach ($files as $file) {
+            $this->deleteStoredFile($file);
+        }
+    }
+
+    public function deleteFolder(DocumentationFolder $folder): void
+    {
+        $folderIds = $this->descendantIds($folder);
+        $documents = DocumentationDocument::query()
+            ->whereIn('folder_id', $folderIds)
+            ->with('versions.file')
+            ->get();
+        $files = $documents->flatMap->versions->pluck('file')->filter();
+
+        DB::transaction(fn (): bool => $folder->delete());
+
+        foreach ($files as $file) {
+            $this->deleteStoredFile($file);
+        }
+
+        $this->accessService->forgetCachedRules();
+    }
+
+    /**
+     * @param  array<string, array<string, mixed>>  $rules
+     */
+    private function mergeRule(
+        array &$rules,
+        string $subjectType,
+        ?int $subjectId,
+        bool $canRead,
+        bool $canWrite,
+    ): void {
+        if (! $canRead && ! $canWrite) {
+            return;
+        }
+
+        $key = "{$subjectType}:".($subjectId ?? 'all');
+        $rules[$key] ??= [
+            'subject_type' => $subjectType,
+            'role_id' => $subjectType === DocumentationFolderPermission::SUBJECT_ROLE ? $subjectId : null,
+            'user_id' => $subjectType === DocumentationFolderPermission::SUBJECT_USER ? $subjectId : null,
+            'can_read' => false,
+            'can_write' => false,
+        ];
+        $rules[$key]['can_read'] = (bool) ($rules[$key]['can_read'] || $canRead || $canWrite);
+        $rules[$key]['can_write'] = (bool) ($rules[$key]['can_write'] || $canWrite);
+    }
+
+    /** @return list<int> */
+    private function descendantIds(DocumentationFolder $folder): array
+    {
+        $ids = [$folder->id];
+        $pending = [$folder->id];
+
+        while ($pending !== []) {
+            $children = DocumentationFolder::query()->whereIn('parent_id', $pending)->pluck('id')->all();
+            $pending = array_map('intval', $children);
+            $ids = [...$ids, ...$pending];
+        }
+
+        return $ids;
+    }
+
+    private function deleteStoredFile(File $file): void
+    {
+        $this->fileService->deletePrivateFile($file);
+        $file->delete();
+    }
+}

+ 160 - 51
app/Services/Export/ExportCommonCatalogService.php

@@ -12,6 +12,7 @@ use PhpOffice\PhpSpreadsheet\Spreadsheet;
 use PhpOffice\PhpSpreadsheet\Style\Alignment;
 use PhpOffice\PhpSpreadsheet\Style\Border;
 use PhpOffice\PhpSpreadsheet\Style\Fill;
+use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
 use PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
 use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
 use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
@@ -19,12 +20,14 @@ use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
 class ExportCommonCatalogService
 {
     public const HEADERS = [
-        'ID',
-        'Внешний вид',
         'Артикул',
-        'Наименование по калькулятору',
+        'Наименование',
         'Вид',
-        'Габаритные размеры',
+        'Габариты',
+        null,
+        null,
+        'Размер участка',
+        null,
         'Высота падения',
         'Дополнительные сведения',
         "Единица измерения\nгабаритов",
@@ -35,80 +38,124 @@ class ExportCommonCatalogService
         'Возрастная группа',
         'Макс.кол-во пользователей',
         'Ед.',
+        'Внешний вид',
         'Серия',
         'ТМ',
-        'Примечание',
+        'Калькулятор',
+        'строители',
+        'опт',
+        'рек',
+        'розница',
+        'проект',
+        'проект+м',
+        'пик',
+        'рек+10',
+    ];
+
+    public const SUBHEADERS = [
+        null,
+        null,
+        null,
+        'Длина',
+        'Ширина',
+        'Высота',
+        'Длина',
+        'Ширина',
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        1.05,
+        1.1,
+        1.5,
+        2,
+        1.2,
+        'инд.',
+        1.21,
     ];
 
     public function handle(int $userId): string
     {
         $spreadsheet = new Spreadsheet;
         $sheet = $spreadsheet->getActiveSheet();
-        $sheet->setTitle('Каталог общий');
+        $sheet->setTitle('Общие сведения');
         $sheet->fromArray(self::HEADERS, null, 'A1');
+        $sheet->fromArray(self::SUBHEADERS, null, 'A2');
+        $this->mergeHeaders($sheet);
+        $this->styleHeaders($sheet);
 
-        $sheet->getStyle('A1:S1')->applyFromArray([
-            'font' => ['bold' => true],
-            'fill' => [
-                'fillType' => Fill::FILL_SOLID,
-                'startColor' => ['rgb' => 'D9EAF7'],
-            ],
-            'alignment' => [
-                'horizontal' => Alignment::HORIZONTAL_CENTER,
-                'vertical' => Alignment::VERTICAL_CENTER,
-                'wrapText' => true,
-            ],
-        ]);
-        $sheet->getRowDimension(1)->setRowHeight(42);
-        $sheet->freezePane('A2');
-        $sheet->setAutoFilter('A1:S1');
-
-        $row = 2;
+        $row = 3;
         CommonCatalogItem::query()
             ->with('imageFile')
             ->orderBy('article')
+            ->orderBy('calculator_name')
             ->chunk(200, function ($items) use ($sheet, &$row): void {
                 foreach ($items as $item) {
-                    $sheet->setCellValue("A{$row}", $item->id);
-                    $sheet->setCellValueExplicit("C{$row}", $item->article, DataType::TYPE_STRING);
-                    $sheet->setCellValue("D{$row}", $item->calculator_name);
-                    $sheet->setCellValue("E{$row}", $item->kind);
-                    $sheet->setCellValue("F{$row}", $item->dimensions);
-                    $sheet->setCellValue("G{$row}", $item->fall_height);
-                    $sheet->setCellValue("H{$row}", $item->additional_info);
-                    $sheet->setCellValue("I{$row}", $item->dimension_unit);
-                    $sheet->setCellValue("J{$row}", $item->weight);
-                    $sheet->setCellValue("K{$row}", $item->volume);
-                    $sheet->setCellValue("L{$row}", $item->places);
-                    $sheet->setCellValue("M{$row}", $item->composition);
-                    $sheet->setCellValue("N{$row}", $item->age_group);
-                    $sheet->setCellValue("O{$row}", $item->max_users);
-                    $sheet->setCellValue("P{$row}", $item->unit);
-                    $sheet->setCellValue("Q{$row}", $item->series);
-                    $sheet->setCellValue("R{$row}", $item->trademark);
-                    $sheet->setCellValue("S{$row}", $item->note);
+                    $sheet->setCellValueExplicit("A{$row}", $item->article, DataType::TYPE_STRING);
+                    $sheet->setCellValue("B{$row}", $item->calculator_name);
+                    $sheet->setCellValue("C{$row}", $item->kind);
+                    $sheet->setCellValue("D{$row}", $item->dimension_length);
+                    $sheet->setCellValue("E{$row}", $item->dimension_width);
+                    $sheet->setCellValue("F{$row}", $item->dimension_height);
+                    $sheet->setCellValue("G{$row}", $item->site_length);
+                    $sheet->setCellValue("H{$row}", $item->site_width);
+                    $sheet->setCellValue("I{$row}", $item->fall_height);
+                    $sheet->setCellValue("J{$row}", $item->additional_info);
+                    $sheet->setCellValue("K{$row}", $item->dimension_unit);
+                    $sheet->setCellValue("L{$row}", $item->weight);
+                    $sheet->setCellValue("M{$row}", $item->volume);
+                    $sheet->setCellValue("N{$row}", $item->places);
+                    $sheet->setCellValue("O{$row}", $item->composition);
+                    $sheet->setCellValue("P{$row}", $item->age_group);
+                    $sheet->setCellValue("Q{$row}", $item->max_users);
+                    $sheet->setCellValue("R{$row}", $item->unit);
+                    $sheet->setCellValue("T{$row}", $item->series);
+                    $sheet->setCellValue("U{$row}", $item->trademark);
+                    $sheet->setCellValue("V{$row}", match ($item->calculator_enabled) {
+                        true => 'да',
+                        false => 'нет',
+                        null => null,
+                    });
+                    $sheet->setCellValue("W{$row}", $item->builders_price);
+                    $sheet->setCellValue("X{$row}", $item->wholesale_price);
+                    $sheet->setCellValue("Y{$row}", $item->recommended_price);
+                    $sheet->setCellValue("Z{$row}", $item->retail_price);
+                    $sheet->setCellValue("AA{$row}", $item->project_price);
+                    $sheet->setCellValue("AB{$row}", $item->project_with_installation_price);
+                    $sheet->setCellValue("AC{$row}", $item->pik_price);
+                    $sheet->setCellValue("AD{$row}", $item->recommended_plus_10_price);
 
                     $this->addImage($sheet, $item, $row);
                     $row++;
                 }
             });
 
-        if ($row > 2) {
-            $sheet->getStyle('A1:S'.($row - 1))
+        if ($row > 3) {
+            $lastRow = $row - 1;
+            $sheet->getStyle("A1:AD{$lastRow}")
                 ->getBorders()
                 ->getAllBorders()
                 ->setBorderStyle(Border::BORDER_THIN);
-            $sheet->getStyle('A2:S'.($row - 1))->getAlignment()
+            $sheet->getStyle("A3:AD{$lastRow}")->getAlignment()
                 ->setVertical(Alignment::VERTICAL_TOP)
                 ->setWrapText(true);
+            $sheet->getStyle("W3:AD{$lastRow}")
+                ->getNumberFormat()
+                ->setFormatCode(NumberFormat::FORMAT_NUMBER_COMMA_SEPARATED1);
         }
 
-        foreach (['A' => 8, 'B' => 18, 'C' => 16, 'D' => 38, 'E' => 24, 'F' => 28] as $column => $width) {
-            $sheet->getColumnDimension($column)->setWidth($width);
-        }
-        foreach (range('G', 'S') as $column) {
-            $sheet->getColumnDimension($column)->setWidth(18);
-        }
+        $this->setColumnWidths($sheet);
 
         $directory = 'export/common_catalog';
         $filename = 'common_catalog_'.now()->format('Y-m-d_H-i-s').'.xlsx';
@@ -130,6 +177,68 @@ class ExportCommonCatalogService
         return $link;
     }
 
+    private function mergeHeaders(Worksheet $sheet): void
+    {
+        foreach (['A', 'B', 'C', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W'] as $column) {
+            $sheet->mergeCells("{$column}1:{$column}2");
+        }
+        $sheet->mergeCells('D1:F1');
+        $sheet->mergeCells('G1:H1');
+    }
+
+    private function styleHeaders(Worksheet $sheet): void
+    {
+        $sheet->getStyle('A1:AD2')->applyFromArray([
+            'font' => ['bold' => true],
+            'fill' => [
+                'fillType' => Fill::FILL_SOLID,
+                'startColor' => ['rgb' => 'D9EAF7'],
+            ],
+            'alignment' => [
+                'horizontal' => Alignment::HORIZONTAL_CENTER,
+                'vertical' => Alignment::VERTICAL_CENTER,
+                'wrapText' => true,
+            ],
+        ]);
+        $sheet->getRowDimension(1)->setRowHeight(36);
+        $sheet->getRowDimension(2)->setRowHeight(24);
+        $sheet->freezePane('A3');
+    }
+
+    private function setColumnWidths(Worksheet $sheet): void
+    {
+        foreach ([
+            'A' => 14,
+            'B' => 38,
+            'C' => 24,
+            'D' => 13,
+            'E' => 13,
+            'F' => 13,
+            'G' => 13,
+            'H' => 13,
+            'I' => 15,
+            'J' => 28,
+            'K' => 18,
+            'L' => 14,
+            'M' => 14,
+            'N' => 11,
+            'O' => 28,
+            'P' => 18,
+            'Q' => 18,
+            'R' => 10,
+            'S' => 18,
+            'T' => 20,
+            'U' => 18,
+            'V' => 14,
+        ] as $column => $width) {
+            $sheet->getColumnDimension($column)->setWidth($width);
+        }
+
+        foreach (['W', 'X', 'Y', 'Z', 'AA', 'AB', 'AC', 'AD'] as $column) {
+            $sheet->getColumnDimension($column)->setWidth(16);
+        }
+    }
+
     private function addImage(Worksheet $sheet, CommonCatalogItem $item, int $row): void
     {
         $path = $item->imageFile?->path;
@@ -140,7 +249,7 @@ class ExportCommonCatalogService
         $drawing = new Drawing;
         $drawing->setName($item->article);
         $drawing->setPath(Storage::disk('public')->path($path));
-        $drawing->setCoordinates("B{$row}");
+        $drawing->setCoordinates("S{$row}");
         $drawing->setHeight(68);
         $drawing->setOffsetX(4);
         $drawing->setOffsetY(4);

+ 37 - 4
app/Services/FileService.php

@@ -5,6 +5,7 @@ namespace App\Services;
 use App\Models\File;
 use Exception;
 use FilesystemIterator;
+use Illuminate\Http\UploadedFile;
 use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Str;
 use RecursiveDirectoryIterator;
@@ -23,7 +24,7 @@ class FileService
     {
         $originalName = $this->sanitizeOriginalName((string)$file->getClientOriginalName());
         $storedFilename = $this->buildStoredFilename($originalName);
-        $relativePath = $this->buildUniquePath($path, $storedFilename);
+        $relativePath = $this->buildUniquePath('public', $path, $storedFilename);
         try {
             Storage::disk('public')->put($relativePath, $file->getContent());
         } catch (\Throwable $e) {
@@ -43,6 +44,37 @@ class FileService
         return $fileModel;
     }
 
+    public function saveUploadedPrivateFile(string $path, UploadedFile $file, int $userId): File
+    {
+        $originalName = $this->sanitizeOriginalName($file->getClientOriginalName());
+        $storedFilename = $this->buildStoredFilename($originalName);
+        $relativePath = $this->buildUniquePath('local', $path, $storedFilename);
+
+        try {
+            $stored = Storage::disk('local')->put($relativePath, $file->getContent());
+            if (! $stored) {
+                throw new RuntimeException('Файловое хранилище отклонило запись.');
+            }
+        } catch (\Throwable $exception) {
+            throw new RuntimeException('Не удалось сохранить файл. Проверьте имя файла и повторите попытку.', 0, $exception);
+        }
+
+        return File::query()->create([
+            'link' => '',
+            'path' => $relativePath,
+            'user_id' => $userId,
+            'original_name' => $originalName,
+            'mime_type' => $file->getClientMimeType(),
+        ]);
+    }
+
+    public function deletePrivateFile(File $file): void
+    {
+        if ($file->path) {
+            Storage::disk('local')->delete($file->path);
+        }
+    }
+
     public function ensureThumbnail(File $file, bool $overwrite = false): bool
     {
         if (!$this->isImageFile($file) || !$file->path || $this->isThumbnailPath($file->path)) {
@@ -198,12 +230,13 @@ class FileService
         return $extension === '' ? $baseName : $baseName . '.' . $extension;
     }
 
-    private function buildUniquePath(string $directory, string $filename): string
+    private function buildUniquePath(string $diskName, string $directory, string $filename): string
     {
         $directory = trim($directory, '/');
         $basePath = $directory === '' ? $filename : $directory . '/' . $filename;
 
-        if (!Storage::disk('public')->exists($basePath)) {
+        $disk = Storage::disk($diskName);
+        if (! $disk->exists($basePath)) {
             return $basePath;
         }
 
@@ -217,7 +250,7 @@ class FileService
             }
 
             $candidatePath = $directory === '' ? $candidateName : $directory . '/' . $candidateName;
-            if (!Storage::disk('public')->exists($candidatePath)) {
+            if (! $disk->exists($candidatePath)) {
                 return $candidatePath;
             }
         }

+ 132 - 29
app/Services/Import/ImportCommonCatalogService.php

@@ -7,11 +7,14 @@ namespace App\Services\Import;
 use App\Models\CommonCatalogItem;
 use App\Models\File;
 use App\Models\Import;
+use App\Models\User;
+use App\Services\Access\AccessService;
 use App\Services\Export\ExportCommonCatalogService;
 use App\Services\FileService;
 use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Facades\Validator;
 use Illuminate\Support\Str;
+use PhpOffice\PhpSpreadsheet\Cell\Cell;
 use PhpOffice\PhpSpreadsheet\Reader\Xlsx;
 use PhpOffice\PhpSpreadsheet\Worksheet\BaseDrawing;
 use PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
@@ -32,38 +35,57 @@ class ImportCommonCatalogService
             $path = Storage::disk('upload')->path((string) $this->import->filename);
             $spreadsheet = (new Xlsx)->load($path);
             $sheet = $spreadsheet->getActiveSheet();
-            $this->assertHeaders($sheet->rangeToArray('A1:S1', null, true, true, false)[0]);
+            $this->assertHeaders(
+                $sheet->rangeToArray('A1:AD1', null, true, true, false)[0],
+                $sheet->rangeToArray('A2:AD2', null, true, true, false)[0],
+            );
             $drawings = $this->drawingsByRow($sheet->getDrawingCollection());
+            $articleCounts = $this->articleCounts($sheet);
+            $user = User::query()->findOrFail($this->userId);
+            $access = app(AccessService::class);
 
             $created = 0;
             $updated = 0;
             $errors = 0;
 
-            for ($row = 2; $row <= $sheet->getHighestDataRow(); $row++) {
-                $article = $this->stringValue($sheet->getCell("C{$row}")->getFormattedValue());
+            for ($row = 3; $row <= $sheet->getHighestDataRow(); $row++) {
+                $article = $this->stringValue($sheet->getCell("A{$row}")->getFormattedValue());
                 if ($article === null) {
                     continue;
                 }
 
                 $data = [
                     'article' => $article,
-                    'calculator_name' => $this->stringValue($sheet->getCell("D{$row}")->getValue()),
-                    'kind' => $this->stringValue($sheet->getCell("E{$row}")->getValue()),
-                    'dimensions' => $this->stringValue($sheet->getCell("F{$row}")->getValue()),
-                    'fall_height' => $this->numericValue($sheet->getCell("G{$row}")->getCalculatedValue()),
-                    'additional_info' => $this->stringValue($sheet->getCell("H{$row}")->getValue()),
-                    'dimension_unit' => $this->stringValue($sheet->getCell("I{$row}")->getValue()),
-                    'weight' => $this->numericValue($sheet->getCell("J{$row}")->getCalculatedValue()),
-                    'volume' => $this->numericValue($sheet->getCell("K{$row}")->getCalculatedValue()),
-                    'places' => $this->integerValue($sheet->getCell("L{$row}")->getCalculatedValue()),
-                    'composition' => $this->stringValue($sheet->getCell("M{$row}")->getValue()),
-                    'age_group' => $this->stringValue($sheet->getCell("N{$row}")->getFormattedValue()),
-                    'max_users' => $this->integerValue($sheet->getCell("O{$row}")->getCalculatedValue()),
-                    'unit' => $this->stringValue($sheet->getCell("P{$row}")->getValue()),
-                    'series' => $this->stringValue($sheet->getCell("Q{$row}")->getValue()),
-                    'trademark' => $this->stringValue($sheet->getCell("R{$row}")->getValue()),
-                    'note' => $this->stringValue($sheet->getCell("S{$row}")->getValue()),
+                    'calculator_name' => $this->stringValue($sheet->getCell("B{$row}")->getValue()),
+                    'kind' => $this->stringValue($sheet->getCell("C{$row}")->getValue()),
+                    'dimension_length' => $this->dimensionValue($sheet->getCell("D{$row}")),
+                    'dimension_width' => $this->dimensionValue($sheet->getCell("E{$row}")),
+                    'dimension_height' => $this->dimensionValue($sheet->getCell("F{$row}")),
+                    'site_length' => $this->dimensionValue($sheet->getCell("G{$row}")),
+                    'site_width' => $this->dimensionValue($sheet->getCell("H{$row}")),
+                    'fall_height' => $this->numericValue($sheet->getCell("I{$row}")->getCalculatedValue()),
+                    'additional_info' => $this->stringValue($sheet->getCell("J{$row}")->getValue()),
+                    'dimension_unit' => $this->stringValue($sheet->getCell("K{$row}")->getValue()),
+                    'weight' => $this->numericValue($sheet->getCell("L{$row}")->getCalculatedValue()),
+                    'volume' => $this->numericValue($sheet->getCell("M{$row}")->getCalculatedValue()),
+                    'places' => $this->integerValue($sheet->getCell("N{$row}")->getCalculatedValue()),
+                    'composition' => $this->stringValue($sheet->getCell("O{$row}")->getValue()),
+                    'age_group' => $this->stringValue($sheet->getCell("P{$row}")->getFormattedValue()),
+                    'max_users' => $this->integerValue($sheet->getCell("Q{$row}")->getCalculatedValue()),
+                    'unit' => $this->stringValue($sheet->getCell("R{$row}")->getValue()),
+                    'series' => $this->stringValue($sheet->getCell("T{$row}")->getValue()),
+                    'trademark' => $this->stringValue($sheet->getCell("U{$row}")->getValue()),
+                    'calculator_enabled' => $this->booleanValue($sheet->getCell("V{$row}")->getValue()),
+                    'builders_price' => $this->numericValue($sheet->getCell("W{$row}")->getCalculatedValue()),
+                    'wholesale_price' => $this->numericValue($sheet->getCell("X{$row}")->getCalculatedValue()),
+                    'recommended_price' => $this->numericValue($sheet->getCell("Y{$row}")->getCalculatedValue()),
+                    'retail_price' => $this->numericValue($sheet->getCell("Z{$row}")->getCalculatedValue()),
+                    'project_price' => $this->numericValue($sheet->getCell("AA{$row}")->getCalculatedValue()),
+                    'project_with_installation_price' => $this->numericValue($sheet->getCell("AB{$row}")->getCalculatedValue()),
+                    'pik_price' => $this->numericValue($sheet->getCell("AC{$row}")->getCalculatedValue()),
+                    'recommended_plus_10_price' => $this->numericValue($sheet->getCell("AD{$row}")->getCalculatedValue()),
                 ];
+                $data = $access->filterWritableData($user, 'common-catalog', $data);
 
                 $validator = Validator::make($data, $this->rules());
                 if ($validator->fails()) {
@@ -76,12 +98,17 @@ class ImportCommonCatalogService
                     continue;
                 }
 
-                $item = CommonCatalogItem::query()->where('article', $article)->first();
+                $validated = $validator->validated();
+                $item = $this->findExistingItem(
+                    $article,
+                    $validated['calculator_name'],
+                    $articleCounts[$article] ?? 1,
+                );
                 if ($item) {
-                    $item->update($validator->validated());
+                    $item->update($validated);
                     $updated++;
                 } else {
-                    $item = CommonCatalogItem::query()->create($validator->validated());
+                    $item = CommonCatalogItem::query()->create($validated);
                     $created++;
                 }
 
@@ -113,22 +140,62 @@ class ImportCommonCatalogService
         }
     }
 
-    private function assertHeaders(array $headers): void
+    private function assertHeaders(array $headers, array $subheaders): void
     {
         $actual = array_map($this->normalizeHeader(...), $headers);
         $expected = array_map($this->normalizeHeader(...), ExportCommonCatalogService::HEADERS);
+        $actualSubheaders = array_map($this->normalizeHeader(...), $subheaders);
+        $expectedSubheaders = array_map($this->normalizeHeader(...), ExportCommonCatalogService::SUBHEADERS);
 
-        if ($actual !== $expected) {
-            throw new RuntimeException('Некорректные заголовки файла общего каталога. Используйте утверждённый шаблон.');
+        if ($actual !== $expected || $actualSubheaders !== $expectedSubheaders) {
+            throw new RuntimeException('Некорректные заголовки файла общего каталога. Используйте утверждённый формат.');
         }
     }
 
+    private function articleCounts(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $sheet): array
+    {
+        $counts = [];
+        for ($row = 3; $row <= $sheet->getHighestDataRow(); $row++) {
+            $article = $this->stringValue($sheet->getCell("A{$row}")->getFormattedValue());
+            if ($article !== null) {
+                $counts[$article] = ($counts[$article] ?? 0) + 1;
+            }
+        }
+
+        return $counts;
+    }
+
+    private function findExistingItem(
+        string $article,
+        ?string $name,
+        int $sourceOccurrences,
+    ): ?CommonCatalogItem {
+        $exact = CommonCatalogItem::query()
+            ->where('article', $article)
+            ->where('calculator_name', $name)
+            ->first();
+        if ($exact) {
+            return $exact;
+        }
+
+        if ($sourceOccurrences !== 1) {
+            return null;
+        }
+
+        $items = CommonCatalogItem::query()
+            ->where('article', $article)
+            ->limit(2)
+            ->get();
+
+        return $items->count() === 1 ? $items->first() : null;
+    }
+
     /** @param iterable<BaseDrawing> $drawings */
     private function drawingsByRow(iterable $drawings): array
     {
         $result = [];
         foreach ($drawings as $drawing) {
-            if (preg_match('/^B(\d+)$/i', $drawing->getCoordinates(), $matches) === 1) {
+            if (preg_match('/^S(\d+)$/i', $drawing->getCoordinates(), $matches) === 1) {
                 $result[(int) $matches[1]] = $drawing;
             }
         }
@@ -216,6 +283,16 @@ class ImportCommonCatalogService
         return str_replace([' ', ','], ['', '.'], $value);
     }
 
+    private function dimensionValue(Cell $cell): ?string
+    {
+        $calculated = $cell->getCalculatedValue();
+        if (is_int($calculated) || is_float($calculated)) {
+            return rtrim(rtrim(number_format((float) $calculated, 10, '.', ''), '0'), '.');
+        }
+
+        return $this->stringValue($cell->getFormattedValue());
+    }
+
     private function integerValue(mixed $value): int|string|null
     {
         $value = $this->numericValue($value);
@@ -226,13 +303,31 @@ class ImportCommonCatalogService
         return is_numeric($value) ? (int) $value : (string) $value;
     }
 
+    private function booleanValue(mixed $value): bool|string|null
+    {
+        $value = $this->stringValue($value);
+        if ($value === null) {
+            return null;
+        }
+
+        return match (mb_strtolower($value)) {
+            'да', 'yes', '1' => true,
+            'нет', 'no', '0' => false,
+            default => $value,
+        };
+    }
+
     private function rules(): array
     {
         return [
             'article' => ['required', 'string', 'max:100'],
-            'calculator_name' => ['required', 'string'],
+            'calculator_name' => ['nullable', 'string', 'max:255'],
             'kind' => ['nullable', 'string', 'max:255'],
-            'dimensions' => ['nullable', 'string'],
+            'dimension_length' => ['nullable', 'string', 'max:255'],
+            'dimension_width' => ['nullable', 'string', 'max:255'],
+            'dimension_height' => ['nullable', 'string', 'max:255'],
+            'site_length' => ['nullable', 'string', 'max:255'],
+            'site_width' => ['nullable', 'string', 'max:255'],
             'fall_height' => ['nullable', 'numeric', 'min:0'],
             'additional_info' => ['nullable', 'string'],
             'dimension_unit' => ['nullable', 'string', 'max:50'],
@@ -245,7 +340,15 @@ class ImportCommonCatalogService
             'unit' => ['nullable', 'string', 'max:50'],
             'series' => ['nullable', 'string', 'max:255'],
             'trademark' => ['nullable', 'string', 'max:255'],
-            'note' => ['nullable', 'string'],
+            'calculator_enabled' => ['nullable', 'boolean'],
+            'builders_price' => ['nullable', 'numeric', 'min:0'],
+            'wholesale_price' => ['nullable', 'numeric', 'min:0'],
+            'recommended_price' => ['nullable', 'numeric', 'min:0'],
+            'retail_price' => ['nullable', 'numeric', 'min:0'],
+            'project_price' => ['nullable', 'numeric', 'min:0'],
+            'project_with_installation_price' => ['nullable', 'numeric', 'min:0'],
+            'pik_price' => ['nullable', 'numeric', 'min:0'],
+            'recommended_plus_10_price' => ['nullable', 'numeric', 'min:0'],
         ];
     }
 }

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

@@ -0,0 +1,147 @@
+<?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('Не указан артикул.');
+                    }
+
+                    $items = CommonCatalogItem::query()
+                        ->where('article', $article)
+                        ->limit(2)
+                        ->get();
+                    if ($items->isEmpty()) {
+                        throw new RuntimeException("Позиция с артикулом {$article} не найдена.");
+                    }
+                    if ($items->count() > 1) {
+                        throw new RuntimeException(
+                            "Артикул {$article} соответствует нескольким вариантам. Добавьте заказ вручную, выбрав наименование.",
+                        );
+                    }
+                    $item = $items->first();
+                    $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,
+        ]);
+    }
+}

+ 54 - 0
config/access.php

@@ -126,6 +126,60 @@ return [
             'documents.upload' => 'Загрузка документов',
             'documents.delete' => 'Удаление документов',
         ],
+        'fields' => [
+            'id' => 'ID',
+            'image' => 'Внешний вид',
+            'article' => 'Артикул',
+            'calculator_name' => 'Наименование',
+            'kind' => 'Вид',
+            'dimension_length' => 'Габариты: длина',
+            'dimension_width' => 'Габариты: ширина',
+            'dimension_height' => 'Габариты: высота',
+            'site_length' => 'Размер участка: длина',
+            'site_width' => 'Размер участка: ширина',
+            'fall_height' => 'Высота падения',
+            'additional_info' => 'Дополнительные сведения',
+            'dimension_unit' => 'Единица измерения габаритов',
+            'weight' => 'Вес, кг.',
+            'volume' => 'Объем, м3',
+            'places' => 'Места',
+            'composition' => 'Состав',
+            'age_group' => 'Возрастная группа',
+            'max_users' => 'Макс. кол-во пользователей',
+            'unit' => 'Ед.',
+            'series' => 'Серия',
+            'trademark' => 'ТМ',
+            'calculator_enabled' => 'Калькулятор',
+            'builders_price' => 'Цена: строители',
+            'wholesale_price' => 'Цена: опт',
+            'recommended_price' => 'Цена: рек',
+            'retail_price' => 'Цена: розница',
+            'project_price' => 'Цена: проект',
+            'project_with_installation_price' => 'Цена: проект+м',
+            'pik_price' => 'Цена: пик',
+            'recommended_plus_10_price' => 'Цена: рек+10',
+        ],
+    ],
+    'documents' => [
+        'name' => 'Документация',
+        'entity' => 'documentation_folder',
+        'actions' => [
+            'view' => 'Просмотр',
+            'update' => 'Управление корневыми папками и правами',
+        ],
+    ],
+    'stock' => [
+        'name' => 'Склад',
+        'entity' => 'stock_order',
+        'actions' => [
+            'view' => 'Просмотр наличия',
+            'reserve' => 'Бронирование',
+            'issue' => 'Списание брони',
+            'cancel' => 'Отмена брони',
+            'orders.view' => 'Просмотр заказов',
+            'orders.manage' => 'Управление заказами',
+            'orders.import' => 'Импорт заказов',
+        ],
     ],
     'maf' => [
         'name' => 'МАФ',

+ 17 - 3
config/access_routes.php

@@ -4,7 +4,6 @@ return [
     'exact' => [
         'area.ajax-get-areas-by-district' => 'areas.ajax.view',
         'calculations.index' => true,
-        'documents.index' => true,
         'getFilters' => 'filters.view',
         'notifications.index' => true,
         'notifications.read-all' => true,
@@ -15,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',
@@ -77,6 +74,23 @@ return [
             'documents.upload' => 'common-catalog.documents.upload',
             'documents.delete' => 'common-catalog.documents.delete',
         ],
+        'documents.' => [
+            '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',

+ 14 - 2
database/factories/CommonCatalogItemFactory.php

@@ -18,7 +18,11 @@ class CommonCatalogItemFactory extends Factory
             'article' => fake()->unique()->numerify('####'),
             'calculator_name' => fake()->sentence(),
             'kind' => fake()->randomElement(['Игровое оборудование', 'Теневой навес', 'Мебель']),
-            'dimensions' => fake()->randomElement(['2,0 × 1,5 × 1,2 м', '3,0 × 2,0 × 2,5 м']),
+            'dimension_length' => (string) fake()->randomFloat(2, 1, 10),
+            'dimension_width' => (string) fake()->randomFloat(2, 1, 10),
+            'dimension_height' => (string) fake()->randomFloat(2, 1, 5),
+            'site_length' => (string) fake()->randomFloat(2, 1, 15),
+            'site_width' => (string) fake()->randomFloat(2, 1, 15),
             'fall_height' => fake()->randomFloat(3, 0, 3),
             'additional_info' => fake()->optional()->sentence(),
             'dimension_unit' => 'м',
@@ -31,7 +35,15 @@ class CommonCatalogItemFactory extends Factory
             'unit' => 'шт.',
             'series' => fake()->word(),
             'trademark' => fake()->company(),
-            'note' => fake()->optional()->sentence(),
+            'calculator_enabled' => fake()->boolean(),
+            'builders_price' => fake()->randomFloat(2, 1000, 3000000),
+            'wholesale_price' => fake()->randomFloat(2, 1000, 3000000),
+            'recommended_price' => fake()->randomFloat(2, 1000, 3000000),
+            'retail_price' => fake()->randomFloat(2, 1000, 3000000),
+            'project_price' => fake()->randomFloat(2, 1000, 3000000),
+            'project_with_installation_price' => fake()->randomFloat(2, 1000, 3000000),
+            'pik_price' => fake()->randomFloat(2, 1000, 3000000),
+            'recommended_plus_10_price' => fake()->randomFloat(2, 1000, 3000000),
         ];
     }
 }

+ 73 - 0
database/migrations/2026_08_03_000002_create_documentation_tables.php

@@ -0,0 +1,73 @@
+<?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('documentation_folders', function (Blueprint $table): void {
+            $table->id();
+            $table->foreignId('parent_id')
+                ->nullable()
+                ->constrained('documentation_folders')
+                ->cascadeOnDelete();
+            $table->string('name');
+            $table->boolean('inherits_permissions')->default(true);
+            $table->foreignId('created_by')->constrained('users')->restrictOnDelete();
+            $table->timestamps();
+
+            $table->index(['parent_id', 'name']);
+        });
+
+        Schema::create('documentation_folder_permissions', function (Blueprint $table): void {
+            $table->id();
+            $table->foreignId('folder_id')->constrained('documentation_folders')->cascadeOnDelete();
+            $table->string('subject_type', 16);
+            $table->foreignId('role_id')->nullable()->constrained('roles')->cascadeOnDelete();
+            $table->foreignId('user_id')->nullable()->constrained('users')->cascadeOnDelete();
+            $table->boolean('can_read')->default(false);
+            $table->boolean('can_write')->default(false);
+            $table->timestamps();
+
+            $table->index(['folder_id', 'subject_type']);
+            $table->index(['role_id', 'can_read', 'can_write'], 'documentation_folder_role_access_index');
+            $table->index(['user_id', 'can_read', 'can_write'], 'documentation_folder_user_access_index');
+        });
+
+        Schema::create('documentation_documents', function (Blueprint $table): void {
+            $table->id();
+            $table->foreignId('folder_id')->constrained('documentation_folders')->cascadeOnDelete();
+            $table->string('name');
+            $table->unsignedInteger('last_version_number')->default(0);
+            $table->foreignId('created_by')->constrained('users')->restrictOnDelete();
+            $table->timestamps();
+
+            $table->index(['folder_id', 'name']);
+        });
+
+        Schema::create('documentation_document_versions', function (Blueprint $table): void {
+            $table->id();
+            $table->foreignId('document_id')->constrained('documentation_documents')->cascadeOnDelete();
+            $table->foreignId('file_id')->constrained('files')->restrictOnDelete();
+            $table->unsignedInteger('version');
+            $table->unsignedBigInteger('file_size')->default(0);
+            $table->foreignId('created_by')->constrained('users')->restrictOnDelete();
+            $table->timestamps();
+
+            $table->unique(['document_id', 'version']);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('documentation_document_versions');
+        Schema::dropIfExists('documentation_documents');
+        Schema::dropIfExists('documentation_folder_permissions');
+        Schema::dropIfExists('documentation_folders');
+    }
+};

+ 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');
+    }
+};

+ 61 - 0
database/migrations/2026_08_05_000001_expand_common_catalog_items_for_actual_catalog.php

@@ -0,0 +1,61 @@
+<?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::table('common_catalog_items', function (Blueprint $table): void {
+            $table->dropUnique(['article']);
+            $table->string('calculator_name')->change();
+            $table->string('dimension_length')->nullable()->after('kind');
+            $table->string('dimension_width')->nullable()->after('dimension_length');
+            $table->string('dimension_height')->nullable()->after('dimension_width');
+            $table->string('site_length')->nullable()->after('dimension_height');
+            $table->string('site_width')->nullable()->after('site_length');
+            $table->boolean('calculator_enabled')->nullable()->after('trademark');
+            $table->unsignedBigInteger('builders_price')->nullable()->after('calculator_enabled');
+            $table->unsignedBigInteger('wholesale_price')->nullable()->after('builders_price');
+            $table->unsignedBigInteger('recommended_price')->nullable()->after('wholesale_price');
+            $table->unsignedBigInteger('retail_price')->nullable()->after('recommended_price');
+            $table->unsignedBigInteger('project_price')->nullable()->after('retail_price');
+            $table->unsignedBigInteger('project_with_installation_price')->nullable()->after('project_price');
+            $table->unsignedBigInteger('pik_price')->nullable()->after('project_with_installation_price');
+            $table->unsignedBigInteger('recommended_plus_10_price')->nullable()->after('pik_price');
+            $table->unique(
+                ['article', 'calculator_name'],
+                'common_catalog_items_article_name_unique',
+            );
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('common_catalog_items', function (Blueprint $table): void {
+            $table->dropUnique('common_catalog_items_article_name_unique');
+            $table->dropColumn([
+                'dimension_length',
+                'dimension_width',
+                'dimension_height',
+                'site_length',
+                'site_width',
+                'calculator_enabled',
+                'builders_price',
+                'wholesale_price',
+                'recommended_price',
+                'retail_price',
+                'project_price',
+                'project_with_installation_price',
+                'pik_price',
+                'recommended_plus_10_price',
+            ]);
+            $table->text('calculator_name')->change();
+            $table->unique('article');
+        });
+    }
+};

+ 24 - 0
database/migrations/2026_08_05_000002_sync_common_catalog_field_permissions.php

@@ -0,0 +1,24 @@
+<?php
+
+declare(strict_types=1);
+
+use Database\Seeders\RbacSeeder;
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        if (
+            ! Schema::hasTable('roles')
+            || ! Schema::hasTable('permissions')
+            || ! Schema::hasTable('role_permissions')
+            || ! Schema::hasTable('user_permissions')
+        ) {
+            return;
+        }
+
+        app(RbacSeeder::class)->run();
+    }
+};

+ 24 - 0
database/migrations/2026_08_05_000003_make_common_catalog_calculator_name_nullable.php

@@ -0,0 +1,24 @@
+<?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::table('common_catalog_items', function (Blueprint $table): void {
+            $table->string('calculator_name')->nullable()->change();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('common_catalog_items', function (Blueprint $table): void {
+            $table->string('calculator_name')->nullable(false)->change();
+        });
+    }
+};

+ 45 - 3
database/seeders/RbacSeeder.php

@@ -11,6 +11,17 @@ use Illuminate\Support\Facades\DB;
 
 class RbacSeeder extends Seeder
 {
+    private const COMMON_CATALOG_PRICE_FIELDS = [
+        'builders_price',
+        'wholesale_price',
+        'recommended_price',
+        'retail_price',
+        'project_price',
+        'project_with_installation_price',
+        'pik_price',
+        'recommended_plus_10_price',
+    ];
+
     public function run(): void
     {
         DB::transaction(function () {
@@ -78,7 +89,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,
                         ],
@@ -121,10 +132,24 @@ class RbacSeeder extends Seeder
             'areas.ajax.view',
             'catalog.search',
             'common-catalog.view',
+            'documents.view',
+            'stock.view',
+            'stock.reserve',
+            'stock.issue',
+            'stock.cancel',
             'pricing_codes.search',
             'filters.view',
         ];
 
+        $commonCatalogPublicFields = array_diff(
+            array_keys(config('access.common-catalog.fields', [])),
+            self::COMMON_CATALOG_PRICE_FIELDS,
+        );
+        $auth = array_merge($auth, array_map(
+            fn (string $field): string => "common-catalog.fields.{$field}.view",
+            $commonCatalogPublicFields,
+        ));
+
         $manager = array_merge($auth, [
             'orders.update',
             'orders.create',
@@ -191,9 +216,14 @@ class RbacSeeder extends Seeder
             'schedule.scope.manager',
         ]);
 
+        $assistantHead = array_values(array_diff(
+            $all,
+            $this->commonCatalogAdminOnlyPermissions(),
+        ));
+
         $rolePermissions = [
             Role::ADMIN => $all,
-            Role::ASSISTANT_HEAD => $all,
+            Role::ASSISTANT_HEAD => $assistantHead,
             Role::MANAGER => $manager,
             Role::BRIGADIER => $brigadier,
             Role::WAREHOUSE_HEAD => $warehouseHead,
@@ -201,7 +231,7 @@ class RbacSeeder extends Seeder
 
         foreach ($rolePermissions as $roleSlug => $permissionSlugs) {
             $role = $roles[$roleSlug] ?? null;
-            if (!$role) {
+            if (! $role) {
                 continue;
             }
 
@@ -243,4 +273,16 @@ class RbacSeeder extends Seeder
                 && in_array(substr($slug, strrpos($slug, '.') + 1), $actions, true)
         ));
     }
+
+    private function commonCatalogAdminOnlyPermissions(): array
+    {
+        $permissions = ['common-catalog.export'];
+
+        foreach (self::COMMON_CATALOG_PRICE_FIELDS as $field) {
+            $permissions[] = "common-catalog.fields.{$field}.view";
+            $permissions[] = "common-catalog.fields.{$field}.update";
+        }
+
+        return $permissions;
+    }
 }

+ 20 - 18
docs/refactor/menu.md

@@ -33,14 +33,14 @@ 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-экспорт наличия по шаблону.
 
@@ -149,26 +147,28 @@ Manager
 | Карточка позиции | Реализовано | `common-catalog.show` | Центральная карточка общей позиции/МАФ со всеми полями шаблона. |
 | Фото МАФ | Реализовано | `common-catalog.image.*` | Фото хранится через текущий файловый механизм и выводится в списке/карточке. |
 | Документы | Реализовано | `common-catalog.documents.*` | Поддержан набор сопутствующих документов позиции. |
-| Техническое описание | Подготовлено место внутри карточки | `common-catalog.show` | Экспорт будет подключен после получения готового Laravel-модуля. |
-| Калькуляция | Подготовлена кнопка | `common-catalog.show` | API-запрос будет подключен после получения контракта API. |
+| Техническое описание | Источник изучен, готово к реализации | `common-catalog.show` | Данные и DOCX-экспорты `to.stroyprofit.com` переносятся внутрь карточки общего каталога. |
+| Калькуляция | Подготовлена кнопка | `common-catalog.show` | Финальное действие кнопки определяется отдельным ТЗ; `calc.stroyprofit.com` изучен как источник модели и экспортов. |
 
 ### Документация
 
-Статус: **Нужно реализовать**.
+Статус: **Реализовано и проверено пользователем**.
 
-Новый раздел для папок и файлов: инструкции, прайсы, прочая документация. На первом этапе нужна заглушка.
+Рабочий раздел для инструкций, прайсов и прочих файлов. Реализованы дерево папок,
+наследуемые права чтения/записи для всех, ролей и пользователей, поиск,
+приватное хранение файлов и управление версиями документов.
 
 ### Технич. описание
 
-Статус: **Встроить в каталог**.
+Статус: **Источник изучен, встроить в каталог**.
 
-Это не отдельный пункт меню и не самостоятельный модуль. Функциональность технического описания должна быть доступна из карточки позиции `Каталог общий`: по сути это те же данные позиции, но с выгрузками в определенном формате. Есть готовый Laravel-модуль, из которого нужно взять экспорт.
+Это не отдельный пункт меню и не самостоятельный модуль. Функциональность технического описания должна быть доступна из карточки позиции `Каталог общий`: по сути это те же данные позиции, но с выгрузками в определенном формате. Модуль `to.stroyprofit.com` уже изучен; из него переносятся недостающие данные, массовый DOCX и отдельные DOCX в ZIP.
 
 ### Калькуляции
 
-Статус: **Нужно реализовать позже**.
+Статус: **Исходный модуль изучен, ожидается отдельное ТЗ**.
 
-Калькуляции работают через внешний API: запрос выполняется по артикулу, в ответ возвращается калькуляция, если она найдена. Подробное описание API будет предоставлено отдельно.
+Кнопка калькуляции остается в карточке общего каталога. Финальная архитектура пока не утверждена: отдельное ТЗ должно определить внутренний расчёт в Laravel либо интеграцию. `calc.stroyprofit.com` используется как источник сущностей, формул и Excel-выгрузок, но не переносится без проверки.
 
 ### Администратор
 
@@ -201,4 +201,6 @@ Manager
 
 ## Открытые вопросы
 
-1. Какой точный контракт внешнего API калькуляций?
+1. Когда будет передано отдельное ТЗ на `График заказов`?
+2. Будет ли отдельное ТЗ на `Калькуляции` и какую архитектуру оно определит?
+3. Где находятся актуальные исходные данные старого калькулятора?

+ 102 - 62
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] Текущие рабочие маршруты оставляем без переименования.
@@ -37,7 +37,15 @@
 - [x] Заглушки новых модулей показываем всем авторизованным пользователям.
 - [x] На новые модули выбранный год не влияет.
 - [x] `Технич. описание` встроено в модуль `Каталог общий`; готовый Laravel-модуль используем как источник экспорта.
-- [x] `Калькуляции` работают через внешний API: запрос по артикулу, возврат калькуляции при наличии.
+- [x] Модуль техописаний `to.stroyprofit.com` изучен: его данные и DOCX-экспорты переносятся в `Каталог общий` без отдельного раздела.
+- [x] Цены принадлежат `Каталог общий` и хранятся непосредственно у позиции; актуальный формат и реальные значения определены файлом `docs/refactor/Каталог общий.xlsx`.
+- [x] В общем каталоге используются восемь видов цен: `строители`, `опт`, `рек`, `розница`, `проект`, `проект+м`, `пик`, `рек+10`.
+- [x] По умолчанию цены и экспорт общего каталога доступны только администратору; просмотр и редактирование цен управляются существующими permissions отдельных полей.
+- [x] Исходный калькулятор `calc.stroyprofit.com` используется как источник модели данных, формул и экспортов, но его код и текущие формулы не переносятся без проверки.
+- [x] Финальный состав и способ реализации `Калькуляций` определяются отдельным ТЗ; допустимы внутренняя реализация в Laravel и интеграционный вариант.
+- [x] Старый `График отгрузок` и действующий обработчик 1С в `manager.stroyprofit.com` изучены как исходная система для `Графика заказов`.
+- [x] `График заказов` проектируется максимально нормализованно: заказ, позиции, доставки и загрузки 1С хранятся отдельными сущностями, без сериализованных полей и фиксированных колонок для нескольких доставок.
+- [x] Финальные поля, статусы, права и пользовательские сценарии `Графика заказов` определяются отдельным ТЗ заказчика.
 - [x] `Рекламации -> ДКР` реализуется как отдельная вкладка на основе текущего списка с обязательным фильтром по типу `dkr`.
 - [x] Тип рекламации хранится через обязательную связь со справочником `reclamation_types`; начальные типы `ДКР` и `Прочее` создаются сидером.
 - [x] В интерфейсе рекламаций обязательны отдельные вкладки `Все` и `ДКР`.
@@ -45,7 +53,7 @@
 - [x] Документация для оплаты доступна только для рекламаций типа `ДКР`.
 - [x] Маршруты `Каталог общий` используют префикс `common-catalog/...`.
 - [x] Таблицы и права `Каталог общий` используют техническое имя `common-catalog`.
-- [x] Техническое имя модуля `Склад наличие`: `stock`.
+- [x] Техническое имя модуля `Склад`: `stock`.
 - [x] Роль `помощник руководителя`: `assistant_head`.
 
 ## Этап 0. Документация
@@ -59,7 +67,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).
@@ -72,17 +80,17 @@
 | 1. Реорганизация меню и заглушки | Реализован и проверен | Новое меню и заглушки приняты по результатам пользовательской проверки. |
 | 2. Адаптация существующих разделов после переноса | Реализован и проверен | Рабочие разделы и их отображение приняты по результатам пользовательской проверки. |
 | 3. Рекламации: вкладки и тип | Реализован, ожидает пользовательской проверки | Добавлены вкладки, справочник типов, перенос существующих данных и запрет платежных документов для `Прочее`; создание из графика остается в этапе 10. |
-| 4. Общий каталог: ядро | Реализован и проверен | Пользователь проверил создание, редактирование, импорт и экспорт; отдельная модель данных, права, фото и документы покрыты автоматическими тестами. |
-| 5. Документация | Готов к реализации | Решены дерево папок, наследование прав, версии и хранение файлов. |
-| 6. Склад наличие: ядро | Готов к реализации | Ядро общего каталога доступно как источник позиций; PDF-экспорт остается отдельным открытым подпунктом. |
-| 7. Техническое описание | Готово к анализу Laravel-модуля | Место в карточке общего каталога подготовлено, но нужно получить модуль и форматы выгрузки. |
-| 8. Калькуляции | Не готово к полной реализации | Нужен контракт внешнего API. Можно сделать только место/кнопку в карточке общего каталога. |
-| 9. Графики заказов и доставок | Не готово к полной реализации | Нужны формат обмена с 1С, поля, статусы и правила доставок/отгрузок. |
+| 4. Общий каталог | Расширение реализовано, ожидает пользовательской проверки | Импорт, экспорт и карточка переведены на фактический 30-колоночный формат `Каталог общий.xlsx`; добавлены восемь цен и ограничения доступа. |
+| 5. Документация | Реализован и проверен | Пользователь проверил дерево папок, работу с документами и версиями; права и приватное хранение дополнительно покрыты автоматическими тестами. |
+| 6. Склад: ядро | Реализован и проверен | Ядро склада принято после пользовательской проверки. Цены относятся к общему каталогу; для склада остаётся отдельной задачей только PDF-экспорт после согласования шаблона. |
+| 7. Техническое описание | Анализ завершён, готово к реализации | Изучены данные, импорт, изображения, DOCX-шаблоны и массовый/раздельный экспорт `to.stroyprofit.com`; требуется перенос в общий каталог. |
+| 8. Калькуляции | Исходник изучен, ожидается отдельное ТЗ | Восстановлены сущности, формулы и экспорты `calc.stroyprofit.com`; финальная архитектура, формулы и сценарии не фиксируются до ТЗ. Рабочая база источника сейчас не содержит расчётных данных. |
+| 9. Графики заказов и доставок | Исходная система изучена, ожидается отдельное ТЗ | Найдены старый график и фактический формат обмена с 1С. Нормализация принята как архитектурный принцип, но финальные поля, статусы и правила уточнит заказчик. |
 | 10. Рекламации из графика заказов | Готово только после графика заказов | Сценарий согласован, но точка входа зависит от карточки заказа в графике. |
 
 ## Этап 1. Реорганизация меню и заглушки
 
-Статус: **реализован и проверен пользователем**.
+Статус: **ядро проверено пользователем; расширение под актуальный каталог реализовано и ожидает пользовательской проверки**.
 
 - [x] Проверить текущие permissions для всех существующих пунктов меню.
 - [x] Для новых пунктов-заглушек открыть доступ всем авторизованным пользователям.
@@ -96,7 +104,7 @@
 - [x] Добавить заглушки `График заказов` и `График доставок`.
 - [x] Разложить `Рекламации` на `Все` и `ДКР`.
 - [x] Оставить `Запчасти` отдельной верхней группой с текущими вкладками.
-- [x] Добавить заглушки `Склад наличие -> Наличие` и `Склад наличие -> Заказы`.
+- [x] Добавить заглушки `Склад -> Наличие` и `Склад -> Заказы`.
 - [x] Добавить заглушки `Документация`, `Каталог общий`, `Калькуляции`.
 - [x] Проверить, что выбранный год не скрывает и не фильтрует новые модули.
 - [x] Переименовать группу `Администрирование` в `Администратор`.
@@ -146,7 +154,7 @@
 - [x] Спроектировать маршруты с префиксом `common-catalog/...`, таблицы и права с техническим именем `common-catalog`.
 - [x] Сверить текущую модель каталога ДКР с требованиями общего каталога как источник паттернов, без смешивания данных.
 - [x] Зафиксировать состав и порядок колонок общего каталога.
-- [x] Зафиксировать файл `docs/refactor/Шаблон Каталог ощий.xlsx` как источник колонок общего каталога.
+- [x] Зафиксировать файл `docs/refactor/Каталог общий.xlsx` как актуальный источник колонок и реальных данных общего каталога; старый шаблон оставить историческим референсом.
 - [x] Зафиксировать предварительные типы данных по шаблону общего каталога.
 - [x] Спроектировать обязательность и валидацию утвержденных полей.
 - [x] Использовать текущий файловый механизм для хранения фото и документов позиции.
@@ -155,77 +163,106 @@
 - [x] Зафиксировать встраивание `Технич. описание` в карточку общего каталога.
 - [x] Добавить в карточку место под будущую кнопку/блок `Калькуляции`.
 - [x] Зафиксировать интеграционный ключ `common_catalog_items.id` для будущих связей с графиками, рекламациями и складом наличия; сами связи реализуются в этапах этих модулей.
+- [x] Перевести поля, импорт и экспорт на фактический формат `docs/refactor/Каталог общий.xlsx` из 30 колонок и двух строк заголовка.
+- [x] Добавить восемь видов цен непосредственно в `common_catalog_items`.
+- [x] Скрыть цены по умолчанию от всех ролей, кроме администратора, через field-permissions.
+- [x] Ограничить экспорт общего каталога администратором по умолчанию.
 
 ## Этап 5. Документация
 
-Статус: **готов к реализации**.
+Статус: **реализован и проверен пользователем**.
 
-- [ ] Реализовать дерево папок.
-- [ ] Разрешить создание корневых папок только админу.
-- [ ] Реализовать права чтения и записи на папки для всех, ролей и отдельных пользователей.
-- [ ] Реализовать наследование прав от родительской папки при создании подпапок.
-- [ ] Реализовать создание, просмотр, редактирование и удаление документов.
-- [ ] Реализовать версионность документов.
-- [ ] Реализовать полное удаление отдельных версий.
-- [ ] Использовать текущий механизм хранения файлов.
+- [x] Реализовать дерево папок.
+- [x] Разрешить создание корневых папок только админу.
+- [x] Реализовать права чтения и записи на папки для всех, ролей и отдельных пользователей.
+- [x] Реализовать наследование прав от родительской папки при создании подпапок.
+- [x] Реализовать создание, просмотр, редактирование и удаление документов.
+- [x] Отображать иконки файлов по MIME-типу и открывать изображения в защищённом предпросмотре.
+- [x] Реализовать версионность документов.
+- [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. Техническое описание в общем каталоге
 
-Статус: **готово после этапа 4 и анализа готового Laravel-модуля**.
-
-- [ ] Получить и изучить готовый Laravel-модуль техописаний.
-- [ ] Составить карту данных модуля техописаний.
-- [ ] Составить карту экспортов и форматов выгрузки.
-- [ ] Сопоставить данные техописаний с полями `Каталог общий`.
-- [ ] Решить, какие данные хранятся прямо в карточке позиции, а какие требуют связанных таблиц.
+Статус: **анализ источника завершён, готово к реализации**.
+
+- [x] Получить и изучить готовый Laravel-модуль техописаний `to.stroyprofit.com`.
+- [x] Составить карту данных модуля техописаний.
+- [x] Составить карту экспортов и форматов выгрузки.
+- [x] Сопоставить данные техописаний с полями `Каталог общий`.
+- [x] Решить, что данные техописаний расширяют `common_catalog_items`, а не образуют отдельный модуль каталога.
+- [x] Зафиксировать, что техописание не хранит отдельную копию цены, а использует нужный вид цены из `Каталог общий`.
+- [ ] Добавить остальные недостающие поля техописаний в `Каталог общий` с безопасной миграцией.
+- [ ] Подготовить перенос или обогащение 938 позиций по артикулу в согласованном режиме без потери уже заполненных полей общего каталога.
+- [ ] Сопоставить старое одиночное поле цены техописаний с нужным видом цены общего каталога, если оно потребуется при переносе.
+- [ ] Перенести изображения через текущий файловый механизм CRM.
 - [ ] Встроить просмотр/редактирование техописания в карточку общего каталога.
 - [ ] Перенести или адаптировать экспорт из готового Laravel-модуля.
+- [ ] Подставлять в DOCX согласованный вид цены из `Каталог общий`, не сохраняя отдельную копию в данных техописания.
+- [ ] Реализовать общий DOCX и отдельные DOCX в ZIP через очередь и приватное хранение файлов.
+- [ ] Покрыть миграцию данных и варианты экспорта автоматическими тестами.
 
 ## Этап 8. Калькуляции
 
-Статус: **не готово к полной реализации**.
+Статус: **исходный Python-модуль изучен, ожидается отдельное ТЗ**.
 
-До получения контракта API можно реализовать только место под кнопку/блок в карточке общего каталога.
+Текущий `calc.stroyprofit.com` рассматривается как источник предметной модели и экспортов. Финальное решение — перенос расчётного движка в Laravel либо интеграция — принимается после отдельного ТЗ. Формулы источника не считаются согласованными автоматически.
 
-- [ ] Получить подробное описание внешнего API калькуляций.
-- [ ] Согласовать endpoint, HTTP-метод, авторизацию, параметры запроса и формат ответа.
-- [ ] Реализовать запрос по артикулу позиции общего каталога.
-- [ ] Реализовать отображение найденной калькуляции.
-- [ ] Реализовать состояние, когда калькуляция по артикулу не найдена.
-- [ ] Реализовать обработку ошибок API.
+- [x] Добавить пункт меню и страницу-заглушку `Калькуляции`.
+- [x] Подготовить место под кнопку `Калькуляция` в карточке общего каталога.
+- [x] Изучить код, сущности, связи, формулы и Excel-экспорты `calc.stroyprofit.com`.
+- [x] Зафиксировать отсутствие расчётных данных в активной базе источника и нерабочий источник восстановления `backup.sql`.
+- [ ] Получить отдельное ТЗ на калькуляции.
+- [ ] Найти резервную копию или исходные Excel-файлы материалов, станков, персонала и технологических связей.
+- [ ] Согласовать формулы, единицы коэффициентов, ставки, налоги, доставку, накладные расходы и прибыль.
+- [ ] Определить, нужны ли версии калькуляций и история изменения нормативов.
+- [ ] Выбрать по ТЗ внутреннюю Laravel-реализацию или интеграционный контракт.
+- [ ] Спроектировать нормализованную модель данных без общей изменяемой калькуляции на артикул.
+- [ ] Реализовать согласованные сценарии, права, импорт и экспорты.
+- [ ] Покрыть расчётные формулы и версионность автоматическими тестами.
 
 ## Этап 9. Графики заказов и доставок
 
-Статус: **не готово к полной реализации**.
+Статус: **исходная система и обмен с 1С изучены, ожидается отдельное ТЗ**.
 
-Текущий `График монтажей` переносится на этапе 2. Новые графики требуют уточнения обмена с 1С, полей, статусов и правил доставок.
+Старый `График отгрузок` является источником существующих данных и сценариев, но не переносится одной таблицей. Архитектура нового модуля должна оставаться нормализованной и расширяемой под дополнительные требования заказчика.
 
-- [ ] Описать источник данных для `График заказов`.
+- [x] Изучить старый `График отгрузок` в `manager.stroyprofit.com`.
+- [x] Описать найденный HTTP-обмен с 1С и состав текущего payload.
+- [x] Проанализировать существующие статусы, ручные поля, вложения и несколько доставок по заказу.
+- [x] Зафиксировать обязательную нормализацию заказов, позиций, доставок и загрузок 1С.
+- [ ] Получить и согласовать отдельное ТЗ на `График заказов`.
+- [ ] Уточнить стабильный внешний идентификатор заказа и передачу артикула каждой позиции из 1С.
+- [ ] Согласовать принадлежность полей источнику или ручному редактированию, чтобы повторная загрузка не уничтожала плановые данные.
+- [ ] Спроектировать нормализованные таблицы и связи после получения ТЗ.
 - [ ] Спроектировать загрузку данных через 1С.
-- [ ] Реализовать или перенести `График заказов`.
+- [ ] Реализовать безопасный идемпотентный импорт с журналом запусков и ошибок.
+- [ ] Реализовать `График заказов` без прямого копирования устаревшей таблицы `graf1`.
+- [ ] Подготовить отдельную миграцию исторических данных старого Manager с отчётом о конфликтах и дублях.
 - [ ] Добавить карточку заказа с выбором одного или нескольких МАФ.
 - [ ] Добавить создание рекламации из заказа через текущую форму.
+- [ ] Получить и согласовать требования к `Графику доставок`, если они не войдут в ТЗ графика заказов.
 - [ ] Реализовать `График доставок` в виде недельного списка.
 - [ ] Добавить ручное планирование занятости водителей.
 - [ ] Связать доставки с адресами из графика заказов.
@@ -255,9 +292,12 @@
 
 ## Открытые вопросы
 
-- [ ] Какой точный контракт внешнего API калькуляций?
-- [ ] Где находится готовый Laravel-модуль техописаний и какие форматы выгрузки из него переносим?
-- [ ] Какой точный PDF-шаблон нужен для экспорта `Склад наличие`?
-- [ ] Какой формат обмена с 1С для `График заказов`?
-- [ ] Какие поля, статусы и правила ручного редактирования нужны для `График заказов`?
+- [ ] Когда будет передано отдельное ТЗ на `График заказов` и какие новые требования добавит заказчик?
+- [ ] Передаёт ли 1С стабильный GUID заказа и артикулы позиций, либо формат обмена нужно расширить?
+- [ ] Будет ли отдельное ТЗ на `Калькуляции` и выберет ли оно внутренний расчёт в Laravel или интеграцию?
+- [ ] Где находится актуальная резервная копия или исходные Excel-данные `calc.stroyprofit.com`?
+- [ ] Какие формулы и нормативы калькулятора подтверждены заказчиком?
+- [ ] Нужно ли переносить в общий каталог все 938 записей `to.stroyprofit.com` как исходные или только обогащать совпавшие артикулы?
+- [ ] Какой из восьми видов цены общего каталога использовать в DOCX техописания вместо старого одиночного поля `price`?
+- [ ] Какой точный PDF-шаблон нужен для экспорта `Склад`?
 - [ ] Какие поля, статусы, правила отгрузок и связи с заказами нужны для `График доставок`?

+ 124 - 84
docs/refactor/tz-calculations.md

@@ -9,107 +9,147 @@
 
 ## 1. Назначение
 
-Модуль `Калькуляции` предназначен для связи Manager 2.0 с внешним API калькуляций.
+Модуль `Калькуляции` предназначен для расчёта стоимости изготовления позиции общего каталога, хранения расчётных нормативов и формирования согласованных выгрузок.
 
-По новым вводным калькуляции не реализуются внутри CRM как самостоятельный расчетный движок. CRM должна отправлять запрос во внешний API по артикулу позиции и отображать возвращенную калькуляцию, если она есть.
+Финальный состав модуля будет определён отдельным ТЗ. До его получения нельзя считать утверждённым ни вариант внешнего API, ни полный перенос расчётного движка внутрь CRM.
 
 ## 2. Статус
 
-Статус модуля: **будет разработан отдельно**.
+Статус: **исходный Python-модуль изучен, ожидается отдельное ТЗ**.
 
-На первом этапе:
+На первом этапе уже реализованы:
 
-- добавить пункт меню;
-- добавить страницу-заглушку;
-- подготовить кнопку `Калькуляция` в карточке позиции общего каталога.
+- пункт меню;
+- страница-заглушка;
+- место под кнопку `Калькуляция` в карточке позиции общего каталога.
 
-Заглушка доступна всем авторизованным пользователям.
+Исходный модуль `calc.stroyprofit.com` используется как источник:
 
-## 3. Место в меню
+- предметной модели;
+- существующих формул;
+- пользовательских сценариев;
+- импорта справочников;
+- форматов Excel и ZIP-выгрузок.
 
-Целевое меню:
+Код и формулы источника не переносятся без проверки и согласования.
 
-```text
-Калькуляции
-```
+## 3. Результаты анализа источника
 
-Также в карточке позиции `Каталог общий` должна быть кнопка `Калькуляция`.
+В `calc.stroyprofit.com` найдены сущности:
 
-## 4. Права доступа
+- изделие, связанное с артикулом;
+- материал;
+- количество материала в изделии;
+- станок;
+- профессия и ставка;
+- связь станка с персоналом;
+- допустимые станки для материала;
+- технологическая обработка материала с коэффициентом.
 
-На первом этапе:
+Источник рассчитывает:
 
-- текущую систему прав не менять;
-- заглушку показывать всем авторизованным пользователям.
+- материалы;
+- станко-часы;
+- труд персонала;
+- страховые взносы;
+- доставку;
+- накладные расходы;
+- прибыль;
+- НДС;
+- итоговую стоимость.
 
-Целевая модель прав определяется после появления платформы калькуляций.
+Также реализованы:
+
+- ручное изменение коэффициентов;
+- подгонка расчёта под целевую цену;
+- Excel-калькуляция;
+- технологическая карта;
+- ZIP с расчётом и подтверждающими документами.
+
+Текущее состояние данных источника:
+
+- активная PostgreSQL-база не содержит изделий, материалов, станков, персонала и технологических связей;
+- сохранился только один пользователь;
+- `backup.sql` является пустым каталогом и не может использоваться для восстановления;
+- на диске остались отдельные ранее сформированные файлы, но они не заменяют исходные справочники и связи.
+
+## 4. Архитектурные ограничения
+
+До отдельного ТЗ допускаются два варианта:
+
+1. Реализация расчётного модуля внутри текущего Laravel-приложения.
+2. Интеграция с отдельным сервисом по согласованному API.
+
+Независимо от выбранного варианта:
+
+- позиция калькуляции должна связываться с `common_catalog_items` по устойчивой связи, а не только по свободному тексту;
+- нормативы, ставки и проценты не должны быть зашиты в код без возможности версионирования;
+- изменение или подгонка одной калькуляции не должно незаметно менять исходные нормативы других расчётов;
+- необходимо разделять нормативные данные, черновик расчёта и зафиксированную версию;
+- импорт и длительные экспорты должны выполняться через очередь;
+- файлы должны выдаваться через текущий защищённый файловый механизм CRM;
+- расчётные формулы должны быть покрыты автоматическими тестами.
 
 ## 5. Интеграция с общим каталогом
 
-Требования:
+Сохраняются предварительные требования:
 
 - карточка позиции общего каталога содержит кнопку `Калькуляция`;
-- кнопка выполняет запрос во внешний API калькуляций;
-- запрос выполняется по артикулу позиции;
-- если API возвращает калькуляцию, CRM должна показать ее пользователю;
-- если калькуляции нет, CRM должна показать понятное сообщение.
-- выбранный год в CRM не влияет на запрос и отображение калькуляций.
-
-Открыто:
-
-- endpoint API;
-- метод запроса;
-- параметры кроме артикула;
-- формат авторизации;
-- структура успешного ответа;
-- структура ответа, когда калькуляция не найдена;
-- требования к хранению или кешированию результата в CRM.
-
-## 6. Возможный функционал будущего модуля
-
-Предварительный список:
-
-- запрос калькуляции из позиции каталога по артикулу;
-- отображение результата, если калькуляция найдена;
-- сообщение об отсутствии калькуляции;
-- просмотр статуса калькуляции;
-- привязка результата к позиции каталога;
-- история калькуляций по позиции.
-
-Этот список предварительный и должен быть уточнен после отдельного ТЗ на платформу калькуляций.
-
-## 7. Что не входит в первый этап
-
-- реализация платформы калькуляций;
-- API-интеграция до получения подробного описания API;
-- хранение результатов калькуляций;
-- новые права;
-- синхронизация данных.
-
-## 8. Этапы реализации
-
-- [ ] Добавить пункт меню `Калькуляции`.
-- [ ] Добавить страницу-заглушку.
-- [ ] Добавить место под кнопку `Калькуляция` в карточке общего каталога.
-- [ ] Получить подробное описание внешнего API калькуляций.
-- [ ] Согласовать формат запроса по артикулу.
-- [ ] Реализовать запрос из карточки позиции.
-- [ ] Реализовать отображение возвращенной калькуляции.
-- [ ] Реализовать сообщение для случая, когда калькуляция не найдена.
-- [ ] При необходимости реализовать хранение истории калькуляций.
-
-## 9. Критерии приемки
-
-- Пункт `Калькуляции` есть в меню.
-- Заглушка открывается.
-- В карточке общего каталога предусмотрена кнопка `Калькуляция`.
-- После появления API запрос по артикулу работает для конкретной позиции каталога.
-- Если калькуляция найдена, пользователь видит результат.
-- Если калькуляция не найдена, пользователь видит понятное сообщение.
-
-## 10. Открытые вопросы
-
-- Какой endpoint, метод, авторизация и формат ответа у внешнего API?
-- Какие параметры кроме артикула нужно передавать?
-- Нужно ли хранить историю полученных калькуляций в CRM?
-- Какие роли имеют доступ к калькуляциям?
+- калькуляция открывается для конкретной позиции;
+- выбранный год CRM не влияет на доступность модуля;
+- при отсутствии калькуляции пользователь видит понятное состояние;
+- необходимость хранения истории определяется отдельным ТЗ.
+
+Точное поведение кнопки зависит от выбранной архитектуры: переход во внутреннюю карточку расчёта либо запрос к внешнему сервису.
+
+## 6. Что требует отдельного ТЗ
+
+- роли и права;
+- состав экранов и справочников;
+- утверждённые формулы;
+- единицы и смысл технологических коэффициентов;
+- источник целевой цены;
+- правила подгонки под цену;
+- версии нормативов и калькуляций;
+- актуальные налоговые и коммерческие ставки;
+- импорт исходных данных;
+- необходимые варианты Excel, ZIP и подтверждающих документов;
+- необходимость внешнего API.
+
+## 7. Этапы реализации
+
+- [x] Добавить пункт меню `Калькуляции`.
+- [x] Добавить страницу-заглушку.
+- [x] Добавить место под кнопку `Калькуляция` в карточке общего каталога.
+- [x] Изучить исходный Python-модуль.
+- [x] Составить предварительную карту сущностей, связей, формул и экспортов.
+- [x] Проверить наличие данных для миграции и зафиксировать их отсутствие в активной базе.
+- [ ] Получить отдельное ТЗ.
+- [ ] Найти резервную копию или исходные Excel-файлы справочников и связей.
+- [ ] Подтвердить формулы и нормативы с заказчиком.
+- [ ] Выбрать внутреннюю Laravel-реализацию или интеграционный вариант.
+- [ ] Спроектировать нормализованную и версионируемую модель.
+- [ ] Реализовать согласованные права, интерфейс, импорт и экспорт.
+- [ ] Покрыть критичные расчёты и историю автоматическими тестами.
+
+## 8. Предварительные критерии приемки
+
+Финальные критерии формируются после отдельного ТЗ. Обязательная техническая часть:
+
+- калькуляция однозначно связана с позицией общего каталога;
+- повторный расчёт воспроизводим на зафиксированной версии нормативов;
+- изменение черновика не меняет исторические калькуляции;
+- все коэффициенты и ставки имеют явный источник;
+- экспорт соответствует согласованному образцу;
+- ошибки расчёта и импорта диагностируются;
+- права доступа проверяются сервером.
+
+## 9. Открытые вопросы
+
+- Будет ли отдельное ТЗ и когда оно будет доступно?
+- Расчёт выполняется внутри Laravel или остаётся отдельным сервисом?
+- Где находятся актуальные данные старого калькулятора?
+- Формула времени — количество, умноженное на коэффициент, или количество, разделённое на производительность?
+- Какие проценты, ставки и правила подгонки считаются утверждёнными?
+- Требуется ли хранить версии и историю калькуляций?
+- Какие роли имеют доступ к просмотру и редактированию?

+ 83 - 60
docs/refactor/tz-catalog-common.md

@@ -5,7 +5,8 @@
 - [План реорганизации](plan.md)
 - [Карта нового меню](menu.md)
 - [ТЗ: Модуль ДКР](tz-dkr.md)
-- [Шаблон каталога](Шаблон%20Каталог%20ощий.xlsx)
+- [Актуальный каталог с реальными данными](Каталог%20общий.xlsx)
+- [Предыдущий шаблон каталога](Шаблон%20Каталог%20ощий.xlsx)
 
 ## 1. Назначение
 
@@ -15,7 +16,7 @@
 
 - `Графики`;
 - `Рекламации`;
-- `Склад наличие`;
+- `Склад`;
 - `Технич. описание`;
 - `Калькуляции`.
 
@@ -23,7 +24,7 @@
 
 ## 2. Статус
 
-Статус модуля: **ядро реализовано и проверено пользователем**.
+Статус модуля: **ядро реализовано и проверено пользователем; переход на актуальный 30-колоночный формат с ценами реализован и ожидает пользовательской проверки**.
 
 Проверены создание и редактирование позиций, импорт и экспорт каталога.
 
@@ -72,13 +73,18 @@ SQL-таблицы названы `common_catalog_items` и `common_catalog_item
 | Просмотр заглушки | Любой авторизованный пользователь |
 | Просмотр общего каталога после реализации | `common-catalog.view` |
 | Создание/редактирование/удаление | Права с префиксом `common-catalog.*` |
-| Импорт/экспорт | Права с префиксом `common-catalog.*` |
+| Импорт | `common-catalog.import` |
+| Экспорт | `common-catalog.export`, по умолчанию только администратор |
+| Просмотр/редактирование отдельного поля | `common-catalog.fields.{field}.view/update` |
 
 Требования:
 
 - не переиспользовать `catalog.view` автоматически, потому что это право относится к каталогу ДКР;
 - новые права общего каталога использовать с техническим именем `common-catalog`;
 - заглушку `Каталог общий` показывать всем авторизованным пользователям.
+- восемь полей цен по умолчанию видны и доступны для редактирования только администратору;
+- доступ к каждой цене можно настраивать через существующий механизм прав отдельных полей;
+- экспорт общего каталога по умолчанию доступен только администратору.
 
 ## 5. Основные сущности
 
@@ -95,7 +101,7 @@ SQL-таблицы названы `common_catalog_items` и `common_catalog_item
 Целевая роль позиции:
 
 - единый справочник номенклатуры;
-- источник изображения, артикула, наименования, характеристик и единицы измерения для склада наличия;
+- источник изображения, артикула, наименования, характеристик и единицы измерения для склада;
 - источник данных для графиков и рекламаций;
 - точка перехода в техническое описание;
 - точка перехода в калькуляцию.
@@ -114,62 +120,67 @@ SQL-таблицы названы `common_catalog_items` и `common_catalog_item
 - экспорт;
 - импорт, если он будет предусмотрен для общего каталога.
 
-Источник полей: файл [Шаблон Каталог ощий.xlsx](Шаблон%20Каталог%20ощий.xlsx), лист 1, строка 1. В строке 3 шаблона есть пример заполнения.
+Источник полей и данных: файл [Каталог общий.xlsx](Каталог%20общий.xlsx), лист `Общие сведения`. Заголовок занимает строки 1–2, данные начинаются со строки 3. Файл содержит реальные позиции, изображения и цены.
 
 Колонки:
 
-1. `ID`;
-2. `Внешний вид`;
-3. `Артикул`;
-4. `Наименование по калькулятору`;
-5. `Вид`;
-6. `Габаритные размеры`;
-7. `Высота падения`;
-8. `Дополнительные сведения`;
-9. `Единица измерения габаритов`;
-10. `Вес, кг.`;
-11. `Объем, м3`;
-12. `Места`;
-13. `Состав`;
-14. `Возрастная группа`;
-15. `Макс.кол-во пользователей`;
-16. `Ед.`;
-17. `Серия`;
-18. `ТМ`;
-19. `Примечание`.
+1. `Артикул`;
+2. `Наименование`;
+3. `Вид`;
+4–6. `Габариты`: длина, ширина, высота;
+7–8. `Размер участка`: длина, ширина;
+9. `Высота падения`;
+10. `Дополнительные сведения`;
+11. `Единица измерения габаритов`;
+12. `Вес, кг.`;
+13. `Объем, м3`;
+14. `Места`;
+15. `Состав`;
+16. `Возрастная группа`;
+17. `Макс.кол-во пользователей`;
+18. `Ед.`;
+19. `Внешний вид`;
+20. `Серия`;
+21. `ТМ`;
+22. `Калькулятор`;
+23–30. цены `строители`, `опт`, `рек`, `розница`, `проект`, `проект+м`, `пик`, `рек+10`.
 
 Требования к колонкам:
 
-- указанный перечень и порядок являются целевым форматом общего каталога;
+- указанный перечень, порядок и две строки заголовка являются целевым форматом общего каталога;
 - при импорте принимать заголовок `Единица измерения габаритов` в формате шаблона с переносом строки между словами `измерения` и `габаритов`;
 - поле `Артикул` хранить строкой, чтобы сохранять ведущие нули;
 - `Внешний вид` отображает фото позиции;
 - состав полей общего каталога не зависит от состава полей каталога ДКР;
-- обязательность заполнения и правила валидации полей уточняются при проектировании модели данных.
+- все восемь цен хранятся непосредственно у позиции общего каталога;
+- цены хранятся точно до копеек и не зависят от складской партии;
+- габаритные поля хранятся строками, потому что реальные данные содержат диапазоны и текстовые значения (`от 180`, `0,19-0,72`);
+- при ручном создании обязательны артикул и наименование;
+- при импорте фактического каталога допускается пустое наименование: строка сохраняется без подстановки выдуманного значения;
+- остальные поля допускают постепенное заполнение.
 
 Предварительная модель полей по шаблону:
 
 | Колонка | Поле | Тип данных | Примечание |
 |---|---|---|---|
-| A | `ID` | integer | Внутренний идентификатор/номер строки каталога. |
-| B | `Внешний вид` | image/file | Фото позиции; в шаблоне есть встроенное изображение. |
-| C | `Артикул` | string | Сохранять ведущие нули, пример: `0254`. |
-| D | `Наименование по калькулятору` | text | Может содержать переносы строк. |
-| E | `Вид` | string | Категория/вид позиции. |
-| F | `Габаритные размеры` | text | Может содержать текст и единицы измерения. |
-| G | `Высота падения` | decimal | Числовое значение, допускается `0`. |
-| H | `Дополнительные сведения` | text nullable | Дополнительное описание. |
-| I | `Единица измерения габаритов` | string | В шаблоне заголовок разбит переносом строки. |
-| J | `Вес, кг.` | decimal | Вес в килограммах. |
-| K | `Объем, м3` | decimal | Объем в кубических метрах. |
-| L | `Места` | integer | Количество мест. |
-| M | `Состав` | text nullable | Состав комплекта/позиции. |
-| N | `Возрастная группа` | string | Пример: `0+`. |
-| O | `Макс.кол-во пользователей` | integer | Максимальное количество пользователей. |
-| P | `Ед.` | string | Единица учета, пример: `шт.`. |
-| Q | `Серия` | string | Серия позиции. |
-| R | `ТМ` | string | Торговая марка. |
-| S | `Примечание` | text nullable | Свободное примечание. |
+| A | `article` | string | Сохранять ведущие нули, пример: `0254`. |
+| B | `calculator_name` | text | Наименование; внутреннее имя поля сохранено для совместимости. |
+| C | `kind` | string nullable | Категория/вид позиции. |
+| D–F | `dimension_length`, `dimension_width`, `dimension_height` | string nullable | Отдельные размеры; допускают диапазоны и текст. |
+| G–H | `site_length`, `site_width` | string nullable | Размеры участка. |
+| I | `fall_height` | decimal nullable | Высота падения. |
+| J | `additional_info` | text nullable | Дополнительное описание. |
+| K | `dimension_unit` | string nullable | Единица измерения габаритов. |
+| L–M | `weight`, `volume` | decimal nullable | Вес и объём. |
+| N | `places` | integer nullable | Количество мест. |
+| O | `composition` | text nullable | Состав комплекта/позиции. |
+| P | `age_group` | string nullable | Возрастная группа. |
+| Q | `max_users` | integer nullable | Максимальное количество пользователей. |
+| R | `unit` | string nullable | Единица учета. |
+| S | `image_file_id` | image/file nullable | Встроенное изображение импортируется через файловый механизм CRM. |
+| T–U | `series`, `trademark` | string nullable | Серия и торговая марка. |
+| V | `calculator_enabled` | boolean nullable | Значения `да` / `нет` / пусто. |
+| W–AD | восемь полей `*_price` | money nullable | Цены хранятся в копейках, в UI и XLSX отображаются в рублях. |
 
 ## 7. Карточка позиции
 
@@ -195,7 +206,7 @@ SQL-таблицы названы `common_catalog_items` и `common_catalog_item
 Фото:
 
 - хранится у позиции общего каталога;
-- используется в `Склад наличие`;
+- используется в `Склад`;
 - используется в карточке позиции;
 - должно быть доступно для отображения в таблицах, где нужна картинка позиции.
 
@@ -214,8 +225,13 @@ SQL-таблицы названы `common_catalog_items` и `common_catalog_item
 - спроектировать импорт и экспорт общего каталога;
 - использовать текущий импорт/экспорт каталога ДКР как технический референс, если это ускорит реализацию;
 - использовать утвержденные колонки общего каталога в заданном порядке;
-- использовать [Шаблон Каталог ощий.xlsx](Шаблон%20Каталог%20ощий.xlsx) как целевой шаблон импорта/экспорта общего каталога;
-- при импорте обновлять существующие позиции по стабильному ключу;
+- использовать [Каталог общий.xlsx](Каталог%20общий.xlsx) как целевой формат импорта/экспорта общего каталога;
+- проверять обе строки заголовка и начинать обработку данных со строки 3;
+- сохранять ведущие нули в артикулах;
+- учитывать реальные варианты с одинаковым артикулом и разными наименованиями: уникальность позиции определяется парой `Артикул + Наименование`;
+- если импортируемый файл содержит один вариант артикула и в базе найдена ровно одна такая позиция, разрешать обновление наименования без создания дубля;
+- импортировать встроенные изображения из колонки `S`;
+- применять field-permissions к импортируемым значениям, чтобы пользователь без права редактирования цены не мог изменить её импортом;
 - ошибки импорта должны быть понятны пользователю.
 
 ## 10. Связи с другими модулями
@@ -223,11 +239,11 @@ SQL-таблицы названы `common_catalog_items` и `common_catalog_item
 | Модуль | Связь | Требование |
 |---|---|---|
 | ДКР | Имеет собственный каталог | Не смешивать каталог ДКР с общим каталогом. |
-| Склад наличие | Использует картинку, артикул, наименование, характеристики, ед. изм. | Склад должен брать справочные поля из общего каталога. |
+| Склад | Использует картинку, артикул, наименование, характеристики и ед. изм. | Цены принадлежат общему каталогу и не хранятся в складских партиях. |
 | Графики | Используют данные по позициям | При разработке графиков опираться на общий каталог. |
 | Рекламации | Используют данные по позициям | Сохранить/добавить связь рекламаций с позициями общего каталога. |
-| Технич. описание | Является частью общего каталога | Использовать готовый Laravel-модуль как источник экспорта/формата. |
-| Калькуляции | Запрашиваются из карточки позиции через внешний API | Запрос по артикулу, отображение возвращенной калькуляции при наличии. |
+| Технич. описание | Является частью общего каталога | Описательные поля и DOCX-экспорты переносятся в карточку; согласованный вид цены читается из этой же позиции каталога. |
+| Калькуляции | Открываются из карточки позиции | Финальное поведение кнопки и способ реализации определяются отдельным ТЗ; исходный `calc.stroyprofit.com` используется как референс. |
 
 ## 11. Что не входит в первый этап
 
@@ -236,9 +252,9 @@ SQL-таблицы названы `common_catalog_items` и `common_catalog_item
 - переименование текущих маршрутов `catalog.*`;
 - полная переработка модели `Product`;
 - внедрение новых прав доступа;
-- разработка модуля `Склад наличие`;
+- разработка модуля `Склад`;
 - отдельный модуль `Технич. описание`;
-- разработка интеграции с калькуляциями до получения подробного описания API;
+- разработка калькуляций до получения отдельного ТЗ и согласования архитектуры;
 - изменение бизнес-логики ДКР.
 
 ## 12. Этапы реализации
@@ -255,12 +271,16 @@ SQL-таблицы названы `common_catalog_items` и `common_catalog_item
 - [x] Зафиксировать состав и порядок колонок общего каталога.
 - [x] Зафиксировать шаблон общего каталога как источник колонок.
 - [x] Зафиксировать предварительные типы данных по шаблону общего каталога.
-- [x] Спроектировать обязательность и валидацию утвержденных полей: обязательны артикул и наименование по калькулятору; числовые значения неотрицательны; остальные поля допускают постепенное заполнение.
+- [x] Спроектировать обязательность и валидацию утвержденных полей: при ручном вводе обязательны артикул и наименование, импорт принимает фактические строки без наименования; числовые значения неотрицательны; остальные поля допускают постепенное заполнение.
 - [x] Реализовать утвержденные поля в списке и карточке позиции.
-- [x] Реализовать фоновый импорт/экспорт по утвержденным 19 колонкам с обновлением по артикулу и поддержкой встроенных изображений.
+- [x] Реализовать первоначальный фоновый импорт/экспорт по 19-колоночному шаблону.
+- [x] Перевести импорт/экспорт на актуальный формат из 30 колонок и двух строк заголовка.
+- [x] Добавить пять раздельных размерных полей, признак калькулятора и восемь цен.
+- [x] Подключить field-permissions общего каталога; цены по умолчанию доступны только администратору.
+- [x] Ограничить экспорт общего каталога администратором по умолчанию.
 - [x] Реализовать блок документов как набор файлов.
 - [x] Зафиксировать, что `Технич. описание` включается внутрь карточки общего каталога.
-- [x] Добавить в карточку место под API-запрос `Калькуляции`.
+- [x] Добавить в карточку место под будущий модуль `Калькуляции`.
 - [x] Зафиксировать `common_catalog_items.id` как внешний ключ для будущих связей модулей; добавление связей выполняется вместе с соответствующими модулями.
 - [x] Настроить `common-catalog.view` для основных авторизованных ролей и отдельные права на изменение, файлы, импорт и экспорт.
 
@@ -271,11 +291,14 @@ SQL-таблицы названы `common_catalog_items` и `common_catalog_item
 - Текущие маршруты `catalog.index` и `catalog.show` сохранены как маршруты каталога ДКР.
 - Рабочий раздел `Каталог общий` доступен основным авторизованным ролям через право `common-catalog.view`.
 - После реализации общий каталог не смешивает данные с каталогом ДКР без отдельной миграции/синхронизации.
-- Список, импорт и экспорт общего каталога используют утвержденный состав и порядок колонок из файла `docs/refactor/Шаблон Каталог ощий.xlsx`.
-- Карточка позиции общего каталога поддерживает фото и набор документов; места интеграции техописания и калькуляции подготовлены до получения внешних вводных.
+- Список, карточка, импорт и экспорт используют состав и порядок колонок из файла `docs/refactor/Каталог общий.xlsx`.
+- Пользователь без field-permission не видит цену и не может изменить её прямым POST-запросом или импортом.
+- Экспорт по умолчанию доступен только администратору.
+- Карточка позиции общего каталога поддерживает фото и набор документов; места интеграции техописания и калькуляции подготовлены до реализации соответствующих этапов.
 - ДКР продолжает использовать свой каталог без поломки существующих связей.
 - Реорганизация меню выполнена как часть общей структуры Manager 2.0.
 
 ## 14. Открытые вопросы
 
-- Какой точный формат API калькуляций: endpoint, параметры, авторизация, структура ответа?
+- Какие дополнительные поля техописания добавляются напрямую в `common_catalog_items` после переноса источника?
+- Какое поведение кнопки калькуляции будет утверждено отдельным ТЗ?

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

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

+ 13 - 1
docs/refactor/tz-documents.md

@@ -21,10 +21,21 @@
 
 ## 2. Статус
 
-Статус модуля: **нужно реализовать**.
+Статус модуля: **реализован и проверен пользователем**.
 
 Модуль должен быть доступен из верхнего меню `Документация`.
 
+Техническая реализация:
+
+- папки: `documentation_folders`;
+- правила доступа: `documentation_folder_permissions`;
+- документы и версии: `documentation_documents`, `documentation_document_versions`;
+- системные права: `documents.view`, `documents.update`;
+- корневая папка по умолчанию доступна всем на чтение;
+- подпапка до явного переопределения использует правила родительской папки;
+- файлы версий учитываются в текущей таблице `files`, физически хранятся на
+  приватном диске и скачиваются только через контроллер после проверки прав.
+
 ## 3. Место в меню
 
 Целевое меню:
@@ -63,6 +74,7 @@
 
 - загрузка документа;
 - скачивание документа;
+- отображение иконки по MIME-типу и защищённый предпросмотр изображений;
 - переименование документа;
 - удаление документа;
 - загрузка новой версии документа;

+ 50 - 15
docs/refactor/tz-schedules.md

@@ -19,7 +19,7 @@
 
 ## 2. Статус
 
-Статус модуля: **частично реализован**.
+Статус модуля: **частично реализован; исходная система графика заказов изучена, финальное ТЗ ожидается**.
 
 В текущей CRM реализован:
 
@@ -39,6 +39,15 @@
 - новую верхнюю группу меню `Графики`;
 - связи между графиками.
 
+После анализа `manager.stroyprofit.com` установлено:
+
+- текущий `График отгрузок` хранится в одной таблице `graf1` и совмещает данные заказа, статусы, доставку, монтаж и ручные комментарии;
+- данные поступают из 1С HTTP-запросом с JSON в поле `data`;
+- payload содержит заказ, клиента, договор, оплату, доставку, менеджера и список позиций;
+- позиции сейчас не нормализуются, а сохраняются только в сформированный Excel-файл;
+- поддерживаются до трёх доставок через фиксированные колонки;
+- финальные требования нового `Графика заказов` будут переданы отдельным ТЗ и могут расширить существующие сценарии.
+
 ## 3. Место в меню
 
 Целевое меню:
@@ -77,7 +86,7 @@
 
 ## 5. График заказов
 
-Статус: **нужно реализовать**.
+Статус: **исходная реализация изучена; проектирование и разработка начинаются после отдельного ТЗ**.
 
 Назначение:
 
@@ -94,6 +103,18 @@
 - связь с `Каталог общий`;
 - связь с `График монтажей`.
 
+Архитектурные требования, принятые до получения финального ТЗ:
+
+- не переносить таблицу `graf1` как есть;
+- хранить заказ, позиции заказа, доставки и загрузки 1С отдельными нормализованными сущностями;
+- не использовать сериализованные данные для монтажей, позиций и доставок;
+- не ограничивать заказ фиксированным количеством доставок;
+- отделить поля, которыми управляет 1С, от полей ручного планирования;
+- повторная загрузка должна быть идемпотентной и не должна сбрасывать ручные статусы и комментарии;
+- сохранять журнал загрузок, ошибки сопоставления и исходный payload для диагностики;
+- предусмотреть миграцию исторических данных старого Manager с отчётом о дублях и конфликтах;
+- сохранять возможность расширения модели без денормализации после получения дополнительных требований заказчика.
+
 Предварительный функционал:
 
 - список заказов/позиций в производственном графике;
@@ -122,12 +143,13 @@
 
 Открытые вопросы:
 
-- какой источник данных 1С и формат обмена;
-- какие поля приходят из 1С;
+- какой финальный состав полей и сценариев будет утверждён отдельным ТЗ;
+- может ли 1С передавать стабильный GUID заказа вместо ключа, собранного из номера и года;
+- может ли 1С передавать артикул позиции для связи с `Каталог общий`;
 - нужен ли ручной ввод/редактирование или график полностью загружается;
-- что именно в текущем/старом manager называется `график отгрузок`;
 - какие статусы производства должны отображаться;
-- как связывать строку графика с позицией `Каталог общий`.
+- какие поля принадлежат 1С, а какие должны сохраняться при повторной синхронизации;
+- какие части старых заявок на доставку и монтаж нужно сохранить в новом модуле.
 
 ## 6. График доставок
 
@@ -225,22 +247,32 @@
 
 Для `График монтажей` использовать текущие модели и таблицы.
 
-Для `График заказов` и `График доставок` модели проектируются отдельно после уточнения данных.
+Для `График заказов` и `График доставок` модели проектируются отдельно после получения финального ТЗ. Независимо от точных имён таблиц модель должна быть нормализована.
 
-Предварительно потребуются сущности:
+Минимально потребуются сущности:
 
-- производственная запись графика заказов;
-- строка/позиция производственного заказа;
-- запись доставки;
+- производственный заказ;
+- позиция производственного заказа со ссылкой на `Каталог общий`, когда сопоставление возможно;
+- произвольное количество доставок по заказу;
 - водитель или ответственный доставки;
 - журнал загрузок из 1С.
+- журнал ошибок и результатов сопоставления;
+- безопасно сохранённый исходный payload или ссылка на него для диагностики.
+
+Запрещённые варианты модели:
+
+- одна таблица, совмещающая заказ, позиции, доставки и монтаж;
+- колонки вида `delivery_1`, `delivery_2`, `delivery_3`;
+- сериализованные массивы в текстовых колонках как основная модель данных;
+- перезапись ручных плановых данных значениями по умолчанию при импорте из 1С.
 
 Открыто:
 
 - точные имена таблиц и моделей;
 - структура полей;
 - необходимость истории изменений;
-- связь с существующими `Order`, `Product`, `ProductSKU`.
+- связь с существующими `Order`, `Product`, `ProductSKU`;
+- правила миграции дублей и некорректных дат старого `graf1`.
 
 ## 10. Первый этап реализации
 
@@ -276,7 +308,9 @@
 - [x] Проверить создание записи графика из площадки/заказа.
 - [x] Проверить создание записи графика из рекламации.
 - [x] Проверить экспорт графика монтажей.
-- [ ] Составить отдельный список полей для будущего графика заказов.
+- [x] Изучить старый график заказов/отгрузок и существующий обмен с 1С.
+- [x] Зафиксировать нормализованную модель как обязательный архитектурный принцип.
+- [ ] Получить отдельное ТЗ и составить по нему финальный список полей графика заказов.
 - [ ] Составить отдельный список полей для будущего графика доставок.
 - [ ] Реализовать карточку заказа с выбором одного или нескольких МАФ.
 - [ ] Добавить действие `Добавить рекламацию`.
@@ -299,8 +333,9 @@
 
 ## 13. Открытые вопросы
 
-- Какой формат обмена с 1С для графика заказов?
-- Какие поля должны отображаться в графике заказов?
+- Какие дополнительные требования войдут в отдельное ТЗ графика заказов?
+- Передаёт ли 1С стабильный GUID заказа и артикулы позиций?
+- Какие поля должны отображаться в графике заказов по финальному ТЗ?
 - Нужен ли ручной ввод в графике заказов?
 - Где хранить список водителей для графика доставок?
 - Нужен ли отдельный модуль `Отгрузки`, или график доставок закрывает эту задачу?

+ 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. Критерии приемки
 

+ 39 - 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 @@
 - редактирование заказа;
 - импорт;
 - изменение статуса;
+- массовый перевод всех заказанных позиций с выбранным номером заказа в статус `на складе`;
 - связь с позицией общего каталога;
 - расчет остатка по заказу;
 - отображение активных броней.
@@ -212,65 +207,52 @@
 | Модуль | Связь | Требование |
 |---|---|---|
 | Каталог общий | Источник картинки, артикула, наименования, характеристик, ед. изм. | Обязательная связь. |
+| Технич. описание | Использует данные позиции при формировании DOCX | Цена хранится в `Каталог общий`; склад не владеет ценами. |
 | График отгрузок / График заказов | Данные об отгрузках | Уточнить после реализации графиков. |
 | Запчасти | Эталон логики | Использовать паттерны реализации. |
 
 ## 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 +262,7 @@
 - Списание уменьшает остаток соответствующего заказа.
 - Отмена брони возвращает доступный остаток.
 - История операций сохраняется.
-- PDF-экспорт формируется по шаблону.
+- PDF-экспорт формируется по шаблону после его согласования и передачи в проект.
 
 ## 17. Открытые вопросы
 

+ 58 - 17
docs/refactor/tz-technical-description.md

@@ -15,15 +15,17 @@
 
 ## 2. Статус
 
-Статус: **встроить в модуль `Каталог общий`**.
+Статус: **источник изучен, встроить в модуль `Каталог общий`**.
 
-Есть готовый модуль на Laravel. Его нужно изучить и использовать как источник:
+Laravel-модуль `to.stroyprofit.com` изучен и используется как источник:
 
 - структуры данных технического описания;
 - правил формирования выгрузок;
 - шаблонов/форматов экспорта;
 - возможных UI-решений, если они применимы к текущей CRM.
 
+В источнике находятся 938 позиций из 24 серий. Для всех позиций заполнены цена, характеристики, полное и краткое техническое описание, группа, название для формы и изображение. Наличие одиночной цены в старом модуле зафиксировано как факт анализа; в новой CRM цены хранятся у позиции общего каталога по восьми отдельным видам.
+
 ## 3. Место в меню
 
 Отдельный пункт меню `Технич. описание` не нужен. Целевая модель:
@@ -61,34 +63,73 @@
 - формирование выгрузки технического описания в установленном формате;
 - использование готового Laravel-модуля как основы для экспорта;
 - хранение данных в структуре общего каталога или связанной таблице, если данные нельзя уложить в основную карточку позиции.
+- получение согласованного вида цены для DOCX из `Каталог общий` без собственной копии цены в техописании.
 
-## 6. Что не входит до анализа готового модуля
+## 6. Что не переносим из готового модуля
 
 - отдельный самостоятельный раздел техописаний;
 - отдельная модель прав, не связанная с каталогом;
-- перенос данных без анализа структуры готового Laravel-модуля;
-- финальная схема таблиц.
-
-## 7. Этапы реализации
-
-- [ ] Получить и изучить готовый Laravel-модуль техописаний.
-- [ ] Составить карту данных модуля техописаний.
-- [ ] Составить карту экспортов и форматов выгрузки.
-- [ ] Сопоставить данные техописаний с полями `Каталог общий`.
-- [ ] Решить, какие данные хранятся прямо в карточке позиции, а какие требуют связанных таблиц.
+- прямое копирование устаревшего механизма авторизации и публичного хранения экспортов;
+- перезапись актуальных данных общего каталога без сопоставления по артикулу.
+- отдельное поле цены в данных техописания.
+
+## 7. Результаты анализа
+
+Источник хранит:
+
+- артикул;
+- серию;
+- наименование;
+- наименование для печатной формы;
+- группу;
+- цену;
+- характеристики;
+- полное техническое описание;
+- краткое техническое описание;
+- изображение.
+
+Экспорт поддерживает:
+
+- выбор нескольких позиций;
+- общий DOCX;
+- отдельный DOCX для каждой позиции в ZIP;
+- выбор полного, краткого или комбинированного описания;
+- отдельный и массовый DOCX-шаблоны.
+
+В текущем `common_catalog_items` отсутствуют готовые поля для названия печатной формы, текстовых характеристик, полного и краткого технического описания. Эти описательные поля должны дополнять общий каталог.
+
+Техописание не хранит собственную копию цены. При формировании DOCX оно читает согласованный вид цены непосредственно из позиции `Каталог общий`. Старое одиночное поле `price` переносится только после сопоставления с одним из восьми новых видов цены.
+
+## 8. Этапы реализации
+
+- [x] Получить и изучить готовый Laravel-модуль техописаний.
+- [x] Составить карту данных модуля техописаний.
+- [x] Составить карту экспортов и форматов выгрузки.
+- [x] Сопоставить данные техописаний с полями `Каталог общий`.
+- [x] Решить, что данные хранятся в карточке общего каталога, а не в отдельном модуле.
+- [x] Исключить отдельную копию цены из модели техописания; владельцем цен является общий каталог.
+- [ ] Добавить остальные недостающие поля общего каталога.
+- [ ] Перенести данные по артикулу с защитой уже заполненных полей.
+- [ ] При необходимости сопоставить старое поле `price` с согласованным видом цены общего каталога.
+- [ ] Перенести изображения в текущий файловый механизм CRM.
 - [ ] Встроить просмотр/редактирование техописания в карточку общего каталога.
 - [ ] Перенести или адаптировать экспорт из готового Laravel-модуля.
+- [ ] Подставлять в экспорт согласованный вид цены из `Каталог общий`.
+- [ ] Выполнять массовые экспорты через очередь и выдавать результат из приватного хранилища.
 - [x] Не добавлять отдельный пункт меню `Технич. описание` в финальную навигацию.
 
-## 8. Критерии приемки
+## 9. Критерии приемки
 
 - Техническое описание доступно из карточки позиции общего каталога.
 - Данные техописания не дублируют общий каталог без необходимости.
+- Цена не дублируется в техописании и берётся из позиции общего каталога.
 - Экспорт техописания работает в согласованном формате.
 - Готовый Laravel-модуль использован как источник экспортной логики.
 - Права просмотра/редактирования согласованы с правами общего каталога.
 
-## 9. Открытые вопросы
+## 10. Открытые вопросы
 
-- Где находится готовый Laravel-модуль техописаний и какие его части можно переносить напрямую?
-- Какие точные форматы выгрузки нужны?
+- Переносить все 938 позиций как источник данных или только обогащать артикулы, уже существующие в общем каталоге?
+- Сохраняются ли оба текущих DOCX-шаблона без визуальных изменений?
+- Нужна ли история изменения текстов технического описания?
+- Какому из восьми видов цены общего каталога соответствует старое поле `price`?

BIN
docs/refactor/Каталог общий.xlsx


+ 118 - 36
resources/views/common_catalog/edit.blade.php

@@ -1,11 +1,31 @@
 @extends('layouts.app')
 
 @section('content')
+    @php
+        $commonCatalogReadableFields = $commonCatalogReadableFields ?? [];
+        $commonCatalogWritableFields = $commonCatalogWritableFields ?? [];
+        $canView = static fn (string $field): bool => (bool) ($commonCatalogReadableFields[$field] ?? false);
+        $canUpdate = static fn (string $field): bool => (bool) ($commonCatalogWritableFields[$field] ?? false);
+        $calculatorEnabledValue = old(
+            'calculator_enabled',
+            $item === null || $item->calculator_enabled === null ? '' : (string) (int) $item->calculator_enabled,
+        );
+        $priceFields = [
+            'builders_price' => 'строители',
+            'wholesale_price' => 'опт',
+            'recommended_price' => 'рек',
+            'retail_price' => 'розница',
+            'project_price' => 'проект',
+            'project_with_installation_price' => 'проект+м',
+            'pik_price' => 'пик',
+            'recommended_plus_10_price' => 'рек+10',
+        ];
+    @endphp
     <div class="px-3">
         <div class="row mb-3">
             <div class="col-md-7 d-flex align-items-center">
                 <h3 class="mb-0">
-                    {{ $item ? 'Позиция '.$item->article : 'Новая позиция общего каталога' }}
+                    {{ $item ? 'Позиция '.($canView('article') ? $item->article : '') : 'Новая позиция общего каталога' }}
                 </h3>
             </div>
             @if($item)
@@ -30,7 +50,7 @@
             @endif
         </div>
 
-        @if($item?->imageFile)
+        @if($item?->imageFile && $canView('image'))
             <div class="card mb-3">
                 <div class="card-body d-flex align-items-start gap-3">
                     <a href="{{ $item->imageFile->link }}" data-toggle="lightbox" data-gallery="common-catalog">
@@ -56,55 +76,117 @@
 
             <div class="row">
                 <div class="col-xl-6">
-                    @include('partials.input', ['name' => 'article', 'title' => 'Артикул', 'required' => true, 'value' => $item?->article])
+                    @if($canView('article'))
+                        @include('partials.input', ['name' => 'article', 'title' => 'Артикул', 'required' => true, 'value' => $item?->article, 'disabled' => !$canUpdate('article')])
+                    @endif
 
-                    <div class="row mb-2">
-                        <label for="calculator_name" class="col-form-label small col-md-4 text-md-end">
-                            Наименование по калькулятору <sup>*</sup>
-                        </label>
-                        <div class="col-md-8">
-                            <textarea name="calculator_name" id="calculator_name" rows="4" required
-                                      class="form-control form-control-sm @error('calculator_name') is-invalid @enderror">{{ old('calculator_name', $item?->calculator_name) }}</textarea>
-                            @error('calculator_name')
-                                <div class="invalid-feedback"><strong>{{ $message }}</strong></div>
-                            @enderror
+                    @if($canView('calculator_name'))
+                        <div class="row mb-2">
+                            <label for="calculator_name" class="col-form-label small col-md-4 text-md-end">
+                                Наименование <sup>*</sup>
+                            </label>
+                            <div class="col-md-8">
+                                <textarea name="calculator_name" id="calculator_name" rows="4" required
+                                          @disabled(!$canUpdate('calculator_name'))
+                                          class="form-control form-control-sm @error('calculator_name') is-invalid @enderror">{{ old('calculator_name', $item?->calculator_name) }}</textarea>
+                                @error('calculator_name')
+                                    <div class="invalid-feedback"><strong>{{ $message }}</strong></div>
+                                @enderror
+                            </div>
                         </div>
-                    </div>
+                    @endif
 
-                    @include('partials.input', ['name' => 'kind', 'title' => 'Вид', 'value' => $item?->kind])
-                    @include('partials.input', ['name' => 'dimensions', 'title' => 'Габаритные размеры', 'value' => $item?->dimensions])
-                    @include('partials.input', ['name' => 'fall_height', 'title' => 'Высота падения', 'type' => 'number', 'min' => 0, 'step' => '0.001', 'value' => $item?->fall_height])
-                    @include('partials.input', ['name' => 'dimension_unit', 'title' => 'Единица измерения габаритов', 'value' => $item?->dimension_unit])
-                    @include('partials.input', ['name' => 'weight', 'title' => 'Вес, кг.', 'type' => 'number', 'min' => 0, 'step' => '0.001', 'value' => $item?->weight])
-                    @include('partials.input', ['name' => 'volume', 'title' => 'Объем, м3', 'type' => 'number', 'min' => 0, 'step' => '0.001', 'value' => $item?->volume])
-                    @include('partials.input', ['name' => 'places', 'title' => 'Места', 'type' => 'number', 'min' => 0, 'step' => 1, 'value' => $item?->places])
-                    @include('partials.input', ['name' => 'max_users', 'title' => 'Макс. кол-во пользователей', 'type' => 'number', 'min' => 0, 'step' => 1, 'value' => $item?->max_users])
+                    @if($canView('kind')) @include('partials.input', ['name' => 'kind', 'title' => 'Вид', 'value' => $item?->kind, 'disabled' => !$canUpdate('kind')]) @endif
+                    @if($canView('dimension_length')) @include('partials.input', ['name' => 'dimension_length', 'title' => 'Габариты: длина', 'value' => $item?->dimension_length, 'disabled' => !$canUpdate('dimension_length')]) @endif
+                    @if($canView('dimension_width')) @include('partials.input', ['name' => 'dimension_width', 'title' => 'Габариты: ширина', 'value' => $item?->dimension_width, 'disabled' => !$canUpdate('dimension_width')]) @endif
+                    @if($canView('dimension_height')) @include('partials.input', ['name' => 'dimension_height', 'title' => 'Габариты: высота', 'value' => $item?->dimension_height, 'disabled' => !$canUpdate('dimension_height')]) @endif
+                    @if($canView('site_length')) @include('partials.input', ['name' => 'site_length', 'title' => 'Размер участка: длина', 'value' => $item?->site_length, 'disabled' => !$canUpdate('site_length')]) @endif
+                    @if($canView('site_width')) @include('partials.input', ['name' => 'site_width', 'title' => 'Размер участка: ширина', 'value' => $item?->site_width, 'disabled' => !$canUpdate('site_width')]) @endif
+                    @if($canView('fall_height')) @include('partials.input', ['name' => 'fall_height', 'title' => 'Высота падения', 'type' => 'number', 'min' => 0, 'step' => '0.001', 'value' => $item?->fall_height, 'disabled' => !$canUpdate('fall_height')]) @endif
+
+                    @if($canView('additional_info'))
+                        <div class="row mb-2">
+                            <label for="additional_info" class="col-form-label small col-md-4 text-md-end">Дополнительные сведения</label>
+                            <div class="col-md-8">
+                                <textarea name="additional_info" id="additional_info" rows="4"
+                                          @disabled(!$canUpdate('additional_info'))
+                                          class="form-control form-control-sm @error('additional_info') is-invalid @enderror">{{ old('additional_info', $item?->additional_info) }}</textarea>
+                                @error('additional_info')
+                                    <div class="invalid-feedback"><strong>{{ $message }}</strong></div>
+                                @enderror
+                            </div>
+                        </div>
+                    @endif
                 </div>
                 <div class="col-xl-6">
-                    @include('partials.input', ['name' => 'age_group', 'title' => 'Возрастная группа', 'value' => $item?->age_group])
-                    @include('partials.input', ['name' => 'unit', 'title' => 'Ед.', 'value' => $item?->unit])
-                    @include('partials.input', ['name' => 'series', 'title' => 'Серия', 'value' => $item?->series])
-                    @include('partials.input', ['name' => 'trademark', 'title' => 'ТМ', 'value' => $item?->trademark])
+                    @if($canView('dimension_unit')) @include('partials.input', ['name' => 'dimension_unit', 'title' => 'Единица измерения габаритов', 'value' => $item?->dimension_unit, 'disabled' => !$canUpdate('dimension_unit')]) @endif
+                    @if($canView('weight')) @include('partials.input', ['name' => 'weight', 'title' => 'Вес, кг.', 'type' => 'number', 'min' => 0, 'step' => '0.001', 'value' => $item?->weight, 'disabled' => !$canUpdate('weight')]) @endif
+                    @if($canView('volume')) @include('partials.input', ['name' => 'volume', 'title' => 'Объем, м3', 'type' => 'number', 'min' => 0, 'step' => '0.001', 'value' => $item?->volume, 'disabled' => !$canUpdate('volume')]) @endif
+                    @if($canView('places')) @include('partials.input', ['name' => 'places', 'title' => 'Места', 'type' => 'number', 'min' => 0, 'step' => 1, 'value' => $item?->places, 'disabled' => !$canUpdate('places')]) @endif
 
-                    @foreach([
-                        'additional_info' => 'Дополнительные сведения',
-                        'composition' => 'Состав',
-                        'note' => 'Примечание',
-                    ] as $field => $label)
+                    @if($canView('composition'))
                         <div class="row mb-2">
-                            <label for="{{ $field }}" class="col-form-label small col-md-4 text-md-end">{{ $label }}</label>
+                            <label for="composition" class="col-form-label small col-md-4 text-md-end">Состав</label>
                             <div class="col-md-8">
-                                <textarea name="{{ $field }}" id="{{ $field }}" rows="4"
-                                          class="form-control form-control-sm @error($field) is-invalid @enderror">{{ old($field, $item?->{$field}) }}</textarea>
-                                @error($field)
+                                <textarea name="composition" id="composition" rows="4"
+                                          @disabled(!$canUpdate('composition'))
+                                          class="form-control form-control-sm @error('composition') is-invalid @enderror">{{ old('composition', $item?->composition) }}</textarea>
+                                @error('composition')
                                     <div class="invalid-feedback"><strong>{{ $message }}</strong></div>
                                 @enderror
                             </div>
                         </div>
-                    @endforeach
+                    @endif
+
+                    @if($canView('age_group')) @include('partials.input', ['name' => 'age_group', 'title' => 'Возрастная группа', 'value' => $item?->age_group, 'disabled' => !$canUpdate('age_group')]) @endif
+                    @if($canView('max_users')) @include('partials.input', ['name' => 'max_users', 'title' => 'Макс. кол-во пользователей', 'type' => 'number', 'min' => 0, 'step' => 1, 'value' => $item?->max_users, 'disabled' => !$canUpdate('max_users')]) @endif
+                    @if($canView('unit')) @include('partials.input', ['name' => 'unit', 'title' => 'Ед.', 'value' => $item?->unit, 'disabled' => !$canUpdate('unit')]) @endif
+                    @if($canView('series')) @include('partials.input', ['name' => 'series', 'title' => 'Серия', 'value' => $item?->series, 'disabled' => !$canUpdate('series')]) @endif
+                    @if($canView('trademark')) @include('partials.input', ['name' => 'trademark', 'title' => 'ТМ', 'value' => $item?->trademark, 'disabled' => !$canUpdate('trademark')]) @endif
+
+                    @if($canView('calculator_enabled'))
+                        <div class="row mb-2">
+                            <label for="calculator_enabled" class="col-form-label small col-md-4 text-md-end">Калькулятор</label>
+                            <div class="col-md-8">
+                                <select name="calculator_enabled" id="calculator_enabled"
+                                        @disabled(!$canUpdate('calculator_enabled'))
+                                        class="form-select form-select-sm @error('calculator_enabled') is-invalid @enderror">
+                                    <option value="">—</option>
+                                    <option value="1" @selected((string) $calculatorEnabledValue === '1')>да</option>
+                                    <option value="0" @selected((string) $calculatorEnabledValue === '0')>нет</option>
+                                </select>
+                                @error('calculator_enabled')
+                                    <div class="invalid-feedback"><strong>{{ $message }}</strong></div>
+                                @enderror
+                            </div>
+                        </div>
+                    @endif
                 </div>
             </div>
 
+            @if(collect(array_keys($priceFields))->contains(fn (string $field): bool => $canView($field)))
+                <div class="card mt-3">
+                    <div class="card-header"><strong>Цены</strong></div>
+                    <div class="card-body row">
+                        @foreach($priceFields as $field => $label)
+                            @if($canView($field))
+                                <div class="col-xl-6">
+                                    @include('partials.input', [
+                                        'name' => $field,
+                                        'title' => $label,
+                                        'type' => 'number',
+                                        'min' => 0,
+                                        'step' => '0.01',
+                                        'value' => $item?->{$field},
+                                        'disabled' => !$canUpdate($field),
+                                    ])
+                                </div>
+                            @endif
+                        @endforeach
+                    </div>
+                </div>
+            @endif
+
             <div class="d-flex flex-wrap gap-2 mt-3">
                 @if(($item && hasPermission('common-catalog.update')) || (!$item && hasPermission('common-catalog.create')))
                     <button type="submit" class="btn btn-sm btn-primary">Сохранить</button>

+ 29 - 0
resources/views/documents/_folder_tree.blade.php

@@ -0,0 +1,29 @@
+@php($children = $foldersByParent->get($parentId, collect()))
+
+@if($children->isNotEmpty())
+    <ul class="list-unstyled {{ $parentId === 0 ? 'mb-0' : 'ms-3 mt-1 mb-0' }}">
+        @foreach($children as $folder)
+            <li class="mb-1">
+                @if($folder->getAttribute('can_read'))
+                    <a href="{{ route('documents.index', ['folder' => $folder->id]) }}"
+                       class="d-flex align-items-center gap-2 rounded px-2 py-1 text-decoration-none {{ $selectedFolder?->id === $folder->id ? 'bg-primary text-white' : 'text-body' }}">
+                        <i class="bi bi-folder{{ $selectedFolder?->id === $folder->id ? '2-open' : '' }}"></i>
+                        <span class="text-break">{{ $folder->name }}</span>
+                    </a>
+                @else
+                    <span class="d-flex align-items-center gap-2 px-2 py-1 text-muted" title="Папка доступна только для навигации к вложенному разделу">
+                        <i class="bi bi-folder"></i>
+                        <span class="text-break">{{ $folder->name }}</span>
+                        <i class="bi bi-lock-fill small"></i>
+                    </span>
+                @endif
+
+                @include('documents._folder_tree', [
+                    'parentId' => $folder->id,
+                    'foldersByParent' => $foldersByParent,
+                    'selectedFolder' => $selectedFolder,
+                ])
+            </li>
+        @endforeach
+    </ul>
+@endif

+ 356 - 0
resources/views/documents/index.blade.php

@@ -0,0 +1,356 @@
+@extends('layouts.app')
+
+@section('content')
+    <div class="row mb-3 page-header-row">
+        <div class="col-12 col-md-6 page-header-title">
+            <h3>Документация</h3>
+        </div>
+        @if($canCreateRoot)
+            <div class="col-12 col-md-6 text-md-end page-header-actions">
+                <button class="btn btn-sm btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#createRootFolder">
+                    <i class="bi bi-folder-plus"></i> Корневая папка
+                </button>
+            </div>
+        @endif
+    </div>
+
+    @if($canCreateRoot)
+        <div class="collapse mb-3" id="createRootFolder">
+            <div class="card card-body">
+                <form action="{{ route('documents.folders.store') }}" method="POST" class="row g-2 align-items-end">
+                    @csrf
+                    <div class="col-md-8">
+                        <label class="form-label" for="root-folder-name">Название корневой папки</label>
+                        <input class="form-control" id="root-folder-name" name="name" maxlength="255" required>
+                    </div>
+                    <div class="col-md-4">
+                        <button class="btn btn-primary w-100" type="submit">Создать</button>
+                    </div>
+                </form>
+            </div>
+        </div>
+    @endif
+
+    <div class="row g-3">
+        <div class="col-12 col-lg-3">
+            <div class="card h-100">
+                <div class="card-header fw-semibold">Папки</div>
+                <div class="card-body p-2">
+                    @include('documents._folder_tree', [
+                        'parentId' => 0,
+                        'foldersByParent' => $foldersByParent,
+                        'selectedFolder' => $selectedFolder,
+                    ])
+
+                    @if($folders->isEmpty())
+                        <div class="text-muted small p-2">
+                            {{ $canCreateRoot ? 'Создайте первую корневую папку.' : 'Доступных папок пока нет.' }}
+                        </div>
+                    @endif
+                </div>
+            </div>
+        </div>
+
+        <div class="col-12 col-lg-9">
+            @if($selectedFolder)
+                <div class="d-flex flex-wrap justify-content-between align-items-start gap-2 mb-3">
+                    <div>
+                        <h4 class="mb-1"><i class="bi bi-folder2-open"></i> {{ $selectedFolder->name }}</h4>
+                        <div class="small text-muted">
+                            @if($selectedFolder->parent_id && $selectedFolder->inherits_permissions)
+                                Права наследуются от родительской папки
+                            @else
+                                Для папки настроены собственные права
+                            @endif
+                        </div>
+                    </div>
+                    @if($canWrite)
+                        <div class="d-flex flex-wrap gap-1">
+                            <button class="btn btn-sm btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#folderSettings">
+                                <i class="bi bi-pencil"></i> Папка
+                            </button>
+                            <button class="btn btn-sm btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#uploadDocument">
+                                <i class="bi bi-file-earmark-arrow-up"></i> Загрузить документ
+                            </button>
+                        </div>
+                    @endif
+                </div>
+
+                @if($canWrite)
+                    <div class="collapse mb-3" id="folderSettings">
+                        <div class="card card-body">
+                            <div class="row g-3">
+                                <div class="col-md-6">
+                                    <h6>Переименовать папку</h6>
+                                    <form action="{{ route('documents.folders.update', $selectedFolder) }}" method="POST" class="d-flex gap-2">
+                                        @csrf
+                                        @method('PUT')
+                                        <input class="form-control" name="name" value="{{ $selectedFolder->name }}" maxlength="255" required>
+                                        <button class="btn btn-outline-primary" type="submit">Сохранить</button>
+                                    </form>
+                                </div>
+                                <div class="col-md-6">
+                                    <h6>Создать подпапку</h6>
+                                    <form action="{{ route('documents.folders.store') }}" method="POST" class="d-flex gap-2">
+                                        @csrf
+                                        <input type="hidden" name="parent_id" value="{{ $selectedFolder->id }}">
+                                        <input class="form-control" name="name" placeholder="Название подпапки" maxlength="255" required>
+                                        <button class="btn btn-outline-primary" type="submit">Создать</button>
+                                    </form>
+                                </div>
+                            </div>
+                            <hr>
+                            <form action="{{ route('documents.folders.destroy', $selectedFolder) }}" method="POST"
+                                  onsubmit="return confirm('Удалить папку, все вложенные папки, документы и версии?')">
+                                @csrf
+                                @method('DELETE')
+                                <button class="btn btn-sm btn-outline-danger" type="submit">
+                                    <i class="bi bi-trash"></i> Удалить папку со всем содержимым
+                                </button>
+                            </form>
+                        </div>
+                    </div>
+
+                    <div class="collapse mb-3" id="uploadDocument">
+                        <div class="card card-body">
+                            <h6>Новый документ</h6>
+                            <form action="{{ route('documents.store', $selectedFolder) }}" method="POST" enctype="multipart/form-data" class="row g-2 align-items-end">
+                                @csrf
+                                <div class="col-md-5">
+                                    <label class="form-label" for="document-name">Название</label>
+                                    <input class="form-control" id="document-name" name="name" maxlength="255" placeholder="По умолчанию — имя файла">
+                                </div>
+                                <div class="col-md-5">
+                                    <label class="form-label" for="document-file">Файл, до 20 МБ</label>
+                                    <input class="form-control" id="document-file" type="file" name="file" required>
+                                </div>
+                                <div class="col-md-2">
+                                    <button class="btn btn-primary w-100" type="submit">Загрузить</button>
+                                </div>
+                            </form>
+                        </div>
+                    </div>
+                @endif
+
+                @if($canManagePermissions)
+                    <div class="card mb-3">
+                        <div class="card-header p-0">
+                            <button class="btn w-100 text-start px-3 py-2" type="button" data-bs-toggle="collapse" data-bs-target="#folderPermissions">
+                                <i class="bi bi-shield-lock"></i> Права доступа
+                            </button>
+                        </div>
+                        <div class="collapse" id="folderPermissions">
+                            <div class="card-body">
+                                <form action="{{ route('documents.folders.permissions.update', $selectedFolder) }}" method="POST">
+                                    @csrf
+                                    @method('PUT')
+
+                                    @if($selectedFolder->parent_id)
+                                        <div class="form-check mb-3">
+                                            <input class="form-check-input" type="checkbox" value="1" name="inherit_permissions" id="inherit-permissions"
+                                                   @checked($selectedFolder->inherits_permissions)>
+                                            <label class="form-check-label" for="inherit-permissions">
+                                                Наследовать права родительской папки
+                                            </label>
+                                        </div>
+                                    @endif
+
+                                    <div class="row g-3">
+                                        <div class="col-12">
+                                            <div class="d-flex gap-4">
+                                                <div class="form-check">
+                                                    <input class="form-check-input" type="checkbox" value="1" name="all_read" id="all-read"
+                                                           @checked($permissionValues['all_read'])>
+                                                    <label class="form-check-label" for="all-read">Все: чтение</label>
+                                                </div>
+                                                <div class="form-check">
+                                                    <input class="form-check-input" type="checkbox" value="1" name="all_write" id="all-write"
+                                                           @checked($permissionValues['all_write'])>
+                                                    <label class="form-check-label" for="all-write">Все: запись</label>
+                                                </div>
+                                            </div>
+                                        </div>
+                                        <div class="col-md-6">
+                                            <label class="form-label" for="role-read">Роли: чтение</label>
+                                            <select class="form-select" id="role-read" name="role_read_ids[]" multiple size="5">
+                                                @foreach($roles as $role)
+                                                    <option value="{{ $role->id }}" @selected(in_array($role->id, $permissionValues['role_read_ids'], true))>{{ $role->name }}</option>
+                                                @endforeach
+                                            </select>
+                                        </div>
+                                        <div class="col-md-6">
+                                            <label class="form-label" for="role-write">Роли: запись</label>
+                                            <select class="form-select" id="role-write" name="role_write_ids[]" multiple size="5">
+                                                @foreach($roles as $role)
+                                                    <option value="{{ $role->id }}" @selected(in_array($role->id, $permissionValues['role_write_ids'], true))>{{ $role->name }}</option>
+                                                @endforeach
+                                            </select>
+                                        </div>
+                                        <div class="col-md-6">
+                                            <label class="form-label" for="user-read">Пользователи: чтение</label>
+                                            <select class="form-select" id="user-read" name="user_read_ids[]" multiple size="7">
+                                                @foreach($users as $user)
+                                                    <option value="{{ $user->id }}" @selected(in_array($user->id, $permissionValues['user_read_ids'], true))>
+                                                        {{ $user->name }} ({{ $user->email }})
+                                                    </option>
+                                                @endforeach
+                                            </select>
+                                        </div>
+                                        <div class="col-md-6">
+                                            <label class="form-label" for="user-write">Пользователи: запись</label>
+                                            <select class="form-select" id="user-write" name="user_write_ids[]" multiple size="7">
+                                                @foreach($users as $user)
+                                                    <option value="{{ $user->id }}" @selected(in_array($user->id, $permissionValues['user_write_ids'], true))>
+                                                        {{ $user->name }} ({{ $user->email }})
+                                                    </option>
+                                                @endforeach
+                                            </select>
+                                        </div>
+                                    </div>
+                                    <p class="small text-muted mt-3 mb-2">Право записи автоматически включает чтение. При включённом наследовании собственные правила удаляются.</p>
+                                    <button class="btn btn-primary" type="submit">Сохранить права</button>
+                                </form>
+                            </div>
+                        </div>
+                    </div>
+                @endif
+
+                <form action="{{ route('documents.index') }}" method="GET" class="row g-2 mb-3">
+                    <input type="hidden" name="folder" value="{{ $selectedFolder->id }}">
+                    <div class="col-md-10">
+                        <input class="form-control" name="search" value="{{ $search }}" placeholder="Поиск документа по названию">
+                    </div>
+                    <div class="col-md-2">
+                        <button class="btn btn-outline-primary w-100" type="submit"><i class="bi bi-search"></i> Найти</button>
+                    </div>
+                </form>
+
+                <div class="card">
+                    <div class="table-responsive">
+                        <table class="table table-hover align-middle mb-0">
+                            <thead>
+                            <tr>
+                                <th>Документ</th>
+                                <th>Текущая версия</th>
+                                <th>Обновлён</th>
+                                <th>Автор</th>
+                                <th class="text-end">Действия</th>
+                            </tr>
+                            </thead>
+                            <tbody>
+                            @forelse($documents as $document)
+                                @php($currentVersion = $document->currentVersion)
+                                <tr>
+                                    <td class="text-break">
+                                        @if($currentVersion?->isImage())
+                                            <a href="{{ route('documents.versions.preview', $currentVersion) }}" target="_blank" rel="noopener"
+                                               class="text-body text-decoration-none" title="Открыть изображение">
+                                                <i class="{{ $currentVersion->iconClass() }} me-1"></i>{{ $document->name }}
+                                            </a>
+                                        @else
+                                            <i class="{{ $currentVersion?->iconClass() ?? 'bi bi-file-earmark' }} me-1"></i>{{ $document->name }}
+                                        @endif
+                                        <details class="mt-2">
+                                            <summary class="small text-primary" style="cursor: pointer">Все версии ({{ $document->versions->count() }})</summary>
+                                            <div class="mt-2">
+                                                @foreach($document->versions as $version)
+                                                    <div class="d-flex flex-wrap align-items-center gap-2 border-top py-2 small">
+                                                        <span class="badge text-bg-secondary">v{{ $version->version }}</span>
+                                                        @if($version->isImage())
+                                                            <a href="{{ route('documents.versions.preview', $version) }}" target="_blank" rel="noopener"
+                                                               class="text-body text-decoration-none" title="Открыть изображение">
+                                                                <i class="{{ $version->iconClass() }}"></i> {{ $version->file?->original_name }}
+                                                            </a>
+                                                        @else
+                                                            <span><i class="{{ $version->iconClass() }}"></i> {{ $version->file?->original_name }}</span>
+                                                        @endif
+                                                        <span class="text-muted">{{ number_format($version->file_size / 1024, 1, ',', ' ') }} КБ</span>
+                                                        <span class="text-muted">{{ $version->created_at?->format('d.m.Y H:i') }}</span>
+                                                        <a class="btn btn-sm btn-outline-primary" href="{{ route('documents.versions.download', $version) }}">Скачать</a>
+                                                        @if($canWrite)
+                                                            <form action="{{ route('documents.versions.destroy', $version) }}" method="POST"
+                                                                  onsubmit="return confirm('Полностью удалить эту версию?')">
+                                                                @csrf
+                                                                @method('DELETE')
+                                                                <button class="btn btn-sm btn-outline-danger" type="submit">Удалить</button>
+                                                            </form>
+                                                        @endif
+                                                    </div>
+                                                @endforeach
+                                            </div>
+                                        </details>
+                                    </td>
+                                    <td>v{{ $currentVersion?->version }}</td>
+                                    <td>{{ $currentVersion?->created_at?->format('d.m.Y H:i') }}</td>
+                                    <td>{{ $currentVersion?->creator?->name }}</td>
+                                    <td class="text-end">
+                                        @if($currentVersion)
+                                            <a class="btn btn-sm btn-outline-primary mb-1" href="{{ route('documents.versions.download', $currentVersion) }}">
+                                                <i class="bi bi-download"></i>
+                                            </a>
+                                        @endif
+                                        @if($canWrite)
+                                            <button class="btn btn-sm btn-outline-secondary mb-1" type="button" data-bs-toggle="collapse"
+                                                    data-bs-target="#document-actions-{{ $document->id }}">
+                                                <i class="bi bi-pencil"></i>
+                                            </button>
+                                        @endif
+                                    </td>
+                                </tr>
+                                @if($canWrite)
+                                    <tr class="collapse" id="document-actions-{{ $document->id }}">
+                                        <td colspan="5" class="bg-light">
+                                            <div class="row g-3">
+                                                <div class="col-lg-6">
+                                                    <form action="{{ route('documents.update', $document) }}" method="POST" class="d-flex gap-2">
+                                                        @csrf
+                                                        @method('PUT')
+                                                        <input class="form-control form-control-sm" name="name" value="{{ $document->name }}" maxlength="255" required>
+                                                        <button class="btn btn-sm btn-outline-primary" type="submit">Переименовать</button>
+                                                    </form>
+                                                </div>
+                                                <div class="col-lg-6">
+                                                    <form action="{{ route('documents.versions.store', $document) }}" method="POST" enctype="multipart/form-data" class="d-flex gap-2">
+                                                        @csrf
+                                                        <input class="form-control form-control-sm" type="file" name="file" required>
+                                                        <button class="btn btn-sm btn-outline-primary" type="submit">Новая версия</button>
+                                                    </form>
+                                                </div>
+                                                <div class="col-12">
+                                                    <form action="{{ route('documents.destroy', $document) }}" method="POST"
+                                                          onsubmit="return confirm('Полностью удалить документ и все версии?')">
+                                                        @csrf
+                                                        @method('DELETE')
+                                                        <button class="btn btn-sm btn-outline-danger" type="submit">Удалить документ</button>
+                                                    </form>
+                                                </div>
+                                            </div>
+                                        </td>
+                                    </tr>
+                                @endif
+                            @empty
+                                <tr>
+                                    <td colspan="5" class="text-center text-muted py-4">
+                                        {{ $search !== '' ? 'Документы по запросу не найдены.' : 'В папке пока нет документов.' }}
+                                    </td>
+                                </tr>
+                            @endforelse
+                            </tbody>
+                        </table>
+                    </div>
+                </div>
+
+                @if($documents->hasPages())
+                    <div class="mt-3">
+                        {{ $documents->links() }}
+                    </div>
+                @endif
+            @else
+                <div class="card card-body text-center text-muted py-5">
+                    <i class="bi bi-folder2-open fs-1"></i>
+                    <p class="mb-0 mt-2">Выберите доступную папку или создайте новую.</p>
+                </div>
+            @endif
+        </div>
+    </div>
+@endsection

+ 20 - 12
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">
@@ -119,9 +125,11 @@
         </li>
     @endif
 
-    <li class="nav-item">
-        <a class="nav-link @if(($active ?? '') === 'documents') active @endif" href="{{ route('documents.index') }}">Документация</a>
-    </li>
+    @if(hasPermission('documents.view'))
+        <li class="nav-item">
+            <a class="nav-link @if(($active ?? '') === 'documents') active @endif" href="{{ route('documents.index') }}">Документация</a>
+        </li>
+    @endif
 
     <li class="nav-item">
         <a class="nav-link @if(($active ?? '') === 'calculations') active @endif" href="{{ route('calculations.index') }}">Калькуляции</a>

+ 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

+ 44 - 40
routes/web.php

@@ -3,15 +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\ContractorController;
-use App\Http\Controllers\YearDataController;
 use App\Http\Controllers\ContractController;
+use App\Http\Controllers\ContractorController;
+use App\Http\Controllers\DocumentationController;
 use App\Http\Controllers\FilterController;
 use App\Http\Controllers\ImportController;
 use App\Http\Controllers\MafOrderController;
@@ -27,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;
@@ -40,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.';
 });
 
@@ -61,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);
         }
 
@@ -69,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');
         });
@@ -92,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');
@@ -119,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');
@@ -130,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');
@@ -180,20 +183,33 @@ 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::get('documents', UnderDevelopmentController::class)
-        ->defaults('title', 'Документация')
-        ->defaults('active', 'documents')
-        ->name('documents.index');
+    Route::prefix('documents')->name('documents.')->group(function () {
+        Route::get('', [DocumentationController::class, 'index'])->name('index');
+        Route::post('folders', [DocumentationController::class, 'storeFolder'])->name('folders.store');
+        Route::put('folders/{folder}', [DocumentationController::class, 'updateFolder'])->name('folders.update');
+        Route::delete('folders/{folder}', [DocumentationController::class, 'destroyFolder'])->name('folders.destroy');
+        Route::put('folders/{folder}/permissions', [DocumentationController::class, 'updatePermissions'])->name('folders.permissions.update');
+        Route::post('folders/{folder}/documents', [DocumentationController::class, 'storeDocument'])->name('store');
+        Route::put('{document}', [DocumentationController::class, 'updateDocument'])->name('update');
+        Route::delete('{document}', [DocumentationController::class, 'destroyDocument'])->name('destroy');
+        Route::post('{document}/versions', [DocumentationController::class, 'storeVersion'])->name('versions.store');
+        Route::get('versions/{version}/preview', [DocumentationController::class, 'previewVersion'])->name('versions.preview');
+        Route::get('versions/{version}/download', [DocumentationController::class, 'downloadVersion'])->name('versions.download');
+        Route::delete('versions/{version}', [DocumentationController::class, 'destroyVersion'])->name('versions.destroy');
+    });
 
     Route::get('calculations', UnderDevelopmentController::class)
         ->defaults('title', 'Калькуляции')
@@ -201,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');
@@ -211,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');
@@ -278,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');
@@ -310,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');
@@ -336,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');
@@ -348,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'])
@@ -371,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');
 

+ 110 - 4
tests/Feature/CommonCatalogControllerTest.php

@@ -25,11 +25,14 @@ class CommonCatalogControllerTest extends TestCase
 
     private User $manager;
 
+    private User $assistantHead;
+
     protected function setUp(): void
     {
         parent::setUp();
         $this->admin = User::factory()->create(['role' => Role::ADMIN]);
         $this->manager = User::factory()->create(['role' => Role::MANAGER]);
+        $this->assistantHead = User::factory()->create(['role' => Role::ASSISTANT_HEAD]);
     }
 
     public function test_guest_cannot_access_common_catalog(): void
@@ -60,6 +63,69 @@ class CommonCatalogControllerTest extends TestCase
             ->assertForbidden();
     }
 
+    public function test_prices_are_visible_to_admin_and_hidden_from_other_roles_by_default(): void
+    {
+        $item = CommonCatalogItem::factory()->create([
+            'article' => 'PRICE-001',
+            'builders_price' => 123456.78,
+        ]);
+
+        $this->actingAs($this->admin)
+            ->get(route('common-catalog.show', $item))
+            ->assertOk()
+            ->assertSeeText('Цены')
+            ->assertSeeText('строители')
+            ->assertSee('123456.78', false);
+
+        $this->actingAs($this->manager)
+            ->get(route('common-catalog.show', $item))
+            ->assertOk()
+            ->assertDontSeeText('Цены')
+            ->assertDontSeeText('строители')
+            ->assertDontSee('123456.78', false);
+
+        $this->actingAs($this->assistantHead)
+            ->get(route('common-catalog.show', $item))
+            ->assertOk()
+            ->assertDontSeeText('Цены')
+            ->assertDontSeeText('строители');
+    }
+
+    public function test_assistant_cannot_change_hidden_price_with_forged_payload(): void
+    {
+        $item = CommonCatalogItem::factory()->create([
+            'calculator_name' => 'Старое наименование',
+            'builders_price' => 100,
+        ]);
+        $payload = $this->validData();
+        $payload['article'] = $item->article;
+        $payload['calculator_name'] = 'Новое наименование';
+        $payload['builders_price'] = 999999;
+
+        $this->actingAs($this->assistantHead)
+            ->put(route('common-catalog.update', $item), $payload)
+            ->assertRedirect();
+
+        $item->refresh();
+        $this->assertSame('Новое наименование', $item->calculator_name);
+        $this->assertSame(100.0, $item->builders_price);
+    }
+
+    public function test_export_is_available_only_to_admin_by_default(): void
+    {
+        Bus::fake([ExportCommonCatalogJob::class]);
+
+        $this->actingAs($this->assistantHead)
+            ->post(route('common-catalog.export'))
+            ->assertForbidden();
+        Bus::assertNotDispatched(ExportCommonCatalogJob::class);
+
+        $this->actingAs($this->admin)
+            ->post(route('common-catalog.export'))
+            ->assertRedirect(route('common-catalog.index'));
+        Bus::assertDispatched(ExportCommonCatalogJob::class);
+    }
+
     public function test_admin_can_create_update_and_delete_item(): void
     {
         $this->actingAs($this->admin)
@@ -87,16 +153,43 @@ class CommonCatalogControllerTest extends TestCase
         $this->assertSoftDeleted('common_catalog_items', ['id' => $item->id]);
     }
 
-    public function test_article_and_calculator_name_are_required_and_article_is_unique(): void
+    public function test_article_and_name_are_required_and_their_pair_is_unique(): void
     {
-        CommonCatalogItem::factory()->create(['article' => '0007']);
+        CommonCatalogItem::factory()->create([
+            'article' => '0007',
+            'calculator_name' => 'Игровой комплекс',
+        ]);
 
         $this->actingAs($this->admin)
             ->post(route('common-catalog.store'), [
-                'article' => '0007',
+                'article' => '',
                 'calculator_name' => '',
             ])
             ->assertSessionHasErrors(['article', 'calculator_name']);
+
+        $this->actingAs($this->admin)
+            ->post(route('common-catalog.store'), [
+                'article' => '0007',
+                'calculator_name' => 'Игровой комплекс',
+            ])
+            ->assertSessionHasErrors(['article']);
+    }
+
+    public function test_same_article_is_allowed_for_different_named_variants(): void
+    {
+        CommonCatalogItem::factory()->create([
+            'article' => '6416',
+            'calculator_name' => 'Оборудование игровой площадки',
+        ]);
+        $payload = $this->validData();
+        $payload['article'] = '6416';
+        $payload['calculator_name'] = 'Оборудование игровой площадки (Цинк)';
+
+        $this->actingAs($this->admin)
+            ->post(route('common-catalog.store'), $payload)
+            ->assertRedirect();
+
+        $this->assertDatabaseCount('common_catalog_items', 2);
     }
 
     public function test_item_card_contains_technical_description_and_calculation_placeholders(): void
@@ -192,7 +285,11 @@ class CommonCatalogControllerTest extends TestCase
             'article' => '0007',
             'calculator_name' => "0007\nИгровой комплекс",
             'kind' => 'Игровое оборудование',
-            'dimensions' => '2 × 3 × 1,5 м',
+            'dimension_length' => '2',
+            'dimension_width' => '3',
+            'dimension_height' => '1,5',
+            'site_length' => '5',
+            'site_width' => '6',
             'fall_height' => 1.2,
             'dimension_unit' => 'м',
             'weight' => 350.5,
@@ -203,6 +300,15 @@ class CommonCatalogControllerTest extends TestCase
             'unit' => 'шт.',
             'series' => 'Двор',
             'trademark' => 'Наш Двор',
+            'calculator_enabled' => true,
+            'builders_price' => 100000,
+            'wholesale_price' => 105000,
+            'recommended_price' => 110000,
+            'retail_price' => 150000,
+            'project_price' => 200000,
+            'project_with_installation_price' => 240000,
+            'pik_price' => 110000,
+            'recommended_plus_10_price' => 121000,
         ];
     }
 }

+ 337 - 0
tests/Feature/DocumentationControllerTest.php

@@ -0,0 +1,337 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Feature;
+
+use App\Models\DocumentationDocument;
+use App\Models\DocumentationDocumentVersion;
+use App\Models\DocumentationFolder;
+use App\Models\DocumentationFolderPermission;
+use App\Models\Role;
+use App\Models\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Storage;
+use Tests\TestCase;
+
+class DocumentationControllerTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected bool $seed = true;
+
+    private User $admin;
+
+    private User $manager;
+
+    private User $brigadier;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        Storage::fake('public');
+        Storage::fake('local');
+
+        $this->admin = User::factory()->create(['role' => Role::ADMIN]);
+        $this->manager = User::factory()->create(['role' => Role::MANAGER]);
+        $this->brigadier = User::factory()->create(['role' => Role::BRIGADIER]);
+    }
+
+    public function test_guest_cannot_open_documentation(): void
+    {
+        $this->get(route('documents.index'))->assertRedirect(route('login'));
+    }
+
+    public function test_authenticated_user_can_open_year_independent_documentation(): void
+    {
+        $folder = $this->createRootFolder();
+
+        $this->actingAs($this->manager)
+            ->withSession(['year' => 2020])
+            ->get(route('documents.index', ['folder' => $folder->id]))
+            ->assertOk()
+            ->assertViewIs('documents.index')
+            ->assertSeeText($folder->name);
+    }
+
+    public function test_user_without_documents_view_permission_cannot_enter_module(): void
+    {
+        $role = Role::query()->create([
+            'slug' => 'documentation_denied',
+            'name' => 'Без документации',
+            'is_active' => true,
+        ]);
+        $user = User::factory()->create([
+            'role' => $role->slug,
+            'role_id' => $role->id,
+        ]);
+
+        $this->actingAs($user)->get(route('documents.index'))->assertForbidden();
+    }
+
+    public function test_only_real_administrator_can_create_root_folder(): void
+    {
+        $assistant = User::factory()->create(['role' => Role::ASSISTANT_HEAD]);
+
+        $this->actingAs($this->manager)
+            ->post(route('documents.folders.store'), ['name' => 'Закрытая папка'])
+            ->assertForbidden();
+        $this->actingAs($assistant)
+            ->post(route('documents.folders.store'), ['name' => 'Папка помощника'])
+            ->assertForbidden();
+
+        $this->actingAs($this->admin)
+            ->post(route('documents.folders.store'), ['name' => 'Общие инструкции'])
+            ->assertRedirect();
+
+        $folder = DocumentationFolder::query()->where('name', 'Общие инструкции')->firstOrFail();
+        $this->assertFalse($folder->inherits_permissions);
+        $this->assertDatabaseHas('documentation_folder_permissions', [
+            'folder_id' => $folder->id,
+            'subject_type' => DocumentationFolderPermission::SUBJECT_ALL,
+            'can_read' => true,
+            'can_write' => false,
+        ]);
+    }
+
+    public function test_folder_permissions_support_all_roles_and_users(): void
+    {
+        $folder = $this->createRootFolder();
+        $managerRole = Role::query()->where('slug', Role::MANAGER)->firstOrFail();
+
+        $this->actingAs($this->admin)
+            ->put(route('documents.folders.permissions.update', $folder), [
+                'role_read_ids' => [$managerRole->id],
+                'user_write_ids' => [$this->brigadier->id],
+            ])
+            ->assertRedirect(route('documents.index', ['folder' => $folder->id]));
+
+        $this->actingAs($this->manager)
+            ->get(route('documents.index', ['folder' => $folder->id]))
+            ->assertOk();
+        $this->actingAs($this->manager)
+            ->post(route('documents.folders.store'), ['parent_id' => $folder->id, 'name' => 'Нельзя'])
+            ->assertForbidden();
+
+        $this->actingAs($this->brigadier)
+            ->post(route('documents.folders.store'), ['parent_id' => $folder->id, 'name' => 'Можно'])
+            ->assertRedirect();
+
+        $this->assertDatabaseHas('documentation_folder_permissions', [
+            'folder_id' => $folder->id,
+            'subject_type' => DocumentationFolderPermission::SUBJECT_ROLE,
+            'role_id' => $managerRole->id,
+            'can_read' => true,
+            'can_write' => false,
+        ]);
+        $this->assertDatabaseHas('documentation_folder_permissions', [
+            'folder_id' => $folder->id,
+            'subject_type' => DocumentationFolderPermission::SUBJECT_USER,
+            'user_id' => $this->brigadier->id,
+            'can_read' => true,
+            'can_write' => true,
+        ]);
+    }
+
+    public function test_subfolder_inherits_parent_permissions(): void
+    {
+        $folder = $this->createRootFolder();
+        $this->actingAs($this->admin)
+            ->put(route('documents.folders.permissions.update', $folder), [
+                'user_write_ids' => [$this->manager->id],
+            ])
+            ->assertRedirect();
+
+        $this->actingAs($this->manager)
+            ->post(route('documents.folders.store'), [
+                'parent_id' => $folder->id,
+                'name' => 'Регламенты',
+            ])
+            ->assertRedirect();
+
+        $child = DocumentationFolder::query()->where('name', 'Регламенты')->firstOrFail();
+        $this->assertTrue($child->inherits_permissions);
+        $this->assertDatabaseCount('documentation_folder_permissions', 1);
+
+        $this->actingAs($this->manager)
+            ->post(route('documents.store', $child), [
+                'name' => 'Регламент монтажа',
+                'file' => UploadedFile::fake()->create('rules.pdf', 12, 'application/pdf'),
+            ])
+            ->assertRedirect();
+
+        $this->assertDatabaseHas('documentation_documents', [
+            'folder_id' => $child->id,
+            'name' => 'Регламент монтажа',
+            'last_version_number' => 1,
+        ]);
+    }
+
+    public function test_document_versions_can_be_downloaded_and_current_version_falls_back_after_deletion(): void
+    {
+        $folder = $this->createRootFolder();
+
+        $this->actingAs($this->admin)
+            ->post(route('documents.store', $folder), [
+                'name' => 'Прайс',
+                'file' => UploadedFile::fake()->createWithContent('price.xlsx', 'version-one'),
+            ])
+            ->assertRedirect();
+
+        $document = DocumentationDocument::query()->firstOrFail();
+        $firstVersion = $document->versions()->firstOrFail();
+        Storage::disk('local')->assertExists($firstVersion->file->path);
+        Storage::disk('public')->assertMissing($firstVersion->file->path);
+
+        $this->actingAs($this->admin)
+            ->post(route('documents.versions.store', $document), [
+                'file' => UploadedFile::fake()->createWithContent('price.xlsx', 'version-two'),
+            ])
+            ->assertRedirect();
+
+        $document->refresh();
+        $secondVersion = $document->versions()->where('version', 2)->firstOrFail();
+        $secondPath = $secondVersion->file->path;
+        $this->assertSame(2, $document->currentVersion()->firstOrFail()->version);
+
+        $this->actingAs($this->manager)
+            ->get(route('documents.versions.download', $firstVersion))
+            ->assertOk();
+
+        $this->actingAs($this->admin)
+            ->delete(route('documents.versions.destroy', $secondVersion))
+            ->assertRedirect();
+
+        Storage::disk('local')->assertMissing($secondPath);
+        $this->assertSame(1, $document->refresh()->currentVersion()->firstOrFail()->version);
+    }
+
+    public function test_deleting_last_version_deletes_document_and_file(): void
+    {
+        $folder = $this->createRootFolder();
+        $this->actingAs($this->admin)
+            ->post(route('documents.store', $folder), [
+                'file' => UploadedFile::fake()->createWithContent('manual.txt', 'manual'),
+            ])
+            ->assertRedirect();
+
+        $document = DocumentationDocument::query()->firstOrFail();
+        $version = DocumentationDocumentVersion::query()->with('file')->firstOrFail();
+        $fileId = $version->file_id;
+        $path = $version->file->path;
+
+        $this->actingAs($this->admin)
+            ->delete(route('documents.versions.destroy', $version))
+            ->assertRedirect();
+
+        $this->assertDatabaseMissing('documentation_documents', ['id' => $document->id]);
+        $this->assertDatabaseMissing('files', ['id' => $fileId]);
+        Storage::disk('local')->assertMissing($path);
+    }
+
+    public function test_deleting_folder_removes_nested_documents_versions_and_physical_files(): void
+    {
+        $folder = $this->createRootFolder();
+        $this->actingAs($this->admin)
+            ->post(route('documents.folders.store'), ['parent_id' => $folder->id, 'name' => 'Архив'])
+            ->assertRedirect();
+        $child = DocumentationFolder::query()->where('parent_id', $folder->id)->firstOrFail();
+
+        $this->actingAs($this->admin)
+            ->post(route('documents.store', $child), [
+                'file' => UploadedFile::fake()->createWithContent('archive.txt', 'archive'),
+            ])
+            ->assertRedirect();
+
+        $version = DocumentationDocumentVersion::query()->with('file')->firstOrFail();
+        $path = $version->file->path;
+        $fileId = $version->file_id;
+
+        $this->actingAs($this->admin)
+            ->delete(route('documents.folders.destroy', $folder))
+            ->assertRedirect(route('documents.index'));
+
+        $this->assertDatabaseMissing('documentation_folders', ['id' => $folder->id]);
+        $this->assertDatabaseMissing('documentation_folders', ['id' => $child->id]);
+        $this->assertDatabaseMissing('files', ['id' => $fileId]);
+        Storage::disk('local')->assertMissing($path);
+    }
+
+    public function test_document_can_be_renamed_and_found_by_name(): void
+    {
+        $folder = $this->createRootFolder();
+        $this->actingAs($this->admin)
+            ->post(route('documents.store', $folder), [
+                'name' => 'Старое название',
+                'file' => UploadedFile::fake()->create('document.pdf', 5, 'application/pdf'),
+            ])
+            ->assertRedirect();
+
+        $document = DocumentationDocument::query()->firstOrFail();
+        $this->actingAs($this->admin)
+            ->put(route('documents.update', $document), ['name' => 'Инструкция по монтажу'])
+            ->assertRedirect();
+
+        $this->actingAs($this->manager)
+            ->get(route('documents.index', ['folder' => $folder->id, 'search' => 'монтажу']))
+            ->assertOk()
+            ->assertSeeText('Инструкция по монтажу');
+    }
+
+    public function test_file_icons_follow_mime_type_and_images_have_protected_preview(): void
+    {
+        $folder = $this->createRootFolder();
+        $this->actingAs($this->admin)
+            ->post(route('documents.store', $folder), [
+                'name' => 'Фотография',
+                'file' => UploadedFile::fake()->image('photo.png', 120, 80),
+            ])
+            ->assertRedirect();
+        $this->actingAs($this->admin)
+            ->post(route('documents.store', $folder), [
+                'name' => 'Инструкция PDF',
+                'file' => UploadedFile::fake()->create('manual.pdf', 5, 'application/pdf'),
+            ])
+            ->assertRedirect();
+
+        $imageVersion = DocumentationDocument::query()
+            ->where('name', 'Фотография')
+            ->firstOrFail()
+            ->currentVersion()
+            ->with('file')
+            ->firstOrFail();
+        $pdfVersion = DocumentationDocument::query()
+            ->where('name', 'Инструкция PDF')
+            ->firstOrFail()
+            ->currentVersion()
+            ->with('file')
+            ->firstOrFail();
+
+        $this->actingAs($this->manager)
+            ->get(route('documents.index', ['folder' => $folder->id]))
+            ->assertOk()
+            ->assertSee('bi-file-earmark-image', false)
+            ->assertSee('bi-file-earmark-pdf', false)
+            ->assertSee(route('documents.versions.preview', $imageVersion), false);
+
+        $this->actingAs($this->manager)
+            ->get(route('documents.versions.preview', $imageVersion))
+            ->assertOk()
+            ->assertHeader('Content-Type', 'image/png')
+            ->assertHeader('X-Content-Type-Options', 'nosniff');
+
+        $this->actingAs($this->manager)
+            ->get(route('documents.versions.preview', $pdfVersion))
+            ->assertNotFound();
+    }
+
+    private function createRootFolder(): DocumentationFolder
+    {
+        $this->actingAs($this->admin)
+            ->post(route('documents.folders.store'), ['name' => 'Общая документация'])
+            ->assertRedirect();
+
+        return DocumentationFolder::query()->whereNull('parent_id')->latest('id')->firstOrFail();
+    }
+}

+ 1 - 1
tests/Feature/ManagerMenuTest.php

@@ -57,7 +57,7 @@ class ManagerMenuTest extends TestCase
     {
         $user = $this->createUserWithPermissions('contractor_viewer', ['contractors.view']);
 
-        $response = $this->actingAs($user)->get(route('documents.index'));
+        $response = $this->actingAs($user)->get(route('calculations.index'));
 
         $response->assertOk()
             ->assertSeeText('Администратор')

+ 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);
+    }
+}

+ 12 - 3
tests/Unit/Services/Export/ExportCommonCatalogServiceTest.php

@@ -38,6 +38,10 @@ class ExportCommonCatalogServiceTest extends TestCase
             'article' => '0254',
             'calculator_name' => 'Оборудование для благоустройства',
             'image_file_id' => $image->id,
+            'dimension_length' => 'от 180',
+            'calculator_enabled' => true,
+            'builders_price' => 1418500,
+            'recommended_plus_10_price' => 1888023.50,
         ]);
 
         (new ExportCommonCatalogService)->handle($user->id);
@@ -46,9 +50,14 @@ class ExportCommonCatalogServiceTest extends TestCase
         Storage::disk('public')->assertExists($file->path);
 
         $sheet = IOFactory::load(Storage::disk('public')->path($file->path))->getActiveSheet();
-        $this->assertSame(ExportCommonCatalogService::HEADERS, $sheet->rangeToArray('A1:S1')[0]);
-        $this->assertSame('0254', $sheet->getCell('C2')->getValue());
-        $this->assertSame('Оборудование для благоустройства', $sheet->getCell('D2')->getValue());
+        $this->assertSame(ExportCommonCatalogService::HEADERS, $sheet->rangeToArray('A1:AD1')[0]);
+        $this->assertEquals(ExportCommonCatalogService::SUBHEADERS, $sheet->rangeToArray('A2:AD2')[0]);
+        $this->assertSame('0254', $sheet->getCell('A3')->getValue());
+        $this->assertSame('Оборудование для благоустройства', $sheet->getCell('B3')->getValue());
+        $this->assertSame('от 180', $sheet->getCell('D3')->getValue());
+        $this->assertSame('да', $sheet->getCell('V3')->getValue());
+        $this->assertSame(1418500.0, $sheet->getCell('W3')->getValue());
+        $this->assertSame(1888023.5, $sheet->getCell('AD3')->getValue());
         $this->assertCount(1, $sheet->getDrawingCollection());
     }
 }

+ 107 - 20
tests/Unit/Services/Import/ImportCommonCatalogServiceTest.php

@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace Tests\Unit\Services\Import;
 
 use App\Models\Import;
+use App\Models\Role;
 use App\Models\User;
 use App\Services\Export\ExportCommonCatalogService;
 use App\Services\Import\ImportCommonCatalogService;
@@ -12,6 +13,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
 use Illuminate\Http\UploadedFile;
 use Illuminate\Support\Facades\Storage;
 use PhpOffice\PhpSpreadsheet\Cell\DataType;
+use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
 use PhpOffice\PhpSpreadsheet\Spreadsheet;
 use PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
 use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
@@ -28,7 +30,7 @@ class ImportCommonCatalogServiceTest extends TestCase
     {
         Storage::fake('upload');
         Storage::fake('public');
-        $user = User::factory()->create();
+        $user = User::factory()->create(['role' => Role::ADMIN]);
         $import = $this->createImport($user, 'Оборудование для благоустройства', withImage: true);
 
         $this->assertTrue((new ImportCommonCatalogService($import, $user->id))->handle());
@@ -36,6 +38,8 @@ class ImportCommonCatalogServiceTest extends TestCase
             'article' => '0254',
             'calculator_name' => 'Оборудование для благоустройства',
             'places' => 38,
+            'builders_price' => 141850000,
+            'recommended_plus_10_price' => 188802350,
         ]);
         $item = \App\Models\CommonCatalogItem::query()->firstOrFail();
         $this->assertNotNull($item->imageFile);
@@ -54,17 +58,75 @@ class ImportCommonCatalogServiceTest extends TestCase
     public function test_import_accepts_template_header_with_line_break(): void
     {
         Storage::fake('upload');
-        $user = User::factory()->create();
+        $user = User::factory()->create(['role' => Role::ADMIN]);
         $import = $this->createImport($user, 'Тест', "Единица измерения\nгабаритов");
 
         $this->assertTrue((new ImportCommonCatalogService($import, $user->id))->handle());
         $this->assertSame('DONE', $import->refresh()->status);
     }
 
+    public function test_import_accepts_real_catalog_row_without_calculator_name(): void
+    {
+        Storage::fake('upload');
+        $user = User::factory()->create(['role' => Role::ADMIN]);
+        $import = $this->createImport($user, null);
+
+        $this->assertTrue((new ImportCommonCatalogService($import, $user->id))->handle());
+
+        $this->assertDatabaseHas('common_catalog_items', [
+            'article' => '0254',
+            'calculator_name' => null,
+        ]);
+        $this->assertStringContainsString('ошибок: 0', (string) $import->refresh()->result);
+    }
+
+    public function test_import_does_not_change_prices_without_field_permission(): void
+    {
+        Storage::fake('upload');
+        $user = User::factory()->create(['role' => Role::ASSISTANT_HEAD]);
+        $item = \App\Models\CommonCatalogItem::factory()->create([
+            'article' => '0254',
+            'calculator_name' => 'Старое наименование',
+            'builders_price' => 100,
+        ]);
+        $import = $this->createImport($user, 'Новое наименование');
+
+        $this->assertTrue((new ImportCommonCatalogService($import, $user->id))->handle());
+
+        $item->refresh();
+        $this->assertSame('Новое наименование', $item->calculator_name);
+        $this->assertSame(100.0, $item->builders_price);
+    }
+
+    public function test_import_preserves_rows_with_same_article_as_named_variants(): void
+    {
+        Storage::fake('upload');
+        $user = User::factory()->create(['role' => Role::ADMIN]);
+        $import = $this->createImport(
+            $user,
+            'Оборудование игровой площадки',
+            duplicateVariant: true,
+        );
+
+        $this->assertTrue((new ImportCommonCatalogService($import, $user->id))->handle());
+
+        $this->assertDatabaseCount('common_catalog_items', 2);
+        $this->assertDatabaseHas('common_catalog_items', [
+            'article' => '0254',
+            'calculator_name' => 'Оборудование игровой площадки',
+            'builders_price' => 141850000,
+        ]);
+        $this->assertDatabaseHas('common_catalog_items', [
+            'article' => '0254',
+            'calculator_name' => 'Оборудование игровой площадки (Цинк)',
+            'builders_price' => 162280000,
+        ]);
+    }
+
     public function test_import_rejects_wrong_headers_with_clear_error(): void
     {
         Storage::fake('upload');
-        $user = User::factory()->create();
+        $user = User::factory()->create(['role' => Role::ADMIN]);
         $import = $this->createImport($user, 'Тест', 'Неверный заголовок');
 
         $this->expectException(RuntimeException::class);
@@ -79,35 +141,60 @@ class ImportCommonCatalogServiceTest extends TestCase
 
     private function createImport(
         User $user,
-        string $calculatorName,
+        ?string $calculatorName,
         string $dimensionHeader = "Единица измерения\nгабаритов",
         bool $withImage = false,
+        bool $duplicateVariant = false,
     ): Import {
         $spreadsheet = new Spreadsheet;
         $sheet = $spreadsheet->getActiveSheet();
         $headers = ExportCommonCatalogService::HEADERS;
-        $headers[8] = $dimensionHeader;
+        $headers[10] = $dimensionHeader;
         $sheet->fromArray($headers, null, 'A1');
-        $sheet->setCellValueExplicit('C3', '0254', DataType::TYPE_STRING);
-        $sheet->setCellValue('D3', $calculatorName);
-        $sheet->setCellValue('E3', 'Теневой навес');
-        $sheet->setCellValue('F3', '9,15 x 3,46 x 3,43 м');
-        $sheet->setCellValue('G3', 0);
-        $sheet->setCellValue('I3', 'м');
-        $sheet->setCellValue('J3', 2153.84);
-        $sheet->setCellValue('K3', 7.93);
-        $sheet->setCellValue('L3', 38);
-        $sheet->setCellValue('N3', '0+');
-        $sheet->setCellValue('O3', 10);
-        $sheet->setCellValue('P3', 'шт.');
-        $sheet->setCellValue('Q3', 'Теневые навесы');
-        $sheet->setCellValue('R3', 'Наш Двор');
+        $sheet->fromArray(ExportCommonCatalogService::SUBHEADERS, null, 'A2');
+        $sheet->setCellValueExplicit('A3', '0254', DataType::TYPE_STRING);
+        $sheet->setCellValue('B3', $calculatorName);
+        $sheet->setCellValue('C3', 'Теневой навес');
+        $sheet->setCellValue('D3', 'от 180');
+        $sheet->setCellValue('E3', '1,37-1,55');
+        $sheet->setCellValue('F3', 3.43);
+        $sheet->setCellValue('G3', 9.15);
+        $sheet->setCellValue('H3', 3.46);
+        $sheet->setCellValue('I3', 0);
+        $sheet->setCellValue('K3', 'м');
+        $sheet->setCellValue('L3', 2153.84);
+        $sheet->setCellValue('M3', 7.93);
+        $sheet->setCellValue('N3', 38);
+        $sheet->setCellValue('P3', '0+');
+        $sheet->setCellValue('Q3', 10);
+        $sheet->setCellValue('R3', 'шт.');
+        $sheet->setCellValue('T3', 'Теневые навесы');
+        $sheet->setCellValue('U3', 'Наш Двор');
+        $sheet->setCellValue('V3', 'да');
+        $sheet->setCellValue('W3', 1418500);
+        $sheet->setCellValue('X3', 1489425);
+        $sheet->setCellValue('Y3', 1560350);
+        $sheet->setCellValue('Z3', 2127750);
+        $sheet->setCellValue('AA3', 2837000);
+        $sheet->setCellValue('AB3', 3404400);
+        $sheet->setCellValue('AC3', 1560350);
+        $sheet->setCellValue('AD3', 1888023.50);
+
+        if ($duplicateVariant) {
+            for ($index = 1; $index <= 30; $index++) {
+                $column = Coordinate::stringFromColumnIndex($index);
+                $sheet->setCellValue("{$column}4", $sheet->getCell("{$column}3")->getValue());
+            }
+            $sheet->setCellValue('B4', $calculatorName.' (Цинк)');
+            $sheet->setCellValue('V4', 'нет');
+            $sheet->setCellValue('W4', 1622800);
+        }
 
         if ($withImage) {
             $image = UploadedFile::fake()->image('catalog-item.png', 100, 100);
             $drawing = new Drawing;
             $drawing->setPath($image->getPathname());
-            $drawing->setCoordinates('B3');
+            $drawing->setCoordinates('S3');
             $drawing->setWorksheet($sheet);
         }
 

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

@@ -0,0 +1,114 @@
+<?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);
+    }
+
+    public function test_import_does_not_choose_arbitrary_catalog_variant_for_duplicate_article(): void
+    {
+        Storage::fake('upload');
+        $user = User::factory()->create();
+        CommonCatalogItem::factory()->create([
+            'article' => '00125',
+            'calculator_name' => 'Обычное исполнение',
+        ]);
+        CommonCatalogItem::factory()->create([
+            'article' => '00125',
+            'calculator_name' => 'Исполнение Цинк',
+        ]);
+        $import = $this->createImport($user, 'Заказан', 5, 'Ожидаем поставку');
+
+        $this->assertTrue($this->service($import, $user)->handle());
+
+        $this->assertDatabaseCount('stock_orders', 0);
+        $this->assertSame('DONE', $import->refresh()->status);
+        $this->assertStringContainsString(
+            'соответствует нескольким вариантам',
+            (string) $import->result,
+        );
+    }
+
+    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),
+        );
+    }
+}