feat(110): statistics summary

Phase 110 complete:
- add Statistics -> Podsumowanie page
- add monthly order count and value charts per integration plus total
- use Chart.js with table fallback and 04-2026 default history start
- update PAUL and DOCS technical documentation
This commit is contained in:
2026-04-28 22:47:14 +02:00
parent 1156ce046c
commit 0b4ffb7146
21 changed files with 2454 additions and 26 deletions

View File

@@ -70,6 +70,41 @@ final class OrdersStatisticsController
return Response::html($html);
}
public function summary(Request $request): Response
{
[$dateFrom, $dateTo] = $this->resolveSummaryDateRange($request);
$statusGroups = $this->repository->listStatusGroups();
$statusGroupOptions = $this->mapStatusGroupOptions($statusGroups);
$selectedStatusGroups = $this->resolveSelectedStatusGroups($request, $statusGroupOptions);
$channelOptions = $this->mapChannelOptions($this->repository->listChannelOptions());
$selectedChannels = $this->resolveSelectedChannels($request, $channelOptions);
$statusCodes = $this->repository->statusCodesByGroupIds($selectedStatusGroups);
$aggregated = $this->repository->aggregateByMonth($dateFrom, $dateTo, $selectedChannels, $statusCodes);
$summary = $this->buildSummary($dateFrom, $dateTo, $selectedChannels, $channelOptions, $aggregated);
$html = $this->template->render('statistics/summary', [
'title' => $this->translator->get('statistics.summary.title'),
'activeMenu' => 'statistics',
'activeStatistics' => 'summary',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'filters' => [
'date_from' => $dateFrom,
'date_to' => $dateTo,
'selected_channels' => $selectedChannels,
'selected_status_groups' => $selectedStatusGroups,
],
'channelOptions' => $channelOptions,
'statusGroupOptions' => $statusGroupOptions,
'summary' => $summary,
], 'layouts/app');
return Response::html($html);
}
/**
* @return array{0:string,1:string}
*/
@@ -95,6 +130,31 @@ final class OrdersStatisticsController
return [$dateFrom, $dateTo];
}
/**
* @return array{0:string,1:string}
*/
private function resolveSummaryDateRange(Request $request): array
{
$now = new DateTimeImmutable('now');
$defaultFrom = '2026-04-01';
$defaultTo = $now->format('Y-m-d');
$dateFrom = trim((string) $request->input('date_from', $defaultFrom));
$dateTo = trim((string) $request->input('date_to', $defaultTo));
if (!$this->isValidDate($dateFrom)) {
$dateFrom = $defaultFrom;
}
if (!$this->isValidDate($dateTo)) {
$dateTo = $defaultTo;
}
if ($dateFrom > $dateTo) {
[$dateFrom, $dateTo] = [$dateTo, $dateFrom];
}
return [$dateFrom, $dateTo];
}
private function isValidDate(string $date): bool
{
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
@@ -298,6 +358,200 @@ final class OrdersStatisticsController
];
}
/**
* @param array<int, string> $selectedChannels
* @param array<int, array{key:string,label:string}> $channelOptions
* @param array<int, array{month:string,channel_key:string,orders_count:int,total_gross:float}> $aggregated
* @return array<string, mixed>
*/
private function buildSummary(
string $dateFrom,
string $dateTo,
array $selectedChannels,
array $channelOptions,
array $aggregated
): array {
$channelLabels = $this->channelLabels($channelOptions);
$rows = $this->emptySummaryRows($dateFrom, $dateTo, $selectedChannels);
foreach ($aggregated as $item) {
$month = (string) ($item['month'] ?? '');
$channelKey = (string) ($item['channel_key'] ?? '');
if ($month === '' || $channelKey === '' || !isset($rows[$month]['channels'][$channelKey])) {
continue;
}
$ordersCount = (int) ($item['orders_count'] ?? 0);
$totalGross = (float) ($item['total_gross'] ?? 0);
$rows[$month]['channels'][$channelKey] = [
'orders_count' => $ordersCount,
'total_gross' => $totalGross,
];
$rows[$month]['total_orders_count'] += $ordersCount;
$rows[$month]['total_gross'] += $totalGross;
}
return $this->summaryPayload($rows, $selectedChannels, $channelLabels);
}
/**
* @param array<int, array{key:string,label:string}> $channelOptions
* @return array<string, string>
*/
private function channelLabels(array $channelOptions): array
{
$labels = [];
foreach ($channelOptions as $option) {
$key = (string) ($option['key'] ?? '');
if ($key !== '') {
$labels[$key] = (string) ($option['label'] ?? $key);
}
}
return $labels;
}
/**
* @param array<int, string> $selectedChannels
* @return array<string, array{month:string,channels:array<string,array{orders_count:int,total_gross:float}>,total_orders_count:int,total_gross:float}>
*/
private function emptySummaryRows(string $dateFrom, string $dateTo, array $selectedChannels): array
{
$rows = [];
foreach ($this->monthRange($dateFrom, $dateTo) as $month) {
$rows[$month] = [
'month' => $month,
'channels' => $this->emptySummaryChannels($selectedChannels),
'total_orders_count' => 0,
'total_gross' => 0.0,
];
}
return $rows;
}
/**
* @param array<int, string> $selectedChannels
* @return array<string, array{orders_count:int,total_gross:float}>
*/
private function emptySummaryChannels(array $selectedChannels): array
{
$channels = [];
foreach ($selectedChannels as $channelKey) {
$channels[$channelKey] = [
'orders_count' => 0,
'total_gross' => 0.0,
];
}
return $channels;
}
/**
* @param array<string, array{month:string,channels:array<string,array{orders_count:int,total_gross:float}>,total_orders_count:int,total_gross:float}> $rows
* @param array<int, string> $selectedChannels
* @param array<string, string> $channelLabels
* @return array<string, mixed>
*/
private function summaryPayload(array $rows, array $selectedChannels, array $channelLabels): array
{
$months = array_keys($rows);
$labels = array_map(fn (string $month): string => $this->displayMonth($month), $months);
$countSeries = $this->summarySeries($rows, $selectedChannels, $channelLabels, 'orders_count');
$valueSeries = $this->summarySeries($rows, $selectedChannels, $channelLabels, 'total_gross');
$countSeries[] = $this->totalSeries($rows, 'Razem', 'total_orders_count');
$valueSeries[] = $this->totalSeries($rows, 'Razem', 'total_gross');
return [
'months' => $months,
'rows' => array_values($rows),
'hasData' => $this->summaryHasData($rows),
'countChart' => [
'labels' => $labels,
'series' => $countSeries,
'valueType' => 'number',
],
'valueChart' => [
'labels' => $labels,
'series' => $valueSeries,
'valueType' => 'money',
],
];
}
private function displayMonth(string $month): string
{
$date = DateTimeImmutable::createFromFormat('Y-m-d', $month . '-01');
if (!$date instanceof DateTimeImmutable) {
return $month;
}
return $date->format('m-Y');
}
/**
* @param array<string, array{channels:array<string,array{orders_count:int,total_gross:float}>}> $rows
* @param array<int, string> $selectedChannels
* @param array<string, string> $channelLabels
* @return array<int, array{key:string,label:string,values:array<int,int|float>}>
*/
private function summarySeries(array $rows, array $selectedChannels, array $channelLabels, string $metric): array
{
$series = [];
foreach ($selectedChannels as $channelKey) {
$values = [];
foreach ($rows as $row) {
$channelStats = $row['channels'][$channelKey] ?? [];
$values[] = $metric === 'orders_count'
? (int) ($channelStats[$metric] ?? 0)
: (float) ($channelStats[$metric] ?? 0);
}
$series[] = [
'key' => $channelKey,
'label' => $channelLabels[$channelKey] ?? $channelKey,
'values' => $values,
];
}
return $series;
}
/**
* @param array<string, array<string, mixed>> $rows
* @return array{key:string,label:string,values:array<int,int|float>}
*/
private function totalSeries(array $rows, string $label, string $metric): array
{
$values = [];
foreach ($rows as $row) {
$values[] = $metric === 'total_orders_count'
? (int) ($row[$metric] ?? 0)
: (float) ($row[$metric] ?? 0);
}
return [
'key' => 'total',
'label' => $label,
'values' => $values,
];
}
/**
* @param array<string, array{total_orders_count:int}> $rows
*/
private function summaryHasData(array $rows): bool
{
foreach ($rows as $row) {
if ((int) ($row['total_orders_count'] ?? 0) > 0) {
return true;
}
}
return false;
}
/**
* @param array<int, string> $channelKeys
* @return array<string, array{orders_count:int,total_net:float,total_gross:float}>
@@ -333,6 +587,23 @@ final class OrdersStatisticsController
return $dates;
}
/**
* @return array<int, string>
*/
private function monthRange(string $dateFrom, string $dateTo): array
{
$start = (new DateTimeImmutable($dateFrom))->modify('first day of this month');
$end = (new DateTimeImmutable($dateTo))->modify('first day of next month');
$period = new DatePeriod($start, new DateInterval('P1M'), $end);
$months = [];
foreach ($period as $date) {
$months[] = $date->format('Y-m');
}
return $months;
}
/**
* @return array<int, string>
*/

