Quellcode durchsuchen

Merge branch 'dev' into flex-roles

Alexander Musikhin vor 5 Tagen
Ursprung
Commit
18a6d77b93

+ 8 - 0
.env.example

@@ -35,6 +35,14 @@ DB_BACKUP_ENABLED=false
 DB_BACKUP_TIME=02:00
 DB_BACKUP_KEEP=7
 DB_BACKUP_CONNECTION=mysql
+GENERATED_DOCUMENTS_RETENTION_DAYS=14
+IMPORT_FILES_RETENTION_DAYS=14
+GENERATED_DOCUMENTS_TEMP_RETENTION_HOURS=24
+GENERATED_DOCUMENTS_CLEANUP_ENABLED=true
+GENERATED_DOCUMENTS_CLEANUP_TIME=03:30
+DISK_SPACE_WARNING_PATH=/var/www/storage
+DISK_SPACE_WARNING_THRESHOLD_GB=1
+DISK_SPACE_WARNING_CACHE_SECONDS=300
 
 BROADCAST_DRIVER=redis
 CACHE_DRIVER=redis

+ 389 - 0
app/Console/Commands/CleanupGeneratedDocuments.php

@@ -0,0 +1,389 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Console\Commands;
+
+use App\Models\File;
+use App\Models\Import;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+
+class CleanupGeneratedDocuments extends Command
+{
+    protected $signature = 'documents:cleanup-generated
+                            {--days= : Удалять сгенерированные документы старше N дней}
+                            {--import-days= : Удалять загруженные файлы импорта старше N дней}
+                            {--temp-hours= : Удалять временные ZIP-файлы из системного tmp старше N часов}
+                            {--dry-run : Показать, что будет удалено, без удаления}';
+
+    protected $description = 'Удаляет старые сгенерированные документы, не затрагивая пользовательские загрузки';
+
+    public function handle(): int
+    {
+        $days = $this->retentionDays();
+        if ($days < 1) {
+            $this->error('Срок хранения должен быть больше 0 дней.');
+            return self::FAILURE;
+        }
+
+        $cutoff = now()->subDays($days);
+        $dryRun = (bool) $this->option('dry-run');
+
+        $deletedRows = 0;
+        $deletedFiles = 0;
+
+        [$fileRows, $storedFiles] = $this->cleanupGeneratedFileRecords($cutoff, $dryRun, $days);
+        $deletedRows += $fileRows;
+        $deletedFiles += $storedFiles;
+
+        $deletedFiles += $this->cleanupOrphanGeneratedArchives($cutoff, $dryRun);
+        $deletedFiles += $this->cleanupImportFiles($dryRun);
+        $deletedFiles += $this->cleanupSystemTempZipFiles($dryRun);
+
+        if ($dryRun) {
+            $this->info('Проверка завершена. Данные не изменены.');
+            return self::SUCCESS;
+        }
+
+        $this->info("Очистка завершена. Удалено записей: {$deletedRows}. Удалено файлов: {$deletedFiles}.");
+
+        return self::SUCCESS;
+    }
+
+    /**
+     * @return array{0:int,1:int}
+     */
+    private function cleanupGeneratedFileRecords($cutoff, bool $dryRun, int $days): array
+    {
+        $deletedRows = 0;
+        $deletedFiles = 0;
+
+        $query = File::query()
+            ->where('is_generated', true)
+            ->where('created_at', '<', $cutoff)
+            ->orderBy('id');
+
+        $total = (clone $query)->count();
+        if ($total === 0) {
+            $this->info("Сгенерированных документов старше {$days} дн. в БД нет.");
+            return [0, 0];
+        }
+
+        $this->info(($dryRun ? 'Проверка' : 'Очистка') . ": найдено {$total} записей сгенерированных файлов старше " . $cutoff->toDateTimeString());
+
+        $query->chunkById(100, function ($files) use ($dryRun, &$deletedFiles, &$deletedRows): void {
+            foreach ($files as $file) {
+                $paths = $this->storagePaths($file);
+                $this->line(($dryRun ? 'Будет удалён' : 'Удаляется') . ": #{$file->id} {$file->original_name}");
+
+                if ($dryRun) {
+                    foreach ($paths as $path) {
+                        $this->line("  {$path}");
+                    }
+                    continue;
+                }
+
+                DB::table('order_document')->where('file_id', $file->id)->delete();
+                DB::table('reclamation_document')->where('file_id', $file->id)->delete();
+                DB::table('chat_message_file')->where('file_id', $file->id)->delete();
+
+                foreach ($paths as $path) {
+                    if (Storage::disk('public')->exists($path)) {
+                        Storage::disk('public')->delete($path);
+                        $deletedFiles++;
+                    }
+                }
+
+                $file->delete();
+                $deletedRows++;
+            }
+        });
+
+        return [$deletedRows, $deletedFiles];
+    }
+
+    private function retentionDays(): int
+    {
+        $option = $this->option('days');
+        if (is_numeric($option)) {
+            return (int) $option;
+        }
+
+        return (int) config('documents.generated_retention_days', 14);
+    }
+
+    private function importRetentionDays(): int
+    {
+        $option = $this->option('import-days');
+        if (is_numeric($option)) {
+            return (int) $option;
+        }
+
+        return (int) config('documents.import_retention_days', 14);
+    }
+
+    private function tempRetentionHours(): int
+    {
+        $option = $this->option('temp-hours');
+        if (is_numeric($option)) {
+            return (int) $option;
+        }
+
+        return (int) config('documents.temp_file_retention_hours', 24);
+    }
+
+    private function tempDirectory(): string
+    {
+        return (string) config('documents.temp_directory', sys_get_temp_dir());
+    }
+
+    /**
+     * @return list<string>
+     */
+    private function storagePaths(File $file): array
+    {
+        $paths = [];
+
+        foreach ([$file->path, $this->pathFromLink((string) $file->link)] as $path) {
+            if (!is_string($path) || $path === '') {
+                continue;
+            }
+
+            $paths[] = $this->normalizePublicPath($path);
+
+            if (str_contains($path, '/tmp/')) {
+                $paths[] = $this->normalizePublicPath(Str::replace('/tmp/', '/', $path));
+            }
+        }
+
+        return array_values(array_unique(array_filter($paths)));
+    }
+
+    private function pathFromLink(string $link): ?string
+    {
+        $path = parse_url($link, PHP_URL_PATH);
+        if (!is_string($path) || $path === '') {
+            return null;
+        }
+
+        $marker = '/storage/';
+        $position = strpos($path, $marker);
+        if ($position === false) {
+            return null;
+        }
+
+        return substr($path, $position + strlen($marker));
+    }
+
+    private function normalizePublicPath(string $path): string
+    {
+        $path = str_replace('\\', '/', $path);
+        $marker = 'app/public/';
+        $position = strpos($path, $marker);
+
+        if ($position !== false) {
+            $path = substr($path, $position + strlen($marker));
+        }
+
+        return ltrim($path, '/');
+    }
+
+    private function cleanupOrphanGeneratedArchives($cutoff, bool $dryRun): int
+    {
+        $knownPaths = $this->knownGeneratedPublicPaths();
+        $deleted = 0;
+        $candidates = array_values(array_filter(
+            Storage::disk('public')->allFiles(),
+            fn (string $path): bool => $this->isGeneratedArchivePath($path)
+        ));
+
+        foreach ($candidates as $path) {
+            if (isset($knownPaths[$path])) {
+                continue;
+            }
+
+            if (Storage::disk('public')->lastModified($path) >= $cutoff->timestamp) {
+                continue;
+            }
+
+            $this->line(($dryRun ? 'Будет удалён сиротский архив' : 'Удаляется сиротский архив') . ": {$path}");
+
+            if (!$dryRun) {
+                Storage::disk('public')->delete($path);
+            }
+
+            $deleted++;
+        }
+
+        if ($deleted === 0) {
+            $this->info('Сиротских сгенерированных архивов старше срока хранения нет.');
+        }
+
+        return $deleted;
+    }
+
+    /**
+     * @return array<string,true>
+     */
+    private function knownGeneratedPublicPaths(): array
+    {
+        $paths = [];
+
+        File::query()
+            ->where('is_generated', true)
+            ->select(['id', 'path', 'link'])
+            ->chunkById(500, function ($files) use (&$paths): void {
+                foreach ($files as $file) {
+                    foreach ($this->storagePaths($file) as $path) {
+                        $paths[$path] = true;
+                    }
+                }
+            });
+
+        return $paths;
+    }
+
+    private function isGeneratedArchivePath(string $path): bool
+    {
+        if (!Str::endsWith(Str::lower($path), '.zip')) {
+            return false;
+        }
+
+        return (bool) preg_match('#^(orders/\d+/[^/]+|reclamations/\d+/[^/]+|files/[^/]+/[^/]+|export/(?:orders|order|schedule)/[^/]+)\.zip$#u', $path);
+    }
+
+    private function cleanupImportFiles(bool $dryRun): int
+    {
+        $days = $this->importRetentionDays();
+        if ($days < 1) {
+            $this->warn('Срок хранения импортов меньше 1 дня, очистка импортов пропущена.');
+            return 0;
+        }
+
+        $cutoff = now()->subDays($days);
+        $knownPaths = [];
+        $deleted = 0;
+
+        Import::query()
+            ->whereNotNull('filename')
+            ->select(['id', 'filename', 'created_at'])
+            ->orderBy('id')
+            ->chunkById(500, function ($imports) use (&$knownPaths, &$deleted, $cutoff, $dryRun): void {
+                foreach ($imports as $import) {
+                    $path = trim((string) $import->filename, '/');
+                    if ($path === '') {
+                        continue;
+                    }
+
+                    $knownPaths[$path] = true;
+                    if ($import->created_at >= $cutoff) {
+                        continue;
+                    }
+
+                    if (!Storage::disk('upload')->exists($path)) {
+                        continue;
+                    }
+
+                    $this->line(($dryRun ? 'Будет удалён файл импорта' : 'Удаляется файл импорта') . ": {$path}");
+
+                    if (!$dryRun) {
+                        Storage::disk('upload')->delete($path);
+                    }
+
+                    $deleted++;
+                }
+            });
+
+        foreach (Storage::disk('upload')->allFiles() as $path) {
+            if (isset($knownPaths[$path])) {
+                continue;
+            }
+
+            if (!$this->isImportUploadPath($path)) {
+                continue;
+            }
+
+            if (Storage::disk('upload')->lastModified($path) >= $cutoff->timestamp) {
+                continue;
+            }
+
+            $this->line(($dryRun ? 'Будет удалён сиротский файл импорта' : 'Удаляется сиротский файл импорта') . ": {$path}");
+
+            if (!$dryRun) {
+                Storage::disk('upload')->delete($path);
+            }
+
+            $deleted++;
+        }
+
+        if ($deleted === 0) {
+            $this->info("Файлов импорта старше {$days} дн. нет.");
+        }
+
+        return $deleted;
+    }
+
+    private function isImportUploadPath(string $path): bool
+    {
+        return (bool) preg_match('#^(?:[A-Za-z0-9]{2}/[^/]+|import/(?:areas|districts|year_data)/[^/]+)$#', $path);
+    }
+
+    private function cleanupSystemTempZipFiles(bool $dryRun): int
+    {
+        $hours = $this->tempRetentionHours();
+        if ($hours < 1) {
+            $this->warn('Срок хранения системных временных файлов меньше 1 часа, очистка /tmp пропущена.');
+            return 0;
+        }
+
+        $cutoffTimestamp = now()->subHours($hours)->timestamp;
+        $deleted = 0;
+        $paths = glob(rtrim($this->tempDirectory(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . '*') ?: [];
+
+        foreach ($paths as $path) {
+            if (!is_file($path) || filemtime($path) === false || filemtime($path) >= $cutoffTimestamp) {
+                continue;
+            }
+
+            if (!$this->looksLikeZipFile($path)) {
+                continue;
+            }
+
+            $this->line(($dryRun ? 'Будет удалён временный ZIP' : 'Удаляется временный ZIP') . ": {$path}");
+
+            if (!$dryRun && @unlink($path)) {
+                $deleted++;
+                continue;
+            }
+
+            if ($dryRun) {
+                $deleted++;
+            }
+        }
+
+        if ($deleted === 0) {
+            $this->info("Временных ZIP-файлов в /tmp старше {$hours} ч. нет.");
+        }
+
+        return $deleted;
+    }
+
+    private function looksLikeZipFile(string $path): bool
+    {
+        $handle = @fopen($path, 'rb');
+        if ($handle === false) {
+            return false;
+        }
+
+        try {
+            $signature = fread($handle, 4);
+        } finally {
+            fclose($handle);
+        }
+
+        return in_array($signature, ["PK\x03\x04", "PK\x05\x06", "PK\x07\x08"], true);
+    }
+}

+ 42 - 2
app/Http/Controllers/ContractorController.php

@@ -25,6 +25,16 @@ class ContractorController extends Controller
         'hidden_txt' => 'Скрыт',
     ];
     protected array $searchFields = ['name', 'legal_name', 'contract_number', 'director_name'];
+    protected array $priceHeader = [
+        'image' => 'Картинка МАФ',
+        'article' => 'Артикул МАФ',
+        'nomenclature_number' => 'Номер номенклатуры',
+        'name_in_spec' => 'Наименование по спецификации',
+        'installation_price_txt' => 'Цена монтажа',
+        'status_name' => 'Статус',
+        'actions' => '',
+    ];
+    protected array $priceSearchFields = ['article', 'nomenclature_number', 'name_in_spec'];
 
     public function index(Request $request): View
     {
@@ -92,6 +102,11 @@ class ContractorController extends Controller
             'product_id' => ['required', 'integer', 'exists:products,id'],
             'name_in_spec' => ['nullable', 'string', 'max:255'],
             'price' => ['nullable', 'numeric', 'min:0'],
+            'nav' => ['nullable', 'string'],
+            'filters' => ['nullable', 'array'],
+            's' => ['nullable', 'string'],
+            'sortBy' => ['nullable', 'string'],
+            'order' => ['nullable', 'string'],
         ]);
 
         $product = Product::withoutGlobalScope(YearScope::class)->withTrashed()->findOrFail($validated['product_id']);
