Розробка системи вибору пункту видачі на карті для інтернет-магазину

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.
Розробка та обслуговування будь-яких видів сайтів:
Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Розробка системи вибору пункту видачі на карті для інтернет-магазину
Середня
~3-5 робочих днів
Часті питання
Наші компетенції:
Етапи розробки
Останні роботи
  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Розробка системи вибору пункту видачі на карті для інтернет-магазину

Покупець відкриває крок доставки, бачить список з 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 днів.