Преглед изворни кода

implemented documentation module

Alexander Musikhin пре 3 дана
родитељ
комит
bc7b821e2d

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

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

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

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

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

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

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

+ 8 - 0
config/access.php

@@ -127,6 +127,14 @@ return [
             'documents.delete' => 'Удаление документов',
         ],
     ],
+    'documents' => [
+        'name' => 'Документация',
+        'entity' => 'documentation_folder',
+        'actions' => [
+            'view' => 'Просмотр',
+            'update' => 'Управление корневыми папками и правами',
+        ],
+    ],
     'maf' => [
         'name' => 'МАФ',
         'entity' => 'product_sku',

+ 4 - 1
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,
@@ -77,6 +76,10 @@ return [
             'documents.upload' => 'common-catalog.documents.upload',
             'documents.delete' => 'common-catalog.documents.delete',
         ],
+        'documents.' => [
+            'folders.permissions.update' => 'documents.update',
+            '*' => 'documents.view',
+        ],
         'contract.' => [
             'index' => 'contracts.view',
             'show' => 'contracts.view',

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

+ 1 - 0
database/seeders/RbacSeeder.php

@@ -121,6 +121,7 @@ class RbacSeeder extends Seeder
             'areas.ajax.view',
             'catalog.search',
             'common-catalog.view',
+            'documents.view',
             'pricing_codes.search',
             'filters.view',
         ];

+ 4 - 2
docs/refactor/menu.md

@@ -154,9 +154,11 @@ Manager
 
 ### Документация
 
-Статус: **Нужно реализовать**.
+Статус: **Реализовано и проверено пользователем**.
 
-Новый раздел для папок и файлов: инструкции, прайсы, прочая документация. На первом этапе нужна заглушка.
+Рабочий раздел для инструкций, прайсов и прочих файлов. Реализованы дерево папок,
+наследуемые права чтения/записи для всех, ролей и пользователей, поиск,
+приватное хранение файлов и управление версиями документов.
 
 ### Технич. описание
 

+ 11 - 10
docs/refactor/plan.md

@@ -73,7 +73,7 @@
 | 2. Адаптация существующих разделов после переноса | Реализован и проверен | Рабочие разделы и их отображение приняты по результатам пользовательской проверки. |
 | 3. Рекламации: вкладки и тип | Реализован, ожидает пользовательской проверки | Добавлены вкладки, справочник типов, перенос существующих данных и запрет платежных документов для `Прочее`; создание из графика остается в этапе 10. |
 | 4. Общий каталог: ядро | Реализован и проверен | Пользователь проверил создание, редактирование, импорт и экспорт; отдельная модель данных, права, фото и документы покрыты автоматическими тестами. |
-| 5. Документация | Готов к реализации | Решены дерево папок, наследование прав, версии и хранение файлов. |
+| 5. Документация | Реализован и проверен | Пользователь проверил дерево папок, работу с документами и версиями; права и приватное хранение дополнительно покрыты автоматическими тестами. |
 | 6. Склад наличие: ядро | Готов к реализации | Ядро общего каталога доступно как источник позиций; PDF-экспорт остается отдельным открытым подпунктом. |
 | 7. Техническое описание | Готово к анализу Laravel-модуля | Место в карточке общего каталога подготовлено, но нужно получить модуль и форматы выгрузки. |
 | 8. Калькуляции | Не готово к полной реализации | Нужен контракт внешнего API. Можно сделать только место/кнопку в карточке общего каталога. |
@@ -158,16 +158,17 @@
 
 ## Этап 5. Документация
 
-Статус: **готов к реализации**.
+Статус: **реализован и проверен пользователем**.
 
-- [ ] Реализовать дерево папок.
-- [ ] Разрешить создание корневых папок только админу.
-- [ ] Реализовать права чтения и записи на папки для всех, ролей и отдельных пользователей.
-- [ ] Реализовать наследование прав от родительской папки при создании подпапок.
-- [ ] Реализовать создание, просмотр, редактирование и удаление документов.
-- [ ] Реализовать версионность документов.
-- [ ] Реализовать полное удаление отдельных версий.
-- [ ] Использовать текущий механизм хранения файлов.
+- [x] Реализовать дерево папок.
+- [x] Разрешить создание корневых папок только админу.
+- [x] Реализовать права чтения и записи на папки для всех, ролей и отдельных пользователей.
+- [x] Реализовать наследование прав от родительской папки при создании подпапок.
+- [x] Реализовать создание, просмотр, редактирование и удаление документов.
+- [x] Отображать иконки файлов по MIME-типу и открывать изображения в защищённом предпросмотре.
+- [x] Реализовать версионность документов.
+- [x] Реализовать полное удаление отдельных версий.
+- [x] Использовать текущий механизм хранения файлов; сами файлы документации хранить на приватном диске и выдавать только после проверки ACL.
 
 ## Этап 6. Склад наличие: ядро
 

+ 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-типу и защищённый предпросмотр изображений;
 - переименование документа;
 - удаление документа;
 - загрузка новой версии документа;

+ 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

+ 5 - 3
resources/views/layouts/menu.blade.php

@@ -119,9 +119,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>

+ 15 - 4
routes/web.php

@@ -10,6 +10,7 @@ use App\Http\Controllers\AreaController;
 use App\Http\Controllers\ClearDataController;
 use App\Http\Controllers\CommonCatalogController;
 use App\Http\Controllers\ContractorController;
+use App\Http\Controllers\DocumentationController;
 use App\Http\Controllers\YearDataController;
 use App\Http\Controllers\ContractController;
 use App\Http\Controllers\FilterController;
@@ -190,10 +191,20 @@ Route::middleware(['auth:web', 'route.permission'])->group(function () {
             ->name('orders');
     });
 
-    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', 'Калькуляции')

+ 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('Администратор')