diff --git a/.all-contributorsrc b/.all-contributorsrc index 517ed43c37f..f1b26e48e1a 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -1476,6 +1476,15 @@ "code" ] }, + { + "login": "xqiu", + "name": "Xinyang Qiu", + "avatar_url": "https://avatars.githubusercontent.com/u/1487053?v=4", + "profile": "https://github.com/xqiu", + "contributions": [ + "code" + ] + }, { "login": "bucha", "name": "Alexander Buch", diff --git a/README.md b/README.md index 166130100e8..c8a8d685e0e 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
Christoph Massmann

Rob Agnese

Alexander Buch
+
Xinyang Qiu
diff --git a/app/code/core/Mage/Usa/Model/Shipping/Carrier/Fedex.php b/app/code/core/Mage/Usa/Model/Shipping/Carrier/Fedex.php index 1912f9f3307..2519d33dc49 100644 --- a/app/code/core/Mage/Usa/Model/Shipping/Carrier/Fedex.php +++ b/app/code/core/Mage/Usa/Model/Shipping/Carrier/Fedex.php @@ -14,6 +14,19 @@ * @license https://opensource.org/licenses/osl-3.0.php Open Software License (OSL 3.0) */ + use ShipStream\FedEx\FedEx; + use ShipStream\FedEx\Api\RatesAndTransitTimesV1\Dto\FullSchemaQuoteRate; + use ShipStream\FedEx\Api\RatesAndTransitTimesV1\Dto\AccountNumber; + use ShipStream\FedEx\Api\RatesAndTransitTimesV1\Dto\RequestedShipment; + use ShipStream\FedEx\Api\RatesAndTransitTimesV1\Dto\RateParty; + use ShipStream\FedEx\Api\RatesAndTransitTimesV1\Dto\Address; + use ShipStream\FedEx\Api\RatesAndTransitTimesV1\Dto\RequestedPackageLineItem; + use ShipStream\FedEx\Api\RatesAndTransitTimesV1\Dto\Weight; + use ShipStream\FedEx\Enums\Endpoint; + use ShipStream\FedEx\Api\TrackV1\Dto\FullSchemaTrackingNumbers; + use ShipStream\FedEx\Api\TrackV1\Dto\TrackingInfo; + use ShipStream\FedEx\Api\TrackV1\Dto\TrackingNumberInfo; + /** * Fedex shipping implementation * @@ -64,6 +77,13 @@ class Mage_Usa_Model_Shipping_Carrier_Fedex extends Mage_Usa_Model_Shipping_Carr */ protected $_rawRequest = null; + /** + * REST API request data + * + * @var Varien_Object|null + */ + protected $_fedexRestRequestData = null; + /** * Rate result data * @@ -167,9 +187,14 @@ public function collectRates(Mage_Shipping_Model_Rate_Request $request) if (!$this->getConfigFlag($this->_activeFlag)) { return false; } - $this->setRequest($request); - - $this->_getQuotes(); + + if ($this->getConfigData('use_rest_api')) { + $this->setRestApiRequest($request); + $this->_getRestApiQuotes(); + } else { + $this->setRequest($request); + $this->_getQuotes(); + } $this->_updateFreeMethodQuote($request); @@ -779,6 +804,8 @@ public function getCode($type, $code = '') 'STANDARD_OVERNIGHT' => Mage::helper('usa')->__('Standard Overnight'), 'FEDEX_FREIGHT' => Mage::helper('usa')->__('Freight'), 'FEDEX_NATIONAL_FREIGHT' => Mage::helper('usa')->__('National Freight'), + 'FEDEX_INTERNATIONAL_ECONOMY' => Mage::helper('usa')->__('International Economy'), + 'FEDEX_INTERNATIONAL_PRIORITY' => Mage::helper('usa')->__('International Priority'), ], 'dropoff' => [ 'REGULAR_PICKUP' => Mage::helper('usa')->__('Regular Pickup'), @@ -815,9 +842,10 @@ public function getCode($type, $code = '') 'INTERNATIONAL_FIRST', 'INTERNATIONAL_ECONOMY', 'INTERNATIONAL_PRIORITY', - ], - ], - ], + 'FEDEX_INTERNATIONAL_PRIORITY', + ] + ] + ] ], [ 'containers' => ['FEDEX_BOX', 'FEDEX_TUBE'], @@ -841,16 +869,22 @@ public function getCode($type, $code = '') 'INTERNATIONAL_FIRST', 'INTERNATIONAL_ECONOMY', 'INTERNATIONAL_PRIORITY', - ], - ], - ], + 'FEDEX_INTERNATIONAL_PRIORITY', + ] + ] + ] ], [ 'containers' => ['FEDEX_10KG_BOX', 'FEDEX_25KG_BOX'], 'filters' => [ 'within_us' => [], - 'from_us' => ['method' => ['INTERNATIONAL_PRIORITY']], - ], + 'from_us' => [ + 'method' => [ + 'INTERNATIONAL_PRIORITY', + 'FEDEX_INTERNATIONAL_PRIORITY' + ] + ] + ] ], [ 'containers' => ['YOUR_PACKAGING'], @@ -886,10 +920,11 @@ public function getCode($type, $code = '') 'FEDEX_NATIONAL_FREIGHT', 'INTERNATIONAL_ECONOMY_FREIGHT', 'INTERNATIONAL_PRIORITY_FREIGHT', - ], - ], - ], - ], + 'FEDEX_INTERNATIONAL_PRIORITY' + ] + ] + ] + ] ], 'delivery_confirmation_types' => [ @@ -950,14 +985,21 @@ public function getCurrencyCode() */ public function getTracking($trackings) { - $this->setTrackingReqeust(); + if (!$this->getConfigData('use_rest_api')) { + $this->setTrackingReqeust(); + } if (!is_array($trackings)) { $trackings = [$trackings]; } foreach ($trackings as $tracking) { - $this->_getXMLTracking($tracking); + if ($this->getConfigData('use_rest_api')) { + $this->_getRestApiTracking($tracking); + } + else{ + $this->_getXMLTracking($tracking); + } } return $this->_result; @@ -1605,4 +1647,369 @@ public function getDeliveryConfirmationTypes(?Varien_Object $params = null) { return $this->getCode('delivery_confirmation_types'); } + + /** + * Prepare and set request to this instance + * + * @param Mage_Shipping_Model_Rate_Request $request + * @return $this + */ + public function setRestApiRequest(Mage_Shipping_Model_Rate_Request $request) + { + $this->_request = $request; + + // Step 1: Create an AccountNumber object + $accountNumber = new AccountNumber(value: $this->getConfigData('account')); + + // Step 2: Create a RequestedShipment object with shipment details + $requestedShipment = new RequestedShipment( + shipper: new RateParty( + address : new Address( + countryCode: Mage::getModel('directory/country')->load( + Mage::getStoreConfig( + Mage_Shipping_Model_Shipping::XML_PATH_STORE_COUNTRY_ID, + $request->getStoreId() + ) + )->getIso2Code(), + postalCode: Mage::getStoreConfig( + Mage_Shipping_Model_Shipping::XML_PATH_STORE_ZIP, + $request->getStoreId() + ) + )), + recipient: new RateParty( + address : new Address( + countryCode: Mage::getModel('directory/country')->load($request->getDestCountryId())->getIso2Code(), + postalCode: $request->getDestPostcode(), + )), + pickupType: 'CONTACT_FEDEX_TO_SCHEDULE', + requestedPackageLineItems: [ + new RequestedPackageLineItem( + weight: new Weight( + units: $this->getConfigData('unit_of_measure'), + value: $this->getTotalNumOfBoxes($request->getPackageWeight()), + )), + ], + rateRequestType: ['ACCOUNT'] //['LIST', 'ACCOUNT'] + ); + + // Step 3: Construct the FullSchemaQuoteRate object + $rateRequest = new FullSchemaQuoteRate( + accountNumber: $accountNumber, + requestedShipment: $requestedShipment, + ); + + $this->_fedexRestRequestData = $rateRequest; + + return $this; + } + + /** + * Do remote request for and handle errors + * + * @return Mage_Shipping_Model_Rate_Result + */ + protected function _getRestApiQuotes() + { + $this->_result = Mage::getModel('shipping/rate_result'); + + // Initialize FedEx SDK connector + $connector = new FedEx( + clientId: $this->getConfigData('key'), + clientSecret: $this->getConfigData('password'), + endpoint: $this->getConfigFlag('sandbox_mode') ? Endpoint::SANDBOX : Endpoint::PROD + ); + + // need to retry 10 times if the request is failed, each time, set shipDatestamp to the next day, format is YYYY-MM-DD + $shipDatestamp = date('Y-m-d'); + $maxRetries = 10; + $attempt = 0; + while ($attempt < $maxRetries) { + try { + // Get the Rates and Transit Times API instance + $api = $connector->ratesTransitTimesV1(); + + // Create a FullSchemaQuoteRate request using the data prepared in setRequest + //$rateRequest = new FullSchemaQuoteRate(); + + $this->_debug('Quote request:'); + $this->_debug($this->_fedexRestRequestData); + + if($attempt > 0){ + $shipDatestamp = date('Y-m-d', strtotime($shipDatestamp . ' +1 day')); + $this->_fedexRestRequestData->requestedShipment->shipDateStamp = $shipDatestamp; + } + + // Perform the API call + $response = $api->rateAndTransitTimes($this->_fedexRestRequestData); + + // Process the response and convert it to Magento rate result format + $this->_prepareRestApiRateResponse($response); + break; // Exit loop on success + } catch (Exception $e) { + $attempt++; + if ($attempt >= $maxRetries) { + if (method_exists($e, 'getResponse') && $e->getResponse()) { + $this->_debug($e->getResponse()->body()); + Mage::logException(new Exception(print_r($this->_fedexRestRequestData, true) . ' returns ' . print_r($e->getResponse()->body(), true))); + } else { + Mage::logException($e); + } + $error = Mage::getModel('shipping/rate_result_error'); + $error->setCarrier($this->_code); + $error->setCarrierTitle($this->getConfigData('title')); + $error->setErrorMessage($this->getConfigData('specificerrmsg')); + $this->_result->append($error); + } else { + // Optional: wait before retrying + sleep(1); + } + } + } + } + + /** + * Return FeDex currency ISO code by Magento Base Currency Code + * + * @return string 3-digit currency code + */ + public function getOpenmageCurrencyCodeFromFedexCurrencyCode($currencyCode) + { + $codes = [ + 'RDD' => 'DOP', + 'ECD' => 'XCD', + 'ARN' => 'ARS', + 'SID' => 'SGD', + 'WON' => 'KRW', + 'JAD' => 'JMD', + 'SFR' => 'CHF', + 'JYE' => 'JPY', + 'KUD' => 'KWD', + 'UKL' => 'GBP', + 'DHS' => 'AED', + 'NMP' => 'MXN', + 'UYP' => 'UYU', + 'CHP' => 'CLP', + 'NTD' => 'TWD', + ]; + return $codes[$currencyCode] ?? $currencyCode; + } + + /** + * Prepare shipping rate result based on response + * + * @param mixed $response + * @return Mage_Shipping_Model_Rate_Result + */ + protected function _prepareRestApiRateResponse($response) + { + $rateResponseDetails = $response->dto()->output->rateReplyDetails ?? []; + + if (empty($rateResponseDetails)) { + $error = Mage::getModel('shipping/rate_result_error'); + $error->setCarrier($this->_code); + $error->setCarrierTitle($this->getConfigData('title')); + $error->setErrorMessage($this->getConfigData('specificerrmsg')); + $this->_result->append($error); + return; + } + + $this->_debug($rateResponseDetails); + + foreach ($rateResponseDetails as $rateDetail) { + $serviceType = $rateDetail->serviceType ?? ''; + //remove FEDEX_ from serviceType and assign to secondType so that it's compatible with the mangento 1.9x web service allowed methods + $secondType = str_replace('FEDEX_', '', $serviceType); + + if (!array_key_exists($serviceType, $this->getAllowedMethods()) && !array_key_exists($secondType, $this->getAllowedMethods())) { + continue; + } + + $rate = $rateDetail->ratedShipmentDetails[0]->totalNetCharge; + + // if the currency is different with the store currency, calculate using the exchange rate + $currencyCode = (string)$rateDetail->ratedShipmentDetails[0]->shipmentRateDetail->currency; + $currencyCode = $this->getOpenmageCurrencyCodeFromFedexCurrencyCode($currencyCode); + $storeCurrencyCode = Mage::app()->getStore()->getBaseCurrencyCode(); + if($storeCurrencyCode != $currencyCode){ + $currencyStore = Mage::getModel('directory/currency')->load($storeCurrencyCode); + $currencyCurrent = Mage::getModel('directory/currency')->load($currencyCode); + $rate = round($rate / $currencyStore->getRate($currencyCurrent)); + } + + $method = Mage::getModel('shipping/rate_result_method'); + $method->setCarrier($this->_code); + $method->setCarrierTitle($this->getConfigData('title')); + $method->setMethod($serviceType); + $method->setMethodTitle($this->getCode('method', $serviceType)); + + $method->setCost($rate); + $method->setPrice($this->getFinalPriceWithHandlingFee($rate)); + + $this->_result->append($method); + } + } + + /** + * Send REST API request for tracking + * + * @param array $tracking + */ + protected function _getRestApiTracking($tracking) + { + $this->_result = Mage::getModel('shipping/tracking_result'); + + // Initialize FedEx SDK connector + $connector = new FedEx( + clientId: $this->getConfigData('rest_track_key'), + clientSecret: $this->getConfigData('rest_track_secrete'), + endpoint: $this->getConfigFlag('sandbox_mode') ? Endpoint::SANDBOX : Endpoint::PROD + ); + + try { + // Create a TrackingRequest instance + $trackRequest = new FullSchemaTrackingNumbers( + includeDetailedScans: true, + trackingInfo: [ + new TrackingInfo( + trackingNumberInfo: new TrackingNumberInfo(trackingNumber: $tracking), + ) + ], + ); + + $this->_debug($trackRequest); + + // Send tracking request + $api = $connector->trackV1(); + $response = $api->trackByTrackingNumber($trackRequest); + + // Parse the response and convert it to tracking result format + $this->_parseRestApiTrackingResponse($tracking, $response); + } catch (Exception $e) { + $error = Mage::getModel('shipping/tracking_result_error'); + $error->setCarrier($this->_code); + $error->setCarrierTitle($this->getConfigData('title')); + $error->setTracking($tracking); + $error->setErrorMessage(Mage::helper('usa')->__('Unable to retrieve tracking')); + $this->_result->append($error); + } + } + + /** + * Parse tracking response + * + * @param array $trackingValue + * @param stdClass $response + */ + protected function _parseRestApiTrackingResponse($trackingValue, $response) + { + $this->_debug($response->body()); + + $trackInfo = $response->dto()->output->completeTrackResults[0]->trackResults[0] ?? null; + + if (!$trackInfo || !isset($trackInfo->latestStatusDetail)) { + $error = Mage::getModel('shipping/tracking_result_error'); + $error->setCarrier('fedex'); + $error->setCarrierTitle($this->getConfigData('title')); + $error->setTracking($trackingValue); + $error->setErrorMessage(Mage::helper('usa')->__('Unable to retrieve tracking')); + $this->_result->append($error); + return; + } + + $resultArray = []; + $resultArray['status'] = (string)$trackInfo->latestStatusDetail->description; + $resultArray['service'] = (string)$trackInfo->serviceDetail->description; + + // Handle delivery date and time + if($trackInfo->dateAndTimes){ + if($trackInfo->dateAndTimes[0]->type == 'ACTUAL_DELIVERY' || $trackInfo->dateAndTimes[0]->type == 'ACTUAL_PICKUP' || + $trackInfo->dateAndTimes[0]->type == 'ESTIMATED_DELIVERY' || $trackInfo->dateAndTimes[0]->type == 'ESTIMATED_DELIVERY') { + $timestamp = strtotime((string) $trackInfo->dateAndTimes[0]->dateTime); + $resultArray['deliverydate'] = date('Y-m-d', $timestamp); + $resultArray['deliverytime'] = date('H:i:s', $timestamp); + } + + if($trackInfo->dateAndTimes[0]->type == 'ACTUAL_DELIVERY' || $trackInfo->dateAndTimes[0]->type == 'ACTUAL_PICKUP') { + $timestamp = strtotime((string) $trackInfo->dateAndTimes[0]->dateTime); + $resultArray['shippeddate'] = date('Y-m-d', $timestamp); + } + } + + // Handle delivery location + if (isset($trackInfo->lastUpdatedDestinationAddress)) { + $deliveryLocation = $trackInfo->lastUpdatedDestinationAddress; + $deliveryLocationArray = []; + if (isset($deliveryLocation->city)) { + $deliveryLocationArray[] = (string)$deliveryLocation->city; + } + if (isset($deliveryLocation->stateOrProvinceCode)) { + $deliveryLocationArray[] = (string)$deliveryLocation->stateOrProvinceCode; + } + if (isset($deliveryLocation->countryCode)) { + $deliveryLocationArray[] = (string)$deliveryLocation->countryCode; + } + if ($deliveryLocationArray) { + $resultArray['deliverylocation'] = implode(', ', $deliveryLocationArray); + } + } + + if(isset($trackInfo->deliveryDetails)) { + $resultArray['signedby'] = (string)($trackInfo->deliveryDetails->signedByName ?? ''); + } + + if (isset($trackInfo->packageDetails->weightAndDimensions->weight[0]->value) && isset($trackInfo->packageDetails->weightAndDimensions->weight[0]->unit)) { + $resultArray['weight'] = "{$trackInfo->packageDetails->weightAndDimensions->weight[0]->value} {$trackInfo->packageDetails->weightAndDimensions->weight[0]->unit}"; + } + + // Track package progress + $packageProgress = []; + foreach ($trackInfo->scanEvents ?? [] as $event) { + $tempArray = []; + $tempArray['activity'] = (string)$event->derivedStatus; + $timestamp = strtotime((string)$event->date); + if ($timestamp) { + $tempArray['deliverydate'] = date('Y-m-d', $timestamp); + $tempArray['deliverytime'] = date('H:i:s', $timestamp); + } + if (isset($event->scanLocation)) { + $addressArray = []; + if (isset($event->scanLocation->city)) { + $addressArray[] = (string)$event->scanLocation->city; + } + if (isset($event->scanLocation->stateOrProvinceCode)) { + $addressArray[] = (string)$event->scanLocation->stateOrProvinceCode; + } + if (isset($event->scanLocation->countryCode)) { + $addressArray[] = (string)$event->scanLocation->countryCode; + } + if ($addressArray) { + $tempArray['deliverylocation'] = implode(', ', $addressArray); + } + } + $packageProgress[] = $tempArray; + } + $resultArray['progressdetail'] = $packageProgress; + + // Prepare tracking result + if (!$this->_result) { + $this->_result = Mage::getModel('shipping/tracking_result'); + } + + if (isset($resultArray)) { + $tracking = Mage::getModel('shipping/tracking_result_status'); + $tracking->setCarrier('fedex'); + $tracking->setCarrierTitle($this->getConfigData('title')); + $tracking->setTracking($trackingValue); + $tracking->addData($resultArray); + $this->_result->append($tracking); + } else { + $error = Mage::getModel('shipping/tracking_result_error'); + $error->setCarrier('fedex'); + $error->setCarrierTitle($this->getConfigData('title')); + $error->setTracking($trackingValue); + $error->setErrorMessage(Mage::helper('usa')->__('Unable to retrieve tracking')); + $this->_result->append($error); + } + } + } diff --git a/app/code/core/Mage/Usa/etc/config.xml b/app/code/core/Mage/Usa/etc/config.xml index 6925a94e3ab..314ff0a32eb 100644 --- a/app/code/core/Mage/Usa/etc/config.xml +++ b/app/code/core/Mage/Usa/etc/config.xml @@ -126,6 +126,8 @@ + + 0 0 0 diff --git a/app/code/core/Mage/Usa/etc/system.xml b/app/code/core/Mage/Usa/etc/system.xml index 2a2dcbf859e..ec340e23163 100644 --- a/app/code/core/Mage/Usa/etc/system.xml +++ b/app/code/core/Mage/Usa/etc/system.xml @@ -428,6 +428,35 @@ 1 0 + + + select + adminhtml/system_config_source_yesno + 15 + 1 + 1 + 0 + + + + obscure + adminhtml/system_config_backend_encrypted + 17 + 1 + 1 + 0 + Fedex RestAPI need a seperate project for tracking, it's different with project that collect rate. + + + + obscure + adminhtml/system_config_backend_encrypted + 18 + 1 + 1 + 0 + Fedex RestAPI need a seperate project for tracking, it's different with project that collect rate. + <label>Title</label> <sort_order>20</sort_order> @@ -456,6 +485,7 @@ <show_in_website>1</show_in_website> <show_in_store>0</show_in_store> <depends><active>1</active></depends> + <comment>WebService Meter Number. Not used in Fedex RestAPI</comment> </meter_number> <key translate="label"> <label>Key</label> @@ -466,6 +496,7 @@ <show_in_website>1</show_in_website> <show_in_store>0</show_in_store> <depends><active>1</active></depends> + <comment>WebService key. For Fedex RestAPI, this is the API Key</comment> </key> <password translate="label"> <label>Password</label> @@ -476,6 +507,7 @@ <show_in_website>1</show_in_website> <show_in_store>0</show_in_store> <depends><active>1</active></depends> + <comment>WebService Password. For Fedex RestAPI, this is the Secrete Key</comment> </password> <sandbox_mode translate="label"> <label>Sandbox Mode</label> diff --git a/composer.json b/composer.json index 46490762b88..07176dc9f41 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,7 @@ "phpseclib/mcrypt_compat": "^2.0.3", "phpseclib/phpseclib": "^3.0.14", "shardj/zf1-future": "^1.24.1", + "shipstream/fedex-rest-sdk": "^1.1.2", "symfony/polyfill-php74": "^1.31", "symfony/polyfill-php80": "^1.31", "symfony/polyfill-php81": "^1.31", @@ -138,7 +139,7 @@ "openmage/composer-plugin": true }, "platform": { - "php": "7.4" + "php": "8.1.28" }, "sort-packages": true },