\Bitrix\Sale\Fuser
— служебный класс модуля sale (Интернет-магазин), который управляет идентификатором покупателя (FUSER_ID).
Этот идентификатор — «якорь» для корзины, отложенных товаров и любых данных, которые должны переживать перезагрузки страниц и даже гостевой режим.

1) Что такое FUSER_ID и как он живёт
Цель: стабильно связать визит (гостя или пользователя) с корзиной.
Где хранится привязка:
- Сессия (
SALE_USER_ID
) — быстрый доступ в пределах PHP-сессии. - Cookie (
SALE_UID
) — при включённых настройках, позволяет «узнавать» гостя между сессиями. - БД (
b_sale_fuser
) — строка с датами, кодом и, при наличии,USER_ID
.
Создание/поиск FUSER:
- Ищем в сессии.
- Ищем в cookie (учёт кодированного значения при
encode_fuser_id=Y
). - Ищем по текущему
USER_ID
(если авторизован). - При необходимости создаём запись в
b_sale_fuser
(если вы не запретили создание).
Ключевые опции модуля sale
(меняют поведение куки):
sale.encode_fuser_id = Y
— в cookie хранится код, а не числовой ID.sale.use_secure_cookies = Y
— Secure-cookie (требует HTTPS).sale.save_anonymous_fuser_cookie = Y
— разрешить куки гостям при создании FUSER.
2) Обзор методов \Bitrix\Sale\Fuser
public static function getId(bool $skipCreate = false): ?int
Вернёт текущий FUSER_ID.
- skipCreate = false
(по умолчанию) — создаст, если нет.
- skipCreate = true
— только попробует найти, иначе null
.
public static function refreshSessionCurrentId(): ?int
Вернёт FUSER_ID из сессии, а если его нет — вызовет getId(true)
(без создания).
public static function getIdByUserId($userId)
→ false|int
Вернёт FUSER_ID для указанного USER_ID
. Если записи нет — создаст и вернёт ID. В нештатных случаях может вернуть false
— проверяйте.
public static function getUserIdById($fuserId): int
Вернёт USER_ID
, связанный с FUSER, либо 0
, если это «анонимный» FUSER.
public static function getRegeneratedId(): ?int
Только для авторизованных. Перегенерирует код FUSER, обновит cookie/сессию и вернёт FUSER_ID. Для гостей вернёт null
.
public static function add(array $options = []): \Bitrix\Sale\Result
Явно создаёт FUSER.
- $options['save'] = true
— записать cookie:
— для авторизованного пользователя — всегда;
— для анонима — только если sale.save_anonymous_fuser_cookie=Y
.
public static function update(int $id, array $options = []): \Bitrix\Sale\Result
Обновляет дату, при необходимости код/привязку, а также может сохранить cookie, но только если пользователь авторизован и ['save'=>true]
. Для гостей update(..., ['save'=>true])
cookie не поставит — это важно.
public static function deleteOld(int $days): void
Удалит «старые» анонимные FUSER без корзины, старше $days
.
public static function handlerOnUserLogin($userId, array $params): void
Вызов при успешной авторизации. Склеивает корзины (анонимную → пользовательскую), может обновить и сохранить cookie (см. ['update'=>true,'save'=>true]
).
public static function handlerOnUserLogout($userId): void
Очистит сессию и cookie FUSER.
3) Быстрый старт: минимальный рабочий пример
Универсальный подсчёт количества (без завязки на версии Bitrix)
<?php
use Bitrix\Main\Loader;
use Bitrix\Main\Context;
use Bitrix\Sale\Fuser;
use Bitrix\Sale\Basket;
require($_SERVER['DOCUMENT_ROOT'].'/bitrix/modules/main/include/prolog_before.php');
Loader::includeModule('sale');
$siteId = Context::getCurrent()->getSite();
$fuserId = Fuser::getId(); // создастся при необходимости
$basket = Basket::loadItemsForFUser($fuserId, $siteId);
// ВАЖНО: getQuantityList() в большинстве версий возвращает [BASKET_ITEM_ID => QTY].
// Надёжнее посчитать количество вручную:
$totalQty = 0;
foreach ($basket as $item) {
$totalQty += $item->getQuantity(); // это метод у BasketItem
}
echo 'Товаров в корзине: ' . $totalQty;
4) «Тихий режим»: проверить наличие корзины без создания
<?php
use Bitrix\Main\Loader;
use Bitrix\Main\Context;
use Bitrix\Sale\Fuser;
use Bitrix\Sale\Basket;
require($_SERVER['DOCUMENT_ROOT'].'/bitrix/modules/main/include/prolog_before.php');
Loader::includeModule('sale');
$siteId = Context::getCurrent()->getSite();
$fuserId = Fuser::getId(true); // не создаём
if ($fuserId === null) {
echo 'У пользователя ещё нет FUSER/корзины.';
return;
}
$basket = Basket::loadItemsForFUser($fuserId, $siteId);
// Общая сумма (учтите, что getPrice() может вернуть Decimal)
$total = 0.0;
foreach ($basket as $item) {
$total += $item->getFinalPrice();
}
echo 'Есть корзина на сумму: ' . $total . ' руб.';
5) Подгрузка корзины по FUSER_ID (D7) и вывод позиций
<?php
use Bitrix\Main\Loader;
use Bitrix\Main\Context;
use Bitrix\Sale\Fuser;
use Bitrix\Sale\Basket;
require($_SERVER['DOCUMENT_ROOT'].'/bitrix/modules/main/include/prolog_before.php');
Loader::includeModule('sale');
$siteId = Context::getCurrent()->getSite();
$fuserId = Fuser::getId();
$basket = Basket::loadItemsForFUser($fuserId, $siteId);
foreach ($basket as $item) {
printf(
"ID: %d | NAME: %s | QTY: %s | PRICE: %s\n",
$item->getId(),
$item->getField('NAME'),
$item->getQuantity(),
(string)$item->getPrice()
);
}
6) Получить FUSER_ID по USER_ID и обратно
<?php
use Bitrix\Main\Loader;
use Bitrix\Sale\Fuser;
require($_SERVER['DOCUMENT_ROOT'].'/bitrix/modules/main/include/prolog_before.php');
Loader::includeModule('sale');
// FUSER_ID по USER_ID (создаст при необходимости):
$userId = 123;
$fuserId = Fuser::getIdByUserId($userId);
if ($fuserId === false) {
echo 'Не удалось получить/создать FUSER для пользователя.';
return;
}
// USER_ID по FUSER_ID:
$backUserId = Fuser::getUserIdById($fuserId); // 0 — если FUSER «анонимный»
echo "FUSER_ID: {$fuserId}, USER_ID: {$backUserId}";
7) Склейка корзин при авторизации (кастомный логин)
<?php
use Bitrix\Main\Loader;
use Bitrix\Sale\Fuser;
require($_SERVER['DOCUMENT_ROOT'].'/bitrix/modules/main/include/prolog_before.php');
Loader::includeModule('sale');
// После кастомной авторизации:
$userId = 123;
Fuser::handlerOnUserLogin($userId, [
'update' => true, // обновить запись FUSER (дата, привязка)
'save' => true, // сохранить cookie (для авторизованного)
]);
// При выходе:
Fuser::handlerOnUserLogout($userId);
Хендлер внутри переносит позиции из «анонимной» корзины в пользовательскую (\CSaleBasket::TransferBasket(...)
) и может удалить старый FUSER при успехе.
8) Cookie и сессия: как правильно «сохранить» гостя
Важно понимать:
Fuser::update($id, ['save'=>true])
запишет cookie только если пользователь авторизован.- Для гостя cookie ставится в момент создания FUSER через
Fuser::add(['save'=>true])
и только при включённой опцииsale.save_anonymous_fuser_cookie=Y
.
Пример: принудительно сохранить cookie (для авторизованного)
<?php
use Bitrix\Main\Loader;
use Bitrix\Sale\Fuser;
require($_SERVER['DOCUMENT_ROOT'].'/bitrix/modules/main/include/prolog_before.php');
Loader::includeModule('sale');
$fuserId = Fuser::getId();
global $USER;
if (is_object($USER) && $USER->IsAuthorized()) {
Fuser::update($fuserId, ['save' => true]); // cookie запишется
}
Перегенерация кода FUSER (только для авторизованных)
<?php
use Bitrix\Main\Loader;
use Bitrix\Sale\Fuser;
require($_SERVER['DOCUMENT_ROOT'].'/bitrix/modules/main/include/prolog_before.php');
Loader::includeModule('sale');
$newFuserId = Fuser::getRegeneratedId(); // null для гостя
if ($newFuserId !== null) {
echo "Код FUSER перегенерирован. Новый ID: {$newFuserId}";
} else {
echo "Пользователь не авторизован — перегенерация невозможна.";
}
Обновить сессию без создания
<?php
use Bitrix\Main\Loader;
use Bitrix\Sale\Fuser;
require($_SERVER['DOCUMENT_ROOT'].'/bitrix/modules/main/include/prolog_before.php');
Loader::includeModule('sale');
$fuserId = Fuser::refreshSessionCurrentId(); // из сессии → поиск без создания
echo $fuserId === null ? 'FUSER нет' : "FUSER_ID: {$fuserId}";
9) AJAX-эндпоинт «сумма корзины» (минимум зависимостей)
<?php
// local/ajax/basket_total.php
use Bitrix\Main\Loader;
use Bitrix\Main\Context;
use Bitrix\Main\Engine\Response\Json;
use Bitrix\Sale\Fuser;
use Bitrix\Sale\Basket;
require($_SERVER['DOCUMENT_ROOT'].'/bitrix/modules/main/include/prolog_before.php');
if (!Loader::includeModule('sale')) {
(new Json(['error' => 'sale module required']))->send(); die();
}
$siteId = Context::getCurrent()->getSite();
$fuserId = Fuser::getId(); // создаст при необходимости
$basket = Basket::loadItemsForFUser($fuserId, $siteId);
$total = 0.0; $count = 0;
foreach ($basket as $item) {
$count += $item->getQuantity();
$total += $item->getFinalPrice();
}
(new Json([
'fuserId' => $fuserId,
'itemsCount' => $count,
'total' => $total,
]))->send(); die();
10) D7-контроллер API для фронта
<?php
// local/lib/Controller/BasketController.php
namespace Local\Controller;
use Bitrix\Main\Engine\Controller;
use Bitrix\Main\Engine\ActionFilter;
use Bitrix\Main\Context;
use Bitrix\Main\Loader;
use Bitrix\Sale\Fuser;
use Bitrix\Sale\Basket;
class BasketController extends Controller
{
public function configureActions(): array
{
return [
'summary' => [
'prefilters' => [
new ActionFilter\HttpMethod([ActionFilter\HttpMethod::METHOD_GET]),
new ActionFilter\Csrf(false),
],
],
];
}
public function summaryAction(): array
{
if (!Loader::includeModule('sale')) {
return ['error' => 'sale module required'];
}
$siteId = Context::getCurrent()->getSite();
$fuserId = Fuser::getId(); // создастся при необходимости
$basket = Basket::loadItemsForFUser($fuserId, $siteId);
$items = [];
$sum = 0.0; $qty = 0;
foreach ($basket as $item) {
$itemSum = $item->getFinalPrice();
$sum += $itemSum;
$qty += $item->getQuantity();
$items[] = [
'id' => $item->getId(),
'productId' => $item->getProductId(),
'name' => $item->getField('NAME'),
'qty' => $item->getQuantity(),
'price' => (string)$item->getPrice(),
'sum' => $itemSum,
];
}
return [
'fuserId' => $fuserId,
'qty' => $qty,
'sum' => $sum,
'items' => $items,
];
}
}
11) Добавление товара в корзину (надёжный D7-вариант)
<?php
use Bitrix\Main\Loader;
use Bitrix\Main\Context;
use Bitrix\Sale\Fuser;
use Bitrix\Sale\Basket;
use Bitrix\Sale\BasketItem;
require($_SERVER['DOCUMENT_ROOT'].'/bitrix/modules/main/include/prolog_before.php');
Loader::includeModule('sale');
$siteId = Context::getCurrent()->getSite();
$fuserId = Fuser::getId(); // создастся при первом добавлении
$basket = Basket::loadItemsForFUser($fuserId, $siteId);
$productId = (int)($_REQUEST['PRODUCT_ID'] ?? 0);
$qty = max(1, (float)($_REQUEST['QTY'] ?? 1));
if ($productId <= 0) { echo 'Неверный PRODUCT_ID'; die(); }
// Ищем существующую позицию каталога
$basketItem = $basket->getExistsItem('catalog', $productId);
if ($basketItem) {
$basketItem->setField('QUANTITY', $basketItem->getQuantity() + $qty);
} else {
/** @var BasketItem $basketItem */
$basketItem = $basket->createItem('catalog', $productId);
// Лучше setField по одному — меньше рисков
$basketItem->setField('QUANTITY', $qty);
$basketItem->setField('CURRENCY', \Bitrix\Currency\CurrencyManager::getBaseCurrency());
$basketItem->setField('LID', $siteId);
$basketItem->setField('PRODUCT_PROVIDER_CLASS', '\Bitrix\Catalog\Product\Basket');
$basketItem->setField('NAME', 'Товар #'.$productId);
}
$result = $basket->save();
echo $result->isSuccess()
? 'Добавлено. FUSER_ID: '.$fuserId
: 'Ошибка: '.implode('; ', $result->getErrorMessages());
12) Админ-утилита: «подменить» корзину под пользователя
<?php
use Bitrix\Main\Loader;
use Bitrix\Main\Engine\CurrentUser;
use Bitrix\Main\Context;
use Bitrix\Sale\Fuser;
use Bitrix\Sale\Basket;
require($_SERVER['DOCUMENT_ROOT'].'/bitrix/modules/main/include/prolog_before.php');
Loader::includeModule('sale');
if (!CurrentUser::get()->isAdmin()) { die('Доступ запрещён'); }
$userId = 123;
$fuserId = Fuser::getIdByUserId($userId);
if ($fuserId === false) { die('Не удалось получить FUSER'); }
$siteId = Context::getCurrent()->getSite();
$basket = Basket::loadItemsForFUser($fuserId, $siteId);
// Дальше — аудит/экспорт/миграция...
13) Очистка старых FUSER (агент)
<?php
use Bitrix\Main\Loader;
use Bitrix\Sale\Fuser;
function AgentDeleteOldFusers()
{
if (!Loader::includeModule('sale')) { return __FUNCTION__.'();'; }
// Удалит записи из b_sale_fuser:
// - старше 30 дней по DATE_UPDATE,
// - без USER_ID,
// - без позиций в b_sale_basket
Fuser::deleteOld(30);
return __FUNCTION__.'();';
}
14) Отладка и аналитика (микропримеры)
Вывести FUSER_ID в dataLayer:
<?php $fuserId = (int)\Bitrix\Sale\Fuser::getId(); ?>
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ event: 'fuserInit', fuserId: <?= $fuserId ?> });
</script>
Понять, есть ли корзина без создания:
$fuserId = \Bitrix\Sale\Fuser::getId(true);
$hasBasket = false;
if ($fuserId !== null) {
$basket = \Bitrix\Sale\Basket::loadItemsForFUser(
$fuserId,
\Bitrix\Main\Context::getCurrent()->getSite()
);
$qty = 0; $sum = 0.0;
foreach ($basket as $item) {
$qty += $item->getQuantity();
$sum += $item->getFinalPrice();
}
$hasBasket = $qty > 0 || $sum > 0;
}
15) Частые ошибки и как их избегать
- «Я вызывал
Fuser::update(..., ['save'=>true])
, а cookie гостю не ставится»
Так задумано. Для гостя cookie ставится только при создании FUSER и только еслиsale.save_anonymous_fuser_cookie=Y
. - «После логина корзина пустая»
В кастомной авторизации обязательно зовитеFuser::handlerOnUserLogin($userId, ['update'=>true, 'save'=>true])
. - «Метод
Basket::getQuantity()
не найден»
Он есть у элемента корзины (BasketItem
), а не уBasket
. Считайтеforeach ($basket as $item) { $item->getQuantity(); }
. - «getQuantityList() не содержит
QUANTITY_SUM
»
В большинстве установок метод возвращает массив вида[BASKET_ITEM_ID => QTY]
. Не полагайтесь наQUANTITY_SUM
— лучше считайте вручную. - «Сумма выводится криво»
В свежих версияхgetPrice()
может вернутьBitrix\Main\Type\Decimal
. Приводите к строке:(string)$basket->getPrice()
или округляйте->round(2)
. - «Cookie не ставится по HTTPS, но работает по HTTP»
Проверьтеsale.use_secure_cookies=Y
и чтобы сайт реально отдавался по HTTPS. Secure-cookie не пойдут по HTTP. - «Создаётся слишком много FUSER у анонимов»
На страницах без взаимодействия с корзиной используйтеFuser::getId(true)
илиrefreshSessionCurrentId()
.
16) Лучшие практики
- Разделяйте чтение и создание: где можно —
getId(true)
, где нужно —getId()
. - Следите за SITE_ID:
Basket::loadItemsForFUser()
всегда передавайте актуальныйSITE_ID
. - Включайте безопасность:
encode_fuser_id=Y
,use_secure_cookies=Y
, и в целом — постоянный HTTPS. - Склейка — под контролем: кастомный логин → обязательно
handlerOnUserLogin
. - Чистите хвосты: агент
deleteOld(n)
спасёт БД от мусора. - Считайте вручную: количество/сумму безопасно агрегировать в цикле, независимо от версии.
17) Резюме
- FUSER_ID — фундамент корзины в Битрикс.
- Основной вход —
Fuser::getId()
/getId(true)
; для авторизованных естьgetRegeneratedId()
. - Kуки для гостя — только при создании FUSER и при включённой опции; для авторизованного — через
update(..., ['save'=>true])
. - Для корректной работы корзины учитывайте SITE_ID, HTTPS и настройки модуля
sale
. - Надёжнее всего подсчитывать количество/сумму вручную, не полагаясь на «магические» поля.
Дополнение к статье: собранный мини-модуль FuserKit для версии 1С-Битрикс: Управление сайтом:
- REST-контроллер:
summary
,add
(с проверкой остатков),clear
,remove
; - Роуты под новый роутер;
- Агент очистки старых FUSER;
- AJAX-эндпоинты: сумма корзины, полная очистка, удаление одной позиции.
FuserKit — мини-модуль (REST + AJAX + Агент)
Структура
/local
/modules
/local.fuserkit
/install
index.php
version.php
include.php
/lib
/Controller
BasketController.php
/Agent
Cleanup.php
routes.php
/ajax
fuserkit_basket_total.php
fuserkit_basket_clear.php
fuserkit_basket_remove.php
После копирования установи модуль в админке:
«Маркетплейс → Установленные решения → local.fuserkit → Установить».
Это зарегистрирует агент. Роуты и AJAX начнут работать сразу.
/local/modules/local.fuserkit/install/version.php
<?php
$arModuleVersion = [
'VERSION' => '1.0.0',
'VERSION_DATE' => '2025-09-22 00:00:00',
];
/local/modules/local.fuserkit/install/index.php
<?php
use Bitrix\Main\Localization\Loc;
Loc::loadMessages(__FILE__);
class local_fuserkit extends CModule
{
public $MODULE_ID = 'local.fuserkit';
public $MODULE_VERSION;
public $MODULE_VERSION_DATE;
public $MODULE_NAME = 'FuserKit: утилиты для FUSER (контроллер + AJAX + агент)';
public $MODULE_DESCRIPTION = 'REST-контроллер корзины, AJAX-эндпоинты и агент очистки старых FUSER.';
public $PARTNER_NAME = 'Local';
public $PARTNER_URI = 'https://example.local'; // поле требуется формально
public function __construct()
{
$arModuleVersion = [];
include __DIR__ . '/version.php';
$this->MODULE_VERSION = $arModuleVersion['VERSION'];
$this->MODULE_VERSION_DATE = $arModuleVersion['VERSION_DATE'];
}
public function DoInstall()
{
RegisterModule($this->MODULE_ID);
// Ежедневный агент: чистить анонимные FUSER старше 30 дней
CAgent::AddAgent(
'\Local\FuserKit\Agent\Cleanup::run(30);',
$this->MODULE_ID,
'N',
86400, // раз в сутки
'',
'Y',
date('d.m.Y H:i:s', time() + 3600), // старт через час
100,
false,
false
);
}
public function DoUninstall()
{
CAgent::RemoveModuleAgents($this->MODULE_ID);
UnRegisterModule($this->MODULE_ID);
}
}
/local/modules/local.fuserkit/include.php
<?php
use Bitrix\Main\Loader;
Loader::registerAutoLoadClasses('local.fuserkit', [
'Local\\FuserKit\\Controller\\BasketController' => 'lib/Controller/BasketController.php',
'Local\\FuserKit\\Agent\\Cleanup' => 'lib/Agent/Cleanup.php',
]);
/local/modules/local.fuserkit/lib/Controller/BasketController.php
<?php
namespace Local\FuserKit\Controller;
use Bitrix\Main\Engine\ActionFilter;
use Bitrix\Main\Engine\Controller;
use Bitrix\Main\Context;
use Bitrix\Main\Loader;
use Bitrix\Sale\Fuser;
use Bitrix\Sale\Basket;
use Bitrix\Sale\BasketItem;
/**
* REST: работа с корзиной текущего FUSER.
*
* GET /rest/basket.summary → summaryAction
* POST /rest/basket.add → addAction
* POST /rest/basket.clear → clearAction
* POST /rest/basket.remove → removeAction
*/
class BasketController extends Controller
{
public function configureActions(): array
{
return [
'summary' => [
'prefilters' => [
new ActionFilter\HttpMethod([ActionFilter\HttpMethod::METHOD_GET]),
new ActionFilter\Csrf(false),
],
],
'add' => [
'prefilters' => [
new ActionFilter\HttpMethod([ActionFilter\HttpMethod::METHOD_POST]),
new ActionFilter\Csrf(false),
],
],
'clear' => [
'prefilters' => [
new ActionFilter\HttpMethod([ActionFilter\HttpMethod::METHOD_POST]),
new ActionFilter\Csrf(false),
],
],
'remove' => [
'prefilters' => [
new ActionFilter\HttpMethod([ActionFilter\HttpMethod::METHOD_POST]),
new ActionFilter\Csrf(false),
],
],
];
}
/**
* Сводка по корзине текущего FUSER.
*/
public function summaryAction(): array
{
if (!Loader::includeModule('sale')) {
return ['error' => 'sale module required'];
}
$siteId = Context::getCurrent()->getSite();
if (!$siteId) {
return ['error' => 'SITE_ID is not defined'];
}
$fuserId = Fuser::getId(); // создастся при необходимости
$basket = Basket::loadItemsForFUser($fuserId, $siteId);
$items = [];
$sum = 0.0;
$qty = 0.0;
foreach ($basket as $item) {
$itemSum = (float)$item->getFinalPrice();
$sum += $itemSum;
$itemQty = (float)$item->getQuantity();
$qty += $itemQty;
$items[] = [
'id' => (int)$item->getId(),
'productId' => (int)$item->getProductId(),
'name' => (string)$item->getField('NAME'),
'qty' => $itemQty,
'price' => (string)$item->getPrice(), // Decimal → строка
'sum' => $itemSum,
];
}
return [
'fuserId' => (int)$fuserId,
'qty' => $qty,
'sum' => $sum,
'items' => $items,
];
}
/**
* Добавить товар с проверкой остатков.
*
* POST:
* - productId (int, required)
* - qty (float, default=1)
* - strict (bool, default=false)
* strict=true → при нехватке остатка вернем ошибку и не добавим
* strict=false → добавим доступное количество (частично)
*/
public function addAction(): array
{
if (!Loader::includeModule('sale')) { return ['error' => 'sale module required']; }
if (!Loader::includeModule('catalog')) { return ['error' => 'catalog module required']; }
$request = $this->getRequest();
$productId = (int)($request->getPost('productId') ?? $request->get('productId'));
$qtyRaw = $request->getPost('qty') ?? $request->get('qty') ?? 1;
$qty = (float)$qtyRaw;
$strictRaw = $request->getPost('strict') ?? $request->get('strict') ?? '0';
$strict = in_array(strtolower((string)$strictRaw), ['1','y','yes','true'], true);
if ($productId <= 0) { return ['error' => 'Invalid productId']; }
if ($qty <= 0) { $qty = 1.0; }
$siteId = Context::getCurrent()->getSite();
if (!$siteId) { return ['error' => 'SITE_ID is not defined']; }
// Доступное количество по складам/настройкам каталога
$available = (float)\Bitrix\Catalog\Product\Basket::getAvailableQuantity($productId, $siteId);
if ($available <= 0.0) {
return ['error' => 'OUT_OF_STOCK', 'available' => 0.0, 'requested' => $qty];
}
$addQty = $qty;
$notice = null;
if ($qty > $available) {
if ($strict) {
return [
'error' => 'NOT_ENOUGH_STOCK',
'available' => $available,
'requested' => $qty,
];
}
$addQty = $available;
$notice = 'REQUESTED_QTY_REDUCED_TO_AVAILABLE';
}
$fuserId = Fuser::getId(); // создастся при добавлении
$basket = Basket::loadItemsForFUser($fuserId, $siteId);
// Если позиция уже есть — аккуратно проверяем максимум
$basketItem = $basket->getExistsItem('catalog', $productId);
if ($basketItem) {
$already = (float)$basketItem->getQuantity();
$maxForAdd = max(0.0, $available - $already);
if ($addQty > $maxForAdd) {
if ($strict) {
return [
'error' => 'NOT_ENOUGH_STOCK',
'available' => $available,
'inBasket' => $already,
'maxAdd' => $maxForAdd,
'requestedAdd'=> $addQty,
];
}
$addQty = $maxForAdd;
$notice = 'REQUESTED_QTY_REDUCED_TO_AVAILABLE';
}
if ($addQty <= 0.0) {
return ['error' => 'NOTHING_TO_ADD', 'available' => $available, 'inBasket' => $already];
}
$basketItem->setField('QUANTITY', $already + $addQty);
} else {
/** @var BasketItem $basketItem */
$basketItem = $basket->createItem('catalog', $productId);
$basketItem->setField('QUANTITY', $addQty);
$basketItem->setField('CURRENCY', \Bitrix\Currency\CurrencyManager::getBaseCurrency());
$basketItem->setField('LID', $siteId);
$basketItem->setField('PRODUCT_PROVIDER_CLASS', '\Bitrix\Catalog\Product\Basket');
$basketItem->setField('NAME', 'Товар #'.$productId);
}
$result = $basket->save();
if (!$result->isSuccess()) {
return ['error' => implode('; ', $result->getErrorMessages())];
}
// Резерв делается на этапе оформления заказа. Здесь только валидация/количество.
$summary = $this->summaryAction();
if ($notice) { $summary['notice'] = $notice; }
return $summary;
}
/**
* Очистить корзину текущего FUSER.
*/
public function clearAction(): array
{
if (!Loader::includeModule('sale')) { return ['error' => 'sale module required']; }
$siteId = Context::getCurrent()->getSite();
if (!$siteId) { return ['error' => 'SITE_ID is not defined']; }
$fuserId = Fuser::getId(); // если корзины не было — создастся пустая (это ок)
$basket = Basket::loadItemsForFUser($fuserId, $siteId);
foreach ($basket as $item) {
$item->delete();
}
$result = $basket->save();
if (!$result->isSuccess()) {
return ['error' => implode('; ', $result->getErrorMessages())];
}
return [
'fuserId' => (int)$fuserId,
'qty' => 0,
'sum' => 0,
'items' => [],
'status' => 'cleared',
];
}
/**
* Удалить одну позицию из корзины текущего FUSER.
*
* POST:
* - basketItemId (int) — приоритет
* ИЛИ
* - productId (int) — удалить позицию по товару ('catalog', productId)
*/
public function removeAction(): array
{
if (!Loader::includeModule('sale')) { return ['error' => 'sale module required']; }
$request = $this->getRequest();
$basketItemId = (int)($request->getPost('basketItemId') ?? $request->get('basketItemId'));
$productId = (int)($request->getPost('productId') ?? $request->get('productId'));
$siteId = Context::getCurrent()->getSite();
if (!$siteId) { return ['error' => 'SITE_ID is not defined']; }
$fuserId = Fuser::getId();
$basket = Basket::loadItemsForFUser($fuserId, $siteId);
$targetItem = null;
if ($basketItemId > 0) {
foreach ($basket as $item) {
if ((int)$item->getId() === $basketItemId) { $targetItem = $item; break; }
}
if (!$targetItem) {
return ['error' => 'BASKET_ITEM_NOT_FOUND_BY_ID', 'basketItemId' => $basketItemId];
}
} elseif ($productId > 0) {
$targetItem = $basket->getExistsItem('catalog', $productId);
if (!$targetItem) {
return ['error' => 'BASKET_ITEM_NOT_FOUND_BY_PRODUCT', 'productId' => $productId];
}
} else {
return ['error' => 'basketItemId or productId is required'];
}
$targetItem->delete();
$result = $basket->save();
if (!$result->isSuccess()) {
return ['error' => implode('; ', $result->getErrorMessages())];
}
return $this->summaryAction();
}
}
/local/modules/local.fuserkit/lib/Agent/Cleanup.php
<?php
namespace Local\FuserKit\Agent;
use Bitrix\Main\Loader;
use Bitrix\Sale\Fuser;
/**
* Агент очистки: удаляет анонимные FUSER без позиций, старше N дней.
*/
class Cleanup
{
public static function run(int $days = 30): string
{
if (Loader::includeModule('sale')) {
Fuser::deleteOld($days);
}
return __METHOD__.'('.(int)$days.');';
}
}
/local/routes.php
<?php
use Bitrix\Main\Routing\RoutingConfigurator;
use Bitrix\Main\Loader;
/**
* Роуты REST:
* GET /rest/basket.summary → summary
* POST /rest/basket.add → add
* POST /rest/basket.clear → clear
* POST /rest/basket.remove → remove
*/
return static function (RoutingConfigurator $routes) {
Loader::includeModule('local.fuserkit');
$routes->get(
'/rest/basket.summary',
[\Local\FuserKit\Controller\BasketController::class, 'summary']
);
$routes->post(
'/rest/basket.add',
[\Local\FuserKit\Controller\BasketController::class, 'add']
);
$routes->post(
'/rest/basket.clear',
[\Local\FuserKit\Controller\BasketController::class, 'clear']
);
$routes->post(
'/rest/basket.remove',
[\Local\FuserKit\Controller\BasketController::class, 'remove']
);
};
/local/ajax/fuserkit_basket_total.php
<?php
use Bitrix\Main\Loader;
use Bitrix\Main\Context;
use Bitrix\Main\Engine\Response\Json;
use Bitrix\Sale\Fuser;
use Bitrix\Sale\Basket;
header('Content-Type: application/json; charset=UTF-8');
require $_SERVER['DOCUMENT_ROOT'].'/bitrix/modules/main/include/prolog_before.php';
if (!Loader::includeModule('sale')) {
(new Json(['error' => 'sale module required']))->send(); die();
}
$siteId = Context::getCurrent()->getSite();
$fuserId = Fuser::getId();
$basket = Basket::loadItemsForFUser($fuserId, $siteId);
$totalQty = 0.0;
$totalSum = 0.0;
foreach ($basket as $item) {
$totalQty += (float)$item->getQuantity();
$totalSum += (float)$item->getFinalPrice();
}
(new Json([
'fuserId' => (int)$fuserId,
'itemsCount' => $totalQty,
'total' => $totalSum,
]))->send(); die();
/local/ajax/fuserkit_basket_clear.php
<?php
use Bitrix\Main\Loader;
use Bitrix\Main\Context;
use Bitrix\Main\Engine\Response\Json;
use Bitrix\Sale\Fuser;
use Bitrix\Sale\Basket;
header('Content-Type: application/json; charset=UTF-8');
require $_SERVER['DOCUMENT_ROOT'].'/bitrix/modules/main/include/prolog_before.php';
if (!Loader::includeModule('sale')) {
(new Json(['error' => 'sale module required']))->send(); die();
}
$siteId = Context::getCurrent()->getSite();
if (!$siteId) { (new Json(['error' => 'SITE_ID is not defined']))->send(); die(); }
$fuserId = Fuser::getId();
$basket = Basket::loadItemsForFUser($fuserId, $siteId);
foreach ($basket as $item) { $item->delete(); }
$result = $basket->save();
if (!$result->isSuccess()) {
(new Json(['error' => implode('; ', $result->getErrorMessages())]))->send(); die();
}
(new Json([
'fuserId' => (int)$fuserId,
'qty' => 0,
'sum' => 0,
'items' => [],
'status' => 'cleared',
]))->send(); die();
/local/ajax/fuserkit_basket_remove.php
<?php
use Bitrix\Main\Loader;
use Bitrix\Main\Context;
use Bitrix\Main\Engine\Response\Json;
use Bitrix\Sale\Fuser;
use Bitrix\Sale\Basket;
header('Content-Type: application/json; charset=UTF-8');
require $_SERVER['DOCUMENT_ROOT'].'/bitrix/modules/main/include/prolog_before.php';
if (!Loader::includeModule('sale')) {
(new Json(['error' => 'sale module required']))->send(); die();
}
$request = Context::getCurrent()->getRequest();
$basketItemId = (int)($request->getPost('basketItemId') ?? $request->get('basketItemId'));
$productId = (int)($request->getPost('productId') ?? $request->get('productId'));
$siteId = Context::getCurrent()->getSite();
if (!$siteId) { (new Json(['error' => 'SITE_ID is not defined']))->send(); die(); }
$fuserId = Fuser::getId();
$basket = Basket::loadItemsForFUser($fuserId, $siteId);
$targetItem = null;
if ($basketItemId > 0) {
foreach ($basket as $item) {
if ((int)$item->getId() === $basketItemId) { $targetItem = $item; break; }
}
if (!$targetItem) {
(new Json(['error' => 'BASKET_ITEM_NOT_FOUND_BY_ID', 'basketItemId' => $basketItemId]))->send(); die();
}
} elseif ($productId > 0) {
$targetItem = $basket->getExistsItem('catalog', $productId);
if (!$targetItem) {
(new Json(['error' => 'BASKET_ITEM_NOT_FOUND_BY_PRODUCT', 'productId' => $productId]))->send(); die();
}
} else {
(new Json(['error' => 'basketItemId or productId is required']))->send(); die();
}
$targetItem->delete();
$result = $basket->save();
if (!$result->isSuccess()) {
(new Json(['error' => implode('; ', $result->getErrorMessages())]))->send(); die();
}
// Сводка
$totalQty = 0.0; $totalSum = 0.0; $items = [];
foreach ($basket as $it) {
$totalQty += (float)$it->getQuantity();
$totalSum += (float)$it->getFinalPrice();
$items[] = [
'id' => (int)$it->getId(),
'productId' => (int)$it->getProductId(),
'name' => (string)$it->getField('NAME'),
'qty' => (float)$it->getQuantity(),
'price' => (string)$it->getPrice(),
'sum' => (float)$it->getFinalPrice(),
];
}
(new Json([
'fuserId' => (int)$fuserId,
'itemsCount' => $totalQty,
'total' => $totalSum,
'items' => $items,
'status' => 'removed',
]))->send(); die();
Быстрые проверки
REST:
<script>
// Сводка
fetch('/rest/basket.summary').then(r => r.json()).then(console.log);
// Добавить (частично, если не хватает остатков)
fetch('/rest/basket.add', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'},
body: new URLSearchParams({ productId: 18, qty: 3, strict: 0 })
}).then(r => r.json()).then(console.log);
// Очистить всю корзину
fetch('/rest/basket.clear', { method: 'POST' })
.then(r => r.json()).then(console.log);
// Удалить одну позицию по basketItemId
fetch('/rest/basket.remove', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'},
body: new URLSearchParams({ basketItemId: 12345 })
}).then(r => r.json()).then(console.log);
// Удалить по productId
fetch('/rest/basket.remove', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'},
body: new URLSearchParams({ productId: 18 })
}).then(r => r.json()).then(console.log);
AJAX:
/local/ajax/fuserkit_basket_total.php
/local/ajax/fuserkit_basket_clear.php
/local/ajax/fuserkit_basket_remove.php?productId=18
Нюансы
\Bitrix\Sale\Fuser::getId(true)
используем на «холодных» страницах, чтобы не плодить записи.- Cookie гостю ставятся только при создании FUSER и если включено
sale.save_anonymous_fuser_cookie=Y
. Fuser::update($id, ['save'=>true])
пишет cookie только авторизованному пользователю — это норма.Basket::getPrice()
/BasketItem::getPrice()
могут бытьDecimal
. Для вывода —(string)$price
, для математики —(float)
.- Остатки проверяем через
\Bitrix\Catalog\Product\Basket::getAvailableQuantity($productId, $siteId)
. Резерв делается при оформлении заказа.