Разработка калькулятора доставки для интернет-магазина
Покупатель не должен добавлять товар в корзину, вводить адрес, нажимать «Оформить» и только на последнем шаге узнавать, что доставка стоит половину от заказа. Это одна из главных причин брошенных корзин. Калькулятор доставки решает эту проблему — показывает стоимость сразу, до начала оформления.
Что считает калькулятор
Стоимость доставки зависит от параметров, которые нужно получить из разных источников:
- Откуда везут — адрес склада или ближайшего магазина
- Куда везут — адрес покупателя, до двери или до пункта выдачи
- Что везут — вес и габариты товаров в корзине
- Какой способ — курьер, ПВЗ, постамат, Почта России
- Когда нужно — стандарт или экспресс
Параметры товаров хранятся в базе данных интернет-магазина. Тарифы доставки — либо в собственных таблицах (для партнёрских договоров с фиксированными ценами), либо приходят в реальном времени через API службы доставки.
Собственные тарифные таблицы
Для простых случаев — когда есть договор с фиксированными ценами или доставка своими силами — тарифы хранятся локально:
CREATE TABLE shipping_zones (
id SERIAL PRIMARY KEY,
name VARCHAR(100),
regions TEXT[], -- массив кодов регионов или городов
base_price DECIMAL(10,2),
price_per_kg DECIMAL(10,2),
price_per_km DECIMAL(10,2),
min_days INT,
max_days INT
);
CREATE TABLE shipping_methods (
id SERIAL PRIMARY KEY,
zone_id INT REFERENCES shipping_zones(id),
name VARCHAR(100),
carrier VARCHAR(50),
multiplier DECIMAL(4,2) DEFAULT 1.0, -- для экспресс-доставки
free_from DECIMAL(10,2) -- сумма заказа, с которой доставка бесплатна
);
class LocalShippingCalculator
{
public function calculate(Cart $cart, Address $destination): Collection
{
$zone = $this->zoneDetector->detect($destination->city);
$weight = $cart->totalWeight(); // кг
$orderTotal = $cart->total();
return ShippingMethod::where('zone_id', $zone->id)->get()
->map(function (ShippingMethod $method) use ($weight, $orderTotal, $zone) {
$cost = $zone->base_price + ($weight * $zone->price_per_kg);
$cost *= $method->multiplier;
// Бесплатная доставка от определённой суммы
if ($method->free_from && $orderTotal >= $method->free_from) {
$cost = 0;
}
return [
'id' => $method->id,
'name' => $method->name,
'carrier' => $method->carrier,
'cost' => round($cost, 2),
'min_days' => $zone->min_days * $method->multiplier < 1 ? 1 : (int)($zone->min_days / $method->multiplier),
'max_days' => $zone->max_days,
'free' => $cost === 0.0,
];
});
}
}
API-расчёт в реальном времени
Когда нужны актуальные тарифы службы доставки, запрос уходит в их API. Пример с СДЭК:
class CdekShippingCalculator
{
private string $baseUrl = 'https://api.cdek.ru/v2';
public function calculate(
string $fromCity,
string $toCity,
float $weight,
array $dimensions
): array {
$token = $this->authenticate();
$response = Http::withToken($token)
->post("{$this->baseUrl}/calculator/tarifflist", [
'from_location' => ['city' => $fromCity],
'to_location' => ['city' => $toCity],
'packages' => [[
'weight' => (int)($weight * 1000), // граммы
'length' => $dimensions['length'],
'width' => $dimensions['width'],
'height' => $dimensions['height'],
]],
]);
return collect($response->json('tariff_codes'))
->map(fn($t) => [
'tariff_code' => $t['tariff_code'],
'tariff_name' => $t['tariff_name'],
'cost' => $t['delivery_sum'],
'min_days' => $t['period_min'],
'max_days' => $t['period_max'],
])
->toArray();
}
}
Агрегация нескольких служб
Реальный калькулятор обычно показывает варианты от нескольких служб одновременно. Запросы уходят параллельно:
public function getShippingOptions(Cart $cart, Address $address): array
{
$weight = $cart->totalWeight();
$dimensions = $cart->boundingBox();
// Параллельные запросы к службам доставки
$results = collect([
'cdek' => fn() => $this->cdek->calculate($address, $weight, $dimensions),
'boxberry' => fn() => $this->boxberry->calculate($address, $weight, $dimensions),
'pochta' => fn() => $this->russianPost->calculate($address, $weight, $dimensions),
])->map(function ($calculator, $carrier) {
try {
return $calculator();
} catch (\Exception $e) {
// Если один из сервисов недоступен — не ломаем всё
logger()->warning("Shipping calculator error: $carrier", ['error' => $e->getMessage()]);
return [];
}
})->flatten(1)->sortBy('cost')->values();
return $results->toArray();
}
Если СДЭК вернул ошибку — показываем только Почту России и Boxberry. Покупатель не видит ошибку, видит меньше вариантов.
Кеширование расчётов
Запросы к API службы доставки — медленные (200–800 мс) и платные (некоторые шлюзы считают обращения). Кешировать стоит по ключу из параметров:
public function calculateCached(string $fromCity, string $toCity, float $weight): array
{
$cacheKey = "shipping:{$fromCity}:{$toCity}:" . round($weight, 1);
return Cache::remember($cacheKey, now()->addMinutes(30), function () use ($fromCity, $toCity, $weight) {
return $this->calculate($fromCity, $toCity, $weight);
});
}
Тарифы меняются редко — 30 минут вполне достаточно. При обновлении тарифов — инвалидация кеша по паттерну shipping:*.
Интерфейс калькулятора
На странице товара или корзины — компактный блок: поле ввода города или индекса, список методов с ценами и сроками. Без перезагрузки страницы:
const ShippingCalculator = () => {
const [city, setCity] = useState('');
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const calculate = useMemo(
() =>
debounce(async (cityValue) => {
if (cityValue.length < 3) return;
setLoading(true);
try {
const res = await fetch('/api/shipping/calculate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ city: cityValue, cart_id: cartId }),
});
const data = await res.json();
setOptions(data.options);
} finally {
setLoading(false);
}
}, 600),
[cartId]
);
return (
<div className="shipping-calculator">
<input
value={city}
onChange={(e) => { setCity(e.target.value); calculate(e.target.value); }}
placeholder="Введите ваш город"
/>
{loading && <Spinner />}
{options.map((opt) => (
<ShippingOption key={opt.id} option={opt} />
))}
</div>
);
};
Дебаунс на 600 мс — не стреляем запросами после каждого символа.
Объёмный вес
Многие службы считают оплачиваемый вес как максимум из фактического и объёмного:
public function billableWeight(float $actualKg, array $dimensions): float
{
// Стандартный коэффициент: 1 кг = 5000 см³
$volumetricWeight = ($dimensions['length'] * $dimensions['width'] * $dimensions['height']) / 5000;
return max($actualKg, $volumetricWeight);
}
Для воздушной доставки коэффициент другой (6000 или 6800 см³/кг), для морской — ещё другой. Это нужно учитывать при расчёте, иначе расценки будут занижены.
Сроки разработки
Калькулятор с одной службой доставки по фиксированным тарифам — 2–3 дня. С реальным API одной службы — 3–5 дней (включая обработку ошибок и кеширование). Агрегатор на 3–5 служб с интерфейсом выбора — 2–3 недели.







