From 121927ab2dcfa5ddcef8fb8b73ff2047ec9f5bdc Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Wed, 13 May 2026 07:19:25 +0300 Subject: [PATCH] =?UTF-8?q?measurements:=20=D1=81=D1=82=D1=80=D1=83=D0=BA?= =?UTF-8?q?=D1=82=D1=83=D1=80=D0=B0=20=D1=84=D0=BE=D1=82=D0=BE=20+=20?= =?UTF-8?q?=D1=87=D0=B5=D0=BA-=D0=BB=D0=B8=D1=81=D1=82=20+=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=89=D0=B0=D1=8F=20=D0=B8=D0=BD=D1=84=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По чек-листу ЗАМЕРОВ (D:\!!! GOOGLE DISK\ЗАМЕРЫ\...\ЧЕКЛИСТ_ЗАМЕРА.md): каждая стена снимается отдельно, имя файла отражает тип. Wizard: - Каждое фото получает dropdown «Что это»: Стена 1, 2, 3, 4 · План комнаты · Общий вид · Деталь - Авто-предложение типа: w1 → w2 → w3 → w4 → plan → general - Добавлены поля общей инфы: · № замера (опционально) · Дата замера (auto-сегодня) · Стяжка / нулевой пол (default «0,000 = +88 мм над плитой») - В шапке кнопка 📋 — открывает чек-лист отдельной страницей - Inline-рендер markdown с поддержкой заголовков, списков, таблиц, code Backend: - _save_measurement_photo принимает kind+kind_seq → имена файлов структурные: w1.jpg, w2.jpg, plan.jpg, general_2.jpg, detail_1.jpg. Это упрощает дальнейшую обработку для генерации DWG. - Расширена схема Measurements: zamer_no, zamer_date, floor_base, photos_meta. - /api/measurement_detail отдаёт новые поля. Cache bust v=20260513l. --- backend-py/app/main.py | 78 +++++++-- miniapp/assets/measurements.js | 253 ++++++++++++++++++++++++++---- miniapp/assets/podbor.css | 88 +++++++++++ miniapp/assets/zamer-checklist.md | 145 +++++++++++++++++ miniapp/index.html | 20 +-- 5 files changed, 535 insertions(+), 49 deletions(-) create mode 100644 miniapp/assets/zamer-checklist.md diff --git a/backend-py/app/main.py b/backend-py/app/main.py index 506b1f0..90d629d 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -527,9 +527,24 @@ def _handle_me(body: dict[str, Any]) -> dict[str, Any]: _DATA_URL_RE = re.compile(r"^data:image/(jpeg|jpg|png|webp);base64,(.+)$", re.DOTALL) +# Маппинг тип фото → префикс имени файла (по чек-листу замера) +_PHOTO_KIND_PREFIX = { + "wall1": "w1", + "wall2": "w2", + "wall3": "w3", + "wall4": "w4", + "plan": "plan", + "general": "general", + "detail": "detail", +} -def _save_measurement_photo(measurement_id: str, idx: int, data_url: str) -> str | None: - """Сохраняет фото (data-URL) в `PHOTOS_DIR//.`. + +def _save_measurement_photo( + measurement_id: str, idx: int, data_url: str, + kind: str | None = None, kind_seq: int = 0, +) -> str | None: + """Сохраняет фото с осмысленным именем: `w1.jpg` / `plan.jpg` / `general_3.jpg`. + Если несколько фото одного типа — добавляем суффикс _2, _3. Возвращает имя файла или None при ошибке.""" if not isinstance(data_url, str): return None @@ -548,7 +563,24 @@ def _save_measurement_photo(measurement_id: str, idx: int, data_url: str) -> str target_dir = PHOTOS_DIR / measurement_id try: target_dir.mkdir(parents=True, exist_ok=True) - name = f"{idx}.{ext}" + prefix = _PHOTO_KIND_PREFIX.get(kind or "", "") + if prefix: + # wall1/2/3/4 — один на стену; если дубль — добавляем _2, _3... + if prefix.startswith("w") and len(prefix) == 2: + name_base = prefix + candidate = f"{name_base}.{ext}" + n = 2 + while (target_dir / candidate).exists(): + candidate = f"{name_base}_{n}.{ext}" + n += 1 + name = candidate + else: + # plan / general / detail — могут быть множественные + name = f"{prefix}_{kind_seq}.{ext}" if kind_seq > 0 else f"{prefix}.{ext}" + if (target_dir / name).exists(): + name = f"{prefix}_{kind_seq + 1}.{ext}" + else: + name = f"{idx}.{ext}" (target_dir / name).write_bytes(raw) return name except Exception: @@ -562,9 +594,11 @@ def _measurement_columns() -> list[str]: "id", "ts", "client_tg_id", "manager_tg_id", "filled_by", "layout", "area_m2", "ceiling_mm", "walls", "openings", "infra", "niches", "photos", "notes", "status", - # Новые поля (Commit B) + # Поля Commit B (workflow) "assigned_to_tg_id", "requested_by_tg_id", "scheduled_at", "address", "client_name", "client_phone", + # Поля Commit C (структура замера по чек-листу) + "zamer_no", "zamer_date", "floor_base", "photos_meta", ] @@ -595,6 +629,7 @@ def _row_for_measurement(measurement_id: str, ts: str, **fields) -> list[str]: "photos": "", "notes": "", "status": "submitted", "assigned_to_tg_id": "", "requested_by_tg_id": "", "scheduled_at": "", "address": "", "client_name": "", "client_phone": "", + "zamer_no": "", "zamer_date": "", "floor_base": "", "photos_meta": "", } base.update(fields) return [str(base.get(c, "")) for c in cols] @@ -666,17 +701,24 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]: if extras: notes_full = " · ".join(extras) + ("\n" + notes_full if notes_full else "") - # Сохраняем фотографии (data-URL → файлы), в Sheets кладём только имена + # Сохраняем фотографии (data-URL → файлы), в Sheets кладём только имена. + # Имена структурные: w1.jpg, plan.jpg, general_2.jpg — по типу из photos_meta. raw_photos = m.get("photos") or [] + photos_meta = m.get("photos_meta") or [] saved_photos: list[str] = [] + kind_counter: dict[str, int] = {} # сколько раз уже встречался каждый kind if isinstance(raw_photos, list): - for i, p in enumerate(raw_photos[:20]): # хард-кап 20 фото на замер + for i, p in enumerate(raw_photos[:30]): # хард-кап 30 фото на замер + kind = "" + if i < len(photos_meta) and isinstance(photos_meta[i], dict): + kind = photos_meta[i].get("kind", "") + kind_counter[kind] = kind_counter.get(kind, 0) + 1 + seq = kind_counter[kind] - 1 # 0 для первого, 1 для второго и т.д. if isinstance(p, str) and p.startswith("data:"): - fn = _save_measurement_photo(measurement_id, i, p) + fn = _save_measurement_photo(measurement_id, i, p, kind=kind, kind_seq=seq) if fn: saved_photos.append(fn) elif isinstance(p, str) and p and not p.startswith("data:"): - # уже готовое имя/URL — пропускаем как есть saved_photos.append(p) status_new = "completed" @@ -685,6 +727,11 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]: infra_json = json.dumps(m.get("infra") or {}, ensure_ascii=False) niches_json = json.dumps(m.get("niches") or {}, ensure_ascii=False) photos_str = ",".join(saved_photos) + photos_meta_json = json.dumps(m.get("photos_meta") or [], ensure_ascii=False) + + zamer_no = (m.get("zamer_no") or "").strip() + zamer_date = (m.get("zamer_date") or "").strip() + floor_base = (m.get("floor_base") or "").strip() if update_mode: # Обновляем существующую заявку — статус → completed, плюс заполняем поля @@ -698,8 +745,12 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]: "infra": infra_json, "niches": niches_json, "photos": photos_str, + "photos_meta": photos_meta_json, "notes": notes_full, "status": status_new, + "zamer_no": zamer_no, + "zamer_date": zamer_date, + "floor_base": floor_base, } for col, val in updates.items(): sheets.update_cell_by_key("Measurements", "id", measurement_id, col, val) @@ -717,6 +768,7 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]: infra=infra_json, niches=niches_json, photos=photos_str, + photos_meta=photos_meta_json, notes=notes_full, status=status_new, assigned_to_tg_id=assigned_to, @@ -725,6 +777,9 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]: address=address, client_name=client_name, client_phone=client_phone, + zamer_no=zamer_no, + zamer_date=zamer_date, + floor_base=floor_base, )) if client_tg_id: @@ -1262,13 +1317,18 @@ def _handle_measurement_detail(body: dict[str, Any]) -> dict[str, Any]: "photos": photo_files, "notes": row.get("notes", ""), "status": row.get("status", ""), - # Новые поля (Commit B) + # Поля Commit B (workflow) "assigned_to_tg_id": row.get("assigned_to_tg_id", ""), "requested_by_tg_id": row.get("requested_by_tg_id", ""), "scheduled_at": row.get("scheduled_at", ""), "address": row.get("address", ""), "client_name": row.get("client_name", ""), "client_phone": row.get("client_phone", ""), + # Поля Commit C (структура замера) + "zamer_no": row.get("zamer_no", ""), + "zamer_date": row.get("zamer_date", ""), + "floor_base": row.get("floor_base", ""), + "photos_meta": _safe_json(row.get("photos_meta", "")), } diff --git a/miniapp/assets/measurements.js b/miniapp/assets/measurements.js index da46ef1..f0258e5 100644 --- a/miniapp/assets/measurements.js +++ b/miniapp/assets/measurements.js @@ -1,33 +1,53 @@ /* ============================================================ - Замер — упрощённая версия: только фото + заметки. - DWG-чертёж потом делается отдельным процессом из фото. + Замер — структурированная загрузка фото по типам. + Типы фото: стена 1-4, план комнаты, общий вид, деталь. ============================================================ */ const Measurements = (function () { - const STORAGE_KEY = "zov-measurement-draft"; + const STORAGE_KEY = "zov-measurement-draft-v2"; - // Фото держим только в памяти — data-URL'ы тяжёлые - let photos = []; // Array<{ name, dataUrl }> + // Типы фото — в соответствии с чек-листом ЗАМЕРОВ + const PHOTO_KINDS = [ + { key: "wall1", label: "Стена 1" }, + { key: "wall2", label: "Стена 2" }, + { key: "wall3", label: "Стена 3" }, + { key: "wall4", label: "Стена 4" }, + { key: "plan", label: "План комнаты" }, + { key: "general", label: "Общий вид" }, + { key: "detail", label: "Деталь" }, + ]; + + function kindLabel(k) { + return (PHOTO_KINDS.find(p => p.key === k) || {}).label || k; + } + + // Фото держим только в памяти + let photos = []; // Array<{ dataUrl, kind }> let state = loadState(); let root = null; - let measurementId = ""; // если задан — update-mode (закрытие существующей заявки) - let prefilledClient = null; // данные клиента из заявки в update-mode + let measurementId = ""; // если задан — update-mode (закрытие заявки) + let prefilledClient = null; function loadState() { try { const raw = localStorage.getItem(STORAGE_KEY); - if (raw) return JSON.parse(raw); + if (raw) return { ...defaultState(), ...JSON.parse(raw) }; } catch (e) {} return defaultState(); } function defaultState() { + const todayStr = new Date().toISOString().slice(0, 10); return { client_name: "", client_phone: "", address: "", notes: "", + // Общая инфа замера (по чек-листу) + zamer_no: "", + zamer_date: todayStr, + floor_base: "0,000 = +88 мм над плитой", }; } @@ -54,10 +74,15 @@ const Measurements = (function () { measurementId = ""; prefilledClient = null; - // ?id= → update-mode (замерщик закрывает заявку) const hashMatch = (location.hash.split("?")[1] || ""); const fragQp = new URLSearchParams(hashMatch); const mid = fragQp.get("id") || new URLSearchParams(location.search).get("measurement_id") || ""; + + // Спецроут #/measure/checklist — показать чек-лист + if (location.hash.startsWith("#/measure/checklist")) { + renderChecklist(); + return; + } if (mid) { measurementId = mid; loadRequestAndStart(); @@ -91,8 +116,6 @@ const Measurements = (function () { phone: data.client_phone || "", address: data.address || "", }; - // Не показываем форму клиента — она read-only - state.notes = state.notes || ""; render(); } catch (e) { root.innerHTML = ""; @@ -115,17 +138,20 @@ const Measurements = (function () {
${escHtml(title)}
-
+
`); h.querySelector(".podbor-back").addEventListener("click", () => { location.hash = ""; location.reload(); }); + h.querySelector("#openChecklist").addEventListener("click", () => { + location.hash = "#/measure/checklist"; + }); return h; } - /* ===================== Главный экран — всё на одной странице ===================== */ + /* ===================== Главный экран ===================== */ function renderForm() { const isUpdate = !!measurementId && prefilledClient; @@ -135,27 +161,50 @@ const Measurements = (function () {

${isUpdate ? "Фото
с замера" : "Новый
замер"}

${isUpdate - ? "Загрузите фото от руки нарисованных замеров (а также общие фото помещения). Чертёж сделаем по ним отдельно." - : "Заполните данные клиента и загрузите фото замера. Можно фотать рукописные эскизы — главное чтобы были видны размеры."} -

+ ? "Загружайте фото по чек-листу — каждая стена отдельно. Чертёж сделаем по фото." + : "Заполните клиента, дату и загрузите фото по чек-листу. Откройте 📋 чтобы посмотреть как правильно снимать."}

-
📷 Фото замера
+
📐 Общая информация
+
+ + +
+
+ +
+ +
+ 📷 Фото замера + Чек-лист +
+

+ Для каждого фото выберите тип. По чек-листу: каждая стена отдельно + план + общие виды. +

-
+
@@ -171,6 +220,9 @@ const Measurements = (function () { bindInputs(node); bindPhotoInput(node); + node.querySelector("#openChecklist2").addEventListener("click", () => { + location.hash = "#/measure/checklist"; + }); node.querySelector("#submitBtn").addEventListener("click", () => onSubmit(node)); return node; } @@ -186,7 +238,7 @@ const Measurements = (function () { } function renderClientInputs() { - const block = el(` + return el(`
`); - return block; } function bindInputs(node) { @@ -222,17 +273,39 @@ const Measurements = (function () { }); } + function nextKindSuggestion() { + // Авто-предложение: сначала Стена 1, потом 2,3,4, затем План, затем Общий + const usedWalls = new Set(photos.filter(p => p.kind?.startsWith("wall")).map(p => p.kind)); + for (let i = 1; i <= 4; i++) { + if (!usedWalls.has(`wall${i}`)) return `wall${i}`; + } + const hasPlan = photos.some(p => p.kind === "plan"); + if (!hasPlan) return "plan"; + return "general"; + } + function bindPhotoInput(node) { const list = node.querySelector("#photoList"); const input = node.querySelector("#photoInput"); function refreshList() { list.innerHTML = ""; + if (!photos.length) { + list.innerHTML = `
Ещё нет фото
`; + return; + } photos.forEach((ph, idx) => { const tile = el(` -
- фото ${idx + 1} - +
+
+ фото ${idx + 1} + +
+
`); tile.querySelector(".photo-rm").addEventListener("click", e => { @@ -241,6 +314,10 @@ const Measurements = (function () { haptic && haptic("impact"); refreshList(); }); + tile.querySelector(".photo-kind").addEventListener("change", e => { + const i = +e.target.dataset.idx; + photos[i].kind = e.target.value; + }); list.appendChild(tile); }); } @@ -250,11 +327,12 @@ const Measurements = (function () { input.value = ""; if (!files.length) return; for (const f of files) { - if (photos.length >= 20) break; + if (photos.length >= 30) break; if (!f.type || !f.type.startsWith("image/")) continue; try { const dataUrl = await compressImage(f, 1800, 0.78); - photos.push({ name: f.name || `photo_${photos.length + 1}`, dataUrl }); + const kind = nextKindSuggestion(); + photos.push({ dataUrl, kind }); } catch (err) { console.warn("Не удалось сжать фото", err); } @@ -266,7 +344,6 @@ const Measurements = (function () { refreshList(); } - /* Сжатие через canvas */ function compressImage(file, maxSide = 1800, quality = 0.78) { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -298,6 +375,116 @@ const Measurements = (function () { }); } + /* ===================== Чек-лист — отдельный экран ===================== */ + + async function renderChecklist() { + root.innerHTML = ""; + root.appendChild(el(` +
+ +
Чек-лист замера
+
+
+ `)); + root.querySelector(".podbor-back").addEventListener("click", () => { + // Возврат к мастеру (если был открыт через #/measure?id=X) + if (measurementId) location.hash = `#/measure?id=${measurementId}`; + else location.hash = "#/measure"; + }); + + const wrap = el(`
`); + root.appendChild(wrap); + wrap.appendChild(el(`
`)); + + try { + const res = await fetch("./assets/zamer-checklist.md", { cache: "no-cache" }); + const md = await res.text(); + wrap.innerHTML = `
${renderMarkdown(md)}
`; + } catch (e) { + wrap.innerHTML = `
Не удалось загрузить чек-лист: ${e.message}
`; + } + } + + /* Минимальный markdown → HTML: заголовки, списки, таблицы, code */ + function renderMarkdown(md) { + const lines = md.split("\n"); + const out = []; + let inList = false; + let inTable = false; + let tableRows = []; + + function closeList() { if (inList) { out.push(""); inList = false; } } + function closeTable() { + if (!inTable) return; + if (tableRows.length) { + const html = [""]; + tableRows.forEach((cells, i) => { + const tag = i === 0 ? "th" : "td"; + if (i === 1 && cells.every(c => /^[-:\s|]+$/.test(c))) return; // skip separator + html.push(`${cells.map(c => `<${tag}>${inline(c)}`).join("")}`); + }); + html.push("
"); + out.push(html.join("")); + } + tableRows = []; + inTable = false; + } + + function inline(s) { + return escHtml(s) + .replace(/`([^`]+)`/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + } + + for (const raw of lines) { + const line = raw.trimEnd(); + // Таблица + if (line.includes("|") && line.match(/^\s*\|/)) { + if (!inTable) { closeList(); inTable = true; } + const cells = line.split("|").slice(1, -1).map(s => s.trim()); + tableRows.push(cells); + continue; + } else if (inTable) { + closeTable(); + } + // Заголовки + if (line.startsWith("# ")) { + closeList(); + out.push(`

${inline(line.slice(2))}

`); + } else if (line.startsWith("## ")) { + closeList(); + out.push(`

${inline(line.slice(3))}

`); + } else if (line.startsWith("### ")) { + closeList(); + out.push(`

${inline(line.slice(4))}

`); + } else if (line.startsWith("- ") || line.startsWith("* ")) { + if (!inList) { out.push("
    "); inList = true; } + let content = line.slice(2); + // [ ] checkbox + if (content.startsWith("[ ] ")) { + out.push(`
  • ${inline(content.slice(4))}
  • `); + } else if (content.startsWith("[x] ") || content.startsWith("[X] ")) { + out.push(`
  • ${inline(content.slice(4))}
  • `); + } else { + out.push(`
  • ${inline(content)}
  • `); + } + } else if (line === "---") { + closeList(); + out.push(`
    `); + } else if (line === "") { + closeList(); + out.push(""); + } else { + closeList(); + out.push(`

    ${inline(line)}

    `); + } + } + closeList(); + closeTable(); + return out.join("\n"); + } + /* ===================== Submit ===================== */ async function onSubmit(node) { @@ -307,7 +494,6 @@ const Measurements = (function () { btn.innerHTML = ' сохраняем...'; result.innerHTML = ""; - // Валидация: для новой записи нужны клиент + телефон + хотя бы 1 фото const isUpdate = !!measurementId && prefilledClient; if (!isUpdate) { const name = (state.client_name || "").trim(); @@ -334,8 +520,15 @@ const Measurements = (function () { } const measurement = { + // Структурированные фото + их типы photos: photos.map(p => p.dataUrl), + photos_meta: photos.map(p => ({ kind: p.kind })), + // Общая инфа замера + zamer_no: state.zamer_no || "", + zamer_date: state.zamer_date || "", + floor_base: state.floor_base || "", notes: state.notes || "", + // Клиент client_name: isUpdate ? prefilledClient.name : state.client_name, client_phone: isUpdate ? prefilledClient.phone : state.client_phone, address: isUpdate ? prefilledClient.address : state.address, @@ -392,5 +585,5 @@ const Measurements = (function () { } function escAttr(s) { return escHtml(s); } - return { mount, reset }; + return { mount, reset, kindLabel, PHOTO_KINDS }; })(); diff --git a/miniapp/assets/podbor.css b/miniapp/assets/podbor.css index e609a0d..c30cba0 100644 --- a/miniapp/assets/podbor.css +++ b/miniapp/assets/podbor.css @@ -1989,6 +1989,94 @@ overflow-x: auto; } +/* ===== Замер: фото с тегами ===== */ +.podbor-header .podbor-help { + background: transparent; + border: none; + font-size: 18px; + cursor: pointer; + padding: 4px 8px; + width: 28px; + text-align: right; +} +.photo-list-tagged { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(108px, 1fr)); + gap: 10px; + margin: 10px 0 16px; +} +.photo-tagged { + display: flex; + flex-direction: column; + gap: 6px; +} +.photo-tagged-thumb { + position: relative; + aspect-ratio: 1 / 1; + border-radius: 10px; + overflow: hidden; + background: var(--warm, rgba(107, 74, 43, 0.08)); + border: 1px solid rgba(107, 74, 43, 0.15); +} +.photo-tagged-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.photo-tagged-thumb .photo-rm { + position: absolute; + top: 4px; + right: 4px; + width: 22px; + height: 22px; + border-radius: 50%; + border: none; + background: rgba(0, 0, 0, 0.55); + color: white; + font-size: 16px; + line-height: 1; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} +.photo-kind { + width: 100%; + padding: 5px 6px; + font-size: 12px; + border-radius: 6px; + border: 1px solid rgba(107, 74, 43, 0.25); + background: var(--paper, #FBF7F0); + color: var(--ink, #1F1A14); + font-family: var(--font-ui, "Inter", sans-serif); +} + +/* ===== Чек-лист — страница markdown ===== */ +.checklist-page .checklist-md { + font-family: var(--font-ui, "Inter", sans-serif); + font-size: 14px; + line-height: 1.55; + color: var(--ink, #1F1A14); +} +.checklist-md h1 { font-family: var(--font-display, "Newsreader", serif); font-size: 22px; margin: 20px 0 10px; font-style: italic; } +.checklist-md h2 { font-family: var(--font-display, "Newsreader", serif); font-size: 18px; margin: 18px 0 8px; } +.checklist-md h3 { font-size: 14.5px; font-weight: 600; margin: 14px 0 6px; } +.checklist-md p { margin: 6px 0; } +.checklist-md ul { margin: 6px 0 6px 6px; padding-left: 14px; } +.checklist-md li { margin: 3px 0; list-style: none; position: relative; padding-left: 22px; } +.checklist-md li::before { content: ""; position: absolute; left: 6px; top: 9px; width: 6px; height: 6px; background: var(--walnut, #6B4A2B); opacity: 0.45; border-radius: 50%; } +.checklist-md li .cl-check { position: absolute; left: 0; top: 0; font-size: 14px; color: var(--walnut, #6B4A2B); } +.checklist-md li .cl-check.checked { color: var(--accent-1, #003E7E); } +.checklist-md li:has(.cl-check)::before { display: none; } +.checklist-md hr { margin: 18px 0; border: none; border-top: 1px dashed rgba(107, 74, 43, 0.25); } +.checklist-md code { background: rgba(107, 74, 43, 0.08); padding: 1px 5px; border-radius: 3px; font-family: var(--font-mono, "JetBrains Mono", monospace); font-size: 12.5px; } +.checklist-md strong { color: var(--ink, #1F1A14); } +.checklist-md .cl-table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 12.5px; } +.checklist-md .cl-table th, .checklist-md .cl-table td { border: 1px solid rgba(107, 74, 43, 0.18); padding: 4px 8px; text-align: left; } +.checklist-md .cl-table th { background: rgba(107, 74, 43, 0.08); font-weight: 600; } + /* ===== Кабинет сотрудника (замерщик/сборщик) ===== */ .staff-head { display: flex; diff --git a/miniapp/assets/zamer-checklist.md b/miniapp/assets/zamer-checklist.md new file mode 100644 index 0000000..6d5b404 --- /dev/null +++ b/miniapp/assets/zamer-checklist.md @@ -0,0 +1,145 @@ +# Чек-лист замера квартиры + +Применяется ко всем фото-замерам. Цель — чтобы по фото без уточнений собирался корректный CAD/DXF и менеджер всё понял с первого взгляда. + +--- + +## 1. Перед началом + +- [ ] Завести папку `ЗАМЕРЫ/<номер> <адрес> - <кв>/` (как 157, 158…). +- [ ] Снимать каждую стену **отдельно**, кадр должен охватывать стену целиком, от пола до потолка. +- [ ] Имя файла фото = номер стены: `_w1.jpg`, `_w2.jpg`, … (либо переименовать после съёмки). + +--- + +## 2. На каждой стене — обязательный минимум + +### 2.1. Габариты + +- [ ] Ширина по верху (от ЛУ до ПУ). +- [ ] Ширина по низу. +- [ ] При необходимости — ширина по середине (если стена «играет»). +- [ ] Высота слева (от пола до потолка). +- [ ] Высота справа. + +### 2.2. Углы + +- [ ] Угол в ЛВ (левый-верх): значение в градусах, например `89,9°`. +- [ ] Угол в ПВ (правый-верх). +- [ ] Угол в ЛН (левый-низ). +- [ ] Угол в ПН (правый-низ). +- [ ] Если угол не мерил — пиши `≈90°`, чтобы потом не угадывали. + +### 2.3. База отсчёта + +- [ ] Выбрать **один угол** для всех горизонтальных размеров на этой стене (обычно — общий физический угол со смежной стеной). +- [ ] Подписать в кадре: `БАЗА: ЛУ` или `БАЗА: ПУ`. +- [ ] Все горизонтали на этой стене — **только от этой базы**. Никаких цепочек между точками. + +### 2.4. Вертикальная база + +- [ ] Низ — `пол` (нулевой пол, обычно +88 мм над плитой). +- [ ] Верх — `потолок`. +- [ ] Для каждой точки — указать, от пола или от потолка считаешь Y. + +--- + +## 3. Точки замера — формат пина + +Каждая точка = крест/кружок на стене + шильд со стрелкой. В шильде только **код**: + +| Код | Что это | +|-------------|----------------------------------------| +| `R1, R2, …` | розетка одинарная | +| `R1×2`, `R1×3` | блок из 2/3 розеток в одной точке | +| `Rs1` | силовая розетка | +| `Sw1` | выключатель | +| `Ld1` | светильник (LED, бра, потолочный) | +| `V1` | вентиляция / вентбокс (+ габарит, например `200×200`) | +| `J1` | распаечная коробка | +| `Wc1` | вода холодная | +| `Wh1` | вода горячая | +| `D1` | слив / канализация (горизонт. выход) | +| `D1'` | фановый/вертикальный выход у того же узла | +| `Tv1` | ТВ | +| `Net1` | интернет | + +Не писать «роз x1» — только код. + +--- + +## 4. Размеры на каждой точке — два числа + +- [ ] **Горизонталь** до угла-базы: число + (по желанию) буква базы, например `1087→ПУ`. +- [ ] **Вертикаль** до пола или потолка: `617↑пол` или `1919↓потолок`. + +Не давать цепочек между точками. Если одна горизонталь общая для нескольких точек (R6/Wc1/Wh1 на 617) — пиши число один раз, а напротив каждой точки — её X. + +--- + +## 5. Объёмные элементы (выпуски, ниши, колонны) + +Развёртка плоская, глубина не показывается. Пиши в шильд так: + +- [ ] Труба торчит из стены: `D1' выпуск 70` — значит выходит на 70 мм от плоскости стены. Стрелка от точки — косая. +- [ ] Ниша: `ниша 100×400 гл.50`. +- [ ] Колонна / выступ: `колонна +120` (выступает на 120 мм). + +--- + +## 6. Проёмы (двери / окна / балкон) + +- [ ] Обвести проём прямоугольником с диагональю. +- [ ] Подписать код: `ДВ1`, `ОК1`, `БК1`. +- [ ] Размеры: + - ширина проёма + - высота проёма (от пола) + - отступ от угла-базы стены до края проёма + - для окна — высота низа подоконника от пола + +--- + +## 7. Стена «без элементов» + +- [ ] Только габариты + углы. +- [ ] Подписать `пусто` или просто не ставить точки. + +--- + +## 8. Общая информация (один раз на квартиру) + +- [ ] Номер замера. +- [ ] Адрес, корпус, квартира. +- [ ] Дата замера. +- [ ] Толщина стяжки / нулевой пол (например, `0,000 = +88 мм над плитой`, стяжка 98 мм). +- [ ] План комнаты со стрелками-направлениями: какая стена 1/2/3/4 (особенно когда несколько помещений). +- [ ] Если стены смежные — отметить общий угол: «ПУ стены 2 = ЛУ стены 3». + +--- + +## 9. Перед отправкой + +- [ ] Проверить, что для каждой точки указаны и горизонталь, и вертикаль. +- [ ] Проверить, что все горизонтали на одной стене считаны от одного угла-базы. +- [ ] Отметить базы прямо на фото (`БАЗА: ПУ`). +- [ ] Углы в градусах подписаны (или пометка `≈90°`). +- [ ] Сложить все фото в папку замера. + +--- + +## 10. Что я делаю с фото + +1. Кладу всё в `<папка замера>/CAD/`. +2. Создаю DXF (R2018, мм) с тремя/четырьмя развёртками стен. +3. Каждая точка = крест + код в шильде + два размера до баз. +4. PNG и PDF превью для отправки менеджеру. +5. Скрипт `build_cad.py` остаётся в той же папке — можно править координаты и пересобирать. + +--- + +## Пример (замер 157, кв. 411) + +- Стена 1: пусто, только габариты (1149×2745). +- Стена 2: база — ПРАВЫЙ угол. R7, R5, R6, Wc1, Wh1, D1, D1', R8. +- Стена 3: база — ЛЕВЫЙ угол. V1, R4, Ld1, R1×2, Rs5, R3. +- ПУ стены 2 = ЛУ стены 3 (общий физический угол). diff --git a/miniapp/index.html b/miniapp/index.html index d470c44..9eaab7c 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -12,8 +12,8 @@ - - + + @@ -34,13 +34,13 @@
- - - - - - - - + + + + + + + +