update
This commit is contained in:
369
src/Modules/Statistics/OrdersStatisticsController.php
Normal file
369
src/Modules/Statistics/OrdersStatisticsController.php
Normal file
@@ -0,0 +1,369 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Statistics;
|
||||
|
||||
use App\Core\Http\Request;
|
||||
use App\Core\Http\Response;
|
||||
use App\Core\Security\Csrf;
|
||||
use App\Core\View\Template;
|
||||
use App\Core\I18n\Translator;
|
||||
use App\Modules\Auth\AuthService;
|
||||
use DateInterval;
|
||||
use DatePeriod;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final class OrdersStatisticsController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Template $template,
|
||||
private readonly Translator $translator,
|
||||
private readonly AuthService $auth,
|
||||
private readonly OrdersStatisticsRepository $repository
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
[$dateFrom, $dateTo] = $this->resolveDateRange($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->aggregateByDay($dateFrom, $dateTo, $selectedChannels, $statusCodes);
|
||||
$debugEnabled = (string) $request->input('debug', '') === '1';
|
||||
$diagnostics = $debugEnabled
|
||||
? $this->repository->diagnostics($dateFrom, $dateTo, $selectedChannels, $statusCodes)
|
||||
: [];
|
||||
|
||||
$table = $this->buildTable($dateFrom, $dateTo, $selectedChannels, $aggregated);
|
||||
|
||||
$html = $this->template->render('statistics/orders', [
|
||||
'title' => $this->translator->get('statistics.orders.title'),
|
||||
'activeMenu' => 'statistics',
|
||||
'activeStatistics' => 'orders',
|
||||
'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,
|
||||
'table' => $table,
|
||||
'debugEnabled' => $debugEnabled,
|
||||
'diagnostics' => $diagnostics,
|
||||
'debugMeta' => [
|
||||
'selected_channels' => $selectedChannels,
|
||||
'selected_status_groups' => $selectedStatusGroups,
|
||||
'status_codes' => $statusCodes,
|
||||
],
|
||||
], 'layouts/app');
|
||||
|
||||
return Response::html($html);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:string,1:string}
|
||||
*/
|
||||
private function resolveDateRange(Request $request): array
|
||||
{
|
||||
$now = new DateTimeImmutable('now');
|
||||
$defaultFrom = $now->modify('first day of this month')->format('Y-m-d');
|
||||
$defaultTo = $now->modify('last day of this month')->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) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return DateTimeImmutable::createFromFormat('Y-m-d', $date) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{id:int,name:string}> $groups
|
||||
* @return array<int, array{id:int,name:string}>
|
||||
*/
|
||||
private function mapStatusGroupOptions(array $groups): array
|
||||
{
|
||||
$options = [];
|
||||
foreach ($groups as $group) {
|
||||
$groupId = (int) ($group['id'] ?? 0);
|
||||
if ($groupId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$options[] = [
|
||||
'id' => $groupId,
|
||||
'name' => trim((string) ($group['name'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{id:int,name:string}> $statusGroupOptions
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function resolveSelectedStatusGroups(Request $request, array $statusGroupOptions): array
|
||||
{
|
||||
$allowed = [];
|
||||
foreach ($statusGroupOptions as $option) {
|
||||
$allowed[] = (int) $option['id'];
|
||||
}
|
||||
|
||||
$allInput = $request->all();
|
||||
$hasStatusGroupsParam = array_key_exists('status_groups', $allInput);
|
||||
$selected = $this->toIntegerList($request->input('status_groups', []));
|
||||
if (!$hasStatusGroupsParam) {
|
||||
$selected = $this->defaultStatusGroupIds($statusGroupOptions);
|
||||
}
|
||||
|
||||
$selected = array_values(array_intersect($selected, $allowed));
|
||||
if ($selected !== []) {
|
||||
return $selected;
|
||||
}
|
||||
|
||||
return $hasStatusGroupsParam ? [] : $this->defaultStatusGroupIds($statusGroupOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{id:int,name:string}> $statusGroupOptions
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function defaultStatusGroupIds(array $statusGroupOptions): array
|
||||
{
|
||||
$selected = [];
|
||||
foreach ($statusGroupOptions as $group) {
|
||||
$name = trim((string) ($group['name'] ?? ''));
|
||||
if ($this->isCancelledGroup($name)) {
|
||||
continue;
|
||||
}
|
||||
$selected[] = (int) ($group['id'] ?? 0);
|
||||
}
|
||||
|
||||
return array_values(array_filter($selected, static fn (int $id): bool => $id > 0));
|
||||
}
|
||||
|
||||
private function isCancelledGroup(string $name): bool
|
||||
{
|
||||
$normalized = strtr(mb_strtolower(trim($name)), [
|
||||
'ą' => 'a',
|
||||
'ć' => 'c',
|
||||
'ę' => 'e',
|
||||
'ł' => 'l',
|
||||
'ń' => 'n',
|
||||
'ó' => 'o',
|
||||
'ś' => 's',
|
||||
'ż' => 'z',
|
||||
'ź' => 'z',
|
||||
]);
|
||||
|
||||
return in_array($normalized, ['anulowane', 'anulowany', 'cancelled', 'canceled'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{key:string,label:string}> $channels
|
||||
* @return array<int, array{key:string,label:string}>
|
||||
*/
|
||||
private function mapChannelOptions(array $channels): array
|
||||
{
|
||||
$options = [];
|
||||
foreach ($channels as $channel) {
|
||||
$key = trim((string) ($channel['key'] ?? ''));
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$options[] = [
|
||||
'key' => $key,
|
||||
'label' => trim((string) ($channel['label'] ?? $key)),
|
||||
];
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{key:string,label:string}> $channelOptions
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function resolveSelectedChannels(Request $request, array $channelOptions): array
|
||||
{
|
||||
$allowed = [];
|
||||
foreach ($channelOptions as $option) {
|
||||
$allowed[] = (string) $option['key'];
|
||||
}
|
||||
|
||||
$allInput = $request->all();
|
||||
$hasChannelsParam = array_key_exists('channels', $allInput);
|
||||
$selected = $this->toStringList($request->input('channels', []));
|
||||
if (!$hasChannelsParam) {
|
||||
return $allowed;
|
||||
}
|
||||
|
||||
return array_values(array_intersect($selected, $allowed));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $selectedChannels
|
||||
* @param array<int, array{day:string,channel_key:string,orders_count:int,total_net:float,total_gross:float}> $aggregated
|
||||
* @return array{rows:array<int,array<string,mixed>>,totals:array<string,mixed>,hasData:bool}
|
||||
*/
|
||||
private function buildTable(string $dateFrom, string $dateTo, array $selectedChannels, array $aggregated): array
|
||||
{
|
||||
$rows = [];
|
||||
foreach ($this->dateRange($dateFrom, $dateTo) as $day) {
|
||||
$rows[$day] = [
|
||||
'day' => $day,
|
||||
'channels' => $this->emptyChannelsRow($selectedChannels),
|
||||
'day_total_orders' => 0,
|
||||
'day_total_net' => 0.0,
|
||||
'day_total_gross' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($aggregated as $item) {
|
||||
$day = (string) ($item['day'] ?? '');
|
||||
$channelKey = (string) ($item['channel_key'] ?? '');
|
||||
if ($day === '' || $channelKey === '' || !isset($rows[$day]['channels'][$channelKey])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ordersCount = (int) ($item['orders_count'] ?? 0);
|
||||
$totalNet = (float) ($item['total_net'] ?? 0);
|
||||
$totalGross = (float) ($item['total_gross'] ?? 0);
|
||||
|
||||
$rows[$day]['channels'][$channelKey] = [
|
||||
'orders_count' => $ordersCount,
|
||||
'total_net' => $totalNet,
|
||||
'total_gross' => $totalGross,
|
||||
];
|
||||
$rows[$day]['day_total_orders'] += $ordersCount;
|
||||
$rows[$day]['day_total_net'] += $totalNet;
|
||||
$rows[$day]['day_total_gross'] += $totalGross;
|
||||
}
|
||||
|
||||
$totals = [
|
||||
'channels' => $this->emptyChannelsRow($selectedChannels),
|
||||
'orders_count' => 0,
|
||||
'total_net' => 0.0,
|
||||
'total_gross' => 0.0,
|
||||
];
|
||||
|
||||
$hasData = false;
|
||||
foreach ($rows as $row) {
|
||||
foreach ($selectedChannels as $channelKey) {
|
||||
$channelStats = $row['channels'][$channelKey];
|
||||
$totals['channels'][$channelKey]['orders_count'] += (int) ($channelStats['orders_count'] ?? 0);
|
||||
$totals['channels'][$channelKey]['total_net'] += (float) ($channelStats['total_net'] ?? 0);
|
||||
$totals['channels'][$channelKey]['total_gross'] += (float) ($channelStats['total_gross'] ?? 0);
|
||||
}
|
||||
$totals['orders_count'] += (int) ($row['day_total_orders'] ?? 0);
|
||||
$totals['total_net'] += (float) ($row['day_total_net'] ?? 0);
|
||||
$totals['total_gross'] += (float) ($row['day_total_gross'] ?? 0);
|
||||
if ((int) ($row['day_total_orders'] ?? 0) > 0) {
|
||||
$hasData = true;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'rows' => array_values($rows),
|
||||
'totals' => $totals,
|
||||
'hasData' => $hasData,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $channelKeys
|
||||
* @return array<string, array{orders_count:int,total_net:float,total_gross:float}>
|
||||
*/
|
||||
private function emptyChannelsRow(array $channelKeys): array
|
||||
{
|
||||
$row = [];
|
||||
foreach ($channelKeys as $channelKey) {
|
||||
$row[$channelKey] = [
|
||||
'orders_count' => 0,
|
||||
'total_net' => 0.0,
|
||||
'total_gross' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function dateRange(string $dateFrom, string $dateTo): array
|
||||
{
|
||||
$start = new DateTimeImmutable($dateFrom);
|
||||
$end = (new DateTimeImmutable($dateTo))->add(new DateInterval('P1D'));
|
||||
$period = new DatePeriod($start, new DateInterval('P1D'), $end);
|
||||
|
||||
$dates = [];
|
||||
foreach ($period as $date) {
|
||||
$dates[] = $date->format('Y-m-d');
|
||||
}
|
||||
|
||||
return $dates;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function toStringList(mixed $value): array
|
||||
{
|
||||
$items = is_array($value) ? $value : ($value !== null && $value !== '' ? [$value] : []);
|
||||
$list = [];
|
||||
foreach ($items as $item) {
|
||||
$normalized = trim((string) $item);
|
||||
if ($normalized !== '') {
|
||||
$list[] = $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($list));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function toIntegerList(mixed $value): array
|
||||
{
|
||||
$items = is_array($value) ? $value : ($value !== null && $value !== '' ? [$value] : []);
|
||||
$list = [];
|
||||
foreach ($items as $item) {
|
||||
$number = (int) $item;
|
||||
if ($number > 0) {
|
||||
$list[] = $number;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($list));
|
||||
}
|
||||
}
|
||||
590
src/Modules/Statistics/OrdersStatisticsRepository.php
Normal file
590
src/Modules/Statistics/OrdersStatisticsRepository.php
Normal file
@@ -0,0 +1,590 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Statistics;
|
||||
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
final class OrdersStatisticsRepository
|
||||
{
|
||||
private static ?bool $hasOrdersTotalWithoutTax = null;
|
||||
private static ?bool $hasOrdersTotalNet = null;
|
||||
private static ?bool $hasOrdersTotalWithTax = null;
|
||||
private static ?bool $hasOrdersTotalGross = null;
|
||||
private static ?bool $hasOrdersIntegrationId = null;
|
||||
private static ?bool $hasOrdersStatusCode = null;
|
||||
private static ?bool $hasOrdersExternalStatusId = null;
|
||||
|
||||
public function __construct(private readonly PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{id:int,name:string}>
|
||||
*/
|
||||
public function listStatusGroups(): array
|
||||
{
|
||||
try {
|
||||
$stmt = $this->pdo->query(
|
||||
'SELECT id, name
|
||||
FROM order_status_groups
|
||||
WHERE is_active = 1
|
||||
ORDER BY sort_order ASC, id ASC'
|
||||
);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$groups = [];
|
||||
foreach ($rows as $row) {
|
||||
$groupId = (int) ($row['id'] ?? 0);
|
||||
if ($groupId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$groups[] = [
|
||||
'id' => $groupId,
|
||||
'name' => trim((string) ($row['name'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{key:string,label:string}>
|
||||
*/
|
||||
public function listChannelOptions(): array
|
||||
{
|
||||
$hasIntegrationId = $this->hasOrdersColumn('integration_id');
|
||||
|
||||
try {
|
||||
if ($hasIntegrationId) {
|
||||
$rows = $this->pdo->query(
|
||||
'SELECT DISTINCT COALESCE(o.integration_id, 0) AS integration_id
|
||||
FROM orders o
|
||||
WHERE LOWER(COALESCE(o.source, "")) = "shoppro"
|
||||
ORDER BY integration_id ASC'
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
} else {
|
||||
$rows = $this->pdo->query(
|
||||
'SELECT 0 AS integration_id
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM orders o
|
||||
WHERE LOWER(COALESCE(o.source, "")) = "shoppro"
|
||||
)'
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
} catch (Throwable) {
|
||||
$rows = [];
|
||||
}
|
||||
|
||||
$channels = [
|
||||
['key' => 'allegro', 'label' => 'Allegro'],
|
||||
];
|
||||
|
||||
if (!is_array($rows)) {
|
||||
return $channels;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$integrationId = (int) ($row['integration_id'] ?? 0);
|
||||
$channels[] = [
|
||||
'key' => 'shoppro:' . $integrationId,
|
||||
'label' => $this->shopproChannelLabel($integrationId),
|
||||
];
|
||||
}
|
||||
|
||||
return $channels;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $groupIds
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function statusCodesByGroupIds(array $groupIds): array
|
||||
{
|
||||
$groupIds = array_values(array_unique(array_filter($groupIds, static fn (int $id): bool => $id > 0)));
|
||||
if ($groupIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
[$inSql, $params] = $this->buildIntegerInClause('gid', $groupIds);
|
||||
|
||||
try {
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT code
|
||||
FROM order_statuses
|
||||
WHERE is_active = 1
|
||||
AND group_id IN (' . $inSql . ')
|
||||
ORDER BY sort_order ASC, id ASC'
|
||||
);
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$codes = [];
|
||||
foreach ($rows as $code) {
|
||||
$normalized = strtolower(trim((string) $code));
|
||||
if ($normalized !== '') {
|
||||
$codes[] = $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($codes));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $channels
|
||||
* @param array<int, string> $statusCodes
|
||||
* @return array<int, array{day:string,channel_key:string,orders_count:int,total_net:float,total_gross:float}>
|
||||
*/
|
||||
public function aggregateByDay(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');
|
||||
$netAmountSql = $this->netAmountSql('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);
|
||||
}
|
||||
|
||||
$sql = 'SELECT
|
||||
DATE(' . $effectiveDateSql . ') AS day,
|
||||
' . $channelSql . ' AS channel_key,
|
||||
COUNT(*) AS orders_count,
|
||||
SUM(' . $netAmountSql . ') AS total_net,
|
||||
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 DATE(' . $effectiveDateSql . '), ' . $channelSql . '
|
||||
ORDER BY DATE(' . $effectiveDateSql . ') 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) {
|
||||
$day = trim((string) ($row['day'] ?? ''));
|
||||
$channelKey = trim((string) ($row['channel_key'] ?? ''));
|
||||
if ($day === '' || $channelKey === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'day' => $day,
|
||||
'channel_key' => $channelKey,
|
||||
'orders_count' => (int) ($row['orders_count'] ?? 0),
|
||||
'total_net' => (float) ($row['total_net'] ?? 0),
|
||||
'total_gross' => (float) ($row['total_gross'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $channels
|
||||
* @param array<int, string> $statusCodes
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function diagnostics(string $dateFrom, string $dateTo, array $channels, array $statusCodes): array
|
||||
{
|
||||
$effectiveDateSql = $this->effectiveDateSql('o');
|
||||
$channelSql = $this->channelSql('o');
|
||||
$effectiveStatusSql = $this->effectiveStatusSql('o', 'asm');
|
||||
$rawStatusSql = $this->rawStatusSql('o');
|
||||
|
||||
$data = [
|
||||
'columns' => [
|
||||
'integration_id' => $this->hasOrdersColumn('integration_id'),
|
||||
'total_with_tax' => $this->hasOrdersColumn('total_with_tax'),
|
||||
'total_gross' => $this->hasOrdersColumn('total_gross'),
|
||||
'total_without_tax' => $this->hasOrdersColumn('total_without_tax'),
|
||||
'total_net' => $this->hasOrdersColumn('total_net'),
|
||||
'status_code' => $this->hasOrdersColumn('status_code'),
|
||||
'external_status_id' => $this->hasOrdersColumn('external_status_id'),
|
||||
],
|
||||
'counts' => [],
|
||||
'errors' => [],
|
||||
];
|
||||
|
||||
$data['counts']['in_date_and_source'] = $this->safeCount(
|
||||
'SELECT COUNT(*)
|
||||
FROM orders o
|
||||
WHERE LOWER(COALESCE(o.source, "")) IN ("allegro", "shoppro")
|
||||
AND ' . $effectiveDateSql . ' IS NOT NULL
|
||||
AND ' . $effectiveDateSql . ' >= :date_from
|
||||
AND ' . $effectiveDateSql . ' <= :date_to',
|
||||
[
|
||||
'date_from' => $dateFrom . ' 00:00:00',
|
||||
'date_to' => $dateTo . ' 23:59:59',
|
||||
],
|
||||
$data['errors'],
|
||||
'in_date_and_source'
|
||||
);
|
||||
|
||||
if ($channels !== []) {
|
||||
[$channelInSql, $channelParams] = $this->buildStringInClause('dbg_ch', $channels);
|
||||
$data['counts']['after_channel_filter'] = $this->safeCount(
|
||||
'SELECT COUNT(*)
|
||||
FROM orders o
|
||||
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 . ')',
|
||||
array_merge(
|
||||
[
|
||||
'date_from' => $dateFrom . ' 00:00:00',
|
||||
'date_to' => $dateTo . ' 23:59:59',
|
||||
],
|
||||
$channelParams
|
||||
),
|
||||
$data['errors'],
|
||||
'after_channel_filter'
|
||||
);
|
||||
} else {
|
||||
$data['counts']['after_channel_filter'] = 0;
|
||||
}
|
||||
|
||||
if ($statusCodes !== []) {
|
||||
[$statusInSql, $statusParams] = $this->buildStringInClause('dbg_st', $statusCodes);
|
||||
$data['counts']['after_status_filter'] = $this->safeCount(
|
||||
'SELECT COUNT(*)
|
||||
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 ' . $effectiveStatusSql . ' IN (' . $statusInSql . ')',
|
||||
array_merge(
|
||||
[
|
||||
'date_from' => $dateFrom . ' 00:00:00',
|
||||
'date_to' => $dateTo . ' 23:59:59',
|
||||
],
|
||||
$statusParams
|
||||
),
|
||||
$data['errors'],
|
||||
'after_status_filter'
|
||||
);
|
||||
} else {
|
||||
$data['counts']['after_status_filter'] = $data['counts']['after_channel_filter'] ?? 0;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $values
|
||||
* @return array{0:string,1:array<string,int>}
|
||||
*/
|
||||
private function buildIntegerInClause(string $prefix, array $values): array
|
||||
{
|
||||
$placeholders = [];
|
||||
$params = [];
|
||||
foreach (array_values($values) as $index => $value) {
|
||||
$key = $prefix . '_' . $index;
|
||||
$placeholders[] = ':' . $key;
|
||||
$params[$key] = (int) $value;
|
||||
}
|
||||
|
||||
return [implode(', ', $placeholders), $params];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $values
|
||||
* @return array{0:string,1:array<string,string>}
|
||||
*/
|
||||
private function buildStringInClause(string $prefix, array $values): array
|
||||
{
|
||||
$placeholders = [];
|
||||
$params = [];
|
||||
foreach (array_values($values) as $index => $value) {
|
||||
$key = $prefix . '_' . $index;
|
||||
$placeholders[] = ':' . $key;
|
||||
$params[$key] = (string) $value;
|
||||
}
|
||||
|
||||
return [implode(', ', $placeholders), $params];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
* @param array<int, string> $errors
|
||||
*/
|
||||
private function safeCount(string $sql, array $params, array &$errors, string $label): int
|
||||
{
|
||||
try {
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return (int) $stmt->fetchColumn();
|
||||
} catch (Throwable $exception) {
|
||||
$errors[] = $label . ': ' . $exception->getMessage();
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
private function effectiveStatusSql(string $orderAlias, string $mappingAlias): string
|
||||
{
|
||||
$rawStatusSql = $this->rawStatusSql($orderAlias);
|
||||
|
||||
return 'LOWER(
|
||||
CASE
|
||||
WHEN ' . $orderAlias . '.source = "allegro"
|
||||
AND ' . $mappingAlias . '.orderpro_status_code IS NOT NULL
|
||||
AND ' . $mappingAlias . '.orderpro_status_code <> ""
|
||||
THEN ' . $mappingAlias . '.orderpro_status_code
|
||||
ELSE ' . $rawStatusSql . '
|
||||
END
|
||||
)';
|
||||
}
|
||||
|
||||
private function effectiveDateSql(string $orderAlias): string
|
||||
{
|
||||
return 'COALESCE(
|
||||
' . $orderAlias . '.ordered_at,
|
||||
' . $orderAlias . '.source_created_at,
|
||||
' . $orderAlias . '.source_updated_at,
|
||||
' . $orderAlias . '.fetched_at
|
||||
)';
|
||||
}
|
||||
|
||||
private function channelSql(string $orderAlias): string
|
||||
{
|
||||
if ($this->hasOrdersColumn('integration_id')) {
|
||||
return '(CASE
|
||||
WHEN LOWER(COALESCE(' . $orderAlias . '.source, "")) = "allegro" THEN "allegro"
|
||||
WHEN LOWER(COALESCE(' . $orderAlias . '.source, "")) = "shoppro" THEN CONCAT("shoppro:", COALESCE(CAST(' . $orderAlias . '.integration_id AS CHAR) COLLATE utf8mb4_unicode_ci, "0"))
|
||||
ELSE LOWER(COALESCE(' . $orderAlias . '.source, ""))
|
||||
END) COLLATE utf8mb4_unicode_ci';
|
||||
}
|
||||
|
||||
return '(CASE
|
||||
WHEN LOWER(COALESCE(' . $orderAlias . '.source, "")) = "allegro" THEN "allegro"
|
||||
WHEN LOWER(COALESCE(' . $orderAlias . '.source, "")) = "shoppro" THEN "shoppro:0"
|
||||
ELSE LOWER(COALESCE(' . $orderAlias . '.source, ""))
|
||||
END) COLLATE utf8mb4_unicode_ci';
|
||||
}
|
||||
|
||||
private function shopproChannelLabel(int $integrationId): string
|
||||
{
|
||||
if ($integrationId <= 0) {
|
||||
return 'shopPRO (brak integracji)';
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT name
|
||||
FROM integrations
|
||||
WHERE id = :id
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $integrationId]);
|
||||
$name = $stmt->fetchColumn();
|
||||
} catch (Throwable) {
|
||||
$name = false;
|
||||
}
|
||||
|
||||
if (is_string($name) && trim($name) !== '') {
|
||||
return trim($name);
|
||||
}
|
||||
|
||||
return 'shopPRO #' . $integrationId;
|
||||
}
|
||||
|
||||
private function netAmountSql(string $orderAlias): string
|
||||
{
|
||||
$netColumn = null;
|
||||
if ($this->hasOrdersColumn('total_without_tax')) {
|
||||
$netColumn = $orderAlias . '.total_without_tax';
|
||||
} elseif ($this->hasOrdersColumn('total_net')) {
|
||||
$netColumn = $orderAlias . '.total_net';
|
||||
}
|
||||
|
||||
$grossColumn = null;
|
||||
if ($this->hasOrdersColumn('total_with_tax')) {
|
||||
$grossColumn = $orderAlias . '.total_with_tax';
|
||||
} elseif ($this->hasOrdersColumn('total_gross')) {
|
||||
$grossColumn = $orderAlias . '.total_gross';
|
||||
}
|
||||
|
||||
// Fallback: gdy netto z zrodla jest puste (shopPRO nie wysyla netto), wyliczamy z brutto/1.23.
|
||||
// TODO(STAT-NET): docelowo pobierac netto z shopPRO na poziomie zamowienia lub liczyc z order_items po faktycznym tax_rate.
|
||||
if ($netColumn !== null && $grossColumn !== null) {
|
||||
return 'CASE
|
||||
WHEN ' . $netColumn . ' IS NOT NULL AND ' . $netColumn . ' > 0 THEN ' . $netColumn . '
|
||||
WHEN ' . $grossColumn . ' IS NOT NULL AND ' . $grossColumn . ' > 0 THEN ROUND(' . $grossColumn . ' / 1.23, 2)
|
||||
ELSE 0
|
||||
END';
|
||||
}
|
||||
|
||||
if ($netColumn !== null) {
|
||||
return 'COALESCE(' . $netColumn . ', 0)';
|
||||
}
|
||||
|
||||
if ($grossColumn !== null) {
|
||||
return 'ROUND(COALESCE(' . $grossColumn . ', 0) / 1.23, 2)';
|
||||
}
|
||||
|
||||
return '0';
|
||||
}
|
||||
|
||||
private function grossAmountSql(string $orderAlias): string
|
||||
{
|
||||
if ($this->hasOrdersColumn('total_with_tax')) {
|
||||
return 'COALESCE(' . $orderAlias . '.total_with_tax, 0)';
|
||||
}
|
||||
|
||||
if ($this->hasOrdersColumn('total_gross')) {
|
||||
return 'COALESCE(' . $orderAlias . '.total_gross, 0)';
|
||||
}
|
||||
|
||||
if ($this->hasOrdersColumn('total_without_tax')) {
|
||||
return 'COALESCE(' . $orderAlias . '.total_without_tax, 0)';
|
||||
}
|
||||
|
||||
if ($this->hasOrdersColumn('total_net')) {
|
||||
return 'COALESCE(' . $orderAlias . '.total_net, 0)';
|
||||
}
|
||||
|
||||
return '0';
|
||||
}
|
||||
|
||||
private function rawStatusSql(string $orderAlias): string
|
||||
{
|
||||
if ($this->hasOrdersColumn('status_code')) {
|
||||
return 'COALESCE(' . $orderAlias . '.status_code, "")';
|
||||
}
|
||||
|
||||
if ($this->hasOrdersColumn('external_status_id')) {
|
||||
return 'COALESCE(' . $orderAlias . '.external_status_id, "")';
|
||||
}
|
||||
|
||||
return '""';
|
||||
}
|
||||
|
||||
private function hasOrdersColumn(string $column): bool
|
||||
{
|
||||
if ($column === 'integration_id' && self::$hasOrdersIntegrationId !== null) {
|
||||
return self::$hasOrdersIntegrationId;
|
||||
}
|
||||
if ($column === 'total_without_tax' && self::$hasOrdersTotalWithoutTax !== null) {
|
||||
return self::$hasOrdersTotalWithoutTax;
|
||||
}
|
||||
if ($column === 'total_net' && self::$hasOrdersTotalNet !== null) {
|
||||
return self::$hasOrdersTotalNet;
|
||||
}
|
||||
if ($column === 'total_with_tax' && self::$hasOrdersTotalWithTax !== null) {
|
||||
return self::$hasOrdersTotalWithTax;
|
||||
}
|
||||
if ($column === 'total_gross' && self::$hasOrdersTotalGross !== null) {
|
||||
return self::$hasOrdersTotalGross;
|
||||
}
|
||||
if ($column === 'status_code' && self::$hasOrdersStatusCode !== null) {
|
||||
return self::$hasOrdersStatusCode;
|
||||
}
|
||||
if ($column === 'external_status_id' && self::$hasOrdersExternalStatusId !== null) {
|
||||
return self::$hasOrdersExternalStatusId;
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = :table_name
|
||||
AND COLUMN_NAME = :column_name'
|
||||
);
|
||||
$stmt->execute([
|
||||
'table_name' => 'orders',
|
||||
'column_name' => $column,
|
||||
]);
|
||||
$exists = ((int) $stmt->fetchColumn()) > 0;
|
||||
} catch (Throwable) {
|
||||
$exists = false;
|
||||
}
|
||||
|
||||
if ($column === 'total_without_tax') {
|
||||
self::$hasOrdersTotalWithoutTax = $exists;
|
||||
}
|
||||
if ($column === 'total_net') {
|
||||
self::$hasOrdersTotalNet = $exists;
|
||||
}
|
||||
if ($column === 'integration_id') {
|
||||
self::$hasOrdersIntegrationId = $exists;
|
||||
}
|
||||
if ($column === 'total_with_tax') {
|
||||
self::$hasOrdersTotalWithTax = $exists;
|
||||
}
|
||||
if ($column === 'total_gross') {
|
||||
self::$hasOrdersTotalGross = $exists;
|
||||
}
|
||||
if ($column === 'status_code') {
|
||||
self::$hasOrdersStatusCode = $exists;
|
||||
}
|
||||
if ($column === 'external_status_id') {
|
||||
self::$hasOrdersExternalStatusId = $exists;
|
||||
}
|
||||
|
||||
return $exists;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user