Files
kupshop/bundles/KupShop/DropshipBundle/Transfer/ChannableTransfer.php
2025-08-02 16:30:27 +02:00

721 lines
28 KiB
PHP

<?php
namespace KupShop\DropshipBundle\Transfer;
use KupShop\AdminBundle\Util\ActivityLog;
use KupShop\DropshipBundle\TransferInterface;
use KupShop\DropshipBundle\Util\TransferWorker;
use KupShop\KupShopBundle\Context\ContextManager;
use KupShop\KupShopBundle\Context\CountryContext;
use KupShop\KupShopBundle\Context\CurrencyContext;
use KupShop\KupShopBundle\Context\LanguageContext;
use KupShop\KupShopBundle\Query\JsonOperator;
use KupShop\KupShopBundle\Util\Compat\ServiceContainer;
use KupShop\KupShopBundle\Util\Contexts;
use KupShop\KupShopBundle\Util\StringUtil;
use KupShop\OrderingBundle\Event\OrderEvent;
use KupShop\OrderingBundle\Util\Order\OrderInfo;
use KupShop\OrderingBundle\Util\Order\OrderItemInfo;
use Psr\Log\LoggerInterface;
use Query\Operator;
use Query\QueryBuilder;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\Service\Attribute\Required;
class ChannableTransfer extends AbstractTransfer implements TransferInterface
{
protected static string $type = 'channable';
protected static string $name = 'Channable';
public const API_URL_START = 'https://api.channable.com/v1/companies/';
public ContextManager $contextManager;
private HttpClientInterface $httpClient;
/** @required */
public TransferWorker $transferWorker;
protected LoggerInterface $logger;
public function __construct(HttpClientInterface $httpClient, LoggerInterface $logger)
{
$this->httpClient = $httpClient;
$this->logger = $logger;
}
#[Required]
final public function setContextManager(ContextManager $contextManager)
{
$this->contextManager = $contextManager;
}
public function sendTrackingInfoToChannable(): void
{
$qb = $this->getOrdersForUpdate();
foreach ($qb->execute() as $item) {
$order = new \Order();
$order->createFromDB($item['id']);
$connectionData = $this->getConnectionData();
if (!$connectionData) {
$this->addActivityLog('V nastavení dropshipmentu chybí potřebné údaje pro komunikaci s Channable',
severity: ActivityLog::SEVERITY_WARNING);
return;
}
$orderInfo = ServiceContainer::getService(OrderInfo::class);
$delivery = $order->getDeliveryType()->getDelivery()->class;
$packages = array_keys($orderInfo->getPackages($order));
$connectionData['url'] .= '/'.$item['channable_id'].'/shipment';
// load order items
$connectionData['body']['products'] = json_decode($order->note_admin, true)['channable']['channable_item_ids'] ?? [];
$connectionData['body']['packages'] = $packages[0];
$connectionData['body']['delivery'] = $delivery;
if ($this->sendTrackingInfo($connectionData)) {
$order->logHistory('Informace o balíku byly odeslány do Channable');
$this->addActivityLog('Informace o balíku byly odeslány do Channable - objednávka: '.$order->order_no, $connectionData['body'], severity: ActivityLog::SEVERITY_SUCCESS);
sqlQuery('UPDATE order_dropshipment SET data = JSON_OBJECT("trackingSent", "Y")
WHERE id_order = :order_id AND id_dropshipment = :id_dropshipment',
['order_id' => $item['id'], 'id_dropshipment' => $this->dropshipment['id']]);
continue;
}
$this->addActivityLog('Nepodařilo se odestal informace o balíku do Channable u objednávky č. '.$order->order_no);
$order->logHistory('Nepodařilo se odestal informace o balíku do Channable');
}
}
protected function getExternalData(\SimpleXMLElement $xml): array
{
// TODO: Implement getExternalData() method.
return [];
}
protected function getDeliveryTypeByConfiguration(\SimpleXMLElement $order): ?\DeliveryType
{
// TODO: Implement getDeliveryTypeByConfiguration() method.
return null;
}
public function in(array $config): void
{
$ordersJson = $this->getOrders();
$orders = json_decode($ordersJson, true)['orders'] ?? [];
$countryContext = Contexts::get(CountryContext::class);
foreach ($orders as $orderData) {
$this->contextManager->activateContexts([CountryContext::class => $orderData['data']['billing']['country_code'] ?? $countryContext->getDefaultId()],
function () use ($orderData) {
// log incoming order to Kibana
$this->logger->notice(self::$name.' IN', $orderData);
$externalId = $orderData['channel_id'];
if (!$externalId) {
$this->addActivityLog(
'Nepodařilo se naimportovat objednávku do e-shopu, protože nemá externí ID. V odpovědi z Channable chybí ID',
$orderData
);
return;
}
// hledame i podle platform_id, protoze nektere objednavky to maji nastavene jako id_external
$order = $this->getOrderByExternalId($externalId) ?? $this->getOrderByExternalId($orderData['platform_id'] ?? '');
if ($order) {
$this->updateChannableOrder($order, $orderData);
return;
}
try {
$order = sqlGetConnection()->transactional(function () use ($externalId, $orderData) {
$currencyInfo = $this->getCurrencyInfo($orderData['data']['price']['currency']);
$data = $this->getOrderBaseData($orderData);
$data['currency_rate'] = $currencyInfo->rate->asFloat();
$order = $this->createChannableOrder($data, $externalId);
$orderItems = [];
foreach ($orderData['data']['products'] as $item) {
[$code, $ean] = $this->getProductFromItem($item);
[$productId, $variationId] = $this->getProductByCode($code, $ean ?? null);
$pieces = toDecimal($item['quantity']);
$piecePrice = $this->convertPrice(toDecimal($item['price']), $currencyInfo);
$totalPrice = $this->convertPrice(toDecimal($item['price'])->mul($pieces), $currencyInfo);
if ($productId) {
$product = \Variation::createProductOrVariation($productId, $variationId);
$product->createFromDB();
$product->sell($variationId, $pieces->asFloat());
$piecePrice = $piecePrice->removeVat($product->vat);
$totalPrice = $totalPrice->removeVat($product->vat);
}
$productTitle = empty($product->title) ? $item['title'] : $product->title;
$itemData = [
'id_order' => $order->id,
'id_product' => $product->id ?? null,
'id_variation' => $variationId,
'pieces' => $pieces->asFloat(),
'piece_price' => $piecePrice->asFloat(),
'total_price' => $totalPrice->asFloat(),
'descr' => $productTitle,
'tax' => $item['tax'] ?? $product->vat ?? '',
];
sqlQueryBuilder()
->insert('order_items')
->directValues($itemData)
->execute();
$itemData['id'] = sqlInsertId();
$orderItems[] = $itemData;
$this->itemCreatedEvent(
product: $product ?? null,
idVariation: (int) $variationId,
piecePrice: $piecePrice,
pieces: $pieces->asInteger(),
data: [
'row' => $itemData,
'items_table' => 'order_items',
],
order: $order
);
unset($product);
}
$this->insertDeliveryItem($order, $orderData, $orderItems ?? []);
$order->recalculate(round: false);
if (!$order->isPaid() && $data['status_payed'] == 1) {
$this->payDropshipOrder($order);
}
return $order;
});
} catch (\Throwable $e) {
$this->transferWorker->logException($e, $this);
}
});
}
}
public function out(array $config): void
{
if (isDevelopment()) {
return;
}
$this->sendTrackingInfoToChannable();
$this->sendCancelledOrders();
}
protected function sendCancelledOrders()
{
$configuration = $this->getConfiguration();
if ($configuration['do_not_storno_orders'] == 'Y' ?? false) {
return;
}
$qb = $this->getCancelledOrders();
foreach ($qb->execute() as $cancelledOrder) {
$order = new \Order();
$order->createFromDB($cancelledOrder['id']);
$connectionData = $this->getConnectionData();
if (!$connectionData) {
$this->addActivityLog('V nastavení dropshipmentu chybí potřebné údaje pro komunikaci s Channable',
severity: ActivityLog::SEVERITY_WARNING);
return;
}
$connectionData['url'] .= '/'.$cancelledOrder['channable_id'].'/cancel';
$connectionData['body']['products'] = json_decode($order->note_admin, true)['channable']['channable_item_ids'] ?? [];
if ($this->sendCancelledOrder($connectionData)) {
$order->logHistory('Informace o zrušení objednávky byly odeslány do Channable');
sqlQuery('UPDATE order_dropshipment SET data = JSON_OBJECT("cancelSent", "Y")
WHERE id_order = :order_id AND id_dropshipment = :id_dropshipment',
['order_id' => $cancelledOrder['id'], 'id_dropshipment' => $this->dropshipment['id']]);
continue;
}
$this->addActivityLog('Nepodařilo se odeslat informace o zrušení objednávky do Channable u objednávky č. '.$order->order_no);
$order->logHistory('Nepodařilo se odeslat informace o zrušení objednávky do Channable');
}
}
protected function getOrders(): ?string
{
$connectionData = $this->getConnectionData();
if (!$connectionData) {
$this->addActivityLog('V nastavení dropshipmentu chybí potřebné údaje pro komunikaci s Channable',
severity: ActivityLog::SEVERITY_WARNING);
return null;
}
$lastSyncTime = $this->getLastSyncTime();
// potrebuju prevest $lastSyncTime na UTC cas, jinak se stavalo, ze se objednavky vubec na shop nedostavaly
$time = new \DateTime($lastSyncTime);
$time->setTimezone(new \DateTimeZone('UTC'));
$lastSyncTime = $time->format('Y-m-d H:i:s');
$response = $this->httpClient->request(
'GET',
$connectionData['url'],
[
'headers' => [
'Authorization' => $connectionData['api_key'],
],
'query' => [
'last_modified_after' => $lastSyncTime ?? date('Y-m-d H:i:s', strtotime('-1 day')),
],
]
);
if ($response->getStatusCode() != 200) {
$this->addActivityLog('Nepodařilo se načíst objednávky z Channable', [$response->getContent(false)]);
return '';
}
$logData = ['last_sync_time' => $lastSyncTime];
$this->addActivityLog('Načtení objednávek z Channable proběhlo úspěšně', data: $logData, severity: ActivityLog::SEVERITY_SUCCESS);
return $response->getContent(false);
}
protected function sendTrackingInfo(array $data): bool
{
$response = $this->httpClient->request(
'POST',
$data['url'],
[
'headers' => [
'Authorization' => $data['api_key'],
'Content-Type' => 'application/json',
],
'body' => json_encode([
'order_item_ids' => $data['body']['products'],
'tracking_code' => (string) $data['body']['packages'],
]),
]
);
if ($response->getStatusCode() != 200) {
$this->addActivityLog('[Channable] Odesílání informací o doručení - chybná response', [$response->getContent(false)]);
$this->addActivityLog('Nepodařilo se odeslat informace o balíku do Channable', [$response->getContent(false)]);
return false;
}
$this->addActivityLog('[Channable] Odesílání informací o doručení - response', [$response->getContent(false)], severity: ActivityLog::SEVERITY_SUCCESS);
return true;
}
protected function sendCancelledOrder(array $data): bool
{
$response = $this->httpClient->request(
'POST',
$data['url'],
[
'headers' => [
'Authorization' => $data['api_key'],
'Content-Type' => 'application/json',
],
'body' => json_encode([
'order_item_ids' => $data['body']['products'],
]),
]
);
if ($response->getStatusCode() != 200) {
$this->addActivityLog('Nepodařilo se odeslat informace o zrušení objednávky do Channable', [$response->getContent(false)]);
return false;
}
return true;
}
protected function updateChannableOrder(\Order $order, array $orderData): void
{
$data = $this->getOrderBaseData($orderData);
if (!$order->isPaid() && $data['status_payed'] == 1) {
sqlQueryBuilder()
->update('orders')
->directValues(['status_payed' => 1])
->where(Operator::equals(['id' => $order->id]))
->execute();
$this->payDropshipOrder($order);
}
}
protected function getOrderBaseData(array $order): array
{
$configuration = $this->getConfiguration();
$data = [];
$useMarketplaceOrderNo = $configuration['use_marketplace_order_no'] ?? 'N';
if ($useMarketplaceOrderNo === 'Y') {
$data['order_no'] = $order['channel_id'];
}
$data['note_invoice'] = $order['channel_id'];
$data['date_created'] = $this->dateTimeConvertor($order['created']);
$data['date_updated'] = $this->dateTimeConvertor($order['modified']);
$data['currency'] = $order['data']['price']['currency'];
$data['status'] = 0;
$data['status_payed'] = $order['status_paid'] === 'paid' ? 1 : 0;
$data['total_price'] = 0.0000; // 0 => RECALCULATE
// INVOICE
$data['invoice_name'] = $order['data']['billing']['first_name'].' '.$order['data']['billing']['middle_name'];
$data['invoice_surname'] = $order['data']['billing']['last_name'] ?? '';
$data['invoice_firm'] = $order['data']['billing']['company'] ?? '';
$data['invoice_dic'] = $order['data']['billing']['vat_number'] ?? '';
$data['invoice_street'] = $order['data']['billing']['address1'] ?? '';
$data['invoice_city'] = $order['data']['billing']['city'] ?? '';
$data['invoice_zip'] = $order['data']['billing']['zip_code'] ?? '';
$data['invoice_country'] = $order['data']['billing']['country_code'] ?? '';
$data['invoice_email'] = $order['data']['billing']['email'] ?? '';
$data['invoice_phone'] = $order['data']['customer']['phone'] ?? '';
// DELIVERY
$data['delivery_name'] = $order['data']['shipping']['first_name'].' '.$order['data']['shipping']['middle_name'];
$data['delivery_surname'] = $order['data']['shipping']['last_name'] ?? '';
$data['delivery_firm'] = $order['data']['shipping']['company'] ?? '';
$data['delivery_street'] = $order['data']['shipping']['address1'] ?? '';
$data['delivery_city'] = $order['data']['shipping']['city'] ?? '';
$data['delivery_zip'] = $order['data']['shipping']['zip_code'] ?? '';
$data['delivery_country'] = $order['data']['shipping']['country_code'] ?? '';
$data['delivery_phone'] = $order['data']['customer']['phone'] ?? '';
$deliveryType = $this->getDelivery($order);
$data['delivery_type'] = $deliveryType->name ?? '';
$data['id_delivery'] = $deliveryType->id ?? null;
$data['id_language'] = $this->getOrderLanguage($order, $configuration);
$channableOrderItemIds = [];
foreach ($order['data']['products'] as $item) {
$channableOrderItemIds[] = $item['channable_order_item_id'] ?? null;
}
$data['note_admin'] = json_encode(
[
'channable' => [
'id' => $order['id'],
'channel_name' => $order['channel_name'],
'platform_name' => $order['platform_name'],
'project_id' => $order['project_id'],
'order_config_id' => $order['order_config_id'],
'platform_id' => $order['platform_id'],
'channel_id' => $order['channel_id'],
'channable_item_ids' => $channableOrderItemIds,
],
],
);
$flags = ['DSC'];
$data['flags'] = implode(',', $flags);
return $data;
}
protected function createChannableOrder(array $data, int|string $externalId)
{
/** @var \Order $order */
$order = sqlGetConnection()->transactional(function () use ($data) {
sqlQueryBuilder()
->insert('orders')
->directValues(array_merge(['source' => OrderInfo::ORDER_SOURCE_DROPSHIP], $data))
->execute();
$order = \Order::get((int) sqlInsertId());
// dispatch order created event - order no is generated in event subscriber
$this->eventDispatcher->dispatch(new OrderEvent($order), OrderEvent::ORDER_CREATED);
return $order;
});
// vytvorim mapovani na dropshipment
sqlQueryBuilder()
->insert('order_dropshipment')
->directValues(
[
'id_order' => $order->id,
'id_dropshipment' => $this->dropshipment['id'],
'id_external' => $externalId,
]
)->execute();
// zalogovat informaci o vytvoreni objednavky
$order->logHistory(
sprintf('[Dropshipment] <a href="javascript:nw(\'Dropshipment\', %s);">%s</a>: %s',
$this->dropshipment['id'],
'channable',
$externalId)
);
return $order;
}
protected function getDelivery(array $order): ?\DeliveryType
{
$channelConfiguration = $this->getChannelConfiguration($order);
$deliveryId = null;
$country = $order['data']['shipping']['country_code'];
foreach ($channelConfiguration['deliveries'] ?? [] as $delivery) {
if (empty($delivery['country']) || $delivery['country'] === $country) {
$deliveryId = (int) $delivery['id_delivery'];
}
}
$paymentId = empty($channelConfiguration['payments']['default']) ? null : (int) $channelConfiguration['payments']['default'];
return $this->findDeliveryType($deliveryId, $paymentId);
}
protected function getChannelConfiguration(array $order): array
{
$configuration = $this->getConfiguration();
$channelName = $order['channel_name'];
$channelConfiguration = null;
$default = null;
foreach ($configuration['marketplaces'] ?? [] as $marketplace) {
if (StringUtil::slugify($marketplace['name']) === StringUtil::slugify($channelName)) {
$channelConfiguration = $marketplace;
break;
}
if (empty($marketplace['name'])) {
$default = $marketplace;
}
}
if (!$channelConfiguration && $default) {
$channelConfiguration = $default;
}
return $channelConfiguration ?: [];
}
protected function insertDeliveryItem(\Order $order, array $data, array $orderItems): void
{
$dbcfg = \Settings::getDefault();
$deliveryPrice = $this->getDeliveryPrice($order);
$tax = ($dbcfg->delivery_config['from_products'] ?? 'N') == 'Y' ?
$this->getDeliveryVatFromProducts($orderItems)
: $deliveryPrice->getVat() ?? \DecimalConstants::zero();
$shippingPrice = toDecimal($data['data']['price']['shipping']) ?? $deliveryPrice->getPriceWithVat() ?? \DecimalConstants::zero();
$shippingPrice = $shippingPrice->removeVat($tax);
$insertData = [
'id_order' => $order->id,
'id_product' => null,
'id_variation' => null,
'pieces' => 1,
'pieces_reserved' => 1,
'piece_price' => $shippingPrice ?? $deliveryPrice->getPriceWithoutVat(),
'total_price' => $shippingPrice ?? $deliveryPrice->getPriceWithoutVat(),
'tax' => $tax ?? 0,
'descr' => 'Doprava a platba',
'note' => json_encode(['item_type' => OrderItemInfo::TYPE_DELIVERY]),
];
sqlQueryBuilder()
->insert('order_items')
->directValues($insertData)
->execute();
}
protected function getDeliveryPrice(\Order $order)
{
$deliveryType = $order->getDeliveryType();
$deliveryPrice = $this->contextManager->activateContexts(
[
CurrencyContext::class => $order->currency,
],
function () use ($deliveryType) {
return $deliveryType->getPrice();
}
);
return $deliveryPrice;
}
protected function getOrdersForUpdate(): QueryBuilder
{
$qb = parent::getOrdersForUpdate();
$qb->addSelect('JSON_VALUE(o.note_admin, "$.channable.id") as channable_id');
$qb->andWhere(
Operator::equalsNullable([JsonOperator::value('od.data', 'trackingSent') => null])
)
->andWhere(
Operator::not(Operator::equalsNullable([JsonOperator::value('o.note_admin', 'channable.*') => null]))
)
->andWhere(Operator::inIntArray([(int) str_replace('"', '', $this->getConfigurationStatus())], 'o.status'));
return $qb;
}
protected function getConfigurationStatus(): string
{
return sqlQueryBuilder()
->select('JSON_EXTRACT(configuration, "$.statuses[0]") as statuses')
->from('dropshipment')
->where(Operator::equals(['id' => $this->dropshipment['id']]))
->execute()->fetchOne();
}
protected function getCancelledOrders(): QueryBuilder
{
return sqlQueryBuilder()
->select('o.id as id, od.id_external as id_external, JSON_VALUE(o.note_admin, "$.channable.id") as channable_id')
->from('orders', 'o')
->join('o', 'order_dropshipment', 'od', 'o.id = od.id_order AND od.id_dropshipment = :dropshipment_id')
->where('o.status_storno = 1')
->andWhere(Operator::equalsNullable([JsonOperator::value('od.data', 'cancelSent') => null]))
->andWhere(Operator::equalsNullable([JsonOperator::value('od.data', 'trackingSent') => null]))
->setParameter('dropshipment_id', $this->dropshipment['id']);
}
public function prepareConfigurationData(array $data): array
{
foreach ($data['marketplaces'] ?? [] as $key => $marketplace) {
// zpracovani mapovani doprav
foreach ($marketplace['deliveries'] ?? [] as $dKey => $delivery) {
$delivery = array_filter($delivery);
if (!empty($delivery['delete'])) {
unset($data['marketplaces'][$key]['deliveries'][$dKey]);
continue;
}
if ($dKey <= 0) {
if (!empty($delivery['id_delivery'])) {
$data['marketplaces'][$key]['deliveries'][] = $delivery;
}
unset($data['marketplaces'][$key]['deliveries'][$dKey]);
}
}
}
$data['marketplaces'] = array_values($data['marketplaces'] ?? []);
return $data;
}
public function getConnectionData(): ?array
{
$url = self::API_URL_START;
$data = $this->getChannableData();
$companyId = str_replace('"', '', $data['company_id']);
$project_id = str_replace('"', '', $data['project_id']);
if (!$data['company_id'] || !$data['project_id'] || !$data['api_key']) {
return null;
}
return [
'url' => $url.(int) $companyId.'/projects/'.(int) $project_id.'/orders',
'api_key' => 'Bearer '.$data['api_key'],
];
}
protected function getChannableData()
{
return sqlQueryBuilder()
->select('JSON_EXTRACT(data, \'$.company_id\') as company_id, JSON_EXTRACT(data, \'$.project_id\') as project_id, source_url as api_key')
->from('dropshipment')
->where(Operator::equals(['id' => $this->dropshipment['id']]))
->execute()
->fetchAssociative();
}
protected function getOrderLanguage(array $order, array $configuration): string
{
$languageContext = Contexts::get(LanguageContext::class);
$marketplace = array_filter($configuration['marketplaces'], fn ($marketplace) => strtolower($marketplace['name']) === strtolower($order['channel_name'])) ?? [];
$marketplace = reset($marketplace);
return $marketplace['settings']['id_language'] ?? $languageContext->getDefaultId();
}
protected function getProductFromItem(array $item): array
{
$return = [null, null];
if ($item['id']) {
$return[0] = $item['id'];
}
if ($item['ean']) {
$return[1] = $item['ean'];
}
return $return;
}
protected function getDeliveryVatFromProducts(array $orderItems): int
{
return array_reduce($orderItems, function ($maxVat, $product) {
$vat = (int) $product['tax'] ?? 0;
return ($vat > $maxVat) ? $vat : $maxVat;
}, 0);
}
/**
* Converts datetime to specific timezone.
*/
private function dateTimeConvertor(string $datetime, string $timezone = 'Europe/Prague')
{
$date = new \DateTime($datetime);
$date->setTimezone(new \DateTimeZone($timezone));
return $date->format('Y-m-d H:i:s');
}
}