Files
orderPRO/src/Modules/Orders/OrdersRepository.php
Jacek Pyziak 42e647f007 feat(06-sonarqube-quality): extract long methods to fix S138 violations (06-06)
ShopproOrdersSyncService: sync() 195→44 lines via syncOneIntegration,
fetchOrdersPage, processPageCandidates, importOneOrder; mapAddresses()
166→34 lines via buildCustomerAddress, buildDeliveryAddress.

OrdersRepository: paginate() 183→69 lines via buildPaginateFilters,
buildListSql, transformOrderRow; findDetails() 101→40 lines via
loadOrderAddresses/Items/Payments/Shipments/Documents/Notes/StatusHistory.

SonarQube S138 violations: 4 → 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 12:33:12 +01:00

827 lines
30 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Orders;
use App\Core\Support\StringHelper;
use PDO;
use Throwable;
final class OrdersRepository
{
private ?bool $supportsMappedMedia = null;
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;
$effectiveStatusSql = $this->effectiveStatusSql('o', 'asm');
$effectiveOrderedAtSql = $this->effectiveOrderedAtSql('o');
['where' => $where, 'params' => $params] = $this->buildPaginateFilters($filters, $effectiveStatusSql, $effectiveOrderedAtSql);
$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 => $effectiveOrderedAtSql,
};
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'
. $whereSql;
$countStmt = $this->pdo->prepare($countSql);
$countStmt->execute($params);
$total = (int) $countStmt->fetchColumn();
$listSql = $this->buildListSql($effectiveStatusSql, $effectiveOrderedAtSql, $whereSql, $sortColumn, $sortDir);
$stmt = $this->pdo->prepare($listSql);
foreach ($params as $key => $value) {
$stmt->bindValue(':' . $key, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR);
}
$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(fn (array $row) => $this->transformOrderRow($row, $itemPreviewsByOrderId), $rows),
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'error' => '',
];
} catch (Throwable $exception) {
return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $perPage, 'error' => $exception->getMessage()];
}
}
/**
* @param array<string, mixed> $filters
* @return array{where:array<int,string>,params:array<string,mixed>}
*/
private function buildPaginateFilters(array $filters, string $effectiveStatusSql, string $effectiveOrderedAtSql): array
{
$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[] = $effectiveStatusSql . ' = :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[] = $effectiveOrderedAtSql . ' >= :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[] = $effectiveOrderedAtSql . ' <= :date_to';
$params['date_to'] = $dateTo . ' 23:59:59';
}
return ['where' => $where, 'params' => $params];
}
private function buildListSql(string $effectiveStatusSql, string $effectiveOrderedAtSql, string $whereSql, string $sortColumn, string $sortDir): string
{
return 'SELECT
o.id,
o.internal_order_number,
o.source,
o.source_order_id,
o.external_order_id,
o.external_status_id,
' . $effectiveStatusSql . ' AS effective_status_id,
o.payment_status,
o.currency,
o.total_with_tax,
o.total_paid,
o.ordered_at,
o.source_created_at,
o.source_updated_at,
o.fetched_at,
' . $effectiveOrderedAtSql . ' AS effective_ordered_at,
o.is_invoice,
o.is_canceled_by_buyer,
a.name AS buyer_name,
a.email AS buyer_email,
a.city AS buyer_city,
o.external_carrier_id,
o.external_payment_type_id,
(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"
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code'
. $whereSql
. ' ORDER BY ' . $sortColumn . ' ' . $sortDir
. ' LIMIT :limit OFFSET :offset';
}
/**
* @param array<string, mixed> $row
* @param array<int, array<int, array{name:string,quantity:float,media_url:string}>> $itemPreviewsByOrderId
* @return array<string, mixed>
*/
private function transformOrderRow(array $row, array $itemPreviewsByOrderId): array
{
$orderId = (int) ($row['id'] ?? 0);
return [
'id' => $orderId,
'internal_order_number' => (string) ($row['internal_order_number'] ?? ''),
'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'] ?? ''),
'effective_status_id' => (string) ($row['effective_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['effective_ordered_at'] ?? ''),
'source_created_at' => (string) ($row['source_created_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,
'external_carrier_id' => (string) ($row['external_carrier_id'] ?? ''),
'external_payment_type_id' => (string) ($row['external_payment_type_id'] ?? ''),
'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] ?? []),
];
}
/**
* @return array<string, string>
*/
public function statusOptions(): array
{
try {
$effectiveStatusSql = $this->effectiveStatusSql('o', 'asm');
$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
WHERE ' . $effectiveStatusSql . ' IS NOT NULL
AND ' . $effectiveStatusSql . ' <> ""
ORDER BY effective_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 {
$effectiveStatusSql = $this->effectiveStatusSql('o', 'asm');
$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 ' . $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);
} 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 {
$effectiveStatusSql = $this->effectiveStatusSql('o', 'asm');
$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
GROUP BY effective_status_id'
)->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable) {
return [];
}
if (!is_array($rows)) {
return [];
}
$result = [];
foreach ($rows as $row) {
$key = trim((string) ($row['effective_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' => StringHelper::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 {
$effectiveStatusSql = $this->effectiveStatusSql('o', 'asm');
$orderStmt = $this->pdo->prepare(
'SELECT o.*, ' . $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
WHERE o.id = :id
LIMIT 1'
);
$orderStmt->execute(['id' => $orderId]);
$order = $orderStmt->fetch(PDO::FETCH_ASSOC);
if (!is_array($order)) {
return null;
}
return [
'order' => $order,
'addresses' => $this->loadOrderAddresses($orderId),
'items' => $this->loadOrderItems($orderId),
'payments' => $this->loadOrderPayments($orderId),
'shipments' => $this->loadOrderShipments($orderId),
'documents' => $this->loadOrderDocuments($orderId),
'notes' => $this->loadOrderNotes($orderId),
'status_history' => $this->loadOrderStatusHistory($orderId),
'activity_log' => $this->loadActivityLog($orderId),
];
} catch (Throwable) {
return null;
}
}
/**
* @return array<int, array<string, mixed>>
*/
private function loadOrderAddresses(int $orderId): array
{
$stmt = $this->pdo->prepare('SELECT * FROM order_addresses WHERE order_id = :order_id ORDER BY FIELD(address_type, "customer", "invoice", "delivery"), id ASC');
$stmt->execute(['order_id' => $orderId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @return array<int, array<string, mixed>>
*/
private function loadOrderItems(int $orderId): array
{
$itemsMediaSql = $this->resolvedMediaUrlSql('oi', 'o.source');
$stmt = $this->pdo->prepare('SELECT oi.*, ' . $itemsMediaSql . ' AS resolved_media_url
FROM order_items oi
INNER JOIN orders o ON o.id = oi.order_id
WHERE oi.order_id = :order_id
ORDER BY oi.sort_order ASC, oi.id ASC');
$stmt->execute(['order_id' => $orderId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($rows)) {
return [];
}
return array_map(static function (array $row): array {
$resolvedMediaUrl = trim((string) ($row['resolved_media_url'] ?? ''));
if ($resolvedMediaUrl !== '') {
$row['media_url'] = $resolvedMediaUrl;
}
unset($row['resolved_media_url']);
return $row;
}, $rows);
}
/**
* @return array<int, array<string, mixed>>
*/
private function loadOrderPayments(int $orderId): array
{
$stmt = $this->pdo->prepare('SELECT * FROM order_payments WHERE order_id = :order_id ORDER BY payment_date ASC, id ASC');
$stmt->execute(['order_id' => $orderId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @return array<int, array<string, mixed>>
*/
private function loadOrderShipments(int $orderId): array
{
$stmt = $this->pdo->prepare('SELECT * FROM order_shipments WHERE order_id = :order_id ORDER BY posted_at ASC, id ASC');
$stmt->execute(['order_id' => $orderId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @return array<int, array<string, mixed>>
*/
private function loadOrderDocuments(int $orderId): array
{
$stmt = $this->pdo->prepare('SELECT * FROM order_documents WHERE order_id = :order_id ORDER BY source_created_at ASC, id ASC');
$stmt->execute(['order_id' => $orderId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @return array<int, array<string, mixed>>
*/
private function loadOrderNotes(int $orderId): array
{
$stmt = $this->pdo->prepare('SELECT * FROM order_notes WHERE order_id = :order_id ORDER BY created_at_external DESC, id DESC');
$stmt->execute(['order_id' => $orderId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @return array<int, array<string, mixed>>
*/
private function loadOrderStatusHistory(int $orderId): array
{
$stmt = $this->pdo->prepare('SELECT * FROM order_status_history WHERE order_id = :order_id ORDER BY changed_at DESC, id DESC');
$stmt->execute(['order_id' => $orderId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @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 {
$resolvedMediaSql = $this->resolvedMediaUrlSql('oi', 'o.source');
$sql = 'SELECT oi.order_id, oi.original_name, oi.quantity, ' . $resolvedMediaSql . ' AS media_url, oi.sort_order, oi.id
FROM order_items oi
INNER JOIN orders o ON o.id = oi.order_id
WHERE oi.order_id IN (' . $placeholders . ')
ORDER BY oi.order_id ASC, oi.sort_order ASC, oi.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 effectiveStatusSql(string $orderAlias, string $mappingAlias): string
{
return 'CASE
WHEN ' . $orderAlias . '.source = "allegro"
AND ' . $mappingAlias . '.orderpro_status_code IS NOT NULL
AND ' . $mappingAlias . '.orderpro_status_code <> ""
THEN ' . $mappingAlias . '.orderpro_status_code
ELSE ' . $orderAlias . '.external_status_id
END';
}
private function effectiveOrderedAtSql(string $orderAlias): string
{
return 'COALESCE('
. $orderAlias . '.ordered_at, '
. $orderAlias . '.source_created_at, '
. $orderAlias . '.source_updated_at, '
. $orderAlias . '.fetched_at'
. ')';
}
private function resolvedMediaUrlSql(string $itemAlias, string $sourceAlias = '"allegro"'): string
{
if (!$this->canResolveMappedMedia()) {
return 'COALESCE(NULLIF(TRIM(' . $itemAlias . '.media_url), ""), "")';
}
return 'COALESCE(
NULLIF(TRIM(' . $itemAlias . '.media_url), ""),
(
SELECT NULLIF(TRIM(pi.storage_path), "")
FROM product_channel_map pcm
INNER JOIN sales_channels sc ON sc.id = pcm.channel_id
INNER JOIN product_images pi ON pi.product_id = pcm.product_id
WHERE LOWER(sc.code) = LOWER(' . $sourceAlias . ')
AND (
pcm.external_product_id = ' . $itemAlias . '.external_item_id
OR pcm.external_product_id = ' . $itemAlias . '.source_product_id
)
ORDER BY pi.is_main DESC, pi.sort_order ASC, pi.id ASC
LIMIT 1
),
""
)';
}
private function canResolveMappedMedia(): bool
{
if ($this->supportsMappedMedia !== null) {
return $this->supportsMappedMedia;
}
try {
$requiredColumns = [
['table' => 'product_channel_map', 'column' => 'product_id'],
['table' => 'product_channel_map', 'column' => 'channel_id'],
['table' => 'product_channel_map', 'column' => 'external_product_id'],
['table' => 'sales_channels', 'column' => 'id'],
['table' => 'sales_channels', 'column' => 'code'],
['table' => 'product_images', 'column' => 'id'],
['table' => 'product_images', 'column' => 'product_id'],
['table' => 'product_images', 'column' => 'storage_path'],
['table' => 'product_images', 'column' => 'sort_order'],
['table' => 'product_images', 'column' => 'is_main'],
];
$pairsSql = [];
$params = [];
foreach ($requiredColumns as $index => $required) {
$tableParam = ':table_' . $index;
$columnParam = ':column_' . $index;
$pairsSql[] = '(TABLE_NAME = ' . $tableParam . ' AND COLUMN_NAME = ' . $columnParam . ')';
$params['table_' . $index] = $required['table'];
$params['column_' . $index] = $required['column'];
}
$sql = 'SELECT COUNT(*) AS cnt
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND (' . implode(' OR ', $pairsSql) . ')';
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$count = (int) $stmt->fetchColumn();
$this->supportsMappedMedia = ($count === count($requiredColumns));
} catch (Throwable) {
$this->supportsMappedMedia = false;
}
return $this->supportsMappedMedia;
}
/**
* @return array<int, array<string, mixed>>
*/
private function loadActivityLog(int $orderId): array
{
try {
$stmt = $this->pdo->prepare(
'SELECT * FROM order_activity_log
WHERE order_id = :order_id
ORDER BY created_at DESC, id DESC'
);
$stmt->execute(['order_id' => $orderId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
} catch (Throwable) {
return [];
}
}
/**
* @param array<string, mixed>|null $details
*/
public function recordActivity(
int $orderId,
string $eventType,
string $summary,
?array $details = null,
string $actorType = 'system',
?string $actorName = null
): void {
$stmt = $this->pdo->prepare(
'INSERT INTO order_activity_log
(order_id, event_type, summary, details_json, actor_type, actor_name, created_at)
VALUES
(:order_id, :event_type, :summary, :details_json, :actor_type, :actor_name, NOW())'
);
$stmt->execute([
'order_id' => $orderId,
'event_type' => $eventType,
'summary' => $summary,
'details_json' => $details !== null ? json_encode($details, JSON_UNESCAPED_UNICODE) : null,
'actor_type' => $actorType,
'actor_name' => $actorName,
]);
}
public function recordStatusChange(
int $orderId,
?string $fromStatus,
string $toStatus,
string $changeSource = 'manual',
?string $comment = null,
string $actorType = 'system',
?string $actorName = null
): void {
$stmt = $this->pdo->prepare(
'INSERT INTO order_status_history
(order_id, from_status_id, to_status_id, changed_at, change_source, comment)
VALUES
(:order_id, :from_status_id, :to_status_id, NOW(), :change_source, :comment)'
);
$stmt->execute([
'order_id' => $orderId,
'from_status_id' => $fromStatus,
'to_status_id' => $toStatus,
'change_source' => $changeSource,
'comment' => $comment,
]);
$fromLabel = $fromStatus !== null ? $this->resolveStatusName($fromStatus) : '-';
$toLabel = $this->resolveStatusName($toStatus);
$summary = 'Zmiana statusu: ' . $fromLabel . ' → ' . $toLabel;
$this->recordActivity($orderId, 'status_change', $summary, [
'from_status' => $fromStatus,
'to_status' => $toStatus,
'change_source' => $changeSource,
'comment' => $comment,
], $actorType, $actorName);
}
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->execute(['id' => $orderId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!is_array($row)) {
return false;
}
$oldStatus = trim((string) ($row['external_status_id'] ?? ''));
$update = $this->pdo->prepare('UPDATE orders SET external_status_id = :status, updated_at = NOW() WHERE id = :id');
$update->execute(['status' => $newStatusCode, 'id' => $orderId]);
$this->recordStatusChange(
$orderId,
$oldStatus !== '' ? $oldStatus : null,
$newStatusCode,
'manual',
null,
$actorType,
$actorName
);
return true;
} catch (Throwable) {
return false;
}
}
private function resolveStatusName(string $code): string
{
$normalized = strtolower(trim($code));
if ($normalized === '') {
return $code;
}
try {
$stmt = $this->pdo->prepare('SELECT name FROM order_statuses WHERE LOWER(code) = :code LIMIT 1');
$stmt->execute(['code' => $normalized]);
$name = $stmt->fetchColumn();
if (is_string($name) && trim($name) !== '') {
return trim($name);
}
} catch (Throwable) {
}
return $code;
}
}