Files
portal/app/tests/Feature/NightBoundaryMskTest.php
T
Дмитрий 1ed82b6b36 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. Форматтер: чисто.
2026-08-02 10:18:45 +03:00

352 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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:s» БЕЗ смещения. Сеанс базы живёт по Гринвичу и читает
* «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);
});