Files
orderPRO/src/Modules/Orders/OrdersRepository.php
2026-04-22 22:54:26 +02:00

1222 lines
46 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Orders;
use App\Core\Support\StringHelper;
use PDO;
use Throwable;
final class OrdersRepository
{
private static ?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',
'status_code' => 'o.status_code',
'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.status_code) = 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 :s1 OR o.external_order_id LIKE :s2 OR o.customer_login LIKE :s3 OR a.name LIKE :s4 OR a.email LIKE :s5 OR EXISTS (SELECT 1 FROM order_items oi_s WHERE oi_s.order_id = o.id AND oi_s.original_name LIKE :s6))';
$searchVal = '%' . $search . '%';
$params['s1'] = $searchVal;
$params['s2'] = $searchVal;
$params['s3'] = $searchVal;
$params['s4'] = $searchVal;
$params['s5'] = $searchVal;
$params['s6'] = $searchVal;
}
$source = trim((string) ($filters['source'] ?? ''));
if ($source !== '') {
$where[] = 'o.source = :source';
$params['source'] = $source;
}
$statusGroup = trim((string) ($filters['status_group'] ?? ''));
$status = trim((string) ($filters['status'] ?? ''));
if ($statusGroup !== '' && ctype_digit($statusGroup)) {
$groupCodes = $this->statusCodesByGroupId((int) $statusGroup);
if ($groupCodes !== []) {
$placeholders = [];
foreach ($groupCodes as $i => $code) {
$key = 'sg' . $i;
$placeholders[] = ':' . $key;
$params[$key] = $code;
}
$where[] = $effectiveStatusSql . ' IN (' . implode(', ', $placeholders) . ')';
}
} elseif ($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.status_code,
' . $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,
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,
' . $this->customerReturnedCountSubquerySql('o', 'a') . ' AS customer_returned_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.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,
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 (
SELECT order_id, COUNT(*) AS shipments_count
FROM order_shipments GROUP BY order_id
) sh_agg ON sh_agg.order_id = o.id
LEFT JOIN (
SELECT order_id, COUNT(*) AS documents_count
FROM order_documents GROUP BY order_id
) od_agg ON od_agg.order_id = o.id'
. $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'] ?? ''),
'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'] ?? ''),
'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),
'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),
'customer_returned_count' => max(0, (int) ($row['customer_returned_count'] ?? 0)),
];
}
/**
* @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.status_code) = 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.status_code) = 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.status_code) = 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] = [
'id' => $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 list<string>
*/
private function statusCodesByGroupId(int $groupId): array
{
try {
$stmt = $this->pdo->prepare(
'SELECT code FROM order_statuses WHERE group_id = :gid AND is_active = 1 ORDER BY sort_order ASC'
);
$stmt->execute(['gid' => $groupId]);
$rows = $stmt->fetchAll(PDO::FETCH_COLUMN);
} catch (Throwable) {
return [];
}
if (!is_array($rows)) {
return [];
}
$codes = [];
foreach ($rows as $code) {
$trimmed = strtolower(trim((string) $code));
if ($trimmed !== '') {
$codes[] = $trimmed;
}
}
return $codes;
}
/**
* @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,
ig.name AS integration_name,
a.email AS buyer_email,
a.phone AS buyer_phone,
a.name AS buyer_name,
' . $this->customerReturnedCountSubquerySql('o', 'a') . ' AS customer_returned_count
FROM orders o
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 order_addresses a ON a.order_id = o.id AND a.address_type = "customer"
WHERE o.id = :id
LIMIT 1'
);
$orderStmt->execute(['id' => $orderId]);
$order = $orderStmt->fetch(PDO::FETCH_ASSOC);
if (!is_array($order)) {
return null;
}
$order['customer_returned_count'] = max(0, (int) ($order['customer_returned_count'] ?? 0));
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;
}
/**
* Subquery zliczajaca zamowienia klienta biezacego wiersza, ktore w historii
* mialy paczke z delivery_status='returned' (zwrot do nadawcy).
* Matching po email LUB phone (tylko cyfry, min 6) LUB name — identyczne dopasowanie
* po LOWER/TRIM. Wyklucza biezace zamowienie (self-exclusion).
*
* Wymagania: MySQL 8.0+ (REGEXP_REPLACE).
*
* @param string $orderAlias alias tabeli orders w outer query (np. 'o')
* @param string $addressAlias alias joina order_addresses (customer) w outer query (np. 'a')
*/
private function customerReturnedCountSubquerySql(string $orderAlias, string $addressAlias): string
{
return '(SELECT COUNT(DISTINCT sp.order_id)
FROM shipment_packages sp
INNER JOIN order_addresses a2
ON a2.order_id = sp.order_id AND a2.address_type = "customer"
WHERE sp.delivery_status = "returned"
AND sp.order_id != ' . $orderAlias . '.id
AND (
(' . $addressAlias . '.email IS NOT NULL AND ' . $addressAlias . '.email <> ""
AND LOWER(TRIM(a2.email)) = LOWER(TRIM(' . $addressAlias . '.email)))
OR
(' . $addressAlias . '.phone IS NOT NULL
AND LENGTH(REGEXP_REPLACE(' . $addressAlias . '.phone, "[^0-9]+", "")) >= 6
AND REGEXP_REPLACE(a2.phone, "[^0-9]+", "") = REGEXP_REPLACE(' . $addressAlias . '.phone, "[^0-9]+", ""))
OR
(' . $addressAlias . '.name IS NOT NULL AND ' . $addressAlias . '.name <> ""
AND LOWER(TRIM(a2.name)) = LOWER(TRIM(' . $addressAlias . '.name)))
))';
}
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 . '.status_code
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 (self::$supportsMappedMedia !== null) {
return self::$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();
self::$supportsMappedMedia = ($count === count($requiredColumns));
} catch (Throwable) {
self::$supportsMappedMedia = false;
}
return self::$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
*/
/**
* Aktualizuje formę dostawy i/lub formę płatności zamówienia. Zapisuje wpis do activity log.
*
* @return bool true gdy faktycznie nastąpiła zmiana
*/
public function updateDeliveryAndPayment(
int $orderId,
?string $deliveryMethod,
?string $paymentMethod,
?string $externalPaymentTypeId,
string $actorType = 'user',
?string $actorName = null
): bool {
if ($orderId <= 0) {
return false;
}
$stmt = $this->pdo->prepare(
'SELECT id, delivery_method, payment_method, external_payment_type_id FROM orders WHERE id = :id LIMIT 1'
);
$stmt->execute(['id' => $orderId]);
$before = $stmt->fetch(PDO::FETCH_ASSOC);
if (!is_array($before)) {
return false;
}
$updates = [];
$params = ['id' => $orderId];
$changed = [];
if ($deliveryMethod !== null) {
$oldValue = (string) ($before['delivery_method'] ?? '');
if ($oldValue !== $deliveryMethod) {
$updates[] = 'delivery_method = :delivery_method';
$params['delivery_method'] = $deliveryMethod;
$changed['delivery_method'] = ['before' => $oldValue, 'after' => $deliveryMethod];
}
}
if ($paymentMethod !== null) {
$oldValue = (string) ($before['payment_method'] ?? '');
if ($oldValue !== $paymentMethod) {
$updates[] = 'payment_method = :payment_method';
$params['payment_method'] = $paymentMethod;
$changed['payment_method'] = ['before' => $oldValue, 'after' => $paymentMethod];
}
}
if ($externalPaymentTypeId !== null) {
$oldValue = (string) ($before['external_payment_type_id'] ?? '');
if ($oldValue !== $externalPaymentTypeId) {
$updates[] = 'external_payment_type_id = :external_payment_type_id';
$params['external_payment_type_id'] = $externalPaymentTypeId;
$changed['external_payment_type_id'] = ['before' => $oldValue, 'after' => $externalPaymentTypeId];
}
}
if ($updates === []) {
return false;
}
$updates[] = 'updated_at = NOW()';
$sql = 'UPDATE orders SET ' . implode(', ', $updates) . ' WHERE id = :id';
$update = $this->pdo->prepare($sql);
$update->execute($params);
$summaryParts = [];
if (isset($changed['delivery_method'])) {
$summaryParts[] = 'forma dostawy';
}
if (isset($changed['payment_method']) || isset($changed['external_payment_type_id'])) {
$summaryParts[] = 'forma płatności';
}
$summary = 'Zmiana danych zamówienia: ' . implode(', ', $summaryParts);
$this->recordActivity(
$orderId,
'details_change',
$summary,
$changed,
$actorType,
$actorName
);
return true;
}
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,
]);
}
/**
* @param array<string, mixed> $details
*/
public function shouldSkipDuplicateImportActivity(int $orderId, array $details): bool
{
if ($orderId <= 0 || !empty($details['created'])) {
return false;
}
$sourceOrderId = trim((string) ($details['source_order_id'] ?? ''));
$sourceUpdatedAt = trim((string) ($details['source_updated_at'] ?? ''));
$trigger = trim((string) ($details['trigger'] ?? ''));
if ($sourceOrderId === '' || $sourceUpdatedAt === '' || $trigger === '') {
return false;
}
try {
$stmt = $this->pdo->prepare(
'SELECT details_json
FROM order_activity_log
WHERE order_id = :order_id
AND event_type = :event_type
ORDER BY created_at DESC, id DESC
LIMIT 1'
);
$stmt->execute([
'order_id' => $orderId,
'event_type' => 'import',
]);
$lastDetailsJson = $stmt->fetchColumn();
} catch (Throwable) {
return false;
}
if (!is_string($lastDetailsJson) || trim($lastDetailsJson) === '') {
return false;
}
$lastDetails = json_decode($lastDetailsJson, true);
if (!is_array($lastDetails) || !empty($lastDetails['created'])) {
return false;
}
$lastSourceOrderId = trim((string) ($lastDetails['source_order_id'] ?? ''));
$lastSourceUpdatedAt = trim((string) ($lastDetails['source_updated_at'] ?? ''));
$lastTrigger = trim((string) ($lastDetails['trigger'] ?? ''));
return $lastSourceOrderId === $sourceOrderId
&& $lastSourceUpdatedAt === $sourceUpdatedAt
&& $lastTrigger === $trigger;
}
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);
}
/**
* @param array<string, mixed> $data Keys: payment_type_id, amount, payment_date, comment, currency
* @return array{id:int, payment_status:int, total_paid:float}|null
*/
/**
* @return array{source:string, integration_id:int, source_order_id:string}|null
*/
public function findOrderSourceInfo(int $orderId): ?array
{
if ($orderId <= 0) {
return null;
}
$stmt = $this->pdo->prepare('SELECT source, integration_id, source_order_id FROM orders WHERE id = :id LIMIT 1');
$stmt->execute(['id' => $orderId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $row : null;
}
/**
* @param array<string, mixed> $data Keys: payment_type_id, amount, payment_date, comment, currency
* @return array{id:int, payment_status:int, total_paid:float}|null
*/
public function addPayment(int $orderId, array $data): ?array
{
if ($orderId <= 0) {
return null;
}
$stmt = $this->pdo->prepare('SELECT id, total_with_tax, currency FROM orders WHERE id = :id LIMIT 1');
$stmt->execute(['id' => $orderId]);
$order = $stmt->fetch(PDO::FETCH_ASSOC);
if (!is_array($order)) {
return null;
}
$amount = round((float) ($data['amount'] ?? 0), 2);
$paymentTypeId = trim((string) ($data['payment_type_id'] ?? ''));
$paymentDate = trim((string) ($data['payment_date'] ?? ''));
$comment = trim((string) ($data['comment'] ?? ''));
$currency = trim((string) ($data['currency'] ?? $order['currency'] ?? 'PLN'));
if ($amount <= 0 || $paymentTypeId === '') {
return null;
}
$sourcePaymentId = 'manual_' . $orderId . '_' . time();
$insert = $this->pdo->prepare(
'INSERT INTO order_payments (order_id, source_payment_id, payment_type_id, payment_date, amount, currency, comment, created_at, updated_at)
VALUES (:order_id, :source_payment_id, :payment_type_id, :payment_date, :amount, :currency, :comment, NOW(), NOW())'
);
$insert->execute([
'order_id' => $orderId,
'source_payment_id' => $sourcePaymentId,
'payment_type_id' => $paymentTypeId,
'payment_date' => $paymentDate !== '' ? $paymentDate : date('Y-m-d H:i:s'),
'amount' => $amount,
'currency' => $currency,
'comment' => $comment !== '' ? $comment : null,
]);
$paymentId = (int) $this->pdo->lastInsertId();
$sumStmt = $this->pdo->prepare('SELECT COALESCE(SUM(amount), 0) FROM order_payments WHERE order_id = :order_id');
$sumStmt->execute(['order_id' => $orderId]);
$totalPaid = round((float) $sumStmt->fetchColumn(), 2);
$totalWithTax = $order['total_with_tax'] !== null ? (float) $order['total_with_tax'] : null;
$paymentStatus = 0;
if ($totalPaid > 0 && $totalWithTax !== null && $totalPaid >= $totalWithTax) {
$paymentStatus = 2;
} elseif ($totalPaid > 0) {
$paymentStatus = 1;
}
$update = $this->pdo->prepare('UPDATE orders SET payment_status = :payment_status, total_paid = :total_paid, updated_at = NOW() WHERE id = :id');
$update->execute([
'payment_status' => $paymentStatus,
'total_paid' => $totalPaid,
'id' => $orderId,
]);
return [
'id' => $paymentId,
'payment_status' => $paymentStatus,
'total_paid' => $totalPaid,
];
}
public function updateOrderStatus(int $orderId, string $newStatusCode, string $actorType = 'user', ?string $actorName = null): bool
{
try {
$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['status_code'] ?? ''));
$update = $this->pdo->prepare('UPDATE orders SET status_code = :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;
}
/**
* @return array<int, array{id:int, order_number:string, buyer_name:string, buyer_email:string, buyer_phone:string}>
*/
public function quickSearch(string $query, int $limit = 10): array
{
$query = trim($query);
if ($query === '' || mb_strlen($query) < 2) {
return [];
}
$limit = max(1, min($limit, 20));
$searchVal = '%' . $query . '%';
$sql = 'SELECT o.id, o.source_order_id, o.external_order_id, '
. 'a.name AS buyer_name, a.email AS buyer_email, a.phone AS buyer_phone '
. 'FROM orders o '
. 'LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer" '
. 'WHERE (o.source_order_id LIKE :s1 OR o.external_order_id LIKE :s2 '
. 'OR a.name LIKE :s3 OR a.email LIKE :s4 OR a.phone LIKE :s5 '
. 'OR EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.original_name LIKE :s6)) '
. 'ORDER BY o.ordered_at DESC LIMIT :lim';
try {
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':s1', $searchVal);
$stmt->bindValue(':s2', $searchVal);
$stmt->bindValue(':s3', $searchVal);
$stmt->bindValue(':s4', $searchVal);
$stmt->bindValue(':s5', $searchVal);
$stmt->bindValue(':s6', $searchVal);
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map(static function (array $row): array {
$orderNumber = ((string) ($row['source_order_id'] ?? '')) !== ''
? (string) $row['source_order_id']
: (string) ($row['external_order_id'] ?? '');
return [
'id' => (int) ($row['id'] ?? 0),
'order_number' => $orderNumber,
'buyer_name' => (string) ($row['buyer_name'] ?? ''),
'buyer_email' => (string) ($row['buyer_email'] ?? ''),
'buyer_phone' => (string) ($row['buyer_phone'] ?? ''),
];
}, $rows);
} catch (Throwable) {
return [];
}
}
}