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:
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user