Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Stats;
|
||||
|
||||
/**
|
||||
* Resolves a set of location selections down to the single most specific level.
|
||||
*
|
||||
* Combining levels with OR would let a broader level (e.g. country) swallow a
|
||||
* narrower one (e.g. division), making the narrower filter ineffective — so only
|
||||
* the deepest level that has selections is applied.
|
||||
*/
|
||||
class LocationFilterResolver
|
||||
{
|
||||
/**
|
||||
* @return array{column: string, ids: array<int>}|null null when nothing is selected
|
||||
*/
|
||||
public static function resolve(array $countryIds, array $divisionIds, array $cityIds, array $districtIds): ?array
|
||||
{
|
||||
foreach ([
|
||||
'district_id' => $districtIds,
|
||||
'city_id' => $cityIds,
|
||||
'division_id' => $divisionIds,
|
||||
'country_id' => $countryIds,
|
||||
] as $column => $ids) {
|
||||
$ids = array_values(array_filter(array_map('intval', $ids)));
|
||||
if (!empty($ids)) {
|
||||
return ['column' => $column, 'ids' => $ids];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrain a profile query to profiles whose location matches the deepest selected level.
|
||||
*/
|
||||
public static function apply($query, array $countryIds, array $divisionIds, array $cityIds, array $districtIds)
|
||||
{
|
||||
$resolved = self::resolve($countryIds, $divisionIds, $cityIds, $districtIds);
|
||||
|
||||
if ($resolved === null) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->whereHas('locations', function ($q) use ($resolved) {
|
||||
$q->whereIn($resolved['column'], $resolved['ids']);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Stats;
|
||||
|
||||
/**
|
||||
* Which keys occur inside a date window that only ever moves forward.
|
||||
*
|
||||
* Built for the network time-lapse: its layers are windows over a day-ordered
|
||||
* list of [day, key] events - "traded in the month ending on this step",
|
||||
* "traded before this date" - and each step moves them on by a week or a
|
||||
* month. Two pointers walk the events once in total, so all steps together
|
||||
* cost one pass, and each step reports only the keys that entered or left.
|
||||
*
|
||||
* Days are 'Y-m-d' strings, which compare correctly as strings.
|
||||
*/
|
||||
class SlidingWindow
|
||||
{
|
||||
/** @var array<string,int> Occurrences of each key inside the window. */
|
||||
private array $count = [];
|
||||
|
||||
private int $enter = 0;
|
||||
|
||||
private int $leave = 0;
|
||||
|
||||
private int $size;
|
||||
|
||||
/** @param array<array{0:string, 1:string}> $events [day, key], ordered by day */
|
||||
public function __construct(private array $events)
|
||||
{
|
||||
$this->size = count($events);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the window to [$start, $end] (inclusive; null start = no lower
|
||||
* bound) and return what entered and left since the previous call.
|
||||
*
|
||||
* Both bounds may only move forward.
|
||||
*
|
||||
* @return array{added: array<string>, removed: array<string>}
|
||||
*/
|
||||
public function slideTo(?string $start, string $end): array
|
||||
{
|
||||
$before = [];
|
||||
|
||||
while ($this->enter < $this->size && $this->events[$this->enter][0] <= $end) {
|
||||
$key = $this->events[$this->enter++][1];
|
||||
$before[$key] ??= ($this->count[$key] ?? 0) > 0;
|
||||
$this->count[$key] = ($this->count[$key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
if ($start !== null) {
|
||||
while ($this->leave < $this->enter && $this->events[$this->leave][0] < $start) {
|
||||
$key = $this->events[$this->leave++][1];
|
||||
$before[$key] ??= ($this->count[$key] ?? 0) > 0;
|
||||
|
||||
if (--$this->count[$key] === 0) {
|
||||
unset($this->count[$key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$added = [];
|
||||
$removed = [];
|
||||
|
||||
foreach ($before as $key => $was) {
|
||||
$now = isset($this->count[$key]);
|
||||
|
||||
if ($now && !$was) {
|
||||
$added[] = $key;
|
||||
} elseif (!$now && $was) {
|
||||
$removed[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
return ['added' => $added, 'removed' => $removed];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Stats;
|
||||
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* Immutable description of a Stats query: which profiles, over which period.
|
||||
*
|
||||
* Filters combine with AND. An empty filter means "nothing selected yet" — the
|
||||
* Stats view renders its empty state rather than querying the whole platform.
|
||||
*/
|
||||
final class StatsFilter
|
||||
{
|
||||
/**
|
||||
* Profile types that take part in platform statistics.
|
||||
*
|
||||
* Admin is deliberately excluded: admins hold no accounts, and counting them
|
||||
* as community members would skew registration and engagement figures.
|
||||
*/
|
||||
public const PROFILE_TYPES = [User::class, Organization::class, Bank::class];
|
||||
|
||||
public function __construct(
|
||||
public readonly ?Carbon $periodFrom = null,
|
||||
public readonly ?Carbon $periodTill = null,
|
||||
public readonly array $countryIds = [],
|
||||
public readonly array $divisionIds = [],
|
||||
public readonly array $cityIds = [],
|
||||
public readonly array $districtIds = [],
|
||||
public readonly ?Carbon $registeredFrom = null,
|
||||
public readonly ?Carbon $registeredTill = null,
|
||||
/** @var array<array{id:int,type:class-string}> */
|
||||
public readonly array $counterparties = [],
|
||||
/**
|
||||
* Sort for the "most active profiles" table. It belongs here rather
|
||||
* than in the view because the table is a top-20 cut: sorting after the
|
||||
* fact would only reorder those twenty, not find the real top payers.
|
||||
*/
|
||||
public readonly string $topSort = 'count',
|
||||
public readonly string $topDirection = 'desc',
|
||||
/** Same, for the "activity by location" table, which sorts separately. */
|
||||
public readonly string $locationSort = 'count',
|
||||
public readonly string $locationDirection = 'desc',
|
||||
/**
|
||||
* "All time" chosen deliberately. Without it, null dates are
|
||||
* indistinguishable from "nothing selected yet" and isEmpty() would
|
||||
* send the page to its empty state instead of reporting on everything.
|
||||
*/
|
||||
public readonly bool $allTime = false,
|
||||
/**
|
||||
* Profiles that have not logged in since this date.
|
||||
*
|
||||
* An upper bound, not a range: "not logged in since X" means
|
||||
* last_login_at < X, or never logged in at all. Hence a single date.
|
||||
*/
|
||||
public readonly ?Carbon $notLoggedInSince = null,
|
||||
/**
|
||||
* Profiles without a transaction since this date. Same shape as the
|
||||
* above, and deliberately separate from periodFrom: that bounds which
|
||||
* transactions count, this one bounds who is in the selection.
|
||||
*/
|
||||
public readonly ?Carbon $noTransactionSince = null,
|
||||
|
||||
/**
|
||||
* Include profiles their owner has closed (deleted_at set).
|
||||
*
|
||||
* Off by default: the graph describes the live network. On, it also
|
||||
* shows where hours went when a profile was closed - all 134 currency
|
||||
* removals on dev are balance transfers to a bank made during closure,
|
||||
* so they are invisible without this.
|
||||
*/
|
||||
public readonly bool $includeRemoved = false,
|
||||
|
||||
/**
|
||||
* Mark profiles that carry inactive_at with a blue outline.
|
||||
*
|
||||
* NOT an inclusion filter, despite the toggle sitting next to one:
|
||||
* notRemoved() only tests deleted_at, so inactive profiles were never
|
||||
* excluded in the first place. This just asks for them to be
|
||||
* identified.
|
||||
*/
|
||||
public readonly bool $markInactive = false,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* A copy of this filter with a different period.
|
||||
*
|
||||
* Every other criterion is carried over, so a time-lapse frame keeps the
|
||||
* location, registration and counterparty filters the user set - only the
|
||||
* dates move. Readonly promoted properties cannot be reassigned, hence a
|
||||
* new instance rather than a mutation.
|
||||
*/
|
||||
public function withPeriod(?Carbon $from, ?Carbon $till): self
|
||||
{
|
||||
return new self(
|
||||
periodFrom: $from,
|
||||
periodTill: $till,
|
||||
countryIds: $this->countryIds,
|
||||
divisionIds: $this->divisionIds,
|
||||
cityIds: $this->cityIds,
|
||||
districtIds: $this->districtIds,
|
||||
registeredFrom: $this->registeredFrom,
|
||||
registeredTill: $this->registeredTill,
|
||||
counterparties: $this->counterparties,
|
||||
topSort: $this->topSort,
|
||||
topDirection: $this->topDirection,
|
||||
locationSort: $this->locationSort,
|
||||
locationDirection: $this->locationDirection,
|
||||
// Deliberately NOT carried: allTime means "no period at all", which
|
||||
// contradicts the explicit dates being set here.
|
||||
notLoggedInSince: $this->notLoggedInSince,
|
||||
noTransactionSince: $this->noTransactionSince,
|
||||
includeRemoved: $this->includeRemoved,
|
||||
markInactive: $this->markInactive,
|
||||
);
|
||||
}
|
||||
|
||||
/** Columns the top-profiles table may be ordered by. */
|
||||
public const TOP_SORTS = [
|
||||
'paid_count', 'received_count', 'paid_volume', 'received_volume', 'count', 'partners',
|
||||
];
|
||||
|
||||
/**
|
||||
* The sort column as an SQL alias, guarded against anything off the
|
||||
* allow-list.
|
||||
*
|
||||
* "count" is the UI's name for the combined figure; the query aliases it
|
||||
* "cnt", because COUNT is reserved. Mapping here keeps that detail out of
|
||||
* the view.
|
||||
*/
|
||||
public function topSortColumn(): string
|
||||
{
|
||||
$column = in_array($this->topSort, self::TOP_SORTS, true) ? $this->topSort : 'count';
|
||||
|
||||
return $column === 'count' ? 'cnt' : $column;
|
||||
}
|
||||
|
||||
public function topSortDirection(): string
|
||||
{
|
||||
return strtolower($this->topDirection) === 'asc' ? 'asc' : 'desc';
|
||||
}
|
||||
|
||||
/** As topSortColumn(), for the location table. */
|
||||
public function locationSortColumn(): string
|
||||
{
|
||||
$column = in_array($this->locationSort, self::TOP_SORTS, true) ? $this->locationSort : 'count';
|
||||
|
||||
return $column === 'count' ? 'cnt' : $column;
|
||||
}
|
||||
|
||||
public function locationSortDirection(): string
|
||||
{
|
||||
return strtolower($this->locationDirection) === 'asc' ? 'asc' : 'desc';
|
||||
}
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$date = fn(?string $value) => $value ? Carbon::parse($value) : null;
|
||||
|
||||
return new self(
|
||||
periodFrom: $date($data['periodFrom'] ?? null),
|
||||
periodTill: $date($data['periodTill'] ?? null),
|
||||
countryIds: $data['countryIds'] ?? [],
|
||||
divisionIds: $data['divisionIds'] ?? [],
|
||||
cityIds: $data['cityIds'] ?? [],
|
||||
districtIds: $data['districtIds'] ?? [],
|
||||
registeredFrom: $date($data['registeredFrom'] ?? null),
|
||||
registeredTill: $date($data['registeredTill'] ?? null),
|
||||
counterparties: $data['counterparties'] ?? [],
|
||||
topSort: $data['topSort'] ?? 'count',
|
||||
topDirection: $data['topDirection'] ?? 'desc',
|
||||
locationSort: $data['locationSort'] ?? 'count',
|
||||
locationDirection: $data['locationDirection'] ?? 'desc',
|
||||
allTime: (bool) ($data['allTime'] ?? false),
|
||||
);
|
||||
}
|
||||
|
||||
public function hasLocationFilter(): bool
|
||||
{
|
||||
return LocationFilterResolver::resolve(
|
||||
$this->countryIds,
|
||||
$this->divisionIds,
|
||||
$this->cityIds,
|
||||
$this->districtIds
|
||||
) !== null;
|
||||
}
|
||||
|
||||
public function hasRegistrationFilter(): bool
|
||||
{
|
||||
return $this->registeredFrom !== null || $this->registeredTill !== null;
|
||||
}
|
||||
|
||||
public function hasPeriod(): bool
|
||||
{
|
||||
return $this->periodFrom !== null || $this->periodTill !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when nothing has been selected — the caller should render an empty
|
||||
* state instead of building a service and querying the entire platform.
|
||||
*/
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return !$this->allTime
|
||||
&& !$this->hasPeriod()
|
||||
&& !$this->hasLocationFilter()
|
||||
&& !$this->hasRegistrationFilter()
|
||||
&& $this->notLoggedInSince === null
|
||||
&& $this->noTransactionSince === null
|
||||
&& empty($this->counterparties);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable cache key for this exact filter combination.
|
||||
*/
|
||||
public function hash(): string
|
||||
{
|
||||
return md5(serialize([
|
||||
$this->periodFrom?->toDateString(),
|
||||
$this->periodTill?->toDateString(),
|
||||
$this->countryIds,
|
||||
$this->divisionIds,
|
||||
$this->cityIds,
|
||||
$this->districtIds,
|
||||
$this->registeredFrom?->toDateString(),
|
||||
$this->registeredTill?->toDateString(),
|
||||
$this->counterparties,
|
||||
$this->topSortColumn(),
|
||||
$this->topSortDirection(),
|
||||
$this->locationSortColumn(),
|
||||
$this->locationSortDirection(),
|
||||
$this->allTime,
|
||||
$this->notLoggedInSince?->toDateString(),
|
||||
$this->noTransactionSince?->toDateString(),
|
||||
$this->includeRemoved,
|
||||
$this->markInactive,
|
||||
]));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user