@@ -103,7 +118,15 @@ class ContractorController extends Controller
             (float) ($validated['price'] ?? 0),
         );
 
-        return redirect()->route('contractors.show', $contractor)->with('success', 'Цена монтажа сохранена');
+        return redirect()
+            ->route('contractors.show', array_filter([
+                'contractor' => $contractor,
+                'nav' => $validated['nav'] ?? null,
+                's' => $validated['s'] ?? null,
+                'sortBy' => $validated['sortBy'] ?? null,
+                'order' => $validated['order'] ?? null,
+            ]) + $request->only('filters'))
+            ->with('success', 'Цена монтажа сохранена');
     }
 
     public function importPrices(Request $request, Contractor $contractor, ContractorPriceService $priceService): RedirectResponse
@@ -137,14 +160,31 @@ class ContractorController extends Controller
         $nav = $this->resolveNavToken($request);
         $this->rememberNavigation($request, $nav);
         $year = year();
+        $filters = $request->input('filters', []);
+        if (!is_array($filters)) {
+            $filters = [];
+        }
+        $allowedSortFields = array_keys($this->priceHeader);
+        $priceSortBy = in_array($request->get('sortBy'), $allowedSortFields, true) ? $request->get('sortBy') : 'article';
+        $priceOrderBy = $request->get('order') === 'desc' ? 'desc' : 'asc';
+        $allPriceRows = $contractor ? $priceService->rowsForContractor($contractor, $year) : collect();
+        $priceRows = $contractor
+            ? $priceService->rowsForContractor($contractor, $year, $filters, $request->string('s')->toString(), $priceSortBy, $priceOrderBy)
+            : collect();
 
         return view('contractors.edit', [
             'active' => 'contractors',
             'contractor' => $contractor,
             'organizationForms' => Contractor::ORGANIZATION_FORMS,
             'taxRates' => Contractor::TAX_RATES,
-            'priceRows' => $contractor ? $priceService->rowsForContractor($contractor, $year) : collect(),
+            'priceRows' => $priceRows,
             'catalogYear' => $year,
+            'priceHeader' => $this->priceHeader,
+            'priceSearchFields' => $this->priceSearchFields,
+            'priceSortBy' => $priceSortBy,
+            'priceOrderBy' => $priceOrderBy,
+            'priceFilters' => $priceService->filterOptionsForRows($allPriceRows, $this->priceHeader),
+            'priceRanges' => [],
             'nav' => $nav,
             'back_url' => $this->navigationBackUrl($request, $nav, route('contractors.index', session('gp_contractors'))),
         ]);

+ 2 - 1
app/Http/Controllers/OrderController.php

