Можно использовать CASE в запросе для своего усложненного порядка сортировки. Н-р, для ocStore 2.3.0.2.3 примерный итоговый запрос на странице категорий.
SELECT p.product_id, -- p.quantity, p.stock_status_id, -- для отладки по условиям
CASE
WHEN p.quantity = 0 AND p.stock_status_id = 1 THEN 1
WHEN p.quantity = 0 AND p.stock_status_id = 2 THEN 2
ELSE 0
END AS my_sort,
(SELECT AVG(rating) AS total
FROM oc_review r1
WHERE r1.product_id = p.product_id AND r1.status = '1'
GROUP BY r1.product_id) AS rating,
(SELECT price
FROM oc_product_discount pd2
WHERE pd2.product_id = p.product_id AND pd2.customer_group_id = '1' AND pd2.quantity = '1'
AND ((pd2.date_start = '0000-00-00' OR pd2.date_start < NOW())
AND (pd2.date_end = '0000-00-00' OR pd2.date_end > NOW()))
ORDER BY pd2.priority ASC, pd2.price ASC LIMIT 1) AS discount,
(SELECT price FROM oc_product_special ps
WHERE ps.product_id = p.product_id AND ps.customer_group_id = '1'
AND ((ps.date_start = '0000-00-00' OR ps.date_start < NOW())
AND (ps.date_end = '0000-00-00' OR ps.date_end > NOW()))
ORDER BY ps.priority ASC, ps.price ASC LIMIT 1) AS special
FROM oc_product_to_category p2c
LEFT JOIN oc_product p ON (p2c.product_id = p.product_id)
LEFT JOIN oc_product_description pd ON (p.product_id = pd.product_id)
LEFT JOIN oc_product_to_store p2s ON (p.product_id = p2s.product_id)
WHERE pd.language_id = '1' AND p.status = '1' AND p.date_available <= NOW()
AND p2s.store_id = '0' AND p2c.category_id = '28'
GROUP BY p.product_id
ORDER BY my_sort, -- добавляем нашу сортировку перед стандартными
p.sort_order ASC, LCASE(pd.name) ASC LIMIT 0,15
В итоге товары в категории идут сначала с остатком, т.е. quantity<>0 -> затем с условием quantity=0 и stock_status_id=1 -> затем с условием quantity=0 и stock_status_id=2
Что прописать в catalog\model\catalog\product.php в public function getProducts($data = array()) , чтобы получился подобный запрос, думаю, сами догадаетесь.