HEX
Server: nginx/1.18.0
System: Linux test-ipsremont 5.4.0-214-generic #234-Ubuntu SMP Fri Mar 14 23:50:27 UTC 2025 x86_64
User: ips (1000)
PHP: 8.0.30
Disabled: pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,pcntl_unshare,
Upload Files
File: /var/www/heifetz/heifetz-app/crons/GarbageCollector.php
<?php

declare(strict_types=1);

namespace Crons;

use Core\Models\CoreHelper;
use Core\Models\OldUser;
use Helpers\ArrayHelper;
use Helpers\Data;
use Helpers\ProgressBar;
use Models\File;
use Models\FilesRelations;

class GarbageCollector extends AbstractCron
{

    protected string $title = 'Очищает мусорные данные в системе';

    private const UPLOADS_DIR = ROOT . '/uploads';

    private array $softDeletePaths;

    private bool $checkOnly;

    private bool $isAll;

    private int $deleteSize = 0;

    static array $help = [
        '--check-only' => 'Показать что будет удалено',
        '--verbose' => 'Показывать дополнительную информацию',
        '--all' => 'Удалить за все время',
    ];

    public function __construct(bool $checkOnly = false, bool $verbose = false, bool $isAll = false)
    {
        parent::__construct();

        if (!$isAll) {
            $startMonth = date('Y-m', strtotime('-2 months'));
            for ($month = $startMonth; $month <= date('Y-m'); $month = date('Y-m', strtotime($month . ' +1 months'))) {
                $this->softDeletePaths[] = date('/Y/m', strtotime($month));
            }
        }

        $this->checkOnly = $checkOnly;
        $this->verbose = $verbose;
        $this->isAll = $isAll;
    }

    private function deleteIfEmpty(string $dir)
    {
        if (file_exists($dir) && is_dir($dir)) {
            $dirFiles = array_values(array_diff(scandir($dir), ['..', '.']));
            if (empty($dirFiles) && file_exists($dir) && !$this->checkOnly) {
                $this->log('Удаляем папку ' . $dir);
                rmdir($dir);
            }
        }
    }

    protected function clearTimesheetTotalsMaster(): void
    {
        $this->header('Очищаем TimesheetTotalsMaster');
        $timesheetTotalsMasterIds = TimeSheetTotalsMaster::getQuery(['ttm.id'], 'ttm')
            ->joinLeft(WorkDate::$tableName . ' wd', 'ttm.unit_id = wd.unit_id AND (ttm.profession_id = wd.profession_id OR ttm.profession_id IS NULL AND wd.profession_id IS NULL) AND ttm.factory_id = wd.factory_id AND (ttm.rate = wd.rate OR ttm.rate IS NULL AND wd.rate IS NULL)')
            ->whereIsNull('wd.id')
            ->fetchCol();

        if (!empty($timesheetTotalsMasterIds)) {
            TimeSheetTotalsMaster::$db->query('DELETE FROM ' . TimeSheetTotalsMaster::$tableName . ' WHERE id IN (' . join(',', $timesheetTotalsMasterIds) . ')');
        }
    }

    protected function checkFiles(array $files): void
    {
        $files = array_map(fn($file) => str_replace(ROOT, '', $file), $files);
        if (!empty($files)) {
            $filesData = File::getQuery()->whereIn('path', $files)->fetchAll();
            $filesByPath = ArrayHelper::groupByField($filesData, 'path');
        }

        foreach ($files as $file) {
            if (empty($filesByPath[$file])) {
                $this->log('Удаляем ' . $file);
                $file = ROOT . $file;
                $this->deleteSize += filesize($file);
                if (!$this->checkOnly) {
                    unlink($file);
                }
            } elseif ($this->verbose) {
                $this->log('Оставляем ' . $file);
            }
        }
    }

    protected function checkDir(string $path): void
    {
        if (!file_exists($path)) {
            return;
        }

        $files = scandir($path);
        if (empty($files)) {
            return;
        }

        $files = array_values(array_filter($files, fn($fileName) => $fileName[0] != '.'));

        $filesToCheck = [];
        foreach ($files as $file) {
            $filePath = $path . '/' . $file;
            if (is_dir($filePath)) {
                $this->checkDir($filePath);
                $this->deleteIfEmpty($filePath);
            } else {
                $filesToCheck[] = $filePath;
            }
        }
        $this->checkFiles($filesToCheck);
    }

    protected function clearUnusedDocuments(): void
    {
        $this->header('Удаляем ненужные документы');
        $this->deleteByDir(self::UPLOADS_DIR);

        $this->header('Удаляем ненужные записи звонков');
        $this->deleteByDir(self::CALL_RECORDS_DIR);

        $this->log('Удалил ' . round($this->deleteSize / 1024 / 1024, 2) . ' mb');
    }

    private function deleteByDir(string $dir): void
    {
        if ($this->isAll) {
            $this->checkDir($dir);
            $this->deleteIfEmpty($dir);

            return;
        }
        foreach ($this->softDeletePaths as $path) {
            $this->checkDir($dir . $path);
            $this->deleteIfEmpty($dir . $path);
        }
    }

