Как реализовать резервирование товаров
Актуально для версии 2.9.6
Содержание
Задача
Допустим у Вас есть ряд товаров, количество которых ограничено и Вам необходимо создать для них резервирование, то есть, нужно реализовать примерно следующие возможности:
- Включить резервирование товара;
- Задать количество зарезервированного товара;
- Указать дату, до которой этот товар должен быть оплачен;
- Сделать, чтобы было невозможно совершить заказ, в котором количество товара будет превышать резерв;
- Все действия с корзиной перед покупкой (изменения количества, удаление и добавление позиций) должны сразу отражаться на количестве резерва;
- Если заказ с зарезервированным товаром не был переведен в статус "Готов" до даты, заданной в 3 пункте, то товарная позиция должна быть удалена из заказа;
- Если заказ был отменен или отклонен, то у зарезервированного товара должно быть восполнено его количество.
Все эти задачи можно решить только кастомно, ниже даются примеры.
Решение
Управление резервирование у товара
Сначала создадим поля, в которых будут храниться настройки резервирования для товаров. Для этого, создадим в типе данных "объект каталога" следующие поля:
Теперь у товаров появились настройки:
Резервирование при работе с корзиной
К сожалению, стандартный метод emarket basket() совершенно не подойдет для данной задачи, поэтому придется создаваться кастомный, пример ниже. Чтобы его применить нужно будет поменять формы добавления товара в корзину и формы для изменения количества и удаления позиции из корзины.
/*
* Замена стандартного метода emarket basket()
*
* Отличия от ориганала:
* 1) в зависимости от $mode дополнительно вызываются:
* checkAmountAndPut() для 'put'
* checkAmountAndRemove() для 'remove'
* checkAmountAndRemoveAll() для 'remove_all'
* 2) нет тройного вызова метода refresh();
*/
public function smartBasket($mode = false, $itemType = false, $itemId = false){
$mode = $mode ? $mode : getRequest('param0');
$order = emarket::getBasketOrder(!in_array($mode, array('put', 'remove')));
$itemType = $itemType ? $itemType : getRequest('param1');
$itemId = (int) ($itemId ? $itemId : getRequest('param2'));
$amount = (int) getRequest('amount');
$options = getRequest('options');
if($mode == 'put') {
$newElement = false;
if ($itemType == 'element') {
$orderItem = emarket::getBasketItem($itemId, false);
if (!$orderItem) {
$orderItem = emarket::getBasketItem($itemId);
$newElement = true;
}
} else {
$orderItem = order::getItem($itemId);
}
if (!$orderItem) {
throw new publicException("Order item is not defined");
}
if(is_array($options)) {
if($itemType != 'element') {
throw new publicException("Put basket method required element id of optionedOrderItem");
}
// Get all orderItems
$orderItems = order::getItems();
foreach($orderItems as $tOrderItem) {
if (!$tOrderItem instanceOf optionedOrderItem) {
$itemOptions = null;
$tOrderItem = null;
continue;
}
$itemOptions = $tOrderItem->getOptions();
if(sizeof($itemOptions) != sizeof($options)) {
$itemOptions = null;
$tOrderItem = null;
continue;
}
if($tOrderItem->getItemElement()->id != $orderItem->getItemElement()->id) {
$itemOptions = null;
$tOrderItem = null;
continue;
}
// Compare each tOrderItem with options list
foreach($options as $optionName => $optionId) {
$itemOption = getArrayKey($itemOptions, $optionName);
if(getArrayKey($itemOption, 'option-id') != $optionId) {
$tOrderItem = null;
continue 2; // If does not match, create new item using options specified
}
}
break; // If matches, stop loop and continue to amount change
}
if(!isset($tOrderItem) || is_null($tOrderItem)) {
$tOrderItem = orderItem::create($itemId);
$order->appendItem($tOrderItem);
if ($newElement) {
$orderItem->remove();
}
}
if($tOrderItem instanceof optionedOrderItem) {
foreach($options as $optionName => $optionId) {
if($optionId) {
$tOrderItem->appendOption($optionName, $optionId);
} else {
$tOrderItem->removeOption($optionName);
}
}
}
if($tOrderItem) {
$orderItem = $tOrderItem;
}
}
self::checkAmountAndPut($itemId, $amount, $orderItem);
if($itemType == 'element') {
$order->appendItem($orderItem);
}
$order->refresh();
}
if($mode == 'remove') {
$orderItem = ($itemType == 'element') ? emarket::getBasketItem($itemId, false) : orderItem::get($itemId);
self::checkAmountAndRemove($itemId, $orderItem, $order, $itemType);
}
if ($mode == 'remove_all') {
self::checkAmountAndRemoveAll($order);
}
$referer = getServer('HTTP_REFERER');
$noRedirect = getRequest('no-redirect');
if($redirectUri = getRequest('redirect-uri')) {
$this->redirect($redirectUri);
} else if (!defined('VIA_HTTP_SCHEME') && !$noRedirect && $referer) {
$current = $_SERVER['REQUEST_URI'];
if(substr($referer, -strlen($current)) == $current) {
if($itemType == 'element') {
$referer = umiHierarchy::getInstance()->getPathById($itemId);
} else {
$referer = "/";
}
}
$this->redirect($referer);
}
return $this->order($order->getId());
}
Код метода нужно поместить в файл /classes/modules/emarket/__custom.php и не забудьте про пермишены.
Метод работает практически также, как и стандартный, поэтому подробно его описывать не имеет смысла. В зависимости от $mode будет вызван тот или иной служебный метод. Код служебных методов дан ниже, его нужно добавить в тот же файл.
/*
* Вызывается при полном очищении корзины, то есть:
* /udata/emarket/smartBasket/remove_all
* Проверяет товары, связанные с итемами,
* если у товара включена резервация, то
* его "запас" восполняется, все итемы убиваются.
*/
private function checkAmountAndRemoveAll($order){
$hierarchy = umiHierarchy::getInstance();
$items = $order->getItems();
if(count($items) == 0){
return;
}
foreach($items as $orderItem){
$element_id = intval($orderItem->getItemElement()->getId());
$element = $hierarchy->getElement($element_id, true, true);
if(intval($element->getValue('reservation_on')) == 0){
$order->removeItem($orderItem);
}else{
$reserved_amount = intval($element->getValue('reservation_amount'));
$puted_amount = intval($orderItem->getAmount());
$element->setValue('reservation_amount', $reserved_amount + $puted_amount);
$element->commit();
$order->removeItem($orderItem);
}
unset($element_id, $element);
}
$order->refresh();
}
/*
* Вызывается при удалении позиции или товара из корзины, то есть:
* /udata/emarket/smartBasket/remove/item/1045
* или
* /udata/emarket/smartBasket/remove/element/69
* Проверяет включена ли резервация у связанного товара, если включена,
* то его запас восполняется, итемы убиваются.
*/
private function checkAmountAndRemove($itemId, $orderItem, $order, $itemType){
if($itemType == 'element'){
$hierarchy = umiHierarchy::getInstance();
$element = $hierarchy->getElement($itemId, true, true);
}else{
$element = $orderItem->getItemElement();
}
if(intval($element->getValue('reservation_on')) == 0){
if($orderItem instanceof orderItem) {
$order->removeItem($orderItem);
$order->refresh();
}
}else{
$reservation_amount = intval($element->getValue('reservation_amount'));
$basket_amount = intval($orderItem->getAmount());
$element->setValue('reservation_amount', $reservation_amount + $basket_amount);
$element->commit();
$order->removeItem($orderItem);
$order->refresh();
}
}
/*
* Вызывается при добавлении товара или позиции в корзину, с учетом $amount
* Примеры вызовов:
* /udata/emarket/smartBasket/put/element/69
* /udata/emarket/smartBasket/put/element/69?amount=10
* /udata/emarket/smartBasket/put/item/1045
* /udata/emarket/smartBasket/put/item/1045?amount=10
* Если у товаров включена резервация, то вызывается метод checkReservedAmount()
*/
private function checkAmountAndPut($itemId, $amount, $orderItem){
$basket_amount = intval($orderItem->getAmount());
$hierarchy = umiHierarchy::getInstance();
$element = $hierarchy->getElement($itemId, true, true);
if(intval($element->getValue('reservation_on')) == 0){
$amount = $amount ? $amount : ($basket_amount + 1);
$orderItem->setAmount($amount ? $amount : 1);
$orderItem->refresh();
}else{
self::checkReservedAmount($element, $basket_amount, $amount, $orderItem);
}
}
/*
* Проверяется текущее содержание корзины, переданный $amount
* и зарезервированный $amount товара.
* В зависимости от проверки вызывются методы reduceReservedAmount() или increaseReservedAmount()
*/
private function checkReservedAmount($element, $basket_amount, $amount, $orderItem){
$reserved_amount = intval($element->getValue('reservation_amount'));
if($reserved_amount == 0){
throw new publicException('out of stock');
}else{
if($amount > $basket_amount && $amount !== 0){
if($reserved_amount < $amount){
throw new publicException('out of stock');
}else{
self::reduceReservedAmount($reserved_amount, $amount - $basket_amount, $element, $orderItem);
}
}elseif($amount == 0){
self::reduceReservedAmount($reserved_amount, 1, $element, $orderItem);
}else{
self::increaseReservedAmount($reserved_amount, $basket_amount-$amount, $element, $orderItem);
}
}
}
/*
* Уменьшает зарезервированное количество и увеличивает $amount для товарной позиции
*/
private function reduceReservedAmount($reserved_amount, $amount, $element, $orderItem){
$element->setValue('reservation_amount', $reserved_amount - $amount);
$element->commit();
$orderItem->setAmount($orderItem->getAmount() + $amount);
$orderItem->refresh();
$orderItem->commit();
return true;
}
/*
* Увеличивает зарезервированное количество и уменьшает $amount для товарной позиции
*/
private function increaseReservedAmount($reserved_amount, $amount, $element, $orderItem){
$element->setValue('reservation_amount', $reserved_amount + $amount);
$element->commit();
$orderItem->setAmount($orderItem->getAmount() - $amount);
$orderItem->refresh();
$orderItem->commit();
return true;
}
Краткие описания методов даны в комментариях. Эти служебные методы реализуют работу с резервацией, если у товара резервация отключена, то с ним никаких дополнительных действий не происходит.
Установка времени жизни у позиций зарезервированных товаров
Для реализации данной задачи нам поможем событийная модель UMI.CMS, а конкретнее событие order-status-changed, которое вызывается при оформлении каждого заказа. Нам нужно повесить на событие обработчик, который будет проверять есть ли в заказе зарезервированные товары, если они есть то устанавливать время жизни товарных позиций с помощью класса umiObjectsExpiration.
Добавим в файл /classes/modules/emarket/custom_events.php следующие строки:
//создание времени жизни для товарных позиций заказов, содержащих зарезерированные товары
new umiEventListener('order-status-changed', 'emarket', 'makeExpiration');
А в кастом того же модуля добавим код обработчика:
/*
* Вызывается, когда заказ меняет статус с пустого на "Редактируется", это происходит со всеми
* заказами в процессе оформления.
* Если в заказе есть зарезервированные товары, то товарной позиции устанавливается время жизни
* время жизни = время, до которого зарезервирован товар - текущее время
*/
public function makeExpiration(iUmiEventPoint $eventPoint){
$new_status = $eventPoint->getParam('new-status-id');
$old_status = $eventPoint->getParam('old-status-id');
$mode = $eventPoint->getMode();
if($mode == 'after' && $old_status == false && $new_status == order::getStatusByCode('waiting')){
$order = $eventPoint->getRef('order');
$items = $order->getItems();
if(count($items) == 0){
return;
}
$hierarchy = umiHierarchy::getInstance();
$reserved_items = array();
foreach($items as $item){
$element_id = intval($item->getItemElement()->getId());
$element = $hierarchy->getElement($element_id, true, true);
if(intval($element->getValue('reservation_on')) == 1){
$reserved_items[] = $item;
}
unset($element_id, $element);
}
if(count($reserved_items) == 0){
return;
}else{
$object_expire = umiObjectsExpiration::getInstance();
foreach($reserved_items as $reserved_item){
$item_id = $reserved_item->getId();
$element = $hierarchy->getElement($reserved_item->getItemElement()->getId());
$expire_time = $element->getValue('reservation_time')->getDateTimeStamp();
$umi_date = new umiDate;
$current_time = $umi_date->getCurrentTimeStamp();
if($current_time > $expire_time){
break;
}else{
$expiration_time = $expire_time - $current_time;
$object_expire->add($item_id, $expiration_time);
}
}
return;
}
}else{
return;
}
}
И не забудь про пермишены для обработчика. Теперь у нас есть функционал задания времени жизни товарным позициям зарезервированных товаров.
Реализуем функционал резервирования при изменении статуса заказа
Для это задачи нам опять понадобится событийная модель, только точка вызова поменяется на systemModifyObject. Нам нужно, чтобы если заказ был отклонен или отменен, то зарезервированное количество заказа восполнялось, а если заказ был переведен в статус готов, то его итемы выходили из контроля времени жизни.
Добавим в файл /classes/modules/emarket/custom_events.php следующие строки:
//методы резервирования, вызываемые при изменении статуса заказа
new umiEventListener('systemModifyObject', 'emarket', 'expirationControl');
А в кастом добавим следующие методы:
/*
* Вызывается после редактирования заказа в административной панели
* В зависимости от статуса заказа вызывается либо метод canceledOrder(), либо readyOrder()
*/
public function expirationControl(iUmiEventPoint $eventPoint){
if ($eventPoint->getMode() == 'after') {
$object = $eventPoint->getRef('object');
if ($object->getTypeGUID() == 'emarket-order') {
$object_id = $object->getId();
$order = order::get($object_id);
$objects_coll = umiObjectsCollection::getInstance();
$status_object = $objects_coll->getObject($order->getOrderStatus());
$status = $status_object->getValue('codename');
switch ($status){
case 'canceled':
self::canceledOrder($order);
case 'rejected':
self::canceledOrder($order);
case 'ready':
self::readyOrder($order);
default:
return;
}
}else{
return;
}
}else{
return;
}
}
/*
* Метод вызывается у отменных заказов.
* Проверяет есть ли у товарных позиций связанные товары с резервированием.
* Если есть, то количество зарезервированного товара восполняется, а товарная
* позиция удаляется из таблицы cms3_objects_expiration, чтобы её не убил сборщик мусора
*/
private function canceledOrder($order){
$items = $order->getItems();
if(count($items) == 0){
return;
}
$hierarchy = umiHierarchy::getInstance();
$reserved_items = array();
foreach($items as $item){
$element_id = intval($item->getItemElement()->getId());
$element = $hierarchy->getElement($element_id, true, true);
if(intval($element->getValue('reservation_on')) == 1){
$reserved_items[] = $item;
}
unset($element_id, $element);
}
if(count($reserved_items) == 0){
return;
}else{
$object_expire = umiObjectsExpiration::getInstance();
foreach($reserved_items as $reserved_item){
$item_id = $reserved_item->getId();
$item_amount = intval($reserved_item->getAmount());
$object_expire->clear($item_id);
$element = $hierarchy->getElement($reserved_item->getItemElement()->getId());
$old_reserve = intval($element->getValue('reservation_amount'));
$element->setValue('reservation_amount', $old_reserve + $item_amount);
$element->commit();
}
}
}
/*
* Метод вызывается у готовых заказов.
* Проверяет есть ли у товарных позиций связанные товары с резервированием.
* Если есть, то товарная позиция удаляется из таблицы cms3_objects_expiration, чтобы её не убил сборщик мусора
*/
private function readyOrder($order){
$items = $order->getItems();
if(count($items) == 0){
return;
}
$hierarchy = umiHierarchy::getInstance();
$reserved_items = array();
foreach($items as $item){
$element_id = intval($item->getItemElement()->getId());
$element = $hierarchy->getElement($element_id, true, true);
if(intval($element->getValue('reservation_on')) == 1){
$reserved_items[] = $item;
}
unset($element_id, $element);
}
if(count($reserved_items) == 0){
return;
}else{
$object_expire = umiObjectsExpiration::getInstance();
foreach($reserved_items as $reserved_item){
$item_id = $reserved_item->getId();
$object_expire->clear($item_id);
}
}
}
Не забудьте добавить пермишены для метода expirationControl(). Описания методов есть в комментариях к коду.
Реализуем сборщик мусора
Теперь осталось реализовать сборщик мусора, который будет убивать товарные позиции с истекшим сроком жизни и восполнять зарезервированное количество товаров. Так как, мы имеем дело со временем, то нам понадобиться standalone скрипт, который мы будем запускать по cron'у. Ниже дан пример сборщика мусора.
Создайте в корневой директории php файл с именем, например, order_items_expiration.php и поместите в него следующий код:
<?php
/*
* Сборщик мусора для товарных позиций с истекшим временем жизни.
* Время жизни задается в методе makeExpiration, в кастоме emarket'а
* Итемы с истекшим сроков жизни удаляются из бд, удаляется запись о их времени жизни,
* а у товара, из которого сден итем восполняется зарезервированное количество.
*/
include 'standalone.php';
$objects_expire = umiObjectsExpiration::getInstance();
$types_coll = umiObjectTypesCollection::getInstance();
$ord_item_type_id = $types_coll->getBaseType('emarket', 'order_item');
$expired_order_items = array();
$expired_order_items = $objects_expire->getExpiredObjectsByTypeId($ord_item_type_id);
if(count($expired_order_items) == 0){
exit();
}else{
foreach($expired_order_items as $order_item){
$objects_coll = umiObjectsCollection::getInstance();
$object_item = $objects_coll->getObject($order_item);
$amount = intval($object_item->getValue('item_amount'));
$product_page = $object_item->getValue('item_link');
$element = $product_page[0];
$available = intval($element->getValue('reservation_on'));
if($available == 0){
exit();
}
$reserved_amount = $element->getValue('reservation_amount');
$element->setValue('reservation_amount', $reserved_amount + $amount);
$element->commit();
$objects_expire->clear($order_item);
$objects_coll->delObject($order_item);
}
}
?>
Осталось только настроить его исполнение в cron табе. Сборщик мусора средствами класса umiObjectsExpiration получает все "просроченные" товарные позиции, проверяет связанные с ними товары, если у товаров включена резервация, то их зарезервированное количество восполняется, товарная позиция и запись о ней в таблице cms3_objects_expiration убиваются.