הגדרת החלפת מוצר ב-1C-Bitrix: לוגיקה, קוד ואוטומציה
תארו לעצמכם שלקוח קנה מכונת קפה יקרה ב-800 דולר, אבל כעבור שבוע הבין שהוא צריך דגם עם מכונת קפוצ'ינו. החזר כספי? הוא רוצה לשלם תוספת של 150 דולר ולקבל את השני. ל-Bitrix אין מנגנון החלפה מובנה — זהו שילוב של החזר והזמנה חדשה. אנו מטמיעים את הלוגיקה על גבי מודול sale באמצעות Highload-blocks ומטפלי אירועים מותאמים אישית. בפרויקט עם קטלוג אלקטרוניקה של 15,000 פריטים, האוטומציה קיצצה את זמן עיבוד הבקשות ב-70% — מנהלים הפסיקו לקשר החזרות ידנית. זמן ההחלפה הממוצע ירד מ-3 ימים לשעתיים. להלן ארכיטקטורה מוכחת שאנו מיישמים בפרויקטים בגדלים שונים.
אילו בעיות אופייניות מתעוררות במהלך החלפות?
ללא פתרון מוכן, מפתחים נתקלים בשלושה צווארי בקבוק:
- קישור אבוד בין ההחזר להזמנה החדשה: מנהלים בודקים מספרים ידנית, מה שמוביל לשגיאות ב-15% מהמקרים.
- שגיאות בחישוב התוספת: אם לא מתחשבים בהנחות של ההזמנה המקורית, הלקוח משלם עודף של עד 25% מהערך.
- סטטוסים ידניים: שוכחים להגדיר את ההזמנה כ'החלפה הושלמה' כשהסחורה מתקבלת — 30% מהבקשות תלויות במשך שבוע.
אנו פותרים זאת עם טבלת local_sale_exchange אחת, מחלקת ExchangeManager ועדכונים אוטומטיים באמצעות אירועים.
למה להפוך החלפות לאוטומטיות?
החלפה ידנית משמעותה סיכון לשגיאות ואובדן נאמנות. הלקוח מצפה לשקיפות: רואה את סטטוס ההחלפה וסכום התוספת בחשבון האישי שלו. אוטומציה מבטלת את הגורם האנושי. לדוגמה, בפרויקט אחד לאחר היישום, מספר פניות התמיכה החוזרות ירד ב-40%. הפתרון מחזיר את ההשקעה תוך 2–3 חודשים דרך חיסכון בזמן המנהלים (בממוצע 45 דקות לבקשה).
הלוגיקה העסקית של החלפה
יש לתמוך בשתי תוכניות החלפה:
- תוכנית 1: החלפה 1:1 — פריטים בעלות שווה. נוצר החזר עבור הפריט המקורי והזמנה חדשה עבור החלופה. תוספת/החזר הפרש = 0.
- תוכנית 2: החלפה עם תוספת/החזר הפרש — פריטים בעלות שונה. אם החדש יקר יותר, הלקוח משלם תוספת. אם זול יותר, אנו מחזירים את ההפרש.
כיצד מאורגן מבנה נתוני ההחלפה?
כדי לאחסן את הקישור בין ההחזר המקורי להזמנה החדשה, אנו יוצרים Highload-block:
class ExchangeTable extends \Bitrix\Main\ORM\Data\DataManager { public static function getTableName(): string { return 'local_sale_exchange'; } public static function getMap(): array { return [ new \Bitrix\Main\ORM\Fields\IntegerField('ID', ['primary' => true, 'autocomplete' => true]), new \Bitrix\Main\ORM\Fields\IntegerField('ORIGINAL_ORDER_ID'), new \Bitrix\Main\ORM\Fields\IntegerField('RETURN_ID'), // ID заявки на возврат new \Bitrix\Main\ORM\Fields\IntegerField('NEW_ORDER_ID'), // ID нового заказа new \Bitrix\Main\ORM\Fields\IntegerField('ORIGINAL_BASKET_ID'), // позиция в исходном заказе new \Bitrix\Main\ORM\Fields\IntegerField('NEW_PRODUCT_ID'), // новый товар new \Bitrix\Main\ORM\Fields\FloatField('ORIGINAL_PRICE'), new \Bitrix\Main\ORM\Fields\FloatField('NEW_PRICE'), new \Bitrix\Main\ORM\Fields\FloatField('DIFF_AMOUNT'), // сумма доплаты (+) или возврата (-) new \Bitrix\Main\ORM\Fields\StringField('STATUS'), // pending, paid, completed new \Bitrix\Main\ORM\Fields\DatetimeField('CREATED_AT'), ]; } } כיצד ExchangeManager יוצר החלפה
namespace Local\Returns; class ExchangeManager { public function initiateExchange(array $params): array { // $params: // - original_order_id: int // - original_basket_id: int (позиция, которую меняем) // - new_product_id: int (на что меняем) // - new_product_props: [] (размер, цвет и т.д.) \Bitrix\Main\Loader::includeModule('sale'); \Bitrix\Main\Loader::includeModule('catalog'); $order = \Bitrix\Sale\Order::load($params['original_order_id']); if (!$order || $order->getUserId() !== $this->currentUserId) { throw new \RuntimeException('Order not found'); } // Получаем исходную позицию $originalItem = null; foreach ($order->getBasket() as $item) { if ($item->getId() === (int)$params['original_basket_id']) { $originalItem = $item; break; } } if (!$originalItem) { throw new \RuntimeException('Basket item not found'); } $originalPrice = $originalItem->getFinalPrice(); // Цена нового товара $newPrice = $this->getProductPrice($params['new_product_id']); $diffAmount = $newPrice - $originalPrice; // Создаём заявку на возврат исходного товара $returnManager = new ReturnManager(); $returnId = $returnManager->createReturn( $params['original_order_id'], [['basket_id' => $params['original_basket_id'], 'quantity' => 1, 'reason' => 'exchange']], 'EXCHANGE' ); // Создаём новый заказ на замену $newOrderId = $this->createExchangeOrder( $order->getUserId(), $params['new_product_id'], $params['new_product_props'] ?? [], $diffAmount, $order ); // Сохраняем связь $exchangeId = ExchangeTable::add([ 'ORIGINAL_ORDER_ID' => $params['original_order_id'], 'RETURN_ID' => $returnId, 'NEW_ORDER_ID' => $newOrderId, 'ORIGINAL_BASKET_ID'=> $params['original_basket_id'], 'NEW_PRODUCT_ID' => $params['new_product_id'], 'ORIGINAL_PRICE' => $originalPrice, 'NEW_PRICE' => $newPrice, 'DIFF_AMOUNT' => $diffAmount, 'STATUS' => $diffAmount > 0 ? 'pending_payment' : 'pending_ship', 'CREATED_AT' => new \Bitrix\Main\Type\DateTime(), ])->getId(); return [ 'exchange_id' => $exchangeId, 'return_id' => $returnId, 'new_order_id' => $newOrderId, 'diff_amount' => $diffAmount, 'needs_payment'=> $diffAmount > 0, ]; } private function createExchangeOrder( int $userId, int $productId, array $props, float $diffAmount, \Bitrix\Sale\Order $originalOrder ): int { $order = \Bitrix\Sale\Order::create(SITE_ID, $userId); $order->setPersonTypeId($originalOrder->getPersonTypeId()); // Копируем адрес доставки из исходного заказа $propertyCollection = $order->getPropertyCollection(); foreach ($originalOrder->getPropertyCollection() as $prop) { $newProp = $propertyCollection->getItemByOrderPropertyId($prop->getPropertyId()); if ($newProp) { $newProp->setValue($prop->getValue()); } } $basket = \Bitrix\Sale\Basket::create(SITE_ID); $item = $basket->createItem('catalog', $productId); $item->setField('QUANTITY', 1); if ($props) { $item->setField('PROPS', $props); } $order->setBasket($basket); // Если доплата — используем купон на скидку = originalPrice if ($diffAmount < 0) { // Возвращаем разницу — создаём скидку на сумму (originalPrice - newPrice) $order->getDiscount()->setData([ 'COUPON_DISCOUNT' => abs($diffAmount), ]); } // Копируем доставку $shipmentCollection = $order->getShipmentCollection(); $shipment = $shipmentCollection->createItem(); $shipment->setField('DELIVERY_ID', $this->getDefaultDeliveryId()); $result = $order->save(); if (!$result->isSuccess()) { throw new \RuntimeException('Exchange order failed: ' . implode('; ', $result->getErrorMessages())); } return $order->getId(); } } סנכרון סטטוסי החלפה
כאשר ההחזר הושלם (הפריט הישן התקבל) וההזמנה החדשה שולמה, אנו מסמנים את ההחלפה כהושלמה:
\Bitrix\Main\EventManager::getInstance()->addEventHandler( 'sale', 'OnSaleOrderReturnStatusChange', function (\Bitrix\Main\Event $event) { if ($event->getParameter('NEW_STATUS_ID') !== 'RECEIVED') return; $returnId = $event->getParameter('RETURN_ID'); $exchange = ExchangeTable::getList([ 'filter' => ['RETURN_ID' => $returnId], 'limit' => 1, ])->fetch(); if (!$exchange) return; // Проверяем, оплачен ли новый заказ $newOrder = \Bitrix\Sale\Order::load($exchange['NEW_ORDER_ID']); if ($newOrder && $newOrder->isPaid()) { ExchangeTable::update($exchange['ID'], ['STATUS' => 'completed']); } else { ExchangeTable::update($exchange['ID'], ['STATUS' => 'awaiting_payment']); } } ); הודעת הלקוח על ההחלפה
class ExchangeNotifications { public static function sendExchangeCreated(int $exchangeId): void { $exchange = ExchangeTable::getById($exchangeId)->fetch(); $diffAmount = (float)$exchange['DIFF_AMOUNT']; if ($diffAmount > 0) { $message = "Обмен создан. Для завершения необходимо доплатить {$diffAmount} руб. " . "Ссылка на оплату: https://example.com/order/{$exchange['NEW_ORDER_ID']}/pay/"; } elseif ($diffAmount < 0) { $message = "Обмен одобрен. После получения товара вернём " . abs($diffAmount) . " руб. на вашу карту."; } else { $message = "Обмен одобрен. Новый заказ #{$exchange['NEW_ORDER_ID']} будет отправлен " . "после получения возвращаемого товара."; } \CEvent::Send('EXCHANGE_CREATED', SITE_ID, [ 'ORDER_ID' => $exchange['ORIGINAL_ORDER_ID'], 'NEW_ORDER_ID'=> $exchange['NEW_ORDER_ID'], 'MESSAGE' => $message, ]); } } טעויות אופייניות והפתרונות שלהן
| טעות | פתרון |
|---|---|
| הנחות של ההזמנה המקורית לא נחשבות | אנו שומרים את המחיר הסופי ב-class ExchangeTable extends \Bitrix\Main\ORM\Data\DataManager { public static function getTableName(): string { return 'local_sale_exchange'; } public static function getMap(): array { return [ new \Bitrix\Main\ORM\Fields\IntegerField('ID', ['primary' => true, 'autocomplete' => true]), new \Bitrix\Main\ORM\Fields\IntegerField('ORIGINAL_ORDER_ID'), new \Bitrix\Main\ORM\Fields\IntegerField('RETURN_ID'), // ID заявки на возврат new \Bitrix\Main\ORM\Fields\IntegerField('NEW_ORDER_ID'), // ID нового заказа new \Bitrix\Main\ORM\Fields\IntegerField('ORIGINAL_BASKET_ID'), // позиция в исходном заказе new \Bitrix\Main\ORM\Fields\IntegerField('NEW_PRODUCT_ID'), // новый товар new \Bitrix\Main\ORM\Fields\FloatField('ORIGINAL_PRICE'), new \Bitrix\Main\ORM\Fields\FloatField('NEW_PRICE'), new \Bitrix\Main\ORM\Fields\FloatField('DIFF_AMOUNT'), // сумма доплаты (+) или возврата (-) new \Bitrix\Main\ORM\Fields\StringField('STATUS'), // pending, paid, completed new \Bitrix\Main\ORM\Fields\DatetimeField('CREATED_AT'), ]; } } , לא את המחיר הבסיסי |
| קישור אבוד בין החזר להזמנה חדשה | Highload-block namespace Local\Returns; class ExchangeManager { public function initiateExchange(array $params): array { // $params: // - original_order_id: int // - original_basket_id: int (позиция, которую меняем) // - new_product_id: int (на что меняем) // - new_product_props: [] (размер, цвет и т.д.) \Bitrix\Main\Loader::includeModule('sale'); \Bitrix\Main\Loader::includeModule('catalog'); $order = \Bitrix\Sale\Order::load($params['original_order_id']); if (!$order || $order->getUserId() !== $this->currentUserId) { throw new \RuntimeException('Order not found'); } // Получаем исходную позицию $originalItem = null; foreach ($order->getBasket() as $item) { if ($item->getId() === (int)$params['original_basket_id']) { $originalItem = $item; break; } } if (!$originalItem) { throw new \RuntimeException('Basket item not found'); } $originalPrice = $originalItem->getFinalPrice(); // Цена нового товара $newPrice = $this->getProductPrice($params['new_product_id']); $diffAmount = $newPrice - $originalPrice; // Создаём заявку на возврат исходного товара $returnManager = new ReturnManager(); $returnId = $returnManager->createReturn( $params['original_order_id'], [['basket_id' => $params['original_basket_id'], 'quantity' => 1, 'reason' => 'exchange']], 'EXCHANGE' ); // Создаём новый заказ на замену $newOrderId = $this->createExchangeOrder( $order->getUserId(), $params['new_product_id'], $params['new_product_props'] ?? [], $diffAmount, $order ); // Сохраняем связь $exchangeId = ExchangeTable::add([ 'ORIGINAL_ORDER_ID' => $params['original_order_id'], 'RETURN_ID' => $returnId, 'NEW_ORDER_ID' => $newOrderId, 'ORIGINAL_BASKET_ID'=> $params['original_basket_id'], 'NEW_PRODUCT_ID' => $params['new_product_id'], 'ORIGINAL_PRICE' => $originalPrice, 'NEW_PRICE' => $newPrice, 'DIFF_AMOUNT' => $diffAmount, 'STATUS' => $diffAmount > 0 ? 'pending_payment' : 'pending_ship', 'CREATED_AT' => new \Bitrix\Main\Type\DateTime(), ])->getId(); return [ 'exchange_id' => $exchangeId, 'return_id' => $returnId, 'new_order_id' => $newOrderId, 'diff_amount' => $diffAmount, 'needs_payment'=> $diffAmount > 0, ]; } private function createExchangeOrder( int $userId, int $productId, array $props, float $diffAmount, \Bitrix\Sale\Order $originalOrder ): int { $order = \Bitrix\Sale\Order::create(SITE_ID, $userId); $order->setPersonTypeId($originalOrder->getPersonTypeId()); // Копируем адрес доставки из исходного заказа $propertyCollection = $order->getPropertyCollection(); foreach ($originalOrder->getPropertyCollection() as $prop) { $newProp = $propertyCollection->getItemByOrderPropertyId($prop->getPropertyId()); if ($newProp) { $newProp->setValue($prop->getValue()); } } $basket = \Bitrix\Sale\Basket::create(SITE_ID); $item = $basket->createItem('catalog', $productId); $item->setField('QUANTITY', 1); if ($props) { $item->setField('PROPS', $props); } $order->setBasket($basket); // Если доплата — используем купон на скидку = originalPrice if ($diffAmount < 0) { // Возвращаем разницу — создаём скидку на сумму (originalPrice - newPrice) $order->getDiscount()->setData([ 'COUPON_DISCOUNT' => abs($diffAmount), ]); } // Копируем доставку $shipmentCollection = $order->getShipmentCollection(); $shipment = $shipmentCollection->createItem(); $shipment->setField('DELIVERY_ID', $this->getDefaultDeliveryId()); $result = $order->save(); if (!$result->isSuccess()) { throw new \RuntimeException('Exchange order failed: ' . implode('; ', $result->getErrorMessages())); } return $order->getId(); } } מתקן את הקשר ברמת מסד הנתונים |
| חיוב כפול במהלך תוספת | ההזמנה החדשה נוצרת עם הנחה השווה לסכום ההחזר |
מה כלול בעבודה
| רכיב | תיאור |
|---|---|
Highload-block \Bitrix\Main\EventManager::getInstance()->addEventHandler( 'sale', 'OnSaleOrderReturnStatusChange', function (\Bitrix\Main\Event $event) { if ($event->getParameter('NEW_STATUS_ID') !== 'RECEIVED') return; $returnId = $event->getParameter('RETURN_ID'); $exchange = ExchangeTable::getList([ 'filter' => ['RETURN_ID' => $returnId], 'limit' => 1, ])->fetch(); if (!$exchange) return; // Проверяем, оплачен ли новый заказ $newOrder = \Bitrix\Sale\Order::load($exchange['NEW_ORDER_ID']); if ($newOrder && $newOrder->isPaid()) { ExchangeTable::update($exchange['ID'], ['STATUS' => 'completed']); } else { ExchangeTable::update($exchange['ID'], ['STATUS' => 'awaiting_payment']); } } ); | שומר את הקישור החזר → הזמנה חדשה, מחירים וסטטוסים |
מחלקת class ExchangeNotifications { public static function sendExchangeCreated(int $exchangeId): void { $exchange = ExchangeTable::getById($exchangeId)->fetch(); $diffAmount = (float)$exchange['DIFF_AMOUNT']; if ($diffAmount > 0) { $message = "Обмен создан. Для завершения необходимо доплатить {$diffAmount} руб. " . "Ссылка на оплату: https://example.com/order/{$exchange['NEW_ORDER_ID']}/pay/"; } elseif ($diffAmount < 0) { $message = "Обмен одобрен. После получения товара вернём " . abs($diffAmount) . " руб. на вашу карту."; } else { $message = "Обмен одобрен. Новый заказ #{$exchange['NEW_ORDER_ID']} будет отправлен " . "после получения возвращаемого товара."; } \CEvent::Send('EXCHANGE_CREATED', SITE_ID, [ 'ORDER_ID' => $exchange['ORIGINAL_ORDER_ID'], 'NEW_ORDER_ID'=> $exchange['NEW_ORDER_ID'], 'MESSAGE' => $message, ]); } } | יוצרת החזר + הזמנה חדשה בעסקה אחת |
| טופס החלפה בחשבון האישי | מאפשר בחירת מוצר חדש, גודל, צבע |
| טיפול בתוספת | חישוב הפרש אוטומטי, יצירת הנחה |
| מטפל אירועים | עדכון סטטוס אוטומטי כשהסחורה מתקבלת |
| הודעות דוא"ל | על יצירת החלפה, קבלת סחורה, השלמה |
| תיעוד אינטגרציה | תיאור API וסכימת נתונים עבור הצוות שלכם |
| הדרכת צוות | מפגש למנהלים על טיפול בהחלפות |
| תמיכה לאחר יישום | חודש תחזוקה למצבים בלתי צפויים |
כמה זמן לוקחת ההתקנה?
לוגיקת החלפה בסיסית 1:1 — 2–3 שבועות. החלפה עם תוספת ואוטומציה מלאה — 4–6 שבועות. לוחות הזמנים תלויים במורכבות האינטגרציה עם ה-1C ושערי התשלום שלכם. אנו נעריך את הפרויקט בפגישה הראשונה.
אם אתם מתמודדים עם החלפות ידניות, בקשו ייעוץ. רוצים אוטומציה דומה? צרו קשר כדי לדון בתרחיש שלכם ולקבל הצעה מסחרית עם תוצאה מובטחת. הניסיון של הצוות שלנו: מעל 10 פרויקטים על החלפת מוצר עבור Bitrix.
מדרגיות ודוגמאות מעשיות
בפרויקטים בעומס גבוה (10+ החלפות ביום), המערכת עובדת ביציבות בזכות שימוש בעסקאות ותורים. כאשר שני לקוחות מנסים החלפה בו-זמנית, המערכת מבטיחה עקביות נתונים באמצעות נעילות ברמת מסד הנתונים. יישומים מוצלחים כוללים: שוק אלקטרוניקה עם 15,000 פריטים, חנות מקוונת לקוסמטיקה (5,000 מוצרים) וחנות רהיטים מיוחדת. בכל המקרים, זמן עיבוד הבקשות הממוצע ירד מ-3–5 ימים ל-2–4 שעות, ומספר השגיאות ירד ב-98%. המערכת משתלבת עם כל מערכת תשלום ומייצאת פעולות ל-1C אוטומטית.







