Привет, Хабр. На связи Катя Саяпина, менеджер продуктов МТС Exolve. В этой статье разберём, как предотвратить приостановку бизнеса — вовремя пополнять баланс на отправку SMS. С минимальными усилиями соберём свою систему мониторинга расходов на сообщения. Будем фиксировать фактические траты, отслеживать аномалии, строить линейный прогноз и слать себе контрольные SMS.
В статье собрано решение на PHP с Composer, cron и MySQL. Всё максимально просто, чтобы за один вечер развернуть систему на любом сервере без внешних зависимостей.
Система состоит из двух скриптов
Один собирает данные, другой их анализирует и отправляет уведомления по двум триггерам.
Сбор данных
Скрипт запускается каждый час по cron, запрашивает у МТС Exolve количество отправленных SMS за последние 60 минут и текущий баланс и сохраняет всё в базу данных.
Анализ и отправка уведомлений
Запускаются после сбора данных и проверяют историю за последний 31 день. Здесь могут сработать два триггера.
Триггер 1. Баланс на исходе
Система рассчитывает, сколько SMS в среднем отправлялось в сутки за последний 31 день.
Для расчёта берётся медианное количество SMS и умножается на стоимость одного сообщения. В этом примере стоимость сообщения устанавливается вручную — в среднем 3 ₽ за аутентификационное уведомление. Затем сумма на балансе делится на полученное число — так мы видим количество дней, на которое хватит денег.
Если по текущему темпу расходов денег осталось на 5 дней или меньше, отправляется сообщение:
⏳ Баланс 35 000 ₽, хватит на 4 дня. Пополните счёт.
Триггер 2. Всплеск
Система считает количество SMS, отправленных за последние календарные сутки, и сравнивает с медианой за последний 31 день. Если за сутки отправлено в два раза и больше сообщений, чем медианное значение, то подразумевается, что произошёл всплеск. Получаем такое уведомление:
? За сутки отправлено 5 100 SMS — это в 2,3 раза больше обычного. Проверьте активность. Баланса хватит ещё на 1,7 такого дня.
В уведомлении также указывается, на сколько дней хватит текущего баланса, если расход продолжится в подобном темпе.
Установка и запуск проекта
Вся логика разбита по папкам: app/ — сервисы, config/ — зависимости, artisan.php — точка входа для команд. Конфигурация хранится в .env.
Чтобы всё заработало, нужно
- Установить зависимости через Composer. 
- Задать параметры окружения, такие как доступ к БД и API-ключ. 
- Создать таблицу в MySQL. 
- Настроить два cron-скрипта: один для сбора статистики, второй для анализа и уведомлений. 
Под спойлером — структура проекта, установка зависимостей, настройка .env и создание базы данных.
Структура проекта
sms_monitoring/
├── app/
│   ├── Console/
│   │   └── Command/
│   │       ├── CollectStatsCommand.php # Команда для сбора статистики SMS
│   │       └── AnalyzeBalanceCommand.php  # Команда для анализа баланса
│   ├── DTO/
│   │   └── SmsStatDTO.php    # Объект передачи данных для SMS статистики
│   ├── Infrastructure/
│   │   └── Database.php  # Обёртка над PDO и конфигурация подключения к БД
│   ├── Repository/
│   │   └── SmsStatRepository.php    # Работа с таблицей статистики
│   ├── Service/
│   │   ├── BalanceAnalyzerService.php  # Сервис анализа баланса
│   │   ├── ExolveApiService.php     # Сервис запроса статистики от МТС Exolve
│   │   ├── SmsSenderService.php     # Сервис для отправки SMS
├── bootstrap/
│   └── app.php                   # Инициализация контейнера и автозагрузка
├── config/
│   └── container.php             # Конфигурация зависимостей PHP-DI
├── cron/
│   ├── collect_stats.sh     # Shell-скрипт запуска команды сбора статистики
│   ├── analyze_balance.sh   # Shell-скрипт запуска команды анализа баланса
├── routes/
│   └── console.php              # Регистрация команд (маршруты CLI)
├── artisan.php                  # Точка входа CLI-приложения
├── .env                         # Переменные окружения (DB, API_KEY и т.д.)
├── composer.json                # Автозагрузка и зависимости
├── database.sql                 # SQL-дамп структуры базы данныхСоздание проекта и установка зависимостей
Начинаем с инициализации проекта и установки минимального набора зависимостей через Composer.
mkdir sms_monitoring
cd sms_monitoring
composer init
composer require vlucas/phpdotenv guzzlehttp/guzzleComposer.json:
{
   "autoload": {
       "psr-4": {
           "App\\": "app/"
       }
   },
   "require": {
       "vlucas/phpdotenv": "^5.6",
       "guzzlehttp/guzzle": "^7.9"
   }
}Генерация автозагрузки:
composer dump-autoloadСоздание проекта и установка зависимостей
В корне проекта создаём и заполняем файл .env. Номера телефонов должны состоять только из цифр — без плюсов, пробелов, скобок и других символов.
DB_HOST=ваш хост
DB_PORT=ваш порт
DB_NAME=sms_monitoring
DB_USER=root
DB_PASS=root
EXOLVE_API_KEY=ваш_ключ
EXOLVE_API_URL=https://api.exolve.ru
EXOLVE_SENDER=номер_отправителя
ALERT_PHONE=номер_получателяСоздание базы данных
Подключаемся к MySQL и создаём таблицу.
CREATE DATABASE sms_monitoring CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE sms_stats
(
   id        INT AUTO_INCREMENT PRIMARY KEY,
   date_hour DATETIME       NOT NULL UNIQUE,
   sms_count INT            NOT NULL,
   balance   DECIMAL(12, 2) NOT NULL
);Шаг 1. Инициализация приложения
Файл bootstrap/app.php загружает переменные из .env, подключает автозагрузку Composer и собирает мини-контейнер DI. Остальная логика берёт зависимости именно отсюда, так что перенос сервиса сводится к composer install и правильному .env.
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use Dotenv\Dotenv;
$dotenv = Dotenv::createImmutable(__DIR__ . '/../');
$dotenv->load();
$definitions = require __DIR__ . '/../config/container.php';
$container = [];
foreach ($definitions as $key => $factory) {
   $container[$key] = fn() => $factory($container);
}
return $container;Шаг 2. Основные компоненты приложения
Вся бизнес-логика приложения собрана в одном месте и состоит из восьми компонентов: она подключается к базе данных, получает статистику по API, сохраняет данные, анализирует их и отправляет SMS.
Они работают вместе: раз в час данные попадают в базу, раз в день анализируются, и при необходимости отправляется уведомление. Далее — подробно про каждый компонент.
Подключение к БД — Infrastructure/Database.php
Отвечает за соединение с MySQL через PDO, используя параметры из .env. Реализован как Singleton — подключение создаётся один раз и переиспользуется всеми сервисами.
<?php
namespace App\Infrastructure;
use PDO;
use PDOException;
use RuntimeException;
final class Database
{
   private static ?PDO $pdo = null;
   private function __construct()
   {
   }
   public static function getConnection(): PDO
   {
       if (self::$pdo === null) {
           $host = getenv('DB_HOST') ?? null;
           $port = getenv('DB_PORT') ?? '3306';
           $db = getenv('DB_NAME') ?? null;
           $user= getenv('DB_USER') ?? null;
           $pass = getenv('DB_PASS') ?? null;
           if (!$host || !$db || !$user) {
               throw new RuntimeException('Конфигурация базы данных отсутствует в переменных среды.');
           }
           $dsn = sprintf(
               'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
               $host,
               $port,
               $db
           );
           try {
               self::$pdo = new PDO(
                   $dsn,
                   $user,
                   $pass,
                   [
                       PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                       PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                       PDO::ATTR_EMULATE_PREPARES => false,
                   ]
               );
           } catch (PDOException $e) {
               throw new RuntimeException('Подключение к базе данных не удалось: ' . $e->getMessage());
           }
       }
       return self::$pdo;
   }
}Передача статистики — app/DTO/SmsStatDTO.php
Хранит данные за каждый час: количество отправленных сообщений, баланс, временную метку. Используется для передачи информации между слоями — от API до базы и анализа.
<?php
namespace App\DTO;
use DateTimeImmutable;
class SmsStatDTO
{
   public DateTimeImmutable $dateHour;
   public int $smsCount;
   public float $balance;
   public function __construct(DateTimeImmutable $dateHour, int $smsCount, float $balance)
   {
       $this->dateHour = $dateHour;
       $this->smsCount = $smsCount;
       $this->balance = $balance;
   }
}
Сохранение и извлечение данных из БД — app/Repository/SmsStatRepository.php
Репозиторий для работы с таблицей sms_stats. Сохраняет полученную статистику и отдаёт данные за последний 31 день. Использует PDO для SQL-запросов, обрабатывает дубли и возвращает данные в удобном для анализа виде.
<?php
namespace App\Repository;
use App\DTO\SmsStatDTO;
use PDO;
class SmsStatRepository
{
   private PDO $pdo;
   public function __construct(PDO $pdo)
   {
       $this->pdo = $pdo;
   }
   public function save(SmsStatDTO $stat): void
   {
       $stmt = $this->pdo->prepare("
           INSERT INTO sms_stats (date_hour, sms_count, balance)
           VALUES (?, ?, ?)
           ON DUPLICATE KEY UPDATE
               sms_count = VALUES(sms_count),
               balance = VALUES(balance)
       ");
       $stmt->execute([
           $stat->dateHour->format('Y-m-d H:00:00'),
           $stat->smsCount,
           $stat->balance
       ]);
   }
   public function getLast31DaysStats(): array
   {
       $stmt = $this->pdo->query("
           SELECT
              date_hour,
              sms_count,
              balance
           FROM sms_stats
           WHERE date_hour >= NOW() - INTERVAL 31 DAY
       ");
       return $stmt->fetchAll(PDO::FETCH_ASSOC);
   }
}
Получение баланса и статистики отправок по API — app/Service/ExolveApiService.php
Обращается к МТС Exolve по API — получает текущий баланс и количество отправленных SMS. Инкапсулирует работу с HTTP-клиентом Guzzle, автоматически подставляя заголовки и параметры. Обрабатывает ответы от API, проверяя их корректность и отбрасывая исключения при обнаружении ошибок. Используется другими сервисами приложения для получения актуальных данных о состоянии счёта и активности.
<?php
namespace App\Service;
use GuzzleHttp\Client;
use RuntimeException;
use stdClass;
class ExolveApiService
{
   private Client $client;
   private string $apiKey;
   private string $baseUri;
   private float $timeout;
   public function __construct()
   {
       $this->apiKey = getenv('EXOLVE_API_KEY') ?? '';
       $this->baseUri = getenv('EXOLVE_API_URL') ?? 'https://api.exolve.ru';
       $this->timeout = (float)(getenv('EXOLVE_API_TIMEOUT') ?? 10.0);
       if (empty($this->apiKey)) {
           throw new RuntimeException('EXOLVE_API_KEY не задан в переменных окружения.');
       }
       $this->client = new Client([
           'base_uri' => 'https://api.exolve.ru',
           'headers' => [
               'Authorization' => 'Bearer ' . $this->apiKey,
               'Content-Type' => 'application/json',
               'Accept' => 'application/json',
           ],
           'timeout' => 10.0,
       ]);
   }
   public function getBalance(): array
   {
       $response = $this->client->post('/finance/v1/GetBalance', [
           'json' => new stdClass()
       ]);
       $data = json_decode($response->getBody()->getContents(), true);
       if (!isset($data['balance'])) {
           throw new RuntimeException('Неверный ответ от GetBalance');
       }
       return [
           'balance' => (float)$data['balance'],
           'credit_limit' => isset($data['credit_limit']) ? (float)$data['credit_limit'] : 0.0,
       ];
   }
   public function getSmsCount(array $filters = []): int
   {
       $response = $this->client->post('/messaging/v1/GetCount', [
           'json' => $filters ?: new stdClass()
       ]);
       $data = json_decode($response->getBody()->getContents(), true);
       if (!isset($data['count'])) {
           throw new RuntimeException('Неверный ответ от GetCount');
       }
       return (int)$data['count'];
   }
}
Отправка уведомлений — app/Service/SmsSenderService.php
Отправляет уведомления через SMS API при низком балансе или резком росте трафика. Проверяет наличие параметров в окружении и обрабатывает ошибки при отправке.
<?php
namespace App\Service;
use GuzzleHttp\Client;
use Throwable;
class SmsSenderService
{
   private Client $client;
   public function __construct()
   {
       $this->client = new Client();
   }
   public function send(string $to, string $message): void
   {
       $url = getenv('EXOLVE_API_URL') . '/messaging/v1/SendSMS';
       $sender = getenv('EXOLVE_SENDER') ?? null;
       if (empty($sender)) {
           echo 'В env отсутствует номер для отправки!';
           return;
       }
       try {
           $this->client->post($url, [
               'headers' => [
                   'Authorization' => 'Bearer ' . getenv('EXOLVE_API_KEY'),
                   'Content-Type' => 'application/json',
               ],
               'json' => [
                   'number' =>  getenv('EXOLVE_SENDER'),
                   'destination' => $to,
                   'text' => $message
               ]
           ]);
           echo "SMS отправлено: {$to}\n";
       } catch (Throwable $e) {
           echo "Ошибка отправки SMS: {$e->getMessage()}\n";
       }
   }
}Считает медиану и отправляет предупреждения при рисках — app/Service/BalanceAnalyzerService.php
Считает медиану суточных отправок, прогнозирует, на сколько дней хватит баланса. Отправляет SMS, если денег осталось мало или активность резко выросла.
<?php
namespace App\Service;
use App\Repository\SmsStatRepository;
class BalanceAnalyzerService
{
   private SmsStatRepository $repository;
   private SmsSenderService $smsSender;
   private const SMS_COST = 3;
 public function __construct(SmsStatRepository $repository, SmsSenderService $smsSender)
   {
       $this->repository = $repository;
       $this->smsSender = $smsSender;
   }
   public function analyze(): void
   {
       $stats = $this->repository->getLast31DaysStats();
       if (count($stats) < 10) {
           echo "Недостаточно данных для анализа\n";
           return;
       }
       // Подсчет количества SMS по дням.
       $dailySums = [];
       foreach ($stats as $row) {
           $day = substr($row['date_hour'], 0, 10);
           $dailySums[$day] = ($dailySums[$day] ?? 0) + $row['sms_count'];
       }
       // Вычисление медианы отправленных SMS за сутки.
       $dailyValues = array_values($dailySums);
       sort($dailyValues);
       $count = count($dailyValues);
       $median = $count % 2 === 0
           ? ($dailyValues[$count / 2 - 1] + $dailyValues[$count / 2]) / 2
           : $dailyValues[floor($count / 2)];
       $smsCost = self::SMS_COST;
       $lastBalance = $stats[count($stats) - 1]['balance'];
       $daysLeft = $median > 0 ? round($lastBalance / ($median * $smsCost), 1) : PHP_INT_MAX;
       // Отправка уведомления при малом остатке баланса.
       if ($daysLeft <= 5) {
           $this->smsSender->send(
               getenv('ALERT_PHONE'),
               "⏳ Баланс {$lastBalance} ₽, хватит на {$daysLeft} дней. Пополните счёт."
           );
       }
       // Отправка уведомления при резком росте отправленных SMS.
       $today = date('Y-m-d');
       $lastDaySum = $dailySums[$today] ?? 0;
       if ($median > 0 && $lastDaySum > $median * 2) {
           $criticalDaysLeft = round($lastBalance / ($lastDaySum * $smsCost), 1);
           $ratio = round($lastDaySum / $median, 1);
           $this->smsSender->send(
               getenv('ALERT_PHONE'),
               "? За сутки отправлено {$lastDaySum} SMS — это в {$ratio} раза больше обычного. Проверьте активность. Баланса хватит ещё на {$criticalDaysLeft} таких дней."
           );
       }
   }
}
Сбор статистики по API — app/Console/Command/AnalyzeBalanceCommand.php
Консольная команда проверяет текущий баланс и уровень трафика. Делегирует работу BalanceAnalyzerService и выводит результаты в консоль. Может использоваться в cron или по расписанию для регулярного мониторинга состояния.
<?php
namespace App\Console\Command;
use App\Service\BalanceAnalyzerService;
class AnalyzeBalanceCommand
{
   private BalanceAnalyzerService $analyzer;
   public function __construct(BalanceAnalyzerService $analyzer)
   {
       $this->analyzer = $analyzer;
   }
   public function handle(): void
   {
       $this->analyzer->analyze();
   }
}
Анализ данных и отправка SMS — app/Console/Command/CollectStatsCommand.php
Консольная команда для сбора статистики и сохранения её в базу данных. Получает количество отправленных SMS и текущий баланс, формируя запись с временной меткой.
Собирает данные для последующего анализа динамики расходов и активности сервиса.
В случае ошибки информирует пользователя о причине сбоя через консоль.
<?php
namespace App\Console\Command;
use App\Repository\SmsStatRepository;
use App\Service\ExolveApiService;
use App\DTO\SmsStatDTO;
use DateTimeImmutable;
use DateTimeZone;
use Exception;
class CollectStatsCommand
{
   private SmsStatRepository $repository;
   private ExolveApiService $api;
   public function __construct(SmsStatRepository $repository, ExolveApiService $api)
   {
       $this->repository = $repository;
       $this->api = $api;
   }
   /**
    * @throws Exception
    */
   public function handle(): void
   {
       try {
           $smsCount = $this->api->getSmsCount();
           $balanceData = $this->api->getBalance();
           $dto = new SmsStatDTO(
               new DateTimeImmutable('now', new DateTimeZone('UTC')),
               $smsCount,
               $balanceData['balance']
           );
           $this->repository->save($dto);
           echo "Данные сохранены: {$smsCount} SMS, баланс {$balanceData['balance']} ₽\n";
       } catch (Exception $e) {
           echo "Произошла ошибка: " . $e->getMessage() . "\n";
       }
   }
}
Шаг 3. Определение зависимостей и маршрутов
Все компоненты приложения связываются через простой контейнер зависимостей и вызываются по имени через CLI-маршруты — чтобы управлять логикой централизованно и запускать команды без лишней связки между файлами.
Контейнер зависимостей config/container.php
Описывает, как создавать экземпляры сервисов, команд и репозиториев с учётом их зависимостей. Упрощает управление связями между классами и поддерживает единый стиль инициализации компонентов.
<?php
use App\Console\Command\AnalyzeBalanceCommand;
use App\Console\Command\CollectStatsCommand;
use App\Infrastructure\Database;
use App\Repository\SmsStatRepository;
use App\Service\ExolveApiService;
use App\Service\SmsSenderService;
use App\Service\BalanceAnalyzerService;
return [
   'pdo' => fn() => Database::getConnection(),
   'sms_stat_repository' => fn($c) => new SmsStatRepository($c['pdo']()),
   'exolve_api_service' => fn() => new ExolveApiService(),
   'sms_sender_service' => fn() => new SmsSenderService(),
   'balance_analyzer_service' => fn($c) => new BalanceAnalyzerService(
       $c['sms_stat_repository'](),
       $c['sms_sender_service']()
   ),
   'collect_stats_command' => fn($c) => new CollectStatsCommand(
       $c['sms_stat_repository'](),
       $c['exolve_api_service']()
   ),
   'analyze_balance_command' => fn($c) => new AnalyzeBalanceCommand(
       $c['balance_analyzer_service']()
   ),
];
Маршруты CLI-команд routes/console.php
Позволяет запускать команды через artisan.php, указывая их имя в аргументах командной строки. Обеспечивает удобный способ управления командами без жёсткой привязки к конкретным файлам или структурам.
<?php
use App\Console\Command\CollectStatsCommand;
use App\Console\Command\AnalyzeBalanceCommand;
return [
   'collect:stats' => fn($c) => new CollectStatsCommand(
       $c['sms_stat_repository'](),
       $c['exolve_api_service']()
   ),
   'analyze:balance' => fn($c) => new AnalyzeBalanceCommand(
       $c['balance_analyzer_service']()
   ),
];
Шаг 4. Точка входа для консольных команд — artisan.php
Загружает контейнер и маршруты команд, обрабатывает ввод пользователя из командной строки. Проверяет существование и корректность команды, передавая управление соответствующему обработчику. Позволяет централизованно управлять консольными сценариями без использования сторонних фреймворков.
<?php
$container = require __DIR__ . '/bootstrap/app.php';
$routes = require __DIR__ . '/routes/console.php';
$command = $argv[1] ?? null;
if (!$command || !isset($routes[$command])) {
   echo "Неизвестная команда. Доступные команды:\n";
   foreach (array_keys($routes) as $name) {
       echo "  - $name\n";
   }
   exit(1);
}
$handlerFactory = $routes[$command];
if (!is_callable($handlerFactory)) {
   echo "Ошибка: обработчик команды не является функцией\n";
   exit(1);
}
$handler = $handlerFactory($container);
if (!method_exists($handler, 'handle')) {
   echo "Ошибка: у команды нет метода handle()\n";
   exit(1);
}
$handler->handle();Шаг 5. Автоматизация с cron
Сбор статистики с cron/collect_stats.sh:
#!/bin/bash
php /var/www/html/artisan.php collect:statsАнализ баланса с cron/analyze_balance.sh:
#!/bin/bash
php /var/www/html/artisan.php analyze:balanceШаг 6. Настройка cron-задач
Для регулярного сбора статистики и анализа баланса настроим cron.
Скрипты уже готовы
cron/collect_stats.sh — для сбора статистики, запускаем каждый час.
cron/analyze_balance.sh — для анализа баланса и проверки аномалий, запускаем раз в день.
Пример crontab-записи для пользователя www-data:
0 * * * * /var/www/html/cron/collect_stats.sh
45 23 * * * /var/www/html/cron/analyze_balance.shШаг 7. Получаем SMS

Заключение
Теперь у нас есть структурированный и современный проект по контролю расходов на SMS — на базе МТС Exolve с хранением статистики в MySQL. В итоге за вечер можно развернуть собственную систему мониторинга расходов, которая будет автоматически собирать данные, анализировать их и присылать уведомления о рисках снижения баланса или неожиданных всплесках активности. Весь код — на GitHub.
Для повышения точности прогнозов можно добавить учёт количества сегментов через параметр segments_count в методе GetList. Также за счёт параметра category можно разделить сообщения на рекламные, авторизационные, транзакционные и сервисные, так как их стоимость различается. Если вам интересно, пишите в комментариях. Мы доработаем решение и опубликуем продолжение статьи.
 
          