Connect AdSense over OAuth, sync entities and daily reports locally, and show placement performance in the admin panel without calling the Management API from public pages.
289 lines
11 KiB
PHP
289 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Adsense;
|
|
|
|
use App\Models\AdsenseConnection;
|
|
use App\Models\AdsenseDailyStat;
|
|
use App\Models\AdsenseEntity;
|
|
use App\Models\Country;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Schema;
|
|
|
|
final class AdsenseAnalyticsQueryService
|
|
{
|
|
/**
|
|
* @return array{from: Carbon, to: Carbon, previous_from: Carbon, previous_to: Carbon, period: string}
|
|
*/
|
|
public function resolveRange(?string $period, ?string $from, ?string $to): array
|
|
{
|
|
$period = $period ?: '30d';
|
|
$today = now()->startOfDay();
|
|
|
|
[$start, $end] = match ($period) {
|
|
'today' => [$today->copy(), $today->copy()],
|
|
'yesterday' => [$today->copy()->subDay(), $today->copy()->subDay()],
|
|
'7d' => [$today->copy()->subDays(6), $today->copy()],
|
|
'custom' => [
|
|
Carbon::parse($from ?: $today->copy()->subDays(29)->toDateString())->startOfDay(),
|
|
Carbon::parse($to ?: $today->toDateString())->startOfDay(),
|
|
],
|
|
default => [$today->copy()->subDays(29), $today->copy()],
|
|
};
|
|
|
|
if ($end->lt($start)) {
|
|
[$start, $end] = [$end, $start];
|
|
}
|
|
|
|
$days = $start->diffInDays($end) + 1;
|
|
$previousEnd = $start->copy()->subDay();
|
|
$previousStart = $previousEnd->copy()->subDays($days - 1);
|
|
|
|
return [
|
|
'from' => $start,
|
|
'to' => $end,
|
|
'previous_from' => $previousStart,
|
|
'previous_to' => $previousEnd,
|
|
'period' => $period === 'custom' ? 'custom' : (in_array($period, ['today', 'yesterday', '7d', '30d'], true) ? $period : '30d'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function dashboard(AdsenseConnection $connection, Carbon $from, Carbon $to, Carbon $previousFrom, Carbon $previousTo): array
|
|
{
|
|
$account = (string) $connection->account_resource_name;
|
|
$current = $this->totals($account, $from, $to);
|
|
$previous = $this->totals($account, $previousFrom, $previousTo);
|
|
$currency = $current['currency_code'] ?? $previous['currency_code'] ?? 'USD';
|
|
|
|
return [
|
|
'kpis' => [
|
|
$this->kpi('estimated_earnings', 'Estimated Earnings', $current['estimated_earnings'], $previous['estimated_earnings'], 'money', $currency),
|
|
$this->kpi('page_views', 'Page Views', $current['page_views'], $previous['page_views'], 'integer'),
|
|
$this->kpi('impressions', 'Ad Impressions', $current['impressions'], $previous['impressions'], 'integer'),
|
|
$this->kpi('clicks', 'Clicks', $current['clicks'], $previous['clicks'], 'integer'),
|
|
$this->kpi('page_views_ctr', 'Page CTR', $this->ratio($current['clicks'], $current['page_views']), $this->ratio($previous['clicks'], $previous['page_views']), 'percent'),
|
|
$this->kpi('page_views_rpm', 'Page RPM', $this->rpm($current['estimated_earnings'], $current['page_views']), $this->rpm($previous['estimated_earnings'], $previous['page_views']), 'money', $currency),
|
|
$this->kpi('ad_requests_coverage', 'Ad Request Coverage', $this->ratio($current['matched_ad_requests'], $current['ad_requests']), $this->ratio($previous['matched_ad_requests'], $previous['ad_requests']), 'percent'),
|
|
$this->kpi('cost_per_click', 'CPC', $this->cpc($current['estimated_earnings'], $current['clicks']), $this->cpc($previous['estimated_earnings'], $previous['clicks']), 'money', $currency),
|
|
],
|
|
'chart' => $this->chart($account, $from, $to),
|
|
'placements' => $this->dimensionTable($account, AdsenseDailyStat::DIMENSION_AD_UNIT, $from, $to, $currency),
|
|
'platforms' => $this->dimensionTable($account, AdsenseDailyStat::DIMENSION_PLATFORM, $from, $to, $currency),
|
|
'countries' => array_slice($this->dimensionTable($account, AdsenseDailyStat::DIMENSION_COUNTRY, $from, $to, $currency), 0, 20),
|
|
'currency_code' => $currency,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function totals(string $account, Carbon $from, Carbon $to): array
|
|
{
|
|
$row = AdsenseDailyStat::query()
|
|
->where('account_resource_name', $account)
|
|
->where('dimension_type', AdsenseDailyStat::DIMENSION_TOTAL)
|
|
->whereDate('date', '>=', $from->toDateString())
|
|
->whereDate('date', '<=', $to->toDateString())
|
|
->selectRaw('
|
|
SUM(page_views) as page_views,
|
|
SUM(ad_requests) as ad_requests,
|
|
SUM(matched_ad_requests) as matched_ad_requests,
|
|
SUM(impressions) as impressions,
|
|
SUM(clicks) as clicks,
|
|
SUM(estimated_earnings) as estimated_earnings,
|
|
MAX(currency_code) as currency_code
|
|
')
|
|
->first();
|
|
|
|
return [
|
|
'page_views' => (int) ($row?->page_views ?? 0),
|
|
'ad_requests' => (int) ($row?->ad_requests ?? 0),
|
|
'matched_ad_requests' => (int) ($row?->matched_ad_requests ?? 0),
|
|
'impressions' => (int) ($row?->impressions ?? 0),
|
|
'clicks' => (int) ($row?->clicks ?? 0),
|
|
'estimated_earnings' => (string) ($row?->estimated_earnings ?? '0'),
|
|
'currency_code' => $row?->currency_code,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function chart(string $account, Carbon $from, Carbon $to): array
|
|
{
|
|
$rows = AdsenseDailyStat::query()
|
|
->where('account_resource_name', $account)
|
|
->where('dimension_type', AdsenseDailyStat::DIMENSION_TOTAL)
|
|
->whereDate('date', '>=', $from->toDateString())
|
|
->whereDate('date', '<=', $to->toDateString())
|
|
->orderBy('date')
|
|
->get();
|
|
|
|
return $rows->map(function (AdsenseDailyStat $row): array {
|
|
return [
|
|
'date' => optional($row->date)?->toDateString(),
|
|
'estimated_earnings' => (float) $row->estimated_earnings,
|
|
'page_views' => (int) $row->page_views,
|
|
'page_views_rpm' => (float) $row->page_views_rpm,
|
|
'clicks' => (int) $row->clicks,
|
|
];
|
|
})->values()->all();
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function dimensionTable(string $account, string $dimensionType, Carbon $from, Carbon $to, ?string $currency): array
|
|
{
|
|
$rows = AdsenseDailyStat::query()
|
|
->where('account_resource_name', $account)
|
|
->where('dimension_type', $dimensionType)
|
|
->whereDate('date', '>=', $from->toDateString())
|
|
->whereDate('date', '<=', $to->toDateString())
|
|
->selectRaw('
|
|
dimension_key,
|
|
MAX(dimension_label) as dimension_label,
|
|
SUM(page_views) as page_views,
|
|
SUM(impressions) as impressions,
|
|
SUM(clicks) as clicks,
|
|
SUM(estimated_earnings) as estimated_earnings
|
|
')
|
|
->groupBy('dimension_key')
|
|
->orderByDesc('estimated_earnings')
|
|
->get();
|
|
|
|
$labels = $this->entityLabels($account, $dimensionType);
|
|
$countryNames = $dimensionType === AdsenseDailyStat::DIMENSION_COUNTRY ? $this->countryNames() : collect();
|
|
|
|
return $rows->map(function ($row) use ($labels, $countryNames, $currency, $dimensionType): array {
|
|
$key = (string) $row->dimension_key;
|
|
$label = (string) ($labels[$key] ?? $countryNames[$key] ?? $row->dimension_label ?? $key);
|
|
if ($dimensionType === AdsenseDailyStat::DIMENSION_PLATFORM) {
|
|
$label = $this->platformLabel($key, $label);
|
|
}
|
|
|
|
$pageViews = (int) $row->page_views;
|
|
$impressions = (int) $row->impressions;
|
|
$clicks = (int) $row->clicks;
|
|
$earnings = (string) ($row->estimated_earnings ?? '0');
|
|
|
|
return [
|
|
'key' => $key,
|
|
'label' => $label,
|
|
'page_views' => $pageViews,
|
|
'impressions' => $impressions,
|
|
'clicks' => $clicks,
|
|
'ctr' => $this->ratio($clicks, $pageViews),
|
|
'rpm' => $this->rpm($earnings, $pageViews),
|
|
'revenue' => $earnings,
|
|
'currency_code' => $currency,
|
|
];
|
|
})->values()->all();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private function entityLabels(string $account, string $dimensionType): array
|
|
{
|
|
if (! in_array($dimensionType, [AdsenseDailyStat::DIMENSION_AD_UNIT, AdsenseDailyStat::DIMENSION_CUSTOM_CHANNEL], true)) {
|
|
return [];
|
|
}
|
|
|
|
return AdsenseEntity::query()
|
|
->where('account_resource_name', $account)
|
|
->where('type', $dimensionType)
|
|
->pluck('display_name', 'reporting_dimension_id')
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @return Collection<string, string>
|
|
*/
|
|
private function countryNames(): Collection
|
|
{
|
|
if (! Schema::hasTable('countries')) {
|
|
return collect();
|
|
}
|
|
|
|
return Country::query()
|
|
->get(['iso2', 'name', 'name_common'])
|
|
->mapWithKeys(function (Country $country): array {
|
|
$code = strtoupper((string) $country->iso2);
|
|
$name = (string) ($country->name_common ?: $country->name ?: $code);
|
|
|
|
return $code !== '' ? [$code => $name] : [];
|
|
});
|
|
}
|
|
|
|
private function platformLabel(string $key, string $fallback): string
|
|
{
|
|
return match (strtoupper($key)) {
|
|
'DESKTOP' => 'Desktop',
|
|
'HIGH_END_MOBILE', 'MOBILE' => 'Mobile',
|
|
'TABLET' => 'Tablet',
|
|
default => $fallback !== '' ? $fallback : $key,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function kpi(string $key, string $label, mixed $current, mixed $previous, string $format, ?string $currency = null): array
|
|
{
|
|
return [
|
|
'key' => $key,
|
|
'label' => $label,
|
|
'value' => $current,
|
|
'previous' => $previous,
|
|
'change_percent' => $this->changePercent($current, $previous),
|
|
'format' => $format,
|
|
'currency_code' => $currency,
|
|
];
|
|
}
|
|
|
|
private function changePercent(mixed $current, mixed $previous): ?float
|
|
{
|
|
$current = (float) $current;
|
|
$previous = (float) $previous;
|
|
|
|
if ($previous == 0.0) {
|
|
return null;
|
|
}
|
|
|
|
return (($current - $previous) / $previous) * 100;
|
|
}
|
|
|
|
private function ratio(int $numerator, int $denominator): ?float
|
|
{
|
|
if ($denominator === 0) {
|
|
return null;
|
|
}
|
|
|
|
return $numerator / $denominator;
|
|
}
|
|
|
|
private function rpm(string|float|int|null $earnings, int $pageViews): ?float
|
|
{
|
|
if ($pageViews === 0) {
|
|
return null;
|
|
}
|
|
|
|
return ((float) $earnings / $pageViews) * 1000;
|
|
}
|
|
|
|
private function cpc(string|float|int|null $earnings, int $clicks): ?float
|
|
{
|
|
if ($clicks === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (float) $earnings / $clicks;
|
|
}
|
|
}
|