This commit is contained in:
2026-04-12 01:35:19 +02:00
parent 91a8b85f38
commit d04e02020c
70 changed files with 8634 additions and 207 deletions

View File

@@ -29,6 +29,7 @@ final class AuthController
'title' => $this->translator->get('auth.login.title'),
'errorMessage' => Flash::get('error'),
'oldEmail' => (string) Flash::get('old_email', ''),
'oldRemember' => (bool) Flash::get('old_remember', false),
'csrfToken' => Csrf::token(),
], 'layouts/auth');
@@ -38,9 +39,12 @@ final class AuthController
public function login(Request $request): Response
{
$csrfToken = (string) $request->input('_token', '');
$remember = (bool) $request->input('remember', false);
if (!Csrf::validate($csrfToken)) {
Flash::set('error', $this->translator->get('auth.errors.csrf_expired'));
Flash::set('old_email', (string) $request->input('email', ''));
Flash::set('old_remember', $remember);
return Response::redirect('/login');
}
@@ -50,15 +54,24 @@ final class AuthController
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || $password === '') {
Flash::set('error', $this->translator->get('auth.errors.invalid_credentials_format'));
Flash::set('old_email', $email);
Flash::set('old_remember', $remember);
return Response::redirect('/login');
}
if (!$this->auth->attempt($email, $password)) {
Flash::set('error', $this->translator->get('auth.errors.invalid_credentials'));
Flash::set('old_email', $email);
Flash::set('old_remember', $remember);
return Response::redirect('/login');
}
if ($remember) {
$user = $this->auth->user();
if ($user !== null) {
$this->auth->createRememberToken((int) $user['id']);
}
}
return Response::redirect('/settings/users');
}

View File

