fix: сутки по всему порталу считаются с московской полуночи, а не с трёх ночи

Продолжение правки отдела продаж от 02.08: тот же класс ошибки найден ещё
в тринадцати местах. Граница периода, посчитанная по Москве, уходит в базу
надписью без смещения и читается как гринвичская — сутки съезжают на три часа.

У ошибки оказалось ДВА вкуса, и поиск из промта находил только первый:
  1. граница по Москве, отданная как есть (дашборд клиента, списания,
     расход на рекламу, карточка клиента у отдела продаж, сводка админа);
  2. граница ВООБЩЕ без Москвы - now()->startOfDay() и Carbon::today();
     пояс приложения гринвичский, значит день начинался в 03:00 МСК
     (список лидов и выгрузка, биллинг, карточка клиента у админа,
     посетители, ответ бота клиенту, сверка CSV, снимок рекламной кампании).

Правило названо в одном месте - App\Support\MskBoundary: instant() отдаёт
границу мгновением, dayAfter() - первое мгновение после дня (полуинтервал,
чтобы не терять последнюю секунду). Календарные даты и счёт дней НЕ трогали:
там нужен именно московский календарь.

Заодно в списаниях клиента убрана вторая копия условий периода - выгрузка
CSV фильтровала по своей копии, и та уже разъехалась с общей.

Проверка: tests/Feature/NightBoundaryMskTest.php - девять проверок с часами,
замороженными на 00:30 МСК. До правки девять из девяти красные, после - зелёные.
Две из них поначалу проходили и на сломанном коде: в 00:30 МСК гринвичский день
это ещё вчерашний, и ночное событие случайно попадало в окно; добавлено второе
событие "вчера днём", которое обязано остаться за бортом.

Три чужие проверки закрепляли старую ошибку и поправлены:
  - DealIndexTest "конец дня" клал заявку в 23:30 по Гринвичу - по московскому
    календарю это уже 02:30 следующего дня;
  - ClientFactsTest и GuardCardContractTest строят заявки от now() и ночью
    краснеют сами - часы заморожены на 12:00 МСК с возвратом в afterEach.

