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; } }