1ed82b6b36
Продолжение правки отдела продаж от 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. Форматтер: чисто.
352 lines
16 KiB
PHP
352 lines
16 KiB
PHP
<?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);
|
||
});
|