Replace file-based JSON cron queue with DB-backed job queue (pp_cron_jobs, pp_cron_schedules). New Domain\CronJob module: CronJobType (constants), CronJobRepository (CRUD, atomic fetch, retry/backoff), CronJobProcessor (orchestration with handler registration). Priority ordering guarantees apilo_send_order (40) runs before sync tasks (50). Includes cron.php auth protection, race condition fix in fetchNext, API response validation, and DI wiring across all entry points. 41 new tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
859 lines
32 KiB
PHP
859 lines
32 KiB
PHP
<?php
|
|
namespace Domain\Order;
|
|
|
|
class OrderAdminService
|
|
{
|
|
private OrderRepository $orders;
|
|
private $productRepo;
|
|
private $settingsRepo;
|
|
private $transportRepo;
|
|
/** @var \Domain\CronJob\CronJobRepository|null */
|
|
private $cronJobRepo;
|
|
|
|
public function __construct(
|
|
OrderRepository $orders,
|
|
$productRepo = null,
|
|
$settingsRepo = null,
|
|
$transportRepo = null,
|
|
$cronJobRepo = null
|
|
) {
|
|
$this->orders = $orders;
|
|
$this->productRepo = $productRepo;
|
|
$this->settingsRepo = $settingsRepo;
|
|
$this->transportRepo = $transportRepo;
|
|
$this->cronJobRepo = $cronJobRepo;
|
|
}
|
|
|
|
public function details(int $orderId): array
|
|
{
|
|
return $this->orders->findForAdmin($orderId);
|
|
}
|
|
|
|
public function statuses(): array
|
|
{
|
|
return $this->orders->orderStatuses();
|
|
}
|
|
|
|
/**
|
|
* @return array{names: array<int, string>, colors: array<int, string>}
|
|
*/
|
|
public function statusData(): array
|
|
{
|
|
return $this->orders->orderStatusData();
|
|
}
|
|
|
|
/**
|
|
* @return array{items: array<int, array<string, mixed>>, total: int}
|
|
*/
|
|
public function listForAdmin(
|
|
array $filters,
|
|
string $sortColumn = 'date_order',
|
|
string $sortDir = 'DESC',
|
|
int $page = 1,
|
|
int $perPage = 15
|
|
): array {
|
|
return $this->orders->listForAdmin($filters, $sortColumn, $sortDir, $page, $perPage);
|
|
}
|
|
|
|
public function nextOrderId(int $orderId): ?int
|
|
{
|
|
return $this->orders->nextOrderId($orderId);
|
|
}
|
|
|
|
public function prevOrderId(int $orderId): ?int
|
|
{
|
|
return $this->orders->prevOrderId($orderId);
|
|
}
|
|
|
|
public function saveNotes(int $orderId, string $notes): bool
|
|
{
|
|
return $this->orders->saveNotes($orderId, $notes);
|
|
}
|
|
|
|
public function saveOrderByAdmin(array $input): bool
|
|
{
|
|
$saved = $this->orders->saveOrderByAdmin(
|
|
(int)($input['order_id'] ?? 0),
|
|
(string)($input['client_name'] ?? ''),
|
|
(string)($input['client_surname'] ?? ''),
|
|
(string)($input['client_street'] ?? ''),
|
|
(string)($input['client_postal_code'] ?? ''),
|
|
(string)($input['client_city'] ?? ''),
|
|
(string)($input['client_email'] ?? ''),
|
|
(string)($input['firm_name'] ?? ''),
|
|
(string)($input['firm_street'] ?? ''),
|
|
(string)($input['firm_postal_code'] ?? ''),
|
|
(string)($input['firm_city'] ?? ''),
|
|
(string)($input['firm_nip'] ?? ''),
|
|
(int)($input['transport_id'] ?? 0),
|
|
(string)($input['inpost_paczkomat'] ?? ''),
|
|
(int)($input['payment_method_id'] ?? 0)
|
|
);
|
|
|
|
return $saved;
|
|
}
|
|
|
|
// =========================================================================
|
|
// Order products management (admin)
|
|
// =========================================================================
|
|
|
|
public function searchProducts(string $query, string $langId): array
|
|
{
|
|
if (!$this->productRepo || trim($query) === '') {
|
|
return [];
|
|
}
|
|
|
|
$rows = $this->productRepo->searchProductByNameAjax($query, $langId);
|
|
$results = [];
|
|
|
|
foreach ($rows as $row) {
|
|
$productId = (int)($row['product_id'] ?? 0);
|
|
if ($productId <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$product = $this->productRepo->findCached($productId, $langId);
|
|
if (!is_array($product)) {
|
|
continue;
|
|
}
|
|
|
|
$name = isset($product['language']['name']) ? (string)$product['language']['name'] : '';
|
|
$img = $this->productRepo->getProductImg($productId);
|
|
|
|
$results[] = [
|
|
'product_id' => $productId,
|
|
'parent_product_id' => (int)($product['parent_id'] ?? 0),
|
|
'name' => $name,
|
|
'sku' => (string)($product['sku'] ?? ''),
|
|
'ean' => (string)($product['ean'] ?? ''),
|
|
'price_brutto' => (float)($product['price_brutto'] ?? 0),
|
|
'price_brutto_promo' => (float)($product['price_brutto_promo'] ?? 0),
|
|
'vat' => (float)($product['vat'] ?? 0),
|
|
'quantity' => (int)($product['quantity'] ?? 0),
|
|
'image' => $img,
|
|
];
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
|
|
public function saveOrderProducts(int $orderId, array $productsData): bool
|
|
{
|
|
if ($orderId <= 0) {
|
|
return false;
|
|
}
|
|
|
|
$currentProducts = $this->orders->orderProducts($orderId);
|
|
$currentById = [];
|
|
foreach ($currentProducts as $cp) {
|
|
$currentById[(int)$cp['id']] = $cp;
|
|
}
|
|
|
|
$submittedIds = [];
|
|
|
|
foreach ($productsData as $item) {
|
|
$orderProductId = (int)($item['order_product_id'] ?? 0);
|
|
$deleted = !empty($item['delete']);
|
|
|
|
if ($deleted && $orderProductId > 0) {
|
|
// Usunięcie — zwrot na stan
|
|
$existing = isset($currentById[$orderProductId]) ? $currentById[$orderProductId] : null;
|
|
if ($existing) {
|
|
$this->adjustStock((int)$existing['product_id'], (int)$existing['quantity']);
|
|
}
|
|
$this->orders->deleteOrderProduct($orderProductId);
|
|
$submittedIds[] = $orderProductId;
|
|
continue;
|
|
}
|
|
|
|
if ($deleted) {
|
|
continue;
|
|
}
|
|
|
|
if ($orderProductId > 0 && isset($currentById[$orderProductId])) {
|
|
// Istniejący produkt — aktualizacja
|
|
$existing = $currentById[$orderProductId];
|
|
$newQty = max(1, (int)($item['quantity'] ?? 1));
|
|
$oldQty = (int)$existing['quantity'];
|
|
$qtyDiff = $oldQty - $newQty;
|
|
|
|
$update = [
|
|
'quantity' => $newQty,
|
|
'price_brutto' => (float)($item['price_brutto'] ?? $existing['price_brutto']),
|
|
'price_brutto_promo' => (float)($item['price_brutto_promo'] ?? $existing['price_brutto_promo']),
|
|
];
|
|
|
|
$this->orders->updateOrderProduct($orderProductId, $update);
|
|
|
|
// Korekta stanu: qtyDiff > 0 = zmniejszono ilość = zwrot na stan
|
|
if ($qtyDiff !== 0) {
|
|
$this->adjustStock((int)$existing['product_id'], $qtyDiff);
|
|
}
|
|
|
|
$submittedIds[] = $orderProductId;
|
|
} elseif ($orderProductId === 0) {
|
|
// Nowy produkt
|
|
$productId = (int)($item['product_id'] ?? 0);
|
|
$qty = max(1, (int)($item['quantity'] ?? 1));
|
|
|
|
$this->orders->addOrderProduct($orderId, [
|
|
'product_id' => $productId,
|
|
'parent_product_id' => (int)($item['parent_product_id'] ?? $productId),
|
|
'name' => (string)($item['name'] ?? ''),
|
|
'attributes' => '',
|
|
'vat' => (float)($item['vat'] ?? 0),
|
|
'price_brutto' => (float)($item['price_brutto'] ?? 0),
|
|
'price_brutto_promo' => (float)($item['price_brutto_promo'] ?? 0),
|
|
'quantity' => $qty,
|
|
'message' => '',
|
|
'custom_fields' => '',
|
|
]);
|
|
|
|
// Zmniejsz stan magazynowy
|
|
$this->adjustStock($productId, -$qty);
|
|
}
|
|
}
|
|
|
|
// Usunięte z formularza (nie przesłane) — zwrot na stan
|
|
foreach ($currentById as $cpId => $cp) {
|
|
if (!in_array($cpId, $submittedIds)) {
|
|
$this->adjustStock((int)$cp['product_id'], (int)$cp['quantity']);
|
|
$this->orders->deleteOrderProduct($cpId);
|
|
}
|
|
}
|
|
|
|
// Przelicz koszt dostawy (próg darmowej dostawy)
|
|
$this->recalculateTransportCost($orderId);
|
|
|
|
return true;
|
|
}
|
|
|
|
public function getFreeDeliveryThreshold(): float
|
|
{
|
|
if (!$this->settingsRepo) {
|
|
return 0.0;
|
|
}
|
|
|
|
return (float)$this->settingsRepo->getSingleValue('free_delivery');
|
|
}
|
|
|
|
private function adjustStock(int $productId, int $delta): void
|
|
{
|
|
if (!$this->productRepo || $productId <= 0 || $delta === 0) {
|
|
return;
|
|
}
|
|
|
|
$currentQty = $this->productRepo->getQuantity($productId);
|
|
if ($currentQty === null) {
|
|
return;
|
|
}
|
|
|
|
$newQty = max(0, $currentQty + $delta);
|
|
$this->productRepo->updateQuantity($productId, $newQty);
|
|
}
|
|
|
|
private function recalculateTransportCost(int $orderId): void
|
|
{
|
|
$order = $this->orders->findRawById($orderId);
|
|
if (!$order) {
|
|
return;
|
|
}
|
|
|
|
$transportId = (int)($order['transport_id'] ?? 0);
|
|
if ($transportId <= 0 || !$this->transportRepo || !$this->settingsRepo) {
|
|
return;
|
|
}
|
|
|
|
$transport = $this->transportRepo->findActiveById($transportId);
|
|
if (!is_array($transport)) {
|
|
return;
|
|
}
|
|
|
|
// Oblicz sumę produktów (bez dostawy)
|
|
$productsSummary = $this->calculateProductsTotal($orderId);
|
|
$freeDelivery = (float)$this->settingsRepo->getSingleValue('free_delivery');
|
|
|
|
if ((int)($transport['delivery_free'] ?? 0) === 1 && $freeDelivery > 0 && $productsSummary >= $freeDelivery) {
|
|
$transportCost = 0.0;
|
|
} else {
|
|
$transportCost = (float)($transport['cost'] ?? 0);
|
|
}
|
|
|
|
$this->orders->updateTransportCost($orderId, $transportCost);
|
|
}
|
|
|
|
private function calculateProductsTotal(int $orderId): float
|
|
{
|
|
$products = $this->orders->orderProducts($orderId);
|
|
$summary = 0.0;
|
|
|
|
foreach ($products as $row) {
|
|
$pricePromo = (float)($row['price_brutto_promo'] ?? 0);
|
|
$price = (float)($row['price_brutto'] ?? 0);
|
|
$quantity = (float)($row['quantity'] ?? 0);
|
|
|
|
if ($pricePromo > 0) {
|
|
$summary += $pricePromo * $quantity;
|
|
} else {
|
|
$summary += $price * $quantity;
|
|
}
|
|
}
|
|
|
|
return $summary;
|
|
}
|
|
|
|
public function changeStatus(int $orderId, int $status, bool $sendEmail): array
|
|
{
|
|
$order = $this->orders->findRawById($orderId);
|
|
if (!$order || (int)$order['status'] === $status) {
|
|
return ['result' => false];
|
|
}
|
|
|
|
$db = $this->orders->getDb();
|
|
|
|
if ($this->orders->updateOrderStatus($orderId, $status))
|
|
{
|
|
$this->orders->insertStatusHistory($orderId, $status, $sendEmail ? 1 : 0);
|
|
|
|
$response = ['result' => true];
|
|
|
|
if ($sendEmail)
|
|
{
|
|
$order['status'] = $status;
|
|
$response['email'] = $this->sendStatusChangeEmail($order);
|
|
}
|
|
|
|
// Apilo status sync
|
|
$this->syncApiloStatusIfNeeded($order, $status);
|
|
|
|
return $response;
|
|
}
|
|
|
|
return ['result' => false];
|
|
}
|
|
|
|
public function resendConfirmationEmail(int $orderId): bool
|
|
{
|
|
global $settings;
|
|
|
|
$db = $this->orders->getDb();
|
|
$order = $this->orders->orderDetailsFrontend($orderId);
|
|
if (!$order || !$order['id']) {
|
|
return false;
|
|
}
|
|
|
|
$coupon = (int)$order['coupon_id'] ? (new \Domain\Coupon\CouponRepository($db))->find((int)$order['coupon_id']) : null;
|
|
|
|
$mail_order = \Shared\Tpl\Tpl::view('shop-order/mail-summary', [
|
|
'settings' => $settings,
|
|
'order' => $order,
|
|
'coupon' => $coupon,
|
|
]);
|
|
|
|
$settings['ssl'] ? $base = 'https' : $base = 'http';
|
|
|
|
$regex = "-(<img[^>]+src\s*=\s*['\"])(((?!'|\"|https?://).)*)(['\"][^>]*>)-i";
|
|
$mail_order = preg_replace($regex, "$1" . $base . "://" . $_SERVER['SERVER_NAME'] . "$2$4", $mail_order);
|
|
|
|
$regex = "-(<a[^>]+href\s*=\s*['\"])(((?!'|\"|https?://).)*)(['\"][^>]*>)-i";
|
|
$mail_order = preg_replace($regex, "$1" . $base . "://" . $_SERVER['SERVER_NAME'] . "$2$4", $mail_order);
|
|
|
|
\Shared\Helpers\Helpers::send_email($order['client_email'], \Shared\Helpers\Helpers::lang('potwierdzenie-zamowienia-ze-sklepu') . ' ' . $settings['firm_name'], $mail_order);
|
|
\Shared\Helpers\Helpers::send_email($settings['contact_email'], 'Nowe zamówienie / ' . $settings['firm_name'] . ' / ' . $order['number'] . ' - ' . $order['client_surname'] . ' ' . $order['client_name'], $mail_order);
|
|
|
|
return true;
|
|
}
|
|
|
|
public function setOrderAsUnpaid(int $orderId): bool
|
|
{
|
|
$this->orders->setAsUnpaid($orderId);
|
|
return true;
|
|
}
|
|
|
|
public function setOrderAsPaid(int $orderId, bool $sendMail): bool
|
|
{
|
|
$order = $this->orders->findRawById($orderId);
|
|
if (!$order) {
|
|
return false;
|
|
}
|
|
|
|
// Apilo payment sync
|
|
$this->syncApiloPaymentIfNeeded($order);
|
|
|
|
// Mark as paid
|
|
$this->orders->setAsPaid($orderId);
|
|
|
|
// Set status to 1 (opłacone) without email
|
|
$this->changeStatus($orderId, 1, false);
|
|
|
|
// Set status to 4 (przyjęte do realizacji) with email
|
|
$this->changeStatus($orderId, 4, $sendMail);
|
|
|
|
return true;
|
|
}
|
|
|
|
public function sendOrderToApilo(int $orderId): bool
|
|
{
|
|
global $mdb;
|
|
|
|
if ($orderId <= 0) {
|
|
\Domain\Integrations\ApiloLogger::log(
|
|
$mdb,
|
|
'resend_order',
|
|
$orderId,
|
|
'Nieprawidlowe ID zamowienia (orderId <= 0)',
|
|
['order_id' => $orderId]
|
|
);
|
|
return false;
|
|
}
|
|
|
|
$order = $this->orders->findForAdmin($orderId);
|
|
if (empty($order) || empty($order['apilo_order_id'])) {
|
|
\Domain\Integrations\ApiloLogger::log(
|
|
$mdb,
|
|
'resend_order',
|
|
$orderId,
|
|
'Brak zamowienia lub brak apilo_order_id',
|
|
['order_found' => !empty($order), 'apilo_order_id' => $order['apilo_order_id'] ?? null]
|
|
);
|
|
return false;
|
|
}
|
|
|
|
$integrationsRepository = new \Domain\Integrations\IntegrationsRepository( $mdb );
|
|
$accessToken = $integrationsRepository -> apiloGetAccessToken();
|
|
if (!$accessToken) {
|
|
\Domain\Integrations\ApiloLogger::log(
|
|
$mdb,
|
|
'resend_order',
|
|
$orderId,
|
|
'Nie udalo sie uzyskac tokenu Apilo (access token)',
|
|
['apilo_order_id' => $order['apilo_order_id']]
|
|
);
|
|
return false;
|
|
}
|
|
|
|
$newStatus = 8;
|
|
|
|
$ch = curl_init();
|
|
curl_setopt($ch, CURLOPT_URL, 'https://projectpro.apilo.com/rest/api/orders/' . $order['apilo_order_id'] . '/status/');
|
|
curl_setopt($ch, CURLOPT_POST, 1);
|
|
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
|
|
'id' => (int)$order['apilo_order_id'],
|
|
'status' => (int)( new \Domain\ShopStatus\ShopStatusRepository($mdb) )->getApiloStatusId( (int)$newStatus ),
|
|
]));
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
|
'Authorization: Bearer ' . $accessToken,
|
|
'Accept: application/json',
|
|
'Content-Type: application/json',
|
|
]);
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
|
|
$apiloResultRaw = curl_exec($ch);
|
|
$http_code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$apiloResult = json_decode((string)$apiloResultRaw, true);
|
|
|
|
if (!is_array($apiloResult) || (int)($apiloResult['updates'] ?? 0) !== 1) {
|
|
\Domain\Integrations\ApiloLogger::log(
|
|
$mdb,
|
|
'resend_order',
|
|
$orderId,
|
|
'Błąd ponownego wysyłania zamówienia do Apilo (HTTP: ' . $http_code . ')',
|
|
['apilo_order_id' => $order['apilo_order_id'], 'http_code' => $http_code, 'response' => $apiloResult]
|
|
);
|
|
curl_close($ch);
|
|
return false;
|
|
}
|
|
|
|
\Domain\Integrations\ApiloLogger::log(
|
|
$mdb,
|
|
'resend_order',
|
|
$orderId,
|
|
'Zamówienie ponownie wysłane do Apilo (apilo_order_id: ' . $order['apilo_order_id'] . ')',
|
|
['apilo_order_id' => $order['apilo_order_id'], 'http_code' => $http_code, 'response' => $apiloResult]
|
|
);
|
|
|
|
$query = "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'pp_shop_orders' AND COLUMN_NAME != 'id'";
|
|
$stmt = $mdb->query($query);
|
|
$columns = $stmt ? $stmt->fetchAll(\PDO::FETCH_COLUMN) : [];
|
|
$columnsList = implode(', ', $columns);
|
|
$mdb->query('INSERT INTO pp_shop_orders (' . $columnsList . ') SELECT ' . $columnsList . ' FROM pp_shop_orders pso WHERE pso.id = ' . $orderId);
|
|
$newOrderId = (int)$mdb->id();
|
|
|
|
$query = "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'pp_shop_order_products' AND COLUMN_NAME != 'id' AND COLUMN_NAME != 'order_id'";
|
|
$stmt2 = $mdb->query($query);
|
|
$columns = $stmt2 ? $stmt2->fetchAll(\PDO::FETCH_COLUMN) : [];
|
|
$columnsList = implode(', ', $columns);
|
|
$mdb->query('INSERT INTO pp_shop_order_products (order_id, ' . $columnsList . ') SELECT ' . $newOrderId . ', ' . $columnsList . ' FROM pp_shop_order_products psop WHERE psop.order_id = ' . $orderId);
|
|
|
|
$query = "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'pp_shop_order_statuses' AND COLUMN_NAME != 'id' AND COLUMN_NAME != 'order_id'";
|
|
$stmt3 = $mdb->query($query);
|
|
$columns = $stmt3 ? $stmt3->fetchAll(\PDO::FETCH_COLUMN) : [];
|
|
$columnsList = implode(', ', $columns);
|
|
$mdb->query('INSERT INTO pp_shop_order_statuses (order_id, ' . $columnsList . ') SELECT ' . $newOrderId . ', ' . $columnsList . ' FROM pp_shop_order_statuses psos WHERE psos.order_id = ' . $orderId);
|
|
|
|
$mdb->delete('pp_shop_orders', ['id' => $orderId]);
|
|
$mdb->delete('pp_shop_order_products', ['order_id' => $orderId]);
|
|
$mdb->delete('pp_shop_order_statuses', ['order_id' => $orderId]);
|
|
|
|
$mdb->update('pp_shop_orders', ['apilo_order_id' => null], ['id' => $newOrderId]);
|
|
|
|
curl_close($ch);
|
|
|
|
return true;
|
|
}
|
|
|
|
public function toggleTrustmateSend(int $orderId): array
|
|
{
|
|
$newValue = $this->orders->toggleTrustmateSend($orderId);
|
|
if ($newValue === null) {
|
|
return [
|
|
'result' => false,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'result' => true,
|
|
'trustmate_send' => $newValue,
|
|
];
|
|
}
|
|
|
|
public function deleteOrder(int $orderId): bool
|
|
{
|
|
return $this->orders->deleteOrder($orderId);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Private: email
|
|
// =========================================================================
|
|
|
|
private function sendStatusChangeEmail(array $order): bool
|
|
{
|
|
if (!$order['client_email']) {
|
|
return false;
|
|
}
|
|
|
|
$db = $this->orders->getDb();
|
|
$order_statuses = $this->orders->orderStatuses();
|
|
$firm_name = (new \Domain\Settings\SettingsRepository($db))->getSingleValue('firm_name');
|
|
|
|
$status = (int)$order['status'];
|
|
$number = $order['number'];
|
|
|
|
$subjects = [
|
|
0 => $firm_name . ' - zamówienie [NUMER] zostało złożone',
|
|
1 => $firm_name . ' - zamówienie [NUMER] zostało opłacone',
|
|
2 => $firm_name . ' - płatność za zamówienie [NUMER] została odrzucona',
|
|
3 => $firm_name . ' - płatność za zamówienie [NUMER] jest sprawdzania ręcznie',
|
|
4 => $firm_name . ' - zamówienie [NUMER] zostało przyjęte do realizacji',
|
|
5 => $firm_name . ' - zamówienie [NUMER] zostało wysłane',
|
|
6 => $firm_name . ' - zamówienie [NUMER] zostało zrealizowane',
|
|
7 => $firm_name . ' - zamówienie [NUMER] zostało przygotowane go wysłania',
|
|
8 => $firm_name . ' - zamówienie [NUMER] zostało anulowane',
|
|
];
|
|
|
|
$subject = isset($subjects[$status]) ? str_replace('[NUMER]', $number, $subjects[$status]) : '';
|
|
if (!$subject) {
|
|
return false;
|
|
}
|
|
|
|
$email = new \Shared\Email\Email();
|
|
$email->load_by_name('#sklep-zmiana-statusu-zamowienia');
|
|
|
|
$email->text = str_replace('[NUMER_ZAMOWIENIA]', $number, $email->text);
|
|
$email->text = str_replace('[DATA_ZAMOWIENIA]', date('Y/m/d', strtotime($order['date_order'])), $email->text);
|
|
$email->text = str_replace('[STATUS]', isset($order_statuses[$status]) ? $order_statuses[$status] : '', $email->text);
|
|
|
|
return $email->send($order['client_email'], $subject, true);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Private: Apilo sync
|
|
// =========================================================================
|
|
|
|
private function syncApiloPaymentIfNeeded(array $order): void
|
|
{
|
|
global $config;
|
|
|
|
$db = $this->orders->getDb();
|
|
$integrationsRepository = new \Domain\Integrations\IntegrationsRepository($db);
|
|
$apilo_settings = $integrationsRepository->getSettings('apilo');
|
|
|
|
if (!$apilo_settings['enabled'] || !$apilo_settings['access-token'] || !$apilo_settings['sync_orders']) {
|
|
\Domain\Integrations\ApiloLogger::log(
|
|
$db,
|
|
'payment_sync',
|
|
(int)$order['id'],
|
|
'Pominięto sync płatności — Apilo wyłączone lub brak tokenu/sync_orders',
|
|
[
|
|
'enabled' => $apilo_settings['enabled'] ?? false,
|
|
'has_token' => !empty($apilo_settings['access-token']),
|
|
'sync_orders' => $apilo_settings['sync_orders'] ?? false,
|
|
]
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (isset($config['debug']['apilo']) && $config['debug']['apilo']) {
|
|
self::appendApiloLog("SET AS PAID\n" . print_r($order, true));
|
|
}
|
|
|
|
if (!$order['apilo_order_id']) {
|
|
// Zamówienie jeszcze nie wysłane do Apilo — kolejkuj sync płatności na później
|
|
\Domain\Integrations\ApiloLogger::log(
|
|
$db,
|
|
'payment_sync',
|
|
(int)$order['id'],
|
|
'Brak apilo_order_id — płatność zakolejkowana do sync',
|
|
['apilo_order_id' => $order['apilo_order_id'] ?? null]
|
|
);
|
|
$this->queueApiloSync((int)$order['id'], true, null, 'awaiting_apilo_order');
|
|
} elseif (!$this->syncApiloPayment($order)) {
|
|
\Domain\Integrations\ApiloLogger::log(
|
|
$db,
|
|
'payment_sync',
|
|
(int)$order['id'],
|
|
'Sync płatności nieudany — zakolejkowano ponowną próbę',
|
|
['apilo_order_id' => $order['apilo_order_id']]
|
|
);
|
|
$this->queueApiloSync((int)$order['id'], true, null, 'payment_sync_failed');
|
|
}
|
|
}
|
|
|
|
private function syncApiloStatusIfNeeded(array $order, int $status): void
|
|
{
|
|
global $config;
|
|
|
|
$db = $this->orders->getDb();
|
|
$integrationsRepository = new \Domain\Integrations\IntegrationsRepository($db);
|
|
$apilo_settings = $integrationsRepository->getSettings('apilo');
|
|
|
|
if (!$apilo_settings['enabled'] || !$apilo_settings['access-token'] || !$apilo_settings['sync_orders']) {
|
|
\Domain\Integrations\ApiloLogger::log(
|
|
$db,
|
|
'status_sync',
|
|
(int)$order['id'],
|
|
'Pominięto sync statusu — Apilo wyłączone lub brak tokenu/sync_orders',
|
|
[
|
|
'target_status' => $status,
|
|
'enabled' => $apilo_settings['enabled'] ?? false,
|
|
'has_token' => !empty($apilo_settings['access-token']),
|
|
'sync_orders' => $apilo_settings['sync_orders'] ?? false,
|
|
]
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (isset($config['debug']['apilo']) && $config['debug']['apilo']) {
|
|
self::appendApiloLog("UPDATE STATUS\n" . print_r($order, true));
|
|
}
|
|
|
|
if (!$order['apilo_order_id']) {
|
|
// Zamówienie jeszcze nie wysłane do Apilo — kolejkuj sync statusu na później
|
|
\Domain\Integrations\ApiloLogger::log(
|
|
$db,
|
|
'status_sync',
|
|
(int)$order['id'],
|
|
'Brak apilo_order_id — status zakolejkowany do sync',
|
|
['apilo_order_id' => $order['apilo_order_id'] ?? null, 'target_status' => $status]
|
|
);
|
|
$this->queueApiloSync((int)$order['id'], false, $status, 'awaiting_apilo_order');
|
|
} elseif (!$this->syncApiloStatus($order, $status)) {
|
|
\Domain\Integrations\ApiloLogger::log(
|
|
$db,
|
|
'status_sync',
|
|
(int)$order['id'],
|
|
'Sync statusu nieudany — zakolejkowano ponowną próbę',
|
|
['apilo_order_id' => $order['apilo_order_id'], 'target_status' => $status]
|
|
);
|
|
$this->queueApiloSync((int)$order['id'], false, $status, 'status_sync_failed');
|
|
}
|
|
}
|
|
|
|
public function syncApiloPayment(array $order): bool
|
|
{
|
|
global $config;
|
|
|
|
$db = $this->orders->getDb();
|
|
$integrationsRepository = new \Domain\Integrations\IntegrationsRepository($db);
|
|
|
|
if (empty($order['apilo_order_id'])) {
|
|
return true;
|
|
}
|
|
|
|
$payment_type = (int)(new \Domain\PaymentMethod\PaymentMethodRepository($db))->getApiloPaymentTypeId((int)$order['payment_method_id']);
|
|
if ($payment_type <= 0) {
|
|
$payment_type = 1;
|
|
}
|
|
|
|
$payment_date = new \DateTime($order['date_order']);
|
|
$access_token = $integrationsRepository->apiloGetAccessToken();
|
|
|
|
$ch = curl_init();
|
|
curl_setopt($ch, CURLOPT_URL, "https://projectpro.apilo.com/rest/api/orders/" . $order['apilo_order_id'] . '/payment/');
|
|
curl_setopt($ch, CURLOPT_POST, 1);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
|
|
'amount' => str_replace(',', '.', $order['summary']),
|
|
'paymentDate' => $payment_date->format('Y-m-d\TH:i:s\Z'),
|
|
'type' => $payment_type,
|
|
]));
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
|
"Authorization: Bearer " . $access_token,
|
|
"Accept: application/json",
|
|
"Content-Type: application/json",
|
|
]);
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
|
|
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
|
$apilo_response = curl_exec($ch);
|
|
$http_code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$curl_error = curl_errno($ch) ? curl_error($ch) : '';
|
|
curl_close($ch);
|
|
|
|
if (isset($config['debug']['apilo']) && $config['debug']['apilo']) {
|
|
self::appendApiloLog("PAYMENT RESPONSE\nHTTP: " . $http_code . "\nCURL: " . $curl_error . "\n" . print_r($apilo_response, true));
|
|
}
|
|
|
|
$success = ($curl_error === '' && $http_code >= 200 && $http_code < 300);
|
|
|
|
\Domain\Integrations\ApiloLogger::log(
|
|
$db,
|
|
'payment_sync',
|
|
(int)$order['id'],
|
|
$success
|
|
? 'Płatność zsynchronizowana z Apilo (apilo_order_id: ' . $order['apilo_order_id'] . ')'
|
|
: 'Błąd synchronizacji płatności (HTTP: ' . $http_code . ($curl_error ? ', cURL: ' . $curl_error : '') . ')',
|
|
[
|
|
'apilo_order_id' => $order['apilo_order_id'],
|
|
'http_code' => $http_code,
|
|
'curl_error' => $curl_error,
|
|
'response' => json_decode((string)$apilo_response, true),
|
|
]
|
|
);
|
|
|
|
if ($curl_error !== '') return false;
|
|
if ($http_code < 200 || $http_code >= 300) return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
public function syncApiloStatus(array $order, int $status): bool
|
|
{
|
|
global $config;
|
|
|
|
$db = $this->orders->getDb();
|
|
$integrationsRepository = new \Domain\Integrations\IntegrationsRepository($db);
|
|
|
|
if (empty($order['apilo_order_id'])) {
|
|
return true;
|
|
}
|
|
|
|
$access_token = $integrationsRepository->apiloGetAccessToken();
|
|
|
|
$ch = curl_init();
|
|
curl_setopt($ch, CURLOPT_URL, "https://projectpro.apilo.com/rest/api/orders/" . $order['apilo_order_id'] . '/status/');
|
|
curl_setopt($ch, CURLOPT_POST, 1);
|
|
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "PUT");
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
|
|
'id' => $order['apilo_order_id'],
|
|
'status' => (int)(new \Domain\ShopStatus\ShopStatusRepository($db))->getApiloStatusId($status),
|
|
]));
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
|
"Authorization: Bearer " . $access_token,
|
|
"Accept: application/json",
|
|
"Content-Type: application/json",
|
|
]);
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
|
|
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
|
$apilo_result = curl_exec($ch);
|
|
$http_code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$curl_error = curl_errno($ch) ? curl_error($ch) : '';
|
|
curl_close($ch);
|
|
|
|
if (isset($config['debug']['apilo']) && $config['debug']['apilo']) {
|
|
self::appendApiloLog("STATUS RESPONSE\nHTTP: " . $http_code . "\nCURL: " . $curl_error . "\n" . print_r($apilo_result, true));
|
|
}
|
|
|
|
$success = ($curl_error === '' && $http_code >= 200 && $http_code < 300);
|
|
|
|
\Domain\Integrations\ApiloLogger::log(
|
|
$db,
|
|
'status_sync',
|
|
(int)$order['id'],
|
|
$success
|
|
? 'Status zsynchronizowany z Apilo (apilo_order_id: ' . $order['apilo_order_id'] . ', status: ' . $status . ')'
|
|
: 'Błąd synchronizacji statusu (HTTP: ' . $http_code . ($curl_error ? ', cURL: ' . $curl_error : '') . ')',
|
|
[
|
|
'apilo_order_id' => $order['apilo_order_id'],
|
|
'status' => $status,
|
|
'http_code' => $http_code,
|
|
'curl_error' => $curl_error,
|
|
'response' => json_decode((string)$apilo_result, true),
|
|
]
|
|
);
|
|
|
|
if ($curl_error !== '') return false;
|
|
if ($http_code < 200 || $http_code >= 300) return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
// =========================================================================
|
|
// Private: Apilo sync queue (DB-based via CronJobRepository)
|
|
// =========================================================================
|
|
|
|
private function queueApiloSync(int $order_id, bool $payment, ?int $status, string $error): void
|
|
{
|
|
if ($order_id <= 0) return;
|
|
|
|
if ($this->cronJobRepo === null) return;
|
|
|
|
if ($payment) {
|
|
$jobType = \Domain\CronJob\CronJobType::APILO_SYNC_PAYMENT;
|
|
$payload = ['order_id' => $order_id];
|
|
|
|
if (!$this->cronJobRepo->hasPendingJob($jobType, $payload)) {
|
|
$this->cronJobRepo->enqueue(
|
|
$jobType,
|
|
$payload,
|
|
\Domain\CronJob\CronJobType::PRIORITY_HIGH,
|
|
50
|
|
);
|
|
}
|
|
}
|
|
|
|
if ($status !== null) {
|
|
$jobType = \Domain\CronJob\CronJobType::APILO_SYNC_STATUS;
|
|
$payload = ['order_id' => $order_id, 'status' => $status];
|
|
|
|
if (!$this->cronJobRepo->hasPendingJob($jobType, $payload)) {
|
|
$this->cronJobRepo->enqueue(
|
|
$jobType,
|
|
$payload,
|
|
\Domain\CronJob\CronJobType::PRIORITY_HIGH,
|
|
50
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static function appendApiloLog(string $message): void
|
|
{
|
|
$base = isset($_SERVER['DOCUMENT_ROOT']) && $_SERVER['DOCUMENT_ROOT']
|
|
? rtrim($_SERVER['DOCUMENT_ROOT'], '/\\')
|
|
: dirname(__DIR__, 2);
|
|
|
|
$dir = $base . '/logs';
|
|
if (!is_dir($dir)) {
|
|
mkdir($dir, 0777, true);
|
|
}
|
|
|
|
file_put_contents(
|
|
$dir . '/apilo.txt',
|
|
date('Y-m-d H:i:s') . ' --- ' . $message . "\n\n",
|
|
FILE_APPEND
|
|
);
|
|
}
|
|
}
|