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>
827 lines
30 KiB
PHP
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;
|
|
}
|
|
|
|
}
|