    private function clearFileIdInCallStatistic(): void
    {
        $sql = CallStatistic::getQuery(['cs.file_id'], 'cs')
            ->joinLeft(File::$tableName . ' f', 'cs.file_id = f.id')
            ->whereIsNotNull('cs.file_id')
            ->whereIsNull('f.id')
            ->limit(50000);
        if (!$this->isAll) {
            $sql->where('DATE(f.created_at) >= ?', date('Y-m-d', strtotime('-3 month')));
        }
        $totalCount = $sql->totalCount();
        $filesIds = $sql->fetchCol();

        if (empty($filesIds) || $this->checkOnly) {
            return;
        }

        $log = 'Очищаем детализацию звонков (';
        if (count($filesIds) == $totalCount) {
            $log .= $totalCount;
        } else {
            $log .= count($filesIds) . '/' . $totalCount;
            $totalCount = count($filesIds);
        }
        $log .= ')';
        $this->subHeader($log);
        $filesIdsBatch = array_chunk($filesIds, 500);
        $count = 0;
        foreach ($filesIdsBatch as $batchFilesIds) {
            if ($this->verbose) {
                $count += count($batchFilesIds);
                $this->progressBar(Data::getPercent($count, $totalCount, 3));
            }
            CallStatistic::clearFileId($batchFilesIds);
        }
    }

    private function deleteFilesNotLinkedWithCallStatistic(): void
    {
        $sql = File::getQuery(['f.id'], 'f')->joinLeft(CallStatistic::$tableName . ' cs', 'cs.file_id = f.id')->whereIsNull('cs.id')->limit(50000);
        if (!$this->isAll) {
            $sql->where('DATE(f.created_at) >= ?', date('Y-m-d', strtotime('-3 month')));
        }
        $totalCount = $sql->totalCount();
        $filesIds = $sql->fetchCol();

        if (empty($filesIds) || $this->checkOnly) {
            return;
        }

        $log = 'Удаляем файлы больше не связанные с детализацией звонков (';
        if (count($filesIds) == $totalCount) {
            $log .= $totalCount;
        } else {
            $log .= count($filesIds) . '/' . $totalCount;
            $totalCount = count($filesIds);
        }
        $log .= ')';
        $this->subHeader($log);
        $filesIdsBatch = array_chunk($filesIds, 500);
        $count = 0;
        foreach ($filesIdsBatch as $batchFilesIds) {
            if ($this->verbose) {
                $count += count($batchFilesIds);
                $this->progressBar(Data::getPercent($count, $totalCount, 3));
            }

            /** @var File[] $files */
            $files = File::model()->whereIn('id', $filesIds)->fetchAll();
            foreach ($files as $file) {
                $file->remove();
            }
        }
    }

    protected function clearFilesTable(): void
    {
        $this->subHeader('Удаляем записи из таблицы по которым нет файлов');

        $dirs = ['/uploads' => self::UPLOADS_DIR];
        $toRemove = [];

        if (!$this->isAll) {
            $newDirs = [];
            foreach ($dirs as $dir) {
                foreach ($this->softDeletePaths as $path) {
                    $newDirs[] = $dir . $path;
                }
            }
            $dirs = $newDirs;
        }

        foreach ($dirs as $fullDir) {
            $dir = str_replace(ROOT, '', $fullDir);
            $this->subHeader('Проверяем папку: ' . $fullDir);
            $files = File::model(['id', 'path'])->whereLike('path', $dir, 'right')->fetchAll();
            $totalCount = count($files);
            if ($this->verbose) {
                $progressBar = new ProgressBar($totalCount);
            }
            foreach ($files as $file) {
                if ($this->verbose && isset($progressBar)) {
                    $progressBar->advance();
                }
                $path = str_replace($dir, $fullDir, $file->path);
                if (!file_exists($path)) {
                    $toRemove[] = $file->id;
                }
            }
            $this->warning('');
        }
        $toRemove = array_unique($toRemove);

        $this->log('Записей к удалению: ' . count($toRemove));
        if (!empty($toRemove) && !$this->checkOnly) {
            /** @var File[] $files */
            $files = File::model()->whereIn('id', $toRemove)->fetchAll();
            foreach ($files as $file) {
                $file->remove();
            }
        }



        $filesSql = OldUser::getQuery(['u.image'], 'u')->joinLeft(File::$tableName . ' f', 'u.image = f.id')
            ->whereIsNotNull('u.image')
            ->whereIsNull('f.id');
        if (!$this->isAll) {
            $filesSql->where('DATE(f.created_at) >= ?', date('Y-m-d', strtotime('-3 month')));
        }
        $filesIds = $filesSql->fetchCol();
        if (!empty($filesIds) && !$this->checkOnly) {
            $this->subHeader('Очищаем аватарки пользователей (' . count($filesIds) . ')');

            OldUser::clearImageId($filesIds);
        }
    }

    private function removeOldFiles(): void
    {
        $this->subHeader('Удаляем старые файлы');
        /** @var File[] $files */
        $files = File::model()->where('deleted_at < ?', date('Y-m-d', strtotime('-1 month')))->fetchAll();
        if (empty(count($files))) {
            $this->log('Старых файлов не найдено!');

            return;
        }

        $this->log('Найдено файлов: ' . count($files));

        foreach ($files as $file) {
            $file->remove(true);
        }
    }

    public function execute(): void
    {
        $this->removeOldFiles();
        $this->clearFilesTable();
    }

}