1222 lines
46 KiB
PHP
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 [];
|
|
}
|
|
}
|
|
|
|
}
|