reset(); } /** * Sets order manager instance to calculator * * @param OrderManager $manager */ public function setManager(&$manager) { $this->manager =& $manager; } public function reset() { $this->items = Array (); } /** * Returns order object used in order manager * * @return OrdersItem */ protected function &getOrder() { $order =& $this->manager->getOrder(); return $order; } /** * Sets checkout error * * @param int $error_type = {product,coupon,gc} * @param int $error_code * @param int $product_id - {ProductId}:{OptionsSalt}:{BackOrderFlag}:{FieldName} * @return void * @access protected */ protected function setError($error_type, $error_code, $product_id = null) { $this->manager->setError($error_type, $error_code, $product_id); } /** * Perform order calculations and prepares operations for order manager * */ public function calculate() { $this->queryItems(); $this->groupItems(); $this->generateOperations(); $this->applyWholeOrderFlatDiscount(); } /** * Groups order items, when requested * * @return Array */ protected function groupItems() { $skipped_items = Array (); foreach ($this->items as $item_id => $item_data) { if ( in_array($item_id, $skipped_items) ) { continue; } $group_items = $this->getItemsToGroupWith($item_id); if (!$group_items) { continue; } foreach ($group_items as $group_item_id) { $this->items[$item_id]['Quantity'] += $this->items[$group_item_id]['Quantity']; $this->items[$group_item_id]['Quantity'] = 0; } $skipped_items = array_merge($skipped_items, $group_items); } } /** * Returns order item ids, that can be grouped with given order item id * * @param int $target_item_id * @return Array * @see OrderCalculator::canBeGrouped */ protected function getItemsToGroupWith($target_item_id) { $ret = Array (); foreach ($this->items as $item_id => $item_data) { if ( $this->canBeGrouped($this->items[$item_id], $this->items[$target_item_id]) ) { $ret[] = $item_id; } } return array_diff($ret, Array ($target_item_id)); } /** * Checks if 2 given order items can be grouped together * * @param Array $src_item * @param Array $dst_item * @return bool */ public function canBeGrouped($src_item, $dst_item) { if ($dst_item['Type'] != PRODUCT_TYPE_TANGIBLE) { return false; } return ($src_item['ProductId'] == $dst_item['ProductId']) && ($src_item['OptionsSalt'] == $dst_item['OptionsSalt']); } /** * Retrieves order contents from database * */ protected function queryItems() { $poc_table = $this->Application->getUnitOption('poc', 'TableName'); $query = ' SELECT oi.ProductId, oi.OptionsSalt, oi.ItemData, oi.Quantity, IF(p.InventoryStatus = ' . ProductInventory::BY_OPTIONS . ', poc.QtyInStock, p.QtyInStock) AS QtyInStock, p.QtyInStockMin, p.BackOrder, p.InventoryStatus, p.Type, oi.OrderItemId FROM ' . $this->getTable('orditems') . ' AS oi LEFT JOIN ' . TABLE_PREFIX . 'Products AS p ON oi.ProductId = p.ProductId LEFT JOIN ' . $poc_table . ' poc ON (poc.CombinationCRC = oi.OptionsSalt) AND (oi.ProductId = poc.ProductId) WHERE oi.OrderId = ' . $this->getOrder()->GetID(); $this->items = $this->Conn->Query($query, 'OrderItemId'); } /** * Generates operations and returns true, when something was changed * * @return bool */ protected function generateOperations() { $this->manager->resetOperationTotals(); foreach ($this->items as $item) { $this->ensureMinQty($item); $to_order = $back_order = 0; $available = $this->getAvailableQty($item); if ( $this->allowBackordering($item) ) { // split order into order & backorder if ($item['BackOrder'] == ProductBackorder::ALWAYS) { $to_order = $available = 0; $back_order = $item['Quantity']; } elseif ($item['BackOrder'] == ProductBackorder::AUTO) { $to_order = $available; $back_order = $item['Quantity'] - $available; } $qty = $to_order + $back_order; $price = $this->getPlainProductPrice($item, $qty); $cost = $this->getProductCost($item, $qty); $discount_info = $this->getDiscountInfo( $item['ProductId'], $price, $qty ); $this->manager->addOperation($item, 0, $to_order, $price, $cost, $discount_info); $this->manager->addOperation($item, 1, $back_order, $price, $cost, $discount_info); } else { // store as normal order (and remove backorder) // we could get here with backorder=never then we should order only what's available $to_order = min($item['Quantity'], $available); $price = $this->getPlainProductPrice($item, $to_order); $cost = $this->getProductCost($item, $to_order); $discount_info = $this->getDiscountInfo( $item['ProductId'], $price, $to_order ); $this->manager->addOperation($item, 0, $to_order, $price, $cost, $discount_info, $item['OrderItemId']); $this->manager->addOperation($item, 1, 0, $price, $cost, $discount_info); // remove backorder record if ($to_order < $item['Quantity']) { // ordered less, then requested -> inform user if ( $to_order > 0 ) { $this->setError(OrderCheckoutErrorType::PRODUCT, OrderCheckoutError::QTY_UNAVAILABLE, $item['ProductId'] . ':' . $item['OptionsSalt'] . ':0:Quantity'); } else { $this->setError(OrderCheckoutErrorType::PRODUCT, OrderCheckoutError::QTY_OUT_OF_STOCK, $item['ProductId'] . ':' . $item['OptionsSalt'] . ':0:Quantity'); } } } } } /** * Adds product to order (not to db) * * @param Array $item * @param kCatDBItem $product * @param int $qty */ public function addProduct($item, &$product, $qty) { $this->updateItemDataFromProduct($item, $product); $price = $this->getPlainProductPrice($item, $qty); $cost = $this->getProductCost($item, $qty); $discount_info = $this->getDiscountInfo( $item['ProductId'], $price, $qty ); $this->manager->addOperation( $item, 0, $qty, $price, $cost, $discount_info, $item['OrderItemId'] ); } /** * Apply whole order flat discount after sub-total been calculated * */ protected function applyWholeOrderFlatDiscount() { $sub_total_flat = $this->manager->getOperationTotal('SubTotalFlat'); $flat_discount = min( $sub_total_flat, $this->getWholeOrderPlainDiscount($global_discount_id) ); $coupon_flat_discount = min( $sub_total_flat, $this->getWholeOrderCouponDiscount() ); if ($coupon_flat_discount && $coupon_flat_discount > $flat_discount) { $global_discount_type = 'coupon'; $flat_discount = $coupon_flat_discount; $global_discount_id = $coupon_id; } else { $global_discount_type = 'discount'; } $sub_total = $this->manager->getOperationTotal('SubTotal'); if ($sub_total_flat - $sub_total < $flat_discount) { // individual item discounts together are smaller when order flat discount $this->manager->setOperationTotal('CouponDiscount', $flat_discount == $coupon_flat_discount ? $flat_discount : 0); $this->manager->setOperationTotal('SubTotal', $sub_total_flat - $flat_discount); // replace discount for each operation foreach ($this->operations as $index => $operation) { $discounted_price = ($operation['Price'] / $sub_total_flat) * $sub_total; $this->operations[$index]['DiscountInfo'] = Array ($global_discount_id, $global_discount_type, $discounted_price, 0); } } } /** * Returns discount information for given product price and qty * * @param int $product_id * @param float $price * @param int $qty * @return Array */ protected function getDiscountInfo($product_id, $price, $qty) { $discounted_price = $this->getDiscountedProductPrice($product_id, $price, $discount_id); $couponed_price = $this->getCouponDiscountedPrice($product_id, $price); if ($couponed_price < $discounted_price) { $discount_type = 'coupon'; $discount_id = $coupon_id; $discounted_price = $couponed_price; $coupon_discount = ($price - $couponed_price) * $qty; } else { $coupon_discount = 0; $discount_type = 'discount'; } return Array ($discount_id, $discount_type, $discounted_price, $coupon_discount); } /** * Returns product qty, available for ordering * * @param Array $item * @return int */ protected function getAvailableQty($item) { if ( $item['InventoryStatus'] == ProductInventory::DISABLED ) { // always available return $item['Quantity'] * 2; } return max(0, $item['QtyInStock'] - $item['QtyInStockMin']); } /** * Checks, that product in given order item can be backordered * * @param Array $item * @return bool */ protected function allowBackordering($item) { if ($item['BackOrder'] == ProductBackorder::ALWAYS) { return true; } $available = $this->getAvailableQty($item); $backordering = $this->Application->ConfigValue('Comm_Enable_Backordering'); return $backordering && ($item['Quantity'] > $available) && ($item['BackOrder'] == ProductBackorder::AUTO); } /** * Make sure, that user can't order less, then minimal required qty of product * * @param Array $item */ protected function ensureMinQty(&$item) { $sql = 'SELECT MIN(MinQty) FROM ' . TABLE_PREFIX . 'ProductsPricing WHERE ProductId = ' . $item['ProductId']; $min_qty = max(1, $this->Conn->GetOne($sql)); $qty = $item['Quantity']; if ($qty > 0 && $qty < $min_qty) { // qty in cart increased to meat minimal qry requirements of given product $this->setError(OrderCheckoutErrorType::PRODUCT, OrderCheckoutError::QTY_CHANGED_TO_MINIMAL, $item['ProductId'] . ':' . $item['OptionsSalt'] . ':0:Quantity'); $item['Quantity'] = $min_qty; } } /** * Return product price for given qty, taking no discounts into account * * @param Array $item * @param int $qty * @return float */ public function getPlainProductPrice($item, $qty) { $item_data = $this->getItemData($item); if ( isset($item_data['ForcePrice']) ) { return $item_data['ForcePrice']; } $pricing_id = $this->getPriceBracketByQty($item, $qty); $sql = 'SELECT Price FROM ' . TABLE_PREFIX . 'ProductsPricing WHERE PriceId = ' . $pricing_id; $price = (float)$this->Conn->GetOne($sql); if ( isset($item_data['Options']) ) { $price += $this->getOptionPriceAddition($price, $item_data); $price = $this->getCombinationPriceOverride($price, $item_data); } return max($price, 0); } /** * Return product cost for given qty, taking no discounts into account * * @param Array $item * @param int $qty * @return float */ public function getProductCost($item, $qty) { $pricing_id = $this->getPriceBracketByQty($item, $qty); $sql = 'SELECT Cost FROM ' . TABLE_PREFIX . 'ProductsPricing WHERE PriceId = ' . $pricing_id; return (float)$this->Conn->GetOne($sql); } /** * Return product price for given qty, taking no discounts into account * * @param Array $item * @param int $qty * @return float */ protected function getPriceBracketByQty($item, $qty) { $orderby_clause = ''; $where_clause = Array (); $product_id = $item['ProductId']; if ( $this->usePriceBrackets($item) ) { $user_id = $this->getOrder()->GetDBField('PortalUserId'); $where_clause = Array ( 'GroupId IN (' . $this->Application->getUserGroups($user_id) . ')', 'pp.ProductId = ' . $product_id, 'pp.MinQty <= ' . $qty, $qty . ' < pp.MaxQty OR pp.MaxQty = -1', ); $orderby_clause = $this->getPriceBracketOrderClause($user_id); } else { $item_data = $this->getItemData($item); $where_clause = Array( 'pp.ProductId = ' . $product_id, 'pp.PriceId = ' . $this->getPriceBracketFromRequest($product_id, $item_data), ); } $sql = 'SELECT pp.PriceId FROM ' . TABLE_PREFIX . 'ProductsPricing AS pp LEFT JOIN ' . TABLE_PREFIX . 'Products AS p ON p.ProductId = pp.ProductId WHERE (' . implode(') AND (', $where_clause) . ')'; if ($orderby_clause) { $sql .= ' ORDER BY ' . $orderby_clause; } return (float)$this->Conn->GetOne($sql); } /** * Checks if price brackets should be used in price calculations * * @param Array $item * @return bool */ protected function usePriceBrackets($item) { return $item['Type'] == PRODUCT_TYPE_TANGIBLE; } /** * Return product pricing id for given product. * If not passed - return primary pricing ID * * @param int $product_id * @return int */ public function getPriceBracketFromRequest($product_id, $item_data) { if ( !is_array($item_data) ) { $item_data = unserialize($item_data); } // remembered pricing during checkout if ( isset($item_data['PricingId']) && $item_data['PricingId'] ) { return $item_data['PricingId']; } // selected pricing from product detail page $price_id = $this->Application->GetVar('pr_id'); if ($price_id) { return $price_id; } $sql = 'SELECT PriceId FROM ' . TABLE_PREFIX . 'ProductsPricing WHERE ProductId = ' . $product_id . ' AND IsPrimary = 1'; return $this->Conn->GetOne($sql); } /** * Returns order clause for price bracket selection based on configration * * @param int $user_id * @return string */ protected function getPriceBracketOrderClause($user_id) { if ($this->Application->ConfigValue('Comm_PriceBracketCalculation') == 1) { // if we have to stick to primary group, then its pricing will go first, // but if there is no pricing for primary group, then next optimal will be taken $primary_group = $this->getUserPrimaryGroup($user_id); return '( IF(GroupId = ' . $primary_group . ', 1, 2) ) ASC, pp.Price ASC'; } return 'pp.Price ASC'; } /** * Returns addition to product price based on used product option * * @param float $price * @param Array $item_data * @return float */ protected function getOptionPriceAddition($price, $item_data) { $addition = 0; /** @var kProductOptionsHelper $opt_helper */ $opt_helper = $this->Application->recallObject('kProductOptionsHelper'); foreach ($item_data['Options'] as $opt => $val) { $sql = 'SELECT * FROM ' . TABLE_PREFIX . 'ProductOptions WHERE ProductOptionId = ' . $opt; $data = $this->Conn->GetRow($sql); $parsed = $opt_helper->ExplodeOptionValues($data); if ( !$parsed ) { continue; } if ( is_array($val) ) { foreach ($val as $a_val) { $addition += $this->formatPrice($a_val, $price, $parsed); } } else { $addition += $this->formatPrice($val, $price, $parsed); } } return $addition; } protected function formatPrice($a_val, $price, $parsed) { $a_val = kUtil::unescape($a_val, kUtil::ESCAPE_HTML); // TODO: Not sure why we're unescaping. $addition = 0; $conv_prices = $parsed['Prices']; $conv_price_types = $parsed['PriceTypes']; if ( isset($conv_prices[$a_val]) && $conv_prices[$a_val] ) { if ($conv_price_types[$a_val] == '$') { $addition += $conv_prices[$a_val]; } elseif ($conv_price_types[$a_val] == '%') { $addition += $price * $conv_prices[$a_val] / 100; } } return $addition; } /** * Returns product price after applying combination price override * * @param float $price * @param Array $item_data * @return float */ protected function getCombinationPriceOverride($price, $item_data) { $combination_salt = $this->generateOptionsSalt( $item_data['Options'] ); if (!$combination_salt) { return $price; } $sql = 'SELECT * FROM ' . TABLE_PREFIX . 'ProductOptionCombinations WHERE CombinationCRC = ' . $combination_salt; $combination = $this->Conn->GetRow($sql); if (!$combination) { return $price; } switch ( $combination['PriceType'] ) { case OptionCombinationPriceType::EQUALS: return $combination['Price']; break; case OptionCombinationPriceType::FLAT: return $price + $combination['Price']; break; case OptionCombinationPriceType::PECENT: return $price * (1 + $combination['Price'] / 100); break; } return $price; } /** * Generates salt for given option set * * @param Array $options * @return int */ public function generateOptionsSalt($options) { /** @var kProductOptionsHelper $opt_helper */ $opt_helper = $this->Application->recallObject('kProductOptionsHelper'); return $opt_helper->OptionsSalt($options, true); } /** * Return product price for given qty, taking possible discounts into account * * @param int $product_id * @param int $price * @param int $discount_id * @return float */ public function getDiscountedProductPrice($product_id, $price, &$discount_id) { $discount_id = 0; $user_id = $this->getOrder()->GetDBField('PortalUserId'); $join_clause = Array ( 'd.DiscountId = di.DiscountId', 'di.ItemType = ' . DiscountItemType::PRODUCT . ' OR (di.ItemType = ' . DiscountItemType::WHOLE_ORDER . ' AND d.Type = ' . DiscountType::PERCENT . ')', 'd.Status = ' . STATUS_ACTIVE, 'd.GroupId IN (' . $this->Application->getUserGroups($user_id) . ')', 'd.Start IS NULL OR d.Start < ' . $this->getOrder()->GetDBField('OrderDate'), 'd.End IS NULL OR d.End > ' . $this->getOrder()->GetDBField('OrderDate'), ); $sql = 'SELECT ROUND(CASE d.Type WHEN ' . DiscountType::FLAT . ' THEN ' . $price . ' - d.Amount WHEN ' . DiscountType::PERCENT . ' THEN ' . $price . ' * (1 - d.Amount / 100) ELSE ' . $price . ' END, 4), d.DiscountId FROM ' . TABLE_PREFIX . 'Products AS p LEFT JOIN ' . TABLE_PREFIX . 'ProductsDiscountItems AS di ON (di.ItemResourceId = p.ResourceId) OR (di.ItemType = ' . DiscountItemType::WHOLE_ORDER . ') LEFT JOIN ' . TABLE_PREFIX . 'ProductsDiscounts AS d ON (' . implode(') AND (', $join_clause) . ') WHERE (p.ProductId = ' . $product_id . ') AND (d.DiscountId IS NOT NULL)'; $pricing = $this->Conn->GetCol($sql, 'DiscountId'); if (!$pricing) { return $price; } // get minimal price + discount $discounted_price = min($pricing); $pricing = array_flip($pricing); $discount_id = $pricing[$discounted_price]; // optimal discount, but prevent negative price return max( min($discounted_price, $price), 0 ); } public function getWholeOrderPlainDiscount(&$discount_id) { $discount_id = 0; $user_id = $this->getOrder()->GetDBField('PortalUserId'); $join_clause = Array ( 'd.DiscountId = di.DiscountId', 'di.ItemType = ' . DiscountItemType::WHOLE_ORDER . ' AND d.Type = ' . DiscountType::FLAT, 'd.Status = ' . STATUS_ACTIVE, 'd.GroupId IN (' . $this->Application->getUserGroups($user_id) . ')', 'd.Start IS NULL OR d.Start < ' . $this->getOrder()->GetDBField('OrderDate'), 'd.End IS NULL OR d.End > ' . $this->getOrder()->GetDBField('OrderDate'), ); $sql = 'SELECT d.Amount AS Discount, d.DiscountId FROM ' . TABLE_PREFIX . 'ProductsDiscountItems AS di LEFT JOIN ' . TABLE_PREFIX . 'ProductsDiscounts AS d ON (' . implode(') AND (', $join_clause) . ') WHERE d.DiscountId IS NOT NULL'; $pricing = $this->Conn->GetCol($sql, 'DiscountId'); if (!$pricing) { return 0; } $discounted_price = max($pricing); $pricing = array_flip($pricing); $discount_id = $pricing[$discounted_price]; return max($discounted_price, 0); } public function getCouponDiscountedPrice($product_id, $price) { if ( !$this->getCoupon() ) { return $price; } $join_clause = Array ( 'c.CouponId = ci.CouponId', 'ci.ItemType = ' . CouponItemType::PRODUCT . ' OR (ci.ItemType = ' . CouponItemType::WHOLE_ORDER . ' AND c.Type = ' . CouponType::PERCENT . ')', ); $sql = 'SELECT MIN( ROUND(CASE c.Type WHEN ' . CouponType::FLAT . ' THEN ' . $price . ' - c.Amount WHEN ' . CouponType::PERCENT . ' THEN ' . $price . ' * (1 - c.Amount / 100) ELSE ' . $price . ' END, 4) ) FROM ' . TABLE_PREFIX . 'Products AS p LEFT JOIN ' . TABLE_PREFIX . 'ProductsCouponItems AS ci ON (ci.ItemResourceId = p.ResourceId) OR (ci.ItemType = ' . CouponItemType::WHOLE_ORDER . ') LEFT JOIN ' . TABLE_PREFIX . 'ProductsCoupons AS c ON (' . implode(') AND (', $join_clause) . ') WHERE p.ProductId = ' . $product_id . ' AND ci.CouponId = ' . $this->getCoupon() . ' GROUP BY p.ProductId'; $coupon_price = $this->Conn->GetOne($sql); if ($coupon_price === false) { return $price; } return max( min($price, $coupon_price), 0 ); } public function getWholeOrderCouponDiscount() { if ( !$this->getCoupon() ) { return 0; } $where_clause = Array ( 'ci.CouponId = ' . $this->getCoupon(), 'ci.ItemType = ' . CouponItemType::WHOLE_ORDER, 'c.Type = ' . CouponType::FLAT, ); $sql = 'SELECT Amount FROM ' . TABLE_PREFIX . 'ProductsCouponItems AS ci LEFT JOIN ' . TABLE_PREFIX . 'ProductsCoupons AS c ON c.CouponId = ci.CouponId WHERE (' . implode(') AND (', $where_clause) . ')'; return $this->Conn->GetOne($sql); } protected function getCoupon() { return $this->getOrder()->GetDBField('CouponId'); } /** * Returns primary group of given user * * @param int $user_id * @return int */ protected function getUserPrimaryGroup($user_id) { if ($user_id > 0) { $sql = 'SELECT PrimaryGroupId FROM ' . TABLE_PREFIX . 'Users WHERE PortalUserId = ' . $user_id; return $this->Conn->GetOne($sql); } return $this->Application->ConfigValue('User_LoggedInGroup'); } /** * Returns ItemData associated with given order item * * @param Array $item * @return Array */ protected function getItemData($item) { $item_data = $item['ItemData']; if ( is_array($item_data) ) { return $item_data; } return $item_data ? unserialize($item_data) : Array (); } /** * Sets ItemData according to product * * @param Array $item * @param kCatDBItem $product */ protected function updateItemDataFromProduct(&$item, &$product) { $item_data = $this->getItemData($item); $item_data['IsRecurringBilling'] = $product->GetDBField('IsRecurringBilling'); // it item is processed in order using new style, then put such mark in orderitem record $processing_data = $product->GetDBField('ProcessingData'); if ($processing_data) { $processing_data = unserialize($processing_data); if ( isset($processing_data['HasNewProcessing']) ) { $item_data['HasNewProcessing'] = 1; } } $item['ItemData'] = serialize($item_data); } /** * Returns table name according to order temp mode * * @param string $prefix * @return string */ protected function getTable($prefix) { return $this->manager->getTable($prefix); } }