View File

@@ -238,6 +238,95 @@ final class OrdersStatisticsRepository
return $result;
}
/**
* @param array<int, string> $channels
* @param array<int, string> $statusCodes
* @return array<int, array{month:string,channel_key:string,orders_count:int,total_gross:float}>
*/
public function aggregateByMonth(string $dateFrom, string $dateTo, array $channels, array $statusCodes): array
{
$channels = array_values(array_unique(array_filter(
array_map(static fn (string $item): string => trim($item), $channels),
static fn (string $item): bool => $item !== ''
)));
$statusCodes = array_values(array_unique(array_filter(
array_map(static fn (string $item): string => strtolower(trim($item)), $statusCodes),
static fn (string $item): bool => $item !== ''
)));
if ($channels === []) {
return [];
}
$effectiveStatusSql = $this->effectiveStatusSql('o', 'asm');
$effectiveDateSql = $this->effectiveDateSql('o');
$channelSql = $this->channelSql('o');
$grossAmountSql = $this->grossAmountSql('o');
$rawStatusSql = $this->rawStatusSql('o');
[$channelInSql, $channelParams] = $this->buildStringInClause('ch', $channels);
$params = array_merge($channelParams, [
'date_from' => $dateFrom . ' 00:00:00',
'date_to' => $dateTo . ' 23:59:59',
]);
$statusFilterSql = '';
if ($statusCodes !== []) {
[$statusInSql, $statusParams] = $this->buildStringInClause('st', $statusCodes);
$statusFilterSql = ' AND ' . $effectiveStatusSql . ' IN (' . $statusInSql . ')';
$params = array_merge($params, $statusParams);
}
$monthSql = 'DATE_FORMAT(' . $effectiveDateSql . ', "%Y-%m")';
$sql = 'SELECT
' . $monthSql . ' AS month,
' . $channelSql . ' AS channel_key,
COUNT(*) AS orders_count,
SUM(' . $grossAmountSql . ') AS total_gross
FROM orders o
LEFT JOIN allegro_order_status_mappings asm
ON o.source = "allegro"
AND LOWER(' . $rawStatusSql . ') = asm.allegro_status_code
WHERE LOWER(COALESCE(o.source, "")) IN ("allegro", "shoppro")
AND ' . $effectiveDateSql . ' IS NOT NULL
AND ' . $effectiveDateSql . ' >= :date_from
AND ' . $effectiveDateSql . ' <= :date_to
AND ' . $channelSql . ' IN (' . $channelInSql . ')
' . $statusFilterSql . '
GROUP BY ' . $monthSql . ', ' . $channelSql . '
ORDER BY ' . $monthSql . ' ASC';
try {
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable) {
return [];
}
if (!is_array($rows)) {
return [];
}
$result = [];
foreach ($rows as $row) {
$month = trim((string) ($row['month'] ?? ''));
$channelKey = trim((string) ($row['channel_key'] ?? ''));
if ($month === '' || $channelKey === '') {
continue;
}
$result[] = [
'month' => $month,
'channel_key' => $channelKey,
'orders_count' => (int) ($row['orders_count'] ?? 0),
'total_gross' => (float) ($row['total_gross'] ?? 0),
];
}
return $result;
}
/**
* @param array<int, string> $channels
* @param array<int, string> $statusCodes