Розробка калькулятора доставки для інтернет-магазину
Покупець не повинен додавати товар у кошик, вводити адресу, натискати «Оформити» і лише на останньому кроці дізнаватися, що доставка коштує половину замовлення. Це одна з головних причин брошених кошиків. Калькулятор доставки вирішує цю проблему — показує вартість відразу, до початку оформлення.
Що розраховує калькулятор
Вартість доставки залежить від параметрів, які потрібно отримати з різних джерел:
- Звідки везуть — адреса складу або найближчого магазину
- Куди везуть — адреса покупця, до двері або до пункту видачі
- Що везуть — вага і габарити товарів у кошику
- Який спосіб — кур'єр, ПВЗ, постамат, Пошта
- Коли потрібно — стандарт або експрес
Параметри товарів зберігаються в базі даних інтернет-магазину. Тарифи доставки — або у власних таблицях (для партнерських договорів з фіксованими цінами), або приходять в реальному часі через 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. Приклад з CDEK:
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();
}
Якщо CDEK повернув помилку — показуємо тільки Пошту та 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 тижні.







