Разработка системы выбора пункта выдачи на карте для интернет-магазина
Покупатель открывает шаг доставки, видит список из 47 адресов пунктов выдачи, пытается найти нужный район в выпадающем списке и закрывает вкладку. Карта с маркерами решает эту проблему: человек видит точки рядом с домой, работой, по дороге — и выбирает за 5 секунд.
Источники данных о ПВЗ
Пункты выдачи приходят из API служб доставки. У каждой своя структура, но суть одна — список объектов с координатами, адресом, часами работы, ограничениями по весу и габаритам.
СДЭК:
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 дней.







