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

811 lines
28 KiB
PHP

<?php
namespace KupShop\DropshipBundle\Transfer;
use KupShop\AdminBundle\Util\ActivityLog;
use KupShop\DropshipBundle\Exception\TransferException;
use KupShop\DropshipBundle\TransferInterface;
use KupShop\KupShopBundle\Config;
use KupShop\KupShopBundle\Context\CountryContext;
use KupShop\KupShopBundle\Context\CurrencyContext;
use KupShop\KupShopBundle\Context\LanguageContext;
use KupShop\KupShopBundle\Context\VatContext;
use KupShop\KupShopBundle\Query\JsonOperator;
use KupShop\KupShopBundle\Util\Contexts;
use KupShop\KupShopBundle\Util\Functional\Mapping;
use KupShop\KupShopBundle\Util\Logging\SentryLogger;
use KupShop\KupShopBundle\Util\StringUtil;
use KupShop\OrderingBundle\Util\Order\OrderInfo;
use KupShop\OrderingBundle\Util\Order\OrderItemInfo;
use KupShop\OrderingBundle\Util\Order\OrderUtil;
use Psr\Log\LoggerInterface;
use Query\Operator;
use Query\QueryBuilder;
class ExpandoTransfer extends AbstractTransfer implements TransferInterface
{
use \DatabaseCommunication;
protected static string $type = 'expando';
protected static string $name = 'Expando';
public const URL_FULFILLMENT = 'https://app.expan.do/api/v2/fulfillment';
protected array $existsOrdersCache = [];
protected OrderUtil $orderUtil;
protected LoggerInterface $logger;
private $sentryLogger;
private $currencyContext;
private $countryContext;
private $vatContext;
public function __construct(
SentryLogger $sentryLogger,
CurrencyContext $currencyContext,
CountryContext $countryContext,
VatContext $vatContext,
LoggerInterface $logger,
) {
$this->sentryLogger = $sentryLogger;
$this->currencyContext = $currencyContext;
$this->countryContext = $countryContext;
$this->vatContext = $vatContext;
$this->logger = $logger;
}
/**
* @required
*/
public function setOrderUtil(OrderUtil $orderUtil): void
{
$this->orderUtil = $orderUtil;
}
public function in(array $config): void
{
$cfg = Config::get();
if (!($xml = $this->loadXML())) {
return;
}
$currencies = $this->currencyContext->getAll();
$update = $config['update'] ?? null;
$orders = [];
foreach ($xml->order as $order) {
$orders[] = $order;
}
$this->loadExistsOrdersCache($orders);
foreach (array_reverse($orders) as $order) {
if (!$this->isDropshipOrderValidToImport($order)) {
// objednavka uz existuje
if ($update && ($orderUpdateObj = $this->getOrderByExternalId((string) $order->orderId))) {
$this->updateDropshipOrder($orderUpdateObj, $order);
}
continue;
}
$currencyInfo = $this->getCurrencyInfo(
$this->getCurrency($order)
);
$currencyCode = $this->isPriceConvertionEnabled() ? $this->currencyContext->getDefaultId() : $currencyInfo->getCurrencyCode();
$status = 0;
$statusStorno = 0;
if (!$this->isOrderStatusValidToImport($order)) {
continue;
}
$statusXML = strtolower((string) $order->orderStatus);
// import cancelled order
if ($statusXML === 'canceled') {
$statuses = array_filter(array_keys($cfg['Order']['Status']['global']), fn ($x) => $x < 100);
$status = end($statuses);
$statusStorno = 1;
}
$totalPrice = (string) $order->totalPrice;
$totalPrice = toDecimal($totalPrice);
$deliveryType = $this->getDeliveryType($order);
$delivery_type = $order->shippingMethod.' - '.$order->paymentMethod;
$id_delivery = null;
if ($deliveryType) {
$delivery_type = $deliveryType->name;
$id_delivery = $deliveryType->id;
}
$customer = $order->customer;
$userData = $this->getUserData($order);
$country = $userData['invoice_country'];
// Dropship Expando flag
$flags = ['DSE'];
// pokud je OSS aktivni, tak nastavit flag
if ($this->vatContext->isCountryOssActive($country) && empty($userData['invoice_dic'])) {
$flags[] = 'OSS';
}
$data = [
'currency' => $currencyCode,
'currency_rate' => $currencies[$currencyCode]->getRate(),
'date_created' => $this->getOrderDateCreated($order)->format('Y-m-d H:i:s'),
'status' => $status,
'status_storno' => $statusStorno,
'total_price' => $totalPrice,
'id_delivery' => $id_delivery,
'delivery_type' => $delivery_type,
'flags' => implode(',', $flags),
'note_invoice' => (string) $order->orderId,
'note_admin' => json_encode(
[
'expando' => [
'orderId' => (string) $order->orderId,
'marketplace' => (string) $order->marketplace,
'country' => $customer->address->country->__toString(),
],
]
),
'source' => OrderInfo::ORDER_SOURCE_DROPSHIP,
];
// nastavim jazyk objednavky
if (findModule(\Modules::TRANSLATIONS)) {
$marketplaceSettings = $this->getMarketplaceConfiguration($order)['settings'] ?? [];
$data['id_language'] = !empty($marketplaceSettings['id_language']) ? $marketplaceSettings['id_language'] : Contexts::get(LanguageContext::class)->getDefaultId();
}
$data = array_merge(
$data,
$userData
);
$orderObj = sqlGetConnection()->transactional(function () use ($order, $data, $currencyInfo) {
$orderObj = $this->createDropshipOrder($order, $data);
$orderObj->logHistory('[Dropshipment] Marketplace: '.$order->marketplace);
// Vytvorit itemy objednavky
$lastItemTax = null;
foreach ($order->items->item as $item) {
if (!$this->isOrderItemValidToImport($item)) {
continue;
}
[$productID, $variationID] = $this->getProductByItem($item);
$itemTax = $this->getItemTax($item);
// pokud je zapnuta konverze do defaultni meny, tak nastavim i defaultni DPH
if ($this->isPriceConvertionEnabled()) {
$itemTax = getAdminVat()['value'];
}
$itemPrice = $this->convertPrice($this->getItemPiecePrice($item), $currencyInfo)->removeVat(toDecimal($itemTax));
$itemQuantity = toDecimal((string) $item->itemQuantity);
if ($variationID) {
$product = new \Variation($productID, $variationID);
} else {
$product = new \Product($productID);
}
// sell product
if ($productID) {
$product->createFromDB($productID);
$product->sell($variationID, $itemQuantity->asInteger());
}
$title = $product->title;
if (!empty($title) && $variationID) {
$title .= ' ('.$product->variationTitle.')';
}
$modifiedItem = $this->modifyItem([
'id_order' => $orderObj->id,
'id_product' => $productID,
'id_variation' => $variationID,
'pieces' => $itemQuantity,
'pieces_reserved' => $itemQuantity,
'piece_price' => $itemPrice,
'total_price' => $itemPrice->mul($itemQuantity),
'descr' => (!empty($title)) ? $title : (string) $item->itemName,
'tax' => $itemTax,
'note' => json_encode(['item_type' => OrderItemInfo::TYPE_PRODUCT]),
], $item);
$this->insertSQL('order_items', $modifiedItem);
$modifiedItem['id'] = sqlInsertId();
$this->itemCreatedEvent(
product: $product,
idVariation: (int) $variationID,
piecePrice: $itemPrice,
pieces: $itemQuantity->asInteger(),
data: [
'row' => $modifiedItem,
'items_table' => 'order_items',
],
order: $orderObj
);
$lastItemTax = $itemTax;
}
// Doprava a platba
$paymentPrice = toDecimal((string) $order->paymentPrice);
$shippingPrice = toDecimal((string) $order->shippingPrice);
$shippingTaxValue = toDecimal((string) $order->price->delivery->tax);
$shippingTax = $this->calculateItemTax($shippingPrice, $shippingTaxValue)->printFloatValue(-2);
if (!$shippingTax && $lastItemTax) {
$shippingTax = $lastItemTax;
}
// pokud je zapnuta konverze do defaultni meny, tak nastavim i defaultni DPH
if ($this->isPriceConvertionEnabled()) {
$shippingTax = toDecimal(getAdminVat()['value']);
}
$deliveryItemPrice = $shippingPrice->add($paymentPrice);
$deliveryItemPrice = $this->convertPrice($deliveryItemPrice, $currencyInfo)->removeVat($shippingTax);
$this->insertDeliveryItem(
[
'id_order' => $orderObj->id,
'id_product' => null,
'id_variation' => null,
'pieces' => 1,
'pieces_reserved' => 1,
'piece_price' => $deliveryItemPrice,
'total_price' => $deliveryItemPrice,
'descr' => 'Doprava a platba',
'tax' => $shippingTax,
'note' => json_encode(['item_type' => OrderItemInfo::TYPE_DELIVERY]),
]
);
$this->contextManager->activateOrder($orderObj, function () use ($orderObj) {
$orderObj->recalculate(round: false);
});
return $orderObj;
});
$this->modifyInsertedOrder($orderObj, $order);
}
}
public function out(array $config): void
{
if (isDevelopment()) {
return;
}
if (!($accessToken = $config['api_key'] ?? null)) {
$this->addActivityLog(
'V nastavení dropshipmentu chybí API klíč!',
$config
);
return;
}
$qb = $this->getBaseOutQueryBuilder();
foreach ($qb->execute() as $item) {
$order = new \Order();
$order->createFromDB($item['id']);
$balikobotData = json_decode($item['balikobot_data'] ?? '', true) ?? [];
$expandoData = $order->getData('expando');
[$carrier, $carrierName] = $this->getOutCarrier($order);
$params = [
'marketplaceOrderId' => $expandoData['orderId'],
'marketplace' => $this->getOutMarketplace($expandoData['marketplace'] ?? ''),
'shipDate' => $order->date_handle ? $order->date_handle->format('c') : (new \DateTime())->format('c'),
'status' => 'Shipped',
'carrier' => $carrier,
'carrierName' => $carrierName,
];
if ($order->package_id) {
$params['trackingNumber'] = $order->package_id;
$params['trackingUrl'] = $this->getTrackingUrl($order, $balikobotData);
}
try {
$this->sendFulfillment($accessToken, $params);
$expandoData['fulfillmentSent'] = true;
$order->setData('expando', $expandoData);
$order->logHistory('Objednávka v Expandu byla aktualizována na vyřízenou');
} catch (TransferException $e) {
addActivityLog(ActivityLog::SEVERITY_ERROR, ActivityLog::TYPE_SYNC, $e->getMessage(), $params);
}
}
}
protected function loadExistsOrdersCache(array $orders): void
{
$ids = array_map(function ($x) {
return (string) $x->orderId;
}, $orders);
$qb = sqlQueryBuilder()
->select('id_order AS id, id_external AS expandoId')
->from('order_dropshipment')
->sendToMaster()
->where(Operator::andX(
Operator::inStringArray($ids, 'id_external'),
Operator::equals([
'id_dropshipment' => $this->dropshipment['id'],
])
));
$this->existsOrdersCache = Mapping::mapKeys($qb->execute()->fetchAllAssociative(), function ($k, $v) {
return [$v['expandoId'], $v['id']];
});
}
protected function isDropshipOrderValidToImport(\SimpleXMLElement $xml): bool
{
return parent::isDropshipOrderValidToImport($xml) && $this->isValidToImport($xml);
}
protected function isOrderItemValidToImport(\SimpleXMLElement $item): bool
{
return true;
}
protected function isValidToImport(\SimpleXMLElement $order): bool
{
// pokud objednavka uz existuje, tak vracim false, protoze ji nechci importovat znovu
if ($this->existsOrdersCache[(string) $order->orderId] ?? false) {
return false;
}
return true;
}
protected function getExternalData(\SimpleXMLElement $xml): array
{
return [
(string) $xml->orderId,
[
'marketplace' => (string) $xml->marketplace,
'country' => $xml->customer->address->country->__toString(),
],
];
}
protected function getOrderDateCreated(\SimpleXMLElement $order): \DateTimeInterface
{
try {
$date = new \DateTime((string) $order->purchaseDate, new \DateTimeZone('UTC'));
$date->setTimezone(new \DateTimeZone('Europe/Prague'));
} catch (\Exception) {
$date = new \DateTime();
}
return $date;
}
protected function getCurrency(\SimpleXMLElement $order): ?string
{
$currencyCode = (string) $order->currencyCode;
if (empty($currencyCode)) {
$currencyCode = 'EUR';
}
return $currencyCode;
}
protected function getDeliveryType(\SimpleXMLElement $orderItem): ?\DeliveryType
{
return $this->getDeliveryTypeByConfiguration($orderItem);
}
protected function isOrderStatusValidToImport(\SimpleXMLElement $order): bool
{
$statusXML = strtolower((string) $order->orderStatus);
$importStatuses = $this->getConfiguration()['import_statuses'] ?? [];
// default behaviour if config is not set
if (empty($importStatuses)) {
return !in_array($statusXML, $this->getIgnoredStatuses());
}
return in_array($statusXML, $importStatuses);
}
protected function getIgnoredStatuses(): array
{
return [
'canceled',
'pending',
'shipped',
];
}
protected function getItemPiecePrice(\SimpleXMLElement $item): \Decimal
{
return toDecimal((string) $item->itemPrice);
}
protected function getItemTax(\SimpleXMLElement $item): float
{
$line = $item->lineItemPrice;
$priceWithTax = toDecimal((string) $line->withTax);
$taxValue = toDecimal((string) $line->tax);
return $this->calculateItemTax($priceWithTax, $taxValue)->printFloatValue(-2);
}
protected function calculateItemTax(\Decimal $priceWithTax, \Decimal $taxValue): \Decimal
{
if ($taxValue->isZero()) {
return toDecimal(0);
}
$priceWithoutTax = $priceWithTax->sub($taxValue);
return $priceWithTax->div($priceWithoutTax)->mul(\DecimalConstants::hundred())->sub(\DecimalConstants::hundred())->round();
}
protected function getProductByItem(\SimpleXMLElement $item): array
{
[$productId, $variationId] = $this->findProduct((string) $item->itemId);
if (empty($productId) && empty($variationId)) {
return $this->getProductByCode((string) $item->itemId, null);
}
return [$productId, $variationId];
}
protected function getUserData(\SimpleXMLElement $order): array
{
$customer = $order->customer;
$country = $customer->address->country->__toString();
// validate country
if (!isset($this->countryContext->getAll()[$country])) {
$country = '';
}
return [
'invoice_name' => (string) $customer->firstname,
'invoice_surname' => (string) $customer->surname,
'invoice_email' => (string) $customer->email,
'invoice_phone' => (string) $customer->phone,
'invoice_street' => $this->prepareAddressStreet($customer->address),
'invoice_city' => (string) $customer->address->city,
'invoice_zip' => (string) $customer->address->zip,
'invoice_country' => $country,
'invoice_custom_address' => (string) $customer->address->address3,
'invoice_state' => (string) $customer->address->stateOrRegion,
'invoice_dic' => (string) $customer->taxId,
'delivery_name' => (string) $customer->firstname,
'delivery_surname' => (string) $customer->surname,
'delivery_street' => $this->prepareAddressStreet($customer->address),
'delivery_city' => (string) $customer->address->city,
'delivery_zip' => (string) $customer->address->zip,
'delivery_country' => $country,
'delivery_custom_address' => (string) $customer->address->address3,
'delivery_state' => (string) $customer->address->stateOrRegion,
];
}
protected function findProduct(string $itemId): array
{
$codes = explode('_', $itemId);
$productCode = $codes[0] ?? null;
$variationCode = $codes[1] ?? null;
if ($variationCode) {
$variation = sqlQueryBuilder()
->select('id, id_product')
->from('products_variations')
->where(Operator::equals(['code' => $variationCode]))
->execute()->fetch();
if ($variation) {
return [(int) $variation['id_product'], (int) $variation['id']];
}
$variation = sqlQueryBuilder()
->select('id, id_product')
->from('products_variations')
->where(Operator::equals(['id' => $variationCode]))
->execute()->fetch();
if ($variation) {
return [(int) $variation['id_product'], (int) $variation['id']];
}
}
if ($productCode) {
$productId = sqlQueryBuilder()
->select('id')
->from('products')
->where(Operator::equals(['code' => $productCode]))
->execute()->fetchColumn();
if ($productId) {
return [(int) $productId, null];
}
$productId = sqlQueryBuilder()
->select('id')
->from('products')
->where(Operator::equals(['id' => $productCode]))
->execute()->fetchColumn();
if ($productId) {
return [(int) $productId, null];
}
}
return [null, null];
}
protected function insertDeliveryItem(array $item): void
{
$this->insertSQL('order_items', $item);
}
protected function getTrackingUrl(\Order $order, array $balikobotData): ?string
{
if (!($trackingUrl = ($balikobotData['response'][0]['track_url'] ?? false))) {
if ($deliveryType = $order->getDeliveryType()) {
if ($delivery = $deliveryType->getDelivery()) {
$trackingUrl = $delivery->getTrackAndTraceLink($order->package_id, $order);
}
}
}
if (!$trackingUrl) {
return null;
}
return $trackingUrl;
}
protected function getOutCarrier(\Order $order): array
{
$marketplace = $order->getData('expando')['marketplace'] ?? null;
if ($deliveryType = $order->getDeliveryType()) {
if ($delivery = $deliveryType->getDelivery()) {
if ($delivery instanceof \DHL) {
return ['DHL', null];
} elseif ($delivery instanceof \DPD) {
return ['DPD', null];
} elseif ($delivery instanceof \GLS) {
return ['GLS', null];
} elseif ($delivery instanceof \PPL) {
// Amazon nezna PPL, takze to musim poslat jako "Other"
if (strpos(mb_strtolower($marketplace), 'amazon') !== false) {
return ['Other', 'PPL'];
}
return ['PPL', null];
} elseif ($delivery instanceof \Fedex) {
return ['FedEx', null];
} elseif ($delivery instanceof \UPS) {
return ['UPS', 'Standard'];
}
}
}
return ['Other', $order->getDeliveryType()->delivery ?? ''];
}
private function prepareAddressStreet(\SimpleXMLElement $address): string
{
$street = [(string) $address->address1, (string) $address->address2];
$street = implode(', ', array_filter($street));
return $street;
}
private function getOutMarketplace(string $marketplace): string
{
return preg_replace('/\s+/', '_', mb_strtolower($marketplace));
}
private function sendFulfillment(string $accessToken, array $params): void
{
$ch = curl_init();
$this->logger->notice(sprintf('EXPANDO REQUEST: ', static::getType()),
[
'Data' => $params,
'Type' => static::getType(),
]);
curl_setopt($ch, CURLOPT_URL, self::URL_FULFILLMENT);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type:application/json', 'Authorization: Bearer '.$accessToken]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($params));
curl_setopt($ch, CURLOPT_POST, true);
$result = curl_exec($ch);
$this->logger->notice(sprintf('EXPANDO RESPONSE: ', static::getType()),
[
'data' => (array) $result,
'Type' => static::getType(),
]);
curl_close($ch);
$result = json_decode($result, true);
if ($result['message'] ?? null) {
if (StringUtil::startsWith($result['message'], 'ValidationError')) {
throw new TransferException(
sprintf('Send fulfillment failed with message "%s"', $result['message'])
);
}
}
}
protected function getMarketplaceConfiguration(\SimpleXMLElement $order): array
{
// konfigurace dropshipmentu
$configuration = $this->getConfiguration();
// budu hledat konfiguraci podle marketplacu
$orderMarketplace = $order->marketplace->__toString();
$marketplaceConfiguration = null;
$default = null;
foreach ($configuration['marketplaces'] ?? [] as $marketplace) {
// podle nazvu zkusim najit konfiguraci pro dany marketplace
if (StringUtil::slugify($marketplace['name']) === StringUtil::slugify($orderMarketplace)) {
$marketplaceConfiguration = $marketplace;
break;
}
if (empty($marketplace['name'])) {
$default = $marketplace;
}
}
if (!$marketplaceConfiguration && $default) {
$marketplaceConfiguration = $default;
}
return $marketplaceConfiguration ?: [];
}
protected function getDeliveryTypeByConfiguration(\SimpleXMLElement $order): ?\DeliveryType
{
$config = $this->getMarketplaceConfiguration($order);
$deliveryId = null;
// najdu ID dopravy
$country = $order->customer->address->country->__toString();
foreach ($config['deliveries'] ?? [] as $item) {
if (empty($item['country']) || $item['country'] == $country) {
$deliveryId = (int) $item['id_delivery'];
break;
}
}
// nactu ID platby
$paymentId = empty($config['payments']['default']) ? null : (int) $config['payments']['default'];
return $this->findDeliveryType($deliveryId, $paymentId);
}
protected function updateDropshipOrder(\Order $order, \SimpleXMLElement $xml): void
{
$updateStatuses = $this->configuration['update'] ?? [];
$currentStatusXml = strtolower((string) $xml->orderStatus);
// pokud je povoleny update stavu
if (!in_array($currentStatusXml, $updateStatuses)) {
return;
}
// storno objednavky
if ($currentStatusXml === 'canceled') {
if (!$order->status_storno) {
$order->storno(false, '[Expando] Storno objednávky z Expanda', false);
}
return;
}
if ($currentStatusXml !== 'shipped') {
return;
}
// update stavu po expedici
$dic = (string) $xml->customer->taxId;
if ($dic != $order->invoice_dic) {
$this->updateSQL('orders', ['invoice_dic' => $dic], ['id' => $order->id]);
if ($this->vatContext->isCountryOssActive($order->invoice_country)) {
if (empty($dic)) {
$this->orderUtil->addFlag($order, 'OSS');
} else {
$this->orderUtil->removeFlag($order, 'OSS');
$this->orderUtil->removeVat($order, 0, false, true); // $keepFinalPrice = true
}
}
}
}
protected function getBaseOutQueryBuilder(): QueryBuilder
{
$qb = sqlQueryBuilder()
->select('o.id')
->from('orders', 'o')
->where(
Operator::not(
Operator::equalsNullable([JsonOperator::value('o.note_admin', 'expando.orderId') => null])
)
)
->andWhere(
Operator::equalsNullable([JsonOperator::value('o.note_admin', 'expando.fulfillmentSent') => null])
)
->andWhere('o.status_storno = 0')
->andWhere(Operator::inIntArray(getStatuses('handled'), 'o.status'))
->andWhere('DATEDIFF(NOW(), o.date_handle) <= 30') // ne starsi nez 30 dni
->groupBy('o.id');
if (findModule(\Modules::BALIKONOS, 'provider') === 'balikobot') {
$qb->addSelect('b.data as balikobot_data')
->leftJoin('o', 'balikonos', 'b', 'b.id_order = o.id');
}
return $qb;
}
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;
}
}