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:
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,9 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
|
||||
// Окно: начало (сегодня − (WINDOW_DAYS−1) дней) 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();
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use DateTimeInterface;
|
||||
|
||||
/**
|
||||
* Граница периода, посчитанная по Москве, — как МГНОВЕНИЕ для сравнения с
|
||||
* отметками времени в базе.
|
||||
*
|
||||
* 🔴 Зачем это нужно. Laravel подставляет дату в запрос строкой «Y-m-d H:i:s»
|
||||
* в её собственном поясе, БЕЗ смещения, а сеанс базы живёт в 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();
|
||||
}
|
||||
}
|
||||
@@ -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 при общем прогоне.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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: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);
|
||||
});
|
||||
Reference in New Issue
Block a user