measurement logistics: подъезд, GPS, парковка, заметки для логистов

Замерщик/сборщик/менеджер при выезде на объект может дополнить
адрес деталями. Эти же данные будут видны и при сборке —
существенно облегчает планирование подъезда и парковки.

Поля:
  - Подъезд + этаж
  - 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.
This commit is contained in:
wasrusgen 2026-05-13 17:46:53 +03:00
parent fdce3b3c64
commit e2e17fd5a6
4 changed files with 335 additions and 14 deletions

View File

@ -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", ""),
}

View File

@ -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(`
<section class="block logistics-block">
<div class="block-head" id="logHead">
<span>📍 Логистика ${hasData ? '<span class="log-dot">●</span>' : ''}</span>
<button class="log-toggle" id="logToggle" type="button">${hasData ? "Изменить" : "Заполнить"}</button>
</div>
<div class="log-summary" id="logSummary"></div>
<div class="log-editor" id="logEditor" style="display:none;">
<div class="form-row two-col">
<label class="field">
<span class="field-label">Подъезд</span>
<input type="text" id="logEntrance" value="${escHtml(m.entrance || "")}" placeholder="например: 2">
</label>
<label class="field">
<span class="field-label">Этаж</span>
<input type="text" id="logFloor" value="${escHtml(m.floor || "")}" placeholder="например: 7">
</label>
</div>
<div class="form-row">
<label class="field">
<span class="field-label">GPS координаты</span>
<div style="display:flex;gap:8px;align-items:center;">
<input type="text" id="logGps" value="${m.gps_lat && m.gps_lng ? `${m.gps_lat}, ${m.gps_lng}` : ""}" placeholder="широта, долгота" style="flex:1;">
<button class="btn-secondary" id="getGps" type="button" style="white-space:nowrap;padding:8px 14px;">📍 Сейчас</button>
</div>
<span class="field-hint" id="gpsHint">Тап «Сейчас» возьмёт координаты с устройства</span>
</label>
</div>
<div class="form-row">
<span class="field-label" style="display:block;margin-bottom:6px;">Парковка</span>
<div class="preferred-options">
<label class="pref-opt">
<input type="radio" name="parkType" value="free" ${m.parking_type === "free" ? "checked" : ""}>
<span class="pref-label">🅿 Бесплатная</span>
</label>
<label class="pref-opt">
<input type="radio" name="parkType" value="paid" ${m.parking_type === "paid" ? "checked" : ""}>
<span class="pref-label">💰 Платная</span>
</label>
<label class="pref-opt">
<input type="radio" name="parkType" value="street" ${m.parking_type === "street" ? "checked" : ""}>
<span class="pref-label">🛣 На улице</span>
</label>
<label class="pref-opt">
<input type="radio" name="parkType" value="none" ${m.parking_type === "none" ? "checked" : ""}>
<span class="pref-label">🚫 Нет парковки</span>
</label>
</div>
<input type="text" id="logParkNote" value="${escHtml(m.parking_note || "")}" placeholder="зона, тариф, как оплатить" style="margin-top:8px;">
</div>
<div class="form-row">
<label class="field">
<span class="field-label">Заметки логистики</span>
<textarea id="logDelivery" rows="3" placeholder="домофон, шлагбаум, размер лифта (для сборщика), узкий проезд, ...">${escHtml(m.delivery_notes || "")}</textarea>
</label>
</div>
<div class="podbor-cta-row">
<button class="btn-secondary" id="logCancel" type="button">Отмена</button>
<button class="btn-primary" id="logSave" type="button">Сохранить</button>
</div>
</div>
</section>
`);
// Сводка (когда не в режиме редактирования)
function updateSummary(curM) {
const sum = section.querySelector("#logSummary");
const lines = [];
if (curM.entrance) lines.push(`Подъезд <b>${escHtml(curM.entrance)}</b>`);
if (curM.floor) lines.push(`этаж <b>${escHtml(curM.floor)}</b>`);
if (curM.gps_lat && curM.gps_lng) {
const url = `https://maps.google.com/?q=${curM.gps_lat},${curM.gps_lng}`;
lines.push(`<a href="${url}" target="_blank" rel="noopener">📍 ${curM.gps_lat}, ${curM.gps_lng}</a>`);
}
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(`<i>${escHtml(curM.delivery_notes)}</i>`);
}
sum.innerHTML = lines.length
? lines.join(" · ")
: `<span style="color:var(--muted);font-size:13px;">Информация для подъезда не заполнена — заполни при выезде.</span>`;
}
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 ? '<span class="log-dot">●</span>' : ''}`;
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 для <input type="datetime-local">
try {

View File

@ -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;

View File

@ -12,8 +12,8 @@
<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">
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="assets/styles.css?v=20260513u">
<link rel="stylesheet" href="assets/podbor.css?v=20260513u">
<link rel="stylesheet" href="assets/styles.css?v=20260513v">
<link rel="stylesheet" href="assets/podbor.css?v=20260513v">
</head>
<body>
<!-- Splash — за пределами #app, render-функции его не смывают -->
@ -31,13 +31,13 @@
<div class="loader-tagline">Сделано с душой!</div>
</div>
<main id="app"></main>
<script src="assets/icons.js?v=20260513u"></script>
<script src="assets/podbor.config.js?v=20260513u"></script>
<script src="assets/podbor.picts.js?v=20260513u"></script>
<script src="assets/podbor.js?v=20260513u"></script>
<script src="assets/clients.js?v=20260513u"></script>
<script src="assets/measurements.js?v=20260513u"></script>
<script src="assets/request.js?v=20260513u"></script>
<script src="assets/app.js?v=20260513u"></script>
<script src="assets/icons.js?v=20260513v"></script>
<script src="assets/podbor.config.js?v=20260513v"></script>
<script src="assets/podbor.picts.js?v=20260513v"></script>
<script src="assets/podbor.js?v=20260513v"></script>
<script src="assets/clients.js?v=20260513v"></script>
<script src="assets/measurements.js?v=20260513v"></script>
<script src="assets/request.js?v=20260513v"></script>
<script src="assets/app.js?v=20260513v"></script>
</body>
</html>