Add Orders and Order Status repositories with pagination and management features
- Implemented OrdersRepository for handling order data with pagination, filtering, and sorting capabilities. - Added methods for retrieving order status options, quick stats, and detailed order information. - Created OrderStatusRepository for managing order status groups and statuses, including CRUD operations and sorting. - Introduced a bootstrap file for test environment setup and autoloading.
This commit is contained in:
493
src/Modules/Orders/OrdersController.php
Normal file
493
src/Modules/Orders/OrdersController.php
Normal file
@@ -0,0 +1,493 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Orders;
|
||||
|
||||
use App\Core\Http\Request;
|
||||
use App\Core\Http\Response;
|
||||
use App\Core\I18n\Translator;
|
||||
use App\Core\Security\Csrf;
|
||||
use App\Core\View\Template;
|
||||
use App\Modules\Auth\AuthService;
|
||||
|
||||
final class OrdersController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Template $template,
|
||||
private readonly Translator $translator,
|
||||
private readonly AuthService $auth,
|
||||
private readonly OrdersRepository $orders
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$filters = [
|
||||
'search' => trim((string) $request->input('search', '')),
|
||||
'source' => trim((string) $request->input('source', '')),
|
||||
'status' => trim((string) $request->input('status', '')),
|
||||
'payment_status' => trim((string) $request->input('payment_status', '')),
|
||||
'date_from' => trim((string) $request->input('date_from', '')),
|
||||
'date_to' => trim((string) $request->input('date_to', '')),
|
||||
'sort' => (string) $request->input('sort', 'ordered_at'),
|
||||
'sort_dir' => (string) $request->input('sort_dir', 'DESC'),
|
||||
'page' => max(1, (int) $request->input('page', 1)),
|
||||
'per_page' => max(1, min(100, (int) $request->input('per_page', 20)),
|
||||
),
|
||||
];
|
||||
|
||||
$result = $this->orders->paginate($filters);
|
||||
$totalPages = max(1, (int) ceil(((int) $result['total']) / max(1, (int) $result['per_page'])));
|
||||
$sourceOptions = $this->orders->sourceOptions();
|
||||
$statusOptions = $this->orders->statusOptions();
|
||||
$stats = $this->orders->quickStats();
|
||||
$statusCounts = $this->orders->statusCounts();
|
||||
$statusConfig = $this->orders->statusPanelConfig();
|
||||
$statusLabelMap = $this->statusLabelMap($statusConfig);
|
||||
$statusPanel = $this->buildStatusPanel($statusConfig, $statusCounts, $filters['status'], $filters);
|
||||
|
||||
$tableRows = array_map(fn (array $row): array => $this->toTableRow($row, $statusLabelMap), (array) ($result['items'] ?? []));
|
||||
|
||||
$html = $this->template->render('orders/list', [
|
||||
'title' => $this->translator->get('orders.title'),
|
||||
'activeMenu' => 'orders',
|
||||
'activeOrders' => 'list',
|
||||
'user' => $this->auth->user(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
'tableList' => [
|
||||
'list_key' => 'orders',
|
||||
'base_path' => '/orders/list',
|
||||
'query' => $filters,
|
||||
'filters' => [
|
||||
[
|
||||
'key' => 'search',
|
||||
'label' => $this->translator->get('orders.filters.search'),
|
||||
'type' => 'text',
|
||||
'value' => $filters['search'],
|
||||
],
|
||||
[
|
||||
'key' => 'source',
|
||||
'label' => $this->translator->get('orders.filters.source'),
|
||||
'type' => 'select',
|
||||
'value' => $filters['source'],
|
||||
'options' => ['' => $this->translator->get('orders.filters.any')] + $sourceOptions,
|
||||
],
|
||||
[
|
||||
'key' => 'status',
|
||||
'label' => $this->translator->get('orders.filters.status'),
|
||||
'type' => 'select',
|
||||
'value' => $filters['status'],
|
||||
'options' => ['' => $this->translator->get('orders.filters.any')] + $statusOptions,
|
||||
],
|
||||
[
|
||||
'key' => 'payment_status',
|
||||
'label' => $this->translator->get('orders.filters.payment_status'),
|
||||
'type' => 'select',
|
||||
'value' => $filters['payment_status'],
|
||||
'options' => $this->paymentStatusFilterOptions(),
|
||||
],
|
||||
[
|
||||
'key' => 'date_from',
|
||||
'label' => $this->translator->get('orders.filters.date_from'),
|
||||
'type' => 'date',
|
||||
'value' => $filters['date_from'],
|
||||
],
|
||||
[
|
||||
'key' => 'date_to',
|
||||
'label' => $this->translator->get('orders.filters.date_to'),
|
||||
'type' => 'date',
|
||||
'value' => $filters['date_to'],
|
||||
],
|
||||
],
|
||||
'columns' => [
|
||||
['key' => 'order_ref', 'label' => $this->translator->get('orders.fields.order_ref'), 'sortable' => true, 'sort_key' => 'source_order_id', 'raw' => true],
|
||||
['key' => 'buyer', 'label' => $this->translator->get('orders.fields.buyer'), 'raw' => true],
|
||||
['key' => 'status_badges', 'label' => $this->translator->get('orders.fields.status'), 'sortable' => true, 'sort_key' => 'external_status_id', 'raw' => true],
|
||||
['key' => 'products', 'label' => $this->translator->get('orders.fields.products'), 'raw' => true],
|
||||
['key' => 'totals', 'label' => $this->translator->get('orders.fields.totals'), 'sortable' => true, 'sort_key' => 'total_with_tax', 'raw' => true],
|
||||
['key' => 'shipping', 'label' => $this->translator->get('orders.fields.shipping'), 'raw' => true],
|
||||
['key' => 'ordered_at', 'label' => $this->translator->get('orders.fields.ordered_at'), 'sortable' => true, 'sort_key' => 'ordered_at'],
|
||||
['key' => 'source_updated_at', 'label' => $this->translator->get('orders.fields.source_updated_at'), 'sortable' => true, 'sort_key' => 'source_updated_at'],
|
||||
],
|
||||
'rows' => $tableRows,
|
||||
'pagination' => [
|
||||
'page' => (int) ($result['page'] ?? 1),
|
||||
'total_pages' => $totalPages,
|
||||
'total' => (int) ($result['total'] ?? 0),
|
||||
'per_page' => (int) ($result['per_page'] ?? 20),
|
||||
],
|
||||
'per_page_options' => [20, 50, 100],
|
||||
'empty_message' => $this->translator->get('orders.empty'),
|
||||
'show_actions' => false,
|
||||
],
|
||||
'stats' => $stats,
|
||||
'statusPanel' => $statusPanel,
|
||||
'errorMessage' => (string) ($result['error'] ?? ''),
|
||||
], 'layouts/app');
|
||||
|
||||
return Response::html($html);
|
||||
}
|
||||
|
||||
public function show(Request $request): Response
|
||||
{
|
||||
$orderId = max(0, (int) $request->input('id', 0));
|
||||
$details = $this->orders->findDetails($orderId);
|
||||
if ($details === null) {
|
||||
return Response::html('Not found', 404);
|
||||
}
|
||||
|
||||
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
|
||||
$items = is_array($details['items'] ?? null) ? $details['items'] : [];
|
||||
$addresses = is_array($details['addresses'] ?? null) ? $details['addresses'] : [];
|
||||
$payments = is_array($details['payments'] ?? null) ? $details['payments'] : [];
|
||||
$shipments = is_array($details['shipments'] ?? null) ? $details['shipments'] : [];
|
||||
$documents = is_array($details['documents'] ?? null) ? $details['documents'] : [];
|
||||
$notes = is_array($details['notes'] ?? null) ? $details['notes'] : [];
|
||||
$history = is_array($details['status_history'] ?? null) ? $details['status_history'] : [];
|
||||
$statusCode = (string) ($order['external_status_id'] ?? '');
|
||||
$statusCounts = $this->orders->statusCounts();
|
||||
$statusConfig = $this->orders->statusPanelConfig();
|
||||
$statusLabelMap = $this->statusLabelMap($statusConfig);
|
||||
|
||||
$html = $this->template->render('orders/show', [
|
||||
'title' => $this->translator->get('orders.details.title') . ' #' . $orderId,
|
||||
'activeMenu' => 'orders',
|
||||
'activeOrders' => 'list',
|
||||
'user' => $this->auth->user(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
'orderId' => $orderId,
|
||||
'order' => $order,
|
||||
'items' => $items,
|
||||
'addresses' => $addresses,
|
||||
'payments' => $payments,
|
||||
'shipments' => $shipments,
|
||||
'documents' => $documents,
|
||||
'notes' => $notes,
|
||||
'history' => $history,
|
||||
'statusLabel' => $this->statusLabel($statusCode, $statusLabelMap),
|
||||
'statusPanel' => $this->buildStatusPanel($statusConfig, $statusCounts, $statusCode),
|
||||
], 'layouts/app');
|
||||
|
||||
return Response::html($html);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function toTableRow(array $row, array $statusLabelMap): array
|
||||
{
|
||||
$sourceOrderId = trim((string) ($row['source_order_id'] ?? ''));
|
||||
$externalOrderId = trim((string) ($row['external_order_id'] ?? ''));
|
||||
$source = trim((string) ($row['source'] ?? ''));
|
||||
$buyerName = trim((string) ($row['buyer_name'] ?? ''));
|
||||
$buyerEmail = trim((string) ($row['buyer_email'] ?? ''));
|
||||
$buyerCity = trim((string) ($row['buyer_city'] ?? ''));
|
||||
$status = trim((string) ($row['external_status_id'] ?? ''));
|
||||
$currency = trim((string) ($row['currency'] ?? ''));
|
||||
$totalWithTax = $row['total_with_tax'] !== null ? number_format((float) $row['total_with_tax'], 2, '.', ' ') : '-';
|
||||
$totalPaid = $row['total_paid'] !== null ? number_format((float) $row['total_paid'], 2, '.', ' ') : '-';
|
||||
$itemsCount = max(0, (int) ($row['items_count'] ?? 0));
|
||||
$itemsQty = $this->formatQuantity((float) ($row['items_qty'] ?? 0));
|
||||
$shipments = max(0, (int) ($row['shipments_count'] ?? 0));
|
||||
$documents = max(0, (int) ($row['documents_count'] ?? 0));
|
||||
$itemsPreview = is_array($row['items_preview'] ?? null) ? $row['items_preview'] : [];
|
||||
|
||||
return [
|
||||
'order_ref' => '<div class="orders-ref">'
|
||||
. '<div class="orders-ref__main"><a href="/orders/' . (int) ($row['id'] ?? 0) . '">'
|
||||
. htmlspecialchars($sourceOrderId !== '' ? $sourceOrderId : ('#' . (string) ($row['id'] ?? 0)), ENT_QUOTES, 'UTF-8')
|
||||
. '</a></div>'
|
||||
. '<div class="orders-ref__meta">'
|
||||
. '<span>' . htmlspecialchars($externalOrderId, ENT_QUOTES, 'UTF-8') . '</span>'
|
||||
. '<span>' . htmlspecialchars($source, ENT_QUOTES, 'UTF-8') . '</span>'
|
||||
. '</div>'
|
||||
. '</div>',
|
||||
'buyer' => '<div class="orders-buyer">'
|
||||
. '<div class="orders-buyer__name">' . htmlspecialchars($buyerName !== '' ? $buyerName : '-', ENT_QUOTES, 'UTF-8') . '</div>'
|
||||
. '<div class="orders-buyer__meta">'
|
||||
. '<span>' . htmlspecialchars($buyerEmail, ENT_QUOTES, 'UTF-8') . '</span>'
|
||||
. '<span>' . htmlspecialchars($buyerCity, ENT_QUOTES, 'UTF-8') . '</span>'
|
||||
. '</div>'
|
||||
. '</div>',
|
||||
'status_badges' => '<div class="orders-status-wrap">'
|
||||
. $this->statusBadge($this->statusLabel($status, $statusLabelMap))
|
||||
. '</div>',
|
||||
'products' => $this->productsHtml($itemsPreview, $itemsCount, $itemsQty),
|
||||
'totals' => '<div class="orders-money">'
|
||||
. '<div class="orders-money__main">' . htmlspecialchars($totalWithTax . ' ' . $currency, ENT_QUOTES, 'UTF-8') . '</div>'
|
||||
. '<div class="orders-money__meta">oplacono: ' . htmlspecialchars($totalPaid . ' ' . $currency, ENT_QUOTES, 'UTF-8') . '</div>'
|
||||
. '</div>',
|
||||
'shipping' => '<div class="orders-mini">'
|
||||
. '<div>wys.: <strong>' . $shipments . '</strong></div>'
|
||||
. '<div>dok.: <strong>' . $documents . '</strong></div>'
|
||||
. '</div>',
|
||||
'ordered_at' => (string) ($row['ordered_at'] ?? ''),
|
||||
'source_updated_at' => (string) ($row['source_updated_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function statusBadge(string $status): string
|
||||
{
|
||||
$label = $status !== '' ? $status : '-';
|
||||
$class = 'is-neutral';
|
||||
if (in_array($status, ['shipped', 'delivered'], true)) {
|
||||
$class = 'is-success';
|
||||
} elseif (in_array($status, ['cancelled', 'returned'], true)) {
|
||||
$class = 'is-danger';
|
||||
} elseif (in_array($status, ['new', 'confirmed'], true)) {
|
||||
$class = 'is-info';
|
||||
} elseif (in_array($status, ['processing', 'packed', 'paid'], true)) {
|
||||
$class = 'is-warn';
|
||||
}
|
||||
|
||||
return '<span class="order-tag ' . $class . '">' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '</span>';
|
||||
}
|
||||
|
||||
private function statusLabel(string $statusCode, array $statusLabelMap = []): string
|
||||
{
|
||||
$key = strtolower(trim($statusCode));
|
||||
if ($key === '') {
|
||||
return '-';
|
||||
}
|
||||
|
||||
if (isset($statusLabelMap[$key])) {
|
||||
return (string) $statusLabelMap[$key];
|
||||
}
|
||||
|
||||
return ucfirst($statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{name:string,color_hex:string,items:array<int, array{code:string,name:string}>}> $config
|
||||
* @param array<string, int> $counts
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function buildStatusPanel(array $config, array $counts, string $currentStatusCode, array $query = []): array
|
||||
{
|
||||
$allCount = 0;
|
||||
foreach ($counts as $count) {
|
||||
$allCount += (int) $count;
|
||||
}
|
||||
|
||||
$result = [[
|
||||
'name' => '',
|
||||
'items' => [[
|
||||
'code' => '',
|
||||
'label' => 'Wszystkie',
|
||||
'count' => $allCount,
|
||||
'is_active' => trim($currentStatusCode) === '',
|
||||
'tone' => 'neutral',
|
||||
'color_hex' => '#64748b',
|
||||
'url' => $this->statusFilterUrl($query, ''),
|
||||
]],
|
||||
]];
|
||||
|
||||
foreach ($config as $group) {
|
||||
$items = [];
|
||||
$groupColor = $this->normalizeColorHex((string) ($group['color_hex'] ?? '#64748b'));
|
||||
$groupItems = is_array($group['items'] ?? null) ? $group['items'] : [];
|
||||
foreach ($groupItems as $status) {
|
||||
$code = strtolower(trim((string) ($status['code'] ?? '')));
|
||||
if ($code === '') {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'code' => $code,
|
||||
'label' => (string) ($status['name'] ?? $code),
|
||||
'count' => (int) ($counts[$code] ?? 0),
|
||||
'is_active' => trim(strtolower($currentStatusCode)) === $code,
|
||||
'tone' => $this->statusTone($code),
|
||||
'color_hex' => $groupColor,
|
||||
'url' => $this->statusFilterUrl($query, $code),
|
||||
];
|
||||
}
|
||||
if ($items === []) {
|
||||
continue;
|
||||
}
|
||||
$result[] = [
|
||||
'name' => (string) ($group['name'] ?? ''),
|
||||
'color_hex' => $groupColor,
|
||||
'items' => $items,
|
||||
];
|
||||
}
|
||||
|
||||
$usedCodes = [];
|
||||
foreach ($result as $group) {
|
||||
$items = is_array($group['items'] ?? null) ? $group['items'] : [];
|
||||
foreach ($items as $item) {
|
||||
$code = strtolower(trim((string) ($item['code'] ?? '')));
|
||||
if ($code !== '') {
|
||||
$usedCodes[$code] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$extraItems = [];
|
||||
foreach ($counts as $code => $count) {
|
||||
$normalizedCode = strtolower(trim((string) $code));
|
||||
if ($normalizedCode === '' || $normalizedCode === '_empty' || isset($usedCodes[$normalizedCode])) {
|
||||
continue;
|
||||
}
|
||||
$extraItems[] = [
|
||||
'code' => $normalizedCode,
|
||||
'label' => $this->statusLabel($normalizedCode),
|
||||
'count' => (int) $count,
|
||||
'is_active' => trim(strtolower($currentStatusCode)) === $normalizedCode,
|
||||
'tone' => $this->statusTone($normalizedCode),
|
||||
'color_hex' => '#64748b',
|
||||
'url' => $this->statusFilterUrl($query, $normalizedCode),
|
||||
];
|
||||
}
|
||||
if ($extraItems !== []) {
|
||||
$result[] = [
|
||||
'name' => 'Pozostale',
|
||||
'color_hex' => '#64748b',
|
||||
'items' => $extraItems,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
*/
|
||||
private function statusFilterUrl(array $query, string $statusCode): string
|
||||
{
|
||||
$params = $query;
|
||||
if ($statusCode === '') {
|
||||
unset($params['status']);
|
||||
} else {
|
||||
$params['status'] = $statusCode;
|
||||
}
|
||||
$params['page'] = 1;
|
||||
|
||||
$clean = [];
|
||||
foreach ($params as $key => $value) {
|
||||
if ($value === '' || $value === null) {
|
||||
continue;
|
||||
}
|
||||
$clean[(string) $key] = (string) $value;
|
||||
}
|
||||
|
||||
$qs = http_build_query($clean);
|
||||
return $qs === '' ? '/orders/list' : '/orders/list?' . $qs;
|
||||
}
|
||||
|
||||
private function statusTone(string $statusCode): string
|
||||
{
|
||||
$code = strtolower(trim($statusCode));
|
||||
if (in_array($code, ['new', 'confirmed'], true)) {
|
||||
return 'info';
|
||||
}
|
||||
if (in_array($code, ['paid', 'processing', 'packed'], true)) {
|
||||
return 'warn';
|
||||
}
|
||||
if (in_array($code, ['shipped', 'delivered'], true)) {
|
||||
return 'success';
|
||||
}
|
||||
if (in_array($code, ['cancelled', 'returned'], true)) {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{name:string,color_hex:string,items:array<int, array{code:string,name:string}>}> $config
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function statusLabelMap(array $config): array
|
||||
{
|
||||
$map = [];
|
||||
foreach ($config as $group) {
|
||||
$items = is_array($group['items'] ?? null) ? $group['items'] : [];
|
||||
foreach ($items as $item) {
|
||||
$code = strtolower(trim((string) ($item['code'] ?? '')));
|
||||
if ($code === '') {
|
||||
continue;
|
||||
}
|
||||
$map[$code] = (string) ($item['name'] ?? $code);
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $itemsPreview
|
||||
*/
|
||||
private function productsHtml(array $itemsPreview, int $itemsCount, string $itemsQty): string
|
||||
{
|
||||
if ($itemsPreview === []) {
|
||||
return '<div class="orders-products">'
|
||||
. '<div class="orders-products__meta">0 pozycji / 0.000 szt.</div>'
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
$html = '<div class="orders-products">';
|
||||
foreach ($itemsPreview as $item) {
|
||||
$name = trim((string) ($item['name'] ?? ''));
|
||||
$qty = $this->formatQuantity((float) ($item['quantity'] ?? 0));
|
||||
$mediaUrl = trim((string) ($item['media_url'] ?? ''));
|
||||
|
||||
$thumb = $mediaUrl !== ''
|
||||
? '<button type="button" class="orders-image-trigger js-order-img-open" data-image-url="' . htmlspecialchars($mediaUrl, ENT_QUOTES, 'UTF-8') . '" aria-label="Podglad zdjecia produktu">'
|
||||
. '<img class="orders-product__thumb" src="' . htmlspecialchars($mediaUrl, ENT_QUOTES, 'UTF-8') . '" alt="">'
|
||||
. '</button>'
|
||||
: '<span class="orders-product__thumb orders-product__thumb--empty"></span>';
|
||||
|
||||
$html .= '<div class="orders-product">'
|
||||
. $thumb
|
||||
. '<div class="orders-product__txt">'
|
||||
. '<div class="orders-product__name">' . htmlspecialchars($name !== '' ? $name : '-', ENT_QUOTES, 'UTF-8') . '</div>'
|
||||
. '<div class="orders-product__qty">' . htmlspecialchars($qty, ENT_QUOTES, 'UTF-8') . ' szt.</div>'
|
||||
. '</div>'
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
if ($itemsCount > count($itemsPreview)) {
|
||||
$html .= '<div class="orders-products__more">+' . ($itemsCount - count($itemsPreview)) . ' pozycji</div>';
|
||||
}
|
||||
$html .= '<div class="orders-products__meta">' . $itemsCount . ' pozycji / ' . htmlspecialchars($itemsQty, ENT_QUOTES, 'UTF-8') . ' szt.</div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function formatQuantity(float $value): string
|
||||
{
|
||||
$rounded = round($value, 3);
|
||||
if (abs($rounded - round($rounded)) < 0.0005) {
|
||||
return (string) (int) round($rounded);
|
||||
}
|
||||
|
||||
$formatted = number_format($rounded, 3, '.', '');
|
||||
return rtrim(rtrim($formatted, '0'), '.');
|
||||
}
|
||||
|
||||
private function normalizeColorHex(string $value): string
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
if (preg_match('/^#[0-9a-fA-F]{6}$/', $trimmed) === 1) {
|
||||
return strtolower($trimmed);
|
||||
}
|
||||
|
||||
return '#64748b';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function paymentStatusFilterOptions(): array
|
||||
{
|
||||
return [
|
||||
'' => $this->translator->get('orders.filters.any'),
|
||||
'0' => 'nieoplacone',
|
||||
'1' => 'czesciowo oplacone',
|
||||
'2' => 'oplacone',
|
||||
'3' => 'zwrocone',
|
||||
];
|
||||
}
|
||||
}
|
||||
508
src/Modules/Orders/OrdersRepository.php
Normal file
508
src/Modules/Orders/OrdersRepository.php
Normal file
@@ -0,0 +1,508 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Orders;
|
||||
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
final class OrdersRepository
|
||||
{
|
||||
public function __construct(private readonly PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
* @return array{items:array<int, array<string, mixed>>, total:int, page:int, per_page:int, error:string}
|
||||
*/
|
||||
public function paginate(array $filters): array
|
||||
{
|
||||
$page = max(1, (int) ($filters['page'] ?? 1));
|
||||
$perPage = max(1, min(100, (int) ($filters['per_page'] ?? 20)));
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
$search = trim((string) ($filters['search'] ?? ''));
|
||||
if ($search !== '') {
|
||||
$where[] = '('
|
||||
. 'o.source_order_id LIKE :search '
|
||||
. 'OR o.external_order_id LIKE :search '
|
||||
. 'OR o.customer_login LIKE :search '
|
||||
. 'OR a.name LIKE :search '
|
||||
. 'OR a.email LIKE :search'
|
||||
. ')';
|
||||
$params['search'] = '%' . $search . '%';
|
||||
}
|
||||
|
||||
$source = trim((string) ($filters['source'] ?? ''));
|
||||
if ($source !== '') {
|
||||
$where[] = 'o.source = :source';
|
||||
$params['source'] = $source;
|
||||
}
|
||||
|
||||
$status = trim((string) ($filters['status'] ?? ''));
|
||||
if ($status !== '') {
|
||||
$where[] = 'o.external_status_id = :status';
|
||||
$params['status'] = $status;
|
||||
}
|
||||
|
||||
$paymentStatus = trim((string) ($filters['payment_status'] ?? ''));
|
||||
if ($paymentStatus !== '' && ctype_digit($paymentStatus)) {
|
||||
$where[] = 'o.payment_status = :payment_status';
|
||||
$params['payment_status'] = (int) $paymentStatus;
|
||||
}
|
||||
|
||||
$dateFrom = trim((string) ($filters['date_from'] ?? ''));
|
||||
if ($dateFrom !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateFrom) === 1) {
|
||||
$where[] = 'o.ordered_at >= :date_from';
|
||||
$params['date_from'] = $dateFrom . ' 00:00:00';
|
||||
}
|
||||
|
||||
$dateTo = trim((string) ($filters['date_to'] ?? ''));
|
||||
if ($dateTo !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateTo) === 1) {
|
||||
$where[] = 'o.ordered_at <= :date_to';
|
||||
$params['date_to'] = $dateTo . ' 23:59:59';
|
||||
}
|
||||
|
||||
$whereSql = $where === [] ? '' : (' WHERE ' . implode(' AND ', $where));
|
||||
|
||||
$sort = (string) ($filters['sort'] ?? 'ordered_at');
|
||||
$sortDir = strtoupper((string) ($filters['sort_dir'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
|
||||
$sortColumn = match ($sort) {
|
||||
'source_order_id' => 'o.source_order_id',
|
||||
'external_order_id' => 'o.external_order_id',
|
||||
'external_status_id' => 'o.external_status_id',
|
||||
'payment_status' => 'o.payment_status',
|
||||
'total_with_tax' => 'o.total_with_tax',
|
||||
'total_paid' => 'o.total_paid',
|
||||
'source_updated_at' => 'o.source_updated_at',
|
||||
'fetched_at' => 'o.fetched_at',
|
||||
'id' => 'o.id',
|
||||
default => 'o.ordered_at',
|
||||
};
|
||||
|
||||
try {
|
||||
$countSql = 'SELECT COUNT(*) FROM orders o '
|
||||
. 'LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer"'
|
||||
. $whereSql;
|
||||
$countStmt = $this->pdo->prepare($countSql);
|
||||
$countStmt->execute($params);
|
||||
$total = (int) $countStmt->fetchColumn();
|
||||
|
||||
$listSql = 'SELECT
|
||||
o.id,
|
||||
o.source,
|
||||
o.source_order_id,
|
||||
o.external_order_id,
|
||||
o.external_status_id,
|
||||
o.payment_status,
|
||||
o.currency,
|
||||
o.total_with_tax,
|
||||
o.total_paid,
|
||||
o.ordered_at,
|
||||
o.source_updated_at,
|
||||
o.fetched_at,
|
||||
o.is_invoice,
|
||||
o.is_canceled_by_buyer,
|
||||
a.name AS buyer_name,
|
||||
a.email AS buyer_email,
|
||||
a.city AS buyer_city,
|
||||
(SELECT COUNT(*) FROM order_items oi WHERE oi.order_id = o.id) AS items_count,
|
||||
(SELECT COALESCE(SUM(oi.quantity), 0) FROM order_items oi WHERE oi.order_id = o.id) AS items_qty,
|
||||
(SELECT COUNT(*) FROM order_shipments sh WHERE sh.order_id = o.id) AS shipments_count,
|
||||
(SELECT COUNT(*) FROM order_documents od WHERE od.order_id = o.id) AS documents_count
|
||||
FROM orders o
|
||||
LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer"'
|
||||
. $whereSql
|
||||
. ' ORDER BY ' . $sortColumn . ' ' . $sortDir
|
||||
. ' LIMIT :limit OFFSET :offset';
|
||||
|
||||
$stmt = $this->pdo->prepare($listSql);
|
||||
foreach ($params as $key => $value) {
|
||||
if (is_int($value)) {
|
||||
$stmt->bindValue(':' . $key, $value, PDO::PARAM_INT);
|
||||
} else {
|
||||
$stmt->bindValue(':' . $key, $value);
|
||||
}
|
||||
}
|
||||
$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
$rows = $stmt->fetchAll();
|
||||
if (!is_array($rows)) {
|
||||
$rows = [];
|
||||
}
|
||||
$itemPreviewsByOrderId = $this->loadOrderItemsPreviews(array_map(
|
||||
static fn (array $row): int => (int) ($row['id'] ?? 0),
|
||||
$rows
|
||||
));
|
||||
|
||||
return [
|
||||
'items' => array_map(static function (array $row) use ($itemPreviewsByOrderId): array {
|
||||
$orderId = (int) ($row['id'] ?? 0);
|
||||
return [
|
||||
'id' => $orderId,
|
||||
'source' => (string) ($row['source'] ?? ''),
|
||||
'source_order_id' => (string) ($row['source_order_id'] ?? ''),
|
||||
'external_order_id' => (string) ($row['external_order_id'] ?? ''),
|
||||
'external_status_id' => (string) ($row['external_status_id'] ?? ''),
|
||||
'payment_status' => isset($row['payment_status']) ? (int) $row['payment_status'] : null,
|
||||
'currency' => (string) ($row['currency'] ?? ''),
|
||||
'total_with_tax' => $row['total_with_tax'] !== null ? (float) $row['total_with_tax'] : null,
|
||||
'total_paid' => $row['total_paid'] !== null ? (float) $row['total_paid'] : null,
|
||||
'ordered_at' => (string) ($row['ordered_at'] ?? ''),
|
||||
'source_updated_at' => (string) ($row['source_updated_at'] ?? ''),
|
||||
'fetched_at' => (string) ($row['fetched_at'] ?? ''),
|
||||
'is_invoice' => (int) ($row['is_invoice'] ?? 0) === 1,
|
||||
'is_canceled_by_buyer' => (int) ($row['is_canceled_by_buyer'] ?? 0) === 1,
|
||||
'buyer_name' => (string) ($row['buyer_name'] ?? ''),
|
||||
'buyer_email' => (string) ($row['buyer_email'] ?? ''),
|
||||
'buyer_city' => (string) ($row['buyer_city'] ?? ''),
|
||||
'items_count' => (int) ($row['items_count'] ?? 0),
|
||||
'items_qty' => (float) ($row['items_qty'] ?? 0),
|
||||
'shipments_count' => (int) ($row['shipments_count'] ?? 0),
|
||||
'documents_count' => (int) ($row['documents_count'] ?? 0),
|
||||
'items_preview' => (array) ($itemPreviewsByOrderId[$orderId] ?? []),
|
||||
];
|
||||
}, $rows),
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'error' => '',
|
||||
];
|
||||
} catch (Throwable $exception) {
|
||||
return [
|
||||
'items' => [],
|
||||
'total' => 0,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'error' => $exception->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function statusOptions(): array
|
||||
{
|
||||
try {
|
||||
$rows = $this->pdo->query('SELECT DISTINCT external_status_id FROM orders WHERE external_status_id IS NOT NULL AND external_status_id <> "" ORDER BY external_status_id ASC')->fetchAll(PDO::FETCH_COLUMN);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$options = [];
|
||||
foreach ($rows as $row) {
|
||||
$value = trim((string) $row);
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
$options[$value] = $value;
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function sourceOptions(): array
|
||||
{
|
||||
try {
|
||||
$rows = $this->pdo->query('SELECT DISTINCT source FROM orders WHERE source IS NOT NULL AND source <> "" ORDER BY source ASC')->fetchAll(PDO::FETCH_COLUMN);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$options = [];
|
||||
foreach ($rows as $row) {
|
||||
$value = trim((string) $row);
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
$options[$value] = $value;
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{all:int, paid:int, shipped:int}
|
||||
*/
|
||||
public function quickStats(): array
|
||||
{
|
||||
try {
|
||||
$row = $this->pdo->query('SELECT
|
||||
COUNT(*) AS all_count,
|
||||
SUM(CASE WHEN payment_status = 2 THEN 1 ELSE 0 END) AS paid_count,
|
||||
SUM(CASE WHEN external_status_id IN ("shipped", "delivered", "returned") THEN 1 ELSE 0 END) AS shipped_count
|
||||
FROM orders')->fetch(PDO::FETCH_ASSOC);
|
||||
} catch (Throwable) {
|
||||
return [
|
||||
'all' => 0,
|
||||
'paid' => 0,
|
||||
'shipped' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
if (!is_array($row)) {
|
||||
return [
|
||||
'all' => 0,
|
||||
'paid' => 0,
|
||||
'shipped' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'all' => (int) ($row['all_count'] ?? 0),
|
||||
'paid' => (int) ($row['paid_count'] ?? 0),
|
||||
'shipped' => (int) ($row['shipped_count'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function statusCounts(): array
|
||||
{
|
||||
try {
|
||||
$rows = $this->pdo->query('SELECT external_status_id, COUNT(*) AS cnt FROM orders GROUP BY external_status_id')->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$key = trim((string) ($row['external_status_id'] ?? ''));
|
||||
if ($key === '') {
|
||||
$key = '_empty';
|
||||
}
|
||||
$result[$key] = (int) ($row['cnt'] ?? 0);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{name:string,color_hex:string,items:array<int, array{code:string,name:string}>}>
|
||||
*/
|
||||
public function statusPanelConfig(): array
|
||||
{
|
||||
try {
|
||||
$sql = 'SELECT
|
||||
g.id AS group_id,
|
||||
g.name AS group_name,
|
||||
g.color_hex AS group_color_hex,
|
||||
g.sort_order AS group_sort_order,
|
||||
s.code AS status_code,
|
||||
s.name AS status_name,
|
||||
s.sort_order AS status_sort_order
|
||||
FROM order_status_groups g
|
||||
LEFT JOIN order_statuses s ON s.group_id = g.id AND s.is_active = 1
|
||||
WHERE g.is_active = 1
|
||||
ORDER BY g.sort_order ASC, g.id ASC, s.sort_order ASC, s.id ASC';
|
||||
$rows = $this->pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!is_array($rows) || $rows === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$groupMap = [];
|
||||
foreach ($rows as $row) {
|
||||
$groupId = (int) ($row['group_id'] ?? 0);
|
||||
if ($groupId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($groupMap[$groupId])) {
|
||||
$groupMap[$groupId] = [
|
||||
'name' => trim((string) ($row['group_name'] ?? '')),
|
||||
'color_hex' => $this->normalizeColorHex((string) ($row['group_color_hex'] ?? '#64748b')),
|
||||
'items' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$statusCode = trim((string) ($row['status_code'] ?? ''));
|
||||
if ($statusCode === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$groupMap[$groupId]['items'][] = [
|
||||
'code' => $statusCode,
|
||||
'name' => trim((string) ($row['status_name'] ?? $statusCode)),
|
||||
];
|
||||
}
|
||||
|
||||
return array_values($groupMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function findDetails(int $orderId): ?array
|
||||
{
|
||||
if ($orderId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$orderStmt = $this->pdo->prepare('SELECT * FROM orders WHERE id = :id LIMIT 1');
|
||||
$orderStmt->execute(['id' => $orderId]);
|
||||
$order = $orderStmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!is_array($order)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$addressesStmt = $this->pdo->prepare('SELECT * FROM order_addresses WHERE order_id = :order_id ORDER BY FIELD(address_type, "customer", "invoice", "delivery"), id ASC');
|
||||
$addressesStmt->execute(['order_id' => $orderId]);
|
||||
$addresses = $addressesStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
if (!is_array($addresses)) {
|
||||
$addresses = [];
|
||||
}
|
||||
|
||||
$itemsStmt = $this->pdo->prepare('SELECT * FROM order_items WHERE order_id = :order_id ORDER BY sort_order ASC, id ASC');
|
||||
$itemsStmt->execute(['order_id' => $orderId]);
|
||||
$items = $itemsStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
if (!is_array($items)) {
|
||||
$items = [];
|
||||
}
|
||||
|
||||
$paymentsStmt = $this->pdo->prepare('SELECT * FROM order_payments WHERE order_id = :order_id ORDER BY payment_date ASC, id ASC');
|
||||
$paymentsStmt->execute(['order_id' => $orderId]);
|
||||
$payments = $paymentsStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
if (!is_array($payments)) {
|
||||
$payments = [];
|
||||
}
|
||||
|
||||
$shipmentsStmt = $this->pdo->prepare('SELECT * FROM order_shipments WHERE order_id = :order_id ORDER BY posted_at ASC, id ASC');
|
||||
$shipmentsStmt->execute(['order_id' => $orderId]);
|
||||
$shipments = $shipmentsStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
if (!is_array($shipments)) {
|
||||
$shipments = [];
|
||||
}
|
||||
|
||||
$documentsStmt = $this->pdo->prepare('SELECT * FROM order_documents WHERE order_id = :order_id ORDER BY source_created_at ASC, id ASC');
|
||||
$documentsStmt->execute(['order_id' => $orderId]);
|
||||
$documents = $documentsStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
if (!is_array($documents)) {
|
||||
$documents = [];
|
||||
}
|
||||
|
||||
$notesStmt = $this->pdo->prepare('SELECT * FROM order_notes WHERE order_id = :order_id ORDER BY created_at_external DESC, id DESC');
|
||||
$notesStmt->execute(['order_id' => $orderId]);
|
||||
$notes = $notesStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
if (!is_array($notes)) {
|
||||
$notes = [];
|
||||
}
|
||||
|
||||
$historyStmt = $this->pdo->prepare('SELECT * FROM order_status_history WHERE order_id = :order_id ORDER BY changed_at DESC, id DESC');
|
||||
$historyStmt->execute(['order_id' => $orderId]);
|
||||
$history = $historyStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
if (!is_array($history)) {
|
||||
$history = [];
|
||||
}
|
||||
|
||||
return [
|
||||
'order' => $order,
|
||||
'addresses' => $addresses,
|
||||
'items' => $items,
|
||||
'payments' => $payments,
|
||||
'shipments' => $shipments,
|
||||
'documents' => $documents,
|
||||
'notes' => $notes,
|
||||
'status_history' => $history,
|
||||
];
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $orderIds
|
||||
* @return array<int, array<int, array{name:string, quantity:float, media_url:string}>>
|
||||
*/
|
||||
private function loadOrderItemsPreviews(array $orderIds): array
|
||||
{
|
||||
$cleanIds = [];
|
||||
foreach ($orderIds as $id) {
|
||||
$orderId = max(0, (int) $id);
|
||||
if ($orderId <= 0 || in_array($orderId, $cleanIds, true)) {
|
||||
continue;
|
||||
}
|
||||
$cleanIds[] = $orderId;
|
||||
}
|
||||
if ($cleanIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($cleanIds), '?'));
|
||||
try {
|
||||
$sql = 'SELECT order_id, original_name, quantity, COALESCE(media_url, "") AS media_url, sort_order, id
|
||||
FROM order_items
|
||||
WHERE order_id IN (' . $placeholders . ')
|
||||
ORDER BY order_id ASC, sort_order ASC, id ASC';
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
foreach ($cleanIds as $index => $orderId) {
|
||||
$stmt->bindValue($index + 1, $orderId, PDO::PARAM_INT);
|
||||
}
|
||||
$stmt->execute();
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$orderId = (int) ($row['order_id'] ?? 0);
|
||||
if ($orderId <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($result[$orderId])) {
|
||||
$result[$orderId] = [];
|
||||
}
|
||||
if (count($result[$orderId]) >= 4) {
|
||||
continue;
|
||||
}
|
||||
$result[$orderId][] = [
|
||||
'name' => trim((string) ($row['original_name'] ?? '')),
|
||||
'quantity' => (float) ($row['quantity'] ?? 0),
|
||||
'media_url' => trim((string) ($row['media_url'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function normalizeColorHex(string $value): string
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
if (preg_match('/^#[0-9a-fA-F]{6}$/', $trimmed) === 1) {
|
||||
return strtolower($trimmed);
|
||||
}
|
||||
|
||||
return '#64748b';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user