From e2e17fd5a60b80ede16f3812a7eacb7322d72a11 Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Wed, 13 May 2026 17:46:53 +0300 Subject: [PATCH] =?UTF-8?q?measurement=20logistics:=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D1=8A=D0=B5=D0=B7=D0=B4,=20GPS,=20=D0=BF=D0=B0=D1=80=D0=BA?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=B0,=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B4=D0=BB=D1=8F=20=D0=BB=D0=BE=D0=B3=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Замерщик/сборщик/менеджер при выезде на объект может дополнить адрес деталями. Эти же данные будут видны и при сборке — существенно облегчает планирование подъезда и парковки. Поля: - Подъезд + этаж - GPS-координаты (с кнопкой «Сейчас» — забирает с устройства через navigator.geolocation, ссылка на Google Maps в сводке) - Парковка: бесплатная / платная / на улице / нет + текст-уточнение - Заметки логистики: домофон, шлагбаум, размер лифта, узкий проезд UX: - В карточке заявки секция «📍 Логистика» свёрнута по умолчанию, показывает сводку. Кнопка «Заполнить» / «Изменить» раскрывает форму. - Точка-индикатор после заголовка если есть данные. - Сводка собирается строкой: подъезд · этаж · GPS-ссылка · парковка · заметка. Backend: - 7 новых колонок в Measurements (entrance, floor, gps_lat, gps_lng, parking_type, parking_note, delivery_notes). - POST /api/measurement_logistics — точечный апдейт. Право: назначенный замерщик / менеджер-владелец / любой сборщик. Cache bust v=20260513v. --- backend-py/app/main.py | 92 +++++++++++++++++- miniapp/assets/app.js | 200 ++++++++++++++++++++++++++++++++++++++ miniapp/assets/podbor.css | 37 +++++++ miniapp/index.html | 20 ++-- 4 files changed, 335 insertions(+), 14 deletions(-) diff --git a/backend-py/app/main.py b/backend-py/app/main.py index 5b6ceba..d4bf1e1 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -108,10 +108,11 @@ async def _dispatch_post(request: Request): "lead": _handle_lead, "grant_role": _handle_grant_role, "staff_list": _handle_staff_list, - "measurement_request": _handle_measurement_request, - "measurement_inbox": _handle_measurement_inbox, - "measurement_schedule": _handle_measurement_schedule, - "measurement_next_no": _handle_measurement_next_no, + "measurement_request": _handle_measurement_request, + "measurement_inbox": _handle_measurement_inbox, + "measurement_schedule": _handle_measurement_schedule, + "measurement_next_no": _handle_measurement_next_no, + "measurement_logistics": _handle_measurement_logistics, "ping": lambda b: {"pong": True, "time": _now_iso()}, "seed_admin": lambda b: _handle_seed_admin(), "test_ai": lambda b: _handle_test_ai(), @@ -208,6 +209,12 @@ async def api_measurement_next_no(request: Request): return _handle_measurement_next_no(body) +@app.post("/api/measurement_logistics") +async def api_measurement_logistics(request: Request): + body = await _safe_json(request) + return _handle_measurement_logistics(body) + + @app.post("/api/grant_role") async def api_grant_role(request: Request): """Админ выдаёт роль другому пользователю. @@ -612,6 +619,10 @@ def _measurement_columns() -> list[str]: # preferred_time_of_day: morning | day | evening # preferred_note: «после звонка», «не раньше вторника», ... "preferred_type", "preferred_date", "preferred_time_of_day", "preferred_note", + # Логистика — заполняет замерщик на месте (Commit C3), нужна также сборщику + # parking_type: free | paid | street | none + "entrance", "floor", "gps_lat", "gps_lng", + "parking_type", "parking_note", "delivery_notes", ] @@ -644,6 +655,8 @@ def _row_for_measurement(measurement_id: str, ts: str, **fields) -> list[str]: "address": "", "client_name": "", "client_phone": "", "zamer_no": "", "zamer_date": "", "floor_base": "", "photos_meta": "", "preferred_type": "", "preferred_date": "", "preferred_time_of_day": "", "preferred_note": "", + "entrance": "", "floor": "", "gps_lat": "", "gps_lng": "", + "parking_type": "", "parking_note": "", "delivery_notes": "", } base.update(fields) return [str(base.get(c, "")) for c in cols] @@ -1290,6 +1303,69 @@ def _handle_measurement_schedule(body: dict[str, Any]) -> dict[str, Any]: return {"ok": True, "id": measurement_id, "status": "scheduled", "scheduled_at": scheduled_at} +def _handle_measurement_logistics(body: dict[str, Any]) -> dict[str, Any]: + """Замерщик/сборщик/менеджер обновляет логистику замера — + подъезд, этаж, GPS, парковка, заметки для логистов. + body: {initData, measurement_id, entrance, floor, gps_lat, gps_lng, + parking_type, parking_note, delivery_notes}""" + 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 isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"): + auth = {"user": unsafe["user"], "_unsafe": True} + else: + return {"error": "invalid_init_data"} + tg_id = auth["user"]["id"] + user = sheets.find_user(tg_id) + if not user: + return {"error": "user_not_found"} + + measurement_id = (body.get("measurement_id") or "").strip() + if not measurement_id: + return {"error": "missing_measurement_id"} + + row = sheets.find_row("Measurements", "id", measurement_id) + if not row: + return {"error": "measurement_not_found"} + + # Право редактировать — назначенный замерщик, менеджер-заказчик, или админ + is_assigned_measurer = str(row.get("assigned_to_tg_id", "")) == str(tg_id) + is_owner_manager = str(row.get("manager_tg_id", "")) == str(tg_id) or \ + str(row.get("requested_by_tg_id", "")) == str(tg_id) + is_assembler = sheets.has_role(user, "assembler") + if not (is_assigned_measurer or is_owner_manager or is_assembler): + return {"error": "forbidden"} + + # Валидация значений + parking_type = (body.get("parking_type") or "").strip() + if parking_type not in ("free", "paid", "street", "none", ""): + parking_type = "" + + def _num_or_empty(v): + if v is None or v == "": + return "" + try: + return str(float(v)) + except (TypeError, ValueError): + return "" + + updates = { + "entrance": (body.get("entrance") or "").strip()[:80], + "floor": (body.get("floor") or "").strip()[:20], + "gps_lat": _num_or_empty(body.get("gps_lat")), + "gps_lng": _num_or_empty(body.get("gps_lng")), + "parking_type": parking_type, + "parking_note": (body.get("parking_note") or "").strip()[:200], + "delivery_notes": (body.get("delivery_notes") or "").strip()[:500], + } + for col, val in updates.items(): + sheets.update_cell_by_key("Measurements", "id", measurement_id, col, val) + + sheets.log_event("measurement_logistics_updated", tg_id, {"id": measurement_id}) + return {"ok": True, "id": measurement_id, "logistics": updates} + + def _handle_measurement_next_no(body: dict[str, Any]) -> dict[str, Any]: """Возвращает следующий свободный номер замера (max существующих + 1). Если в Sheets ничего нет — стартуем с 1. Менеджер может скорректировать вручную @@ -1434,6 +1510,14 @@ def _handle_measurement_detail(body: dict[str, Any]) -> dict[str, Any]: "preferred_date": row.get("preferred_date", ""), "preferred_time_of_day": row.get("preferred_time_of_day", ""), "preferred_note": row.get("preferred_note", ""), + # Логистика — заполняет замерщик (Commit C3) + "entrance": row.get("entrance", ""), + "floor": row.get("floor", ""), + "gps_lat": row.get("gps_lat", ""), + "gps_lng": row.get("gps_lng", ""), + "parking_type": row.get("parking_type", ""), + "parking_note": row.get("parking_note", ""), + "delivery_notes": row.get("delivery_notes", ""), } diff --git a/miniapp/assets/app.js b/miniapp/assets/app.js index 016c57c..0328502 100644 --- a/miniapp/assets/app.js +++ b/miniapp/assets/app.js @@ -664,6 +664,9 @@ async function renderInboxDetail(measurementId) { `)); } + // Блок логистики — заполняется замерщиком/сборщиком на месте + app.appendChild(renderLogisticsBlock(m)); + // Блок «назначить дату» (если ещё requested) или «изменить дату» (если scheduled) const isScheduled = m.status === "scheduled"; const schedSection = el(` @@ -732,6 +735,203 @@ async function renderInboxDetail(measurementId) { app.appendChild(measureBtn); } +function renderLogisticsBlock(m) { + const hasData = !!(m.entrance || m.floor || m.gps_lat || m.parking_type || m.parking_note || m.delivery_notes); + const parkingLabels = { + free: "🅿️ Бесплатная", + paid: "💰 Платная", + street: "🛣️ На улице", + none: "🚫 Нет парковки", + }; + + const section = el(` +
+
+ 📍 Логистика ${hasData ? '' : ''} + +
+
+ +
+ `); + + // Сводка (когда не в режиме редактирования) + function updateSummary(curM) { + const sum = section.querySelector("#logSummary"); + const lines = []; + if (curM.entrance) lines.push(`Подъезд ${escHtml(curM.entrance)}`); + if (curM.floor) lines.push(`этаж ${escHtml(curM.floor)}`); + if (curM.gps_lat && curM.gps_lng) { + const url = `https://maps.google.com/?q=${curM.gps_lat},${curM.gps_lng}`; + lines.push(`📍 ${curM.gps_lat}, ${curM.gps_lng}`); + } + if (curM.parking_type && parkingLabels[curM.parking_type]) { + let p = parkingLabels[curM.parking_type]; + if (curM.parking_note) p += ` · ${escHtml(curM.parking_note)}`; + lines.push(p); + } + if (curM.delivery_notes) { + lines.push(`${escHtml(curM.delivery_notes)}`); + } + sum.innerHTML = lines.length + ? lines.join(" · ") + : `Информация для подъезда не заполнена — заполни при выезде.`; + } + updateSummary(m); + + const editor = section.querySelector("#logEditor"); + const summary = section.querySelector("#logSummary"); + const toggleBtn = section.querySelector("#logToggle"); + + function setEdit(on) { + editor.style.display = on ? "" : "none"; + summary.style.display = on ? "none" : ""; + toggleBtn.style.display = on ? "none" : ""; + } + + toggleBtn.addEventListener("click", () => setEdit(true)); + section.querySelector("#logCancel").addEventListener("click", () => setEdit(false)); + + // GPS «Сейчас» + section.querySelector("#getGps").addEventListener("click", () => { + const hint = section.querySelector("#gpsHint"); + hint.textContent = "Запрашиваем координаты..."; + if (!navigator.geolocation) { + hint.textContent = "Геолокация недоступна. Введите вручную."; + return; + } + navigator.geolocation.getCurrentPosition( + (pos) => { + const lat = pos.coords.latitude.toFixed(6); + const lng = pos.coords.longitude.toFixed(6); + section.querySelector("#logGps").value = `${lat}, ${lng}`; + hint.textContent = `Получено · точность ${Math.round(pos.coords.accuracy)} м`; + haptic && haptic("success"); + }, + (err) => { + hint.textContent = `Не удалось: ${err.message || "отказано в доступе"}`; + }, + { enableHighAccuracy: true, timeout: 12000, maximumAge: 60000 } + ); + }); + + // Сохранение + section.querySelector("#logSave").addEventListener("click", async () => { + const btn = section.querySelector("#logSave"); + btn.disabled = true; + btn.textContent = "Сохраняем..."; + const gpsStr = (section.querySelector("#logGps").value || "").trim(); + let gps_lat = "", gps_lng = ""; + if (gpsStr) { + const parts = gpsStr.split(/[,;\s]+/).filter(Boolean); + if (parts.length >= 2) { + gps_lat = parts[0]; + gps_lng = parts[1]; + } + } + const parkType = (section.querySelector('input[name="parkType"]:checked') || {}).value || ""; + const payload = { + initData: tg?.initData || "", + initDataUnsafe: tg?.initDataUnsafe || null, + measurement_id: m.id, + entrance: section.querySelector("#logEntrance").value, + floor: section.querySelector("#logFloor").value, + gps_lat, gps_lng, + parking_type: parkType, + parking_note: section.querySelector("#logParkNote").value, + delivery_notes: section.querySelector("#logDelivery").value, + }; + try { + const res = await fetch(`${BACKEND_URL}/api/measurement_logistics`, { + method: "POST", + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (data.error) { + btn.disabled = false; + btn.textContent = "Сохранить"; + alert("Ошибка: " + data.error); + return; + } + // Обновляем локальные данные и сводку + Object.assign(m, data.logistics || {}); + updateSummary(m); + setEdit(false); + // Обновляем точку-индикатор «есть данные» + const hasNow = !!(m.entrance || m.floor || m.gps_lat || m.parking_type || m.parking_note || m.delivery_notes); + const head = section.querySelector("#logHead span"); + head.innerHTML = `📍 Логистика ${hasNow ? '' : ''}`; + toggleBtn.textContent = hasNow ? "Изменить" : "Заполнить"; + btn.disabled = false; + btn.textContent = "Сохранить"; + haptic && haptic("success"); + } catch (e) { + btn.disabled = false; + btn.textContent = "Сохранить"; + alert("Сеть: " + e.message); + } + }); + + return section; +} + function toDatetimeLocalValue(iso) { // ISO → YYYY-MM-DDTHH:MM для try { diff --git a/miniapp/assets/podbor.css b/miniapp/assets/podbor.css index 72e3aa0..8df8c39 100644 --- a/miniapp/assets/podbor.css +++ b/miniapp/assets/podbor.css @@ -2109,6 +2109,43 @@ border-left: 3px solid var(--walnut, #6B4A2B); } +/* ===== Логистика (подъезд, GPS, парковка) ===== */ +.logistics-block .block-head { + display: flex; + align-items: center; + justify-content: space-between; +} +.logistics-block .log-toggle { + background: transparent; + border: 1px solid var(--walnut, #6B4A2B); + color: var(--walnut, #6B4A2B); + padding: 4px 12px; + border-radius: 6px; + font-size: 12px; + cursor: pointer; + font-family: inherit; +} +.logistics-block .log-toggle:active { background: rgba(107, 74, 43, 0.10); } +.logistics-block .log-summary { + padding: 10px 4px 8px; + font-size: 13.5px; + color: var(--ink, #1F1A14); + line-height: 1.55; +} +.logistics-block .log-summary a { + color: var(--accent-1, #003E7E); + text-decoration: underline; +} +.log-dot { + color: var(--accent-1, #003E7E); + font-size: 8px; + vertical-align: middle; + margin-left: 4px; +} +.logistics-block .log-editor .preferred-options { + grid-template-columns: 1fr 1fr; +} + /* ===== Замер: фото с тегами ===== */ .podbor-header .podbor-help { background: transparent; diff --git a/miniapp/index.html b/miniapp/index.html index ac9ac72..ca5afe1 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -12,8 +12,8 @@ - - + + @@ -31,13 +31,13 @@
Сделано с душой!
- - - - - - - - + + + + + + + +