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