כיצד פועלת החזרה חלקית ב-1C-Bitrix
לקוח קנה מקרר ב-320–460 דולר ומכונת כביסה ב-220–320 דולר. המקרר פגום — יש להחזיר רק פריט זה. בחנות אלקטרוניקה מקוונת עם 200 הזמנות ביום, 15% דורשות החזרות חלקיות. עיבוד ידני אורך 20 דקות להחזרה, ושגיאות חישוב מובילות לקנסות של עד 450–650 דולר לפי החוק הפדרלי 54-FZ. ב-Bitrix, החזרה חלקית ללא הגדרה מותאמת אישית עובדת גרוע: הנחות הזמנה, קופונים, שיעורי מע"מ שונים, משלוח — כל אלה צריכים להיות מחושבים מחדש. אם עושים זאת בצורה נאיבית, הסכומים לא יתאמו, והקבלה הפיסקלית תכיל שגיאות. בואו נצלול לאיך להגדיר נכון החזרות חלקיות באמצעות Sale API ומערכות תשלום.
אנחנו, צוות עם שנים של ניסיון בפיתוח Bitrix (ביצענו 30+ פרויקטי החזרות), הכנו פתרון מוכן שמכסה את כל הניואנסים. להלן היישום הטכני שתוכלו להתאים לפרויקט שלכם. בפרויקט עם 10,000 הזמנות בחודש, החזרות חלקיות היוו 12% — ההגדרה חסכה 40 שעות עבודה של מנהלים בחודש.
בעיות שהחזרה חלקית פותרת
חישוב סכום ההחזרה עם הנחות מורכבות הוא החלק הקשה ביותר. אם להזמנה יש קופון או הנחת סל, שימוש במחיר הפריט בלבד אינו מספיק. יש לחשב מחדש את ההנחה עבור הפריט המוחזר. בנוסף, לפריטים עשויים להיות שיעורי מע"מ של 10%, 20% או 0% — הקבלה חייבת לציין את השיעור לכל פריט. בעיה נוספת היא החזרת משלוח: אם לא כל ההזמנה מוחזרת, בדרך כלל לא מחזירים את המשלוח, אבל יש יוצאים מן הכלל (למשל, ביטול כל הפריטים). והכי חשוב — פיסקליזציה: יש לשלוח את הקבלה ל-OFD בזמן שנקבע, אחרת קנס.
שלוש גישות לחישוב סכום ההחזרה
הגישה הפרופורציונלית מחלקת את ההנחה הכוללת בין כל הפריטים — פשוטה אבל יש לה שגיאת עיגול של עד 2%. גישת העלות בפועל (משתמשת במחיר הסופי אחרי הנחות) מדויקת פי 3 ותואמת ללוגיקה של Bitrix. גישת המחיר המקורי (ללא הנחות) נוחה לפריטים פגומים אבל החנות מפסידה את ההטבה של הקונה. אנו ממליצים על הגישה השנייה: היא ממזערת שגיאות ומשמשת בפרויקטי הייצור שלנו.
| גישה | דיוק | מורכבות | מתי להשתמש |
|---|---|---|---|
| פרופורציונלית | ±2% | נמוכה | מספר קטן של פריטים |
| עלות בפועל | גבוה | בינונית | רוב המקרים |
| מחיר מקורי | גבוה | נמוכה | פריטים פגומים |
להלן דוגמה ליישום מחשבון.
namespace Local\Returns;
class PartialReturnCalculator
{
public function calculateRefundAmount(\Bitrix\Sale\Order $order, array $returnItems): array
{
// $returnItems: [['basket_id' => int, 'quantity' => float], ...]
$refundItems = [];
$totalRefund = 0.0;
$basket = $order->getBasket();
foreach ($returnItems as $item) {
$basketItem = $basket->getItemById($item['basket_id']);
if (!$basketItem) continue;
$qty = min((float)$item['quantity'], $basketItem->getQuantity());
$pricePerUnit = $basketItem->getFinalPrice(); // цена с учётом скидок
$lineTotal = round($pricePerUnit * $qty, 2);
$refundItems[] = [
'basket_id' => $item['basket_id'],
'name' => $basketItem->getField('NAME'),
'quantity' => $qty,
'price' => $pricePerUnit,
'line_total' => $lineTotal,
'vat_rate' => $basketItem->getField('VAT_RATE') ?? 0,
];
$totalRefund += $lineTotal;
}
// Пересчёт доставки при частичном возврате
$shippingRefund = $this->calculateShippingRefund($order, $returnItems, $totalRefund);
return [
'items' => $refundItems,
'items_total' => $totalRefund,
'shipping_refund'=> $shippingRefund,
'total' => round($totalRefund + $shippingRefund, 2),
];
}
private function calculateShippingRefund(
\Bitrix\Sale\Order $order,
array $returnItems,
float $returnItemsTotal
): float {
// Если возвращается весь заказ — возвращаем доставку полностью
$totalOrderItems = 0;
$returnBasketIds = array_column($returnItems, 'basket_id');
foreach ($order->getBasket() as $item) {
$totalOrderItems++;
}
if (count($returnBasketIds) === $totalOrderItems) {
$shipment = $order->getShipmentCollection()->getSystemShipment();
return $shipment ? (float)$shipment->getDeliveryPrice() : 0.0;
}
// Иначе — доставка не возвращается (зависит от политики магазина)
return 0.0;
}
} איך ליצור החזרה חלקית באמצעות Sale API?
לאחר חישוב הסכום, צרו אובייקט namespace Local\Returns; class PartialReturnCalculator { public function calculateRefundAmount(\Bitrix\Sale\Order $order, array $returnItems): array { // $returnItems: [[\'basket_id\' => int, \'quantity\' => float], ...] $refundItems = []; $totalRefund = 0.0; $basket = $order->getBasket(); foreach ($returnItems as $item) { $basketItem = $basket->getItemById($item[\'basket_id\']); if (!$basketItem) continue; $qty = min((float)$item[\'quantity\'], $basketItem->getQuantity()); $pricePerUnit = $basketItem->getFinalPrice(); // цена с учётом скидок $lineTotal = round($pricePerUnit * $qty, 2); $refundItems[] = [ \'basket_id\' => $item[\'basket_id\'], \'name\' => $basketItem->getField(\'NAME\'), \'quantity\' => $qty, \'price\' => $pricePerUnit, \'line_total\' => $lineTotal, \'vat_rate\' => $basketItem->getField(\'VAT_RATE\') ?? 0, ]; $totalRefund += $lineTotal; } // Пересчёт доставки при частичном возврате $shippingRefund = $this->calculateShippingRefund($order, $returnItems, $totalRefund); return [ \'items\' => $refundItems, \'items_total\' => $totalRefund, \'shipping_refund\'=> $shippingRefund, \'total\' => round($totalRefund + $shippingRefund, 2), ]; } private function calculateShippingRefund( \Bitrix\Sale\Order $order, array $returnItems, float $returnItemsTotal ): float { // Если возвращается весь заказ — возвращаем доставку полностью $totalOrderItems = 0; $returnBasketIds = array_column($returnItems, \'basket_id\'); foreach ($order->getBasket() as $item) { $totalOrderItems++; } if (count($returnBasketIds) === $totalOrderItems) { $shipment = $order->getShipmentCollection()->getSystemShipment(); return $shipment ? (float)$shipment->getDeliveryPrice() : 0.0; } // Иначе — доставка не возвращается (зависит от политики магазина) return 0.0; } } וצרפו אליו פריטים. הנה דוגמה מלאה:
class PartialReturnManager {
public function create( int $orderId, array $returnItems, string $reason = '', int $userId = 0 ): int {
\Bitrix\Main\Loader::includeModule('sale');
$order = \Bitrix\Sale\Order::load($orderId);
if (!$order) throw new \RuntimeException("Order not found: {$orderId}");
if ($userId && $order->getUserId() !== $userId) {
throw new \RuntimeException("Access denied to order {$orderId}");
}
// Рассчитываем суммы
$calculator = new PartialReturnCalculator();
$refundData = $calculator->calculateRefundAmount($order, $returnItems);
// Создаём объект возврата
$orderReturn = \Bitrix\Sale\OrderReturn::create($order);
$orderReturn->setField('STATUS_ID', 'WAIT');
$orderReturn->setField('TYPE', 'MONEY');
$orderReturn->setField('REASON', $reason ?: 'Частичный возврат');
$orderReturn->setField('REFUND_AMOUNT', $refundData['total']);
$orderReturn->setField('COMMENT', $this->buildComment($refundData));
// Добавляем позиции возврата
foreach ($refundData['items'] as $item) {
$basketItem = $order->getBasket()->getItemById($item['basket_id']);
if (!$basketItem) continue;
$returnItem = $orderReturn->getReturn()->createItem($basketItem);
$returnItem->setField('QUANTITY', $item['quantity']);
$returnItem->setField('REASON', $reason);
}
$result = $orderReturn->save();
if (!$result->isSuccess()) {
throw new \RuntimeException(
'Partial return failed: ' . implode('; ', $result->getErrorMessages())
);
}
// Обновляем статус заказа, если нужно
$this->updateOrderAfterPartialReturn($order, $returnItems);
return $orderReturn->getId();
}
private function updateOrderAfterPartialReturn(
\Bitrix\Sale\Order $order, array $returnItems
): void {
$returnBasketIds = array_column($returnItems, 'basket_id');
$totalBasketItems = count([...$order->getBasket()]);
// Если возвращается последний товар — помечаем заказ как частично возвращённый
if (count($returnBasketIds) < $totalBasketItems) {
// Добавляем пользовательский статус "Частично возвращён"
// через поле USER_DESCRIPTION или кастомный статус
}
}
private function buildComment(array $refundData): string {
$lines = ['Частичный возврат:'];
foreach ($refundData['items'] as $item) {
$lines[] = sprintf(
'- %s × %s = %s руб.',
$item['name'],
$item['quantity'],
number_format($item['line_total'], 2)
);
}
if ($refundData['shipping_refund'] > 0) {
$lines[] = sprintf('- Доставка: %s руб.', number_format($refundData['shipping_refund'], 2));
}
$lines[] = sprintf('Итого к возврату: %s руб.', number_format($refundData['total'], 2));
return implode("\n", $lines);
}
} למה אינטגרציה עם מערכת תשלום חשובה?
רוב מערכות התשלום (YooKassa, Tinkoff, Sber) תומכות בהחזרה חלקית דרך API. הסיכון העיקרי: אם פשוט מחזירים את הכסף אבל לא שולחים קבלה, רשויות המס עלולות להטיל קנס של עד 450–650 דולר על אי-עמידה ב-54-FZ. הפתרון שלנו מייצר אוטומטית קבלת החזרה עם קודים נכונים ושולח אותה דרך ה-OFD. לפי תיעוד 1C-Bitrix, OrderReturn מטפל נכון בקישורים לעסקאות תשלום.
דוגמה עבור YooKassa:
class YooKassaPartialRefund {
public function refund(\Bitrix\Sale\Payment $payment, float $amount, array $items): bool {
$paymentId = $payment->getField(\'PS_INVOICE_ID\'); // ID платежа в ЮKassa
$receipt = $this->buildReceipt($items); // чек для ФНС
$response = $this->yukassaClient->createRefund([
\'payment_id\' => $paymentId,
\'amount\' => [
\'value\' => number_format($amount, 2, \'.\', \'\'),
\'currency\' => \'USD\',
],
\'description\' => \'Частичный возврат по заказу #\' . $payment->getOrderId(),
\'receipt\' => $receipt,
]);
return isset($response[\'id\']) && $response[\'status\'] !== \'canceled\';
}
private function buildReceipt(array $items): array {
$receiptItems = [];
foreach ($items as $item) {
$receiptItems[] = [
\'description\' => $item[\'name\'],
\'quantity\' => $item[\'quantity\'],
\'amount\' => [
\'value\' => number_format($item[\'price\'], 2, \'.\', \'\'),
\'currency\' => \'USD\',
],
\'vat_code\' => $this->vatRateToCode((float)$item[\'vat_rate\']),
\'payment_mode\' => \'full_payment\',
\'payment_subject\' => \'commodity\',
];
}
return [
\'customer\' => [\'email\' => $this->customerEmail],
\'items\' => $receiptItems,
];
}
}הנקודה המרכזית: במהלך החזרה חלקית, יש לשלוח קבלה לשירות המס הפדרלי דרך ה-OFD. YooKassa עושה זאת אוטומטית אם מעבירים את class PartialReturnManager { public function create( int $orderId, array $returnItems, string $reason = \'\', int $userId = 0 ): int { \Bitrix\Main\Loader::includeModule(\'sale\'); $order = \Bitrix\Sale\Order::load($orderId); if (!$order) throw new \RuntimeException("Order not found: {$orderId}"); if ($userId && $order->getUserId() !== $userId) { throw new \RuntimeException("Access denied to order {$orderId}"); } // Рассчитываем суммы $calculator = new PartialReturnCalculator(); $refundData = $calculator->calculateRefundAmount($order, $returnItems); // Создаём объект возврата $orderReturn = \Bitrix\Sale\OrderReturn::create($order); $orderReturn->setField(\'STATUS_ID\', \'WAIT\'); $orderReturn->setField(\'TYPE\', \'MONEY\'); $orderReturn->setField(\'REASON\', $reason ?: \'Частичный возврат\'); $orderReturn->setField(\'REFUND_AMOUNT\', $refundData[\'total\']); $orderReturn->setField(\'COMMENT\', $this->buildComment($refundData)); // Добавляем позиции возврата foreach ($refundData[\'items\'] as $item) { $basketItem = $order->getBasket()->getItemById($item[\'basket_id\']); if (!$basketItem) continue; $returnItem = $orderReturn->getReturn()->createItem($basketItem); $returnItem->setField(\'QUANTITY\', $item[\'quantity\']); $returnItem->setField(\'REASON\', $reason); } $result = $orderReturn->save(); if (!$result->isSuccess()) { throw new \RuntimeException( \'Partial return failed: \' . implode(\'; \', $result->getErrorMessages()) ); } // Обновляем статус заказа, если нужно $this->updateOrderAfterPartialReturn($order, $returnItems); return $orderReturn->getId(); } private function updateOrderAfterPartialReturn( \Bitrix\Sale\Order $order, array $returnItems ): void { $returnBasketIds = array_column($returnItems, \'basket_id\'); $totalBasketItems = count([...$order->getBasket()]); // Если возвращается последний товар — помечаем заказ как частично возвращённый if (count($returnBasketIds) < $totalBasketItems) { // Добавляем пользовательский статус "Частично возвращён" // через поле USER_DESCRIPTION или кастомный статус } } private function buildComment(array $refundData): string { $lines = [\'Частичный возврат:\']; foreach ($refundData[\'items\'] as $item) { $lines[] = sprintf( \'- %s × %s = %s руб.\', $item[\'name\'], $item[\'quantity\'], number_format($item[\'line_total\'], 2) ); } if ($refundData[\'shipping_refund\'] > 0) { $lines[] = sprintf(\'- Доставка: %s руб.\', number_format($refundData[\'shipping_refund\'], 2)); } $lines[] = sprintf(\'Итого к возврату: %s руб.\', number_format($refundData[\'total\'], 2)); return implode("\n", $lines); } } בבקשת ההחזרה.
מה כלול בעבודה?
| רכיב | תיאור |
|---|---|
| מחשבון סכום החזרה | מתחשב בהנחות, קופונים, מע"מ, כמות |
| API ליצירת החזרה | PartialReturnManager עם אימות ושמירה |
| ממשק בחירת פריטים | בלוחות לקוח וניהול |
| אינטגרציה עם מערכת תשלום | YooKassa, Sber, Tinkoff (החזרה חלקית) |
| יצירת קבלה פיסקלית | קבלה נכונה עם קודי 54-FZ |
| לוגיקת החזרת משלוח | ניתן להגדרה לפי מדיניות החנות |
תהליך היישום ולוח זמנים
- ניתוח — לימוד הזמנות נוכחיות, הנחות, מערכות תשלום.
- עיצוב — בחירת גישת חישוב, עיצוב API.
- יישום — כתיבת מחשבון, מנהל החזרות, אינטגרציית תשלום.
- בדיקות — אימות על הזמנות אמיתיות עם תרחישים שונים (פריט בודד, מספר פריטים, עם/בלי הנחות).
- פריסה ותיעוד — פריסה לסביבת ייצור, כתיבת הוראות למפעילים.
לוח זמנים: מכניקה בסיסית — 1–2 שבועות; גרסה מלאה עם אינטגרציה למספר מערכות תשלום ועמידה ב-54-FZ — 3–5 שבועות. אנו נותנים אחריות ל-6 חודשים על כל העבודה.
אם אתם רוצים ליישם החזרות חלקיות בפרויקט שלכם, צרו איתנו קשר — נעריך את הפרויקט וניתן לוחות זמנים מדויקים. התחילו ליישם החזרות חלקיות היום: הלקוחות שלכם יוכלו להחזיר רק פריטים לא רצויים, ורשויות המס יקבלו קבלות נכונות.