Статанализ: 0. Форматтер: чисто.
This commit is contained in:
Дмитрий
2026-08-02 10:18:45 +03:00
parent 1fc83518bf
commit 1ed82b6b36
22 changed files with 573 additions and 69 deletions
@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\AdCampaign;
use App\Models\AdCreativeJob;
use App\Support\MskBoundary;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
@@ -250,15 +251,16 @@ class AdminAdvertisingController extends Controller
{
$now = Carbon::now('Europe/Moscow');
// Границы — МГНОВЕНИЯМИ (см. App\Support\MskBoundary): иначе месяц начинается
// с трёх часов ночи первого числа.
if ($period === 'current_month') {
$query->where($column, '>=', $now->copy()->startOfMonth());
$query->where($column, '>=', MskBoundary::instant($now->copy()->startOfMonth()));
} elseif ($period === 'last_month') {
$query->whereBetween($column, [
$now->copy()->subMonth()->startOfMonth(),
$now->copy()->subMonth()->endOfMonth(),
]);
$prev = $now->copy()->subMonth();
$query->where($column, '>=', MskBoundary::instant($prev->copy()->startOfMonth()))
->where($column, '<', MskBoundary::dayAfter($prev->copy()->endOfMonth()));
} elseif ($period === '90d') {
$query->where($column, '>=', $now->copy()->subDays(90));
$query->where($column, '>=', MskBoundary::instant($now->copy()->subDays(90)));
}
// 'all' — без фильтра по дате.
}
@@ -203,7 +203,9 @@ class AdminBillingController extends Controller
public function index(Request $request): JsonResponse
{
$search = trim((string) $request->query('search', ''));
$monthStart = now()->startOfMonth();
// Месяц — московский, в запрос уходит мгновением (App\Support\MskBoundary):
// гринвичская граница сдвигала начало месяца на три часа ночи первого числа.
$monthStart = now('Europe/Moscow')->startOfMonth()->utc();
$query = DB::table('tenants')
->leftJoin('tariff_plans', 'tariff_plans.id', '=', 'tenants.current_tariff_id')
@@ -34,16 +34,26 @@ class AdminDashboardController extends Controller
{
$df = (string) $request->query('date_from', '');
$dt = (string) $request->query('date_to', '');
// 🔴 Сутки считаем ПО МОСКВЕ, а в запрос отдаём мгновение (`->utc()`) —
// правило названо в App\Support\MskBoundary. Раньше день брался
// гринвичский: «сегодня» у администратора начиналось в 03:00 МСК и
// прихватывало кусок вчерашнего. Здесь `->utc()` вместо помощника
// только потому, что дальше по файлу плитки ждут именно Carbon.
if ($df !== '' && $dt !== '') {
try {
return [Carbon::parse($df)->startOfDay(), Carbon::parse($dt)->endOfDay()];
return [
Carbon::parse($df, 'Europe/Moscow')->startOfDay()->utc(),
Carbon::parse($dt, 'Europe/Moscow')->endOfDay()->utc(),
];
} catch (\Throwable) {
// невалидные даты → падаем на preset ниже
}
}
// Периоды «N дней назад» отсчитываются от текущего мгновения — их пояс
// не важен; важна только граница дня у «сегодня».
$from = match ((string) $request->query('period', '7d')) {
'today' => now()->startOfDay(),
'today' => Carbon::now('Europe/Moscow')->startOfDay()->utc(),
'30d' => now()->subDays(30),
'60d' => now()->subDays(60),
'90d' => now()->subDays(90),
@@ -235,7 +245,9 @@ class AdminDashboardController extends Controller
/** @return array<string,mixed> */
private function leadsMetrics(): array
{
$todayStart = now('Europe/Moscow')->startOfDay();
// Мгновение, а не московская надпись (см. App\Support\MskBoundary):
// иначе «сегодня» у плитки поставщика начиналось в три часа ночи.
$todayStart = now('Europe/Moscow')->startOfDay()->utc();
// Доставлено = реально созданные сегодня сделки у клиентов (не тест, не удал.).
$deliveredToday = DB::table('deals')
@@ -314,7 +314,9 @@ class AdminTenantsController extends Controller
/** @return array<int, array<string, mixed>> */
private function fetchProjects(int $tenantId): array
{
$today = CarbonImmutable::now()->startOfDay();
// Сутки московские, в запрос — мгновением (App\Support\MskBoundary):
// иначе «лидов сегодня» считалось с трёх часов ночи.
$today = CarbonImmutable::now('Europe/Moscow')->startOfDay()->utc();
// Subquery suppliers count per project_id.
$suppliers = DB::table('project_suppliers')
@@ -413,10 +415,13 @@ class AdminTenantsController extends Controller
*/
private function computeMetrics(int $tenantId, object $tenantRow): array
{
$now = CarbonImmutable::now();
$today = $now->startOfDay();
$weekAgo = $now->subDays(7)->startOfDay();
$monthAgo = $now->subDays(30)->startOfDay();
// Границы дней — московские, в запрос уходят мгновениями
// (App\Support\MskBoundary): гринвичская полночь — это 03:00 МСК, и
// «лидов сегодня» у клиента считалось с трёх ночи.
$now = CarbonImmutable::now('Europe/Moscow');
$today = $now->startOfDay()->utc();
$weekAgo = $now->subDays(7)->startOfDay()->utc();
$monthAgo = $now->subDays(30)->startOfDay()->utc();
// Counts по периодам — один SELECT с FILTER.
$leadsRow = DB::table('deals')
@@ -23,8 +23,10 @@ class AdminVisitorsController extends Controller
/** @return array{0:Carbon,1:Carbon} */
private function periodRange(Request $request): array
{
// «Сегодня» — московские сутки, отданные мгновением (App\Support\MskBoundary):
// гринвичская полночь начинала день в 03:00 МСК.
$from = match ((string) $request->query('period', '7d')) {
'today' => now()->startOfDay(),
'today' => now('Europe/Moscow')->startOfDay()->utc(),
'30d' => now()->subDays(30),
default => now()->subDays(7),
};
@@ -9,6 +9,7 @@ use App\Models\Tenant;
use App\Repositories\PricingTierRepository;
use App\Services\Billing\BalanceToLeadsConverter;
use App\Services\Billing\RunwayCalculator;
use App\Support\MskBoundary;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
@@ -54,7 +55,16 @@ class DashboardController extends Controller
default => [$now->subDays(7), $now->subDays(14)],
};
$data = DB::transaction(function () use ($tenantId, $tenant, $now, $range, $windowStart, $prevStart) {
// 🔴 В запросы уходят МГНОВЕНИЯ (см. App\Support\MskBoundary). Московская
// граница, отданная как есть, читается базой как гринвичская, и «сегодня»
// начиналось с трёх часов ночи: лид, пришедший в 00:10, пропадал из
// сегодняшних. Сам $now остаётся московским — по нему считаются подписи
// и бакеты столбиков активности.
$nowAt = MskBoundary::instant($now);
$windowStartAt = MskBoundary::instant($windowStart);
$prevStartAt = MskBoundary::instant($prevStart);
$data = DB::transaction(function () use ($tenantId, $tenant, $now, $range, $nowAt, $windowStartAt, $prevStartAt) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$base = fn () => DB::table('deals')
@@ -63,14 +73,14 @@ class DashboardController extends Controller
->where('is_test', false);
// --- leads received: текущее + предыдущее окно ---
$curLeads = (clone $base())->whereBetween('received_at', [$windowStart, $now])->count();
$prevLeads = (clone $base())->whereBetween('received_at', [$prevStart, $windowStart])->count();
$curLeads = (clone $base())->whereBetween('received_at', [$windowStartAt, $nowAt])->count();
$prevLeads = (clone $base())->whereBetween('received_at', [$prevStartAt, $windowStartAt])->count();
// --- conversion: % статуса 'won' в окне ---
$curPaid = (clone $base())->where('status', 'won')
->whereBetween('received_at', [$windowStart, $now])->count();
->whereBetween('received_at', [$windowStartAt, $nowAt])->count();
$prevPaid = (clone $base())->where('status', 'won')
->whereBetween('received_at', [$prevStart, $windowStart])->count();
->whereBetween('received_at', [$prevStartAt, $windowStartAt])->count();
$curConv = $curLeads > 0 ? round($curPaid / $curLeads * 100, 1) : 0.0;
$prevConv = $prevLeads > 0 ? round($prevPaid / $prevLeads * 100, 1) : 0.0;
@@ -83,7 +93,7 @@ class DashboardController extends Controller
// --- activity: 7 daily-бакетов по received_at (MSK) ---
$activityStart = $now->subDays(6)->startOfDay();
$byDay = (clone $base())
->where('received_at', '>=', $activityStart)
->where('received_at', '>=', MskBoundary::instant($activityStart))
->selectRaw("to_char((received_at AT TIME ZONE 'Europe/Moscow')::date, 'YYYY-MM-DD') AS d, COUNT(*) AS c")
->groupBy('d')
->pluck('c', 'd');
@@ -132,7 +142,7 @@ class DashboardController extends Controller
$avgKopecks = DB::table('lead_charges')
->where('tenant_id', $tenantId)
->where('charge_source', 'rub')
->whereBetween('charged_at', [$windowStart, $now])
->whereBetween('charged_at', [$windowStartAt, $nowAt])
->avg('price_per_lead_kopecks');
$avgLeadCostRub = $avgKopecks !== null ? round((float) $avgKopecks / 100, 2) : null;
@@ -128,12 +128,16 @@ class DealController extends Controller
->orWhere('contact_name', 'ilike', $like);
});
}
// Даты фильтра — МОСКОВСКИЕ сутки, в запрос уходят мгновениями
// (правило названо в App\Support\MskBoundary). Раньше день брался
// гринвичский: «с 2 августа» теряло лиды, пришедшие с 00:00 до 03:00,
// хотя в списке у них стоит второе августа.
if ($receivedFrom !== '') {
$query->where('received_at', '>=', Carbon::parse($receivedFrom)->startOfDay());
$query->where('received_at', '>=', Carbon::parse($receivedFrom, 'Europe/Moscow')->startOfDay()->utc());
}
if ($receivedTo !== '') {
// received_to включительно — до конца дня (+1 день, строгое <).
$query->where('received_at', '<', Carbon::parse($receivedTo)->addDay()->startOfDay());
$query->where('received_at', '<', Carbon::parse($receivedTo, 'Europe/Moscow')->addDay()->startOfDay()->utc());
}
// Audit B2: count_only — отдаём только COUNT(*), пропуская SELECT строк
@@ -53,10 +53,12 @@ class DealExportController extends Controller
$tenantId = (int) $request->user()->tenant_id;
$format = $validated['format'] ?? 'csv';
// Даты фильтра — московские сутки, в запрос мгновениями (App\Support\MskBoundary),
// как и в списке лидов: иначе выгрузка и список расходились бы на три часа.
$from = isset($validated['received_from']) && $validated['received_from'] !== ''
? Carbon::parse($validated['received_from'])->startOfDay() : null;
? Carbon::parse($validated['received_from'], 'Europe/Moscow')->startOfDay()->utc() : null;
$to = isset($validated['received_to']) && $validated['received_to'] !== ''
? Carbon::parse($validated['received_to'])->addDay()->startOfDay() : null;
? Carbon::parse($validated['received_to'], 'Europe/Moscow')->addDay()->startOfDay()->utc() : null;
app(PdAuditLogger::class)->record(
action: 'exported',
@@ -12,6 +12,7 @@ use App\Models\SalesClientAssignment;
use App\Models\SalesUser;
use App\Services\Sales\SalesEarningsService;
use App\Services\Sales\SalesMetricsService;
use App\Support\MskBoundary;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -424,8 +425,14 @@ class SalesClientsController extends Controller
// 6. Лиды по дням (последние 14 дней)
// Оборот за каждый день подтягиваем одним запросом из lead_charges,
// сгруппированным по дню, и мержим с результатами deals.
$last14Start = CarbonImmutable::now('Europe/Moscow')->subDays(13)->startOfDay();
$last14End = CarbonImmutable::now('Europe/Moscow')->startOfDay()->addDay(); // завтра 00:00
// Границы — МГНОВЕНИЯМИ (см. App\Support\MskBoundary): московская полночь,
// отданная в запрос как есть, читается базой как гринвичская, и у самого
// дальнего дня окна пропадали первые три часа, а к завтрашним столбикам
// наоборот прилипали лишние.
$last14Start = MskBoundary::instant(
CarbonImmutable::now('Europe/Moscow')->subDays(13)->startOfDay()
);
$last14End = MskBoundary::dayAfter(CarbonImmutable::now('Europe/Moscow')); // завтра 00:00 МСК
$leadsByDayRows = DB::table('deals')
->where('tenant_id', $tenantId)
@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\BalanceTransaction;
use App\Models\Deal;
use App\Support\MskBoundary;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -129,19 +130,9 @@ class TenantChargesController extends Controller
->orderBy('lc.charged_at', 'desc')
->orderBy('lc.id', 'desc');
if (is_string($period) && $period !== '') {
$now = Carbon::now('Europe/Moscow');
if ($period === 'current_month') {
$query->where('lc.charged_at', '>=', $now->copy()->startOfMonth());
} elseif ($period === 'last_month') {
$query->whereBetween('lc.charged_at', [
$now->copy()->subMonth()->startOfMonth(),
$now->copy()->subMonth()->endOfMonth(),
]);
} elseif ($period === '90d') {
$query->where('lc.charged_at', '>=', $now->copy()->subDays(90));
}
}
// Тот же фильтр периода, что у автоподбор-ветки ниже и у списка:
// раньше здесь лежала своя копия условий, и она разъехалась с общей.
$this->applyPeriodTo($query, 'lc.charged_at', $period);
if ($source !== null && $source !== '') {
$query->where('lc.charge_source', $source);
}
@@ -206,15 +197,17 @@ class TenantChargesController extends Controller
$now = Carbon::now('Europe/Moscow');
// Границы — МГНОВЕНИЯМИ (см. App\Support\MskBoundary): московская полночь,
// отданная в запрос как есть, читается базой как гринвичская, и месяц
// начинался с трёх часов ночи первого числа.
if ($period === 'current_month') {
$query->where($column, '>=', $now->copy()->startOfMonth());
$query->where($column, '>=', MskBoundary::instant($now->copy()->startOfMonth()));
} elseif ($period === 'last_month') {
$query->whereBetween($column, [
$now->copy()->subMonth()->startOfMonth(),
$now->copy()->subMonth()->endOfMonth(),
]);
$prev = $now->copy()->subMonth();
$query->where($column, '>=', MskBoundary::instant($prev->copy()->startOfMonth()))
->where($column, '<', MskBoundary::dayAfter($prev->copy()->endOfMonth()));
} elseif ($period === '90d') {
$query->where($column, '>=', $now->copy()->subDays(90));
$query->where($column, '>=', MskBoundary::instant($now->copy()->subDays(90)));
}
}
}
+3 -1
View File
@@ -103,7 +103,9 @@ final class CsvReconcileJob implements ShouldQueue
// Окно: начало (сегодня − (WINDOW_DAYS1) дней) 00:00 .. сейчас.
$windowEnd = Carbon::now();
$windowStart = Carbon::today()->subDays(self::WINDOW_DAYS - 1);
// Окно считаем по МОСКВЕ и отдаём мгновением (App\Support\MskBoundary):
// гринвичская полночь срезала у самого дальнего дня первые три часа.
$windowStart = Carbon::today('Europe/Moscow')->subDays(self::WINDOW_DAYS - 1)->utc();
// $logId инициализируется внутри try: если сам insertGetId упадёт (БД недоступна),
// catch обязан НЕ обращаться к неинициализированному $logId, а finally — освободить
@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace App\Services\Advertising;
use App\Models\AdCampaign;
use App\Support\MskBoundary;
use App\Support\PhoneNormalizer;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
final class CampaignAudienceBuilder
@@ -35,12 +37,22 @@ final class CampaignAudienceBuilder
if ($isManual) {
$dealPhones = [];
if ($campaign->snapshot_from !== null && $campaign->snapshot_to !== null) {
$from = $campaign->snapshot_from->copy()->startOfDay();
$to = $campaign->snapshot_to->copy()->endOfDay();
// Даты снимка — МОСКОВСКИЕ сутки, в запрос уходят мгновениями
// (App\Support\MskBoundary). Гринвичская полночь сдвигала снимок
// на три часа: у первого дня терялось начало, у последнего
// прихватывался кусок следующего.
// Столбцы объявлены как дата — берём именно календарный день
// (без времени) и уже его толкуем по Москве.
$fromDay = CarbonImmutable::parse((string) $campaign->snapshot_from)->format('Y-m-d');
$toDay = CarbonImmutable::parse((string) $campaign->snapshot_to)->format('Y-m-d');
$from = MskBoundary::instant(CarbonImmutable::parse($fromDay, 'Europe/Moscow'));
$to = MskBoundary::dayAfter(CarbonImmutable::parse($toDay, 'Europe/Moscow'));
$dealPhones = DB::table('deals')
->where('tenant_id', $campaign->tenant_id)
->whereBetween('received_at', [$from, $to])
->where('received_at', '>=', $from)
->where('received_at', '<', $to)
->whereNull('deleted_at')
->pluck('phone')
->all();
+8 -4
View File
@@ -14,6 +14,7 @@ use App\Models\User;
use App\Services\Billing\BalanceToLeadsConverter;
use App\Services\Billing\PricingTierResolver;
use App\Services\Billing\RunwayCalculator;
use App\Support\MskBoundary;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
@@ -197,12 +198,15 @@ class ClientFacts
->when($to !== null, fn ($q) => $q->where('received_at', '<', $to))
->count();
$today = now()->startOfDay();
// Сутки и месяц — московские, в запрос уходят мгновением (правило названо
// в App\Support\MskBoundary). Гринвичские сутки начинались в 03:00 МСК:
// бот отвечал клиенту про «сегодня», прихватывая кусок вчерашнего дня.
$today = MskBoundary::instant(now('Europe/Moscow')->startOfDay());
return 'Заявки: сегодня '.$count($today)
.', вчера '.$count($today->copy()->subDay(), $today)
.', вчера '.$count($today->subDay(), $today)
.', за 7 дней '.$count(now()->subDays(7))
.', за месяц '.$count(now()->startOfMonth()).'.';
.', за месяц '.$count(MskBoundary::instant(now('Europe/Moscow')->startOfMonth())).'.';
}
/** Списано за месяц и последнее пополнение. */
@@ -210,7 +214,7 @@ class ClientFacts
{
$spentKopecks = (int) LeadCharge::query()
->where('tenant_id', $tenantId)
->where('charged_at', '>=', now()->startOfMonth())
->where('charged_at', '>=', MskBoundary::instant(now('Europe/Moscow')->startOfMonth()))
->sum('price_per_lead_kopecks');
$topup = BalanceTransaction::query()
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Services\Reports\Providers;
use App\Models\ReportJob;
use App\Support\MskBoundary;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
@@ -40,15 +41,17 @@ class BillingSummaryProvider implements ReportDataProvider
public function rows(ReportJob $job): array
{
$params = $job->parameters ?? [];
$dateFrom = Carbon::parse($params['date_from'])->startOfDay();
$dateTo = Carbon::parse($params['date_to'])->endOfDay();
// Даты отчёта — московские сутки, мгновениями (App\Support\MskBoundary).
$dateFrom = MskBoundary::instant(Carbon::parse($params['date_from'], 'Europe/Moscow')->startOfDay());
$dateTo = MskBoundary::dayAfter(Carbon::parse($params['date_to'], 'Europe/Moscow'));
return DB::transaction(function () use ($job, $dateFrom, $dateTo): array {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $job->tenant_id);
$rows = DB::table('balance_transactions')
->where('tenant_id', $job->tenant_id)
->whereBetween('created_at', [$dateFrom, $dateTo])
->where('created_at', '>=', $dateFrom)
->where('created_at', '<', $dateTo)
->groupBy('type')
->orderBy('type')
->selectRaw('type, COUNT(*) AS cnt, COALESCE(SUM(amount_rub), 0) AS sum_rub')
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Services\Reports\Providers;
use App\Models\ReportJob;
use App\Support\MskBoundary;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
@@ -29,8 +30,11 @@ class DealsExportProvider implements ReportDataProvider
public function rows(ReportJob $job): array
{
$params = $job->parameters ?? [];
$dateFrom = Carbon::parse($params['date_from'])->startOfDay();
$dateTo = Carbon::parse($params['date_to'])->endOfDay();
// Даты отчёта — МОСКОВСКИЕ сутки, в запрос уходят мгновениями
// (App\Support\MskBoundary). Раньше день брался гринвичский, и отчёт
// «с 1 по 31» терял заявки первых трёх часов каждой границы.
$dateFrom = MskBoundary::instant(Carbon::parse($params['date_from'], 'Europe/Moscow')->startOfDay());
$dateTo = MskBoundary::dayAfter(Carbon::parse($params['date_to'], 'Europe/Moscow'));
return DB::transaction(function () use ($job, $params, $dateFrom, $dateTo): array {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $job->tenant_id);
@@ -41,7 +45,8 @@ class DealsExportProvider implements ReportDataProvider
->leftJoin('supplier_lead_costs', 'deals.id', '=', 'supplier_lead_costs.deal_id')
->where('deals.tenant_id', $job->tenant_id)
->whereNull('deals.deleted_at')
->whereBetween('deals.received_at', [$dateFrom, $dateTo])
->where('deals.received_at', '>=', $dateFrom)
->where('deals.received_at', '<', $dateTo)
->orderBy('deals.received_at')
->orderBy('deals.id')
->select([
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Services\Reports\Providers;
use App\Models\ReportJob;
use App\Support\MskBoundary;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
@@ -29,8 +30,9 @@ class ManagersSummaryProvider implements ReportDataProvider
public function rows(ReportJob $job): array
{
$params = $job->parameters ?? [];
$dateFrom = Carbon::parse($params['date_from'])->startOfDay();
$dateTo = Carbon::parse($params['date_to'])->endOfDay();
// Даты отчёта — московские сутки, мгновениями (App\Support\MskBoundary).
$dateFrom = MskBoundary::instant(Carbon::parse($params['date_from'], 'Europe/Moscow')->startOfDay());
$dateTo = MskBoundary::dayAfter(Carbon::parse($params['date_to'], 'Europe/Moscow'));
return DB::transaction(function () use ($job, $dateFrom, $dateTo): array {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $job->tenant_id);
@@ -40,7 +42,8 @@ class ManagersSummaryProvider implements ReportDataProvider
->where('deals.tenant_id', $job->tenant_id)
->whereNull('deals.deleted_at')
->where('deals.is_test', false)
->whereBetween('deals.received_at', [$dateFrom, $dateTo])
->where('deals.received_at', '>=', $dateFrom)
->where('deals.received_at', '<', $dateTo)
->groupBy('deals.manager_id', 'users.first_name', 'users.last_name', 'users.email')
->orderByRaw('COUNT(*) DESC')
->orderBy('deals.manager_id')
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Services\Reports\Providers;
use App\Models\ReportJob;
use App\Support\MskBoundary;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
@@ -28,8 +29,9 @@ class SourcesSummaryProvider implements ReportDataProvider
public function rows(ReportJob $job): array
{
$params = $job->parameters ?? [];
$dateFrom = Carbon::parse($params['date_from'])->startOfDay();
$dateTo = Carbon::parse($params['date_to'])->endOfDay();
// Даты отчёта — московские сутки, мгновениями (App\Support\MskBoundary).
$dateFrom = MskBoundary::instant(Carbon::parse($params['date_from'], 'Europe/Moscow')->startOfDay());
$dateTo = MskBoundary::dayAfter(Carbon::parse($params['date_to'], 'Europe/Moscow'));
return DB::transaction(function () use ($job, $dateFrom, $dateTo): array {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $job->tenant_id);
@@ -38,7 +40,8 @@ class SourcesSummaryProvider implements ReportDataProvider
->where('tenant_id', $job->tenant_id)
->whereNull('deleted_at')
->where('is_test', false)
->whereBetween('received_at', [$dateFrom, $dateTo])
->where('received_at', '>=', $dateFrom)
->where('received_at', '<', $dateTo)
->groupBy('utm_source')
->orderByRaw('COUNT(*) DESC')
->orderBy('utm_source')
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Support;
use Carbon\CarbonImmutable;
use DateTimeInterface;
/**
* Граница периода, посчитанная по Москве, как МГНОВЕНИЕ для сравнения с
* отметками времени в базе.
*
* 🔴 Зачем это нужно. Laravel подставляет дату в запрос строкой «Y-m-d H:i:
* в её собственном поясе, БЕЗ смещения, а сеанс базы живёт в UTC. Граница
* «02.08 00:00 МСК» уезжала в базу как «02.08 00:00» и читалась как UTC, то есть
* как 03:00 МСК. Из-за этого сутки в отчётах начинались с трёх часов ночи:
* пополнение клиента в 01:00 попадало во вчерашний день, а первые три часа
* первого числа месяца в прошлый месяц. Найдено 02.08.2026; днём эта ошибка
* не видна вовсе только с 00:00 до 03:00 МСК.
*
* 🪤 Переводить всё подряд НЕЛЬЗЯ. Сравнение с КАЛЕНДАРНОЙ ДАТОЙ (столбцы вроде
* `paid_on`, куда идёт `toDateString()`) и счёт числа дней должны остаться
* московскими там смысл именно календарный, и перевод их сломает.
*
* Отдел продаж пользуется своим SalesPeriodRange::startInstant()
* то же правило, только привязанное к периоду продаж.
*/
final class MskBoundary
{
/** Момент границы как мгновение — годится для сравнения с отметкой времени. */
public static function instant(DateTimeInterface $boundary): CarbonImmutable
{
return CarbonImmutable::instance($boundary)->utc();
}
/**
* Первое мгновение ПОСЛЕ указанного дня верхняя граница строгая («меньше»).
* Полуинтервал, а не `<= 23:59:59`: иначе теряется последняя секунда с долями,
* ведь дробная часть до базы всё равно не доезжает.
*/
public static function dayAfter(DateTimeInterface $lastDay): CarbonImmutable
{
return CarbonImmutable::instance($lastDay)->startOfDay()->addDay()->utc();
}
}
+16
View File
@@ -10,10 +10,26 @@ use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Bot\ClientFacts;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// Часы держим на полудне по Москве. Заявки бот считает по МОСКОВСКИМ суткам, а
// фикстуры ниже строятся от `now()`: между 00:00 и 03:00 МСК московский и
// гринвичский день — разные даты, и проверки краснели бы только по ночам.
beforeEach(function (): void {
$polden = CarbonImmutable::now('Europe/Moscow')->startOfDay()->addHours(12);
Carbon::setTestNow($polden);
CarbonImmutable::setTestNow($polden);
});
afterEach(function (): void {
Carbon::setTestNow();
CarbonImmutable::setTestNow();
});
/** Две ступени: 1-я — 300 заявок по 50 ₽, 2-я — «всё свыше» по 40 ₽. */
function tiers(): void
{
@@ -24,10 +24,26 @@ use App\Models\Tenant;
use App\Models\User;
use App\Services\Bot\AnswerGuard;
use App\Services\Bot\ClientFacts;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// Часы держим на полудне по Москве — как в ClientFactsTest. Карточка считает
// «сегодня» по московским суткам, а заявки здесь кладутся от `now()`; ночью,
// с 00:00 до 03:00 МСК, эти два дня расходятся и проверка краснела бы.
beforeEach(function (): void {
$polden = CarbonImmutable::now('Europe/Moscow')->startOfDay()->addHours(12);
Carbon::setTestNow($polden);
CarbonImmutable::setTestNow($polden);
});
afterEach(function (): void {
Carbon::setTestNow();
CarbonImmutable::setTestNow();
});
/**
* Те же две ступени, что в ClientFactsTest::tiers() имя своё (guardContract*), чтобы
* не столкнуться с глобальной функцией `tiers()` из ClientFactsTest.php при общем прогоне.
+5 -1
View File
@@ -335,7 +335,11 @@ test('GET /api/deals фильтрует по received_from/received_to', functio
});
test('GET /api/deals received_to включает весь день (конец дня)', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-16 23:30:00']);
// 20:30 по Гринвичу = 23:30 в Москве, то есть поздний вечер ШЕСТНАДЦАТОГО.
// Раньше здесь стояло «23:30» гринвичских — по московскому календарю это уже
// 02:30 семнадцатого, и проверка закрепляла старую ошибку: сутки фильтра
// считались гринвичские, а в списке у лида стоит московское время.
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-16 20:30:00']);
expect($this->getJson('/api/deals?received_to=2026-05-16')->json('total'))->toBe(1);
});
+351
View File
@@ -0,0 +1,351 @@
<?php
declare(strict_types=1);
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Project;
use App\Models\ReportJob;
use App\Models\SalesUser;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Bot\ClientFacts;
use App\Services\Reports\Providers\DealsExportProvider;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
uses(DatabaseTransactions::class);
/**
* Ночная граница суток: события первых трёх часов дня по Москве.
*
* 🔴 Класс ошибки. Границу периода считают по Москве, а в запрос она уходит
* строкой «Y-m-d H:i: БЕЗ смещения. Сеанс базы живёт по Гринвичу и читает
* «02.08 00:00» как гринвичскую полночь, то есть как 03:00 МСК. В итоге сутки
* в отчётах начинались с трёх часов ночи: пополнение клиента в 01:00 попадало
* во вчерашний день, а первые три часа первого числа в прошлый месяц.
*
* 🔑 Видно ТОЛЬКО ночью. Тот же код в 23:55 давал зелёный прогон, в 00:20
* красный. Поэтому часы здесь ЗАМОРОЖЕНЫ на 00:30 МСК, а не отданы на волю
* случая, и возвращаются в afterEach.
*
* 🪤 Данные в фикстурах кладём МГНОВЕНИЕМ по Гринвичу (->utc()): объект даты по
* дороге в запрос смещение теряет, и фикстура приехала бы в базу на три часа не
* туда две ошибки погасили бы друг друга. Строка со смещением («+03:00») тут
* НЕ годится: у `ad_wallet_transactions.created_at` столбец без пояса, и база
* смещение из строки молча отбрасывает проверка становится зелёной впустую.
*/
afterEach(function (): void {
Carbon::setTestNow();
CarbonImmutable::setTestNow();
});
/** Заморозить часы на 00:30 МСК указанного дня (по умолчанию — сегодня). */
function night_freeze(?CarbonImmutable $day = null): CarbonImmutable
{
$moment = ($day ?? CarbonImmutable::now('Europe/Moscow'))
->setTimezone('Europe/Moscow')
->startOfDay()
->addMinutes(30);
Carbon::setTestNow($moment);
CarbonImmutable::setTestNow($moment);
return $moment;
}
/** 00:10 МСК того же дня — двадцатью минутами раньше замороженного «сейчас». */
function night_tenPastMidnight(CarbonImmutable $frozen): CarbonImmutable
{
return $frozen->setTimezone('Europe/Moscow')->startOfDay()->addMinutes(10);
}
// ── 1. Дашборд клиента: range=today ──────────────────────────────────────────
it('дашборд клиента: лид, пришедший в 00:10 МСК, попадает в «сегодня»', function () {
$frozen = night_freeze();
$tenant = Tenant::factory()->create();
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
Deal::factory()->create([
'tenant_id' => $tenant->id,
'project_id' => $project->id,
'status' => 'new',
'is_test' => false,
'deleted_at' => null,
'received_at' => night_tenPastMidnight($frozen)->utc(),
]);
$this->actingAs(User::factory()->create(['tenant_id' => $tenant->id]));
expect((int) $this->getJson('/api/dashboard/summary?range=today')->json('leads_received.value'))
->toBe(1);
});
// ── 2. Сводка администратора: period=today ───────────────────────────────────
it('сводка администратора: пополнение в 00:10 МСК попадает в «сегодня»', function () {
$frozen = night_freeze();
$tenant = Tenant::factory()->create();
DB::table('balance_transactions')->insert([
// Сегодня в 00:10 МСК — обязано попасть в «сегодня».
[
'tenant_id' => $tenant->id,
'type' => 'topup',
'amount_rub' => 5000,
'created_at' => night_tenPastMidnight($frozen)->utc(),
],
// ВЧЕРА днём — обязано остаться за бортом. Без этой строки проверка была бы
// пустой: при съехавших на три часа сутках вчерашний день частично попадал
// в «сегодня», а ночное пополнение случайно оказывалось внутри окна.
[
'tenant_id' => $tenant->id,
'type' => 'topup',
'amount_rub' => 7000,
'created_at' => night_tenPastMidnight($frozen)->subDay()->addHours(12)->utc(),
],
]);
$answer = $this->getJson('/api/admin/dashboard/finance?period=today');
$answer->assertOk();
expect((float) $answer->json('kpi.topups_rub'))->toBe(5000.0);
});
// ── 3. Списания клиента: period=current_month ────────────────────────────────
it('списания клиента: списание в 00:10 МСК первого числа попадает в текущий месяц', function () {
$frozen = night_freeze(CarbonImmutable::now('Europe/Moscow')->startOfMonth());
$moment = night_tenPastMidnight($frozen);
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$this->actingAs($user);
$deal = Deal::factory()->create([
'tenant_id' => $tenant->id,
'received_at' => $moment->utc(),
]);
LeadCharge::factory()->create([
'tenant_id' => $tenant->id,
'deal_id' => $deal->id,
'deal_received_at' => $deal->received_at,
'charged_at' => $moment->utc(),
]);
$answer = $this->getJson('/api/billing/charges?period=current_month');
$answer->assertOk();
expect($answer->json('data'))->toHaveCount(1);
});
// ── 4. Расход на рекламу у администратора: period=current_month ──────────────
it('расход на рекламу: списание в 00:10 МСК первого числа попадает в текущий месяц', function () {
$frozen = night_freeze(CarbonImmutable::now('Europe/Moscow')->startOfMonth());
$tenant = Tenant::factory()->create();
DB::table('ad_wallet_transactions')->insert([
'tenant_id' => $tenant->id,
'type' => 'charge',
'amount_rub' => -390.00,
'balance_rub_after' => '0.00',
'channel' => 'yandex',
'related_type' => 'campaign',
'related_id' => 1,
// Столбец без пояса — кладём гринвичское настенное время, как это делает
// само приложение (config('app.timezone') = UTC).
'created_at' => night_tenPastMidnight($frozen)->utc()->toDateTimeString(),
]);
$answer = $this->getJson('/api/admin/advertising/spend?period=current_month');
$answer->assertOk();
expect((float) $answer->json('totals.client_spend_rub'))->toBe(390.0);
});
// ── 5. Карточка клиента у отдела продаж: лиды по дням ────────────────────────
it('карточка клиента: лид в 00:10 МСК самого дальнего дня окна не пропадает', function () {
$frozen = night_freeze();
$tenant = Tenant::factory()->create();
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
// Окно карточки — 14 дней, считая сегодняшний. Проверяем САМЫЙ ДАЛЬНИЙ день:
// сегодняшний столбик от съехавшей нижней границы не страдает вовсе, поэтому
// проверка «лид сегодня в 00:10» была бы пустой.
$farDay = night_tenPastMidnight($frozen)->subDays(13);
Deal::factory()->create([
'tenant_id' => $tenant->id,
'project_id' => $project->id,
'is_test' => false,
'deleted_at' => null,
'received_at' => $farDay->utc(),
]);
$boss = SalesUser::create([
'name' => 'Ночной начальник',
'email' => 'night-boundary-'.uniqid().'@test.local',
'password' => Hash::make('secret'),
'role' => 'head',
'is_active' => true,
]);
$token = $boss->createToken('sales')->plainTextToken;
$answer = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/sales/clients/'.$tenant->id);
$answer->assertOk();
$den = $farDay->setTimezone('Europe/Moscow')->toDateString();
$byDay = collect($answer->json('leads_by_day'))->keyBy('date');
expect($byDay->has($den))->toBeTrue()
->and($byDay[$den]['count'])->toBe(1);
});
// ── 6. Список лидов клиента: фильтр по датам ─────────────────────────────────
it('список лидов: фильтр «с сегодня» не теряет лид, пришедший в 00:10 МСК', function () {
$frozen = night_freeze();
$tenant = Tenant::factory()->create();
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
Deal::factory()->create([
'tenant_id' => $tenant->id,
'project_id' => $project->id,
'is_test' => false,
'deleted_at' => null,
'received_at' => night_tenPastMidnight($frozen)->utc(),
]);
$this->actingAs(User::factory()->create(['tenant_id' => $tenant->id]));
$den = $frozen->setTimezone('Europe/Moscow')->toDateString();
$answer = $this->getJson('/api/deals?received_from='.$den);
$answer->assertOk();
expect($answer->json('total'))->toBe(1);
});
// ── 7. Биллинг у администратора: выручка за месяц ────────────────────────────
it('биллинг: пополнение в 00:10 МСК первого числа попадает в выручку месяца', function () {
$frozen = night_freeze(CarbonImmutable::now('Europe/Moscow')->startOfMonth());
$tenant = Tenant::factory()->create();
DB::table('balance_transactions')->insert([
// Первое число, 00:10 МСК — уже новый месяц.
[
'tenant_id' => $tenant->id,
'type' => 'topup',
'amount_rub' => 5000,
'created_at' => night_tenPastMidnight($frozen)->utc(),
],
// Последний день прошлого месяца, полдень — в выручку НЕ идёт.
[
'tenant_id' => $tenant->id,
'type' => 'topup',
'amount_rub' => 7000,
'created_at' => night_tenPastMidnight($frozen)->subDay()->addHours(12)->utc(),
],
]);
$answer = $this->getJson('/api/admin/billing');
$answer->assertOk();
expect((float) $answer->json('summary.monthly_revenue_rub'))->toBe(5000.0);
});
// ── 8. Карточка клиента у администратора: лиды за сегодня ────────────────────
it('карточка у администратора: лид в 00:10 МСК считается сегодняшним', function () {
$frozen = night_freeze();
$tenant = Tenant::factory()->create();
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
Deal::factory()->create([
'tenant_id' => $tenant->id,
'project_id' => $project->id,
'deleted_at' => null,
'received_at' => night_tenPastMidnight($frozen)->utc(),
]);
// Вчера в полдень — сегодняшним считаться не должен.
Deal::factory()->create([
'tenant_id' => $tenant->id,
'project_id' => $project->id,
'deleted_at' => null,
'received_at' => night_tenPastMidnight($frozen)->subDay()->addHours(12)->utc(),
]);
$answer = $this->getJson('/api/admin/tenants/'.$tenant->subdomain);
$answer->assertOk();
expect($answer->json('metrics.leads_today'))->toBe(1);
});
// ── 9. Ответ бота клиенту: заявки за сегодня ─────────────────────────────────
it('бот: заявка в 00:10 МСК попадает в «сегодня»', function () {
$this->seed(PricingTierSeeder::class);
$frozen = night_freeze();
$tenant = Tenant::factory()->create();
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
Deal::factory()->create([
'tenant_id' => $tenant->id,
'project_id' => $project->id,
'deleted_at' => null,
'received_at' => night_tenPastMidnight($frozen)->utc(),
]);
// Вчера в полдень — в «сегодня» попадать не должен. Без этой заявки проверка
// была бы пустой: гринвичские сутки в 00:30 МСК — это ещё вчерашний день,
// и ночная заявка случайно оказывалась внутри окна.
Deal::factory()->create([
'tenant_id' => $tenant->id,
'project_id' => $project->id,
'deleted_at' => null,
'received_at' => night_tenPastMidnight($frozen)->subDay()->addHours(12)->utc(),
]);
$card = app(ClientFacts::class)->card($user->id);
expect($card)->toContain('Заявки: сегодня 1');
});
// ── 10. Отчёт «выгрузка заявок» за один день ─────────────────────────────────
it('отчёт за сегодня: заявка в 00:10 МСК в него попадает, вчерашняя — нет', function () {
$frozen = night_freeze();
$tenant = Tenant::factory()->create();
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
Deal::factory()->create([
'tenant_id' => $tenant->id,
'project_id' => $project->id,
'deleted_at' => null,
'received_at' => night_tenPastMidnight($frozen)->utc(),
]);
Deal::factory()->create([
'tenant_id' => $tenant->id,
'project_id' => $project->id,
'deleted_at' => null,
'received_at' => night_tenPastMidnight($frozen)->subDay()->addHours(12)->utc(),
]);
$den = $frozen->setTimezone('Europe/Moscow')->toDateString();
$job = new ReportJob([
'tenant_id' => $tenant->id,
'type' => 'deals_export',
'parameters' => ['format' => 'csv', 'date_from' => $den, 'date_to' => $den],
]);
expect((new DealsExportProvider)->rows($job))->toHaveCount(1);
});