mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 18:04:47 +00:00
measurements: структура фото + чек-лист + общая инфа
По чек-листу ЗАМЕРОВ (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.
This commit is contained in:
parent
5c2a5bb335
commit
121927ab2d
@ -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)
|
_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/<measurement_id>/<idx>.<ext>`.
|
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 при ошибке."""
|
Возвращает имя файла или None при ошибке."""
|
||||||
if not isinstance(data_url, str):
|
if not isinstance(data_url, str):
|
||||||
return None
|
return None
|
||||||
@ -548,6 +563,23 @@ def _save_measurement_photo(measurement_id: str, idx: int, data_url: str) -> str
|
|||||||
target_dir = PHOTOS_DIR / measurement_id
|
target_dir = PHOTOS_DIR / measurement_id
|
||||||
try:
|
try:
|
||||||
target_dir.mkdir(parents=True, exist_ok=True)
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
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}"
|
name = f"{idx}.{ext}"
|
||||||
(target_dir / name).write_bytes(raw)
|
(target_dir / name).write_bytes(raw)
|
||||||
return name
|
return name
|
||||||
@ -562,9 +594,11 @@ def _measurement_columns() -> list[str]:
|
|||||||
"id", "ts", "client_tg_id", "manager_tg_id", "filled_by",
|
"id", "ts", "client_tg_id", "manager_tg_id", "filled_by",
|
||||||
"layout", "area_m2", "ceiling_mm", "walls", "openings", "infra", "niches",
|
"layout", "area_m2", "ceiling_mm", "walls", "openings", "infra", "niches",
|
||||||
"photos", "notes", "status",
|
"photos", "notes", "status",
|
||||||
# Новые поля (Commit B)
|
# Поля Commit B (workflow)
|
||||||
"assigned_to_tg_id", "requested_by_tg_id", "scheduled_at",
|
"assigned_to_tg_id", "requested_by_tg_id", "scheduled_at",
|
||||||
"address", "client_name", "client_phone",
|
"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",
|
"photos": "", "notes": "", "status": "submitted",
|
||||||
"assigned_to_tg_id": "", "requested_by_tg_id": "", "scheduled_at": "",
|
"assigned_to_tg_id": "", "requested_by_tg_id": "", "scheduled_at": "",
|
||||||
"address": "", "client_name": "", "client_phone": "",
|
"address": "", "client_name": "", "client_phone": "",
|
||||||
|
"zamer_no": "", "zamer_date": "", "floor_base": "", "photos_meta": "",
|
||||||
}
|
}
|
||||||
base.update(fields)
|
base.update(fields)
|
||||||
return [str(base.get(c, "")) for c in cols]
|
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:
|
if extras:
|
||||||
notes_full = " · ".join(extras) + ("\n" + notes_full if notes_full else "")
|
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 []
|
raw_photos = m.get("photos") or []
|
||||||
|
photos_meta = m.get("photos_meta") or []
|
||||||
saved_photos: list[str] = []
|
saved_photos: list[str] = []
|
||||||
|
kind_counter: dict[str, int] = {} # сколько раз уже встречался каждый kind
|
||||||
if isinstance(raw_photos, list):
|
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:"):
|
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:
|
if fn:
|
||||||
saved_photos.append(fn)
|
saved_photos.append(fn)
|
||||||
elif isinstance(p, str) and p and not p.startswith("data:"):
|
elif isinstance(p, str) and p and not p.startswith("data:"):
|
||||||
# уже готовое имя/URL — пропускаем как есть
|
|
||||||
saved_photos.append(p)
|
saved_photos.append(p)
|
||||||
|
|
||||||
status_new = "completed"
|
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)
|
infra_json = json.dumps(m.get("infra") or {}, ensure_ascii=False)
|
||||||
niches_json = json.dumps(m.get("niches") or {}, ensure_ascii=False)
|
niches_json = json.dumps(m.get("niches") or {}, ensure_ascii=False)
|
||||||
photos_str = ",".join(saved_photos)
|
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:
|
if update_mode:
|
||||||
# Обновляем существующую заявку — статус → completed, плюс заполняем поля
|
# Обновляем существующую заявку — статус → completed, плюс заполняем поля
|
||||||
@ -698,8 +745,12 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"infra": infra_json,
|
"infra": infra_json,
|
||||||
"niches": niches_json,
|
"niches": niches_json,
|
||||||
"photos": photos_str,
|
"photos": photos_str,
|
||||||
|
"photos_meta": photos_meta_json,
|
||||||
"notes": notes_full,
|
"notes": notes_full,
|
||||||
"status": status_new,
|
"status": status_new,
|
||||||
|
"zamer_no": zamer_no,
|
||||||
|
"zamer_date": zamer_date,
|
||||||
|
"floor_base": floor_base,
|
||||||
}
|
}
|
||||||
for col, val in updates.items():
|
for col, val in updates.items():
|
||||||
sheets.update_cell_by_key("Measurements", "id", measurement_id, col, val)
|
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,
|
infra=infra_json,
|
||||||
niches=niches_json,
|
niches=niches_json,
|
||||||
photos=photos_str,
|
photos=photos_str,
|
||||||
|
photos_meta=photos_meta_json,
|
||||||
notes=notes_full,
|
notes=notes_full,
|
||||||
status=status_new,
|
status=status_new,
|
||||||
assigned_to_tg_id=assigned_to,
|
assigned_to_tg_id=assigned_to,
|
||||||
@ -725,6 +777,9 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
address=address,
|
address=address,
|
||||||
client_name=client_name,
|
client_name=client_name,
|
||||||
client_phone=client_phone,
|
client_phone=client_phone,
|
||||||
|
zamer_no=zamer_no,
|
||||||
|
zamer_date=zamer_date,
|
||||||
|
floor_base=floor_base,
|
||||||
))
|
))
|
||||||
|
|
||||||
if client_tg_id:
|
if client_tg_id:
|
||||||
@ -1262,13 +1317,18 @@ def _handle_measurement_detail(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"photos": photo_files,
|
"photos": photo_files,
|
||||||
"notes": row.get("notes", ""),
|
"notes": row.get("notes", ""),
|
||||||
"status": row.get("status", ""),
|
"status": row.get("status", ""),
|
||||||
# Новые поля (Commit B)
|
# Поля Commit B (workflow)
|
||||||
"assigned_to_tg_id": row.get("assigned_to_tg_id", ""),
|
"assigned_to_tg_id": row.get("assigned_to_tg_id", ""),
|
||||||
"requested_by_tg_id": row.get("requested_by_tg_id", ""),
|
"requested_by_tg_id": row.get("requested_by_tg_id", ""),
|
||||||
"scheduled_at": row.get("scheduled_at", ""),
|
"scheduled_at": row.get("scheduled_at", ""),
|
||||||
"address": row.get("address", ""),
|
"address": row.get("address", ""),
|
||||||
"client_name": row.get("client_name", ""),
|
"client_name": row.get("client_name", ""),
|
||||||
"client_phone": row.get("client_phone", ""),
|
"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", "")),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,33 +1,53 @@
|
|||||||
/* ============================================================
|
/* ============================================================
|
||||||
Замер — упрощённая версия: только фото + заметки.
|
Замер — структурированная загрузка фото по типам.
|
||||||
DWG-чертёж потом делается отдельным процессом из фото.
|
Типы фото: стена 1-4, план комнаты, общий вид, деталь.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const Measurements = (function () {
|
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 state = loadState();
|
||||||
let root = null;
|
let root = null;
|
||||||
let measurementId = ""; // если задан — update-mode (закрытие существующей заявки)
|
let measurementId = ""; // если задан — update-mode (закрытие заявки)
|
||||||
let prefilledClient = null; // данные клиента из заявки в update-mode
|
let prefilledClient = null;
|
||||||
|
|
||||||
function loadState() {
|
function loadState() {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
if (raw) return JSON.parse(raw);
|
if (raw) return { ...defaultState(), ...JSON.parse(raw) };
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
return defaultState();
|
return defaultState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultState() {
|
function defaultState() {
|
||||||
|
const todayStr = new Date().toISOString().slice(0, 10);
|
||||||
return {
|
return {
|
||||||
client_name: "",
|
client_name: "",
|
||||||
client_phone: "",
|
client_phone: "",
|
||||||
address: "",
|
address: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
|
// Общая инфа замера (по чек-листу)
|
||||||
|
zamer_no: "",
|
||||||
|
zamer_date: todayStr,
|
||||||
|
floor_base: "0,000 = +88 мм над плитой",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,10 +74,15 @@ const Measurements = (function () {
|
|||||||
measurementId = "";
|
measurementId = "";
|
||||||
prefilledClient = null;
|
prefilledClient = null;
|
||||||
|
|
||||||
// ?id=<measurement_id> → update-mode (замерщик закрывает заявку)
|
|
||||||
const hashMatch = (location.hash.split("?")[1] || "");
|
const hashMatch = (location.hash.split("?")[1] || "");
|
||||||
const fragQp = new URLSearchParams(hashMatch);
|
const fragQp = new URLSearchParams(hashMatch);
|
||||||
const mid = fragQp.get("id") || new URLSearchParams(location.search).get("measurement_id") || "";
|
const mid = fragQp.get("id") || new URLSearchParams(location.search).get("measurement_id") || "";
|
||||||
|
|
||||||
|
// Спецроут #/measure/checklist — показать чек-лист
|
||||||
|
if (location.hash.startsWith("#/measure/checklist")) {
|
||||||
|
renderChecklist();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (mid) {
|
if (mid) {
|
||||||
measurementId = mid;
|
measurementId = mid;
|
||||||
loadRequestAndStart();
|
loadRequestAndStart();
|
||||||
@ -91,8 +116,6 @@ const Measurements = (function () {
|
|||||||
phone: data.client_phone || "",
|
phone: data.client_phone || "",
|
||||||
address: data.address || "",
|
address: data.address || "",
|
||||||
};
|
};
|
||||||
// Не показываем форму клиента — она read-only
|
|
||||||
state.notes = state.notes || "";
|
|
||||||
render();
|
render();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
root.innerHTML = "";
|
root.innerHTML = "";
|
||||||
@ -115,17 +138,20 @@ const Measurements = (function () {
|
|||||||
<header class="podbor-header">
|
<header class="podbor-header">
|
||||||
<button class="podbor-back" aria-label="Назад">${ICONS.arrow_left || "‹"}</button>
|
<button class="podbor-back" aria-label="Назад">${ICONS.arrow_left || "‹"}</button>
|
||||||
<div class="podbor-title">${escHtml(title)}</div>
|
<div class="podbor-title">${escHtml(title)}</div>
|
||||||
<div style="width:28px"></div>
|
<button class="podbor-help" id="openChecklist" aria-label="Чек-лист">📋</button>
|
||||||
</header>
|
</header>
|
||||||
`);
|
`);
|
||||||
h.querySelector(".podbor-back").addEventListener("click", () => {
|
h.querySelector(".podbor-back").addEventListener("click", () => {
|
||||||
location.hash = "";
|
location.hash = "";
|
||||||
location.reload();
|
location.reload();
|
||||||
});
|
});
|
||||||
|
h.querySelector("#openChecklist").addEventListener("click", () => {
|
||||||
|
location.hash = "#/measure/checklist";
|
||||||
|
});
|
||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===================== Главный экран — всё на одной странице ===================== */
|
/* ===================== Главный экран ===================== */
|
||||||
|
|
||||||
function renderForm() {
|
function renderForm() {
|
||||||
const isUpdate = !!measurementId && prefilledClient;
|
const isUpdate = !!measurementId && prefilledClient;
|
||||||
@ -135,27 +161,50 @@ const Measurements = (function () {
|
|||||||
<section class="podbor-step">
|
<section class="podbor-step">
|
||||||
<h2 class="display-title">${isUpdate ? "Фото<br><span class=\"accent\">с замера</span>" : "Новый<br><span class=\"accent\">замер</span>"}</h2>
|
<h2 class="display-title">${isUpdate ? "Фото<br><span class=\"accent\">с замера</span>" : "Новый<br><span class=\"accent\">замер</span>"}</h2>
|
||||||
<p class="lede">${isUpdate
|
<p class="lede">${isUpdate
|
||||||
? "Загрузите фото от руки нарисованных замеров (а также общие фото помещения). Чертёж сделаем по ним отдельно."
|
? "Загружайте фото по чек-листу — каждая стена отдельно. Чертёж сделаем по фото."
|
||||||
: "Заполните данные клиента и загрузите фото замера. Можно фотать рукописные эскизы — главное чтобы были видны размеры."}
|
: "Заполните клиента, дату и загрузите фото по чек-листу. Откройте 📋 чтобы посмотреть как правильно снимать."}</p>
|
||||||
</p>
|
|
||||||
|
|
||||||
<div id="clientBlock"></div>
|
<div id="clientBlock"></div>
|
||||||
|
|
||||||
<div class="section-head" style="margin-top:18px;"><span class="label">📷 Фото замера</span></div>
|
<div class="section-head" style="margin-top:18px;"><span class="label">📐 Общая информация</span></div>
|
||||||
|
<div class="form-row two-col">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">№ замера</span>
|
||||||
|
<input type="text" data-bind="zamer_no" value="${escAttr(state.zamer_no)}" placeholder="например 157">
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Дата замера</span>
|
||||||
|
<input type="date" data-bind="zamer_date" value="${escAttr(state.zamer_date)}">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Стяжка / нулевой пол</span>
|
||||||
|
<input type="text" data-bind="floor_base" value="${escAttr(state.floor_base)}" placeholder="0,000 = +88 мм над плитой">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-head" style="margin-top:18px;">
|
||||||
|
<span class="label">📷 Фото замера</span>
|
||||||
|
<a class="more" id="openChecklist2" style="cursor:pointer;">Чек-лист</a>
|
||||||
|
</div>
|
||||||
|
<p class="muted" style="font-size:12px;margin:-4px 0 8px;">
|
||||||
|
Для каждого фото выберите тип. По чек-листу: каждая стена отдельно + план + общие виды.
|
||||||
|
</p>
|
||||||
<div class="photo-uploader">
|
<div class="photo-uploader">
|
||||||
<label class="photo-add-btn" for="photoInput">
|
<label class="photo-add-btn" for="photoInput">
|
||||||
<span class="photo-add-ico">+</span>
|
<span class="photo-add-ico">+</span>
|
||||||
<span class="photo-add-label">Добавить фото</span>
|
<span class="photo-add-label">Добавить фото</span>
|
||||||
<span class="photo-add-hint">камера или галерея · до 20 шт</span>
|
<span class="photo-add-hint">камера или галерея · до 30 шт</span>
|
||||||
</label>
|
</label>
|
||||||
<input id="photoInput" type="file" accept="image/*" capture="environment" multiple hidden>
|
<input id="photoInput" type="file" accept="image/*" capture="environment" multiple hidden>
|
||||||
</div>
|
</div>
|
||||||
<div class="photo-list" id="photoList"></div>
|
<div class="photo-list-tagged" id="photoList"></div>
|
||||||
|
|
||||||
<div class="form-row" style="margin-top:18px;">
|
<div class="form-row" style="margin-top:18px;">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field-label">Заметки (опционально)</span>
|
<span class="field-label">Заметки (опционально)</span>
|
||||||
<textarea data-bind="notes" rows="3" placeholder="что важно отметить — газ/электро, особые условия, размеры которые сложно прочесть на фото">${escHtml(state.notes || "")}</textarea>
|
<textarea data-bind="notes" rows="3" placeholder="особенности доступа, газ/электро, что важно учесть">${escHtml(state.notes || "")}</textarea>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -171,6 +220,9 @@ const Measurements = (function () {
|
|||||||
bindInputs(node);
|
bindInputs(node);
|
||||||
bindPhotoInput(node);
|
bindPhotoInput(node);
|
||||||
|
|
||||||
|
node.querySelector("#openChecklist2").addEventListener("click", () => {
|
||||||
|
location.hash = "#/measure/checklist";
|
||||||
|
});
|
||||||
node.querySelector("#submitBtn").addEventListener("click", () => onSubmit(node));
|
node.querySelector("#submitBtn").addEventListener("click", () => onSubmit(node));
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
@ -186,7 +238,7 @@ const Measurements = (function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderClientInputs() {
|
function renderClientInputs() {
|
||||||
const block = el(`
|
return el(`
|
||||||
<div>
|
<div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
@ -210,7 +262,6 @@ const Measurements = (function () {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`);
|
`);
|
||||||
return block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindInputs(node) {
|
function bindInputs(node) {
|
||||||
@ -222,18 +273,40 @@ 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) {
|
function bindPhotoInput(node) {
|
||||||
const list = node.querySelector("#photoList");
|
const list = node.querySelector("#photoList");
|
||||||
const input = node.querySelector("#photoInput");
|
const input = node.querySelector("#photoInput");
|
||||||
|
|
||||||
function refreshList() {
|
function refreshList() {
|
||||||
list.innerHTML = "";
|
list.innerHTML = "";
|
||||||
|
if (!photos.length) {
|
||||||
|
list.innerHTML = `<div class="empty" style="padding:12px;text-align:center;color:var(--muted);font-size:12px;">Ещё нет фото</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
photos.forEach((ph, idx) => {
|
photos.forEach((ph, idx) => {
|
||||||
const tile = el(`
|
const tile = el(`
|
||||||
<div class="photo-tile">
|
<div class="photo-tagged">
|
||||||
|
<div class="photo-tagged-thumb">
|
||||||
<img src="${ph.dataUrl}" alt="фото ${idx + 1}">
|
<img src="${ph.dataUrl}" alt="фото ${idx + 1}">
|
||||||
<button class="photo-rm" data-idx="${idx}" aria-label="Удалить">×</button>
|
<button class="photo-rm" data-idx="${idx}" aria-label="Удалить">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
<select class="photo-kind" data-idx="${idx}">
|
||||||
|
${PHOTO_KINDS.map(k =>
|
||||||
|
`<option value="${k.key}" ${k.key === ph.kind ? "selected" : ""}>${k.label}</option>`
|
||||||
|
).join("")}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
`);
|
`);
|
||||||
tile.querySelector(".photo-rm").addEventListener("click", e => {
|
tile.querySelector(".photo-rm").addEventListener("click", e => {
|
||||||
const i = +e.currentTarget.dataset.idx;
|
const i = +e.currentTarget.dataset.idx;
|
||||||
@ -241,6 +314,10 @@ const Measurements = (function () {
|
|||||||
haptic && haptic("impact");
|
haptic && haptic("impact");
|
||||||
refreshList();
|
refreshList();
|
||||||
});
|
});
|
||||||
|
tile.querySelector(".photo-kind").addEventListener("change", e => {
|
||||||
|
const i = +e.target.dataset.idx;
|
||||||
|
photos[i].kind = e.target.value;
|
||||||
|
});
|
||||||
list.appendChild(tile);
|
list.appendChild(tile);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -250,11 +327,12 @@ const Measurements = (function () {
|
|||||||
input.value = "";
|
input.value = "";
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
if (photos.length >= 20) break;
|
if (photos.length >= 30) break;
|
||||||
if (!f.type || !f.type.startsWith("image/")) continue;
|
if (!f.type || !f.type.startsWith("image/")) continue;
|
||||||
try {
|
try {
|
||||||
const dataUrl = await compressImage(f, 1800, 0.78);
|
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) {
|
} catch (err) {
|
||||||
console.warn("Не удалось сжать фото", err);
|
console.warn("Не удалось сжать фото", err);
|
||||||
}
|
}
|
||||||
@ -266,7 +344,6 @@ const Measurements = (function () {
|
|||||||
refreshList();
|
refreshList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Сжатие через canvas */
|
|
||||||
function compressImage(file, maxSide = 1800, quality = 0.78) {
|
function compressImage(file, maxSide = 1800, quality = 0.78) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@ -298,6 +375,116 @@ const Measurements = (function () {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===================== Чек-лист — отдельный экран ===================== */
|
||||||
|
|
||||||
|
async function renderChecklist() {
|
||||||
|
root.innerHTML = "";
|
||||||
|
root.appendChild(el(`
|
||||||
|
<header class="podbor-header">
|
||||||
|
<button class="podbor-back" aria-label="Назад">${ICONS.arrow_left || "‹"}</button>
|
||||||
|
<div class="podbor-title">Чек-лист замера</div>
|
||||||
|
<div style="width:28px"></div>
|
||||||
|
</header>
|
||||||
|
`));
|
||||||
|
root.querySelector(".podbor-back").addEventListener("click", () => {
|
||||||
|
// Возврат к мастеру (если был открыт через #/measure?id=X)
|
||||||
|
if (measurementId) location.hash = `#/measure?id=${measurementId}`;
|
||||||
|
else location.hash = "#/measure";
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrap = el(`<section class="podbor-step checklist-page"></section>`);
|
||||||
|
root.appendChild(wrap);
|
||||||
|
wrap.appendChild(el(`<div class="loader-inline"><div class="spinner"></div></div>`));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("./assets/zamer-checklist.md", { cache: "no-cache" });
|
||||||
|
const md = await res.text();
|
||||||
|
wrap.innerHTML = `<div class="checklist-md">${renderMarkdown(md)}</div>`;
|
||||||
|
} catch (e) {
|
||||||
|
wrap.innerHTML = `<div class="error">Не удалось загрузить чек-лист: ${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Минимальный 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("</ul>"); inList = false; } }
|
||||||
|
function closeTable() {
|
||||||
|
if (!inTable) return;
|
||||||
|
if (tableRows.length) {
|
||||||
|
const html = ["<table class='cl-table'>"];
|
||||||
|
tableRows.forEach((cells, i) => {
|
||||||
|
const tag = i === 0 ? "th" : "td";
|
||||||
|
if (i === 1 && cells.every(c => /^[-:\s|]+$/.test(c))) return; // skip separator
|
||||||
|
html.push(`<tr>${cells.map(c => `<${tag}>${inline(c)}</${tag}>`).join("")}</tr>`);
|
||||||
|
});
|
||||||
|
html.push("</table>");
|
||||||
|
out.push(html.join(""));
|
||||||
|
}
|
||||||
|
tableRows = [];
|
||||||
|
inTable = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inline(s) {
|
||||||
|
return escHtml(s)
|
||||||
|
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||||
|
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||||
|
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
|
||||||
|
}
|
||||||
|
|
||||||
|
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(`<h1>${inline(line.slice(2))}</h1>`);
|
||||||
|
} else if (line.startsWith("## ")) {
|
||||||
|
closeList();
|
||||||
|
out.push(`<h2>${inline(line.slice(3))}</h2>`);
|
||||||
|
} else if (line.startsWith("### ")) {
|
||||||
|
closeList();
|
||||||
|
out.push(`<h3>${inline(line.slice(4))}</h3>`);
|
||||||
|
} else if (line.startsWith("- ") || line.startsWith("* ")) {
|
||||||
|
if (!inList) { out.push("<ul>"); inList = true; }
|
||||||
|
let content = line.slice(2);
|
||||||
|
// [ ] checkbox
|
||||||
|
if (content.startsWith("[ ] ")) {
|
||||||
|
out.push(`<li><span class="cl-check">☐</span> ${inline(content.slice(4))}</li>`);
|
||||||
|
} else if (content.startsWith("[x] ") || content.startsWith("[X] ")) {
|
||||||
|
out.push(`<li><span class="cl-check checked">☑</span> ${inline(content.slice(4))}</li>`);
|
||||||
|
} else {
|
||||||
|
out.push(`<li>${inline(content)}</li>`);
|
||||||
|
}
|
||||||
|
} else if (line === "---") {
|
||||||
|
closeList();
|
||||||
|
out.push(`<hr>`);
|
||||||
|
} else if (line === "") {
|
||||||
|
closeList();
|
||||||
|
out.push("");
|
||||||
|
} else {
|
||||||
|
closeList();
|
||||||
|
out.push(`<p>${inline(line)}</p>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closeList();
|
||||||
|
closeTable();
|
||||||
|
return out.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
/* ===================== Submit ===================== */
|
/* ===================== Submit ===================== */
|
||||||
|
|
||||||
async function onSubmit(node) {
|
async function onSubmit(node) {
|
||||||
@ -307,7 +494,6 @@ const Measurements = (function () {
|
|||||||
btn.innerHTML = '<span class="spinner-inline"></span> сохраняем...';
|
btn.innerHTML = '<span class="spinner-inline"></span> сохраняем...';
|
||||||
result.innerHTML = "";
|
result.innerHTML = "";
|
||||||
|
|
||||||
// Валидация: для новой записи нужны клиент + телефон + хотя бы 1 фото
|
|
||||||
const isUpdate = !!measurementId && prefilledClient;
|
const isUpdate = !!measurementId && prefilledClient;
|
||||||
if (!isUpdate) {
|
if (!isUpdate) {
|
||||||
const name = (state.client_name || "").trim();
|
const name = (state.client_name || "").trim();
|
||||||
@ -334,8 +520,15 @@ const Measurements = (function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const measurement = {
|
const measurement = {
|
||||||
|
// Структурированные фото + их типы
|
||||||
photos: photos.map(p => p.dataUrl),
|
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 || "",
|
notes: state.notes || "",
|
||||||
|
// Клиент
|
||||||
client_name: isUpdate ? prefilledClient.name : state.client_name,
|
client_name: isUpdate ? prefilledClient.name : state.client_name,
|
||||||
client_phone: isUpdate ? prefilledClient.phone : state.client_phone,
|
client_phone: isUpdate ? prefilledClient.phone : state.client_phone,
|
||||||
address: isUpdate ? prefilledClient.address : state.address,
|
address: isUpdate ? prefilledClient.address : state.address,
|
||||||
@ -392,5 +585,5 @@ const Measurements = (function () {
|
|||||||
}
|
}
|
||||||
function escAttr(s) { return escHtml(s); }
|
function escAttr(s) { return escHtml(s); }
|
||||||
|
|
||||||
return { mount, reset };
|
return { mount, reset, kindLabel, PHOTO_KINDS };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -1989,6 +1989,94 @@
|
|||||||
overflow-x: auto;
|
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 {
|
.staff-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
145
miniapp/assets/zamer-checklist.md
Normal file
145
miniapp/assets/zamer-checklist.md
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
# Чек-лист замера квартиры
|
||||||
|
|
||||||
|
Применяется ко всем фото-замерам. Цель — чтобы по фото без уточнений собирался корректный CAD/DXF и менеджер всё понял с первого взгляда.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Перед началом
|
||||||
|
|
||||||
|
- [ ] Завести папку `ЗАМЕРЫ/<номер> <адрес> - <кв>/` (как 157, 158…).
|
||||||
|
- [ ] Снимать каждую стену **отдельно**, кадр должен охватывать стену целиком, от пола до потолка.
|
||||||
|
- [ ] Имя файла фото = номер стены: `<N>_w1.jpg`, `<N>_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 (общий физический угол).
|
||||||
@ -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&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&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=20260513k">
|
<link rel="stylesheet" href="assets/styles.css?v=20260513l">
|
||||||
<link rel="stylesheet" href="assets/podbor.css?v=20260513k">
|
<link rel="stylesheet" href="assets/podbor.css?v=20260513l">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Splash — за пределами #app, render-функции его не смывают -->
|
<!-- Splash — за пределами #app, render-функции его не смывают -->
|
||||||
@ -34,13 +34,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<main id="app"></main>
|
<main id="app"></main>
|
||||||
<script src="assets/icons.js?v=20260513k"></script>
|
<script src="assets/icons.js?v=20260513l"></script>
|
||||||
<script src="assets/podbor.config.js?v=20260513k"></script>
|
<script src="assets/podbor.config.js?v=20260513l"></script>
|
||||||
<script src="assets/podbor.picts.js?v=20260513k"></script>
|
<script src="assets/podbor.picts.js?v=20260513l"></script>
|
||||||
<script src="assets/podbor.js?v=20260513k"></script>
|
<script src="assets/podbor.js?v=20260513l"></script>
|
||||||
<script src="assets/clients.js?v=20260513k"></script>
|
<script src="assets/clients.js?v=20260513l"></script>
|
||||||
<script src="assets/measurements.js?v=20260513k"></script>
|
<script src="assets/measurements.js?v=20260513l"></script>
|
||||||
<script src="assets/request.js?v=20260513k"></script>
|
<script src="assets/request.js?v=20260513l"></script>
|
||||||
<script src="assets/app.js?v=20260513k"></script>
|
<script src="assets/app.js?v=20260513l"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user