From 4612c3a4e4f76b665d5048c2867f71f7fae73e27 Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Thu, 14 May 2026 11:42:27 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D0=B0=20?= =?UTF-8?q?=E2=80=94=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5,=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5,=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BF=D0=BE=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=D0=B0=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1: «Завожу клиента и не вижу данных» Причина: 3 эндпоинта проверяли user.get('role') == 'manager' напрямую, вместо sheets.has_role(user, 'manager'). У админа теперь multi-role (manager,measurer,assembler) → проверка падала с only_manager, /api/measurements возвращало ошибку → таймлайн/файлы пустые. Backend fixes: - _handle_measurements_list: has_role + initDataUnsafe fallback + фильтр archived_at + возвращает client_name, client_phone, address, scheduled_at, client_no, contract_no, contract_date, assigned_to_tg_id - _handle_measurement_request: has_role вместо == - _handle_measurement_detail: has_role + поддержка assigned_to_tg_id для мастера - _handle_clients: возвращает address, contract_date, measurements_count, in_work in_work=True если: есть лиды ∨ есть не-draft замер ∨ есть сборка Bug 2: «Не могу удалить клиента» Причина: была спрятана в expand «Опасная зона» внизу страницы. Новая логика прав (по запросу): - Клиент не в работе → ✏️ Редактировать + 🗑 Удалить - Клиент в работе → только ✏️ Редактировать - Бэкенд тоже enforce: client_delete отвечает {error: 'in_work'} если есть лиды/сборки/не-draft замеры Новые эндпоинты: - POST /api/client_update — обновляет имя/телефон/адрес/договор во всех Measurements этого клиента. Возвращает обновлённый client_key если имя изменилось Frontend: - Секция «⚙️ Управление карточкой» вместо «Опасной зоны» - Кнопка ✏️ Редактировать всегда видна, 🗑 Удалить только если !in_work - renderEditClient — форма редактирования (имя, тел, адрес, договор № + дата) - В шапке карточки теперь видны адрес и (если не в работе) бейдж «ещё не в работе» - Draft-карточки скрыты из таймлайна (это техническая строка, не событие) index.html: cache bump v=20260514d --- backend-py/app/main.py | 226 +++++++++++++++++++++++++++++++++++--- miniapp/assets/clients.js | 183 +++++++++++++++++++++++++++--- miniapp/index.html | 26 ++--- 3 files changed, 390 insertions(+), 45 deletions(-) diff --git a/backend-py/app/main.py b/backend-py/app/main.py index 7c3f9fc..36cba41 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -116,6 +116,7 @@ async def _dispatch_post(request: Request): "geocode": _handle_geocode, "client_note": _handle_client_note, "client_create": _handle_client_create, + "client_update": _handle_client_update, "client_delete": _handle_client_delete, "measurement_design_upload": _handle_measurement_design_upload, "measurement_decision": _handle_measurement_decision, @@ -249,6 +250,12 @@ async def api_client_delete(request: Request): return _handle_client_delete(body) +@app.post("/api/client_update") +async def api_client_update(request: Request): + body = await _safe_json(request) + return _handle_client_update(body) + + @app.post("/api/measurement_design_upload") async def api_measurement_design_upload(request: Request): body = await _safe_json(request) @@ -942,7 +949,7 @@ def _handle_podbor(body: dict[str, Any]) -> dict[str, Any]: user = sheets.find_user(tg_id) if not user: return {"error": "user_not_found"} - if user.get("role") != "manager": + if not sheets.has_role(user, "manager"): return {"error": "only_manager_can_request_podbor"} checklist = body.get("checklist") or {} @@ -1061,12 +1068,17 @@ def _handle_clients(body: dict[str, Any]) -> dict[str, Any]: "client_name": name or "Без имени", "client_tg_id": ctg_id or None, "client_phone": phone or "", + "address": "", "client_no": "", "contract_no": "", + "contract_date": "", "leads_count": 0, + "measurements_count": 0, "last_lead_at": "", "last_lead_id": "", "leads": [], + # in_work=True если есть хотя бы один лид или замер не-draft + "in_work": False, } else: # Заполним пустые поля если в этой записи есть данные @@ -1100,6 +1112,7 @@ def _handle_clients(body: dict[str, Any]) -> dict[str, Any]: continue c = _ensure_client(key, client_name, phone, client_tg_id) c["leads_count"] += 1 + c["in_work"] = True # есть подбор — клиент в работе lead_id = row.get("id", "") created_at = row.get("created_at", "") status = row.get("status", "") @@ -1128,12 +1141,22 @@ def _handle_clients(body: dict[str, Any]) -> dict[str, Any]: client_tg_id = (row.get("client_tg_id") or "").strip() client_no = (row.get("client_no") or "").strip() contract_no = (row.get("contract_no") or "").strip() + contract_date = (row.get("contract_date") or "").strip() + address = (row.get("address") or "").strip() + m_status = (row.get("status") or "").strip() key = client_tg_id or client_name.lower() if not key: continue c = _ensure_client(key, client_name, client_phone, client_tg_id) if client_no and not c.get("client_no"): c["client_no"] = client_no if contract_no and not c.get("contract_no"): c["contract_no"] = contract_no + if contract_date and not c.get("contract_date"): c["contract_date"] = contract_date + if address and not c.get("address"): c["address"] = address + if client_phone and not c.get("client_phone"): c["client_phone"] = client_phone + c["measurements_count"] = c.get("measurements_count", 0) + 1 + # Замер не-draft = клиент в работе (requested/scheduled/completed) + if m_status and m_status != "draft": + c["in_work"] = True # Если у клиента нет ни одного лида — last_at берём из measurement.ts ts = row.get("ts") or row.get("created_at") or "" if ts > c["last_lead_at"]: @@ -1141,6 +1164,30 @@ def _handle_clients(body: dict[str, Any]) -> dict[str, Any]: except Exception as e: log.warning("Failed to read Measurements for clients: %s", e) + # 3. Из Assemblies — есть сборка = клиент в работе + try: + ws = sheets.sheet("Assemblies") + rows = ws.get_all_values() + if rows and len(rows) >= 2: + headers = rows[0] + for r in rows[1:]: + row = dict(zip(headers, r + [""] * (len(headers) - len(r)))) + if str(row.get("manager_tg_id", "")) != str(tg_id): + continue + if row.get("archived_at"): + continue + a_name = (row.get("client_name") or "").strip() + a_ctg = (row.get("client_tg_id") or "").strip() + a_phone = (row.get("client_phone") or "").strip() + key = a_ctg or a_name.lower() + if not key: + continue + c = _ensure_client(key, a_name, a_phone, a_ctg) + c["in_work"] = True + except Exception: + # Лист может ещё не существовать — не критично + pass + # Сортируем по дате последней активности (новые сверху) clients = sorted(by_client.values(), key=lambda x: x.get("last_lead_at") or "", reverse=True) for c in clients: @@ -2133,6 +2180,8 @@ def _handle_client_create(body: dict[str, Any]) -> dict[str, Any]: def _handle_client_delete(body: dict[str, Any]) -> dict[str, Any]: """Soft-delete всех записей Measurements по клиенту (для текущего менеджера). + Удаление разрешено ТОЛЬКО если у клиента нет реальной работы: + нет лидов и все его замеры в статусе 'draft' (карточка не использована). body: {initData, client_key} — client_key это name.lower() как в _handle_clients.""" cfg = get_config() auth = verify_init_data(body.get("initData") or "", cfg.bot_token) @@ -2151,6 +2200,40 @@ def _handle_client_delete(body: dict[str, Any]) -> dict[str, Any]: if not client_key: return {"error": "missing_client_key"} + # Проверка: есть ли у клиента работа в Leads + try: + ws_l = sheets.sheet("Leads") + rows_l = ws_l.get_all_values() + if rows_l and len(rows_l) >= 2: + headers_l = rows_l[0] + for r in rows_l[1:]: + row = dict(zip(headers_l, r + [""] * (len(headers_l) - len(r)))) + if str(row.get("manager_tg_id", "")) != str(tg_id): + continue + if (row.get("client_name") or "").strip().lower() != client_key: + continue + return {"error": "in_work", "msg": "У клиента есть подбор — удаление запрещено. Используйте редактирование."} + except Exception: + pass + + # Проверка: есть ли у клиента сборки + try: + ws_a = sheets.sheet("Assemblies") + rows_a = ws_a.get_all_values() + if rows_a and len(rows_a) >= 2: + headers_a = rows_a[0] + for r in rows_a[1:]: + row = dict(zip(headers_a, r + [""] * (len(headers_a) - len(r)))) + if str(row.get("manager_tg_id", "")) != str(tg_id): + continue + if row.get("archived_at"): + continue + if (row.get("client_name") or "").strip().lower() != client_key: + continue + return {"error": "in_work", "msg": "У клиента есть сборка — удаление запрещено. Используйте редактирование."} + except Exception: + pass + try: ws = sheets.sheet("Measurements") rows = ws.get_all_values() @@ -2162,6 +2245,20 @@ def _handle_client_delete(body: dict[str, Any]) -> dict[str, Any]: headers = rows[0] if "archived_at" not in headers or "client_name" not in headers or "manager_tg_id" not in headers: return {"error": "schema_missing"} + + # Проверка: есть ли не-draft замеры? + for r in rows[1:]: + row = dict(zip(headers, r + [""] * (len(headers) - len(r)))) + if str(row.get("manager_tg_id", "")) != str(tg_id): + continue + if (row.get("client_name") or "").strip().lower() != client_key: + continue + if row.get("archived_at"): + continue + status = (row.get("status") or "").strip() + if status and status != "draft": + return {"error": "in_work", "msg": "У клиента есть замер в работе — удаление запрещено. Используйте редактирование."} + archived_idx = headers.index("archived_at") + 1 now = _now_iso() count = 0 @@ -2172,7 +2269,7 @@ def _handle_client_delete(body: dict[str, Any]) -> dict[str, Any]: if (row.get("client_name") or "").strip().lower() != client_key: continue if row.get("archived_at"): - continue # уже архивирован + continue ws.update_cell(i, archived_idx, now) count += 1 @@ -2180,6 +2277,93 @@ def _handle_client_delete(body: dict[str, Any]) -> dict[str, Any]: return {"ok": True, "archived": count} +def _handle_client_update(body: dict[str, Any]) -> dict[str, Any]: + """Менеджер обновляет данные клиента (имя, телефон, адрес, договор). + Обновляет ВСЕ строки Measurements этого менеджера для этого клиента. + body: {initData, client_key, full_name?, phone?, address?, contract_no?, contract_date?}""" + 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"]} + else: + return {"error": "invalid_init_data"} + tg_id = auth["user"]["id"] + user = sheets.find_user(tg_id) + if not user or not sheets.has_role(user, "manager"): + return {"error": "only_manager"} + + client_key = (body.get("client_key") or "").strip().lower() + if not client_key: + return {"error": "missing_client_key"} + + new_name = (body.get("full_name") or "").strip() + new_phone_raw = (body.get("phone") or "").strip() + new_address = body.get("address") + new_contract_no = body.get("contract_no") + new_contract_date = body.get("contract_date") + + if new_name and len(new_name) < 2: + return {"error": "bad_name", "msg": "Имя слишком короткое"} + + new_phone = "" + if new_phone_raw: + norm, ok = _normalize_phone(new_phone_raw) + if not ok: + return {"error": "bad_phone", "msg": "Телефон в формате +7XXXXXXXXXX"} + new_phone = norm + + if isinstance(new_address, str) and new_address.strip() and len(new_address.strip()) < 5: + return {"error": "bad_address", "msg": "Адрес слишком короткий"} + + try: + ws = sheets.sheet("Measurements") + rows = ws.get_all_values() + except Exception as e: + return {"error": f"sheets: {e}"} + if not rows or len(rows) < 2: + return {"ok": True, "updated": 0} + + headers = rows[0] + if "client_name" not in headers or "manager_tg_id" not in headers: + return {"error": "schema_missing"} + + def col_idx(name: str) -> int | None: + return headers.index(name) + 1 if name in headers else None + + name_col = col_idx("client_name") + phone_col = col_idx("client_phone") + address_col = col_idx("address") + contract_no_col = col_idx("contract_no") + contract_date_col = col_idx("contract_date") + + updated = 0 + for i, r in enumerate(rows[1:], start=2): + row = dict(zip(headers, r + [""] * (len(headers) - len(r)))) + if str(row.get("manager_tg_id", "")) != str(tg_id): + continue + if (row.get("client_name") or "").strip().lower() != client_key: + continue + if row.get("archived_at"): + continue + if new_name and name_col: + ws.update_cell(i, name_col, new_name) + if new_phone and phone_col: + ws.update_cell(i, phone_col, new_phone) + if isinstance(new_address, str) and address_col: + ws.update_cell(i, address_col, new_address.strip()) + if isinstance(new_contract_no, str) and contract_no_col: + ws.update_cell(i, contract_no_col, new_contract_no.strip()) + if isinstance(new_contract_date, str) and contract_date_col: + ws.update_cell(i, contract_date_col, new_contract_date.strip()) + updated += 1 + + sheets.log_event("client_updated", tg_id, {"client_key": client_key, "updated": updated}) + new_key = new_name.lower() if new_name else client_key + return {"ok": True, "updated": updated, "client_key": new_key} + + def _normalize_client_key(name: str, phone: str) -> str: """Стабильный ключ клиента: телефон в цифрах либо имя в lower.""" digits = "".join(c for c in (phone or "") if c.isdigit()) @@ -2399,13 +2583,12 @@ def _handle_measurement_detail(body: dict[str, Any]) -> dict[str, Any]: if not row: return {"error": "measurement_not_found"} - # Только владелец-менеджер или клиент-владелец видит замер - if user.get("role") == "manager": - if str(row.get("manager_tg_id", "")) != str(tg_id): - return {"error": "forbidden"} - else: - if str(row.get("client_tg_id", "")) != str(tg_id): - return {"error": "forbidden"} + # Доступ: владелец-менеджер, назначенный мастер (замер/сборка), клиент-владелец + is_owner_manager = sheets.has_role(user, "manager") and str(row.get("manager_tg_id", "")) == str(tg_id) + is_assigned_master = sheets.is_master(user) and str(row.get("assigned_to_tg_id", "")) == str(tg_id) + is_client = str(row.get("client_tg_id", "")) == str(tg_id) + if not (is_owner_manager or is_assigned_master or is_client): + return {"error": "forbidden"} def _safe_json(s: str) -> Any: try: @@ -2477,10 +2660,14 @@ def _handle_measurements_list(body: dict[str, Any]) -> dict[str, Any]: cfg = get_config() auth = verify_init_data(body.get("initData") or "", cfg.bot_token) if not auth or not auth.get("user"): - return {"error": "invalid_init_data"} + unsafe = body.get("initDataUnsafe") or {} + if isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"): + auth = {"user": unsafe["user"]} + else: + return {"error": "invalid_init_data"} tg_id = auth["user"]["id"] user = sheets.find_user(tg_id) - if not user or user.get("role") != "manager": + if not user or not sheets.has_role(user, "manager"): return {"error": "only_manager"} client_tg_id = body.get("client_tg_id") or "" @@ -2502,12 +2689,14 @@ def _handle_measurements_list(body: dict[str, Any]) -> dict[str, Any]: row = dict(zip(headers, r + [""] * (len(headers) - len(r)))) if str(row.get("manager_tg_id", "")) != str(tg_id): continue + # Скрываем soft-deleted + if row.get("archived_at"): + continue # Опциональные фильтры по клиенту if client_tg_id and str(row.get("client_tg_id", "")) != str(client_tg_id): continue - # Из notes / других JSON-полей вытащим client_name если был передан в measurement - # (он не сохраняется в отдельной колонке — только в JSON-обвязке) - # Для MVP — фильтр по имени делаем после парсинга JSON-полей + if client_name and (row.get("client_name") or "").strip().lower() != client_name: + continue photo_files = [p for p in (row.get("photos") or "").split(",") if p] out.append({ "id": row.get("id", ""), @@ -2522,6 +2711,15 @@ def _handle_measurements_list(body: dict[str, Any]) -> dict[str, Any]: "status": row.get("status", ""), "photos": photo_files, "photo_count": len(photo_files), + # Ключевые поля для рендера карточки клиента и таймлайна + "client_name": row.get("client_name", ""), + "client_phone": row.get("client_phone", ""), + "address": row.get("address", ""), + "scheduled_at": row.get("scheduled_at", ""), + "client_no": row.get("client_no", ""), + "contract_no": row.get("contract_no", ""), + "contract_date": row.get("contract_date", ""), + "assigned_to_tg_id": row.get("assigned_to_tg_id", ""), }) # Сортируем по дате desc diff --git a/miniapp/assets/clients.js b/miniapp/assets/clients.js index 1054f43..fdb21c6 100644 --- a/miniapp/assets/clients.js +++ b/miniapp/assets/clients.js @@ -394,15 +394,23 @@ const Clients = (function () { ? `#${escHtml(client.client_no)}` : ""; const contractTag = client.contract_no - ? `
📋 договор ${escHtml(client.contract_no)}
` + ? `
📋 договор ${escHtml(client.contract_no)}${client.contract_date ? ` · ${escHtml(client.contract_date)}` : ""}
` : ""; + const addressTag = client.address + ? `
📍 ${escHtml(client.address)}
` + : ""; + const statusTag = client.in_work + ? "" + : `
● ещё не в работе
`; root.appendChild(el(`
${initial(client.client_name)}