@@ -14,7 +14,7 @@ final class AuthMiddleware
public function __invoke(Request $request, callable $next): Response
{
if (!$this->auth->check()) {
if (!$this->auth->check() && !$this->auth->loginFromRememberToken()) {
return Response::redirect('/login');
}

View File

@@ -9,6 +9,8 @@ use App\Modules\Users\UserRepository;
final class AuthService
{
private const SESSION_USER_KEY = 'auth_user';
private const REMEMBER_COOKIE = 'remember_token';
private const REMEMBER_DAYS = 30;
public function __construct(private readonly UserRepository $users)
{
@@ -57,9 +59,69 @@ final class AuthService
return $user;
}
public function createRememberToken(int $userId): void
{
$token = bin2hex(random_bytes(32));
$this->users->updateRememberToken($userId, hash('sha256', $token));
$secure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
setcookie(self::REMEMBER_COOKIE, $token, [
'expires' => time() + (self::REMEMBER_DAYS * 86400),
'path' => '/',
'httponly' => true,
'secure' => $secure,
'samesite' => 'Lax',
]);
}
public function loginFromRememberToken(): bool
{
$cookieToken = $_COOKIE[self::REMEMBER_COOKIE] ?? '';
if ($cookieToken === '') {
return false;
}
$tokenHash = hash('sha256', $cookieToken);
$user = $this->users->findByRememberToken($tokenHash);
if ($user === null) {
$this->clearRememberCookie();
return false;
}
Session::regenerate();
$_SESSION[self::SESSION_USER_KEY] = [
'id' => (int) $user['id'],
'name' => (string) $user['name'],
'email' => (string) $user['email'],
'login_at' => date(DATE_ATOM),
];
return true;
}
public function logout(): void
{
$user = $this->user();
if ($user !== null) {
$this->users->updateRememberToken((int) $user['id'], null);
}
$this->clearRememberCookie();
unset($_SESSION[self::SESSION_USER_KEY]);
Session::regenerate();
}
private function clearRememberCookie(): void
{
$secure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
setcookie(self::REMEMBER_COOKIE, '', [
'expires' => time() - 3600,
'path' => '/',
'httponly' => true,
'secure' => $secure,
'samesite' => 'Lax',
]);
unset($_COOKIE[self::REMEMBER_COOKIE]);
}
}

View File

@@ -17,7 +17,7 @@ final class AutomationController
{
private const HISTORY_PER_PAGE = 25;
private const ALLOWED_EVENTS = ['receipt.created', 'shipment.created', 'shipment.status_changed', 'payment.status_changed', 'order.status_changed', 'order.status_aged', 'order.imported'];
private const ALLOWED_CONDITION_TYPES = ['integration', 'shipment_status', 'payment_status', 'order_status', 'days_in_status'];
private const ALLOWED_CONDITION_TYPES = ['integration', 'shipment_status', 'payment_status', 'payment_method', 'order_status', 'days_in_status'];
private const PAYMENT_STATUS_OPTIONS = [
'0' => 'Nieopłacone',
'1' => 'Częściowo opłacone',
@@ -27,6 +27,12 @@ final class AutomationController
private const ALLOWED_RECIPIENTS = ['client', 'client_and_company', 'company'];
private const ALLOWED_RECEIPT_ISSUE_DATE_MODES = ['today', 'order_date', 'payment_date'];
private const ALLOWED_RECEIPT_DUPLICATE_POLICIES = ['skip_if_exists', 'allow_duplicates'];
private const PAYMENT_METHOD_OPTIONS = [
'cod' => 'Platnosc przy odbiorze (COD)',
'transfer' => 'Przelew bankowy',
'online' => 'Karta / platnosc online',
'other' => 'Inna',
];
private const SHIPMENT_STATUS_OPTIONS = [
'registered' => ['label' => 'Przesylka zarejestrowana', 'statuses' => ['created', 'confirmed']],
'ready_for_pickup' => ['label' => 'Przesylka do odbioru', 'statuses' => ['ready_for_pickup']],
@@ -251,6 +257,7 @@ final class AutomationController
'receiptDuplicatePolicies' => self::ALLOWED_RECEIPT_DUPLICATE_POLICIES,
'shipmentStatusOptions' => self::SHIPMENT_STATUS_OPTIONS,
'paymentStatusOptions' => self::PAYMENT_STATUS_OPTIONS,
'paymentMethodOptions' => self::PAYMENT_METHOD_OPTIONS,
'orderStatusOptions' => $this->repository->listActiveOrderStatuses(),
'errorMessage' => $errorMessage !== '' ? $errorMessage : Flash::get('settings.automation.error', ''),
], 'layouts/app');
@@ -275,6 +282,8 @@ final class AutomationController
$value = ['status_keys' => is_array($cond['shipment_status_keys'] ?? null) ? $cond['shipment_status_keys'] : []];
} elseif ($type === 'payment_status') {
$value = ['status_keys' => is_array($cond['payment_status_keys'] ?? null) ? $cond['payment_status_keys'] : []];
} elseif ($type === 'payment_method') {
$value = ['method_keys' => is_array($cond['payment_method_keys'] ?? null) ? $cond['payment_method_keys'] : []];
} elseif ($type === 'order_status') {
$value = ['order_status_codes' => is_array($cond['order_status_codes'] ?? null) ? $cond['order_status_codes'] : []];
} elseif ($type === 'days_in_status') {
@@ -441,6 +450,21 @@ final class AutomationController
return count($statusKeys) > 0 ? ['status_keys' => array_values(array_unique($statusKeys))] : null;
}
if ($type === 'payment_method') {
$keys = $condition['payment_method_keys'] ?? [];
if (!is_array($keys)) {
$keys = [];
}
$allowedKeys = array_keys(self::PAYMENT_METHOD_OPTIONS);
$methodKeys = array_values(array_filter(
array_map(static fn (mixed $key): string => trim((string) $key), $keys),
static fn (string $key): bool => $key !== '' && in_array($key, $allowedKeys, true)
));
return count($methodKeys) > 0 ? ['method_keys' => array_values(array_unique($methodKeys))] : null;
}
if ($type === 'order_status') {
$codes = $condition['order_status_codes'] ?? [];
if (!is_array($codes)) {

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Automation;
use App\Core\Support\StringHelper;
use App\Modules\Accounting\ReceiptIssueException;
use App\Modules\Accounting\ReceiptRepository;
use App\Modules\Accounting\ReceiptService;
@@ -134,6 +135,9 @@ final class AutomationService
if ($type === 'payment_status') {
return $this->evaluatePaymentStatusCondition($value, $context);
}
if ($type === 'payment_method') {
return $this->evaluatePaymentMethodCondition($value, $order);
}
if ($type === 'order_status') {
return $this->evaluateOrderStatusCondition($value, $context);
}
@@ -216,6 +220,57 @@ final class AutomationService
return in_array($newPaymentStatus, array_map(static fn (mixed $k): string => trim((string) $k), $statusKeys), true);
}
/**
* @param array<string, mixed> $value
* @param array<string, mixed> $order
*/
private function evaluatePaymentMethodCondition(array $value, array $order): bool
{
$methodKeys = is_array($value['method_keys'] ?? null) ? $value['method_keys'] : [];
if ($methodKeys === []) {
return false;
}
$paymentType = trim((string) ($order['external_payment_type_id'] ?? ''));
if ($paymentType === '') {
return false;
}
$upperType = strtoupper($paymentType);
foreach ($methodKeys as $key) {
$match = match ((string) $key) {
'cod' => StringHelper::isCodPayment($paymentType),
'transfer' => str_contains($upperType, 'PRZELEW')
|| str_contains($upperType, 'TRANSFER')
|| str_contains($upperType, 'WIRE'),
'online' => str_contains($upperType, 'CARD')
|| str_contains($upperType, 'ONLINE')
|| str_contains($upperType, 'PAYU')
|| str_contains($upperType, 'PRZELEWY24')
|| str_contains($upperType, 'BLIK')
|| str_contains($upperType, 'TPAY'),
'other' => !StringHelper::isCodPayment($paymentType)
&& !str_contains($upperType, 'PRZELEW')
&& !str_contains($upperType, 'TRANSFER')
&& !str_contains($upperType, 'WIRE')
&& !str_contains($upperType, 'CARD')
&& !str_contains($upperType, 'ONLINE')
&& !str_contains($upperType, 'PAYU')
&& !str_contains($upperType, 'PRZELEWY24')
&& !str_contains($upperType, 'BLIK')
&& !str_contains($upperType, 'TPAY'),
default => false,
};
if ($match) {
return true;
}
}
return false;
}
/**
* @param array<string, mixed> $value
* @param array<string, mixed> $context
@@ -491,7 +546,7 @@ final class AutomationService
$details = $this->orders->findDetails($orderId);
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
$oldStatus = strtolower(trim((string) ($order['external_status_id'] ?? '')));
$oldStatus = strtolower(trim((string) ($order['status_code'] ?? '')));
$actorName = 'Automatyzacja: ' . $ruleName;
$updated = $this->orders->updateOrderStatus($orderId, $statusCode, 'system', $actorName);

View File

@@ -60,7 +60,7 @@ final class OrderStatusAgedService
}
try {
$currentStatus = strtolower(trim((string) ($order['external_status_id'] ?? '')));
$currentStatus = strtolower(trim((string) ($order['status_code'] ?? '')));
$lastChanged = (string) ($order['last_changed'] ?? '');
$actualDays = $lastChanged !== '' ? $this->daysSince($lastChanged) : $days;
@@ -129,12 +129,12 @@ final class OrderStatusAgedService
$placeholders = implode(', ', array_fill(0, count($statusCodes), '?'));
$sql = "SELECT o.id, o.external_status_id, MAX(h.changed_at) AS last_changed
$sql = "SELECT o.id, o.status_code, MAX(h.changed_at) AS last_changed
FROM orders o
INNER JOIN order_status_history h ON h.order_id = o.id
AND LOWER(h.to_status_id) = LOWER(o.external_status_id)
WHERE LOWER(COALESCE(o.external_status_id, '')) IN ({$placeholders})
GROUP BY o.id, o.external_status_id
AND LOWER(h.to_status_id) = LOWER(o.status_code)
WHERE LOWER(COALESCE(o.status_code, '')) IN ({$placeholders})
GROUP BY o.id, o.status_code
HAVING MAX(h.changed_at) <= DATE_SUB(NOW(), INTERVAL ? DAY)
LIMIT " . self::MAX_ORDERS_PER_RULE;

View File

@@ -45,7 +45,7 @@ final class OrderImportRepository
$newPaymentStatus = (int) ($orderData['payment_status'] ?? 0);
$paymentTransition = $currentStatus === 'nieoplacone' && $newPaymentStatus === 2;
if (!$paymentTransition) {
$orderData['external_status_id'] = $currentStatus;
$orderData['status_code'] = $currentStatus;
}
}
@@ -104,7 +104,7 @@ final class OrderImportRepository
private function getCurrentStatus(int $orderId): string
{
$statement = $this->pdo->prepare(
'SELECT external_status_id FROM orders WHERE id = :id LIMIT 1'
'SELECT status_code FROM orders WHERE id = :id LIMIT 1'
);
$statement->execute(['id' => $orderId]);
$value = $statement->fetchColumn();
@@ -120,13 +120,13 @@ final class OrderImportRepository
$statement = $this->pdo->prepare(
'INSERT INTO orders (
integration_id, source, source_order_id, external_order_id, external_platform_id, external_platform_account_id,
external_status_id, external_payment_type_id, payment_status, external_carrier_id, external_carrier_account_id,
status_code, external_payment_type_id, payment_status, external_carrier_id, external_carrier_account_id,
customer_login, is_invoice, is_encrypted, is_canceled_by_buyer, currency,
total_without_tax, total_with_tax, total_paid, delivery_price, send_date_min, send_date_max, ordered_at,
source_created_at, source_updated_at, preferences_json, payload_json, fetched_at
) VALUES (
:integration_id, :source, :source_order_id, :external_order_id, :external_platform_id, :external_platform_account_id,
:external_status_id, :external_payment_type_id, :payment_status, :external_carrier_id, :external_carrier_account_id,
:status_code, :external_payment_type_id, :payment_status, :external_carrier_id, :external_carrier_account_id,
:customer_login, :is_invoice, :is_encrypted, :is_canceled_by_buyer, :currency,
:total_without_tax, :total_with_tax, :total_paid, :delivery_price, :send_date_min, :send_date_max, :ordered_at,
:source_created_at, :source_updated_at, :preferences_json, :payload_json, :fetched_at
@@ -155,7 +155,7 @@ final class OrderImportRepository
external_order_id = :external_order_id,
external_platform_id = :external_platform_id,
external_platform_account_id = :external_platform_account_id,
external_status_id = :external_status_id,
status_code = :status_code,
external_payment_type_id = :external_payment_type_id,
payment_status = :payment_status,
external_carrier_id = :external_carrier_id,
@@ -201,7 +201,7 @@ final class OrderImportRepository
'external_order_id' => $orderData['external_order_id'] ?? null,
'external_platform_id' => $orderData['external_platform_id'] ?? null,
'external_platform_account_id' => $orderData['external_platform_account_id'] ?? null,
'external_status_id' => $orderData['external_status_id'] ?? null,
'status_code' => $orderData['status_code'] ?? null,
'external_payment_type_id' => $orderData['external_payment_type_id'] ?? null,
'payment_status' => $orderData['payment_status'] ?? null,
'external_carrier_id' => $orderData['external_carrier_id'] ?? null,

View File

@@ -71,81 +71,98 @@ final class OrdersController
$tableRows = array_map(fn (array $row): array => $this->toTableRow($row, $statusLabelMap, $statusColorMap), (array) ($result['items'] ?? []));
$tableListData = [
'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' => 'status_code', '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'],
],
'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],
'selectable' => true,
'select_name' => 'selected_ids[]',
'select_value_key' => 'id',
'header_actions' => [],
'empty_message' => $this->translator->get('orders.empty'),
'show_actions' => false,
];
if ($request->header('X-Requested-With') === 'XMLHttpRequest') {
$tableHtml = $this->template->render('components/table-list', [
'tableList' => $tableListData,
]);
$panelHtml = $this->template->render('components/order-status-panel', [
'statusPanelList' => $statusPanel,
'statusPanelTitle' => 'Statusy',
]);
return Response::json([
'tableHtml' => $tableHtml,
'panelHtml' => $panelHtml,
]);
}
$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'],
],
'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],
'selectable' => true,
'select_name' => 'selected_ids[]',
'select_value_key' => 'id',
'header_actions' => [],
'empty_message' => $this->translator->get('orders.empty'),
'show_actions' => false,
],
'tableList' => $tableListData,
'stats' => $stats,
'statusPanel' => $statusPanel,
'allStatuses' => $this->buildAllStatusOptions($statusConfig),
@@ -173,7 +190,7 @@ final class OrdersController
$notes = is_array($details['notes'] ?? null) ? $details['notes'] : [];
$history = is_array($details['status_history'] ?? null) ? $details['status_history'] : [];
$activityLog = is_array($details['activity_log'] ?? null) ? $details['activity_log'] : [];
$statusCode = (string) (($order['effective_status_id'] ?? '') !== '' ? $order['effective_status_id'] : ($order['external_status_id'] ?? ''));
$statusCode = (string) (($order['effective_status_id'] ?? '') !== '' ? $order['effective_status_id'] : ($order['status_code'] ?? ''));
$statusCounts = $this->orders->statusCounts();
$statusConfig = $this->orders->statusPanelConfig();
$statusLabelMap = $this->statusLabelMap($statusConfig);
@@ -280,7 +297,7 @@ final class OrdersController
$oldDetails = $this->orders->findDetails($orderId);
$oldOrder = is_array($oldDetails['order'] ?? null) ? $oldDetails['order'] : [];
$oldStatus = strtolower(trim((string) ($oldOrder['external_status_id'] ?? '')));
$oldStatus = strtolower(trim((string) ($oldOrder['status_code'] ?? '')));
$success = $this->orders->updateOrderStatus($orderId, $newStatus, 'user', $actorName !== '' ? $actorName : null);
@@ -336,7 +353,7 @@ final class OrdersController
$buyerName = trim((string) ($row['buyer_name'] ?? ''));
$buyerEmail = trim((string) ($row['buyer_email'] ?? ''));
$buyerCity = trim((string) ($row['buyer_city'] ?? ''));
$status = trim((string) (($row['effective_status_id'] ?? '') !== '' ? $row['effective_status_id'] : ($row['external_status_id'] ?? '')));
$status = trim((string) (($row['effective_status_id'] ?? '') !== '' ? $row['effective_status_id'] : ($row['status_code'] ?? '')));
$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, '.', ' ') : '-';
@@ -349,11 +366,17 @@ final class OrdersController
$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'] : [];
$projectsDone = max(0, (int) ($row['projects_done'] ?? 0));
$projectsTotal = max(0, (int) ($row['projects_total'] ?? 0));
$previewBtn = '<button type="button" class="btn-icon js-order-preview-btn" data-order-id="' . (int) ($row['id'] ?? 0) . '" title="Podglad">'
. '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>'
. '</button>';
return [
'id' => (int) ($row['id'] ?? 0),
'order_ref' => '<div class="orders-ref">'
. '<div class="orders-ref__main"><a href="/orders/' . (int) ($row['id'] ?? 0) . '">'
. '<div class="orders-ref__main">' . $previewBtn . '<a href="/orders/' . (int) ($row['id'] ?? 0) . '">'
. htmlspecialchars($internalOrderNumber !== '' ? $internalOrderNumber : ('#' . (string) ($row['id'] ?? 0)), ENT_QUOTES, 'UTF-8')
. '</a></div>'
. '<div class="orders-ref__meta">'
@@ -371,7 +394,7 @@ final class OrdersController
'status_badges' => '<div class="orders-status-wrap" data-order-id="' . (int) ($row['id'] ?? 0) . '" data-current-status="' . htmlspecialchars($status, ENT_QUOTES, 'UTF-8') . '">'
. $this->statusBadge($status, $this->statusLabel($status, $statusLabelMap), $statusColorMap[strtolower(trim($status))] ?? '')
. '</div>',
'products' => $this->productsHtml($itemsPreview, $itemsCount, $itemsQty),
'products' => $this->productsHtml($itemsPreview, $itemsCount, $itemsQty, $projectsDone, $projectsTotal),
'totals' => '<div class="orders-money">'
. '<div class="orders-money__main">' . htmlspecialchars($totalWithTax . ' ' . $currency, ENT_QUOTES, 'UTF-8') . ($isUnpaid ? ' <span class="order-tag is-unpaid">Nieopłacone</span>' : '') . '</div>'
. '<div class="orders-money__meta">' . ($isCod ? '<span class="order-tag is-cod">Za pobraniem</span>' : 'oplacono: ' . htmlspecialchars($totalPaid . ' ' . $currency, ENT_QUOTES, 'UTF-8')) . '</div>'
@@ -671,7 +694,7 @@ final class OrdersController
/**
* @param array<int, array<string, mixed>> $itemsPreview
*/
private function productsHtml(array $itemsPreview, int $itemsCount, string $itemsQty): string
private function productsHtml(array $itemsPreview, int $itemsCount, string $itemsQty, int $projectsDone = 0, int $projectsTotal = 0): string
{
if ($itemsPreview === []) {
return '<div class="orders-products">'
@@ -704,12 +727,37 @@ final class OrdersController
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 class="orders-products__meta">' . $itemsCount . ' pozycji / ' . htmlspecialchars($itemsQty, ENT_QUOTES, 'UTF-8') . ' szt.'
. $this->projectBadge($projectsDone, $projectsTotal)
. '</div>';
$html .= '</div>';
return $html;
}
private function projectBadge(int $done, int $total): string
{
if ($total === 0) {
return '';
}
if ($done === $total) {
return ' <span class="project-badge project-badge--done" title="Wszystkie projekty wygenerowane (' . $done . '/' . $total . ')">'
. '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>'
. '</span>';
}
if ($done > 0) {
return ' <span class="project-badge project-badge--partial" title="Projekty: ' . $done . '/' . $total . '">'
. $done . '/' . $total
. '</span>';
}
return ' <span class="project-badge project-badge--none" title="Brak wygenerowanych projektow (0/' . $total . ')">'
. '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>'
. '</span>';
}
private function shippingHtml(string $deliveryMethod, int $shipments, int $documents): string
{
$deliveryMethod = trim(html_entity_decode(strip_tags($deliveryMethod), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
@@ -961,4 +1009,35 @@ final class OrdersController
return Response::json(['results' => $results]);
}
public function preview(Request $request): Response
{
$orderId = max(0, (int) $request->input('id', 0));
$details = $this->orders->findDetails($orderId);
if ($details === null) {
return Response::html('<div class="order-preview-error">Zamowienie nie znalezione.</div>', 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'] : [];
$notes = is_array($details['notes'] ?? null) ? $details['notes'] : [];
$addressByType = ['customer' => null, 'delivery' => null, 'invoice' => null];
foreach ($addresses as $address) {
$type = (string) ($address['address_type'] ?? '');
if ($type !== '' && array_key_exists($type, $addressByType) && $addressByType[$type] === null) {
$addressByType[$type] = $address;
}
}
$html = $this->template->render('orders/partials/preview-content', [
'order' => $order,
'items' => $items,
'addressByType' => $addressByType,
'notes' => $notes,
]);
return Response::html($html);
}
}

View File

@@ -35,7 +35,7 @@ final class OrdersRepository
$sortColumn = match ($sort) {
'source_order_id' => 'o.source_order_id',
'external_order_id' => 'o.external_order_id',
'external_status_id' => 'o.external_status_id',
'status_code' => 'o.status_code',
'payment_status' => 'o.payment_status',
'total_with_tax' => 'o.total_with_tax',
'total_paid' => 'o.total_paid',
@@ -48,7 +48,7 @@ final class OrdersRepository
try {
$countSql = 'SELECT COUNT(*) FROM orders o '
. 'LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer" '
. 'LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code'
. 'LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.status_code) = asm.allegro_status_code'
. $whereSql;
$countStmt = $this->pdo->prepare($countSql);
$countStmt->execute($params);
@@ -158,7 +158,7 @@ final class OrdersRepository
o.source,
o.source_order_id,
o.external_order_id,
o.external_status_id,
o.status_code,
' . $effectiveStatusSql . ' AS effective_status_id,
o.payment_status,
o.currency,
@@ -178,15 +178,19 @@ final class OrdersRepository
o.external_payment_type_id,
COALESCE(oi_agg.items_count, 0) AS items_count,
COALESCE(oi_agg.items_qty, 0) AS items_qty,
COALESCE(oi_agg.projects_done, 0) AS projects_done,
COALESCE(oi_agg.projects_total, 0) AS projects_total,
COALESCE(sh_agg.shipments_count, 0) AS shipments_count,
COALESCE(od_agg.documents_count, 0) AS documents_count,
ig.name AS integration_name
FROM orders o
LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer"
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.status_code) = asm.allegro_status_code
LEFT JOIN integrations ig ON ig.id = o.integration_id
LEFT JOIN (
SELECT order_id, COUNT(*) AS items_count, COALESCE(SUM(quantity), 0) AS items_qty
SELECT order_id, COUNT(*) AS items_count, COALESCE(SUM(quantity), 0) AS items_qty,
SUM(CASE WHEN project_generated = 1 THEN 1 ELSE 0 END) AS projects_done,
COUNT(*) AS projects_total
FROM order_items GROUP BY order_id
) oi_agg ON oi_agg.order_id = o.id
LEFT JOIN (
@@ -217,7 +221,7 @@ final class OrdersRepository
'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'] ?? ''),
'status_code' => (string) ($row['status_code'] ?? ''),
'effective_status_id' => (string) ($row['effective_status_id'] ?? ''),
'payment_status' => isset($row['payment_status']) ? (int) $row['payment_status'] : null,
'currency' => (string) ($row['currency'] ?? ''),
@@ -240,6 +244,8 @@ final class OrdersRepository
'documents_count' => (int) ($row['documents_count'] ?? 0),
'integration_name' => (string) ($row['integration_name'] ?? ''),
'items_preview' => (array) ($itemPreviewsByOrderId[$orderId] ?? []),
'projects_done' => (int) ($row['projects_done'] ?? 0),
'projects_total' => (int) ($row['projects_total'] ?? 0),
];
}
@@ -253,7 +259,7 @@ final class OrdersRepository
$rows = $this->pdo->query(
'SELECT DISTINCT ' . $effectiveStatusSql . ' AS effective_status_id
FROM orders o
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.status_code) = asm.allegro_status_code
WHERE ' . $effectiveStatusSql . ' IS NOT NULL
AND ' . $effectiveStatusSql . ' <> ""
ORDER BY effective_status_id ASC'
@@ -317,7 +323,7 @@ final class OrdersRepository
SUM(CASE WHEN payment_status = 2 THEN 1 ELSE 0 END) AS paid_count,
SUM(CASE WHEN ' . $effectiveStatusSql . ' IN ("shipped", "delivered", "returned") THEN 1 ELSE 0 END) AS shipped_count
FROM orders o
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code')->fetch(PDO::FETCH_ASSOC);
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.status_code) = asm.allegro_status_code')->fetch(PDO::FETCH_ASSOC);
} catch (Throwable) {
return [
'all' => 0,
@@ -351,7 +357,7 @@ final class OrdersRepository
$rows = $this->pdo->query(
'SELECT ' . $effectiveStatusSql . ' AS effective_status_id, COUNT(*) AS cnt
FROM orders o
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.status_code) = asm.allegro_status_code
GROUP BY effective_status_id'
)->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable) {
@@ -475,7 +481,7 @@ final class OrdersRepository
'SELECT o.*, ' . $effectiveStatusSql . ' AS effective_status_id,
ig.name AS integration_name
FROM orders o
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.status_code) = asm.allegro_status_code
LEFT JOIN integrations ig ON ig.id = o.integration_id
WHERE o.id = :id
LIMIT 1'
@@ -670,7 +676,7 @@ final class OrdersRepository
AND ' . $mappingAlias . '.orderpro_status_code IS NOT NULL
AND ' . $mappingAlias . '.orderpro_status_code <> ""
THEN ' . $mappingAlias . '.orderpro_status_code
ELSE ' . $orderAlias . '.external_status_id
ELSE ' . $orderAlias . '.status_code
END';
}
@@ -981,16 +987,16 @@ final class OrdersRepository
public function updateOrderStatus(int $orderId, string $newStatusCode, string $actorType = 'user', ?string $actorName = null): bool
{
try {
$stmt = $this->pdo->prepare('SELECT external_status_id FROM orders WHERE id = :id LIMIT 1');
$stmt = $this->pdo->prepare('SELECT status_code FROM orders WHERE id = :id LIMIT 1');
$stmt->execute(['id' => $orderId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!is_array($row)) {
return false;
}
$oldStatus = trim((string) ($row['external_status_id'] ?? ''));
$oldStatus = trim((string) ($row['status_code'] ?? ''));
$update = $this->pdo->prepare('UPDATE orders SET external_status_id = :status, updated_at = NOW() WHERE id = :id');
$update = $this->pdo->prepare('UPDATE orders SET status_code = :status, updated_at = NOW() WHERE id = :id');
$update->execute(['status' => $newStatusCode, 'id' => $orderId]);
$this->recordStatusChange(

View File

@@ -209,7 +209,7 @@ final class AllegroOrderImportService
'external_order_id' => $checkoutFormId,
'external_platform_id' => trim((string) ($payload['marketplace']['id'] ?? 'allegro-pl')),
'external_platform_account_id' => null,
'external_status_id' => $externalStatus,
'status_code' => $externalStatus,
'external_payment_type_id' => trim((string) ($payment['type'] ?? '')),
'payment_status' => $mappedPaymentStatus,
'external_carrier_id' => $deliveryForm !== '' ? $deliveryForm : null,

View File

@@ -241,10 +241,10 @@ final class AllegroStatusSyncService
try {
$statement = $this->pdo->prepare(
'SELECT id, source_order_id, external_status_id
'SELECT id, source_order_id, status_code
FROM orders
WHERE source = ?
AND LOWER(COALESCE(external_status_id, "")) NOT IN (' . $placeholders . ')
AND LOWER(COALESCE(status_code, "")) NOT IN (' . $placeholders . ')
AND (last_status_checked_at IS NULL OR source_updated_at > last_status_checked_at)
ORDER BY source_updated_at DESC
LIMIT ' . self::MAX_ORDERS_PER_RUN
@@ -273,7 +273,7 @@ final class AllegroStatusSyncService
'SELECT
o.id,
o.source_order_id,
o.external_status_id AS orderpro_status_code,
o.status_code AS orderpro_status_code,
MAX(h.changed_at) AS latest_change
FROM order_status_history h
INNER JOIN orders o ON o.id = h.order_id
@@ -281,7 +281,7 @@ final class AllegroStatusSyncService
AND o.integration_id = :integration_id
AND h.change_source = :change_source
AND h.changed_at > :since_date
GROUP BY o.id, o.source_order_id, o.external_status_id
GROUP BY o.id, o.source_order_id, o.status_code
ORDER BY latest_change ASC
LIMIT ' . self::MAX_ORDERS_PER_RUN
);

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Core\I18n\Translator;
use App\Modules\Auth\AuthService;
final class ProjectMappingController
{
private const SCRIPTS_DIR = 'tools/generowanie';
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly ProjectMappingRepository $repository,
private readonly string $basePath
) {
}
public function index(Request $request): Response
{
$mappings = $this->repository->getAll();
$scripts = $this->scanScripts();
$html = $this->template->render('settings/project-mappings', [
'title' => $this->translator->get('settings.project_mapping.title'),
'activeMenu' => 'settings',
'activeSettings' => 'project-mappings',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'mappings' => $mappings,
'scripts' => $scripts,
'errorMessage' => (string) Flash::get('settings_error', ''),
'successMessage' => (string) Flash::get('settings_success', ''),
], 'layouts/app');
return Response::html($html);
}
public function store(Request $request): Response
{
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/settings/project-mappings');
}
$pattern = trim((string) $request->input('product_name_pattern', ''));
$scriptName = trim((string) $request->input('script_name', ''));
$outputDir = trim((string) $request->input('output_dir', ''));
if ($pattern === '' || $scriptName === '') {
Flash::set('settings_error', $this->translator->get('settings.project_mapping.flash.validation_error'));
return Response::redirect('/settings/project-mappings');
}
$scriptPath = $this->basePath . '/' . self::SCRIPTS_DIR . '/' . $scriptName;
if (!file_exists($scriptPath)) {
Flash::set('settings_error', $this->translator->get('settings.project_mapping.flash.script_not_found'));
return Response::redirect('/settings/project-mappings');
}
$this->repository->create([
'product_name_pattern' => $pattern,
'script_name' => $scriptName,
'output_dir' => $outputDir !== '' ? $outputDir : null,
]);
Flash::set('settings_success', $this->translator->get('settings.project_mapping.flash.created'));
return Response::redirect('/settings/project-mappings');
}
public function update(Request $request): Response
{
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/settings/project-mappings');
}
$id = (int) $request->param('id', '0');
$pattern = trim((string) $request->input('product_name_pattern', ''));
$scriptName = trim((string) $request->input('script_name', ''));
$outputDir = trim((string) $request->input('output_dir', ''));
if ($id <= 0 || $pattern === '' || $scriptName === '') {
Flash::set('settings_error', $this->translator->get('settings.project_mapping.flash.validation_error'));
return Response::redirect('/settings/project-mappings');
}
$this->repository->update($id, [
'product_name_pattern' => $pattern,
'script_name' => $scriptName,
'output_dir' => $outputDir !== '' ? $outputDir : null,
]);
Flash::set('settings_success', $this->translator->get('settings.project_mapping.flash.updated'));
return Response::redirect('/settings/project-mappings');
}
public function delete(Request $request): Response
{
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/settings/project-mappings');
}
$id = (int) $request->param('id', '0');
if ($id > 0) {
$this->repository->delete($id);
Flash::set('settings_success', $this->translator->get('settings.project_mapping.flash.deleted'));
}
return Response::redirect('/settings/project-mappings');
}
public function toggleActive(Request $request): Response
{
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/settings/project-mappings');
}
$id = (int) $request->param('id', '0');
if ($id > 0) {
$this->repository->toggleActive($id);
Flash::set('settings_success', $this->translator->get('settings.project_mapping.flash.toggled'));
}
return Response::redirect('/settings/project-mappings');
}
private function scanScripts(): array
{
$dir = $this->basePath . '/' . self::SCRIPTS_DIR;
if (!is_dir($dir)) {
return [];
}
$files = glob($dir . '/*.py');
$scripts = [];
foreach ($files as $file) {
$scripts[] = basename($file);
}
sort($scripts);
return $scripts;
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use PDO;
final class ProjectMappingRepository
{
public function __construct(
private readonly PDO $pdo
) {
}
public function getAll(): array
{
$statement = $this->pdo->prepare(
'SELECT * FROM project_mappings ORDER BY id DESC'
);
$statement->execute();
return $statement->fetchAll(PDO::FETCH_ASSOC) ?: [];
}
public function getById(int $id): ?array
{
$statement = $this->pdo->prepare(
'SELECT * FROM project_mappings WHERE id = :id LIMIT 1'
);
$statement->execute([':id' => $id]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
public function create(array $data): int
{
$statement = $this->pdo->prepare(
'INSERT INTO project_mappings (product_name_pattern, script_name, output_dir, is_active)
VALUES (:product_name_pattern, :script_name, :output_dir, :is_active)'
);
$statement->execute([
':product_name_pattern' => $data['product_name_pattern'],
':script_name' => $data['script_name'],
':output_dir' => $data['output_dir'] ?? null,
':is_active' => (int) ($data['is_active'] ?? 1),
]);
return (int) $this->pdo->lastInsertId();
}
public function update(int $id, array $data): bool
{
$statement = $this->pdo->prepare(
'UPDATE project_mappings
SET product_name_pattern = :product_name_pattern,
script_name = :script_name,
output_dir = :output_dir
WHERE id = :id'
);
return $statement->execute([
':product_name_pattern' => $data['product_name_pattern'],
':script_name' => $data['script_name'],
':output_dir' => $data['output_dir'] ?? null,
':id' => $id,
]);
}
public function delete(int $id): bool
{
$statement = $this->pdo->prepare(
'DELETE FROM project_mappings WHERE id = :id'
);
return $statement->execute([':id' => $id]);
}
public function toggleActive(int $id): bool
{
$statement = $this->pdo->prepare(
'UPDATE project_mappings SET is_active = 1 - is_active WHERE id = :id'
);
return $statement->execute([':id' => $id]);
}
}

View File

@@ -135,7 +135,7 @@ final class ShopproOrderMapper
'external_order_id' => $sourceOrderId,
'external_platform_id' => IntegrationSources::SHOPPRO,
'external_platform_account_id' => null,
'external_status_id' => $effectiveStatus,
'status_code' => $effectiveStatus,
'external_payment_type_id' => $this->normalizeCodPaymentType((string) $this->readPath($payload, ['payment_method', 'payment.method', 'payments.method'])),
'payment_status' => $this->mapPaymentStatus($payload, $isPaid),
'external_carrier_id' => StringHelper::nullableString($deliveryLabel),

View File

@@ -164,9 +164,9 @@ final class ShopproPaymentStatusSyncService
}
if ($watchedStatuses !== []) {
$where[] = 'LOWER(COALESCE(external_status_id, "")) IN (' . implode(', ', $statusPlaceholders) . ')';
$where[] = 'LOWER(COALESCE(status_code, "")) IN (' . implode(', ', $statusPlaceholders) . ')';
} else {
$where[] = 'LOWER(COALESCE(external_status_id, "")) NOT IN (' . implode(', ', $statusPlaceholders) . ')';
$where[] = 'LOWER(COALESCE(status_code, "")) NOT IN (' . implode(', ', $statusPlaceholders) . ')';
}
$sql = 'SELECT id, source_order_id, payment_status, total_paid, total_with_tax, currency, external_payment_type_id

View File

@@ -137,7 +137,7 @@ final class ShopproStatusSyncService
foreach ($orders as $order) {
$sourceOrderId = (int) ($order['source_order_id'] ?? 0);
$orderproStatus = strtolower(trim((string) ($order['external_status_id'] ?? '')));
$orderproStatus = strtolower(trim((string) ($order['status_code'] ?? '')));
$changeAt = (string) ($order['latest_change'] ?? '');
if ($sourceOrderId <= 0 || $orderproStatus === '') {
@@ -224,7 +224,7 @@ final class ShopproStatusSyncService
}
/**
* @return array<int, array{order_id:int,source_order_id:string,external_status_id:string,latest_change:string}>
* @return array<int, array{order_id:int,source_order_id:string,status_code:string,latest_change:string}>
*/
private function findOrdersWithManualStatusChanges(int $integrationId, ?string $lastPushedAt): array
{
@@ -233,14 +233,14 @@ final class ShopproStatusSyncService
try {
$statement = $this->pdo->prepare(
'SELECT o.id AS order_id, o.source_order_id, o.external_status_id,
'SELECT o.id AS order_id, o.source_order_id, o.status_code,
MAX(h.changed_at) AS latest_change
FROM order_status_history h
JOIN orders o ON o.id = h.order_id
WHERE o.integration_id = :integration_id
AND h.change_source = :change_source
AND h.changed_at > :since_date
GROUP BY o.id, o.source_order_id, o.external_status_id
GROUP BY o.id, o.source_order_id, o.status_code
ORDER BY latest_change ASC
LIMIT 50'
);

View File

@@ -139,6 +139,43 @@ final class UserRepository
/**
* @return array<string, mixed>
*/
public function updateRememberToken(int $userId, ?string $tokenHash): void
{
$statement = $this->pdo->prepare(
'UPDATE users SET remember_token = :token WHERE id = :id'
);
$statement->execute([
'token' => $tokenHash,
'id' => $userId,
]);
}
/**
* @return array<string, mixed>|null
*/
public function findByRememberToken(string $tokenHash): ?array
{
if ($tokenHash === '') {
return null;
}
$statement = $this->pdo->prepare(
'SELECT id, name, email FROM users WHERE remember_token = :token LIMIT 1'
);
$statement->execute(['token' => $tokenHash]);
$row = $statement->fetch();
if (!is_array($row)) {
return null;
}
return [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'email' => (string) ($row['email'] ?? ''),
];
}
public function create(string $name, string $email, string $passwordHash): array
{
$createdAt = date('Y-m-d H:i:s');