Preface: This is meant to serve as both a written observation of Magento's architecture for the community (and myself), as well as an actual question. We're working with a heavily modified cart and checkout experience, but the root of this problem is within Magento's core logic.
Background
We've created a free shipping coupon using the standard shopping cart price rules functionality. There are no conditions on the coupon, and the only action is that Free Shipping
is set to For matching items only
. Since there are no conditions, this will set free_shipping
to 1
for all of the sales quote items.
As is customary, we've also enabled the Free Shipping shipping method. The Freeshipping
carrier model will provide rates whenever the request has free shipping or the subtotal matches or exceeds the threshold (but we're not using the threshold option). See Mage_Shipping_Model_Carrier_Freeshipping::collectRates
:
$this->_updateFreeMethodQuote($request);
if (($request->getFreeShipping()) // <-- This is the condition we're relying on
|| ($request->getBaseSubtotalInclTax() >=
$this->getConfigData('free_shipping_subtotal'))
) {
/* Snip: Add $0.00 method to the result */
}
And Mage_Shipping_Model_Carrier_Freeshipping::_updateFreeMethodQuote
looks like this:
protected function _updateFreeMethodQuote($request)
{
$freeShipping = false;
$items = $request->getAllItems();
$c = count($items);
for ($i = 0; $i < $c; $i++) {
if ($items[$i]->getProduct() instanceof Mage_Catalog_Model_Product) {
if ($items[$i]->getFreeShipping()) {
$freeShipping = true;
} else {
return;
}
}
}
if ($freeShipping) {
$request->setFreeShipping(true);
}
}
So, as long as all of the items have free_shipping
set to a truthy value (which they will, due to the coupon), we should get free shipping. And we do!
The problem
However, there's a major side effect: any shipping methods that rely on an item's row_weight
(as is the case with our customized version of the FedEx carrier) will fail to calculate proper shipping rates because each item's row_weight
is set to 0
when free shipping is active.
Interestingly enough, none of Magento's default shipping carriers actually rely on row_weight
, but we'll get to that after we figure out why/when row_weight
is set to 0
.
Figuring out why row_weight
is set to 0
This part was actually pretty easy to dig up. A big chunk of the shipping calculations happen in Mage_Sales_Model_Quote_Address_Total_Shipping::collect
, including setting row_weight
to 0
:
public function collect(Mage_Sales_Model_Quote_Address $address)
{
parent::collect($address);
foreach ($items as $item) {
/* Snip: Handling virtual items and parent items */
if ($item->getHasChildren() && $item->isShipSeparately()) {
/* Snip: Handling items with children */
}
else {
if (!$item->getProduct()->isVirtual()) {
$addressQty += $item->getQty();
}
$itemWeight = $item->getWeight();
$rowWeight = $itemWeight*$item->getQty();
$addressWeight+= $rowWeight;
if ($freeAddress || $item->getFreeShipping()===true) {
$rowWeight = 0;
} elseif (is_numeric($item->getFreeShipping())) {
$freeQty = $item->getFreeShipping();
if ($item->getQty()>$freeQty) {
$rowWeight = $itemWeight*($item->getQty()-$freeQty);
}
else {
$rowWeight = 0;
}
}
$freeMethodWeight+= $rowWeight;
$item->setRowWeight($rowWeight);
}
}
Why this doesn't affect Magento's default carriers
If you do a regex search for /row_?weight/i
(e.g. getRowWeight
, setRowWeight
, setData('row_weight')
, etc.) in Mage_Shipping
(simple carriers) and Mage_Usa
(FedEx, UPS, and some other carriers), nothing pops up. Why? Because the default carriers use the total address weight, not the individual item weights.
For example, let's look at Mage_Usa_Model_Shipping_Carrier_Fedex::setRequest
:
public function setRequest(Mage_Shipping_Model_Rate_Request $request)
{
$this->_request = $request;
$r = new Varien_Object();
/* Snip */
$weight = $this->getTotalNumOfBoxes($request->getPackageWeight());
$r->setWeight($weight);
if ($request->getFreeMethodWeight()!= $request->getPackageWeight()) {
$r->setFreeMethodWeight($request->getFreeMethodWeight());
}
And where does the request get the package weight from? The answer is in Mage_Sales_Model_Quote_Address::requestShippingRates
:
public function requestShippingRates(Mage_Sales_Model_Quote_Item_Abstract $item = null)
{
/** @var $request Mage_Shipping_Model_Rate_Request */
$request = Mage::getModel('shipping/rate_request');
/* Snip */
$request->setPackageWeight($item ? $item->getRowWeight() : $this->getWeight());
We can ignore the use of $item->getRowWeight()
here because requestShippingRates
is called without providing a specific item as a parameter in Mage_Sales_Model_Quote_Address_Total_Shipping::collect
:
public function collect(Mage_Sales_Model_Quote_Address $address)
{
parent::collect($address);
foreach ($items as $item) {
/* Snip: Handling virtual items and parent items */
if ($item->getHasChildren() && $item->isShipSeparately()) {
/* Snip: Handling items with children */
}
else {
if (!$item->getProduct()->isVirtual()) {
$addressQty += $item->getQty();
}
$itemWeight = $item->getWeight();
$rowWeight = $itemWeight*$item->getQty();
$addressWeight+= $rowWeight;
if ($freeAddress || $item->getFreeShipping()===true) {
$rowWeight = 0;
} elseif (is_numeric($item->getFreeShipping())) {
$freeQty = $item->getFreeShipping();
if ($item->getQty()>$freeQty) {
$rowWeight = $itemWeight*($item->getQty()-$freeQty);
}
else {
$rowWeight = 0;
}
}
$freeMethodWeight+= $rowWeight;
$item->setRowWeight($rowWeight);
}
}
$address->setWeight($addressWeight);
$address->setFreeMethodWeight($freeMethodWeight);
$address->collectShippingRates();
This should look familiar, as this is the same place that each item's row_weight
is set to 0
if free shipping is in effect. Notice how $addressWeight
sums up each item's $rowWeight
, but that's done before row_weight
is set to 0
.
Basically, the address weight will always be the total weight of all the items, regardless of the free_shipping
value of each item. Since Magento's default carriers only rely on the address weight, the issue with row_weight
doesn't show up.
So why do we need row_weight
We need row_weight
because we've customized Magento's FedEx carrier to calculate separate rates for items that are coming from different origins, even if they're going to the same destination (and are thus part of the same address). For example, if you live in NJ, it's cheaper (and quicker) to ship an item from NJ than from CA - and if you have items from both NJ and CA in your order, you're able to see the cost (and estimated delivery date) of each shipment.
All in all, it looks like we can easily work around this issue by ignoring row_weight
and using weight * qty
directly. But, it does lead us to:
The question
Why does the Shipping
total set the row_weight
of the sales quote items to 0
if free shipping is in effect? This doesn't appear to be utilized anywhere.
Further observations
I neglected to mention that row_weight
could actually be non-zero, but still less than weight * qty
, if free_shipping
is a number instead of true
. I assume the purpose of this is to provide a solution to a scenario like this:
I have 3 items of the same product in my cart, each item weighing 2 lbs. I apply a free shipping coupon, but it's limited to a quantity of 2, so it only applies to 2 of the items. Now, when I look at the shipping rates, I'll be looking at shipping rates for 2 lbs (2 + 0 + 0) rather than 6 lbs (2 + 2 + 2).
This seems to make sense, but there are two major problems with it:
None of the default Magento carriers work like this (they use the address total weight, see above).
Even if some of the carriers worked like this, that would mean that I could pick any shipping method (e.g. overnight shipping) and only pay for the weight of 1 item - meaning, the merchant would have to cover the cost of the other 2 items. It would be up to the merchant to somehow figure out that I only paid for the weight of 1 item, and then ship the other 2 items using a more cost effective method, effectively creating a mismatch between what Magento displays and how the items were actually shipped out.