mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 19:04:49 +00:00
geocoder: порт из проекта СЕКРЕТАРЬ + кнопка «По адресу»
backend/app/geocoder.py — Python-порт хибридного геокодера из
secretary/lib/geocoder.js: Yandex (если есть YANDEX_GEOCODER_API_KEY
в env) → fallback OSM Nominatim (бесплатный, rate-limit 1/sec).
normalize_address — та же логика, что и в JS-версии: расшифровка
сокращений улиц, срез номера квартиры/этажа/подъезда, корпус → к<N>.
POST /api/geocode — текст адреса → {lat, lng, formatted, source}.
Frontend в логистике замера:
- Новая кнопка «🔍 По адресу» — берёт текст адреса заявки и
вызывает геокодер. Заполняет GPS автоматически.
- В сводке ссылка на 📍 теперь ведёт на Я.Карты (а не Google) —
для России лучше: открывает приложение Я.Карты на телефоне.
Cache bust v=20260513w.
This commit is contained in:
parent
e2e17fd5a6
commit
effb62a1d8
187
backend-py/app/geocoder.py
Normal file
187
backend-py/app/geocoder.py
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
"""Hybrid Geocoder: Yandex (если есть API-ключ) → fallback OSM Nominatim.
|
||||||
|
|
||||||
|
Порт из secretary/lib/geocoder.js — та же логика нормализации адреса
|
||||||
|
и стратегия fallback.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import urllib.parse
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
log = logging.getLogger("zov.geocoder")
|
||||||
|
|
||||||
|
OSM_HOST = "https://nominatim.openstreetmap.org"
|
||||||
|
YANDEX_HOST = "https://geocode-maps.yandex.ru"
|
||||||
|
OSM_RATE_DELAY_SEC = 1.1 # ~1 req/sec по политике Nominatim
|
||||||
|
|
||||||
|
_cache: dict[str, dict | None] = {}
|
||||||
|
_last_osm_at: float = 0.0
|
||||||
|
_yandex_disabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_address(s: str) -> str:
|
||||||
|
"""Готовим адрес для геокодера. Срезаем квартиру/этаж/подъезд/корпус,
|
||||||
|
раскрываем сокращения улиц. Совпадает с logic из secretary/lib/geocoder.js.
|
||||||
|
"""
|
||||||
|
if not s:
|
||||||
|
return ""
|
||||||
|
out = str(s)
|
||||||
|
|
||||||
|
# Замена сокращений улиц на полные слова (лучше для OSM)
|
||||||
|
# \b плохо работает с кириллицей в Python — используем lookaround
|
||||||
|
L = r"[А-Яа-яЁёA-Za-z]"
|
||||||
|
NL = rf"(?<!{L})"
|
||||||
|
NLA = rf"(?!{L})"
|
||||||
|
abbrev = [
|
||||||
|
(rf"{NL}пр-?к?т{NLA}\.?", "проспект"),
|
||||||
|
(rf"{NL}пр{NLA}\.?", "проспект"),
|
||||||
|
(rf"{NL}ул{NLA}\.?", "улица"),
|
||||||
|
(rf"{NL}пер{NLA}\.?", "переулок"),
|
||||||
|
(rf"{NL}наб{NLA}\.?", "набережная"),
|
||||||
|
(rf"{NL}пл{NLA}\.?", "площадь"),
|
||||||
|
(rf"{NL}ш{NLA}\.?", "шоссе"),
|
||||||
|
(rf"{NL}б-?р{NLA}\.?", "бульвар"),
|
||||||
|
(rf"{NL}алл{NLA}\.?", "аллея"),
|
||||||
|
]
|
||||||
|
for pat, sub in abbrev:
|
||||||
|
out = re.sub(pat, sub, out, flags=re.IGNORECASE | re.UNICODE)
|
||||||
|
|
||||||
|
# Срезаем номер квартиры/офиса/помещения, парадную, подъезд, этаж
|
||||||
|
cuts = [
|
||||||
|
rf"{NL}(кв|квартира|оф|офис|пом|помещение)\.?\s*\d+\w*",
|
||||||
|
rf"{NL}\d+-?я?\s*парадная",
|
||||||
|
rf"{NL}парадная\s*\d+",
|
||||||
|
rf"{NL}(подъезд|этаж|эт)\.?\s*\d+",
|
||||||
|
]
|
||||||
|
for pat in cuts:
|
||||||
|
out = re.sub(pat, "", out, flags=re.IGNORECASE | re.UNICODE)
|
||||||
|
|
||||||
|
# "д.9" / "дом 9" → "9"
|
||||||
|
out = re.sub(rf"{NL}(дом|д)\.?\s*(\d+){NLA}", r" \2", out, flags=re.IGNORECASE | re.UNICODE)
|
||||||
|
# "стр.1" → "с1"
|
||||||
|
out = re.sub(rf"{NL}(стр|строение)\.?\s*(\d+)", r"с\2", out, flags=re.IGNORECASE | re.UNICODE)
|
||||||
|
# "корп.3" / "к. 3" → "к3" (только перед числом)
|
||||||
|
out = re.sub(rf"{NL}(корпус|корп|к)\.?\s+(\d+)", r"к\2", out, flags=re.IGNORECASE | re.UNICODE)
|
||||||
|
|
||||||
|
# Лишняя пунктуация и сжатие
|
||||||
|
out = re.sub(r"[,;]+", ",", out)
|
||||||
|
out = re.sub(r"\s*,\s*", ", ", out)
|
||||||
|
out = re.sub(r"\s+", " ", out)
|
||||||
|
out = out.replace("–", "-").replace("—", "-")
|
||||||
|
out = re.sub(r"\s*-\s*\d+\s*$", "", out) # "- 459" в конце = № квартиры
|
||||||
|
out = out.strip().strip(",").strip()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _yandex_lookup(query: str, api_key: str) -> dict | None:
|
||||||
|
global _yandex_disabled
|
||||||
|
path = f"/v1/?apikey={urllib.parse.quote(api_key)}&geocode={urllib.parse.quote(query)}&format=json&results=1&lang=ru_RU"
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=15.0) as cli:
|
||||||
|
r = cli.get(YANDEX_HOST + path)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Yandex geocoder error: %s", e)
|
||||||
|
return None
|
||||||
|
if r.status_code == 403:
|
||||||
|
log.warning("Yandex 403 — отключаю на эту сессию")
|
||||||
|
_yandex_disabled = True
|
||||||
|
return None
|
||||||
|
if r.status_code >= 400:
|
||||||
|
log.warning("Yandex %d", r.status_code)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = r.json()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
features = data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", [])
|
||||||
|
if not features:
|
||||||
|
return None
|
||||||
|
obj = features[0].get("GeoObject", {})
|
||||||
|
pos = (obj.get("Point") or {}).get("pos", "")
|
||||||
|
try:
|
||||||
|
lon_s, lat_s = pos.split(" ")
|
||||||
|
lat = float(lat_s)
|
||||||
|
lon = float(lon_s)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return None
|
||||||
|
meta = (obj.get("metaDataProperty") or {}).get("GeocoderMetaData", {})
|
||||||
|
return {
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"formatted": meta.get("text") or obj.get("description") or query,
|
||||||
|
"precision": meta.get("precision", ""),
|
||||||
|
"kind": meta.get("kind", ""),
|
||||||
|
"source": "yandex",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _osm_lookup(query: str, orig: str) -> dict | None:
|
||||||
|
global _last_osm_at
|
||||||
|
wait = max(0.0, _last_osm_at + OSM_RATE_DELAY_SEC - time.time())
|
||||||
|
if wait > 0:
|
||||||
|
time.sleep(wait)
|
||||||
|
_last_osm_at = time.time()
|
||||||
|
|
||||||
|
path = f"/search?q={urllib.parse.quote(query)}&format=json&limit=1&accept-language=ru"
|
||||||
|
headers = {"User-Agent": "zov-tech/1.0 (vasrusgen@gmail.com)"}
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=15.0, headers=headers) as cli:
|
||||||
|
r = cli.get(OSM_HOST + path)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("OSM error: %s", e)
|
||||||
|
return None
|
||||||
|
if r.status_code >= 400:
|
||||||
|
log.warning("OSM %d", r.status_code)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = r.json()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
it = data[0]
|
||||||
|
try:
|
||||||
|
lat = float(it.get("lat"))
|
||||||
|
lon = float(it.get("lon"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"formatted": it.get("display_name") or orig or query,
|
||||||
|
"precision": it.get("type", ""),
|
||||||
|
"kind": it.get("addresstype") or it.get("class", ""),
|
||||||
|
"source": "osm",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def geocode(address_text: str, city: str = "Санкт-Петербург") -> dict | None:
|
||||||
|
"""Прямое геокодирование: текст адреса → {lat, lon, formatted, source, ...}"""
|
||||||
|
if not address_text:
|
||||||
|
return None
|
||||||
|
cleaned = normalize_address(address_text)
|
||||||
|
q = f"{city}, {cleaned}" if city else cleaned
|
||||||
|
if q in _cache:
|
||||||
|
return _cache[q]
|
||||||
|
|
||||||
|
api_key = os.environ.get("YANDEX_GEOCODER_API_KEY", "").strip()
|
||||||
|
if api_key and not _yandex_disabled:
|
||||||
|
r = _yandex_lookup(q, api_key)
|
||||||
|
if r:
|
||||||
|
_cache[q] = r
|
||||||
|
return r
|
||||||
|
|
||||||
|
r = _osm_lookup(q, address_text)
|
||||||
|
_cache[q] = r
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def build_yandex_maps_url(lat: float, lon: float, *, zoom: int = 17, text: str = "") -> str:
|
||||||
|
"""Deeplink в Я.Карты — открывается в приложении на телефоне."""
|
||||||
|
params = [f"pt={lon},{lat},pm2rdm", f"z={zoom}", f"ll={lon},{lat}"]
|
||||||
|
if text:
|
||||||
|
params.append(f"text={urllib.parse.quote(text)}")
|
||||||
|
return "https://yandex.ru/maps/?" + "&".join(params)
|
||||||
@ -16,7 +16,7 @@ from fastapi.responses import FileResponse, JSONResponse
|
|||||||
|
|
||||||
from .config import get_config
|
from .config import get_config
|
||||||
from .auth import verify_init_data
|
from .auth import verify_init_data
|
||||||
from . import sheets, ai, telegram as tg, proxy_pool, catalog
|
from . import sheets, ai, telegram as tg, proxy_pool, catalog, geocoder
|
||||||
from . import parsers
|
from . import parsers
|
||||||
from .parsers import dns as parser_dns, wb as parser_wb, ozon as parser_ozon, yamarket as parser_ym, citilink as parser_cl
|
from .parsers import dns as parser_dns, wb as parser_wb, ozon as parser_ozon, yamarket as parser_ym, citilink as parser_cl
|
||||||
|
|
||||||
@ -113,6 +113,7 @@ async def _dispatch_post(request: Request):
|
|||||||
"measurement_schedule": _handle_measurement_schedule,
|
"measurement_schedule": _handle_measurement_schedule,
|
||||||
"measurement_next_no": _handle_measurement_next_no,
|
"measurement_next_no": _handle_measurement_next_no,
|
||||||
"measurement_logistics": _handle_measurement_logistics,
|
"measurement_logistics": _handle_measurement_logistics,
|
||||||
|
"geocode": _handle_geocode,
|
||||||
"ping": lambda b: {"pong": True, "time": _now_iso()},
|
"ping": lambda b: {"pong": True, "time": _now_iso()},
|
||||||
"seed_admin": lambda b: _handle_seed_admin(),
|
"seed_admin": lambda b: _handle_seed_admin(),
|
||||||
"test_ai": lambda b: _handle_test_ai(),
|
"test_ai": lambda b: _handle_test_ai(),
|
||||||
@ -215,6 +216,12 @@ async def api_measurement_logistics(request: Request):
|
|||||||
return _handle_measurement_logistics(body)
|
return _handle_measurement_logistics(body)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/geocode")
|
||||||
|
async def api_geocode(request: Request):
|
||||||
|
body = await _safe_json(request)
|
||||||
|
return _handle_geocode(body)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/grant_role")
|
@app.post("/api/grant_role")
|
||||||
async def api_grant_role(request: Request):
|
async def api_grant_role(request: Request):
|
||||||
"""Админ выдаёт роль другому пользователю.
|
"""Админ выдаёт роль другому пользователю.
|
||||||
@ -1366,6 +1373,41 @@ def _handle_measurement_logistics(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
return {"ok": True, "id": measurement_id, "logistics": updates}
|
return {"ok": True, "id": measurement_id, "logistics": updates}
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_geocode(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Прямое геокодирование: текст адреса → lat/lon.
|
||||||
|
Использует Yandex (если есть YANDEX_GEOCODER_API_KEY в env) с fallback на OSM.
|
||||||
|
body: {initData, address, city?}"""
|
||||||
|
cfg = get_config()
|
||||||
|
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
|
||||||
|
if not auth or not auth.get("user"):
|
||||||
|
unsafe = body.get("initDataUnsafe") or {}
|
||||||
|
if not (isinstance(unsafe, dict) and unsafe.get("user", {}).get("id")):
|
||||||
|
return {"error": "invalid_init_data"}
|
||||||
|
|
||||||
|
address = (body.get("address") or "").strip()
|
||||||
|
if not address:
|
||||||
|
return {"error": "missing_address"}
|
||||||
|
city = (body.get("city") or "Санкт-Петербург").strip()
|
||||||
|
result = geocoder.geocode(address, city=city)
|
||||||
|
if not result:
|
||||||
|
return {"ok": False, "error": "not_found", "address": address}
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"address": address,
|
||||||
|
"result": {
|
||||||
|
"lat": result.get("lat"),
|
||||||
|
"lng": result.get("lon"), # фронт использует lng
|
||||||
|
"formatted": result.get("formatted"),
|
||||||
|
"precision": result.get("precision"),
|
||||||
|
"kind": result.get("kind"),
|
||||||
|
"source": result.get("source"),
|
||||||
|
},
|
||||||
|
"yandex_maps_url": geocoder.build_yandex_maps_url(
|
||||||
|
result["lat"], result["lon"], text=result.get("formatted") or address,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _handle_measurement_next_no(body: dict[str, Any]) -> dict[str, Any]:
|
def _handle_measurement_next_no(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Возвращает следующий свободный номер замера (max существующих + 1).
|
"""Возвращает следующий свободный номер замера (max существующих + 1).
|
||||||
Если в Sheets ничего нет — стартуем с 1. Менеджер может скорректировать вручную
|
Если в Sheets ничего нет — стартуем с 1. Менеджер может скорректировать вручную
|
||||||
|
|||||||
@ -766,11 +766,12 @@ function renderLogisticsBlock(m) {
|
|||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field-label">GPS координаты</span>
|
<span class="field-label">GPS координаты</span>
|
||||||
<div style="display:flex;gap:8px;align-items:center;">
|
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
|
||||||
<input type="text" id="logGps" value="${m.gps_lat && m.gps_lng ? `${m.gps_lat}, ${m.gps_lng}` : ""}" placeholder="широта, долгота" style="flex:1;">
|
<input type="text" id="logGps" value="${m.gps_lat && m.gps_lng ? `${m.gps_lat}, ${m.gps_lng}` : ""}" placeholder="широта, долгота" style="flex:1;min-width:140px;">
|
||||||
<button class="btn-secondary" id="getGps" type="button" style="white-space:nowrap;padding:8px 14px;">📍 Сейчас</button>
|
<button class="btn-secondary" id="getGps" type="button" style="white-space:nowrap;padding:8px 12px;">📍 Сейчас</button>
|
||||||
|
<button class="btn-secondary" id="getGpsAddr" type="button" style="white-space:nowrap;padding:8px 12px;">🔍 По адресу</button>
|
||||||
</div>
|
</div>
|
||||||
<span class="field-hint" id="gpsHint">Тап «Сейчас» — возьмёт координаты с устройства</span>
|
<span class="field-hint" id="gpsHint">«Сейчас» — с устройства. «По адресу» — геокодер по адресу заявки.</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -819,8 +820,8 @@ function renderLogisticsBlock(m) {
|
|||||||
if (curM.entrance) lines.push(`Подъезд <b>${escHtml(curM.entrance)}</b>`);
|
if (curM.entrance) lines.push(`Подъезд <b>${escHtml(curM.entrance)}</b>`);
|
||||||
if (curM.floor) lines.push(`этаж <b>${escHtml(curM.floor)}</b>`);
|
if (curM.floor) lines.push(`этаж <b>${escHtml(curM.floor)}</b>`);
|
||||||
if (curM.gps_lat && curM.gps_lng) {
|
if (curM.gps_lat && curM.gps_lng) {
|
||||||
const url = `https://maps.google.com/?q=${curM.gps_lat},${curM.gps_lng}`;
|
const ymUrl = `https://yandex.ru/maps/?pt=${curM.gps_lng},${curM.gps_lat},pm2rdm&z=17&ll=${curM.gps_lng},${curM.gps_lat}`;
|
||||||
lines.push(`<a href="${url}" target="_blank" rel="noopener">📍 ${curM.gps_lat}, ${curM.gps_lng}</a>`);
|
lines.push(`<a href="${ymUrl}" target="_blank" rel="noopener">📍 ${curM.gps_lat}, ${curM.gps_lng}</a>`);
|
||||||
}
|
}
|
||||||
if (curM.parking_type && parkingLabels[curM.parking_type]) {
|
if (curM.parking_type && parkingLabels[curM.parking_type]) {
|
||||||
let p = parkingLabels[curM.parking_type];
|
let p = parkingLabels[curM.parking_type];
|
||||||
@ -872,6 +873,39 @@ function renderLogisticsBlock(m) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GPS «По адресу» — геокодирование через backend
|
||||||
|
section.querySelector("#getGpsAddr").addEventListener("click", async () => {
|
||||||
|
const hint = section.querySelector("#gpsHint");
|
||||||
|
const addr = (m.address || "").trim();
|
||||||
|
if (!addr) {
|
||||||
|
hint.textContent = "В заявке нет адреса — нужен текст адреса для геокодера.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hint.textContent = "Ищем по адресу...";
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BACKEND_URL}/api/geocode`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData: tg?.initData || "",
|
||||||
|
initDataUnsafe: tg?.initDataUnsafe || null,
|
||||||
|
address: addr,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.ok || !data.result) {
|
||||||
|
hint.textContent = "Адрес не найден геокодером — введите GPS вручную.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = data.result;
|
||||||
|
section.querySelector("#logGps").value = `${r.lat.toFixed(6)}, ${r.lng.toFixed(6)}`;
|
||||||
|
const srcLabel = r.source === "yandex" ? "Я.Геокодер" : "OSM";
|
||||||
|
hint.textContent = `Найдено: ${r.formatted || addr} · источник ${srcLabel}`;
|
||||||
|
haptic && haptic("success");
|
||||||
|
} catch (e) {
|
||||||
|
hint.textContent = "Сеть: " + e.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Сохранение
|
// Сохранение
|
||||||
section.querySelector("#logSave").addEventListener("click", async () => {
|
section.querySelector("#logSave").addEventListener("click", async () => {
|
||||||
const btn = section.querySelector("#logSave");
|
const btn = section.querySelector("#logSave");
|
||||||
|
|||||||
@ -12,8 +12,8 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&display=swap">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&display=swap">
|
||||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
<link rel="stylesheet" href="assets/styles.css?v=20260513v">
|
<link rel="stylesheet" href="assets/styles.css?v=20260513w">
|
||||||
<link rel="stylesheet" href="assets/podbor.css?v=20260513v">
|
<link rel="stylesheet" href="assets/podbor.css?v=20260513w">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Splash — за пределами #app, render-функции его не смывают -->
|
<!-- Splash — за пределами #app, render-функции его не смывают -->
|
||||||
@ -31,13 +31,13 @@
|
|||||||
<div class="loader-tagline">Сделано с душой!</div>
|
<div class="loader-tagline">Сделано с душой!</div>
|
||||||
</div>
|
</div>
|
||||||
<main id="app"></main>
|
<main id="app"></main>
|
||||||
<script src="assets/icons.js?v=20260513v"></script>
|
<script src="assets/icons.js?v=20260513w"></script>
|
||||||
<script src="assets/podbor.config.js?v=20260513v"></script>
|
<script src="assets/podbor.config.js?v=20260513w"></script>
|
||||||
<script src="assets/podbor.picts.js?v=20260513v"></script>
|
<script src="assets/podbor.picts.js?v=20260513w"></script>
|
||||||
<script src="assets/podbor.js?v=20260513v"></script>
|
<script src="assets/podbor.js?v=20260513w"></script>
|
||||||
<script src="assets/clients.js?v=20260513v"></script>
|
<script src="assets/clients.js?v=20260513w"></script>
|
||||||
<script src="assets/measurements.js?v=20260513v"></script>
|
<script src="assets/measurements.js?v=20260513w"></script>
|
||||||
<script src="assets/request.js?v=20260513v"></script>
|
<script src="assets/request.js?v=20260513w"></script>
|
||||||
<script src="assets/app.js?v=20260513v"></script>
|
<script src="assets/app.js?v=20260513w"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user