Bläddra i källkod

refactor generate big files

Alexander Musikhin 22 timmar sedan
förälder
incheckning
c4615de66f

+ 1 - 0
.env.example

@@ -37,6 +37,7 @@ 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

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

@@ -16,6 +16,7 @@ class CleanupGeneratedDocuments extends Command
     protected $signature = 'documents:cleanup-generated
                             {--days= : Удалять сгенерированные документы старше N дней}
                             {--import-days= : Удалять загруженные файлы импорта старше N дней}
+                            {--temp-hours= : Удалять временные ZIP-файлы из системного tmp старше N часов}
                             {--dry-run : Показать, что будет удалено, без удаления}';
 
     protected $description = 'Удаляет старые сгенерированные документы, не затрагивая пользовательские загрузки';
@@ -40,6 +41,7 @@ class CleanupGeneratedDocuments extends Command
 
         $deletedFiles += $this->cleanupOrphanGeneratedArchives($cutoff, $dryRun);
         $deletedFiles += $this->cleanupImportFiles($dryRun);
+        $deletedFiles += $this->cleanupSystemTempZipFiles($dryRun);
 
         if ($dryRun) {
             $this->info('Проверка завершена. Данные не изменены.');
@@ -123,6 +125,21 @@ class CleanupGeneratedDocuments extends Command
         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>
      */
@@ -313,4 +330,60 @@ class CleanupGeneratedDocuments extends Command
     {
         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);
+    }
 }

+ 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 {

+ 13 - 13
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;
@@ -116,34 +118,32 @@ class FileService
      */
     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();
-
-        $contents = file_get_contents($tempFile);
-        $archivePath = $this->archivePath($path, $archiveName);
-        Storage::disk('public')->put($archivePath, $contents);
-        unlink($tempFile);
 
+        $zip->close();
         $fileModel = File::query()->updateOrCreate([
             'link' => url('/storage/') . '/' . $archivePath,
         ], [

+ 151 - 129
app/Services/GenerateDocumentsService.php

@@ -49,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
@@ -143,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
@@ -394,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);
+        }
     }
 
     /**
@@ -447,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);
+        }
     }
 
     /**
@@ -614,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
     }
 
 
-}
+}

+ 2 - 0
config/documents.php

@@ -3,6 +3,8 @@
 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
tests/Feature/CleanupGeneratedDocumentsCommandTest.php

@@ -142,4 +142,45 @@ class CleanupGeneratedDocumentsCommandTest extends TestCase
         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);
+        }
+    }
 }

+ 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')) {