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:
2026-03-03 01:32:28 +01:00
parent d1576bc4ab
commit c489891d15
106 changed files with 11669 additions and 5091 deletions

View 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',
];
}
}