Разработка калькулятора доставки на сайте
Калькулятор доставки должен давать точный ответ до оформления заказа — не «от 300 рублей», а конкретную сумму с выбором способа и датой. Размытые формулировки в корзине — прямая причина отказов. По данным Baymard Institute, неожиданная стоимость доставки на checkout — причина брошенной корзины в 48% случаев.
Архитектура
Запрос (город + товары + вес) → DeliveryCalculator → [Carrier APIs] → Список вариантов → Фронтенд
Калькулятор должен работать как на странице товара (приблизительно), так и в корзине (точно, с учётом всего состава).
Схема данных
CREATE TABLE delivery_zones (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255),
country CHAR(2) DEFAULT 'RU',
regions TEXT[], -- коды регионов ФИАС
cities TEXT[], -- коды городов КЛАДР
carrier_id INT REFERENCES carriers(id)
);
CREATE TABLE delivery_rates (
id BIGSERIAL PRIMARY KEY,
carrier_id INT REFERENCES carriers(id),
zone_id BIGINT REFERENCES delivery_zones(id),
method VARCHAR(50), -- 'courier', 'pickup', 'post'
weight_from_g INT DEFAULT 0,
weight_to_g INT,
price NUMERIC(10,2) NOT NULL,
days_min SMALLINT,
days_max SMALLINT,
free_from NUMERIC(12,2), -- бесплатно при сумме от X
is_active BOOLEAN DEFAULT TRUE
);
Сервис вычисления
class DeliveryCalculatorService
{
public function calculate(DeliveryRequest $request): DeliveryResult
{
$totalWeight = $this->calculateWeight($request->items);
$totalPrice = $request->items->sum(fn($i) => $i->price * $i->quantity);
$zone = $this->zoneResolver->resolve($request->destination);
if (!$zone) {
// Регион не в таблице зон — считать через API перевозчика
return $this->calculateViaApi($request, $totalWeight, $totalPrice);
}
$rates = DeliveryRate::where('zone_id', $zone->id)
->where('weight_from_g', '<=', $totalWeight)
->where(fn($q) => $q->whereNull('weight_to_g')->orWhere('weight_to_g', '>=', $totalWeight))
->where('is_active', true)
->with('carrier')
->orderBy('price')
->get();
$options = $rates->map(function ($rate) use ($totalPrice) {
$price = ($rate->free_from && $totalPrice >= $rate->free_from) ? 0 : $rate->price;
return new DeliveryOption(
carrierId: $rate->carrier_id,
method: $rate->method,
name: $rate->carrier->name . ' — ' . $rate->method_label,
price: $price,
daysMin: $rate->days_min,
daysMax: $rate->days_max,
isFree: $price === 0.0,
);
});
return new DeliveryResult(options: $options, destination: $request->destination);
}
private function calculateWeight(Collection $items): int
{
return $items->sum(function ($item) {
$product = $item->product;
$weight = $product->weight_g ?? 500; // дефолт 500г если не указан
return $weight * $item->quantity;
});
}
}
Интеграция с СДЭК API
class CdekDeliveryProvider implements DeliveryProviderInterface
{
private string $baseUrl = 'https://api.cdek.ru/v2';
public function calculate(DeliveryRequest $request, int $weightG): array
{
$token = $this->getToken();
$response = Http::withToken($token)
->post("{$this->baseUrl}/calculator/tarifflist", [
'type' => 1, // 1 = интернет-магазин
'currency' => 1, // RUB
'lang' => 'rus',
'from_location' => ['code' => config('cdek.from_city_code')],
'to_location' => ['address' => $request->destination->address],
'packages' => [[
'weight' => $weightG,
'length' => 30,
'width' => 20,
'height' => 10,
]],
]);
if (!$response->successful()) return [];
return collect($response->json('tariff_codes', []))
->map(fn($t) => new DeliveryOption(
carrierId: 'cdek',
method: $this->mapTariffToMethod($t['tariff_code']),
name: 'СДЭК — ' . $t['tariff_name'],
price: $t['delivery_sum'],
daysMin: $t['period_min'],
daysMax: $t['period_max'],
))
->toArray();
}
private function getToken(): string
{
return Cache::remember('cdek_token', 3600, function () {
$response = Http::post("{$this->baseUrl}/oauth/token", [
'grant_type' => 'client_credentials',
'client_id' => config('cdek.client_id'),
'client_secret' => config('cdek.client_secret'),
]);
return $response->json('access_token');
});
}
}
Интеграция с «Почтой России»
class RussianPostProvider implements DeliveryProviderInterface
{
public function calculate(DeliveryRequest $request, int $weightG): array
{
$response = Http::withHeaders([
'Authorization' => 'AccessToken ' . config('russianpost.token'),
'X-User-Login' => config('russianpost.login'),
'Content-Type' => 'application/json',
])->post('https://tariff.pochta.ru/v2/calculate/tariff', [
'object' => 47020, // ПОСЫЛКА ОНЛАЙН
'from' => config('russianpost.from_index'),
'to' => $request->destination->postalCode,
'weight' => $weightG,
'fragile' => false,
'declared' => (int) ($request->declaredValue * 100), // в копейках
]);
if (!$response->successful()) return [];
$total = $response->json('paymoney', 0) / 100;
return [new DeliveryOption(
carrierId: 'russianpost',
method: 'post',
name: 'Почта России',
price: $total,
daysMin: $response->json('delivery.min') ?? 7,
daysMax: $response->json('delivery.max') ?? 14,
)];
}
}
API-эндпоинт для фронтенда
class DeliveryCalculatorController extends Controller
{
public function calculate(CalculateRequest $request): JsonResponse
{
$cacheKey = 'delivery.' . md5(serialize($request->validated()));
$result = Cache::remember($cacheKey, 300, function () use ($request) {
return $this->calculator->calculate(
DeliveryRequest::fromArray($request->validated())
);
});
return response()->json([
'options' => DeliveryOptionResource::collection($result->options),
'destination' => $result->destination->label,
]);
}
}
Фронтенд-компонент
const DeliveryCalculator: React.FC<{ cartItems: CartItem[] }> = ({ cartItems }) => {
const [city, setCity] = useState('');
const [options, setOptions] = useState<DeliveryOption[]>([]);
const [loading, setLoading] = useState(false);
const calculate = useDebouncedCallback(async (cityValue: string) => {
if (cityValue.length < 3) return;
setLoading(true);
try {
const res = await api.post('/delivery/calculate', {
destination: cityValue,
items: cartItems.map(i => ({ product_id: i.id, quantity: i.qty })),
});
setOptions(res.data.options);
} finally {
setLoading(false);
}
}, 600);
return (
<div>
<input
placeholder="Введите город доставки"
value={city}
onChange={e => { setCity(e.target.value); calculate(e.target.value); }}
className="border rounded px-3 py-2 w-full"
/>
{loading && <p className="text-sm text-gray-400 mt-2">Считаем стоимость...</p>}
{options.length > 0 && (
<ul className="mt-3 space-y-2">
{options.map(opt => (
<li key={opt.id} className="flex justify-between items-center border rounded px-3 py-2">
<div>
<p className="font-medium">{opt.name}</p>
<p className="text-sm text-gray-500">{opt.daysMin}–{opt.daysMax} дней</p>
</div>
<span className="font-semibold">
{opt.isFree ? 'Бесплатно' : formatPrice(opt.price)}
</span>
</li>
))}
</ul>
)}
</div>
);
};
Подсказка города через DaData
const [suggestions, setSuggestions] = useState<string[]>([]);
const fetchCities = useDebouncedCallback(async (query: string) => {
const res = await fetch('https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Token ${DADATA_TOKEN}`,
},
body: JSON.stringify({ query, from_bound: { value: 'city' }, to_bound: { value: 'city' }, count: 5 }),
});
const data = await res.json();
setSuggestions(data.suggestions.map((s: any) => s.value));
}, 400);
Сроки реализации
- Схема данных + DeliveryCalculatorService с таблицей зон: 1 день
- СДЭК API: 1 день
- Почта России API: 0.5 дня
- Фронтенд-компонент + подсказка городов: 1 день
- Кеширование + API-эндпоинт: 0.5 дня
Итого: 3–4 рабочих дня.







