Розробка системи вибору пункту видачі на карті для інтернет-магазину
Покупець відкриває крок доставки, бачить список з 47 адрес пунктів видачі, намагається знайти потрібний район у випадаючому списку і закриває вкладку. Карта з маркерами вирішує цю проблему: людина бачить точки рядом з домом, роботою, по дорозі — і вибирає за 5 секунд.
Джерела даних про ПВЗ
Пункти видачі приходять з API служб доставки. У кожної своя структура, але суть одна — список об'єктів з координатами, адресою, годинами роботи, обмеженнями по вазі й габаритам.
CDEK:
GET https://api.cdek.ru/v2/deliverypoints?city_code=44&weight_max=30&type=PVZ
Authorization: Bearer {token}
Відповідь містить масив entity з полями location.latitude, location.longitude, work_time, address_comment, allowed_max_weight.
Boxberry:
GET https://api.boxberry.ru/json.php?token={token}&method=ListPoints&CityCode=77&prepaid=1
Структура інша, але дані ті самі — координати, адреса, режим роботи.
Кешування довідника ПВЗ
Список ПВЗ змінюється рідко — раз на добу, іноді рідше. Запитувати його при кожному відкритті сторінки — розточительство й повільно. Правильний підхід: синхронізація за розписанням, зберігання у власній базі:
// Artisan command: php artisan delivery:sync-pickup-points
class SyncPickupPoints extends Command
{
public function handle(CdekService $cdek, BoxberryService $boxberry): void
{
$carriers = [
'cdek' => fn() => $cdek->getAllPickupPoints(),
'boxberry' => fn() => $boxberry->getAllPickupPoints(),
];
foreach ($carriers as $carrier => $fetcher) {
$points = $fetcher();
$this->info("$carrier: {$points->count()} points");
PickupPoint::where('carrier', $carrier)->delete();
PickupPoint::insert(
$points->map(fn($p) => [
'carrier' => $carrier,
'external_id' => $p['code'],
'name' => $p['name'],
'address' => $p['address'],
'city' => $p['city'],
'lat' => $p['lat'],
'lng' => $p['lng'],
'work_time' => $p['work_time'],
'max_weight' => $p['max_weight_kg'],
'cash_allowed' => $p['cash_allowed'],
'updated_at' => now(),
])->toArray()
);
}
$this->info('Done');
}
}
Команда запускається через cron вночі. Користувач отримує дані з локальної БД за 10–20 мс замість 500–2000 мс від API.
Геопросторові запити
Після того як покупець вводить свою адресу або ділиться геолокацією, потрібно показати ближайші ПВЗ. PostGIS робить це елегантно:
-- Включення розширення (один раз)
CREATE EXTENSION IF NOT EXISTS postgis;
-- Додавання geography-колонки
ALTER TABLE pickup_points ADD COLUMN location geography(POINT, 4326);
UPDATE pickup_points SET location = ST_SetSRID(ST_MakePoint(lng, lat), 4326);
CREATE INDEX idx_pickup_points_location ON pickup_points USING GIST(location);
-- Запит: 20 ближайших ПВЗ у радіусі 10 км, несучих вантажи до 5 кг
SELECT
id, carrier, name, address, work_time, cash_allowed,
ST_Distance(location, ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)) AS distance_m
FROM pickup_points
WHERE
max_weight >= :weight
AND ST_DWithin(
location,
ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography,
10000
)
ORDER BY distance_m
LIMIT 20;
Без PostGIS можна використовувати формулу гаверсинуса прямо в SQL або PHP — але це повільніше й менш точно.
Карта: рендеринг маркерів
Коли точок багато — кілька тисяч — рендерити кожну як окремий DOM-елемент неможливо, браузер зависне. Використовується кластеризація:
import L from 'leaflet';
import 'leaflet.markercluster';
const map = L.map('pickup-map').setView([55.7558, 37.6173], 11);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
const markers = L.markerClusterGroup({
maxClusterRadius: 50,
iconCreateFunction: (cluster) => {
const count = cluster.getChildCount();
return L.divIcon({
html: `<div class="cluster-icon">${count}</div>`,
className: '',
iconSize: [40, 40],
});
},
});
// Завантаження точок у межах поточного viewport карти
map.on('moveend', async () => {
const bounds = map.getBounds();
const response = await fetch('/api/pickup-points?' + new URLSearchParams({
north: bounds.getNorth(),
south: bounds.getSouth(),
east: bounds.getEast(),
west: bounds.getWest(),
weight: cartWeight,
}));
const points = await response.json();
markers.clearLayers();
points.forEach((point) => {
const marker = L.marker([point.lat, point.lng], {
icon: carrierIcon(point.carrier),
});
marker.bindPopup(buildPopup(point));
marker.on('click', () => selectPickupPoint(point));
markers.addLayer(marker);
});
});
map.addLayer(markers);
Завантаження тільки видимої області (moveend) — замість вивантаження всіх точок відразу. Для 50 000 точок по всій країні це принципово.
Попап з деталями ПВЗ
function buildPopup(point) {
return `
<div class="pickup-popup">
<div class="carrier-badge ${point.carrier}">${point.carrier.toUpperCase()}</div>
<strong>${point.name}</strong>
<p>${point.address}</p>
<p class="work-time">${point.work_time}</p>
${point.cash_allowed ? '<span class="badge">Готівка</span>' : ''}
<p class="delivery-cost">Доставка: <b>${formatPrice(point.cost)} ₽</b></p>
<p class="delivery-days">Термін: ${point.min_days}–${point.max_days} дн.</p>
<button onclick="selectPickupPoint(${point.id})">Вибрати</button>
</div>
`;
}
Визначення міста покупця
Геолокація через navigator.geolocation — найточніший спосіб, але вимагає дозволу. Якщо користувач відмовив або знаходиться в іншому місті — потрібен фоллбек:
async function detectUserLocation() {
// Спроба через IP-геолокацію
try {
const res = await fetch('https://ipapi.co/json/');
const data = await res.json();
return { city: data.city, lat: data.latitude, lng: data.longitude };
} catch {
return { city: 'Москва', lat: 55.7558, lng: 37.6173 };
}
}
ipapi.co дає 1000 безплатних запитів на добу. Для великих магазинів — власна база GeoIP (MaxMind GeoLite2, безплатна).
Фільтрація ПВЗ
Якщо покупець хоче тільки ПВЗ з примеркою (для одежі), або тільки постаматов (працюють цілодобово), або тільки ті, де приймають готівку — потрібні фільтри:
const filters = {
fitting_room: false,
cash_allowed: false,
type: 'all', // 'pvz' | 'postamat' | 'all'
carrier: 'all',
};
// При зміні фільтрів — перезапросити точки
Object.keys(filters).forEach((key) => {
document.getElementById(`filter-${key}`).addEventListener('change', (e) => {
filters[key] = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
loadPickupPoints();
});
});
Підтвердження вибору й передача в замовлення
Після вибору ПВЗ — його дані потрапляють у форму замовлення. ID пункту видачі зберігається як частина способу доставки:
function selectPickupPoint(point) {
selectedPoint = point;
// Оновлюємо UI
document.getElementById('selected-point-address').textContent = point.address;
document.getElementById('selected-point-work-time').textContent = point.work_time;
// Передаємо у форму замовлення
document.getElementById('delivery_type').value = 'pickup';
document.getElementById('pickup_point_id').value = point.id;
document.getElementById('pickup_carrier').value = point.carrier;
document.getElementById('pickup_external_id').value = point.external_id;
document.getElementById('delivery_cost').value = point.cost;
}
Терміни розробки
Карта з ПВЗ однієї служби, дані з API з кешем, кластеризація маркерів — 4–6 днів. Агрегатор кількох служб, геолокація, фільтри — 2 тижні. Додавання ПВЗ на карту до існуючої форми замовлення без переробки архітектури — 3–5 днів.