@@ -565,7 +565,8 @@ class OrderController extends Controller
     public function uploadPhoto(Request $request, Order $order, FileService $fileService)
     {
         $data = $request->validate([
-            'photo.*' => 'mimes:jpeg,jpg,png,webp',
+            'photo' => 'required|array',
+            'photo.*' => 'file|mimes:jpeg,jpg,png,webp',
         ]);
 
         try {

+ 20 - 21
app/Http/Controllers/ReportController.php

@@ -27,7 +27,6 @@ class ReportController extends Controller
 
     public function index()
     {
-        $mountStatuses = [5, 7, 8, 9, 10];
         $installedStatuses = [
             Order::STATUS_READY_TO_HAND_OVER,
             Order::STATUS_NOT_HANDED_OVER_WITH_NOTES,
@@ -47,7 +46,7 @@ class ReportController extends Controller
         // всего заказов, выполнено заказов
         $this->data['totalOrders']  = Order::all()->count();
         $this->data['doneOrders']  = Order::query()->whereIn('order_status_id', $doneStatuses)->count();
-        $this->data['mountOrders']  = Order::query()->whereIn('order_status_id', $mountStatuses)->count();
+        $this->data['mountOrders']  = Order::query()->whereIn('order_status_id', $installedStatuses)->count();
         $this->data['installedOrders'] = Order::query()->whereIn('order_status_id', $installedStatuses)->count();
 
         // всего маф / завершено маф / установлено маф
@@ -57,8 +56,8 @@ class ReportController extends Controller
                 $query->whereIn('order_status_id', $doneStatuses);
             })->count();
         $this->data['mountMafs']    = ProductSKU::query()->
-        whereHas('order', function ($query) use ($mountStatuses){
-            $query->whereIn('order_status_id', $mountStatuses);
+        whereHas('order', function ($query) use ($installedStatuses){
+            $query->whereIn('order_status_id', $installedStatuses);
         })->count();
         $this->data['installedMafs'] = ProductSKU::query()
             ->whereHas('order', function ($query) use ($installedStatuses) {
@@ -110,27 +109,27 @@ class ReportController extends Controller
                     $query->where('object_type_id', '=', $objectTypeId);
                 })->count();
 
-            // со статусами: в монтаже, готова к сдаче, не сдана замечания, сдана замечания, сдана - зеленый цвет
+            // Установленные: статусы после выхода из "В монтаже".
 
             $this->data['mountOrdersType'][$objectTypeId]  = Order::query()
                 ->where('object_type_id', '=', $objectTypeId)
-                ->whereIn('order_status_id', $mountStatuses)
+                ->whereIn('order_status_id', $installedStatuses)
                 ->count();
             $this->data['mountMafsType'][$objectTypeId]     = ProductSKU::query()->
-            whereHas('order', function ($query) use ($objectTypeId, $mountStatuses) {
+            whereHas('order', function ($query) use ($objectTypeId, $installedStatuses) {
                 $query->where('object_type_id', '=', $objectTypeId)
-                ->whereIn('order_status_id', $mountStatuses);
+                ->whereIn('order_status_id', $installedStatuses);
             })->count();
 
             // остальные - не готовы
             $this->data['notMountOrdersType'][$objectTypeId]  = Order::query()
                 ->where('object_type_id', '=', $objectTypeId)
-                ->whereNotIn('order_status_id', $mountStatuses)
+                ->whereNotIn('order_status_id', $installedStatuses)
                 ->count();
             $this->data['notMountMafsType'][$objectTypeId]     = ProductSKU::query()->
-            whereHas('order', function ($query) use ($objectTypeId, $mountStatuses) {
+            whereHas('order', function ($query) use ($objectTypeId, $installedStatuses) {
                 $query->where('object_type_id', '=', $objectTypeId)
-                    ->whereNotIn('order_status_id', $mountStatuses);
+                    ->whereNotIn('order_status_id', $installedStatuses);
             })->count();
 
         }
@@ -214,24 +213,24 @@ class ReportController extends Controller
 
             $totalYardOrders = $this->getOrderCount($districtId, null, 1);
             $totalYardMafs = $this->getMafCount($districtId, null, 1);
-            $mountYardOrders = $this->getOrderCount($districtId, $mountStatuses, 1);
-            $mountYardMafs = $this->getMafCount($districtId, $mountStatuses, 1);
+            $mountYardOrders = $this->getOrderCount($districtId, $installedStatuses, 1);
+            $mountYardMafs = $this->getMafCount($districtId, $installedStatuses, 1);
             $ostYardOrders = $totalYardOrders - $mountYardOrders;
             $ostYardMafs = $totalYardMafs - $mountYardMafs;
 
             $totalEduOrders = $this->getOrderCount($districtId, null, 2);
             $totalEduMafs = $this->getMafCount($districtId, null, 2);
-            $mountEduOrders = $this->getOrderCount($districtId, $mountStatuses, 2);
-            $mountEduMafs = $this->getMafCount($districtId, $mountStatuses, 2);
-            $ostEduOrders = $totalYardOrders - $mountYardOrders;
-            $ostEduMafs = $totalYardMafs - $mountYardMafs;
+            $mountEduOrders = $this->getOrderCount($districtId, $installedStatuses, 2);
+            $mountEduMafs = $this->getMafCount($districtId, $installedStatuses, 2);
+            $ostEduOrders = $totalEduOrders - $mountEduOrders;
+            $ostEduMafs = $totalEduMafs - $mountEduMafs;
 
             $totalZnakOrders = $this->getOrderCount($districtId, null, 3);
             $totalZnakMafs = $this->getMafCount($districtId, null, 3);
-            $mountZnakOrders = $this->getOrderCount($districtId, $mountStatuses, 3);
-            $mountZnakMafs = $this->getMafCount($districtId, $mountStatuses, 3);
-            $ostZnakOrders = $totalYardOrders - $mountYardOrders;
-            $ostZnakMafs = $totalYardMafs - $mountYardMafs;
+            $mountZnakOrders = $this->getOrderCount($districtId, $installedStatuses, 3);
+            $mountZnakMafs = $this->getMafCount($districtId, $installedStatuses, 3);
+            $ostZnakOrders = $totalZnakOrders - $mountZnakOrders;
+            $ostZnakMafs = $totalZnakMafs - $mountZnakMafs;
             $this->data['byDistrict'][$districtId] = [
                 'name'  => $district,
                 'totalSum' => $this->getDistrictSum($districtId),

+ 5 - 0
app/Models/File.php

@@ -16,12 +16,17 @@ class File extends Model
         'mime_type',
         'path',
         'link',
+        'is_generated',
     ];
 
     protected $appends = [
         'name',
     ];
 
+    protected $casts = [
+        'is_generated' => 'boolean',
+    ];
+
     public function user(): BelongsTo
     {
         return $this->belongsTo(User::class);

+ 9 - 0
app/Providers/AppServiceProvider.php

@@ -4,9 +4,11 @@ namespace App\Providers;
 
 use App\Models\SparePartOrder;
 use App\Observers\SparePartOrderObserver;
+use App\Services\DiskSpaceMonitor;
 use Illuminate\Pagination\Paginator;
 use Illuminate\Support\Facades\Blade;
 use Illuminate\Support\Facades\URL;
+use Illuminate\Support\Facades\View;
 use Illuminate\Support\ServiceProvider;
 
 class AppServiceProvider extends ServiceProvider
@@ -37,5 +39,12 @@ class AppServiceProvider extends ServiceProvider
 
         // Регистрация Observer для автоматической обработки дефицитов
         SparePartOrder::observe(SparePartOrderObserver::class);
+
+        View::composer('layouts.app', function ($view): void {
+            $diskSpaceMonitor = app(DiskSpaceMonitor::class);
+
+            $view->with('diskSpaceStatus', $diskSpaceMonitor->status());
+            $view->with('diskSpaceMonitor', $diskSpaceMonitor);
+        });
     }
 }

+ 159 - 12
app/Services/ContractorPriceService.php

@@ -6,8 +6,10 @@ use App\Models\Contractor;
 use App\Models\ContractorInstallationPrice;
 use App\Models\Product;
 use App\Models\Scopes\YearScope;
+use Illuminate\Support\Arr;
 use Illuminate\Http\UploadedFile;
 use Illuminate\Support\Collection;
+use stdClass;
 use PhpOffice\PhpSpreadsheet\IOFactory;
 use PhpOffice\PhpSpreadsheet\Spreadsheet;
 use PhpOffice\PhpSpreadsheet\Style\Border;
@@ -16,7 +18,54 @@ use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
 
 class ContractorPriceService
 {
-    public function rowsForContractor(Contractor $contractor, int $year): Collection
+    public function rowsForContractor(
+        Contractor $contractor,
+        int $year,
+        array $filters = [],
+        string $search = '',
+        string $sortBy = 'article',
+        string $orderBy = 'asc'
+    ): Collection
+    {
+        return $this->sortRows(
+            $this->applySearch($this->applyFilters($this->baseRowsForContractor($contractor, $year), $filters), $search),
+            $sortBy,
+            $orderBy,
+        )->values();
+    }
+
+    public function filterOptionsForRows(Collection $rows, array $header): array
+    {
+        return [
+            'article' => [
+                'title' => $header['article'],
+                'values' => $this->uniqueFilterValues($rows, 'article'),
+            ],
+            'nomenclature_number' => [
+                'title' => $header['nomenclature_number'],
+                'values' => $this->uniqueFilterValues($rows, 'nomenclature_number'),
+            ],
+            'name_in_spec' => [
+                'title' => $header['name_in_spec'],
+                'values' => $this->uniqueFilterValues($rows, 'name_in_spec'),
+            ],
+            'installation_price_txt' => [
+                'title' => $header['installation_price_txt'],
+                'values' => $this->uniqueFilterValues($rows, 'installation_price_filter'),
+            ],
+            'status_name' => [
+                'title' => $header['status_name'],
+                'values' => [
+                    'Доступен' => 'Доступен',
+                    'МАФ недоступен' => 'МАФ недоступен',
+                    'С ценой' => 'С ценой',
+                    'Без цены' => 'Без цены',
+                ],
+            ],
+        ];
+    }
+
+    private function baseRowsForContractor(Contractor $contractor, int $year): Collection
     {
         $currentProducts = Product::query()
             ->where('year', $year)
@@ -47,6 +96,17 @@ class ContractorPriceService
         return $rows;
     }
 
+    private function uniqueFilterValues(Collection $rows, string $field): array
+    {
+        return $rows
+            ->pluck($field)
+            ->map(fn($value) => $value === null || $value === '' ? '-пусто-' : (string) $value)
+            ->unique()
+            ->sort(SORT_NATURAL)
+            ->mapWithKeys(fn($value) => [$value => $value])
+            ->all();
+    }
+
     public function updatePrice(Contractor $contractor, Product $product, int $year, ?string $nameInSpec, float $price): ContractorInstallationPrice
     {
         return ContractorInstallationPrice::query()->updateOrCreate(
@@ -137,11 +197,11 @@ class ContractorPriceService
 
         $rowNumber = 2;
         foreach ($this->rowsForContractor($contractor, $year) as $row) {
-            $this->insertProductImage($sheet, $row['product'], $rowNumber);
-            $sheet->setCellValue('B' . $rowNumber, $row['product']->article);
-            $sheet->setCellValue('C' . $rowNumber, $row['product']->nomenclature_number);
-            $sheet->setCellValue('D' . $rowNumber, $row['price']?->name_in_spec ?? '');
-            $sheet->setCellValue('E' . $rowNumber, $row['price']?->price ?? 0);
+            $this->insertProductImage($sheet, $row->product, $rowNumber);
+            $sheet->setCellValue('B' . $rowNumber, $row->product->article);
+            $sheet->setCellValue('C' . $rowNumber, $row->product->nomenclature_number);
+            $sheet->setCellValue('D' . $rowNumber, $row->price?->name_in_spec ?? '');
+            $sheet->setCellValue('E' . $rowNumber, $row->price?->price ?? 0);
             $sheet->getRowDimension($rowNumber)->setRowHeight(55);
             $rowNumber++;
         }
@@ -161,13 +221,100 @@ class ContractorPriceService
         return $path;
     }
 
-    private function buildRow(Product $product, ?ContractorInstallationPrice $price, bool $available): array
+    private function buildRow(Product $product, ?ContractorInstallationPrice $price, bool $available): stdClass
     {
-        return [
-            'product' => $product,
-            'price' => $price,
-            'available' => $available && is_null($product->deleted_at),
-        ];
+        $row = new stdClass();
+        $row->id = $product->id;
+        $row->product = $product;
+        $row->price = $price;
+        $row->product_id = $product->id;
+        $row->image = $product->image;
+        $row->article = $product->article;
+        $row->nomenclature_number = $product->nomenclature_number;
+        $row->name_in_spec = $price?->name_in_spec ?? '';
+        $row->installation_price = (float) ($price?->price ?? 0);
+        $row->installation_price_txt = $price?->price_txt ?? '0.00₽';
+        $row->installation_price_filter = str_replace('&nbsp;', ' ', $row->installation_price_txt);
+        $row->available = $available && is_null($product->deleted_at);
+        $row->status_name = $row->available ? 'Доступен' : 'МАФ недоступен';
+
+        return $row;
+    }
+
+    private function applyFilters(Collection $rows, array $filters): Collection
+    {
+        $article = trim((string) ($filters['article'] ?? ''));
+        $nomenclatureNumber = trim((string) ($filters['nomenclature_number'] ?? ''));
+        $nameInSpec = trim((string) ($filters['name_in_spec'] ?? ''));
+        $installationPrice = trim((string) ($filters['installation_price_txt'] ?? ''));
+        $status = (string) ($filters['status_name'] ?? '');
+
+        return $rows->filter(function (stdClass $row) use ($article, $nomenclatureNumber, $nameInSpec, $installationPrice, $status): bool {
+            if (!$this->matchesListFilter((string) $row->article, $article)) {
+                return false;
+            }
+
+            if (!$this->matchesListFilter((string) $row->nomenclature_number, $nomenclatureNumber)) {
+                return false;
+            }
+
+            if (!$this->matchesListFilter((string) $row->name_in_spec, $nameInSpec)) {
+                return false;
+            }
+
+            if (!$this->matchesListFilter((string) $row->installation_price_filter, $installationPrice)) {
+                return false;
+            }
+
+            return match ($status) {
+                'С ценой' => $row->installation_price > 0,
+                'Без цены' => $row->installation_price <= 0,
+                'Доступен' => (bool) $row->available,
+                'МАФ недоступен' => !$row->available,
+                default => true,
+            };
+        });
+    }
+
+    private function matchesListFilter(string $value, string $filter): bool
+    {
+        if ($filter === '') {
+            return true;
+        }
+
+        $normalizedValue = $value === '' ? '-пусто-' : $value;
+        $values = explode('||', $filter);
+
+        return in_array($normalizedValue, $values, true);
+    }
+
+    private function applySearch(Collection $rows, string $search): Collection
+    {
+        $search = trim(mb_strtolower($search));
+        if ($search === '') {
+            return $rows;
+        }
+
+        return $rows->filter(fn(stdClass $row) => str_contains(mb_strtolower((string) $row->article), $search)
+            || str_contains(mb_strtolower((string) $row->nomenclature_number), $search)
+            || str_contains(mb_strtolower((string) $row->name_in_spec), $search));
+    }
+
+    private function sortRows(Collection $rows, string $sortBy, string $orderBy): Collection
+    {
+        $sortField = match ($sortBy) {
+            'installation_price_txt' => 'installation_price',
+            'status_name' => 'status_name',
+            'nomenclature_number' => 'nomenclature_number',
+            'name_in_spec' => 'name_in_spec',
+            default => 'article',
+        };
+
+        return $rows->sortBy(
+            fn(stdClass $row) => Arr::get((array) $row, $sortField),
+            SORT_REGULAR,
+            $orderBy === 'desc',
+        );
     }
 
     private function parsePrice(mixed $value): float

+ 81 - 0
app/Services/DiskSpaceMonitor.php

@@ -0,0 +1,81 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services;
+
+use Illuminate\Support\Facades\Cache;
+use Throwable;
+
+class DiskSpaceMonitor
+{
+    private const CACHE_KEY = 'disk_space_monitor.status';
+
+    /**
+     * @return array{warning: bool, free_bytes: int|null, threshold_bytes: int, path: string, checked_at: string|null}
+     */
+    public function status(): array
+    {
+        try {
+            return Cache::remember(
+                self::CACHE_KEY,
+                now()->addSeconds($this->cacheSeconds()),
+                fn (): array => $this->freshStatus()
+            );
+        } catch (Throwable) {
+            return $this->freshStatus();
+        }
+    }
+
+    public function formatBytes(?int $bytes): string
+    {
+        if ($bytes === null) {
+            return 'неизвестно';
+        }
+
+        $units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ'];
+        $value = (float) $bytes;
+        $unitIndex = 0;
+
+        while ($value >= 1024 && $unitIndex < count($units) - 1) {
+            $value /= 1024;
+            $unitIndex++;
+        }
+
+        return sprintf('%s %s', round($value, $unitIndex === 0 ? 0 : 2), $units[$unitIndex]);
+    }
+
+    /**
+     * @return array{warning: bool, free_bytes: int|null, threshold_bytes: int, path: string, checked_at: string|null}
+     */
+    private function freshStatus(): array
+    {
+        $path = $this->path();
+        $thresholdBytes = $this->thresholdBytes();
+        $freeBytes = @disk_free_space($path);
+        $freeBytes = $freeBytes === false ? null : (int) $freeBytes;
+
+        return [
+            'warning' => $freeBytes !== null && $freeBytes < $thresholdBytes,
+            'free_bytes' => $freeBytes,
+            'threshold_bytes' => $thresholdBytes,
+            'path' => $path,
+            'checked_at' => now()->toDateTimeString(),
+        ];
+    }
+
+    private function path(): string
+    {
+        return (string) config('app.disk_space_warning.path', storage_path());
+    }
+
+    private function thresholdBytes(): int
+    {
+        return max(0, (int) config('app.disk_space_warning.threshold_bytes', 1024 * 1024 * 1024));
+    }
+
+    private function cacheSeconds(): int
+    {
+        return max(1, (int) config('app.disk_space_warning.cache_seconds', 300));
+    }
+}

+ 29 - 14
app/Services/FileService.php

@@ -4,8 +4,10 @@ namespace App\Services;
 
 use App\Models\File;
 use Exception;
+use FilesystemIterator;
 use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Str;
+use RecursiveDirectoryIterator;
 use RecursiveIteratorIterator;
 use RuntimeException;
 use ZipArchive;
@@ -114,44 +116,57 @@ class FileService
      * @return File
      * @throws Exception
      */
-    public function createZipArchive(string $path, string $archiveName, int $userId = null): File
+    public function createZipArchive(string $path, string $archiveName, ?int $userId = null): File
     {
+        $fullPath = storage_path('app/public/' . $path);
+        $archivePath = $this->archivePath($path, $archiveName);
+        $archiveFullPath = Storage::disk('public')->path($archivePath);
+        Storage::disk('public')->makeDirectory(dirname($archivePath));
 
-        if (!($tempFile = tempnam(sys_get_temp_dir(), Str::random()))) {
-            throw new Exception('Cant create temporary file!');
+        $zip = new ZipArchive();
+        if ($zip->open($archiveFullPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
+            throw new Exception('Cant create zip archive!');
         }
 
-        $zip = new ZipArchive();
-        $fullPath = storage_path('app/public/' . $path);
-        $zip->open($tempFile, ZipArchive::CREATE | ZipArchive::OVERWRITE);
         $files = new RecursiveIteratorIterator(
-            new \RecursiveDirectoryIterator($fullPath),
+            new RecursiveDirectoryIterator($fullPath, FilesystemIterator::SKIP_DOTS),
             RecursiveIteratorIterator::LEAVES_ONLY
         );
+
         foreach ($files as $file) {
             $filePath = $file->getRealPath();
             $relativePath = substr($filePath, strlen($fullPath) + 1);
-            if(!$file->isDir()) {
+            if (!$file->isDir()) {
                 $zip->addFile($filePath, $relativePath);
             } else {
                 $zip->addEmptyDir($relativePath);
             }
         }
-        $zip->close();
 
+        $zip->close();
         $fileModel = File::query()->updateOrCreate([
-            'link' => url('/storage/') . '/' . Str::replace('/tmp', '', $path) . '/' .$archiveName,
-            'path' => $path . '/' .$archiveName,
+            'link' => url('/storage/') . '/' . $archivePath,
+        ], [
+            'path' => $archivePath,
             'user_id' => $userId ?? auth()->user()->id,
             'original_name' => $archiveName,
             'mime_type' => 'application/zip',
+            'is_generated' => true,
         ]);
-        $contents = file_get_contents($tempFile);
-        Storage::disk('public')->put($path . '/../' .$archiveName, $contents);
-        unlink($tempFile);
 
         return $fileModel;
     }
 
+    private function archivePath(string $path, string $archiveName): string
+    {
+        $normalizedPath = trim($path, '/');
+
+        if (Str::endsWith($normalizedPath, '/tmp')) {
+            $normalizedPath = (string) Str::beforeLast($normalizedPath, '/tmp');
+        }
+
+        return $normalizedPath . '/' . $archiveName;
+    }
+
 
 }

+ 163 - 137
app/Services/GenerateDocumentsService.php

@@ -18,6 +18,7 @@ use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Str;
 use PhpOffice\PhpSpreadsheet\IOFactory;
+use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
 use PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
 use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
 
@@ -48,42 +49,47 @@ class GenerateDocumentsService
             ->where('year', $order->year)
             ->get();
         $order->setRelation('products_sku', $products_sku);
-        $articles = [];
-        Storage::disk('public')->makeDirectory('orders/' . $order->id . '/tmp/Схемы сборки/');
-
-        foreach ($products_sku as $sku) {
-            if (!in_array($sku->product->article, $articles)) {
-                $articles[] = $sku->product->article;
-                // find and copy scheme files to installation directory
-                if (file_exists($techDocsPath . $sku->product->article . '/')) {
-                    foreach (Storage::disk('base')->allFiles('public/images/tech-docs/' . $sku->product->article) as $p) {
-                        $content = Storage::disk('base')->get($p);
-                        Storage::disk('public')->put('orders/' . $order->id . '/tmp/Схемы сборки/' . basename($p), $content);
+        $tmpRoot = 'orders/' . $order->id . '/tmp';
+
+        try {
+            $articles = [];
+            Storage::disk('public')->makeDirectory($tmpRoot . '/Схемы сборки/');
+
+            foreach ($products_sku as $sku) {
+                if (!in_array($sku->product->article, $articles)) {
+                    $articles[] = $sku->product->article;
+                    // find and copy scheme files to installation directory
+                    if (file_exists($techDocsPath . $sku->product->article . '/')) {
+                        foreach (Storage::disk('base')->allFiles('public/images/tech-docs/' . $sku->product->article) as $p) {
+                            $content = Storage::disk('base')->get($p);
+                            Storage::disk('public')->put($tmpRoot . '/Схемы сборки/' . basename($p), $content);
+                        }
                     }
                 }
             }
-        }
 
-        $orderDocumentsDir = 'orders/' . $order->id . '/tmp/Документы площадки';
-        foreach ($order->documents as $document) {
-            if ($this->shouldSkipArchiveDocument($document)) {
-                continue;
+            $orderDocumentsDir = $tmpRoot . '/Документы площадки';
+            foreach ($order->documents as $document) {
+                if ($this->shouldSkipArchiveDocument($document)) {
+                    continue;
+                }
+                $this->copyFileToDir($document->path, $orderDocumentsDir, $document->original_name);
             }
-            $this->copyFileToDir($document->path, $orderDocumentsDir, $document->original_name);
-        }
 
-        // generate xlsx order file
-        $this->generateOrderForMount($order);
+            // generate xlsx order file
+            $this->generateOrderForMount($order);
 
-        // create zip archive
-        $fileModel = (new FileService())->createZipArchive('orders/' . $order->id . '/tmp', self::INSTALL_FILENAME . fileName($order->common_name) . '.zip', $userId);
+            // create zip archive
+            $fileModel = (new FileService())->createZipArchive($tmpRoot, self::INSTALL_FILENAME . fileName($order->common_name) . '.zip', $userId);
 
-        // remove temp files
-        Storage::disk('public')->deleteDirectory('orders/' . $order->id . '/tmp');
-        $order->documents()->syncWithoutDetaching($fileModel);
+            $order->documents()->syncWithoutDetaching($fileModel);
 
-        // return link
-        return $fileModel?->link ?? '';
+            // return link
+            return $fileModel?->link ?? '';
+        } finally {
+            // remove temp files
+            Storage::disk('public')->deleteDirectory($tmpRoot);
+        }
     }
 
     private function generateOrderForMount(Order $order): void
@@ -142,61 +148,65 @@ class GenerateDocumentsService
             ->get();
         $order->setRelation('products_sku', $productsSku);
 
-        $articles = [];
-        Storage::disk('public')->makeDirectory('orders/' . $order->id . '/tmp/ПАСПОРТ/');
-        Storage::disk('public')->makeDirectory('orders/' . $order->id . '/tmp/СЕРТИФИКАТ/');
-        Storage::disk('public')->makeDirectory('orders/' . $order->id . '/tmp/ФОТО ПСТ/');
-        Storage::disk('public')->makeDirectory('orders/' . $order->id . '/tmp/ФОТО ТН/');
-
-        // copy app photos
-        foreach ($order->photos as $photo) {
-            $from = $photo->path;
-            $to = 'orders/' . $order->id . '/tmp/ФОТО ПСТ/' . $photo->original_name;
-            if (!Storage::disk('public')->exists($to)) {
-                Storage::disk('public')->copy($from, $to);
-            }
-        }
+        $tmpRoot = 'orders/' . $order->id . '/tmp';
+
+        try {
+            Storage::disk('public')->makeDirectory($tmpRoot . '/ПАСПОРТ/');
+            Storage::disk('public')->makeDirectory($tmpRoot . '/СЕРТИФИКАТ/');
+            Storage::disk('public')->makeDirectory($tmpRoot . '/ФОТО ПСТ/');
+            Storage::disk('public')->makeDirectory($tmpRoot . '/ФОТО ТН/');
 
-        foreach ($productsSku as $sku) {
-            // copy certificates
-            if ($sku->product->certificate_id) {
-                $from = $sku->product->certificate->path;
-                $to = 'orders/' . $order->id . '/tmp/СЕРТИФИКАТ/' . $sku->product->certificate->original_name;
+            // copy app photos
+            foreach ($order->photos as $photo) {
+                $from = $photo->path;
+                $to = $tmpRoot . '/ФОТО ПСТ/' . $photo->original_name;
                 if (!Storage::disk('public')->exists($to)) {
                     Storage::disk('public')->copy($from, $to);
                 }
             }
 
-            // copy passport
-            if ($sku->passport_id) {
-                $from = $sku->passport->path;
-                $to = 'orders/' . $order->id . '/tmp/ПАСПОРТ/';
-                if (!Storage::disk('public')->exists($to . $sku->passport->original_name)) {
-                    $f = Storage::disk('public')->get($from);
-                    $ext = \File::extension($sku->passport->original_name);
-                    $targetName = $to . 'Паспорт ' . $sku->factory_number . ' арт. ' . $sku->product->article . '.' . $ext;
-                    Storage::disk('public')->put($targetName, $f);
+            foreach ($productsSku as $sku) {
+                // copy certificates
+                if ($sku->product->certificate_id) {
+                    $from = $sku->product->certificate->path;
+                    $to = $tmpRoot . '/СЕРТИФИКАТ/' . $sku->product->certificate->original_name;
+                    if (!Storage::disk('public')->exists($to)) {
+                        Storage::disk('public')->copy($from, $to);
+                    }
                 }
-            }
 
-        }
+                // copy passport
+                if ($sku->passport_id) {
+                    $from = $sku->passport->path;
+                    $to = $tmpRoot . '/ПАСПОРТ/';
+                    if (!Storage::disk('public')->exists($to . $sku->passport->original_name)) {
+                        $f = Storage::disk('public')->get($from);
+                        $ext = \File::extension($sku->passport->original_name);
+                        $targetName = $to . 'Паспорт ' . $sku->factory_number . ' арт. ' . $sku->product->article . '.' . $ext;
+                        Storage::disk('public')->put($targetName, $f);
+                    }
+                }
 
-        // generate xlsx order files
-        $this->generateStatement($order);
-        $this->generateQualityDeclaration($order);
-        $this->generateInventory($order);
-        $this->generatePassport($order);
+            }
+
+            // generate xlsx order files
+            $this->generateStatement($order);
+            $this->generateQualityDeclaration($order);
+            $this->generateInventory($order);
+            $this->generatePassport($order);
 
 
-        // create zip archive
-        $fileModel = (new FileService())->createZipArchive('orders/' . $order->id . '/tmp', self::HANDOVER_FILENAME . fileName($order->common_name) . '.zip', $userId);
+            // create zip archive
+            $fileModel = (new FileService())->createZipArchive($tmpRoot, self::HANDOVER_FILENAME . fileName($order->common_name) . '.zip', $userId);
 
-        // remove temp files
-        Storage::disk('public')->deleteDirectory('orders/' . $order->id . '/tmp');
-        $order->documents()->syncWithoutDetaching($fileModel);
+            $order->documents()->syncWithoutDetaching($fileModel);
 
-        // return link
-        return $fileModel?->link ?? '';
+            // return link
+            return $fileModel?->link ?? '';
+        } finally {
+            // remove temp files
+            Storage::disk('public')->deleteDirectory($tmpRoot);
+        }
     }
 
     private function generateStatement(Order $order): void
@@ -248,21 +258,25 @@ class GenerateDocumentsService
 
     private function generateQualityDeclaration(Order $order): void
     {
+        $rowsPerDeclaration = 57;
         $inputFileType = 'Xlsx';
         $inputFileName = './templates/QualityDeclaration.xlsx';
 
         $reader = IOFactory::createReader($inputFileType);
         $spreadsheet = $reader->load($inputFileName);
         $sheet = $spreadsheet->getActiveSheet();
+        $sheet->getPageSetup()->setFitToWidth(1);
+        $sheet->getPageSetup()->setFitToHeight(0);
 
         $address = 'г. Москва, ' . $order->district->shortname . ', район ' . $order->area->name . ', по адресу: ' . (filled(trim((string) $order->name)) ? $order->name : $order->object_address);
         $i = 1; // start of table
         $n = 1;
         foreach ($order->products_sku as $sku) {
             if ($n++ > 1) {
-                $range = 'A' . $i . ':I' . $i + 56;
-                $i = $i + 57;
+                $range = 'A' . $i . ':I' . ($i + $rowsPerDeclaration - 1);
+                $i += $rowsPerDeclaration;
                 ExcelHelper::copyRows($sheet, $range, 'A' . $i);
+                $sheet->setBreak('A' . ($i - 1), Worksheet::BREAK_ROW);
 
                 // add header img
                 $drawing = new Drawing();
@@ -279,16 +293,15 @@ class GenerateDocumentsService
             }
 
             // document date?
-            $sheet->setCellValue('A' . $i + 7, DateHelper::getHumanDate(date('Y-m-d'), true));
+            $sheet->setCellValue('A' . ($i + 7), DateHelper::getHumanDate(date('Y-m-d'), true));
 
-            $sheet->setCellValue('B' . $i + 16, $sku->product->passport_name);
-            $sheet->setCellValue('C' . $i + 18, $sku->rfid);
-            $sheet->setCellValue('C' . $i + 20, $address);
-
-            // add page break and copy prev page
+            $sheet->setCellValue('B' . ($i + 16), $sku->product->passport_name);
+            $sheet->setCellValue('C' . ($i + 18), $sku->rfid);
+            $sheet->setCellValue('C' . ($i + 20), $address);
 
 
         }
+        $sheet->getPageSetup()->setPrintArea('A1:I' . ($i + $rowsPerDeclaration - 1));
 
         // save file
         $fileName = '2.Декларация качества.xlsx';
@@ -390,39 +403,44 @@ class GenerateDocumentsService
         $baseDir = 'reclamations/' . $reclamation->id . '/tmp/' . fileName($reclamation->order->object_address);
         $photosDir = $baseDir . '/ФОТО НАРУШЕНИЯ';
 
-        Storage::disk('public')->makeDirectory($photosDir);
+        $tmpRoot = 'reclamations/' . $reclamation->id . '/tmp';
 
-        // copy photos
-        foreach ($reclamation->photos_before as $photo) {
-            $from = $photo->path;
-            $to = $photosDir . '/' . $photo->original_name;
-            if (!Storage::disk('public')->exists($to)) {
-                Storage::disk('public')->copy($from, $to);
-            }
-        }
+        try {
+            Storage::disk('public')->makeDirectory($photosDir);
 
-        foreach ($reclamation->documents as $document) {
-            if ($this->shouldSkipArchiveDocument($document)) {
-                continue;
+            // copy photos
+            foreach ($reclamation->photos_before as $photo) {
+                $from = $photo->path;
+                $to = $photosDir . '/' . $photo->original_name;
+                if (!Storage::disk('public')->exists($to)) {
+                    Storage::disk('public')->copy($from, $to);
+                }
             }
 
-            $this->copyFileToDir($document->path, $baseDir, $document->original_name);
-        }
+            foreach ($reclamation->documents as $document) {
+                if ($this->shouldSkipArchiveDocument($document)) {
+                    continue;
+                }
+
+                $this->copyFileToDir($document->path, $baseDir, $document->original_name);
+            }
 
-        // create xls and pdf
-        $this->generateReclamationOrder($reclamation);
-        $this->generateReclamationAct($reclamation);
-        $this->generateReclamationGuarantee($reclamation);
+            // create xls and pdf
+            $this->generateReclamationOrder($reclamation);
+            $this->generateReclamationAct($reclamation);
+            $this->generateReclamationGuarantee($reclamation);
 
-        // create zip archive
-        $fileModel = (new FileService())->createZipArchive('reclamations/' . $reclamation->id . '/tmp', self::RECLAMATION_FILENAME . fileName($reclamation->order->object_address) . '.zip', $userId);
+            // create zip archive
+            $fileModel = (new FileService())->createZipArchive($tmpRoot, self::RECLAMATION_FILENAME . fileName($reclamation->order->object_address) . '.zip', $userId);
 
-        // remove temp files
-        Storage::disk('public')->deleteDirectory('reclamations/' . $reclamation->id . '/tmp');
-        $reclamation->documents()->syncWithoutDetaching($fileModel);
+            $reclamation->documents()->syncWithoutDetaching($fileModel);
 
-        // return link
-        return $fileModel?->link ?? '';
+            // return link
+            return $fileModel?->link ?? '';
+        } finally {
+            // remove temp files
+            Storage::disk('public')->deleteDirectory($tmpRoot);
+        }
     }
 
     /**
@@ -443,34 +461,37 @@ class GenerateDocumentsService
         $beforeDir = $baseDir . '/Фотографии проблемы';
         $afterDir = $baseDir . '/Фотографии после устранения';
 
-        Storage::disk('public')->makeDirectory($beforeDir);
-        Storage::disk('public')->makeDirectory($afterDir);
+        try {
+            Storage::disk('public')->makeDirectory($beforeDir);
+            Storage::disk('public')->makeDirectory($afterDir);
 
-        $this->copyPhotosWithEmptyFallback($reclamation->photos_before, $beforeDir);
-        $this->copyPhotosWithEmptyFallback($reclamation->photos_after, $afterDir);
+            $this->copyPhotosWithEmptyFallback($reclamation->photos_before, $beforeDir);
+            $this->copyPhotosWithEmptyFallback($reclamation->photos_after, $afterDir);
 
-        foreach ($reclamation->documents as $document) {
-            if ($this->shouldSkipDocumentForPaymentPack($document)) {
-                continue;
+            foreach ($reclamation->documents as $document) {
+                if ($this->shouldSkipDocumentForPaymentPack($document)) {
+                    continue;
+                }
+                $this->copyFileToDir($document->path, $baseDir, $document->original_name);
             }
-            $this->copyFileToDir($document->path, $baseDir, $document->original_name);
-        }
 
-        foreach ($reclamation->acts as $act) {
-            $this->copyFileToDir($act->path, $baseDir, $act->original_name);
-        }
+            foreach ($reclamation->acts as $act) {
+                $this->copyFileToDir($act->path, $baseDir, $act->original_name);
+            }
 
-        foreach ($reclamation->order?->statements ?? [] as $statement) {
-            $this->copyFileToDir($statement->path, $baseDir, $statement->original_name);
-        }
+            foreach ($reclamation->order?->statements ?? [] as $statement) {
+                $this->copyFileToDir($statement->path, $baseDir, $statement->original_name);
+            }
 
-        $archiveName = 'Пакет документов на оплату - ' . fileName($reclamation->order->object_address) . '.zip';
-        $fileModel = (new FileService())->createZipArchive($tmpRoot, $archiveName, $userId);
+            $archiveName = 'Пакет документов на оплату - ' . fileName($reclamation->order->object_address) . '.zip';
+            $fileModel = (new FileService())->createZipArchive($tmpRoot, $archiveName, $userId);
 
-        Storage::disk('public')->deleteDirectory($tmpRoot);
-        $reclamation->documents()->syncWithoutDetaching($fileModel);
+            $reclamation->documents()->syncWithoutDetaching($fileModel);
 
-        return $fileModel?->link ?? '';
+            return $fileModel?->link ?? '';
+        } finally {
+            Storage::disk('public')->deleteDirectory($tmpRoot);
+        }
     }
 
     /**
@@ -610,25 +631,30 @@ class GenerateDocumentsService
     public function generateFilePack(Collection $files, int $userId, string $name = 'files'): \App\Models\File
     {
         $dir = Str::random(2);
-        Storage::disk('public')->makeDirectory('files/' . $dir . '/tmp/');
-
-        // copy files
-        foreach ($files as $file) {
-            $from = $file->path;
-            $to = 'files/' . $dir . '/tmp/' . $file->original_name;
-            if (!Storage::disk('public')->exists($to)) {
-                Storage::disk('public')->copy($from, $to);
+        $tmpRoot = 'files/' . $dir . '/tmp';
+
+        try {
+            Storage::disk('public')->makeDirectory($tmpRoot . '/');
+
+            // copy files
+            foreach ($files as $file) {
+                $from = $file->path;
+                $to = $tmpRoot . '/' . $file->original_name;
+                if (!Storage::disk('public')->exists($to)) {
+                    Storage::disk('public')->copy($from, $to);
+                }
             }
-        }
 
-        // create zip archive
-        $fileModel = (new FileService())->createZipArchive('files/' . $dir . '/tmp', $name .'_' . date('Y-m-d_h-i-s') . '.zip', $userId);
+            // create zip archive
+            $fileModel = (new FileService())->createZipArchive($tmpRoot, $name .'_' . date('Y-m-d_h-i-s') . '.zip', $userId);
 
-        // remove temp files
-        Storage::disk('public')->deleteDirectory('files/' . $dir . '/tmp');
+            // return link
+            return $fileModel;
+        } finally {
+            // remove temp files
+            Storage::disk('public')->deleteDirectory($tmpRoot);
+        }
 
-        // return link
-        return $fileModel;
     }
 
     private function copyPhotosWithEmptyFallback(Collection $photos, string $targetDir): void

+ 11 - 2
app/Services/PdfConverterClient.php

@@ -22,7 +22,16 @@ class PdfConverterClient
         if(!file_exists($filePath)) {
             throw new Exception("File does not exist");
         }
-        $response = Http::attach('file', file_get_contents($filePath), '123.xlsx')->post(self::CONVERTER_ADDRESS);
+        $file = fopen($filePath, 'rb');
+        if ($file === false) {
+            throw new Exception("Cant open file");
+        }
+
+        try {
+            $response = Http::attach('file', $file, '123.xlsx')->post(self::CONVERTER_ADDRESS);
+        } finally {
+            fclose($file);
+        }
 
         if($response->successful()) {
             $newFilename = Str::replace('.xlsx', '.pdf', $filePath);
@@ -33,4 +42,4 @@ class PdfConverterClient
     }
 
 
-}
+}

+ 9 - 0
config/app.php

@@ -129,4 +129,13 @@ return [
 
     'default_maf_order_user_id' => env('APP_DEFAULT_MAF_ORDER_USER_ID', 1),
 
+    'disk_space_warning' => [
+        'path' => env('DISK_SPACE_WARNING_PATH', storage_path()),
+        'threshold_bytes' => (int) env(
+            'DISK_SPACE_WARNING_THRESHOLD_BYTES',
+            (int) env('DISK_SPACE_WARNING_THRESHOLD_GB', 1) * 1024 * 1024 * 1024
+        ),
+        'cache_seconds' => (int) env('DISK_SPACE_WARNING_CACHE_SECONDS', 300),
+    ],
+
 ];

+ 10 - 0
config/documents.php

@@ -0,0 +1,10 @@
+<?php
+
+return [
+    'generated_retention_days' => (int) env('GENERATED_DOCUMENTS_RETENTION_DAYS', 14),
+    'import_retention_days' => (int) env('IMPORT_FILES_RETENTION_DAYS', env('GENERATED_DOCUMENTS_RETENTION_DAYS', 14)),
+    'temp_file_retention_hours' => (int) env('GENERATED_DOCUMENTS_TEMP_RETENTION_HOURS', 24),
+    'temp_directory' => env('GENERATED_DOCUMENTS_TEMP_DIRECTORY', sys_get_temp_dir()),
+    'cleanup_enabled' => filter_var(env('GENERATED_DOCUMENTS_CLEANUP_ENABLED', true), FILTER_VALIDATE_BOOL),
+    'cleanup_time' => env('GENERATED_DOCUMENTS_CLEANUP_TIME', '03:30'),
+];

+ 41 - 0
database/migrations/2026_05_15_000001_add_is_generated_to_files_table.php

@@ -0,0 +1,41 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::table('files', function (Blueprint $table) {
+            $table->boolean('is_generated')->default(false)->after('link');
+            $table->index(['is_generated', 'created_at']);
+        });
+
+        DB::table('files')
+            ->where(function ($query) {
+                $query
+                    ->where('path', 'like', '%/tmp/%.zip')
+                    ->orWhere('original_name', 'like', 'Монтаж %.zip')
+                    ->orWhere('original_name', 'like', 'Сдача %.zip')
+                    ->orWhere('original_name', 'like', 'Рекламация %.zip')
+                    ->orWhere('original_name', 'like', 'Пакет документов на оплату - %.zip');
+            })
+            ->where(function ($query) {
+                $query
+                    ->where('mime_type', 'application/zip')
+                    ->orWhere('mime_type', 'like', '%zip%');
+            })
+            ->update(['is_generated' => true]);
+    }
+
+    public function down(): void
+    {
+        Schema::table('files', function (Blueprint $table) {
+            $table->dropIndex(['is_generated', 'created_at']);
+            $table->dropColumn('is_generated');
+        });
+    }
+};

+ 10 - 0
resources/sass/app.scss

@@ -677,6 +677,16 @@ td p {
   background: #dc3545;
 }
 
+.disk-space-warning {
+  width: 100%;
+  padding: 0.75rem 1rem;
+  background: #dc3545;
+  color: #fff;
+  font-size: 0.95rem;
+  line-height: 1.35;
+  text-align: center;
+}
+
 .notifications-list {
   display: flex;
   flex-direction: column;

+ 42 - 59
resources/views/contractors/edit.blade.php

@@ -1,6 +1,11 @@
 @extends('layouts.app')
 
 @section('content')
+    @php
+        $hasCatalogFilters = collect(request()->filters ?? [])
+            ->filter(fn ($value) => $value !== null && $value !== '')
+            ->isNotEmpty() || filled(request()->s) || filled(request()->sortBy) || filled(request()->order);
+    @endphp
     <div class="px-3">
         <h4 class="mb-4">{{ $contractor ? 'Редактирование подрядчика' : 'Добавление подрядчика' }}</h4>
 
@@ -32,21 +37,24 @@
 
         <ul class="nav nav-tabs mb-3" role="tablist">
             <li class="nav-item" role="presentation">
-                <button class="nav-link active" id="contractor-main-tab" data-bs-toggle="tab" data-bs-target="#contractor-main-pane" type="button" role="tab">
+                <button class="nav-link @unless($hasCatalogFilters) active @endunless" id="contractor-main-tab" data-bs-toggle="tab" data-bs-target="#contractor-main-pane" type="button" role="tab">
                     Карточка
                 </button>
             </li>
             @if($contractor)
                 <li class="nav-item" role="presentation">
-                    <button class="nav-link" id="contractor-prices-tab" data-bs-toggle="tab" data-bs-target="#contractor-prices-pane" type="button" role="tab">
+                    <button class="nav-link @if($hasCatalogFilters) active @endif" id="contractor-prices-tab" data-bs-toggle="tab" data-bs-target="#contractor-prices-pane" type="button" role="tab">
                         Цены монтажа
+                        @if($hasCatalogFilters)
+                            <span class="badge text-bg-primary ms-1">фильтр</span>
+                        @endif
                     </button>
                 </li>
             @endif
         </ul>
 
         <div class="tab-content">
-            <div class="tab-pane fade show active" id="contractor-main-pane" role="tabpanel" aria-labelledby="contractor-main-tab">
+            <div class="tab-pane fade @unless($hasCatalogFilters) show active @endunless" id="contractor-main-pane" role="tabpanel" aria-labelledby="contractor-main-tab">
                 <div class="col-xxl-7 offset-xxl-1">
                     <form action="{{ $contractor ? route('contractors.update', $contractor) : route('contractors.store') }}" method="post">
                         @csrf
@@ -74,7 +82,7 @@
             </div>
 
             @if($contractor)
-                <div class="tab-pane fade" id="contractor-prices-pane" role="tabpanel" aria-labelledby="contractor-prices-tab">
+                <div class="tab-pane fade @if($hasCatalogFilters) show active @endif" id="contractor-prices-pane" role="tabpanel" aria-labelledby="contractor-prices-tab">
                     <div class="row mb-3 align-items-end">
                         <div class="col-md-3">
                             <div class="text-muted small">
@@ -92,61 +100,19 @@
                         </div>
                     </div>
 
-                    <div class="table-responsive">
-                        <table class="table table-sm table-bordered align-middle">
-                            <thead class="table-primary">
-                            <tr>
-                                <th>Картинка МАФ</th>
-                                <th>Артикул МАФ</th>
-                                <th>Номер номенклатуры</th>
-                                <th>Наименование по спецификации</th>
-                                <th>Цена монтажа</th>
-                                <th>Статус</th>
-                                <th></th>
-                            </tr>
-                            </thead>
-                            <tbody>
-                            @foreach($priceRows as $row)
-                                @php
-                                    $product = $row['product'];
-                                    $price = $row['price'];
-                                @endphp
-                                <tr>
-                                    <td style="width: 120px">
-                                        @if($product->image)
-                                            <img src="{{ $product->image }}" alt="" class="img-thumbnail maf-img">
-                                        @endif
-                                    </td>
-                                    <td>{{ $product->article }}</td>
-                                    <td>{{ $product->nomenclature_number }}</td>
-                                    <td>{{ $price?->name_in_spec }}</td>
-                                    <td>{{ $price?->price_txt ?? '0.00₽' }}</td>
-                                    <td>
-                                        @if($row['available'])
-                                            <span class="badge text-bg-success">Доступен</span>
-                                        @else
-                                            <span class="badge text-bg-warning">МАФ недоступен</span>
-                                        @endif
-                                    </td>
-                                    <td class="text-end">
-                                        <button
-                                            type="button"
-                                            class="btn btn-sm btn-outline-primary edit-price"
-                                            data-bs-toggle="modal"
-                                            data-bs-target="#editPriceModal"
-                                            data-product-id="{{ $product->id }}"
-                                            data-article="{{ $product->article }}"
-                                            data-name="{{ e($price?->name_in_spec ?? '') }}"
-                                            data-price="{{ $price?->price ?? 0 }}"
-                                        >
-                                            Изменить
-                                        </button>
-                                    </td>
-                                </tr>
-                            @endforeach
-                            </tbody>
-                        </table>
-                    </div>
+                    @include('partials.table', [
+                        'id' => 'contractor_prices',
+                        'header' => $priceHeader,
+                        'strings' => $priceRows,
+                        'searchFields' => $priceSearchFields,
+                        'sortBy' => $priceSortBy,
+                        'orderBy' => $priceOrderBy,
+                        'filters' => $priceFilters,
+                        'ranges' => $priceRanges,
+                        'dates' => [],
+                        'enableColumnFilters' => true,
+                        'nav' => $nav ?? null,
+                    ])
 
                     <div class="modal fade" id="editPriceModal" tabindex="-1" aria-labelledby="editPriceModalLabel" aria-hidden="true">
                         <div class="modal-dialog">
@@ -159,6 +125,23 @@
                                     <form action="{{ route('contractors.prices.update', $contractor) }}" method="post">
                                         @csrf
                                         <input type="hidden" name="product_id" id="price_product_id">
+                                        @if($nav ?? null)
+                                            <input type="hidden" name="nav" value="{{ $nav }}">
+                                        @endif
+                                        @if(request()->s)
+                                            <input type="hidden" name="s" value="{{ request()->s }}">
+                                        @endif
+                                        @if(request()->sortBy)
+                                            <input type="hidden" name="sortBy" value="{{ request()->sortBy }}">
+                                        @endif
+                                        @if(request()->order)
+                                            <input type="hidden" name="order" value="{{ request()->order }}">
+                                        @endif
+                                        @foreach(request()->filters ?? [] as $filterName => $filterValue)
+                                            @if($filterValue !== null && $filterValue !== '')
+                                                <input type="hidden" name="filters[{{ $filterName }}]" value="{{ $filterValue }}">
+                                            @endif
+                                        @endforeach
                                         <div class="mb-2 small text-muted" id="price_article"></div>
                                         @include('partials.input', ['name' => 'name_in_spec', 'title' => 'Наименование по спецификации'])
                                         @include('partials.input', ['name' => 'price', 'title' => 'Цена монтажа', 'type' => 'number', 'min' => '0'])

+ 8 - 0
resources/views/layouts/app.blade.php

@@ -78,6 +78,14 @@
     </div>
 
     <div id="app">
+        @if(($diskSpaceStatus['warning'] ?? false) === true)
+            <div class="disk-space-warning" role="alert">
+                <strong>Внимание: заканчивается место на диске.</strong>
+                Доступно {{ $diskSpaceMonitor->formatBytes($diskSpaceStatus['free_bytes'] ?? null) }}
+                из минимально необходимых {{ $diskSpaceMonitor->formatBytes($diskSpaceStatus['threshold_bytes'] ?? null) }}.
+            </div>
+        @endif
+
         <nav class="navbar navbar-expand-md navbar-light bg-white shadow-sm">
             <div class="container-fluid">
                 <a class="navbar-brand" href="{{ url('/') }}">

+ 22 - 18
resources/views/partials/newFilterElement.blade.php

@@ -114,7 +114,7 @@
                 const existingFilter = urlParams.searchParams.get(`filters[{{$id}}]`);
                 const selectedValues = existingFilter ? existingFilter.split("||") : null;
 
-                let filterData = [];
+                let filterData = @json(isset($data['values']) ? array_values(array_keys($data['values'])) : []);
                 let sortAsc = true;
 
                 function renderFilterList(data) {
@@ -146,25 +146,29 @@
                     });
                 }
 
-                try {
-                    const response = await fetch(`{!! route('getFilters', ['column' => $id, 'table' => $table]) !!}`);
-                    const data = await response.json();
+                if (filterData.length) {
+                    renderFilterList(sortData(sortAsc));
+                } else {
+                    try {
+                        const response = await fetch(`{!! route('getFilters', ['column' => $id, 'table' => $table]) !!}`);
+                        const data = await response.json();
+
+                        if (Array.isArray(data) && data.length) {
+                            if(data[0] === null) data[0] = '-пусто-';
+                            filterData = data;
+                            const sortedData = sortData(sortAsc);
+                            renderFilterList(sortedData);
+                        } else {
+                            $("#search_{{$id}}").parent().hide();
+                            $bulkToggle.hide();
+                            $("#modal-footer_{{$id}}").hide();
+                            $container.html('<div class="text-muted">Нет данных</div>');
+                        }
 
-                    if (Array.isArray(data) && data.length) {
-                        if(data[0] === null) data[0] = '-пусто-';
-                        filterData = data;
-                        const sortedData = sortData(sortAsc);
-                        renderFilterList(sortedData);
-                    } else {
-                        $("#search_{{$id}}").parent().hide();
-                        $bulkToggle.hide();
-                        $("#modal-footer_{{$id}}").hide();
-                        $container.html('<div class="text-muted">Нет данных</div>');
+                    } catch (error) {
+                        console.error("Ошибка при загрузке фильтров:", error);
+                        $container.html('<div class="text-danger">Ошибка загрузки</div>');
                     }
-
-                } catch (error) {
-                    console.error("Ошибка при загрузке фильтров:", error);
-                    $container.html('<div class="text-danger">Ошибка загрузки</div>');
                 }
 
                 $sortBtn.on("click", function (e) {

+ 21 - 0
resources/views/partials/table.blade.php

@@ -64,6 +64,8 @@
                                         data-bs-toggle="dropdown"
                                         class="dropdown-toggle bi
                                 @if(isset(request()->filters[$headerName]) ||
+                                    isset(request()->filters[$headerName . '_from']) ||
+                                    isset(request()->filters[$headerName . '_to']) ||
                                     isset(request()->filters[str_replace('_txt', '', $headerName) . '_from']) ||
                                     isset(request()->filters[str_replace('_txt', '', $headerName) . '_to'])
                                     )
@@ -221,6 +223,25 @@
                                 @method('DELETE')
                                 <button type="submit" class="btn btn-sm btn-danger">Удалить</button>
                             </form>
+                        @elseif($id === 'contractor_prices' && $headerName === 'status_name')
+                            @if($string->available)
+                                <span class="badge text-bg-success">Доступен</span>
+                            @else
+                                <span class="badge text-bg-warning">МАФ недоступен</span>
+                            @endif
+                        @elseif($id === 'contractor_prices' && $headerName === 'actions')
+                            <button
+                                type="button"
+                                class="btn btn-sm btn-outline-primary edit-price"
+                                data-bs-toggle="modal"
+                                data-bs-target="#editPriceModal"
+                                data-product-id="{{ $string->product_id }}"
+                                data-article="{{ $string->article }}"
+                                data-name="{{ e($string->name_in_spec) }}"
+                                data-price="{{ $string->installation_price }}"
+                            >
+                                Изменить
+                            </button>
                         @elseif($id === 'notifications' && $headerName === 'type')
                             <span class="badge text-bg-{{ \App\Models\UserNotification::TYPE_COLORS[$string->type] ?? 'secondary' }}">{{ $string->type_name }}</span>
                         @elseif($id === 'notifications' && $headerName === 'event')

+ 35 - 3
resources/views/reports/index.blade.php

@@ -313,12 +313,30 @@
     <script>
         const totalMafs = {{ $totalMafs }};
 
+        function formatMafPercent(value) {
+            const percent = totalMafs > 0 ? (value / totalMafs * 100) : 0;
+
+            return percent.toFixed(2).replace('.', ',');
+        }
+
+        function getBarValue(context) {
+            if (Array.isArray(context.raw)) {
+                return Math.abs(Number(context.raw[1] || 0) - Number(context.raw[0] || 0));
+            }
+
+            return Number(context.parsed?.y ?? context.parsed ?? 0);
+        }
+
         function mafTooltipLabel(context) {
             const value = Number(context.parsed || 0);
-            const percent = totalMafs > 0 ? (value / totalMafs * 100) : 0;
-            const formattedPercent = percent.toFixed(2).replace('.', ',');
 
-            return context.label + ': ' + value + ' МАФ (' + formattedPercent + '%)';
+            return context.label + ': ' + value + ' МАФ (' + formatMafPercent(value) + '%)';
+        }
+
+        function mafBarTooltipLabel(context) {
+            const value = getBarValue(context);
+
+            return context.dataset.label + ': ' + value + ' МАФ (' + formatMafPercent(value) + '%)';
         }
 
         const ctx = document.getElementById('order_types');
@@ -348,6 +366,13 @@
                 ]
             },
             options: {
+                plugins: {
+                    tooltip: {
+                        callbacks: {
+                            label: mafBarTooltipLabel
+                        }
+                    }
+                },
                 scales: {
                     y: {
                         beginAtZero: true
@@ -386,6 +411,13 @@
                 ]
             },
             options: {
+                plugins: {
+                    tooltip: {
+                        callbacks: {
+                            label: mafBarTooltipLabel
+                        }
+                    }
+                },
                 scales: {
                     y: {
                         beginAtZero: true

+ 5 - 0
routes/console.php

@@ -15,3 +15,8 @@ Schedule::command('db:backup:rotate', [
     ->dailyAt((string) env('DB_BACKUP_TIME', '02:00'))
     ->withoutOverlapping()
     ->when(static fn (): bool => filter_var(env('DB_BACKUP_ENABLED', false), FILTER_VALIDATE_BOOL));
+
+Schedule::command('documents:cleanup-generated')
+    ->dailyAt((string) config('documents.cleanup_time', '03:30'))
+    ->withoutOverlapping()
+    ->when(static fn (): bool => (bool) config('documents.cleanup_enabled', true));

+ 186 - 0
tests/Feature/CleanupGeneratedDocumentsCommandTest.php

@@ -0,0 +1,186 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\File;
+use App\Models\Import;
+use App\Models\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Artisan;
+use Illuminate\Support\Facades\Storage;
+use Tests\TestCase;
+
+class CleanupGeneratedDocumentsCommandTest extends TestCase
+{
+    use RefreshDatabase;
+
+    public function test_cleanup_generated_documents_removes_only_old_generated_files(): void
+    {
+        Storage::fake('public');
+
+        $user = User::factory()->create();
+
+        $generated = File::factory()->create([
+            'user_id' => $user->id,
+            'original_name' => 'Монтаж тест.zip',
+            'mime_type' => 'application/zip',
+            'path' => 'orders/10/Монтаж тест.zip',
+            'link' => url('/storage/orders/10/Монтаж тест.zip'),
+            'is_generated' => true,
+            'created_at' => now()->subDays(15),
+            'updated_at' => now()->subDays(15),
+        ]);
+
+        $uploaded = File::factory()->create([
+            'user_id' => $user->id,
+            'original_name' => 'manual.zip',
+            'mime_type' => 'application/zip',
+            'path' => 'orders/10/document/manual.zip',
+            'link' => url('/storage/orders/10/document/manual.zip'),
+            'is_generated' => false,
+            'created_at' => now()->subDays(30),
+            'updated_at' => now()->subDays(30),
+        ]);
+
+        $freshGenerated = File::factory()->create([
+            'user_id' => $user->id,
+            'original_name' => 'Сдача свежий.zip',
+            'mime_type' => 'application/zip',
+            'path' => 'orders/11/Сдача свежий.zip',
+            'link' => url('/storage/orders/11/Сдача свежий.zip'),
+            'is_generated' => true,
+            'created_at' => now()->subDays(2),
+            'updated_at' => now()->subDays(2),
+        ]);
+
+        Storage::disk('public')->put($generated->path, 'generated');
+        Storage::disk('public')->put($uploaded->path, 'uploaded');
+        Storage::disk('public')->put($freshGenerated->path, 'fresh');
+
+        $exitCode = Artisan::call('documents:cleanup-generated', ['--days' => 14]);
+
+        $this->assertSame(0, $exitCode);
+        $this->assertDatabaseMissing('files', ['id' => $generated->id]);
+        $this->assertDatabaseHas('files', ['id' => $uploaded->id]);
+        $this->assertDatabaseHas('files', ['id' => $freshGenerated->id]);
+        Storage::disk('public')->assertMissing($generated->path);
+        Storage::disk('public')->assertExists($uploaded->path);
+        Storage::disk('public')->assertExists($freshGenerated->path);
+    }
+
+    public function test_cleanup_generated_documents_removes_legacy_tmp_archive_path(): void
+    {
+        Storage::fake('public');
+
+        $user = User::factory()->create();
+        $file = File::factory()->create([
+            'user_id' => $user->id,
+            'original_name' => 'Рекламация тест.zip',
+            'mime_type' => 'application/zip',
+            'path' => 'reclamations/20/tmp/Рекламация тест.zip',
+            'link' => url('/storage/reclamations/20/Рекламация тест.zip'),
+            'is_generated' => true,
+            'created_at' => now()->subDays(15),
+            'updated_at' => now()->subDays(15),
+        ]);
+
+        Storage::disk('public')->put('reclamations/20/Рекламация тест.zip', 'legacy archive');
+
+        $exitCode = Artisan::call('documents:cleanup-generated', ['--days' => 14]);
+
+        $this->assertSame(0, $exitCode);
+        $this->assertDatabaseMissing('files', ['id' => $file->id]);
+        Storage::disk('public')->assertMissing('reclamations/20/Рекламация тест.zip');
+    }
+
+    public function test_cleanup_generated_documents_removes_old_orphan_generated_archive(): void
+    {
+        Storage::fake('public');
+        Storage::fake('upload');
+
+        Storage::disk('public')->put('orders/15/Монтаж сирота.zip', 'orphan archive');
+        touch(Storage::disk('public')->path('orders/15/Монтаж сирота.zip'), now()->subDays(20)->timestamp);
+
+        Storage::disk('public')->put('orders/15/document/manual.zip', 'manual archive');
+        touch(Storage::disk('public')->path('orders/15/document/manual.zip'), now()->subDays(20)->timestamp);
+
+        $exitCode = Artisan::call('documents:cleanup-generated', ['--days' => 14]);
+
+        $this->assertSame(0, $exitCode);
+        Storage::disk('public')->assertMissing('orders/15/Монтаж сирота.zip');
+        Storage::disk('public')->assertExists('orders/15/document/manual.zip');
+    }
+
+    public function test_cleanup_generated_documents_removes_old_import_files_and_orphans(): void
+    {
+        Storage::fake('public');
+        Storage::fake('upload');
+
+        $oldImport = Import::factory()->create([
+            'filename' => 'ab/old-import.xlsx',
+            'created_at' => now()->subDays(20),
+            'updated_at' => now()->subDays(20),
+        ]);
+        $freshImport = Import::factory()->create([
+            'filename' => 'cd/fresh-import.xlsx',
+            'created_at' => now()->subDays(2),
+            'updated_at' => now()->subDays(2),
+        ]);
+
+        Storage::disk('upload')->put($oldImport->filename, 'old import');
+        Storage::disk('upload')->put($freshImport->filename, 'fresh import');
+        Storage::disk('upload')->put('ef/orphan-import.xlsx', 'orphan import');
+        touch(Storage::disk('upload')->path('ef/orphan-import.xlsx'), now()->subDays(20)->timestamp);
+
+        $exitCode = Artisan::call('documents:cleanup-generated', [
+            '--days' => 14,
+            '--import-days' => 14,
+        ]);
+
+        $this->assertSame(0, $exitCode);
+        Storage::disk('upload')->assertMissing($oldImport->filename);
+        Storage::disk('upload')->assertExists($freshImport->filename);
+        Storage::disk('upload')->assertMissing('ef/orphan-import.xlsx');
+    }
+
+    public function test_cleanup_generated_documents_removes_only_old_system_temp_zip_files(): void
+    {
+        Storage::fake('public');
+        Storage::fake('upload');
+
+        $tempDir = storage_path('framework/testing/tmp-cleanup');
+        if (!is_dir($tempDir)) {
+            mkdir($tempDir, 0777, true);
+        }
+
+        $oldZip = $tempDir . '/old-zip';
+        $freshZip = $tempDir . '/fresh-zip';
+        $oldText = $tempDir . '/old-text';
+
+        file_put_contents($oldZip, "PK\x03\x04old");
+        file_put_contents($freshZip, "PK\x03\x04fresh");
+        file_put_contents($oldText, 'not zip');
+
+        touch($oldZip, now()->subHours(2)->timestamp);
+        touch($oldText, now()->subHours(2)->timestamp);
+
+        config(['documents.temp_directory' => $tempDir]);
+
+        try {
+            $exitCode = Artisan::call('documents:cleanup-generated', [
+                '--days' => 14,
+                '--temp-hours' => 1,
+            ]);
+
+            $this->assertSame(0, $exitCode);
+            $this->assertFileDoesNotExist($oldZip);
+            $this->assertFileExists($freshZip);
+            $this->assertFileExists($oldText);
+        } finally {
+            @unlink($oldZip);
+            @unlink($freshZip);
+            @unlink($oldText);
+            @rmdir($tempDir);
+        }
+    }
+}

+ 2 - 2
tests/Feature/ReportControllerTest.php

@@ -109,8 +109,8 @@ class ReportControllerTest extends TestCase
 
         $response->assertViewHas('installedOrders', 4);
         $response->assertViewHas('installedMafs', 4);
-        $response->assertViewHas('mountOrders', 5);
-        $response->assertViewHas('mountMafs', 5);
+        $response->assertViewHas('mountOrders', 4);
+        $response->assertViewHas('mountMafs', 4);
     }
 
     public function test_reports_index_has_reclamations_data(): void

+ 50 - 0
tests/Unit/Services/DiskSpaceMonitorTest.php

@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Unit\Services;
+
+use App\Services\DiskSpaceMonitor;
+use Illuminate\Support\Facades\Cache;
+use Tests\TestCase;
+
+class DiskSpaceMonitorTest extends TestCase
+{
+    public function testItWarnsWhenFreeSpaceIsBelowThreshold(): void
+    {
+        Cache::forget('disk_space_monitor.status');
+        config([
+            'app.disk_space_warning.path' => storage_path(),
+            'app.disk_space_warning.threshold_bytes' => PHP_INT_MAX,
+            'app.disk_space_warning.cache_seconds' => 300,
+        ]);
+
+        $status = app(DiskSpaceMonitor::class)->status();
+
+        $this->assertTrue($status['warning']);
+        $this->assertNotNull($status['free_bytes']);
+    }
+
+    public function testItDoesNotWarnWhenFreeSpaceIsAboveThreshold(): void
+    {
+        Cache::forget('disk_space_monitor.status');
+        config([
+            'app.disk_space_warning.path' => storage_path(),
+            'app.disk_space_warning.threshold_bytes' => 1,
+            'app.disk_space_warning.cache_seconds' => 300,
+        ]);
+
+        $status = app(DiskSpaceMonitor::class)->status();
+
+        $this->assertFalse($status['warning']);
+        $this->assertNotNull($status['free_bytes']);
+    }
+
+    public function testItFormatsBytes(): void
+    {
+        $monitor = new DiskSpaceMonitor();
+
+        $this->assertSame('1 ГБ', $monitor->formatBytes(1024 * 1024 * 1024));
+        $this->assertSame('неизвестно', $monitor->formatBytes(null));
+    }
+}

+ 18 - 0
tests/Unit/Services/FileServiceTest.php

@@ -78,4 +78,22 @@ class FileServiceTest extends TestCase
         $this->assertEquals('tests/uploads/file.pdf', $saved->path);
         Storage::disk('public')->assertExists($saved->path);
     }
+
+    public function test_create_zip_archive_marks_file_as_generated_and_stores_archive_outside_tmp(): void
+    {
+        $user = User::factory()->create();
+        $path = 'tests/archive/tmp';
+
+        Storage::disk('public')->makeDirectory($path);
+        Storage::disk('public')->put($path . '/file.txt', 'test content');
+
+        $archive = app(FileService::class)->createZipArchive($path, 'archive.zip', $user->id);
+
+        $this->assertTrue($archive->is_generated);
+        $this->assertEquals('tests/archive/archive.zip', $archive->path);
+        $this->assertEquals(url('/storage/tests/archive/archive.zip'), $archive->link);
+        Storage::disk('public')->assertExists('tests/archive/archive.zip');
+
+        Storage::disk('public')->deleteDirectory('tests/archive');
+    }
 }

+ 168 - 0
tests/Unit/Services/GenerateDocumentsServiceTest.php

@@ -14,6 +14,7 @@ use App\Services\GenerateDocumentsService;
 use Illuminate\Foundation\Testing\RefreshDatabase;
 use Illuminate\Support\Facades\Storage;
 use Tests\TestCase;
+use ZipArchive;
 
 class GenerateDocumentsServiceTest extends TestCase
 {
@@ -184,6 +185,152 @@ class GenerateDocumentsServiceTest extends TestCase
         }
     }
 
+    public function test_generate_handover_pack_handles_many_large_related_files_and_cleans_tmp(): void
+    {
+        foreach (['./templates/Statement.xlsx', './templates/QualityDeclaration.xlsx', './templates/Inventory.xlsx', './templates/Passport.xlsx'] as $template) {
+            if (!file_exists($template)) {
+                $this->markTestSkipped('Excel template ' . basename($template) . ' not found');
+            }
+        }
+
+        $user = User::factory()->create();
+        $order = Order::factory()->create([
+            'object_address' => 'г Москва, тестовый объект для большого пакета',
+            'name' => 'Большой тестовый пакет сдачи',
+        ]);
+
+        Contract::factory()->create([
+            'year' => $order->year,
+            'contract_number' => 'LOAD-001',
+            'contract_date' => now(),
+        ]);
+
+        $createdPaths = [];
+        $generatedArchivePath = null;
+        $photoIds = [];
+        $documentIds = [];
+
+        try {
+            for ($index = 1; $index <= 50; $index++) {
+                $path = "orders/{$order->id}/photo/load-photo-{$index}.jpg";
+                $this->putLargePublicFile($path, 2 * 1024 * 1024);
+                $createdPaths[] = $path;
+
+                $photo = File::factory()->image()->create([
+                    'user_id' => $user->id,
+                    'original_name' => "load-photo-{$index}.jpg",
+                    'path' => $path,
+                    'link' => url('/storage/' . $path),
+                    'mime_type' => 'image/jpeg',
+                ]);
+                $photoIds[] = $photo->id;
+            }
+
+            for ($index = 1; $index <= 8; $index++) {
+                $path = "orders/{$order->id}/document/load-document-{$index}.pdf";
+                $this->putLargePublicFile($path, 2 * 1024 * 1024);
+                $createdPaths[] = $path;
+
+                $document = File::factory()->pdf()->create([
+                    'user_id' => $user->id,
+                    'original_name' => "load-document-{$index}.pdf",
+                    'path' => $path,
+                    'link' => url('/storage/' . $path),
+                    'mime_type' => 'application/pdf',
+                ]);
+                $documentIds[] = $document->id;
+            }
+
+            $order->photos()->syncWithoutDetaching($photoIds);
+            $order->documents()->syncWithoutDetaching($documentIds);
+
+            for ($index = 1; $index <= 12; $index++) {
+                $certificatePath = "tests/handover-large/certificates/certificate-{$index}.pdf";
+                $passportPath = "tests/handover-large/passports/passport-{$index}.pdf";
+                $this->putLargePublicFile($certificatePath, 2 * 1024 * 1024);
+                $this->putLargePublicFile($passportPath, 2 * 1024 * 1024);
+                $createdPaths[] = $certificatePath;
+                $createdPaths[] = $passportPath;
+
+                $certificate = File::factory()->pdf()->create([
+                    'user_id' => $user->id,
+                    'original_name' => "certificate-{$index}.pdf",
+                    'path' => $certificatePath,
+                    'link' => url('/storage/' . $certificatePath),
+                    'mime_type' => 'application/pdf',
+                ]);
+                $passport = File::factory()->pdf()->create([
+                    'user_id' => $user->id,
+                    'original_name' => "passport-{$index}.pdf",
+                    'path' => $passportPath,
+                    'link' => url('/storage/' . $passportPath),
+                    'mime_type' => 'application/pdf',
+                ]);
+
+                $product = Product::factory()->create([
+                    'year' => $order->year,
+                    'article' => 'LOAD-MAF-' . $index,
+                    'passport_name' => 'Паспортное имя МАФ ' . $index,
+                    'statement_name' => 'Ведомость МАФ ' . $index,
+                    'certificate_id' => $certificate->id,
+                    'certificate_number' => 'CERT-' . $index,
+                    'service_life' => 84,
+                ]);
+
+                ProductSKU::factory()->create([
+                    'year' => $order->year,
+                    'order_id' => $order->id,
+                    'product_id' => $product->id,
+                    'passport_id' => $passport->id,
+                    'rfid' => 'LOAD-RFID-' . $index,
+                    'factory_number' => 'LOAD-FN-' . $index,
+                    'manufacture_date' => now()->subDays($index)->format('Y-m-d'),
+                ]);
+            }
+
+            $link = $this->service->generateHandoverPack($order->fresh(), $user->id);
+
+            $this->assertNotSame('', $link);
+            $this->assertStringContainsString('/storage/orders/' . $order->id . '/', $link);
+            $this->assertFalse(Storage::disk('public')->exists("orders/{$order->id}/tmp"));
+
+            $generatedArchive = File::query()
+                ->where('is_generated', true)
+                ->where('path', 'like', "orders/{$order->id}/%.zip")
+                ->firstOrFail();
+            $generatedArchivePath = $generatedArchive->path;
+
+            Storage::disk('public')->assertExists($generatedArchivePath);
+            $this->assertGreaterThan(100 * 1024 * 1024, filesize(Storage::disk('public')->path($generatedArchivePath)));
+
+            $zip = new ZipArchive();
+            $this->assertTrue($zip->open(Storage::disk('public')->path($generatedArchivePath)));
+            try {
+                $this->assertGreaterThanOrEqual(78, $zip->numFiles);
+                $this->assertNotFalse($zip->locateName('ФОТО ПСТ/load-photo-1.jpg'));
+                $this->assertNotFalse($zip->locateName('СЕРТИФИКАТ/certificate-1.pdf'));
+                $this->assertNotFalse($zip->locateName('ПАСПОРТ/Паспорт LOAD-FN-1 арт. LOAD-MAF-1.pdf'));
+                $this->assertNotFalse($zip->locateName('1.Ведомость.xlsx'));
+                $this->assertNotFalse($zip->locateName('2.Декларация качества.xlsx'));
+                $this->assertNotFalse($zip->locateName('3.Опись.xlsx'));
+            } finally {
+                $zip->close();
+            }
+        } finally {
+            Storage::disk('public')->deleteDirectory("orders/{$order->id}/tmp");
+            if ($generatedArchivePath) {
+                Storage::disk('public')->delete($generatedArchivePath);
+            }
+
+            foreach ($createdPaths as $path) {
+                Storage::disk('public')->delete($path);
+            }
+            Storage::disk('public')->deleteDirectory("orders/{$order->id}/photo");
+            Storage::disk('public')->deleteDirectory("orders/{$order->id}/document");
+            Storage::disk('public')->deleteDirectory('tests/handover-large');
+        }
+    }
+
     public function test_generate_reclamation_pack_creates_documents(): void
     {
         // Skip if templates don't exist
@@ -224,6 +371,27 @@ class GenerateDocumentsServiceTest extends TestCase
         }
     }
 
+    private function putLargePublicFile(string $path, int $bytes): void
+    {
+        Storage::disk('public')->makeDirectory(dirname($path));
+
+        $handle = fopen(Storage::disk('public')->path($path), 'wb');
+        if ($handle === false) {
+            $this->fail('Cannot create test file: ' . $path);
+        }
+
+        try {
+            $remaining = $bytes;
+            while ($remaining > 0) {
+                $chunkSize = min($remaining, 256 * 1024);
+                fwrite($handle, random_bytes($chunkSize));
+                $remaining -= $chunkSize;
+            }
+        } finally {
+            fclose($handle);
+        }
+    }
+
     public function test_generate_ttn_pack_uses_departure_date_and_address_in_filename(): void
     {
         if (!file_exists('./templates/Ttn.xlsx')) {