mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 15:44:47 +00:00
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:
parent
fdce3b3c64
commit
e2e17fd5a6
@ -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", ""),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user