${escHtml(client.client_name)} ${noTag}

${client.client_phone ? `
${escHtml(client.client_phone)}
` : ""} + ${addressTag} ${contractTag} + ${statusTag}
${callHref ? `📞` : ""}
@@ -474,25 +482,40 @@ const Clients = (function () { // Детальные списки внизу (свёрнуты) detailsPlaceholder.replaceWith(renderClientDetails(client, myMeasurements)); - // Опасная зона — удалить клиента (soft-delete всех его записей) - const deleteZone = el(` -
- ⚠️ Опасная зона -
-

- При удалении клиент будет архивирован вместе со всеми его заявками, - замерами и подборами. Из списка он исчезнет. -

- -
+ // Управление карточкой клиента — редактировать + (условно) удалить + root.appendChild(renderClientManagement(client)); + } + + /* ===================== Управление карточкой (edit / delete) ===================== */ + + function renderClientManagement(client) { + const inWork = !!client.in_work; + const wrap = el(` +
+
⚙️ Управление карточкой
+
+ ${inWork + ? "Клиент в работе. Удалить нельзя, можно только отредактировать данные." + : "Клиент ещё не передан в работу — можно изменить данные или удалить карточку."}
-
+
+ + ${inWork ? "" : ``} +
+
+ `); - deleteZone.querySelector("#deleteClient").addEventListener("click", async () => { + + wrap.querySelector("#editClient")?.addEventListener("click", () => { + haptic && haptic("impact"); + renderEditClient(client); + }); + + wrap.querySelector("#deleteClient")?.addEventListener("click", async () => { const confirmed = await confirmDialog(`Удалить клиента ${client.client_name}? Это нельзя отменить из бота.`); if (!confirmed) return; - const btn = deleteZone.querySelector("#deleteClient"); - const result = deleteZone.querySelector("#deleteResult"); + const btn = wrap.querySelector("#deleteClient"); + const result = wrap.querySelector("#manageResult"); btn.disabled = true; btn.textContent = "Удаляем..."; try { const res = await fetch(`${BACKEND_URL}/api/client_delete`, { @@ -505,7 +528,8 @@ const Clients = (function () { }); const data = await res.json(); if (data.error) { - result.innerHTML = `Ошибка: ${escHtml(data.error)}`; + const msg = data.msg || data.error; + result.innerHTML = `${escHtml(msg)}`; btn.disabled = false; btn.textContent = "🗑 Удалить клиента"; return; } @@ -518,7 +542,127 @@ const Clients = (function () { btn.disabled = false; btn.textContent = "🗑 Удалить клиента"; } }); - root.appendChild(deleteZone); + + return wrap; + } + + /* ===================== Форма редактирования клиента ===================== */ + + function renderEditClient(client) { + root.innerHTML = ""; + root.appendChild(headerEl("Редактировать клиента", "#/clients")); + + const form = el(` +
+

Редактируем
клиента

+

Изменения применятся ко всем заявкам и замерам этого клиента.

+ +
+ +
+
+ +
+
+ +
+
+ + +
+ +
+ + +
+
+
+ `); + root.appendChild(form); + + form.querySelector("#ed_cancel").addEventListener("click", () => { + const key = client.client_tg_id || (client.client_name || "").toLowerCase(); + location.hash = `#/clients/client/${encodeURIComponent(key)}`; + }); + + form.querySelector("#ed_save").addEventListener("click", async () => { + const fn = form.querySelector("#ed_fn").value.trim(); + const ph = form.querySelector("#ed_ph").value.trim(); + const addr = form.querySelector("#ed_addr").value.trim(); + const cno = form.querySelector("#ed_cno").value.trim(); + const cdate = form.querySelector("#ed_cdate").value.trim(); + const errName = form.querySelector("#ed_errName"); + const errPhone = form.querySelector("#ed_errPhone"); + const result = form.querySelector("#ed_result"); + errName.textContent = ""; errPhone.textContent = ""; result.innerHTML = ""; + + if (!fn || fn.length < 2) { + errName.textContent = "Имя слишком короткое"; + return; + } + const norm = normalizePhone(ph); + if (!norm.ok) { + errPhone.textContent = "Телефон в формате +7XXXXXXXXXX"; + return; + } + if (addr && addr.length < 5) { + result.innerHTML = `Адрес слишком короткий`; + return; + } + + const btn = form.querySelector("#ed_save"); + btn.disabled = true; btn.textContent = "Сохраняем..."; + + try { + const res = await fetch(`${BACKEND_URL}/api/client_update`, { + method: "POST", + body: JSON.stringify({ + initData: tg?.initData || "", + initDataUnsafe: tg?.initDataUnsafe || null, + client_key: (client.client_name || "").toLowerCase(), + full_name: fn, + phone: norm.value, + address: addr, + contract_no: cno, + contract_date: cdate, + }), + }); + const data = await res.json(); + if (data.error) { + result.innerHTML = `${escHtml(data.msg || data.error)}`; + btn.disabled = false; btn.textContent = "Сохранить"; + return; + } + haptic && haptic("success"); + clientsCache = null; + const newKey = data.client_key || fn.toLowerCase(); + result.innerHTML = `✓ обновлено ${data.updated} запис(ей). Открываем карточку...`; + setTimeout(() => { + location.hash = `#/clients/client/${encodeURIComponent(newKey)}`; + window.location.reload(); + }, 800); + } catch (e) { + result.innerHTML = `Сеть: ${escHtml(e.message)}`; + btn.disabled = false; btn.textContent = "Сохранить"; + } + }); } function confirmDialog(msg) { @@ -549,6 +693,9 @@ const Clients = (function () { for (const m of measurements) { const photoCount = m.photo_count || (m.photos || []).length; + // Скрываем draft-карточки из таймлайна — это пустая «техническая» строка, + // которая создаётся при заведении клиента. В таймлайн попадают только реальные события. + if (m.status === "draft") continue; // Создание заявки / замера events.push({ ts: m.created_at, diff --git a/miniapp/index.html b/miniapp/index.html index 14ed76c..64385fe 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -12,14 +12,14 @@ - - + +
- +
- - - - - - - - - - + + + + + + + + + +