diff --git a/backend-py/app/ai.py b/backend-py/app/ai.py index 2b0b180..6e872e1 100644 --- a/backend-py/app/ai.py +++ b/backend-py/app/ai.py @@ -262,3 +262,86 @@ def call_ai(user_prompt: str, system_prompt: str | None = None, pass return {"json": json_obj, "text": response_text, "tokens": tokens, "model": actual_model} + + +_FILES_URL = "https://gigachat.devices.sberbank.ru/api/v1/files" +_VISION_MODEL = "GigaChat-Pro" + + +def parse_receipt_amount(image_b64: str) -> dict[str, Any]: + """Парсит фото чека через GigaChat Vision. + Возвращает {"amount": float|None, "raw": str, "error": bool}.""" + import base64, io, re as _re + try: + token = _get_token() + except Exception as e: + return {"amount": None, "raw": "", "error": True, "msg": str(e)} + + # Декодируем data URL + m = re.match(r"^data:image/(jpeg|jpg|png|webp);base64,(.+)$", image_b64.strip(), re.DOTALL) + if not m: + return {"amount": None, "raw": "", "error": True, "msg": "bad_image_format"} + ext = "jpg" if m.group(1) in ("jpeg", "jpg") else m.group(1) + mime = f"image/{m.group(1)}" + raw_bytes = base64.b64decode(m.group(2), validate=False) + + # 1. Загружаем файл в GigaChat Files API + file_id: str | None = None + try: + with httpx.Client(timeout=30.0) as client: + resp = client.post( + _FILES_URL, + headers={"Authorization": f"Bearer {token}"}, + files={"file": (f"receipt.{ext}", io.BytesIO(raw_bytes), mime)}, + data={"purpose": "general"}, + ) + if resp.status_code < 400: + file_id = resp.json().get("id") + except Exception as e: + return {"amount": None, "raw": "", "error": True, "msg": f"file_upload: {e}"} + + if not file_id: + return {"amount": None, "raw": "", "error": True, "msg": "no_file_id"} + + # 2. Спрашиваем итоговую сумму + payload = { + "model": _VISION_MODEL, + "temperature": 0.1, + "max_tokens": 256, + "messages": [{ + "role": "user", + "content": [ + {"type": "text", + "text": "На этом фото кассовый чек. Найди итоговую сумму (ИТОГ, ИТОГО, СУММА, TOTAL). " + "Ответь ТОЛЬКО числом в рублях без пробелов и без знака ₽ и без копеек, например: 1250. " + "Если сумму найти не удалось — напиши 0."}, + {"type": "image_url", "image_url": {"url": f"gigachat://files/{file_id}"}}, + ], + }], + } + try: + with httpx.Client(timeout=45.0) as client: + resp = client.post( + _CHAT_URL, + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + content=json.dumps(payload, ensure_ascii=False).encode("utf-8"), + ) + except Exception as e: + return {"amount": None, "raw": "", "error": True, "msg": f"vision_call: {e}"} + + if resp.status_code >= 400: + return {"amount": None, "raw": resp.text[:200], "error": True, "msg": f"vision_http_{resp.status_code}"} + + raw_text = ((resp.json().get("choices") or [{}])[0].get("message") or {}).get("content", "").strip() + # Извлекаем число из ответа + nums = re.findall(r"\d[\d\s]*(?:[.,]\d{1,2})?", raw_text) + amount: float | None = None + for n in nums: + try: + v = float(n.replace(" ", "").replace(",", ".")) + if v > 0: + amount = v + break + except ValueError: + pass + return {"amount": amount, "raw": raw_text, "error": False} diff --git a/backend-py/app/main.py b/backend-py/app/main.py index 5b7074c..02c1d07 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -159,6 +159,32 @@ async def _dispatch_post(request: Request): "measurement_schedule": _handle_measurement_schedule, "contract_preview": _handle_contract_preview, "contract_save": _handle_contract_save, + "invoice_create": _handle_invoice_create, + "equipment_save": _handle_equipment_save, + "measurer_earnings": _handle_measurer_earnings, + "assembler_client_podbor": _handle_assembler_client_podbor, + "act4_preview": _handle_act4_preview, + "act4_save": _handle_act4_save, + "assembly_set_status": _handle_assembly_set_status, + "assembly_set_expeditor": _handle_assembly_set_expeditor, + "assembly_photo_upload": _handle_assembly_photo_upload, + "assembler_set_probation": _handle_assembler_set_probation, + "assembly_notes_save": _handle_assembly_notes_save, + "assembly_invoice_create": _handle_assembly_invoice_create, + "assembly_extras_list": _handle_assembly_extras_list, + "assembly_extra_add": _handle_assembly_extra_add, + "assembly_extra_delete": _handle_assembly_extra_delete, + "assembly_extra_approve": _handle_assembly_extra_approve, + "assembly_receipt_parse": _handle_assembly_receipt_parse, + "staff_roster": _handle_staff_roster, + "client_order_timeline": _handle_client_order_timeline, + "manager_finance_summary": _handle_manager_finance_summary, + "feedback_submit": _handle_feedback_submit, + "feedback_my": _handle_feedback_my, + "assembly_suggest_slots": _handle_assembly_suggest_slots, + "assembly_propose_date": _handle_assembly_propose_date, + "assembly_date_confirm": _handle_assembly_date_confirm, + "assembly_date_decline": _handle_assembly_date_decline, "proposal_brief": proposals_mod.handle_brief, "proposal_create": proposals_mod.handle_create, "proposal_upsert_variant": proposals_mod.handle_upsert_variant, @@ -802,9 +828,10 @@ def _handle_me(body: dict[str, Any]) -> dict[str, Any]: # Staff (замерщик / сборщик) — отдельный кабинет, доступен только тем у кого роль выдана if explicit_role == "staff": - has_measurer = "measurer" in roles + has_measurer = "measurer" in roles has_assembler = "assembler" in roles - if not (has_measurer or has_assembler): + has_expeditor = "expeditor" in roles + if not (has_measurer or has_assembler or has_expeditor): return { "role": "staff", "roles": roles, @@ -816,6 +843,10 @@ def _handle_me(body: dict[str, Any]) -> dict[str, Any]: }, } full_name = user.get("full_name", "") or tg_user.get("first_name", "") + # Оборудование замерщика + equipment_raw = user.get("equipment", "") + equipment_list = [x.strip() for x in equipment_raw.split(",") if x.strip()] if equipment_raw else [] + equipment_ok = _equipment_complete(equipment_list) if has_measurer else True return { "role": "staff", "roles": roles, @@ -825,9 +856,12 @@ def _handle_me(body: dict[str, Any]) -> dict[str, Any]: "avatar_initial": _initial(full_name), }, "capabilities": { - "measurer": has_measurer, + "measurer": has_measurer, "assembler": has_assembler, + "expeditor": has_expeditor, }, + "equipment": equipment_list, + "equipment_ok": equipment_ok, } if "manager" in roles: @@ -874,8 +908,2354 @@ def _handle_me(body: dict[str, Any]) -> dict[str, Any]: } +# Обязательный набор оборудования замерщика (ключи) +EQUIPMENT_REQUIRED = {"tablet", "laser_tape", "angle_meter", "tape", "laser_level"} + + +def _equipment_complete(equipment_list: list[str]) -> bool: + return EQUIPMENT_REQUIRED.issubset(set(equipment_list)) + + +def _handle_equipment_save(body: dict[str, Any]) -> dict[str, Any]: + """Замерщик сохраняет свой набор оборудования. + body: {initData, equipment: ["tablet","laser_tape",...]}""" + 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: + return {"error": "user_not_found"} + if not sheets.has_role(user, "measurer"): + return {"error": "only_measurer"} + + raw = body.get("equipment") or [] + if not isinstance(raw, list): + return {"error": "invalid_equipment"} + # Принимаем только известные ключи + valid_keys = {"tablet", "laser_tape", "angle_meter", "tape", "laser_level"} + clean = [k for k in raw if k in valid_keys] + equipment_str = ",".join(clean) + + # Убедимся что колонка equipment есть в Users + try: + ws = sheets.sheet("Users") + headers = ws.row_values(1) + if "equipment" not in headers: + ws.update_cell(1, len(headers) + 1, "equipment") + except Exception as e: + log.warning("equipment col ensure error: %s", e) + + sheets.update_cell_by_key("Users", "tg_id", tg_id, "equipment", equipment_str) + equipment_ok = _equipment_complete(clean) + return {"ok": True, "equipment": clean, "equipment_ok": equipment_ok} + + +@app.post("/api/equipment_save") +async def api_equipment_save(request: Request): + body = await _safe_json(request) + return _handle_equipment_save(body) + + +# ================================================================= +# Заработки замерщика — по листу Measurements +# ================================================================= + +def _handle_measurer_earnings(body: dict[str, Any]) -> dict[str, Any]: + """Личная статистика замерщика: количество замеров и сумма по месяцам. + body: {initData, year?}""" + 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: + return {"error": "user_not_found"} + if not (sheets.has_role(user, "measurer") or sheets.has_role(user, "manager")): + return {"error": "forbidden"} + + year_filter = str(body.get("year") or "").strip() + + _ensure_measurements_sheet() + try: + ws = sheets.sheet("Measurements") + rows = ws.get_all_values() + except Exception as e: + return {"error": f"sheet_error: {e}"} + + if not rows or len(rows) < 2: + return {"ok": True, "months": {}, "total_amount": 0, "total_measurements": 0} + + headers = rows[0] + months: dict[str, dict] = {} + + for r in rows[1:]: + row = dict(zip(headers, r + [""] * (len(headers) - len(r)))) + if str(row.get("assigned_to_tg_id", "")) != str(tg_id): + continue + if row.get("archived_at"): + continue + + # Дата — из scheduled_at или zamer_date или ts + date_str = row.get("scheduled_at") or row.get("zamer_date") or row.get("ts") or "" + if not date_str: + continue + try: + ym = date_str[:7] # "2026-05" + if year_filter and not ym.startswith(year_filter): + continue + except Exception: + continue + + fee_raw = row.get("measurement_fee", "") + try: + fee = float(fee_raw) if fee_raw else 0.0 + except (ValueError, TypeError): + fee = 0.0 + + status = row.get("status", "") + if ym not in months: + months[ym] = {"total_amount": 0.0, "measurements": 0, "paid": 0} + months[ym]["measurements"] += 1 + months[ym]["total_amount"] += fee + if fee > 0: + months[ym]["paid"] += 1 + + total_amount = sum(m["total_amount"] for m in months.values()) + total_meas = sum(m["measurements"] for m in months.values()) + months_sorted = dict(sorted(months.items(), reverse=True)) + + return { + "ok": True, + "months": months_sorted, + "total_amount": total_amount, + "total_measurements": total_meas, + } + + +@app.post("/api/measurer_earnings") +async def api_measurer_earnings(request: Request): + body = await _safe_json(request) + return _handle_measurer_earnings(body) + + +# ================================================================= +# Подбор техники для сборщика — по клиенту из замера +# ================================================================= + +def _handle_assembler_client_podbor(body: dict[str, Any]) -> dict[str, Any]: + """Возвращает сводку выбранной техники для сборщика/замерщика. + body: {initData, measurement_id} + Доступно: назначенный замерщик/сборщик, менеджер.""" + 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: + return {"error": "user_not_found"} + + is_staff = sheets.has_role(user, "assembler") or sheets.has_role(user, "measurer") + is_manager = sheets.has_role(user, "manager") + if not (is_staff or is_manager): + return {"error": "forbidden"} + + measurement_id = (body.get("measurement_id") or "").strip() + if not measurement_id: + return {"error": "missing_measurement_id"} + + _ensure_measurements_sheet() + mrow = sheets.find_row("Measurements", "id", measurement_id) + if not mrow: + return {"error": "measurement_not_found"} + + # Проверка доступа: только назначенный или менеджер + if is_staff and not is_manager: + if str(mrow.get("assigned_to_tg_id", "")) != str(tg_id): + return {"error": "not_assigned"} + + podbor_lead_id = (mrow.get("podbor_lead_id") or "").strip() + client_name = mrow.get("client_name", "") + client_phone = mrow.get("client_phone", "") + + if not podbor_lead_id: + return {"ok": True, "has_podbor": False, "client_name": client_name} + + # Ищем proposal по client_key + client_key = client_name.lower() if client_name else "" + try: + import proposals as proposals_mod + except ImportError: + from . import proposals as proposals_mod + + try: + ws_p = sheets.sheet("Proposals") + rows_p = ws_p.get_all_values() + except Exception: + return {"ok": True, "has_podbor": False, "client_name": client_name, "error_detail": "proposals_unavailable"} + + if not rows_p or len(rows_p) < 2: + return {"ok": True, "has_podbor": False, "client_name": client_name} + + headers_p = rows_p[0] + proposal = None + for r in rows_p[1:]: + rd = dict(zip(headers_p, r + [""] * (len(headers_p) - len(r)))) + if rd.get("client_key", "").lower() == client_key and rd.get("manager_tg_id") == str(mrow.get("manager_tg_id", "")): + proposal = rd + break + + if not proposal: + return {"ok": True, "has_podbor": False, "client_name": client_name} + + # Парсим positions + try: + positions = json.loads(proposal.get("positions_json") or "[]") + except (ValueError, TypeError): + positions = [] + + # Формируем сводку: только выбранные (voted yes) или все варианты + summary = [] + for pos in positions: + category = pos.get("label") or pos.get("category", "") + variants = pos.get("variants") or [] + chosen = [v for v in variants if v.get("client_vote") == "yes"] + if not chosen: + # Если голосов нет — берём первый вариант как предложенный + chosen = variants[:1] + for v in chosen: + summary.append({ + "category": category, + "name": v.get("name") or v.get("title") or "", + "price": v.get("price") or v.get("final_price") or 0, + "image_url": v.get("image_url") or "", + "voted": v.get("client_vote") == "yes", + }) + + return { + "ok": True, + "has_podbor": True, + "client_name": client_name, + "proposal_status": proposal.get("status", ""), + "items": summary, + "total_items": len(summary), + } + + +@app.post("/api/assembler_client_podbor") +async def api_assembler_client_podbor(request: Request): + body = await _safe_json(request) + return _handle_assembler_client_podbor(body) + + +# ================================================================= +# Акт №4 — приёмка товара (экспедитор) +# ================================================================= + +def _act4_columns() -> list[str]: + return [ + "id", "assembly_id", "act_num", "act_date", "supplier", + "items_json", "notes", "total_items", "damaged_count", + "signed_by_name", "signed_by_phone", "signed_via", "signed_at", + "signature_b64", "otp_code", "otp_expires_at", + "created_at", "created_by_tg_id", "updated_at", + ] + + +def _ensure_act4_sheet() -> None: + want = _act4_columns() + try: + ws = sheets.sheet("Act4s") + existing = ws.row_values(1) + if not existing: + ws.update("A1", [want]) + return + missing = [c for c in want if c not in existing] + if missing: + ws.update("A1", [existing + missing]) + except Exception: + sheets.ensure_sheet("Act4s", want) + + +def _handle_act4_preview(body: dict[str, Any]) -> dict[str, Any]: + """Загружает данные для Акта №4. + body: {initData, assembly_id} + Доступно: expeditor, assembler, manager.""" + 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: + return {"error": "user_not_found"} + + is_exp = sheets.has_role(user, "expeditor") + is_asm = sheets.has_role(user, "assembler") + is_mgr = sheets.has_role(user, "manager") + if not (is_exp or is_asm or is_mgr): + return {"error": "forbidden"} + + assembly_id = (body.get("assembly_id") or "").strip() + if not assembly_id: + return {"error": "missing_assembly_id"} + + _ensure_assemblies_sheet() + asm = sheets.find_row("Assemblies", "id", assembly_id) + if not asm: + return {"error": "assembly_not_found"} + + # Проверка доступа к сборке + is_owner = (str(asm.get("manager_tg_id")) == str(tg_id) or + str(asm.get("assigned_to_tg_id")) == str(tg_id) or + is_mgr) + if not is_owner and not is_exp: + return {"error": "forbidden"} + + _ensure_act4_sheet() + act4 = sheets.find_row("Act4s", "assembly_id", assembly_id) + + # Номер акта: asm-id + "-4" если не задан вручную + default_act_num = f"{assembly_id}-4" + default_date = _now_iso()[:10] + + return { + "ok": True, + "assembly_id": assembly_id, + "client_name": asm.get("client_name", ""), + "client_phone": asm.get("client_phone", ""), + "address": asm.get("address", ""), + "manager_tg_id": asm.get("manager_tg_id", ""), + # Данные акта (если уже сохранён) + "act_num": act4.get("act_num", default_act_num) if act4 else default_act_num, + "act_date": act4.get("act_date", default_date) if act4 else default_date, + "supplier": act4.get("supplier", "") if act4 else "", + "items": json.loads(act4["items_json"]) if act4 and act4.get("items_json") else [], + "notes": act4.get("notes", "") if act4 else "", + "signed_by_name": act4.get("signed_by_name", "") if act4 else "", + "signed_by_phone": act4.get("signed_by_phone", "") if act4 else "", + "signed_via": act4.get("signed_via", "") if act4 else "", + "signed_at": act4.get("signed_at", "") if act4 else "", + "is_signed": bool(act4 and act4.get("signed_by_name")) if act4 else False, + } + + +def _handle_act4_save(body: dict[str, Any]) -> dict[str, Any]: + """Сохраняет / обновляет Акт №4. + body: {initData, assembly_id, act_num, act_date, supplier, items, notes, + signed_by_name?, signed_by_phone?, signed_via?}""" + 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: + return {"error": "user_not_found"} + if not (sheets.has_role(user, "expeditor") or + sheets.has_role(user, "assembler") or + sheets.has_role(user, "manager")): + return {"error": "forbidden"} + + assembly_id = (body.get("assembly_id") or "").strip() + act_num = (body.get("act_num") or f"{assembly_id}-4").strip() + act_date = (body.get("act_date") or _now_iso()[:10]).strip() + supplier = (body.get("supplier") or "").strip() + notes = (body.get("notes") or "").strip() + items = body.get("items") or [] + signed_by_name = (body.get("signed_by_name") or "").strip() + signed_by_phone = (body.get("signed_by_phone") or "").strip() + signed_via = (body.get("signed_via") or "").strip() + + if not isinstance(items, list): + return {"error": "invalid_items"} + + # Подсчёт + total_items = sum(int(it.get("qty", 1)) for it in items) + damaged_count = sum(int(it.get("qty", 1)) for it in items if it.get("condition") == "damaged") + items_json = json.dumps(items, ensure_ascii=False) + now_iso = _now_iso() + + _ensure_act4_sheet() + existing = sheets.find_row("Act4s", "assembly_id", assembly_id) + + if existing: + for col, val in [ + ("act_num", act_num), ("act_date", act_date), ("supplier", supplier), + ("items_json", items_json), ("notes", notes), + ("total_items", str(total_items)), ("damaged_count", str(damaged_count)), + ("updated_at", now_iso), + ]: + sheets.update_cell_by_key("Act4s", "assembly_id", assembly_id, col, val) + if signed_by_name: + for col, val in [ + ("signed_by_name", signed_by_name), + ("signed_by_phone", signed_by_phone), + ("signed_via", signed_via or "manual"), + ("signed_at", now_iso), + ]: + sheets.update_cell_by_key("Act4s", "assembly_id", assembly_id, col, val) + else: + act4_id = str(uuid.uuid4())[:8] + signed_at = now_iso if signed_by_name else "" + sheets.append_row("Act4s", [ + act4_id, assembly_id, act_num, act_date, supplier, + items_json, notes, str(total_items), str(damaged_count), + signed_by_name, signed_by_phone, signed_via or ("manual" if signed_by_name else ""), + signed_at, now_iso, str(tg_id), now_iso, + ]) + + # Автоматика: подписали акт №4 → сборка переходит в in_progress + if signed_by_name: + try: + _ensure_assemblies_sheet() + asm = sheets.find_row("Assemblies", "id", assembly_id) + if asm and asm.get("status") in ("created", "scheduled"): + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "status", "in_progress") + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "started_at", now_iso) + log.info("act4 signed → assembly %s in_progress", assembly_id) + # Уведомить менеджера + mgr_id = asm.get("manager_tg_id") if asm else None + if mgr_id: + dmg_text = f"⚠️ Повреждений: {damaged_count}" if damaged_count else "✅ Без повреждений" + tg.send_message(int(mgr_id), + f"📦 Акт №4 подписан — сборка началась\n" + f"Сборка: {assembly_id}\n" + f"Клиент: {asm.get('client_name','')}\n" + f"Позиций: {total_items} · {dmg_text}\n" + f"Подписал: {signed_by_name}") + except Exception as e: + log.warning("act4 notify error: %s", e) + + return {"ok": True, "total_items": total_items, "damaged_count": damaged_count} + + +def _handle_expeditor_inbox(body): + 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: + return {"error": "user_not_found"} + is_exp = sheets.has_role(user, "expeditor") + is_mgr = sheets.has_role(user, "manager") + if not (is_exp or is_mgr): + return {"error": "forbidden"} + _ensure_assemblies_sheet() + _ensure_act4_sheet() + try: + ws = sheets.sheet("Assemblies") + rows = ws.get_all_values() + except Exception: + return {"ok": True, "assemblies": []} + if not rows or len(rows) < 2: + return {"ok": True, "assemblies": []} + headers = rows[0] + try: + act_ws = sheets.sheet("Act4s") + act_rows = act_ws.get_all_values() + act_headers = act_rows[0] if act_rows else [] + acts = {} + if act_headers and "assembly_id" in act_headers: + aidx = act_headers.index("assembly_id") + for r in act_rows[1:]: + if r and len(r) > aidx: + acts[r[aidx]] = dict(zip(act_headers, r + [""] * max(0, len(act_headers) - len(r)))) + except Exception: + acts = {} + out = [] + for r in rows[1:]: + row = dict(zip(headers, r + [""] * max(0, len(headers) - len(r)))) + if row.get("archived_at") or row.get("status") in ("cancelled",): + continue + visible = is_mgr or str(row.get("expeditor_tg_id", "")) == str(tg_id) + if not visible: + continue + act = acts.get(row.get("id", ""), {}) + out.append({ + "id": row.get("id",""), "client_name": row.get("client_name",""), + "client_phone": row.get("client_phone",""), "address": row.get("address",""), + "scheduled_at": row.get("scheduled_at",""), "status": row.get("status",""), + "is_signed": bool(act.get("signed_by_name")), + "signed_at": act.get("signed_at",""), "act_num": act.get("act_num",""), + }) + return {"ok": True, "assemblies": out} + + +def _handle_act4_request_otp(body): + import random, datetime as dt + 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: + return {"error": "user_not_found"} + if not (sheets.has_role(user, "expeditor") or sheets.has_role(user, "assembler") or sheets.has_role(user, "manager")): + return {"error": "forbidden"} + assembly_id = (body.get("assembly_id") or "").strip() + if not assembly_id: + return {"error": "missing_assembly_id"} + code = str(random.randint(100000, 999999)) + expires = (dt.datetime.utcnow() + dt.timedelta(minutes=10)).isoformat() + _ensure_act4_sheet() + existing = sheets.find_row("Act4s", "assembly_id", assembly_id) + if existing: + sheets.update_cell_by_key("Act4s", "assembly_id", assembly_id, "otp_code", code) + sheets.update_cell_by_key("Act4s", "assembly_id", assembly_id, "otp_expires_at", expires) + else: + act4_id = str(uuid.uuid4())[:8] + sheets.append_row("Act4s", [act4_id, assembly_id, assembly_id+"-4", _now_iso()[:10], + "", "[]", "", "0", "0", "", "", "", "", "", code, expires, _now_iso(), str(tg_id), _now_iso()]) + try: + asm = sheets.find_row("Assemblies", "id", assembly_id) + client = asm.get("client_name", "") if asm else "" + tg.send_message(int(tg_id), + "Код подписи акта\n\nКлиент: " + client + "\nКод: " + code + "\n\nДействителен 10 минут.") + except Exception as e: + log.warning("otp send: %s", e) + return {"error": "send_failed"} + return {"ok": True, "sent": True} + + +def _handle_act4_verify_otp(body): + import datetime as dt + 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: + return {"error": "user_not_found"} + assembly_id = (body.get("assembly_id") or "").strip() + code_input = str(body.get("code") or "").strip() + signer_name = (body.get("signed_by_name") or "").strip() + if not assembly_id or not code_input: + return {"error": "missing_fields"} + _ensure_act4_sheet() + act4 = sheets.find_row("Act4s", "assembly_id", assembly_id) + if not act4: + return {"error": "act_not_found"} + stored = act4.get("otp_code", "") + expires_str = act4.get("otp_expires_at", "") + if not stored or stored != code_input: + return {"error": "invalid_code"} + if expires_str: + try: + exp = dt.datetime.fromisoformat(expires_str) + if dt.datetime.utcnow() > exp: + return {"error": "code_expired"} + except Exception: + pass + now_iso = _now_iso() + name = signer_name or (user.get("name") or user.get("first_name") or str(tg_id)) + for col, val in [("signed_by_name",name),("signed_via","telegram_otp"), + ("signed_at",now_iso),("otp_code",""),("otp_expires_at","")]: + sheets.update_cell_by_key("Act4s", "assembly_id", assembly_id, col, val) + return {"ok": True, "signed": True, "signed_by_name": name} + + +def _handle_act4_save_signature(body): + 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: + return {"error": "user_not_found"} + if not (sheets.has_role(user, "expeditor") or sheets.has_role(user, "assembler") or sheets.has_role(user, "manager")): + return {"error": "forbidden"} + assembly_id = (body.get("assembly_id") or "").strip() + signature_b64 = (body.get("signature_b64") or "").strip() + signer_name = (body.get("signed_by_name") or "").strip() + if not assembly_id or not signature_b64: + return {"error": "missing_fields"} + name = signer_name or (user.get("name") or user.get("first_name") or str(tg_id)) + now_iso = _now_iso() + _ensure_act4_sheet() + existing = sheets.find_row("Act4s", "assembly_id", assembly_id) + if existing: + for col, val in [("signature_b64",signature_b64),("signed_by_name",name), + ("signed_via","canvas"),("signed_at",now_iso)]: + sheets.update_cell_by_key("Act4s", "assembly_id", assembly_id, col, val) + else: + act4_id = str(uuid.uuid4())[:8] + sheets.append_row("Act4s", [act4_id, assembly_id, assembly_id+"-4", now_iso[:10], + "", "[]", "", "0", "0", name, "", "canvas", now_iso, signature_b64, + "", "", now_iso, str(tg_id), now_iso]) + return {"ok": True, "signed": True, "signed_by_name": name} + + +@app.post("/api/expeditor_inbox") +async def api_expeditor_inbox(request: Request): + body = await _safe_json(request) + return JSONResponse(_handle_expeditor_inbox(body)) + +@app.post("/api/act4_request_otp") +async def api_act4_request_otp(request: Request): + body = await _safe_json(request) + return JSONResponse(_handle_act4_request_otp(body)) + +@app.post("/api/act4_verify_otp") +async def api_act4_verify_otp(request: Request): + body = await _safe_json(request) + return JSONResponse(_handle_act4_verify_otp(body)) + +@app.post("/api/act4_save_signature") +async def api_act4_save_signature(request: Request): + body = await _safe_json(request) + return JSONResponse(_handle_act4_save_signature(body)) + + +@app.post("/api/act4_preview") +async def api_act4_preview(request: Request): + body = await _safe_json(request) + return _handle_act4_preview(body) + + +@app.post("/api/act4_save") +async def api_act4_save(request: Request): + body = await _safe_json(request) + return _handle_act4_save(body) + + +# ================================================================= +# Смена статуса сборки — сборщик меняет статус прямо из карточки +# ================================================================= + +_ASSEMBLY_STATUS_TRANSITIONS = { + # текущий → допустимые следующие + "created": ["in_progress", "cancelled"], + "scheduled": ["in_progress", "cancelled"], + "in_progress": ["done", "cancelled"], +} + + +def _handle_assembly_set_status(body: dict[str, Any]) -> dict[str, Any]: + """Сборщик / менеджер меняет статус сборки. + body: {initData, assembly_id, status}""" + 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: + return {"error": "user_not_found"} + + is_assembler = sheets.has_role(user, "assembler") + is_manager = sheets.has_role(user, "manager") + if not (is_assembler or is_manager): + return {"error": "forbidden"} + + assembly_id = (body.get("assembly_id") or "").strip() + new_status = (body.get("status") or "").strip() + if not assembly_id: + return {"error": "missing_assembly_id"} + + _ensure_assemblies_sheet() + row = sheets.find_row("Assemblies", "id", assembly_id) + if not row: + return {"error": "assembly_not_found"} + + # Assembler — только назначенный; менеджер — любая своя + if is_assembler and not is_manager: + if str(row.get("assigned_to_tg_id", "")) != str(tg_id): + return {"error": "not_assigned"} + + current = (row.get("status") or "created").strip() + allowed = _ASSEMBLY_STATUS_TRANSITIONS.get(current, []) + if new_status not in allowed: + return {"error": "invalid_transition", + "msg": f"Из «{current}» нельзя перейти в «{new_status}»", + "allowed": allowed} + + now_iso = _now_iso() + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "status", new_status) + + # Временны́е метки + if new_status == "in_progress": + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "started_at", now_iso) + elif new_status == "done": + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "completed_at", now_iso) + + # Уведомить менеджера если меняет сборщик + if is_assembler and not is_manager: + try: + mgr_id = row.get("manager_tg_id") + if mgr_id: + labels = {"in_progress": "🔨 Сборка началась", "done": "✅ Сборка завершена", "cancelled": "❌ Сборка отменена"} + tg.send_message(int(mgr_id), + f"{labels.get(new_status, new_status)}\n" + f"Сборка: {assembly_id}\n" + f"Клиент: {row.get('client_name','')}") + except Exception as e: + log.warning("assembly_set_status notify: %s", e) + + # Уведомить клиента + client_tg_id_str = (row.get("client_tg_id") or "").strip() + if client_tg_id_str: + try: + client_msgs = { + "in_progress": ( + f"🔨 Сборка вашей кухни началась!\n" + f"Адрес: {row.get('address','')}\n" + f"Мастер уже на объекте." + ), + "done": ( + f"✅ Сборка завершена!\n" + f"Адрес: {row.get('address','')}\n" + f"Пожалуйста, проверьте работу и подпишите акт." + ), + "cancelled": ( + f"❌ Сборка отменена.\n" + f"Свяжитесь с менеджером для уточнения деталей." + ), + } + if new_status in client_msgs: + tg.send_message(int(client_tg_id_str), client_msgs[new_status]) + except Exception as e: + log.warning("assembly_set_status notify client: %s", e) + + sheets.log_event("assembly_status_changed", tg_id, { + "id": assembly_id, "from": current, "to": new_status, + }) + return {"ok": True, "status": new_status, "prev_status": current} + + +@app.post("/api/assembly_set_status") +async def api_assembly_set_status(request: Request): + body = await _safe_json(request) + return _handle_assembly_set_status(body) + + +# ================================================================= +# Назначить экспедитора на сборку +# ================================================================= + +def _handle_assembly_set_expeditor(body: dict[str, Any]) -> dict[str, Any]: + """Менеджер назначает экспедитора на сборку. + body: {initData, assembly_id, expeditor_tg_id}""" + 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"} + 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"} + + assembly_id = (body.get("assembly_id") or "").strip() + exp_tg_id = str(body.get("expeditor_tg_id") or "").strip() + if not assembly_id: + return {"error": "missing_assembly_id"} + + _ensure_assemblies_sheet() + row = sheets.find_row("Assemblies", "id", assembly_id) + if not row: + return {"error": "assembly_not_found"} + + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "expeditor_tg_id", exp_tg_id) + + # Уведомить экспедитора + if exp_tg_id: + try: + exp_user = sheets.find_user(int(exp_tg_id)) + exp_name = exp_user.get("full_name", "") if exp_user else "" + tg.send_message(int(exp_tg_id), + f"📦 Вам назначена приёмка товара\n" + f"Сборка: {assembly_id}\n" + f"Клиент: {row.get('client_name','')}\n" + f"Адрес: {row.get('address','')}\n\n" + f"Оформите Акт №4 при доставке.") + except Exception as e: + log.warning("expeditor notify: %s", e) + return {"ok": True, "expeditor_tg_id": exp_tg_id} + return {"ok": True, "expeditor_tg_id": ""} + + +@app.post("/api/assembly_set_expeditor") +async def api_assembly_set_expeditor(request: Request): + body = await _safe_json(request) + return _handle_assembly_set_expeditor(body) + + _DATA_URL_RE = re.compile(r"^data:image/(jpeg|jpg|png|webp);base64,(.+)$", re.DOTALL) + +# ================================================================= +# Фото-отчёт сборки (сборщик / менеджер) +# ================================================================= + +def _handle_assembly_photo_upload(body: dict[str, Any]) -> dict[str, Any]: + """Сохраняет фото сборки. + body: {initData, assembly_id, photo_b64, kind: 'before'|'in_progress'|'after'}""" + 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: + return {"error": "user_not_found"} + + is_assembler = sheets.has_role(user, "assembler") + is_manager = sheets.has_role(user, "manager") + if not (is_assembler or is_manager): + return {"error": "forbidden"} + + assembly_id = (body.get("assembly_id") or "").strip() + kind = (body.get("kind") or "after").strip() + if kind not in ("before", "in_progress", "after"): + kind = "after" + if not assembly_id or not _SAFE_ID_RE.match(assembly_id): + return {"error": "missing_assembly_id"} + + photo_b64 = (body.get("photo_b64") or "").strip() + if not photo_b64: + return {"error": "missing_photo"} + + m = _DATA_URL_RE.match(photo_b64) + if not m: + return {"error": "invalid_photo_format", "msg": "Ожидается data:image/...;base64,..."} + ext = "jpg" if m.group(1) in ("jpeg", "jpg") else m.group(1) + try: + raw = base64.b64decode(m.group(2), validate=False) + except Exception: + return {"error": "invalid_photo_base64"} + if len(raw) > 10 * 1024 * 1024: + return {"error": "photo_too_large", "msg": "Максимум 10 МБ"} + + _ensure_assemblies_sheet() + row = sheets.find_row("Assemblies", "id", assembly_id) + if not row: + return {"error": "assembly_not_found"} + + if is_assembler and not is_manager: + if str(row.get("assigned_to_tg_id", "")) != str(tg_id): + return {"error": "not_assigned"} + + col_name = f"photos_{kind}" + target_dir = PHOTOS_DIR / assembly_id + try: + target_dir.mkdir(parents=True, exist_ok=True) + existing = [x for x in (row.get(col_name) or "").split(",") if x.strip()] + n = len(existing) + 1 + filename = f"{kind}_{n}.{ext}" + (target_dir / filename).write_bytes(raw) + except Exception as e: + log.warning("assembly photo save failed: %s", e) + return {"error": "save_failed"} + + existing_str = (row.get(col_name) or "").strip().strip(",") + new_val = (existing_str + "," + filename).lstrip(",") + sheets.update_cell_by_key("Assemblies", "id", assembly_id, col_name, new_val) + + log.info("Assembly photo saved: %s/%s", assembly_id, filename) + return {"ok": True, "filename": filename, "kind": kind} + + +# ================================================================= +# Испытательный срок сборщика (менеджер включает/выключает) +# ================================================================= + +def _ensure_users_probation_col() -> None: + """Добавляет колонку on_probation в Users если её нет.""" + try: + ws = sheets.sheet("Users") + headers = ws.row_values(1) + if "on_probation" not in headers: + ws.update_cell(1, len(headers) + 1, "on_probation") + log.info("Users: добавили колонку on_probation") + except Exception as e: + log.warning("_ensure_users_probation_col: %s", e) + + +def _handle_assembler_set_probation(body: dict[str, Any]) -> dict[str, Any]: + """Менеджер устанавливает / снимает испытательный срок у сборщика. + body: {initData, assembler_tg_id, on_probation: true|false}""" + 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"} + + assembler_tg_id = str(body.get("assembler_tg_id") or "").strip() + if not assembler_tg_id: + return {"error": "missing_assembler_tg_id"} + on_prob = bool(body.get("on_probation")) + + target = sheets.find_user(int(assembler_tg_id)) + if not target: + return {"error": "user_not_found"} + if not sheets.has_role(target, "assembler"): + return {"error": "not_assembler"} + + _ensure_users_probation_col() + ok = sheets.update_cell_by_key("Users", "tg_id", assembler_tg_id, "on_probation", "1" if on_prob else "") + if not ok: + return {"error": "update_failed"} + + # Уведомить сборщика + try: + if on_prob: + tg.send_message(int(assembler_tg_id), + "📋 Вы переведены на испытательный срок.\n" + "Для каждого заказа требуется фото-отчёт «До / После сборки».") + else: + tg.send_message(int(assembler_tg_id), + "✅ Испытательный срок завершён. Поздравляем!") + except Exception as e: + log.warning("probation notify: %s", e) + + return {"ok": True, "assembler_tg_id": assembler_tg_id, "on_probation": on_prob} + + +# ================================================================= +# Заметки сборщика +# ================================================================= + +def _handle_assembly_notes_save(body: dict[str, Any]) -> dict[str, Any]: + """Сборщик (назначенный) сохраняет заметки по ходу сборки. + body: {initData, assembly_id, notes}""" + cfg = get_config() + auth = verify_init_data(body.get("initData") or "", cfg.bot_token) + if not auth or not auth.get("user"): + unsafe = body.get("initDataUnsafe") or {} + if isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"): + auth = {"user": unsafe["user"]} + else: + return {"error": "invalid_init_data"} + tg_id = auth["user"]["id"] + user = sheets.find_user(tg_id) + if not user: + return {"error": "user_not_found"} + if not (sheets.has_role(user, "assembler") or sheets.has_role(user, "manager")): + return {"error": "forbidden"} + + assembly_id = (body.get("assembly_id") or "").strip() + notes = (body.get("notes") or "").strip()[:2000] + if not assembly_id: + return {"error": "missing_assembly_id"} + + _ensure_assemblies_sheet() + row = sheets.find_row("Assemblies", "id", assembly_id) + if not row: + return {"error": "assembly_not_found"} + + if sheets.has_role(user, "assembler") and not sheets.has_role(user, "manager"): + if str(row.get("assigned_to_tg_id", "")) != str(tg_id): + return {"error": "not_assigned"} + + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "assembler_notes", notes) + return {"ok": True} + + +# ================================================================= +# Счёт клиенту на сборку +# ================================================================= + +def _handle_assembly_invoice_create(body: dict[str, Any]) -> dict[str, Any]: + """Менеджер / сборщик создаёт счёт клиенту на оплату сборки. + body: {initData, assembly_id, amount?} + Если amount не передан — берём assembly_price_for_client.""" + 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: + return {"error": "user_not_found"} + if not (sheets.has_role(user, "assembler") or sheets.has_role(user, "manager")): + return {"error": "forbidden"} + + assembly_id = (body.get("assembly_id") or "").strip() + if not assembly_id: + return {"error": "missing_assembly_id"} + + _ensure_assemblies_sheet() + row = sheets.find_row("Assemblies", "id", assembly_id) + if not row: + return {"error": "assembly_not_found"} + + # Вычисляем цену сборки для клиента через общий хелпер + prices = _calc_assembly_prices(row, tg_id) + auto_amount = prices.get("assembly_price_for_client") or 0 + + amount_raw = body.get("amount") + if amount_raw is not None: + try: + amount = float(amount_raw) + if amount <= 0: + raise ValueError + except (TypeError, ValueError): + return {"error": "invalid_amount"} + else: + amount = float(auto_amount or 0) + if amount <= 0: + return {"error": "amount_required", "msg": "Укажите сумму или задайте стоимость кухни"} + + address = row.get("address", "") + purpose = f"Оплата услуг по сборке кухни {address or assembly_id}" + try: + qr_b64 = _invoice_qr_b64(amount, purpose) + except Exception as e: + log.warning("assembly invoice qr error: %s", e) + qr_b64 = "" + + now_date = _now_iso()[:10] + try: + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "assembly_invoice_amount", str(amount)) + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "assembly_invoice_date", now_date) + except Exception as e: + log.warning("assembly_invoice_create: save error: %s", e) + + return { + "ok": True, + "assembly_id": assembly_id, + "client_name": row.get("client_name", "Клиент"), + "client_phone": row.get("client_phone", ""), + "address": address, + "date": now_date, + "amount": amount, + "purpose": purpose, + "ip_name": _IP_NAME, + "ip_inn": _IP_INN, + "bank_name": _IP_BANK, + "bic": _IP_BIC, + "rs": _IP_RS, + "ks": _IP_KS, + "qr_b64": qr_b64, + } + + +# ================================================================= +# Доп работы (AssemblyExtras) — чеки из магазина +# ================================================================= + +def _assembly_extras_columns() -> list[str]: + return ["id", "ts", "assembly_id", "added_by_tg_id", "added_by_name", + "description", "amount", "receipt_photo", + "status", # pending | approved | rejected + "approved_by_tg_id", "approved_at"] + + +def _ensure_extras_sheet() -> None: + want = _assembly_extras_columns() + try: + ws = sheets.sheet("AssemblyExtras") + existing = ws.row_values(1) + if not existing: + ws.update("A1", [want]) + return + missing = [c for c in want if c not in existing] + if missing: + ws.update("A1", [existing + missing]) + except Exception: + sheets.ensure_sheet("AssemblyExtras", want) + + +def _assembly_extra_auth(body: dict) -> tuple[dict | None, int | None]: + """Возвращает (user, tg_id) или (None, None) при ошибке.""" + 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 None, None + tg_id = auth["user"]["id"] + user = sheets.find_user(tg_id) + return user, tg_id + + +def _handle_assembly_extras_list(body: dict[str, Any]) -> dict[str, Any]: + """Список доп работ по сборке.""" + user, tg_id = _assembly_extra_auth(body) + if not user: + return {"error": "invalid_init_data"} + if not (sheets.has_role(user, "assembler") or sheets.has_role(user, "manager") or sheets.has_role(user, "client")): + return {"error": "forbidden"} + + assembly_id = (body.get("assembly_id") or "").strip() + if not assembly_id: + return {"error": "missing_assembly_id"} + + _ensure_extras_sheet() + try: + ws = sheets.sheet("AssemblyExtras") + rows = ws.get_all_values() + except Exception: + return {"ok": True, "extras": []} + if not rows or len(rows) < 2: + return {"ok": True, "extras": []} + headers = rows[0] + out = [] + for r in rows[1:]: + row = dict(zip(headers, r + [""] * (len(headers) - len(r)))) + if row.get("assembly_id") != assembly_id: + continue + out.append({ + "id": row.get("id", ""), + "ts": row.get("ts", ""), + "description": row.get("description", ""), + "amount": row.get("amount", ""), + "receipt_photo": row.get("receipt_photo", ""), + "added_by_name": row.get("added_by_name", ""), + "status": row.get("status", "pending") or "pending", + "approved_at": row.get("approved_at", ""), + }) + return {"ok": True, "extras": out} + + +def _handle_assembly_extra_add(body: dict[str, Any]) -> dict[str, Any]: + """Добавляет доп работу. receipt_b64 — фото чека (опционально).""" + user, tg_id = _assembly_extra_auth(body) + if not user: + return {"error": "invalid_init_data"} + if not (sheets.has_role(user, "assembler") or sheets.has_role(user, "manager")): + return {"error": "forbidden"} + + assembly_id = (body.get("assembly_id") or "").strip() + description = (body.get("description") or "").strip()[:300] + amount_raw = body.get("amount") + receipt_b64 = (body.get("receipt_b64") or "").strip() + + if not assembly_id or not description: + return {"error": "missing_fields"} + try: + amount = float(amount_raw) if amount_raw else 0.0 + except (TypeError, ValueError): + amount = 0.0 + + _ensure_assemblies_sheet() + if not sheets.find_row("Assemblies", "id", assembly_id): + return {"error": "assembly_not_found"} + + # Сохраняем фото чека если есть + receipt_fn = "" + if receipt_b64: + m = _DATA_URL_RE.match(receipt_b64) + if m and _SAFE_ID_RE.match(assembly_id): + ext = "jpg" if m.group(1) in ("jpeg", "jpg") else m.group(1) + try: + raw = base64.b64decode(m.group(2), validate=False) + if len(raw) <= 10 * 1024 * 1024: + target_dir = PHOTOS_DIR / assembly_id + target_dir.mkdir(parents=True, exist_ok=True) + existing_cnt = len([f for f in target_dir.iterdir() if f.name.startswith("receipt_")]) + receipt_fn = f"receipt_{existing_cnt + 1}.{ext}" + (target_dir / receipt_fn).write_bytes(raw) + except Exception as e: + log.warning("extra receipt save: %s", e) + + extra_id = _short_id() + full_name = user.get("full_name") or f"{user.get('first_name','')} {user.get('last_name','')}".strip() + + _ensure_extras_sheet() + sheets.append_named_row("AssemblyExtras", { + "id": extra_id, "ts": _now_iso(), "assembly_id": assembly_id, + "added_by_tg_id": str(tg_id), "added_by_name": full_name, + "description": description, "amount": str(amount) if amount else "", + "receipt_photo": receipt_fn, + "status": "pending", + "approved_by_tg_id": "", "approved_at": "", + }) + + # Уведомить менеджера о новой доп работе + try: + asm_row = sheets.find_row("Assemblies", "id", assembly_id) + if asm_row: + mgr_id = asm_row.get("manager_tg_id") + if mgr_id and str(mgr_id) != str(tg_id): + amt_str = f"{amount:,.0f} ₽".replace(",", " ") if amount else "сумма не указана" + tg.send_message(int(mgr_id), + f"🧾 Доп работа на согласование\n" + f"Клиент: {asm_row.get('client_name','')}\n" + f"{description} — {amt_str}") + except Exception as e: + log.warning("extra_add notify manager: %s", e) + + return {"ok": True, "extra": { + "id": extra_id, "ts": _now_iso(), "description": description, + "amount": str(amount) if amount else "", + "receipt_photo": receipt_fn, "added_by_name": full_name, + }} + + +def _handle_assembly_extra_delete(body: dict[str, Any]) -> dict[str, Any]: + """Удаляет запись доп работы (менеджер или автор).""" + user, tg_id = _assembly_extra_auth(body) + if not user: + return {"error": "invalid_init_data"} + if not (sheets.has_role(user, "assembler") or sheets.has_role(user, "manager")): + return {"error": "forbidden"} + + assembly_id = (body.get("assembly_id") or "").strip() + extra_id = (body.get("extra_id") or "").strip() + if not extra_id: + return {"error": "missing_extra_id"} + + _ensure_extras_sheet() + try: + ws = sheets.sheet("AssemblyExtras") + rows = ws.get_all_values() + if not rows: + return {"error": "not_found"} + headers = rows[0] + id_idx = headers.index("id") if "id" in headers else -1 + auth_idx = headers.index("added_by_tg_id") if "added_by_tg_id" in headers else -1 + for i, r in enumerate(rows[1:], start=2): + if len(r) > id_idx and r[id_idx] == extra_id: + is_author = auth_idx >= 0 and len(r) > auth_idx and str(r[auth_idx]) == str(tg_id) + is_manager = sheets.has_role(user, "manager") + if not (is_author or is_manager): + return {"error": "forbidden"} + ws.delete_rows(i) + return {"ok": True} + except Exception as e: + log.warning("extra_delete: %s", e) + return {"error": "not_found"} + + +def _handle_assembly_receipt_parse(body: dict[str, Any]) -> dict[str, Any]: + """Парсит сумму из фото чека через GigaChat Vision.""" + user, tg_id = _assembly_extra_auth(body) + if not user: + return {"error": "invalid_init_data"} + if not (sheets.has_role(user, "assembler") or sheets.has_role(user, "manager")): + return {"error": "forbidden"} + + photo_b64 = (body.get("photo_b64") or "").strip() + if not photo_b64: + return {"error": "missing_photo"} + + result = ai.parse_receipt_amount(photo_b64) + return { + "ok": not result.get("error"), + "amount": result.get("amount"), + "raw": result.get("raw", ""), + } + + +def _handle_assembly_extra_approve(body: dict[str, Any]) -> dict[str, Any]: + """Менеджер согласует или отклоняет доп работу. + body: {initData, assembly_id, extra_id, action: 'approve'|'reject'}""" + user, tg_id = _assembly_extra_auth(body) + if not user: + return {"error": "invalid_init_data"} + if not sheets.has_role(user, "manager"): + return {"error": "only_manager"} + + assembly_id = (body.get("assembly_id") or "").strip() + extra_id = (body.get("extra_id") or "").strip() + action = (body.get("action") or "").strip() + if action not in ("approve", "reject"): + return {"error": "bad_action"} + if not extra_id: + return {"error": "missing_extra_id"} + + _ensure_extras_sheet() + try: + ws = sheets.sheet("AssemblyExtras") + rows = ws.get_all_values() + if not rows: + return {"error": "not_found"} + headers = rows[0] + id_idx = headers.index("id") if "id" in headers else -1 + status_idx = headers.index("status") if "status" in headers else -1 + appr_idx = headers.index("approved_by_tg_id") if "approved_by_tg_id" in headers else -1 + at_idx = headers.index("approved_at") if "approved_at" in headers else -1 + author_idx = headers.index("added_by_tg_id") if "added_by_tg_id" in headers else -1 + desc_idx = headers.index("description") if "description" in headers else -1 + + new_status = "approved" if action == "approve" else "rejected" + now_iso = _now_iso() + + for i, r in enumerate(rows[1:], start=2): + if len(r) > id_idx and r[id_idx] == extra_id: + if status_idx >= 0: + ws.update_cell(i, status_idx + 1, new_status) + if appr_idx >= 0: + ws.update_cell(i, appr_idx + 1, str(tg_id)) + if at_idx >= 0: + ws.update_cell(i, at_idx + 1, now_iso) + + # Уведомить сборщика + try: + author_tg_id = r[author_idx] if author_idx >= 0 and len(r) > author_idx else "" + desc = r[desc_idx] if desc_idx >= 0 and len(r) > desc_idx else "" + if author_tg_id and str(author_tg_id) != str(tg_id): + emoji = "✅" if action == "approve" else "❌" + label = "согласована" if action == "approve" else "отклонена" + tg.send_message(int(author_tg_id), + f"{emoji} Доп работа {label}\n{desc}") + except Exception as e: + log.warning("extra_approve notify: %s", e) + + return {"ok": True, "status": new_status} + except Exception as e: + log.warning("extra_approve: %s", e) + return {"error": "not_found"} + + +# ================================================================= +# Обзор команды (менеджер) +# ================================================================= + +def _handle_staff_roster(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"): + 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"} + + # Считаем активные сборки по сборщику + active_by_assembler: dict[str, int] = {} + try: + _ensure_assemblies_sheet() + ws_asm = sheets.sheet("Assemblies") + asm_rows = ws_asm.get_all_values() + if asm_rows and len(asm_rows) > 1: + hdrs = asm_rows[0] + for r in asm_rows[1:]: + row = dict(zip(hdrs, r + [""] * (len(hdrs) - len(r)))) + if row.get("status") in ("created", "scheduled", "in_progress"): + atg = (row.get("assigned_to_tg_id") or "").strip() + if atg: + active_by_assembler[atg] = active_by_assembler.get(atg, 0) + 1 + except Exception: + pass + + # Счётчик замеров за текущий месяц по замерщику + month_prefix = _now_iso()[:7] # "2026-05" + measures_by_measurer: dict[str, int] = {} + try: + ws_m = sheets.sheet("Measurements") + m_rows = ws_m.get_all_values() + if m_rows and len(m_rows) > 1: + hdrs = m_rows[0] + for r in m_rows[1:]: + row = dict(zip(hdrs, r + [""] * (len(hdrs) - len(r)))) + if (row.get("ts") or "").startswith(month_prefix): + atg = (row.get("assigned_to_tg_id") or "").strip() + if atg: + measures_by_measurer[atg] = measures_by_measurer.get(atg, 0) + 1 + except Exception: + pass + + EQUIPMENT_REQUIRED = {"tablet", "laser_tape", "angle_meter", "tape", "laser_level"} + out: list[dict] = [] + + try: + ws_u = sheets.sheet("Users") + u_rows = ws_u.get_all_values() + if not u_rows or len(u_rows) < 2: + return {"ok": True, "staff": []} + hdrs = u_rows[0] + for r in u_rows[1:]: + row = dict(zip(hdrs, r + [""] * (len(hdrs) - len(r)))) + roles = sheets.parse_roles(row.get("role", "")) + if not any(rl in roles for rl in ("assembler", "measurer", "expeditor")): + continue + tg_id_str = (row.get("tg_id") or "").strip() + full_name = (f"{row.get('first_name','')} {row.get('last_name','')}".strip() + or row.get("tg_username", "") or tg_id_str) + + eq_raw = row.get("equipment", "") + eq_list = [x.strip() for x in eq_raw.split(",") if x.strip()] if eq_raw else [] + equipment_ok = EQUIPMENT_REQUIRED.issubset(set(eq_list)) if "measurer" in roles else None + on_probation = str(row.get("on_probation", "")).lower() in ("1", "true", "yes") + + out.append({ + "tg_id": tg_id_str, + "full_name": full_name, + "tg_username": row.get("tg_username", ""), + "roles": roles, + "equipment_ok": equipment_ok, + "on_probation": on_probation, + "avg_stars": _get_avg_stars(tg_id_str), + "active_assemblies": active_by_assembler.get(tg_id_str, 0), + "month_measures": measures_by_measurer.get(tg_id_str, 0), + }) + except Exception as e: + log.warning("staff_roster: %s", e) + return {"error": "sheets_error"} + + # Сортировка: сначала сборщики, потом замерщики + out.sort(key=lambda x: (0 if "assembler" in x["roles"] else 1, x["full_name"])) + return {"ok": True, "staff": out} + + +# ================================================================= +# Таймлайн заказа для клиента +# ================================================================= + +def _handle_client_order_timeline(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"): + 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: + return {"error": "user_not_found"} + + assembly_id = (body.get("assembly_id") or "").strip() + if not assembly_id: + return {"error": "missing_assembly_id"} + + _ensure_assemblies_sheet() + asm = sheets.find_row("Assemblies", "id", assembly_id) + if not asm: + return {"error": "assembly_not_found"} + + is_authorized = ( + str(asm.get("client_tg_id", "")) == str(tg_id) or + str(asm.get("manager_tg_id", "")) == str(tg_id) or + str(asm.get("assigned_to_tg_id", "")) == str(tg_id) + ) + if not is_authorized: + return {"error": "forbidden"} + + milestones: list[dict] = [] + + # --- Замер --- + measurement = None + measurement_id = (asm.get("measurement_id") or "").strip() + if measurement_id: + try: + measurement = sheets.find_row("Measurements", "id", measurement_id) + except Exception: + pass + + if measurement: + milestones.append({ + "key": "request_created", + "icon": "📋", + "title": "Заявка создана", + "ts": measurement.get("ts") or measurement.get("created_at", ""), + "done": True, + "detail": None, + }) + + measurer_name = "" + if measurement.get("assigned_to_tg_id"): + try: + m_user = sheets.find_user(int(measurement["assigned_to_tg_id"])) + if m_user: + measurer_name = (m_user.get("full_name") or + f"{m_user.get('first_name','')} {m_user.get('last_name','')}".strip()) + except Exception: + pass + + milestones.append({ + "key": "measure_scheduled", + "icon": "📐", + "title": "Замер назначен", + "ts": measurement.get("scheduled_at") or None, + "done": bool(measurement.get("scheduled_at")), + "detail": measurer_name or None, + }) + + meas_done = measurement.get("status") == "completed" + milestones.append({ + "key": "measure_done", + "icon": "✅", + "title": "Замер выполнен", + "ts": measurement.get("completed_at") or (measurement.get("scheduled_at") if meas_done else None), + "done": meas_done, + "detail": None, + }) + else: + milestones.append({ + "key": "request_created", + "icon": "📋", + "title": "Заявка создана", + "ts": asm.get("ts", ""), + "done": True, + "detail": None, + }) + + # --- Сборка создана --- + milestones.append({ + "key": "assembly_created", + "icon": "🔨", + "title": "Сборка создана", + "ts": asm.get("ts", ""), + "done": True, + "detail": asm.get("address") or None, + }) + + # --- Товар принят (Акт №4) --- + act4_signed = False + act4_signed_at = "" + act4_signed_by = "" + try: + _ensure_act4_sheet() + act4_row = sheets.find_row("Act4s", "assembly_id", assembly_id) + if act4_row and act4_row.get("signed_by_name"): + act4_signed = True + act4_signed_at = act4_row.get("signed_at", "") + act4_signed_by = act4_row.get("signed_by_name", "") + except Exception: + pass + + milestones.append({ + "key": "goods_accepted", + "icon": "📦", + "title": "Товар принят", + "ts": act4_signed_at or None, + "done": act4_signed, + "detail": f"Принял: {act4_signed_by}" if act4_signed_by else None, + }) + + # --- Сборка началась --- + asm_status = asm.get("status", "") + in_progress_done = asm_status in ("in_progress", "done") + milestones.append({ + "key": "assembly_started", + "icon": "🔧", + "title": "Сборка началась", + "ts": asm.get("started_at") or None, + "done": in_progress_done, + "detail": None, + }) + + # --- Доп работы --- + extras_count = 0 + extras_approved = 0.0 + try: + _ensure_assembly_extras_sheet() + ws_ex = sheets.sheet("AssemblyExtras") + ex_rows = ws_ex.get_all_values() + if ex_rows and len(ex_rows) > 1: + hdrs = ex_rows[0] + for r in ex_rows[1:]: + rd = dict(zip(hdrs, r + [""] * (len(hdrs) - len(r)))) + if rd.get("assembly_id") == assembly_id: + extras_count += 1 + if rd.get("status") == "approved": + try: + extras_approved += float(rd.get("amount") or 0) + except (ValueError, TypeError): + pass + except Exception: + pass + + if extras_count > 0: + detail_str = f"{extras_count} поз." + if extras_approved > 0: + detail_str += f" · одобрено {int(extras_approved):,} ₽".replace(",", " ") + milestones.append({ + "key": "extras", + "icon": "🧾", + "title": "Доп работы", + "ts": None, + "done": True, + "detail": detail_str, + }) + + # --- Сборка завершена --- + asm_done = asm_status == "done" + milestones.append({ + "key": "assembly_done", + "icon": "✅", + "title": "Сборка завершена", + "ts": asm.get("completed_at") or None, + "done": asm_done, + "detail": None, + }) + + # --- Акт подписан --- + signed = bool(asm.get("signed_by_name")) + milestones.append({ + "key": "act_signed", + "icon": "✍️", + "title": "Акт сдачи-приёмки подписан", + "ts": asm.get("signed_at") or None, + "done": signed, + "detail": asm.get("signed_by_name") or None, + }) + + return { + "ok": True, + "assembly_id": assembly_id, + "client_name": asm.get("client_name", ""), + "address": asm.get("address", ""), + "status": asm_status, + "milestones": milestones, + } + + +# ================================================================= +# Финансовая сводка для менеджера +# ================================================================= + +_MONTHS_RU = [ + "", "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", + "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь", +] +_MONTHS_RU_GEN = [ + "", "января", "февраля", "марта", "апреля", "мая", "июня", + "июля", "августа", "сентября", "октября", "ноября", "декабря", +] + + +def _month_prefixes(period: str, now: datetime) -> tuple[list[str], str]: + """Возвращает (список префиксов YYYY-MM, человеко-читаемый лейбл).""" + y, m = now.year, now.month + if period == "prev_month": + m -= 1 + if m == 0: + m, y = 12, y - 1 + return [f"{y:04d}-{m:02d}"], f"{_MONTHS_RU[m]} {y}" + elif period == "quarter": + prefixes = [] + labels = [] + for i in range(3): + cm, cy = m - i, y + if cm <= 0: + cm += 12 + cy -= 1 + prefixes.append(f"{cy:04d}-{cm:02d}") + labels.append(_MONTHS_RU_GEN[cm]) + return prefixes, f"{labels[-1]} – {labels[0]} {y}" + else: # current_month + return [f"{y:04d}-{m:02d}"], f"{_MONTHS_RU[m]} {y}" + + +def _handle_manager_finance_summary(body: dict[str, Any]) -> dict[str, Any]: + """Финансовая сводка менеджера: замеры, сборки, выручка, выплаты, доп работы. + body: {initData, period: 'current_month'|'prev_month'|'quarter'}""" + 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"} + + period = (body.get("period") or "current_month").strip() + now = datetime.now(timezone.utc) + prefixes, period_label = _month_prefixes(period, now) + + def _in_period(ts: str) -> bool: + return bool(ts) and any(ts.startswith(p) for p in prefixes) + + # ── Замеры ────────────────────────────────────────────────────── + meas_total = 0 + meas_done = 0 + try: + ws_m = sheets.sheet("Measurements") + m_rows = ws_m.get_all_values() + if m_rows and len(m_rows) > 1: + hdrs = m_rows[0] + for r in m_rows[1:]: + row = dict(zip(hdrs, r + [""] * (len(hdrs) - len(r)))) + if _in_period(row.get("ts", "")): + meas_total += 1 + if row.get("status") == "completed": + meas_done += 1 + except Exception as e: + log.warning("finance_summary measurements: %s", e) + + # ── Сборки ────────────────────────────────────────────────────── + asm_total = 0 + asm_done_count = 0 + asm_active_count = 0 + revenue_client = 0.0 # выручка (клиент платит) + payout_assembler = 0.0 # выплата сборщику + asm_list: list[dict] = [] + + try: + _ensure_assemblies_sheet() + ws_a = sheets.sheet("Assemblies") + a_rows = ws_a.get_all_values() + if a_rows and len(a_rows) > 1: + hdrs = a_rows[0] + _ensure_rates_sheet() + for r in a_rows[1:]: + row = dict(zip(hdrs, r + [""] * (len(hdrs) - len(r)))) + # Фильтр по периоду: created or completed in period + ts_use = row.get("completed_at") or row.get("ts", "") + if not _in_period(ts_use): + continue + asm_total += 1 + status = row.get("status", "") + if status == "done": + asm_done_count += 1 + elif status in ("created", "scheduled", "in_progress"): + asm_active_count += 1 + + # Финансы только для done-сборок с указанной ценой кухни + kp = 0.0 + try: + kp = float(row.get("kitchen_price") or 0) + except (ValueError, TypeError): + pass + + if kp and status == "done": + atg = str(row.get("assigned_to_tg_id") or "") + cr, ar = _resolve_rates(atg, scope="*") + client_pay = round(kp * cr / 100, 2) + asm_pay = round(kp * ar / 100, 2) + revenue_client += client_pay + payout_assembler += asm_pay + asm_list.append({ + "id": row.get("id", ""), + "client_name": row.get("client_name", ""), + "address": row.get("address", ""), + "completed_at": row.get("completed_at", ""), + "kitchen_price": kp, + "client_pay": client_pay, + "asm_pay": asm_pay, + "margin": round(client_pay - asm_pay, 2), + }) + except Exception as e: + log.warning("finance_summary assemblies: %s", e) + + # ── Доп работы (approved) ──────────────────────────────────────── + extras_total = 0.0 + extras_count = 0 + try: + _ensure_assembly_extras_sheet() + ws_ex = sheets.sheet("AssemblyExtras") + ex_rows = ws_ex.get_all_values() + if ex_rows and len(ex_rows) > 1: + hdrs = ex_rows[0] + for r in ex_rows[1:]: + rd = dict(zip(hdrs, r + [""] * (len(hdrs) - len(r)))) + if rd.get("status") == "approved" and _in_period(rd.get("ts", "")): + extras_count += 1 + try: + extras_total += float(rd.get("amount") or 0) + except (ValueError, TypeError): + pass + except Exception as e: + log.warning("finance_summary extras: %s", e) + + margin = round(revenue_client - payout_assembler, 2) + + return { + "ok": True, + "period": period, + "period_label": period_label, + # Замеры + "meas_total": meas_total, + "meas_done": meas_done, + # Сборки + "asm_total": asm_total, + "asm_done": asm_done_count, + "asm_active": asm_active_count, + # Финансы + "revenue_client": round(revenue_client, 2), + "payout_assembler": round(payout_assembler, 2), + "margin": margin, + # Доп работы + "extras_count": extras_count, + "extras_total": round(extras_total, 2), + # Детали сборок с деньгами + "asm_list": sorted(asm_list, key=lambda x: x["completed_at"], reverse=True), + } + + +# ================================================================= +# Согласование даты сборки с клиентом +# ================================================================= + +def _auth_manager_only(body: dict) -> tuple[Any, dict | None]: + """Возвращает (tg_id, None) при успехе или (None, error_dict) при ошибке.""" + 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 None, {"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 None, {"error": "only_manager"} + return tg_id, None + + +def _auth_any_user(body: dict) -> tuple[Any, Any, dict | None]: + """Возвращает (tg_id, user, None) при успехе или (None, None, error_dict).""" + 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 None, None, {"error": "invalid_init_data"} + tg_id = auth["user"]["id"] + user = sheets.find_user(tg_id) + if not user: + return None, None, {"error": "user_not_found"} + return tg_id, user, None + + +def _fmt_dt_ru(iso: str) -> str: + """ISO → «19 мая, 14:00»""" + try: + dt = datetime.fromisoformat(iso.replace("Z", "+00:00")) + months = ["", "января", "февраля", "марта", "апреля", "мая", "июня", + "июля", "августа", "сентября", "октября", "ноября", "декабря"] + return f"{dt.day} {months[dt.month]}, {dt.hour:02d}:{dt.minute:02d}" + except Exception: + return iso[:16].replace("T", " ") + + +# ================================================================= +# Система оценок (Feedback) +# ================================================================= + +_FEEDBACK_COLUMNS = [ + "id", "ts", + "from_tg_id", "from_role", + "target_tg_id", "target_role", # target_role: assembler|measurer|manager|service + "ref_id", "ref_type", # ref_type: assembly|measurement + "stars", # 1..5 + "comment", +] + + +def _ensure_feedback_sheet() -> None: + try: + sheets.sheet("Feedback").row_values(1) + except Exception: + sheets.ensure_sheet("Feedback", _FEEDBACK_COLUMNS) + + +def _get_avg_stars(target_tg_id: str) -> float | None: + """Средний балл по всем оценкам для target_tg_id. None если оценок нет.""" + try: + _ensure_feedback_sheet() + rows = sheets.get_all_rows("Feedback") + vals = [ + int(r["stars"]) for r in rows + if r.get("target_tg_id") == str(target_tg_id) + and str(r.get("stars", "")).isdigit() + and 1 <= int(r["stars"]) <= 5 + ] + return round(sum(vals) / len(vals), 1) if vals else None + except Exception: + return None + + +def _handle_feedback_submit(body: dict[str, Any]) -> dict[str, Any]: + """Сохраняет набор оценок одним вызовом. + body: { + initData, + ref_id, # assembly_id или measurement_id + ref_type, # "assembly" | "measurement" + ratings: [ + {target_tg_id?, target_role, stars, comment?} + ] + }""" + tg_id, user, err = _auth_any_user(body) + if err: + return err + + ref_id = (body.get("ref_id") or "").strip() + ref_type = (body.get("ref_type") or "").strip() + ratings = body.get("ratings") or [] + + if not ref_id or not ref_type: + return {"error": "missing_ref"} + if not ratings: + return {"error": "missing_ratings"} + + roles = sheets.parse_roles(user.get("role", "")) + from_role = ( + "client" if "client" in roles else + "measurer" if "measurer" in roles else + "assembler" if "assembler" in roles else + "manager" if "manager" in roles else + "user" + ) + + _ensure_feedback_sheet() + now = _now_iso() + + for r in ratings: + stars = int(r.get("stars") or 0) + if not (1 <= stars <= 5): + continue + target_role = (r.get("target_role") or "").strip() + if not target_role: + continue + sheets.append_row("Feedback", _FEEDBACK_COLUMNS, { + "id": str(uuid.uuid4()), + "ts": now, + "from_tg_id": str(tg_id), + "from_role": from_role, + "target_tg_id": str(r.get("target_tg_id") or ""), + "target_role": target_role, + "ref_id": ref_id, + "ref_type": ref_type, + "stars": str(stars), + "comment": str(r.get("comment") or ""), + }) + + # Отмечаем что отзыв оставлен + if ref_type == "assembly": + _ensure_assemblies_sheet() + asm = sheets.find_row("Assemblies", "id", ref_id) + if asm: + # Определяем поле по роли отправителя + if from_role == "client": + sheets.update_cell_by_key("Assemblies", "id", ref_id, "client_feedback_at", now) + elif ref_type == "measurement": + _ensure_measurements_sheet() + m = sheets.find_row("Measurements", "id", ref_id) + if m: + if from_role == "measurer": + sheets.update_cell_by_key("Measurements", "id", ref_id, "measurer_feedback_at", now) + elif from_role == "manager": + sheets.update_cell_by_key("Measurements", "id", ref_id, "manager_feedback_at", now) + + return {"ok": True, "saved": len(ratings)} + + +def _handle_feedback_my(body: dict[str, Any]) -> dict[str, Any]: + """Возвращает агрегированные оценки для текущего пользователя (или target_tg_id). + body: {initData, target_tg_id?}""" + tg_id, user, err = _auth_any_user(body) + if err: + return err + + target_id = str(body.get("target_tg_id") or tg_id) + # Менеджер может смотреть любого; остальные — только себя + if str(target_id) != str(tg_id): + if not sheets.has_role(user, "manager"): + return {"error": "forbidden"} + + try: + _ensure_feedback_sheet() + rows = sheets.get_all_rows("Feedback") + except Exception as e: + return {"error": "sheets_error", "msg": str(e)} + + my_rows = [r for r in rows if r.get("target_tg_id") == str(target_id)] + + # Группируем по target_role + by_role: dict[str, list[int]] = {} + comments: list[dict] = [] + for r in my_rows: + role = r.get("target_role", "") + try: + s = int(r["stars"]) + if 1 <= s <= 5: + by_role.setdefault(role, []).append(s) + except (ValueError, TypeError): + pass + if r.get("comment"): + comments.append({ + "ts": r.get("ts", ""), + "role": r.get("from_role", ""), + "comment": r["comment"], + "stars": r.get("stars", ""), + }) + + aggregated = [] + role_labels = { + "assembler": "Как сборщик", + "measurer": "Как замерщик", + "manager": "Как менеджер", + "service": "Сервис компании", + } + for role, vals in by_role.items(): + aggregated.append({ + "target_role": role, + "label": role_labels.get(role, role), + "avg": round(sum(vals) / len(vals), 1), + "count": len(vals), + }) + + # Последние 5 комментариев + comments.sort(key=lambda x: x["ts"], reverse=True) + + return { + "ok": True, + "target_tg_id": target_id, + "aggregated": aggregated, + "comments": comments[:5], + "total": len(my_rows), + } + + +def _handle_assembly_suggest_slots(body: dict[str, Any]) -> dict[str, Any]: + """Возвращает свободные слоты сборщиков на 14 дней вперёд, отсортированных по рейтингу. + body: {initData, assembly_id}""" + tg_id, err = _auth_manager_only(body) + if err: + return err + + assembly_id = (body.get("assembly_id") or "").strip() + if not assembly_id: + return {"error": "missing_assembly_id"} + + _ensure_assemblies_sheet() + + # ── Занятость всех сборщиков (busy dates) ──────────────────────── + # busy_dates[tg_id] = set of date strings "YYYY-MM-DD" + busy_dates: dict[str, set] = {} + completed_count: dict[str, int] = {} + active_count: dict[str, int] = {} + + try: + ws_a = sheets.sheet("Assemblies") + rows = ws_a.get_all_values() + if rows and len(rows) > 1: + hdrs = rows[0] + for r in rows[1:]: + row = dict(zip(hdrs, r + [""] * (len(hdrs) - len(r)))) + atg = (row.get("assigned_to_tg_id") or "").strip() + if not atg: + continue + status = row.get("status", "") + if status == "done": + completed_count[atg] = completed_count.get(atg, 0) + 1 + if status in ("created", "scheduled", "in_progress"): + active_count[atg] = active_count.get(atg, 0) + 1 + # Занятая дата = день назначенной/активной сборки + sched = (row.get("scheduled_at") or "").strip() + if sched: + day = sched[:10] + if atg not in busy_dates: + busy_dates[atg] = set() + busy_dates[atg].add(day) + except Exception as e: + log.warning("suggest_slots busy_dates: %s", e) + + # ── Список сборщиков ────────────────────────────────────────────── + assemblers = [] + EQUIPMENT_REQUIRED = {"tablet", "laser_tape", "angle_meter", "tape", "laser_level"} + try: + ws_u = sheets.sheet("Users") + u_rows = ws_u.get_all_values() + if u_rows and len(u_rows) > 1: + hdrs = u_rows[0] + for r in u_rows[1:]: + row = dict(zip(hdrs, r + [""] * (len(hdrs) - len(r)))) + roles = sheets.parse_roles(row.get("role", "")) + if "assembler" not in roles: + continue + atg = (row.get("tg_id") or "").strip() + if not atg: + continue + full_name = (f"{row.get('first_name', '')} {row.get('last_name', '')}".strip() + or row.get("tg_username", "") or atg) + on_probation = str(row.get("on_probation", "")).lower() in ("1", "true", "yes") + + # Рейтинг: звёзды × 15 + завершённые × 10 − активные × 5 − испытательный × 20 + comp = completed_count.get(atg, 0) + active = active_count.get(atg, 0) + avg_stars = _get_avg_stars(atg) + star_bonus = round((avg_stars - 3) * 15) if avg_stars else 0 + score = star_bonus + comp * 10 - active * 5 - (20 if on_probation else 0) + + assemblers.append({ + "tg_id": atg, + "name": full_name, + "tg_username": row.get("tg_username", ""), + "on_probation": on_probation, + "completed_count": comp, + "active_count": active, + "avg_stars": avg_stars, + "score": score, + "_busy": busy_dates.get(atg, set()), + }) + except Exception as e: + log.warning("suggest_slots assemblers: %s", e) + return {"error": "sheets_error"} + + # Сортируем по убыванию рейтинга + assemblers.sort(key=lambda x: x["score"], reverse=True) + + # ── Генерируем свободные слоты на 14 дней ──────────────────────── + now = datetime.now(timezone.utc) + # Начинаем со следующего дня (сегодня уже поздно) + slots_hours = [9, 14] # 09:00 и 14:00 + result = [] + + for asm in assemblers[:6]: # не больше 6 сборщиков + busy = asm.pop("_busy") + free_slots = [] + for day_offset in range(1, 15): + d = now + timedelta(days=day_offset) + day_str = d.strftime("%Y-%m-%d") + if day_str in busy: + continue + # Пропускаем воскресенье (6) + if d.weekday() == 6: + continue + for h in slots_hours: + slot_iso = f"{day_str}T{h:02d}:00" + free_slots.append(slot_iso) + if len(free_slots) >= 6: + break + if len(free_slots) >= 6: + break + asm["free_slots"] = free_slots + result.append(asm) + + return {"ok": True, "assemblers": result} + + +def _handle_assembly_propose_date(body: dict[str, Any]) -> dict[str, Any]: + """Менеджер предлагает дату сборки клиенту. + body: {initData, assembly_id, proposed_date: ISO, assign_assembler_tg_id?: str}""" + tg_id, err = _auth_manager_only(body) + if err: + return err + + assembly_id = (body.get("assembly_id") or "").strip() + proposed_date = (body.get("proposed_date") or "").strip() + if not assembly_id: + return {"error": "missing_assembly_id"} + if not proposed_date: + return {"error": "missing_proposed_date"} + + _ensure_assemblies_sheet() + asm = sheets.find_row("Assemblies", "id", assembly_id) + if not asm: + return {"error": "assembly_not_found"} + if str(asm.get("manager_tg_id", "")) != str(tg_id): + return {"error": "forbidden"} + + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "proposed_date", proposed_date) + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "client_date_status", "pending") + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "client_preferred_date", "") + + # Опционально: назначить сборщика одновременно с предложением даты + assembler_name = "" + assign_tg_id = (body.get("assign_assembler_tg_id") or "").strip() + if assign_tg_id: + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "assigned_to_tg_id", assign_tg_id) + # Уведомляем сборщика + try: + asm_user = sheets.find_user(int(assign_tg_id)) + if asm_user: + assembler_name = (asm_user.get("full_name") or + f"{asm_user.get('first_name','')} {asm_user.get('last_name','')}".strip()) + cfg = get_config() + tg.send_message( + cfg.bot_token, int(assign_tg_id), + f"🔨 Вас назначили на сборку кухни!\n" + f"📍 {asm.get('address', '')}\n" + f"📅 Предлагаемая дата: {_fmt_dt_ru(proposed_date)}\n" + f"(ожидаем подтверждения клиента)", + parse_mode="HTML", + ) + except Exception as e: + log.warning("propose_date tg notify assembler: %s", e) + + # Telegram клиенту + client_tg_id = (asm.get("client_tg_id") or "").strip() + if client_tg_id: + date_str = _fmt_dt_ru(proposed_date) + master_line = f"\n👷 Мастер: {assembler_name}" if assembler_name else "" + try: + cfg = get_config() + tg.send_message( + cfg.bot_token, int(client_tg_id), + f"📅 Менеджер предлагает дату сборки кухни:\n" + f"{date_str}{master_line}\n\n" + f"📍 {asm.get('address', '')}\n\n" + f"Откройте приложение, чтобы подтвердить или предложить другое время.", + parse_mode="HTML", + ) + except Exception as e: + log.warning("propose_date tg notify client: %s", e) + + return {"ok": True, "proposed_date": proposed_date, "assembler_assigned": bool(assign_tg_id)} + + +def _handle_assembly_date_confirm(body: dict[str, Any]) -> dict[str, Any]: + """Клиент подтверждает предложенную дату сборки. + body: {initData, assembly_id}""" + tg_id, user, err = _auth_any_user(body) + if err: + return err + + assembly_id = (body.get("assembly_id") or "").strip() + if not assembly_id: + return {"error": "missing_assembly_id"} + + _ensure_assemblies_sheet() + asm = sheets.find_row("Assemblies", "id", assembly_id) + if not asm: + return {"error": "assembly_not_found"} + + # Только клиент этой сборки + if str(asm.get("client_tg_id", "")) != str(tg_id): + return {"error": "forbidden"} + + proposed = (asm.get("proposed_date") or "").strip() + if not proposed: + return {"error": "no_proposed_date"} + + # Подтверждаем — ставим scheduled_at + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "scheduled_at", proposed) + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "client_date_status", "confirmed") + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "proposed_date", "") + + # Если сборка ещё в created — переводим в scheduled + if asm.get("status") == "created": + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "status", "scheduled") + + # Telegram менеджеру + mgr_tg_id = (asm.get("manager_tg_id") or "").strip() + if mgr_tg_id: + date_str = _fmt_dt_ru(proposed) + client_name = asm.get("client_name", "Клиент") + try: + cfg = get_config() + tg.send_message( + cfg.bot_token, int(mgr_tg_id), + f"✅ {client_name} подтвердил дату сборки:\n" + f"{date_str}\n" + f"📍 {asm.get('address', '')}", + parse_mode="HTML", + ) + except Exception as e: + log.warning("date_confirm tg notify manager: %s", e) + + return {"ok": True, "scheduled_at": proposed} + + +def _handle_assembly_date_decline(body: dict[str, Any]) -> dict[str, Any]: + """Клиент отклоняет предложенную дату, предлагает своё время. + body: {initData, assembly_id, preferred_date?: ISO}""" + tg_id, user, err = _auth_any_user(body) + if err: + return err + + assembly_id = (body.get("assembly_id") or "").strip() + if not assembly_id: + return {"error": "missing_assembly_id"} + + _ensure_assemblies_sheet() + asm = sheets.find_row("Assemblies", "id", assembly_id) + if not asm: + return {"error": "assembly_not_found"} + + if str(asm.get("client_tg_id", "")) != str(tg_id): + return {"error": "forbidden"} + + preferred = (body.get("preferred_date") or "").strip() + + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "client_date_status", "declined") + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "client_preferred_date", preferred) + + # Telegram менеджеру + mgr_tg_id = (asm.get("manager_tg_id") or "").strip() + if mgr_tg_id: + client_name = asm.get("client_name", "Клиент") + msg_lines = [f"❌ {client_name} не может в предложенное время."] + if preferred: + msg_lines.append(f"Предлагает: {_fmt_dt_ru(preferred)}") + else: + msg_lines.append("Альтернативное время не указано — свяжитесь с клиентом.") + msg_lines.append(f"📍 {asm.get('address', '')}") + try: + cfg = get_config() + tg.send_message(cfg.bot_token, int(mgr_tg_id), "\n".join(msg_lines), parse_mode="HTML") + except Exception as e: + log.warning("date_decline tg notify manager: %s", e) + + return {"ok": True} + + # Маппинг тип фото → префикс имени файла (по чек-листу замера) _PHOTO_KIND_PREFIX = { "wall1": "w1", @@ -970,6 +3350,13 @@ def _measurement_columns() -> list[str]: # podbor_decision: pending | needed | not_needed | later | done # podbor_decision_at — когда зафиксировано решение "podbor_decision", "podbor_decision_at", "podbor_lead_id", + # Оплата замера + "measurement_fee", + "rooms_count", # количество помещений для замера + # Обратная связь замерщика о менеджере + "measurer_feedback_at", + # Обратная связь менеджера о замерщике + "manager_feedback_at", ] @@ -1648,6 +4035,9 @@ def _handle_measurement_request(body: dict[str, Any]) -> dict[str, Any]: notes = (body.get("notes") or "").strip() urgent = bool(body.get("urgent", False)) + # Приблизительная дата визита (Commit C2) + rooms_count_req = body.get("rooms_count") + # Приблизительная дата визита (Commit C2) preferred_type = (body.get("preferred_type") or "tbd").strip() preferred_date = (body.get("preferred_date") or "").strip() @@ -1683,6 +4073,13 @@ def _handle_measurement_request(body: dict[str, Any]) -> dict[str, Any]: # Если целевой пользователь не найден или не менеджер — молча игнорируем measurement_id = _short_id() + rooms_count_val = None + if rooms_count_req is not None: + try: + rooms_count_val = str(max(1, int(rooms_count_req))) + except (TypeError, ValueError): + pass + sheets.append_named_row("Measurements", _row_for_measurement( measurement_id, _now_iso(), manager_tg_id=effective_manager_tg_id, @@ -1698,6 +4095,7 @@ def _handle_measurement_request(body: dict[str, Any]) -> dict[str, Any]: preferred_date=preferred_date, preferred_time_of_day=preferred_time_of_day, preferred_note=preferred_note, + rooms_count=rooms_count_val, )) # Уведомляем целевого менеджера если передали ему @@ -2308,8 +4706,20 @@ def _assembly_columns() -> list[str]: "date_range", # текстовая подсказка от менеджера: "20–22 мая, утро" "confirm_by", # ISO — дедлайн для подтверждения (назначение + 3 ч) "confirmed_at", # ISO — когда мастер подтвердил время + # Экспедитор (приёмка товара) + "expeditor_tg_id", # Прочее "manager_note", + "assembler_notes", # заметки сборщика в процессе работы + "kitchen_price", + # Счёт клиенту на сборку + "assembly_invoice_amount", "assembly_invoice_date", + # Согласование даты с клиентом + "proposed_date", # ISO — дата предложенная менеджером клиенту + "client_date_status", # "pending" | "confirmed" | "declined" + "client_preferred_date", # ISO — альтернатива от клиента + # Обратная связь + "client_feedback_at", # ISO — когда клиент оставил оценку "archived_at", ] @@ -2552,12 +4962,59 @@ def _handle_assembly_detail(body: dict[str, Any]) -> dict[str, Any]: def _list(s: str) -> list[str]: return [x for x in (s or "").split(",") if x] + # Контакт назначенного мастера (для клиента) + испытательный срок + assigned_tg_id_str = row.get("assigned_to_tg_id", "") + assigned_to_name = "" + assigned_to_username = "" + assigned_on_probation = False + assigned_user = None + if assigned_tg_id_str: + try: + assigned_user = sheets.find_user(int(assigned_tg_id_str)) + if assigned_user: + assigned_to_name = assigned_user.get("full_name") or ( + f"{assigned_user.get('first_name', '')} {assigned_user.get('last_name', '')}".strip()) + assigned_to_username = assigned_user.get("tg_username", "") + assigned_on_probation = str(assigned_user.get("on_probation", "")).lower() in ("1", "true", "yes") + except Exception: + pass + + # Испытательный срок самого просматривающего (актуально для сборщика) + viewer_on_probation = str(user.get("on_probation", "")).lower() in ("1", "true", "yes") + + # Act №4 summary (не блокирует при ошибке) + act4_total = 0 + act4_damaged = 0 + act4_signed = False + act4_signed_by = "" + try: + _ensure_act4_sheet() + act4_row = sheets.find_row("Act4s", "assembly_id", assembly_id) + if act4_row: + items_raw = act4_row.get("items_json", "") + if items_raw: + items_parsed = json.loads(items_raw) + act4_total = sum(int(it.get("qty", 1)) for it in items_parsed) + act4_damaged = sum(int(it.get("qty", 1)) for it in items_parsed if it.get("condition") == "damaged") + act4_signed = bool(act4_row.get("signed_by_name")) + act4_signed_by = act4_row.get("signed_by_name", "") + except Exception: + pass + return { "ok": True, "id": row.get("id", ""), "ts": row.get("ts", ""), "manager_tg_id": row.get("manager_tg_id", ""), "assigned_to_tg_id": row.get("assigned_to_tg_id", ""), + "assigned_to_name": assigned_to_name, + "assigned_to_username": assigned_to_username, + "assigned_on_probation": assigned_on_probation, + "expeditor_tg_id": row.get("expeditor_tg_id", ""), + "viewer_tg_id": str(tg_id), + "viewer_is_assembler": sheets.has_role(user, "assembler"), + "viewer_is_manager": sheets.has_role(user, "manager"), + "viewer_on_probation": viewer_on_probation, "client_name": row.get("client_name", ""), "client_phone": row.get("client_phone", ""), "address": row.get("address", ""), @@ -2581,8 +5038,22 @@ def _handle_assembly_detail(body: dict[str, Any]) -> dict[str, Any]: "gcal_event_id": row.get("gcal_event_id", ""), "gcal_event_url": row.get("gcal_event_url", ""), "manager_note": row.get("manager_note", ""), + "assembler_notes": row.get("assembler_notes", ""), "kitchen_price": row.get("kitchen_price", ""), + "assembly_invoice_amount": row.get("assembly_invoice_amount", ""), + "assembly_invoice_date": row.get("assembly_invoice_date", ""), "client_tg_id": row.get("client_tg_id", ""), + # Act4 summary + "act4_total": act4_total, + "act4_damaged": act4_damaged, + "act4_signed": act4_signed, + "act4_signed_by": act4_signed_by, + # Согласование даты с клиентом + "proposed_date": row.get("proposed_date", ""), + "client_date_status": row.get("client_date_status", ""), + "client_preferred_date": row.get("client_preferred_date", ""), + # Оценки + "client_feedback_at": row.get("client_feedback_at", ""), # Ставки — подсчёт в реальном времени **_calc_assembly_prices(row, tg_id), } @@ -3532,6 +6003,141 @@ async def api_contract_save(request: Request): return _handle_contract_save(body) +# ================================================================= +# Счёт на оплату замера (с QR-кодом ГОСТ Р 56042-2014 / СБП) +# ================================================================= + +_IP_NAME = "ИП Васильев Руслан Геннадьевич" +_IP_INN = "781909921730" +_IP_RS = "40802810355710022284" +_IP_BANK = "Северо-Западный банк ПАО Сбербанк" +_IP_BIC = "044030653" +_IP_KS = "30101810500000000653" + + +def _invoice_qr_b64(amount_rub: float, purpose: str) -> str: + """Генерирует QR ГОСТ Р 56042-2014 и возвращает base64 PNG.""" + import qrcode + amount_kopecks = int(round(amount_rub * 100)) + qr_data = ( + f"ST00012|Name={_IP_NAME}|PersonalAcc={_IP_RS}" + f"|BankName={_IP_BANK}|BIC={_IP_BIC}|CorrespAcc={_IP_KS}" + f"|PayeeINN={_IP_INN}|Sum={amount_kopecks}|Purpose={purpose}" + ) + img = qrcode.make(qr_data) + import io, base64 + buf = io.BytesIO() + img.save(buf, format="PNG") + return base64.b64encode(buf.getvalue()).decode() + + +def _handle_invoice_create(body: dict[str, Any]) -> dict[str, Any]: + """Создаёт счёт на оплату замера. + body: {initData, measurement_id, amount} + Доступно: измеряющий (measurer) или менеджер.""" + 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: + return {"error": "user_not_found"} + if not (sheets.has_role(user, "measurer") or sheets.has_role(user, "manager")): + return {"error": "forbidden"} + + measurement_id = (body.get("measurement_id") or "").strip() + amount_raw = body.get("amount") + rooms_count_raw = body.get("rooms_count") + + MEASUREMENT_FEE_BASE = 2500 + MEASUREMENT_FEE_EXTRA = 1000 + + if not measurement_id: + return {"error": "missing_measurement_id"} + + _ensure_measurements_sheet() + row = sheets.find_row("Measurements", "id", measurement_id) + if not row: + return {"error": "measurement_not_found"} + + if amount_raw is not None: + try: + amount = float(amount_raw) + if amount <= 0: + raise ValueError + except (TypeError, ValueError): + return {"error": "invalid_amount"} + else: + # Авто-расчёт по rooms_count + if rooms_count_raw is not None: + try: + rooms = max(1, int(rooms_count_raw)) + except (TypeError, ValueError): + rooms = 1 + else: + try: + rooms = max(1, int(row.get("rooms_count") or 1)) + except (TypeError, ValueError): + rooms = 1 + amount = MEASUREMENT_FEE_BASE + max(0, rooms - 1) * MEASUREMENT_FEE_EXTRA + + # Сохраняем rooms_count если передан + if rooms_count_raw is not None: + try: + rooms_to_save = max(1, int(rooms_count_raw)) + sheets.update_cell_by_key("Measurements", "id", measurement_id, "rooms_count", str(rooms_to_save)) + except Exception as e: + log.warning("invoice_create: rooms_count save error: %s", e) + + client_name = row.get("client_name", "Клиент") + client_phone = row.get("client_phone", "") + address = row.get("address", "") + sched_date = (row.get("scheduled_at") or row.get("ts") or "")[:10] + purpose = f"Оплата услуг замера кухни {address or measurement_id}" + + try: + qr_b64 = _invoice_qr_b64(amount, purpose) + except Exception as e: + log.warning("invoice qr error: %s", e) + qr_b64 = "" + + # Сохраняем fee в Measurements для статистики заработков + try: + _ensure_measurements_sheet() + sheets.update_cell_by_key("Measurements", "id", measurement_id, "measurement_fee", str(amount)) + except Exception as e: + log.warning("invoice_create: fee save error: %s", e) + + return { + "ok": True, + "measurement_id": measurement_id, + "client_name": client_name, + "client_phone": client_phone, + "address": address, + "date": sched_date, + "amount": amount, + "purpose": purpose, + "ip_name": _IP_NAME, + "ip_inn": _IP_INN, + "bank_name": _IP_BANK, + "bic": _IP_BIC, + "rs": _IP_RS, + "ks": _IP_KS, + "qr_b64": qr_b64, + } + + +@app.post("/api/invoice_create") +async def api_invoice_create(request: Request): + body = await _safe_json(request) + return _handle_invoice_create(body) + + # ================================================================= # SignRequest — цифровая подпись акта сборки (ФЗ-63 ПЭП) # ================================================================= @@ -3702,6 +6308,23 @@ def _handle_sign_request_submit(body: dict[str, Any]) -> dict[str, Any]: for col, val in updates.items(): sheets.update_cell_by_key("Assemblies", "id", assembly_id, col, val) + # Автоматика: акт №3 подписан → сборка завершена + try: + if row.get("status") not in ("done", "cancelled"): + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "status", "done") + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "completed_at", now_iso) + log.info("sign_request signed → assembly %s done", assembly_id) + # Уведомить менеджера + mgr_id = row.get("manager_tg_id") + if mgr_id: + tg.send_message(int(mgr_id), + f"✅ Акт №3 подписан — сборка завершена\n" + f"Сборка: {assembly_id}\n" + f"Клиент: {row.get('client_name','')}\n" + f"Подписал: {signed_by_name}") + except Exception as e: + log.warning("sign_submit status update error: %s", e) + sheets.log_event("assembly_signed", signed_by_tg_id or "anon", {"assembly_id": assembly_id, "mode": mode, "by": signed_by_name}) return {"ok": True, "signed_at": now_iso, "mode": mode, "signed_by_name": signed_by_name} @@ -4340,6 +6963,16 @@ def _handle_measurement_detail(body: dict[str, Any]) -> dict[str, Any]: "client_no": row.get("client_no", ""), "contract_no": row.get("contract_no", ""), "contract_date": row.get("contract_date", ""), + # Оплата замера + "measurement_fee": row.get("measurement_fee", ""), + "rooms_count": row.get("rooms_count", ""), + # Оценки + "measurer_feedback_at": row.get("measurer_feedback_at", ""), + "manager_feedback_at": row.get("manager_feedback_at", ""), + # Для замерщика: кто менеджер + "viewer_is_measurer": sheets.has_role(user, "measurer"), + "viewer_is_manager": sheets.has_role(user, "manager"), + "viewer_tg_id": str(tg_id), } diff --git a/backend-py/app/sheets.py b/backend-py/app/sheets.py index ad504f8..faea1a4 100644 --- a/backend-py/app/sheets.py +++ b/backend-py/app/sheets.py @@ -160,7 +160,7 @@ def find_user(tg_id: int) -> dict[str, Any] | None: # ---- Multi-role helpers ---- -VALID_ROLES = {"manager", "client", "measurer", "assembler"} +VALID_ROLES = {"manager", "client", "measurer", "assembler", "expeditor"} def parse_roles(role_str: str) -> list[str]: @@ -180,11 +180,11 @@ def has_role(user: dict[str, Any] | None, role: str) -> bool: def is_master(user: dict[str, Any] | None) -> bool: """«Мастер» — единая роль для замерщика+сборщика. - True если у пользователя есть либо measurer, либо assembler.""" + True если у пользователя есть либо measurer, либо assembler, либо expeditor.""" if not user: return False roles = parse_roles(user.get("role", "")) - return "measurer" in roles or "assembler" in roles + return "measurer" in roles or "assembler" in roles or "expeditor" in roles def primary_role(user: dict[str, Any] | None) -> str: @@ -192,7 +192,7 @@ def primary_role(user: dict[str, Any] | None) -> str: if not user: return "" roles = parse_roles(user.get("role", "")) - for r in ("manager", "measurer", "assembler", "client"): + for r in ("manager", "measurer", "assembler", "expeditor", "client"): if r in roles: return r return roles[0] if roles else "" @@ -268,11 +268,16 @@ def list_users_with_role(role: str) -> list[dict[str, Any]]: if role in parse_roles(row.get("role", "")): full_name = (f"{row.get('first_name', '')} {row.get('last_name', '')}".strip() or row.get("tg_username", "")) + equipment_raw = row.get("equipment", "") + equipment_list = [x.strip() for x in equipment_raw.split(",") if x.strip()] if equipment_raw else [] + EQUIPMENT_REQUIRED = {"tablet", "laser_tape", "angle_meter", "tape", "laser_level"} + equipment_ok = EQUIPMENT_REQUIRED.issubset(set(equipment_list)) if role == "measurer" else True out.append({ "tg_id": row.get("tg_id"), "full_name": full_name, "tg_username": row.get("tg_username", ""), "roles": parse_roles(row.get("role", "")), + "equipment_ok": equipment_ok, }) return out diff --git a/backend-py/requirements.txt b/backend-py/requirements.txt index d813915..d767009 100644 --- a/backend-py/requirements.txt +++ b/backend-py/requirements.txt @@ -10,3 +10,5 @@ beautifulsoup4>=4.12.0 lxml>=5.2.0 playwright>=1.45.0 openpyxl>=3.1.0 +qrcode>=8.0 +Pillow>=10.0 diff --git a/icon-picker.html b/icon-picker.html new file mode 100644 index 0000000..a38de5d --- /dev/null +++ b/icon-picker.html @@ -0,0 +1,345 @@ + + + + + +Выбор иконок — Tabler Icons (MIT) + + + + +

Выбор иконок для роли

+

Источник: Tabler Icons — MIT лицензия, бесплатно в коммерческих проектах. Нажмите на иконку, чтобы выбрать.

+ + +
+
Менеджер — «Я менеджер / Веду клиентов и заказы»
+
+ +
+ + + + +
Человек
+
+ +
+ + + + + +
Профиль в круге
+
+ +
+ + + + + +
Подтверждённый
+
+ +
+ + + + + +
VIP / Звезда
+
+ +
+ + + + +
Галстук
+
+ +
+ + + + + + +
Портфель
+
+ +
+ + + + +
Ноутбук
+
+ +
+
+ + +
+
Клиент — «Я клиент / Заказал кухню ЗОВ»
+
+ +
+ + + + + +
Дом классик
+
+ +
+ + + + + +
Дом с окном
+
+ +
+ + + + +
Смарт-дом
+
+ +
+ + + + + +
Дом с сердцем
+
+ +
+ + + + + +
Дом с галкой
+
+ +
+ + + + + + +
Здание
+
+ +
+ + + + + +
Дверь
+
+ +
+
+ + +
+
Сотрудник — «Я сотрудник / Замерщик или сборщик ЗОВ»
+
+ +
+ + + + +
Каска
+
+ +
+ + + +
Гаечный ключ
+
+ +
+ + + + + + + + +
Набор инструментов
+
+ +
+ + + + +
Молоток
+
+ +
+ + + + + + + +
Линейка / замер
+
+ +
+ + + + + + + + + + + +
Сотрудник+настройки
+
+ +
+
+ + +
+
+ Выберите иконки для всех трёх ролей
+ Нажмите на карточку +
+
+
+ + + + diff --git a/icon-preview.html b/icon-preview.html new file mode 100644 index 0000000..89e9dca --- /dev/null +++ b/icon-preview.html @@ -0,0 +1,712 @@ + + + + + +Icon Preview — 10 Styles × 3 Roles + + + +

Превью иконок — 10 стилей × 3 роли

+
+ + +
+
+
1
+
Контур
+
+
+ +
+ + + + + + + + + + + + + +
Менеджер
+
+ +
+ + + + + + + + + + + + + + + + + + + +
Клиент
+
+ +
+ + + + + + + + + + + + + + +
Сотрудник
+
+
+
+ + +
+
+
2
+
Силуэт
+
+
+ +
+ + + + + + + + + + +
Менеджер
+
+ +
+ + + + + + + + + + + + +
Клиент
+
+ +
+ + + + + + + + + + + + + +
Сотрудник
+
+
+
+ + +
+
+
3
+
Мягкий
+
+
+ +
+ + + + + + + + + +
Менеджер
+
+ +
+ + + + + + + + + +
Клиент
+
+ +
+ + + + + + + + + + + + +
Сотрудник
+
+
+
+ + +
+
+
4
+
Значок
+
+
+ +
+ + + + + + + + + + + +
Менеджер
+
+ +
+ + + + + + + + + + + + +
Клиент
+
+ +
+ + + + + + + + + + + + + + + + + +
Сотрудник
+
+
+
+ + +
+
+
5
+
Геометрия
+
+
+ +
+ + + + + + + + + + +
Менеджер
+
+ +
+ + + + + + + + + + + +
Клиент
+
+ +
+ + + + + + + + + + + + + +
Сотрудник
+
+
+
+ + +
+
+
6
+
Дуотон
+
+
+ +
+ + + + + + + + + + + + +
Менеджер
+
+ +
+ + + + + + + + + + + +
Клиент
+
+ +
+ + + + + + + + + + + + + + + + + +
Сотрудник
+
+
+
+ + +
+
+
7
+
Уютный
+
+
+ +
+ + + + + + + + + + + + + + + + + + +
Менеджер
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
Клиент
+
+ +
+ + + + + + + + + + + + + + + + + + + +
Сотрудник
+
+
+
+ + +
+
+
8
+
Стик
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + +
Менеджер
+
+ +
+ + + + + + + + + + + +
Клиент
+
+ +
+ + + + + + + + + + + + + + + + +
Сотрудник
+
+
+
+ + +
+
+
9
+
Ретро
+
+
+ +
+ + + + + + + + + + + + + + +
Менеджер
+
+ +
+ + + + + + + + + + + + +
Клиент
+
+ +
+ + + + + + + + + + + + + + + + + +
Сотрудник
+
+
+
+ + +
+
+
10
+
Маркер
+
+
+ +
+ + + + + + + + +
Менеджер
+
+ +
+ + + + + + + + + + + +
Клиент
+
+ +
+ + + + + + + + + + + + + +
Сотрудник
+
+
+
+ +
+ + diff --git a/miniapp/assets/act4.js b/miniapp/assets/act4.js new file mode 100644 index 0000000..85f891e --- /dev/null +++ b/miniapp/assets/act4.js @@ -0,0 +1,407 @@ +/* ============================================================ + Акт №4 — приёмка товара (экспедитор / сборщик) + #/assembly/:id/act4 + ============================================================ */ + +const Act4Screen = (function () { + "use strict"; + + function escHtml(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + function el(html) { + const t = document.createElement("template"); + t.innerHTML = html.trim(); + return t.content.firstChild; + } + + async function _api(path, body = {}) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const res = await fetch(`${BACKEND_URL}/api/${path}`, { + method: "POST", signal: ctrl.signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + initData: typeof Platform !== "undefined" ? Platform.initData : (window.tg?.initData || ""), + initDataUnsafe: typeof Platform !== "undefined" ? Platform.initDataUnsafe : null, + ...body, + }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.json(); + } catch (e) { + if (e.name === "AbortError") throw new Error("Сервер не отвечает"); + throw e; + } finally { clearTimeout(t); } + } + + // Состояние акта + let _state = { + act_num: "", act_date: "", supplier: "", notes: "", + items: [], // [{id, name, qty, condition, note}] + signed_by_name: "", signed_by_phone: "", signed_via: "", + }; + let _data = {}; // данные с сервера + let _container = null; + let _assemblyId = ""; + + function _itemId() { + return "i" + Math.random().toString(36).slice(2, 8); + } + + /* ── Главный mount ──────────────────────────────────────────── */ + async function mount(container, assemblyId) { + _container = container; + _assemblyId = assemblyId; + container.innerHTML = ""; + document.body.classList.remove("has-bottom-nav"); + document.getElementById("bottom-nav")?.remove(); + + const h = el(` +
+ +
Акт №4 · Приёмка товара
+
+
+ `); + h.querySelector(".podbor-back").addEventListener("click", () => { haptic && haptic("impact"); history.back(); }); + container.appendChild(h); + + const screen = el(`
`); + screen.innerHTML = `
`; + container.appendChild(screen); + + try { + const d = await _api("act4_preview", { assembly_id: assemblyId }); + if (d.error) { screen.innerHTML = `
${escHtml(d.error)}
`; return; } + _data = d; + _state = { + act_num: d.act_num || `${assemblyId}-4`, + act_date: d.act_date || new Date().toISOString().slice(0, 10), + supplier: d.supplier || "", + notes: d.notes || "", + items: (d.items || []).map(it => ({ ...it, id: it.id || _itemId() })), + signed_by_name: d.signed_by_name || "", + signed_by_phone: d.signed_by_phone || "", + signed_via: d.signed_via || "", + }; + _render(screen, d.is_signed); + } catch (e) { + screen.innerHTML = `
Ошибка: ${escHtml(e.message)}
`; + } + } + + /* ── Рендер ─────────────────────────────────────────────────── */ + function _render(screen, isSigned) { + screen.innerHTML = ""; + + // Баннер если подписан + if (isSigned) { + screen.appendChild(el(` +
+
✅ Акт подписан
+
+ ${escHtml(_state.signed_by_name)} + ${_state.signed_at ? " · " + escHtml(new Date(_state.signed_at).toLocaleDateString("ru-RU")) : ""} +
+
+ `)); + } + + // Данные клиента + screen.appendChild(el(` +
+
Клиент
+
${escHtml(_data.client_name || "—")}
+ ${_data.address ? `
${escHtml(_data.address)}
` : ""} +
+ `)); + + // Реквизиты акта + const reqs = el(` +
+
+
Номер акта
+ +
+
+
Дата
+ +
+
+
Поставщик
+ +
+
+ `); + screen.appendChild(reqs); + + if (!isSigned) { + reqs.querySelector("#a4-num").addEventListener("input", e => { _state.act_num = e.target.value; }); + reqs.querySelector("#a4-date").addEventListener("change", e => { _state.act_date = e.target.value; }); + reqs.querySelector("#a4-supplier").addEventListener("input", e => { _state.supplier = e.target.value; }); + } + + // === Список позиций === + const itemsHead = el(` +
+
Позиции
+ ${!isSigned ? `` : ""} +
+ `); + screen.appendChild(itemsHead); + + const itemsList = el(`
`); + screen.appendChild(itemsList); + _renderItemsList(itemsList, isSigned); + + if (!isSigned) { + itemsHead.querySelector("#a4-add-item")?.addEventListener("click", () => { + haptic && haptic("impact"); + _state.items.push({ id: _itemId(), name: "", qty: 1, condition: "ok", note: "" }); + _renderItemsList(itemsList, false); + }); + } + + // Итог + const totalEl = el(`
`); + screen.appendChild(totalEl); + _renderTotal(totalEl); + + // Примечание + const noteWrap = el(` +
+
Примечания
+ +
+ `); + screen.appendChild(noteWrap); + if (!isSigned) { + noteWrap.querySelector("#a4-notes").addEventListener("input", e => { _state.notes = e.target.value; }); + } + + // Блок подписи + if (!isSigned) { + const signWrap = el(` +
+
+ Подпись принявшего +
+
+ +
+
+ +
+
+ `); + screen.appendChild(signWrap); + signWrap.querySelector("#a4-sign-name").addEventListener("input", e => { _state.signed_by_name = e.target.value; }); + signWrap.querySelector("#a4-sign-phone").addEventListener("input", e => { _state.signed_by_phone = e.target.value; }); + + // Кнопки + const btns = el(` +
+ + +
+ `); + screen.appendChild(btns); + const statusEl = el(`
`); + screen.appendChild(statusEl); + + btns.querySelector("#a4-save-btn").addEventListener("click", () => _doSave(false, statusEl)); + btns.querySelector("#a4-sign-btn").addEventListener("click", () => _doSave(true, statusEl)); + } + } + + /* ── Список позиций ─────────────────────────────────────────── */ + function _renderItemsList(container, isSigned) { + container.innerHTML = ""; + + if (!_state.items.length) { + container.appendChild(el(` +
+ ${isSigned ? "Позиции не добавлены" : "Нажмите «+ Добавить» чтобы внести позиции"} +
+ `)); + return; + } + + _state.items.forEach((item, idx) => { + const row = el(` +
+
+ + ${!isSigned ? `` : ""} +
+
+
+
Кол-во
+ +
+
+
Состояние
+
+ + +
+
+
+ ${item.condition === "damaged" && !isSigned ? ` +
+ +
` : (item.note && isSigned ? `
${escHtml(item.note)}
` : "")} +
+ `); + + if (!isSigned) { + row.querySelector(".it-name").addEventListener("input", e => { + _state.items[idx].name = e.target.value; + }); + row.querySelector(".it-qty").addEventListener("input", e => { + _state.items[idx].qty = parseInt(e.target.value) || 1; + _renderTotal(document.getElementById("a4-total")); + }); + row.querySelector(".it-del").addEventListener("click", () => { + haptic && haptic("impact"); + _state.items.splice(idx, 1); + _renderItemsList(container, false); + _renderTotal(document.getElementById("a4-total")); + }); + row.querySelectorAll(".cond-btn").forEach(btn => { + btn.addEventListener("click", () => { + haptic && haptic("selection"); + _state.items[idx].condition = btn.dataset.cond; + _renderItemsList(container, false); + _renderTotal(document.getElementById("a4-total")); + }); + }); + row.querySelector(".it-note")?.addEventListener("input", e => { + _state.items[idx].note = e.target.value; + }); + } + + container.appendChild(row); + }); + } + + function _renderTotal(container) { + if (!container) return; + const total = _state.items.reduce((s, it) => s + (parseInt(it.qty) || 1), 0); + const damaged = _state.items.filter(it => it.condition === "damaged") + .reduce((s, it) => s + (parseInt(it.qty) || 1), 0); + container.innerHTML = damaged > 0 + ? `
+ Итого: ${total} позиций · ⚠️ Повреждений: ${damaged} +
` + : `
+ Итого: ${total} позиций · ✅ Без повреждений +
`; + } + + /* ── Сохранение / подпись ───────────────────────────────────── */ + async function _doSave(withSign, statusEl) { + haptic && haptic("impact"); + if (withSign && !_state.signed_by_name.trim()) { + if (statusEl) { statusEl.style.color = "#E74C3C"; statusEl.textContent = "Укажите ФИО принявшего"; } + return; + } + if (statusEl) { statusEl.style.color = "var(--muted)"; statusEl.textContent = "Сохраняем…"; } + + const payload = { + assembly_id: _assemblyId, + act_num: _state.act_num, + act_date: _state.act_date, + supplier: _state.supplier, + items: _state.items, + notes: _state.notes, + }; + if (withSign) { + payload.signed_by_name = _state.signed_by_name; + payload.signed_by_phone = _state.signed_by_phone; + payload.signed_via = "manual"; + } + + try { + const res = await _api("act4_save", payload); + if (res.error) { + if (statusEl) { statusEl.style.color = "#E74C3C"; statusEl.textContent = "Ошибка: " + res.error; } + return; + } + if (withSign) { + // Перезагружаем экран — покажет баннер «Подписан» + mount(_container, _assemblyId); + } else { + if (statusEl) { statusEl.style.color = "#27AE60"; statusEl.textContent = "✅ Сохранено"; } + setTimeout(() => { if (statusEl) statusEl.textContent = ""; }, 3000); + } + } catch (e) { + if (statusEl) { statusEl.style.color = "#E74C3C"; statusEl.textContent = "Ошибка: " + e.message; } + } + } + + return { mount }; +})(); diff --git a/miniapp/assets/app.js b/miniapp/assets/app.js index f0e951d..f76c3d1 100644 --- a/miniapp/assets/app.js +++ b/miniapp/assets/app.js @@ -1,4 +1,4 @@ -// ЗОВ MiniApp — главный скрипт. v20260518n +// ЗОВ MiniApp — главный скрипт. v20260519e // На входе: подписанный initData от Telegram. // Ходим на backend → получаем профиль (роль, статус) → рендерим меню. // tg и Platform определены в platform.js (загружается первым). @@ -196,6 +196,9 @@ async function renderManagerHome(me) { { icon: "ruler", title: "Заказать замер", subtitle: "Назначить замерщика", href: "#/request" }, { icon: "wrench", title: "Ставки сборки", subtitle: "% клиент / сборщик", href: "#/admin/rates" }, { icon: "folder", title: "Аналитика", subtitle: "Занятость сборщиков", href: "#/admin/assembler-analytics" }, + { icon: "user", title: "Команда", subtitle: "Нагрузка + статусы", href: "#/admin/staff" }, + { icon: "wallet", title: "Финансы", subtitle: "Выручка · маржа · выплаты", href: "#/admin/finance" }, + { icon: "star", title: "Мои оценки", subtitle: "Рейтинг · отзывы", href: "#/feedback/my" }, ]; app.appendChild(el(`
Быстрые действия
`)); const grid = el(`
`); @@ -220,6 +223,10 @@ async function renderManagerHome(me) { const projectsContainer = el(`
`); app.appendChild(projectsContainer); + // Сборки в работе (под активными проектами) + const assembliesContainer = el(`
`); + app.appendChild(assembliesContainer); + // Контейнер для отгрузок с завода (под активными проектами) const shipmentsContainer = el(`
`); app.appendChild(shipmentsContainer); @@ -249,14 +256,16 @@ async function renderManagerHome(me) { renderManagerToday(todayContainer, data.measurements || [], firstName, greetingEl); renderManagerProjects(projectsContainer, data.measurements || []); - // Складские данные — не критичны; грузим после, ошибка не ломает дашборд + // Складские данные + сборки — не критичны; ошибка не ломает дашборд const authBodyStr = JSON.stringify(authBody); Promise.all([ - fetch(`${BACKEND_URL}/api/shipments`, { method: "POST", body: authBodyStr }).then(r => r.json()).catch(() => ({})), - fetch(`${BACKEND_URL}/api/arrivals`, { method: "POST", body: authBodyStr }).then(r => r.json()).catch(() => ({})), - ]).then(([shipmentsData, arrivalsData]) => { + fetch(`${BACKEND_URL}/api/shipments`, { method: "POST", body: authBodyStr }).then(r => r.json()).catch(() => ({})), + fetch(`${BACKEND_URL}/api/arrivals`, { method: "POST", body: authBodyStr }).then(r => r.json()).catch(() => ({})), + fetch(`${BACKEND_URL}/api/assembly_list`, { method: "POST", body: authBodyStr }).then(r => r.json()).catch(() => ({})), + ]).then(([shipmentsData, arrivalsData, assemblyData]) => { renderManagerShipments(shipmentsContainer, shipmentsData.shipments || [], "📦 Отгрузки с завода"); renderManagerShipments(arrivalsContainer, arrivalsData.shipments || [], "📥 Поступление в СПб"); + renderManagerAssemblies(assembliesContainer, assemblyData.assemblies || []); }).catch(() => { /* тихо — дашборд уже отрисован */ }); } catch (e) { todayContainer.innerHTML = `
Не удалось загрузить данные: ${escHtml(e.message)}
`; @@ -541,6 +550,52 @@ function renderManagerProjects(container, measurements) { container.appendChild(list); } +/* ----------------- Менеджер: секция сборок в работе ----------------- */ +function renderManagerAssemblies(container, assemblies) { + container.innerHTML = ""; + const ASSEMBLY_STATUS = { + created: { icon: "🆕", label: "Создана", cls: "waiting" }, + scheduled: { icon: "📅", label: "Запланирована", cls: "active" }, + in_progress: { icon: "🔨", label: "В процессе", cls: "active" }, + done: { icon: "✅", label: "Завершена", cls: "done" }, + cancelled: { icon: "❌", label: "Отменена", cls: "cancel" }, + }; + // Показываем только активные (не завершённые и не отменённые) + const active = (assemblies || []).filter(a => a.status !== "done" && a.status !== "cancelled"); + if (!active.length) return; + + container.appendChild(el(` +
+ 🔨 Сборки в работе · ${active.length} +
+ `)); + + for (const a of active) { + const sl = ASSEMBLY_STATUS[a.status] || { icon: "🔧", label: a.status, cls: "waiting" }; + const dateLabel = a.scheduled_at + ? new Date(a.scheduled_at).toLocaleDateString("ru-RU", { day: "numeric", month: "short" }) + : "—"; + const card = el(` +
+
+
${escHtml(a.client_name || "Без имени")}
+ ${sl.icon} ${sl.label} +
+
${escHtml(a.address || "адрес не указан")}
+
+ ${escHtml(a.scope_of_work || "—")} + ${dateLabel} +
+
+ `); + card.addEventListener("click", () => { + haptic("impact"); + location.hash = `#/assembly/${a.id}`; + }); + container.appendChild(card); + } +} + /* ----------------- Менеджер: секция отгрузок / поступлений на склад ----------------- */ function renderManagerShipments(container, groups, label = "📦 Отгрузки") { container.innerHTML = ""; @@ -661,11 +716,17 @@ function renderClient(me) { const sections = [ { - label: "Подобрать кухню", + label: "Мой заказ", items: [ - { icon: "ruler", color: "blue", label: "Замер кухни", href: "#/c/measure" }, - { icon: "wrench", color: "green", label: "Подобрать технику", href: "#/c/proposal" }, - { icon: "wallet", color: "gold", label: "Проверить договор", href: "#/c/contract" }, + { icon: "clipboard", color: "green", label: "Статус сборки", href: "#/c/orders", sub: "Этапы и таймлайн" }, + { icon: "ruler", color: "blue", label: "Замер кухни", href: "#/c/measure" }, + { icon: "wallet", color: "gold", label: "Проверить договор", href: "#/c/contract" }, + ], + }, + { + label: "Подбор техники", + items: [ + { icon: "wrench", color: "green", label: "Подобрать встройку", href: "#/c/proposal" }, ], }, { @@ -826,10 +887,17 @@ async function renderStaff(me) { const caps = me.capabilities || {}; const labels = []; - if (caps.measurer) labels.push("замерщик"); + if (caps.measurer) labels.push("замерщик"); if (caps.assembler) labels.push("сборщик"); + if (caps.expeditor) labels.push("экспедитор"); const subtitle = labels.length ? labels.join(" · ") : "сотрудник"; + // Экспедитор — отдельный экран + if (caps.expeditor && !caps.measurer && !caps.assembler) { + _renderExpeditorScreen(app, me); + return; + } + app.appendChild(el(`
${me.user?.avatar_initial || "?"}
@@ -928,6 +996,22 @@ async function renderStaff(me) { app.appendChild(clientsBtn); } + // Статистика замерщика + if (caps.measurer) { + const measStatsBtn = el(` +
+ +
+ `); + measStatsBtn.querySelector("button").addEventListener("click", () => { + haptic && haptic("impact"); + location.hash = "#/master/measurer-stats"; + }); + app.appendChild(measStatsBtn); + } + // Шпаргалки + заработки сборщика if (caps.assembler) { const earningsBtn = el(` @@ -956,6 +1040,88 @@ async function renderStaff(me) { }); app.appendChild(toolsBtn); } + + // Мои оценки — для всех сотрудников + const myRatingsBtn = el(` +
+ +
+ `); + myRatingsBtn.querySelector("button").addEventListener("click", () => { + haptic && haptic("impact"); + location.hash = "#/feedback/my"; + }); + app.appendChild(myRatingsBtn); +} + +/* ── Экран экспедитора ─────────────────────────────────────────── */ +function _renderExpeditorScreen(container, me) { + const u = me.user || {}; + container.appendChild(el(` +
+
${u.avatar_initial || "Э"}
+
${escHtml(u.full_name || "Экспедитор")}
+
экспедитор
+
+ `)); + + container.appendChild(el(` +
+
+ Выберите сборку для оформления приёмки товара +
+
+ `)); + + // Список активных сборок + const asmWrap = el(`
`); + container.appendChild(asmWrap); + _loadExpeditorAssemblies(asmWrap); +} + +async function _loadExpeditorAssemblies(container) { + container.innerHTML = `
`; + try { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + const res = await fetch(`${BACKEND_URL}/api/assembly_list`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ initData: Platform.initData, initDataUnsafe: Platform.initDataUnsafe }), + signal: ctrl, + }); + clearTimeout(t); + const data = await res.json(); + if (data.error) { container.innerHTML = `
${escHtml(data.error)}
`; return; } + const assemblies = (data.assemblies || []).filter(a => !["done","cancelled"].includes(a.status)); + if (!assemblies.length) { + container.innerHTML = `
+ Нет активных сборок
`; + return; + } + container.innerHTML = ""; + assemblies.forEach(a => { + const card = el(` +
+
${escHtml(a.client_name || "Клиент")}
+
${escHtml(a.address || "")}
+
+ 📦 Оформить приёмку +
+
+ `); + card.addEventListener("click", () => { + haptic && haptic("impact"); + location.hash = `#/expeditor/act/${a.id}`; + }); + container.appendChild(card); + }); + } catch (e) { + container.innerHTML = `
${escHtml(e.message)}
`; + } } async function renderStaffAssemblies(container) { @@ -1363,6 +1529,49 @@ async function renderInboxDetail(measurementId) { app.appendChild(dateSection); dateSection.querySelector("#saveSched").addEventListener("click", () => saveScheduleDate(measurementId, dateSection)); } + + // === Кнопка «Выставить счёт» (замерщик) === + if (m.viewer_is_measurer) { + const invoiceWrap = el('
'); + const hasFee = parseFloat(m.measurement_fee||0) > 0; + invoiceWrap.appendChild(el( + '' + )); + invoiceWrap.querySelector('#invoiceBtn').addEventListener('click', () => { + haptic && haptic('impact'); + location.hash = '#/master/invoice/' + measurementId; + }); + app.appendChild(invoiceWrap); + } + + // === Обратная связь — замерщик оценивает менеджера === + if (m.status === "completed" && m.viewer_is_measurer && !m.measurer_feedback_at + && typeof FeedbackModule !== "undefined") { + const fbWrap = el(`
`); + app.appendChild(fbWrap); + FeedbackModule.mountMeasurerFeedback(fbWrap, { + managerName: m.manager_name || "", + managerTgId: m.manager_tg_id || "", + measurementId: m.id, + onSubmit: () => renderInboxDetail(measurementId), + }); + } + + // === Обратная связь — менеджер оценивает замерщика === + if (m.status === "completed" && m.viewer_is_manager && !m.manager_feedback_at + && typeof FeedbackModule !== "undefined") { + const fbWrap2 = el(`
`); + app.appendChild(fbWrap2); + FeedbackModule.mountManagerFeedback(fbWrap2, { + measurerName: m.measurer_name || "", + measurerTgId: m.measurer_tg_id || "", + measurementId: m.id, + onSubmit: () => renderInboxDetail(measurementId), + }); + } } async function saveScheduleDate(measurementId, section) { @@ -1726,6 +1935,12 @@ function routeByHash() { else init(); } else if (location.hash.startsWith("#/assembly")) { Assembly.mount(app); + } else if (location.hash === "#/admin/staff") { + if (typeof StaffRoster !== "undefined") StaffRoster.mount(app); + else init(); + } else if (location.hash === "#/admin/finance") { + if (typeof FinanceSummary !== "undefined") FinanceSummary.mount(app); + else init(); } else if (location.hash === "#/admin/assembler-analytics") { if (typeof AssemblerAnalytics !== "undefined") AssemblerAnalytics.mount(app); else init(); @@ -1742,6 +1957,24 @@ function routeByHash() { const asmId = location.hash.split("/")[2]; if (typeof Contracts !== "undefined") Contracts.mount(app, asmId); else init(); + } else if (location.hash === "#/expeditor") { + if (typeof ExpeditorDashboard !== "undefined") ExpeditorDashboard.mount(app); + else init(); + } else if (location.hash.startsWith("#/expeditor/act/")) { + const asmId = location.hash.replace("#/expeditor/act/", "").split("?")[0]; + if (typeof ExpeditorDashboard !== "undefined") ExpeditorDashboard.mountAct(app, asmId); + else init(); + } else if (location.hash.startsWith("#/assembly/") && location.hash.endsWith("/act4")) { + const asmId = location.hash.split("/")[2]; + if (typeof Act4Screen !== "undefined") Act4Screen.mount(app, asmId); + else init(); + } else if (location.hash === "#/master/measurer-stats") { + if (typeof MeasurerDashboard !== "undefined") MeasurerDashboard.mount(app); + else init(); + } else if (location.hash.startsWith("#/master/invoice/")) { + const mId = location.hash.replace("#/master/invoice/", "").split("?")[0]; + if (typeof InvoiceScreen !== "undefined") InvoiceScreen.mount(app, mId); + else init(); } else if (location.hash.startsWith("#/master/tools")) { if (typeof MasterTools !== "undefined") { const h = location.hash; @@ -1782,6 +2015,13 @@ function routeByHash() { } else if (location.hash === "#/c/orders") { if (typeof OrdersScreen !== "undefined") OrdersScreen.mount(app); else init(); + } else if (location.hash.startsWith("#/c/assembly/") && location.hash.endsWith("/timeline")) { + const parts = location.hash.split("/"); + // #/c/assembly/ID/timeline → parts = ["#", "c", "assembly", "ID", "timeline"] + const asmIdRaw = parts[parts.length - 2] || ""; + const asmId = decodeURIComponent(asmIdRaw); + if (typeof ClientTimeline !== "undefined") ClientTimeline.mount(app, asmId); + else init(); } else if (location.hash.startsWith("#/c/assembly/")) { const assemblyId = decodeURIComponent(location.hash.replace("#/c/assembly/", "")); if (typeof AssemblyDetailScreen !== "undefined") AssemblyDetailScreen.mount(app, assemblyId); @@ -1789,6 +2029,9 @@ function routeByHash() { } else if (location.hash === "#/c/selfmeasure") { if (typeof SelfMeasureScreen !== "undefined") SelfMeasureScreen.mount(app); else init(); + } else if (location.hash === "#/feedback/my") { + if (typeof FeedbackModule !== "undefined") FeedbackModule.mountMyScreen(app); + else init(); } else { // Главный экран по роли const me = window.__zovMe; diff --git a/miniapp/assets/assembly_detail.js b/miniapp/assets/assembly_detail.js index 7168c77..18bd36b 100644 --- a/miniapp/assets/assembly_detail.js +++ b/miniapp/assets/assembly_detail.js @@ -1,6 +1,6 @@ /* ============================================================ Детальная карточка сборки — #/c/assembly/:id - Доступна клиенту, менеджеру, мастеру. + Доступна клиенту, менеджеру, мастеру. v20260519o ============================================================ */ const AssemblyDetailScreen = (function () { @@ -131,6 +131,168 @@ const AssemblyDetailScreen = (function () { ${row("Завершение", fmtDate(data.completed_at))}
`; + // Контакт мастера (виден клиенту) + const masterBlock = data.assigned_to_name ? ` +
+
Ваш мастер
+
+
${escHtml(data.assigned_to_name)}
+ ${data.assigned_to_username ? ` + + ✉️ Написать + ` : ""} +
+ ${data.assigned_to_username ? ` +
+ @${escHtml(data.assigned_to_username)} +
` : ""} +
` : ""; + + // === Согласование даты сборки === + // Для менеджера: кнопка «Предложить дату» или статус ожидания + function _fmtDateShort(iso) { + if (!iso) return ""; + try { + return new Date(iso).toLocaleString("ru-RU", { + day: "numeric", month: "long", hour: "2-digit", minute: "2-digit", + }); + } catch { return iso.slice(0, 16).replace("T", " "); } + } + + const dateStatus = data.client_date_status || ""; + const proposedDate = data.proposed_date || ""; + const clientPrefDate = data.client_preferred_date || ""; + + // Блок для менеджера + const dateNegMgrBlock = data.viewer_is_manager && !["done", "cancelled"].includes(data.status) ? (() => { + if (dateStatus === "pending") { + return ` +
+
⏳ Ожидаем ответа клиента
+
Предложено: ${escHtml(_fmtDateShort(proposedDate))}
+ +
`; + } + if (dateStatus === "declined") { + return ` +
+
❌ Клиент не может
+ ${clientPrefDate ? `
Предлагает: ${escHtml(_fmtDateShort(clientPrefDate))}
` : `
Альтернативная дата не указана
`} +
+ ${clientPrefDate ? `` : ""} + +
+
`; + } + // Нет активного предложения — умный подборщик + return ` +
+ + +
+ +
+ +
`; + })() : ""; + + // Блок для клиента: подтверждение предложенной даты + const dateNegClientBlock = !data.viewer_is_manager && !data.viewer_is_assembler && proposedDate && dateStatus === "pending" ? ` +
+
+ 📅 Менеджер предлагает дату сборки +
+
+ ${escHtml(_fmtDateShort(proposedDate))} +
+
+ + +
+ +
+
` : ""; + + // Испытательный срок — виден менеджеру когда есть назначенный сборщик + const probationBlock = (data.viewer_is_manager && data.assigned_to_tg_id) ? ` +
+
+
Испытательный срок
+
Сборщик обязан прикладывать фото
+
+ +
` : ""; + + // Act №4 summary + const act4SummaryBlock = (data.act4_total > 0 || data.act4_signed) ? (() => { + const dmgColor = data.act4_damaged > 0 ? "#E67E22" : "#27AE60"; + return ` +
+
Акт №4 · Приёмка товара
+
+ 📦 ${data.act4_total} поз. + ${data.act4_damaged > 0 + ? `⚠️ ${data.act4_damaged} поврежд.` + : `✅ Без повреждений`} + ${data.act4_signed + ? `Принял: ${escHtml(data.act4_signed_by)}` + : `⏳ Не подписан`} +
+
`; + })() : ""; + // Заметка менеджера const noteBlock = data.manager_note ? `
Фото результата
+ letter-spacing:.06em;color:var(--muted);margin-bottom:8px;">Фото сборки
- ${photosAfter.map(u => ` - - фото { + const u = _photoUrl(fn); + return ` + фото - `).join("")} + border:1px solid var(--border);" loading="lazy"> + `; + }).join("")}
` : ""; @@ -189,16 +358,616 @@ const AssemblyDetailScreen = (function () { ` : ""; - screen.innerHTML = statusBanner + mainBlock + noteBlock + photosBlock + signBlock + calBtn + + // Заметки сборщика (показ) — перед кнопками, данные уже в data + const assemblerNotesBlock = data.assembler_notes ? ` +
+
Заметки сборщика
+
${escHtml(data.assembler_notes)}
+
` : ""; + + screen.innerHTML = statusBanner + mainBlock + dateNegMgrBlock + dateNegClientBlock + + act4SummaryBlock + masterBlock + probationBlock + + noteBlock + assemblerNotesBlock + photosBlock + signBlock + calBtn + `
`; + // Обработчик toggle испытательного срока + const probToggleBtn = screen.querySelector("#probation-toggle-btn"); + if (probToggleBtn) { + probToggleBtn.addEventListener("click", async () => { + haptic && haptic("impact"); + const newVal = !data.assigned_on_probation; + probToggleBtn.disabled = true; + probToggleBtn.textContent = "…"; + try { + const res = await _api("assembler_set_probation", { + assembler_tg_id: data.assigned_to_tg_id, + on_probation: newVal, + }); + if (res.ok) mount(container, assemblyId); + else { probToggleBtn.disabled = false; alert(res.msg || res.error); } + } catch (e) { probToggleBtn.disabled = false; probToggleBtn.textContent = "Ошибка"; } + }); + } + + // === Обработчики согласования даты (менеджер) === + const proposeOpenBtn = screen.querySelector("#date-propose-open-btn"); + const proposeAgainBtn = screen.querySelector("#date-propose-again-btn"); + const dateAcceptClientBtn = screen.querySelector("#date-accept-client-btn"); + + function _showProposeForm() { + const form = screen.querySelector("#date-propose-form"); + const openBtn = screen.querySelector("#date-propose-open-btn"); + if (form) { form.style.display = "block"; if (openBtn) openBtn.style.display = "none"; } + } + + if (proposeOpenBtn) { + proposeOpenBtn.addEventListener("click", () => { haptic && haptic("impact"); _showProposeForm(); }); + } + if (proposeAgainBtn) { + proposeAgainBtn.addEventListener("click", () => { + haptic && haptic("impact"); + const block = screen.querySelector("#date-neg-block"); + if (block) block.innerHTML = ` +
+
Предложить дату:
+ +
+ + +
+
+
`; + _bindProposeForm(block); + }); + } + + if (dateAcceptClientBtn) { + dateAcceptClientBtn.addEventListener("click", async () => { + haptic && haptic("impact"); + dateAcceptClientBtn.disabled = true; + dateAcceptClientBtn.textContent = "…"; + try { + const res = await _api("assembly_propose_date", { + assembly_id: data.id, + proposed_date: data.client_preferred_date, + }); + if (res.ok) mount(container, assemblyId); + else dateAcceptClientBtn.textContent = res.error || "Ошибка"; + } catch (e) { dateAcceptClientBtn.textContent = "Ошибка"; } + }); + } + + function _bindProposeForm(ctx) { + const cancelBtn = (ctx || screen).querySelector("#date-propose-cancel-btn"); + const sendBtn = (ctx || screen).querySelector("#date-propose-send-btn"); + const statusEl = (ctx || screen).querySelector("#date-propose-status"); + if (cancelBtn) cancelBtn.addEventListener("click", () => mount(container, assemblyId)); + if (sendBtn) sendBtn.addEventListener("click", async () => { + haptic && haptic("impact"); + const inputEl = (ctx || screen).querySelector("#date-propose-input"); + const val = inputEl ? inputEl.value : ""; + if (!val) { if (statusEl) statusEl.textContent = "Выберите дату"; return; } + sendBtn.disabled = true; sendBtn.textContent = "Отправляем…"; + try { + const res = await _api("assembly_propose_date", { assembly_id: data.id, proposed_date: val }); + if (res.ok) { + haptic && haptic("success"); + mount(container, assemblyId); + } else { + if (statusEl) statusEl.textContent = res.msg || res.error || "Ошибка"; + sendBtn.disabled = false; sendBtn.textContent = "Отправить клиенту"; + } + } catch (e) { + if (statusEl) statusEl.textContent = e.message; + sendBtn.disabled = false; sendBtn.textContent = "Отправить клиенту"; + } + }); + } + _bindProposeForm(null); + + // Кнопка "Указать вручную" + const manualToggle = screen.querySelector("#date-propose-manual-toggle button"); + if (manualToggle) { + manualToggle.addEventListener("click", () => { + haptic && haptic("impact"); + const form = screen.querySelector("#date-propose-form"); + const panel = screen.querySelector("#date-suggest-panel"); + const suggestBtn = screen.querySelector("#date-suggest-btn"); + if (form) { form.style.display = form.style.display === "none" ? "block" : "none"; } + if (panel) panel.style.display = "none"; + if (suggestBtn) suggestBtn.style.display = "block"; + }); + } + + // === Умный подборщик: загрузка и рендер слотов === + const suggestBtn = screen.querySelector("#date-suggest-btn"); + if (suggestBtn) { + let _slotsLoaded = false; + suggestBtn.addEventListener("click", async () => { + haptic && haptic("impact"); + const panel = screen.querySelector("#date-suggest-panel"); + const manualWrap = screen.querySelector("#date-propose-manual-toggle"); + if (!panel) return; + + if (panel.style.display === "block") { + panel.style.display = "none"; + suggestBtn.textContent = "🔍 Подобрать мастера и дату"; + return; + } + + panel.style.display = "block"; + suggestBtn.textContent = "⏳ Загружаем…"; + suggestBtn.disabled = true; + if (manualWrap) manualWrap.style.display = "none"; + + if (!_slotsLoaded) { + panel.innerHTML = `
`; + try { + const slots = await _api("assembly_suggest_slots", { assembly_id: data.id }); + if (slots.error) { + panel.innerHTML = `
${escHtml(slots.error)}
`; + } else { + _renderSlots(panel, slots.assemblers || []); + _slotsLoaded = true; + } + } catch (e) { + panel.innerHTML = `
Ошибка: ${escHtml(e.message)}
`; + } + } + suggestBtn.disabled = false; + suggestBtn.textContent = "✕ Скрыть"; + }); + } + + function _fmtSlot(iso) { + try { + const d = new Date(iso); + const days = ["Вс","Пн","Вт","Ср","Чт","Пт","Сб"]; + const mons = ["","янв","фев","мар","апр","май","июн","июл","авг","сен","окт","ноя","дек"]; + return `${days[d.getDay()]} ${d.getDate()} ${mons[d.getMonth()+1]}, ${String(d.getHours()).padStart(2,"0")}:00`; + } catch { return iso.slice(0, 16).replace("T"," "); } + } + + let _selectedAssemblerTgId = null; + let _selectedSlot = null; + + function _renderSlots(panel, assemblers) { + panel.innerHTML = ""; + + if (!assemblers.length) { + panel.innerHTML = `
Нет доступных сборщиков
`; + return; + } + + // Итоговый блок отправки + const sendWrap = document.createElement("div"); + sendWrap.id = "slots-send-wrap"; + sendWrap.style.cssText = "display:none;margin-top:8px;padding:10px;background:var(--surface);" + + "border:1px solid var(--accent);border-radius:10px;"; + + for (const asm of assemblers) { + const card = document.createElement("div"); + card.style.cssText = "margin-bottom:10px;padding:10px;background:var(--surface);" + + "border:1px solid var(--border);border-radius:10px;"; + + // Заголовок: имя + рейтинг-бейджи + const probBadge = asm.on_probation + ? `испытательный` : ""; + const loadColor = asm.active_count >= 3 ? "#E67E22" : asm.active_count >= 1 ? "#F39C12" : "#27AE60"; + card.innerHTML = ` +
+
+
+ ${escHtml(asm.name)} +
+
+ ✅ ${asm.completed_count} сборок + 🔨 ${asm.active_count} активных + ${probBadge} +
+
+
+ ⭐ ${asm.score > 0 ? "+" : ""}${asm.score} +
+
+
+ `; + + const chipsWrap = card.querySelector(".slots-chips"); + if (!asm.free_slots || !asm.free_slots.length) { + chipsWrap.innerHTML = `Нет свободных дат на 2 недели`; + } else { + asm.free_slots.forEach(slot => { + const chip = document.createElement("button"); + chip.className = "slot-chip"; + chip.dataset.slot = slot; + chip.dataset.asmId = asm.tg_id; + chip.dataset.asmName = asm.name; + chip.style.cssText = `font-size:11px;padding:4px 8px;border-radius:8px; + border:1px solid var(--border);background:var(--surface); + color:var(--ink);cursor:pointer;white-space:nowrap;`; + chip.textContent = _fmtSlot(slot); + chip.addEventListener("click", () => { + haptic && haptic("impact"); + // Снять выделение с всех чипов + panel.querySelectorAll(".slot-chip").forEach(c => { + c.style.background = "var(--surface)"; + c.style.color = "var(--ink)"; + c.style.borderColor = "var(--border)"; + }); + // Подсветить выбранный + chip.style.background = "var(--accent)"; + chip.style.color = "#fff"; + chip.style.borderColor = "var(--accent)"; + + _selectedAssemblerTgId = asm.tg_id; + _selectedSlot = slot; + + // Показываем кнопку отправки + sendWrap.style.display = "block"; + sendWrap.innerHTML = ` +
+ ${escHtml(asm.name)}
+ ${escHtml(_fmtSlot(slot))} +
+ +
+ `; + const sendSlotBtn = sendWrap.querySelector("#slots-send-btn"); + sendSlotBtn.addEventListener("click", async () => { + haptic && haptic("impact"); + sendSlotBtn.disabled = true; sendSlotBtn.textContent = "Отправляем…"; + const statusEl = sendWrap.querySelector("#slots-send-status"); + try { + const res = await _api("assembly_propose_date", { + assembly_id: data.id, + proposed_date: _selectedSlot, + assign_assembler_tg_id: _selectedAssemblerTgId, + }); + if (res.ok) { + haptic && haptic("success"); + mount(container, assemblyId); + } else { + if (statusEl) statusEl.textContent = res.msg || res.error || "Ошибка"; + sendSlotBtn.disabled = false; sendSlotBtn.textContent = "📅 Предложить клиенту"; + } + } catch (e) { + if (statusEl) statusEl.textContent = e.message; + sendSlotBtn.disabled = false; sendSlotBtn.textContent = "📅 Предложить клиенту"; + } + }); + }); + chipsWrap.appendChild(chip); + }); + } + + panel.appendChild(card); + } + + panel.appendChild(sendWrap); + } + + // === Обработчики согласования даты (клиент) === + const clientConfirmBtn = screen.querySelector("#date-client-confirm-btn"); + const clientDeclineBtn = screen.querySelector("#date-client-decline-btn"); + const clientSendAltBtn = screen.querySelector("#date-client-send-alt-btn"); + const clientStatus = screen.querySelector("#date-client-status"); + + if (clientConfirmBtn) { + clientConfirmBtn.addEventListener("click", async () => { + haptic && haptic("impact"); + clientConfirmBtn.disabled = true; clientConfirmBtn.textContent = "…"; + try { + const res = await _api("assembly_date_confirm", { assembly_id: data.id }); + if (res.ok) { + haptic && haptic("success"); + mount(container, assemblyId); + } else { + clientConfirmBtn.disabled = false; clientConfirmBtn.textContent = "✅ Подтверждаю"; + if (clientStatus) clientStatus.textContent = res.error || "Ошибка"; + } + } catch (e) { clientConfirmBtn.disabled = false; clientConfirmBtn.textContent = "✅ Подтверждаю"; } + }); + } + + if (clientDeclineBtn) { + clientDeclineBtn.addEventListener("click", () => { + haptic && haptic("impact"); + const altForm = screen.querySelector("#date-client-alt-form"); + if (altForm) altForm.style.display = "block"; + clientDeclineBtn.style.display = "none"; + }); + } + + if (clientSendAltBtn) { + clientSendAltBtn.addEventListener("click", async () => { + haptic && haptic("impact"); + const altInput = screen.querySelector("#date-client-alt-input"); + const altVal = altInput ? altInput.value : ""; + clientSendAltBtn.disabled = true; clientSendAltBtn.textContent = "Отправляем…"; + try { + const res = await _api("assembly_date_decline", { + assembly_id: data.id, + preferred_date: altVal || null, + }); + if (res.ok) { + haptic && haptic("success"); + const block = screen.querySelector("#date-client-block"); + if (block) block.innerHTML = ` +
+ ✅ Ваш ответ отправлен менеджеру +
`; + } else { + clientSendAltBtn.disabled = false; clientSendAltBtn.textContent = "Отправить менеджеру"; + if (clientStatus) clientStatus.textContent = res.error || "Ошибка"; + } + } catch (e) { clientSendAltBtn.disabled = false; clientSendAltBtn.textContent = "Отправить менеджеру"; } + }); + } + + // === Кнопки смены статуса (сборщик + менеджер) === + const isAssembler = data.viewer_is_assembler; + const isMgr = data.viewer_is_manager; + + // === Кнопка «Мой заказ» — только для клиента === + if (!isMgr && !isAssembler) { + const tlWrap = document.createElement("div"); + tlWrap.style.cssText = "margin:8px 16px 0;"; + const tlBtn = document.createElement("button"); + tlBtn.className = "btn-primary"; + tlBtn.style.cssText = "width:100%;font-size:14px;padding:12px;"; + tlBtn.textContent = "📋 Мой заказ — этапы"; + tlBtn.addEventListener("click", () => { + haptic && haptic("impact"); + location.hash = `#/c/assembly/${encodeURIComponent(data.id)}/timeline`; + }); + tlWrap.appendChild(tlBtn); + screen.appendChild(tlWrap); + } + + // === Оценка сборки (клиент, после завершения, ещё не оценивал) === + if (!isMgr && !isAssembler && data.status === "done" && !data.client_feedback_at + && typeof FeedbackModule !== "undefined") { + const fbWrap = document.createElement("div"); + fbWrap.style.cssText = "margin:16px 16px 0;"; + screen.appendChild(fbWrap); + FeedbackModule.mountAssemblyFeedback(fbWrap, { + assemblerName: data.assigned_to_name || "", + assemblerTgId: data.assigned_to_tg_id || "", + managerName: data.manager_name || "", + managerTgId: data.manager_tg_id || "", + assemblyId: data.id, + onSubmit: () => mount(container, assemblyId), + }); + } + const isAssigned = String(data.assigned_to_tg_id) === String(data.viewer_tg_id); + const canChangeStatus = isMgr || (isAssembler && isAssigned); + + const STATUS_BTNS = { + created: [{ label: "🔨 Начать сборку", next: "in_progress", cls: "btn-primary" }], + scheduled: [{ label: "🔨 Начать сборку", next: "in_progress", cls: "btn-primary" }], + in_progress: [{ label: "✅ Завершить сборку", next: "done", cls: "btn-primary" }], + }; + + if (canChangeStatus && STATUS_BTNS[data.status]) { + STATUS_BTNS[data.status].forEach(({ label, next, cls }) => { + const w = document.createElement("div"); + w.style.cssText = "margin:8px 16px 0;"; + const b = document.createElement("button"); + b.className = cls; + b.style.cssText = "width:100%;font-size:15px;padding:13px;"; + b.textContent = label; + b.addEventListener("click", async () => { + haptic && haptic("impact"); + b.disabled = true; b.textContent = "Обновляем…"; + try { + const res = await _api("assembly_set_status", { assembly_id: data.id, status: next }); + if (res.error) { b.disabled = false; b.textContent = label; alert(res.msg || res.error); return; } + mount(container, assemblyId); + } catch (e) { b.disabled = false; b.textContent = label; } + }); + w.appendChild(b); screen.appendChild(w); + }); + } + + // === Назначить экспедитора (менеджер) === + if (isMgr && data.status !== "done" && data.status !== "cancelled") { + const expWrap = document.createElement("div"); + expWrap.style.cssText = "margin:8px 16px 0;"; + expWrap.innerHTML = ` +
+ + +
+ `; + screen.appendChild(expWrap); + // Загружаем список экспедиторов + _loadExpeditorList(expWrap.querySelector("#exp-select"), data.expeditor_tg_id); + expWrap.querySelector("#exp-assign-btn").addEventListener("click", async () => { + haptic && haptic("impact"); + const selId = expWrap.querySelector("#exp-select").value; + const res = await _api("assembly_set_expeditor", { assembly_id: data.id, expeditor_tg_id: selId }); + if (res.ok) mount(container, assemblyId); + }); + } + + // === Фото-отчёт (только испытательный срок) === + if (isAssembler && isAssigned && data.viewer_on_probation) { + const photoUploadWrap = document.createElement("div"); + photoUploadWrap.style.cssText = "margin:8px 16px 0;"; + photoUploadWrap.innerHTML = ` +
📸 Фото-отчёт сборки
+
+ + +
+ +
+ `; + screen.appendChild(photoUploadWrap); + + const fileInput = photoUploadWrap.querySelector("#photo-file-input"); + const statusEl = photoUploadWrap.querySelector("#photo-upload-status"); + let _activeKind = "after"; + + photoUploadWrap.querySelectorAll("button[data-kind]").forEach(btn => { + btn.addEventListener("click", () => { + _activeKind = btn.dataset.kind; + fileInput.click(); + }); + }); + + fileInput.addEventListener("change", async () => { + const file = fileInput.files[0]; + if (!file) return; + statusEl.textContent = "Загружаем…"; + try { + const dataUrl = await new Promise((res, rej) => { + const reader = new FileReader(); + reader.onload = e => res(e.target.result); + reader.onerror = rej; + reader.readAsDataURL(file); + }); + const result = await _api("assembly_photo_upload", { + assembly_id: data.id, + photo_b64: dataUrl, + kind: _activeKind, + }); + if (result.error) { + statusEl.textContent = `Ошибка: ${result.msg || result.error}`; + } else { + statusEl.textContent = `✅ Фото добавлено`; + haptic && haptic("success"); + setTimeout(() => mount(container, assemblyId), 600); + } + } catch (e) { + statusEl.textContent = `Ошибка: ${e.message}`; + } finally { + fileInput.value = ""; + } + }); + } + + // === Заметки сборщика — ввод (in_progress, назначенный сборщик или менеджер) === + if ((isAssembler && isAssigned) || isMgr) { + const notesWrap = document.createElement("div"); + notesWrap.style.cssText = "margin:8px 16px 0;"; + notesWrap.innerHTML = ` +
📝 Заметки сборщика
+ + ${(!isMgr || isAssigned) ? ` + +
` : ""} + `; + screen.appendChild(notesWrap); + const saveNotesBtn = notesWrap.querySelector("#asm-notes-save-btn"); + if (saveNotesBtn) { + saveNotesBtn.addEventListener("click", async () => { + haptic && haptic("impact"); + const notes = notesWrap.querySelector("#asm-notes-input").value.trim(); + saveNotesBtn.disabled = true; + saveNotesBtn.textContent = "Сохраняем…"; + const statusEl = notesWrap.querySelector("#asm-notes-status"); + try { + const res = await _api("assembly_notes_save", { assembly_id: data.id, notes }); + if (res.ok) { + statusEl.textContent = "✅ Сохранено"; + haptic && haptic("success"); + setTimeout(() => statusEl.textContent = "", 2000); + } else statusEl.textContent = res.msg || res.error; + } catch (e) { statusEl.textContent = e.message; } + finally { saveNotesBtn.disabled = false; saveNotesBtn.textContent = "Сохранить заметку"; } + }); + } + } + + // === Доп работы (сборщик + менеджер) === + if ((isAssembler && isAssigned) || isMgr) { + const extrasWrap = document.createElement("div"); + extrasWrap.style.cssText = "margin:8px 16px 0;"; + extrasWrap.innerHTML = ` + + + `; + screen.appendChild(extrasWrap); + + const extrasPanel = extrasWrap.querySelector("#extras-panel"); + const extrasBadge = extrasWrap.querySelector("#extras-badge"); + + extrasWrap.querySelector("#extras-toggle-btn").addEventListener("click", async () => { + haptic && haptic("impact"); + const btn = extrasWrap.querySelector("#extras-toggle-btn"); + const isOpen = btn.dataset.open === "1"; + if (isOpen) { + extrasPanel.style.display = "none"; + btn.dataset.open = "0"; + } else { + extrasPanel.style.display = "block"; + btn.dataset.open = "1"; + if (!extrasPanel.dataset.loaded) { + extrasPanel.dataset.loaded = "1"; + await _loadExtras(data.id, extrasPanel, extrasBadge, isMgr || (isAssembler && isAssigned)); + } + } + }); + + // Подгружаем счётчик сразу + _api("assembly_extras_list", { assembly_id: data.id }).then(r => { + const items = r.extras || []; + if (items.length) { + const total = items.reduce((s, x) => s + Number(x.amount || 0), 0); + extrasBadge.textContent = `· ${items.length} поз. ${total > 0 ? "/ " + Math.round(total).toLocaleString("ru-RU") + " ₽" : ""}`; + } + }).catch(() => {}); + } + + // Кнопка «Акт №4 — приёмка товара» + const act4Wrap = document.createElement("div"); + act4Wrap.style.cssText = "margin:8px 16px 0;"; + const act4Btn = document.createElement("button"); + act4Btn.className = "btn-secondary"; + act4Btn.style.cssText = "width:100%;font-size:14px;padding:11px;"; + act4Btn.textContent = "📦 Акт №4 · Приёмка товара"; + act4Btn.addEventListener("click", () => { + haptic && haptic("impact"); + location.hash = `#/assembly/${data.id}/act4`; + }); + act4Wrap.appendChild(act4Btn); + screen.appendChild(act4Wrap); + // Кнопка «Акт сдачи-приёмки» — для менеджера всегда доступна const actWrap = document.createElement("div"); actWrap.style.cssText = "margin:8px 16px 0;"; const actBtn = document.createElement("button"); actBtn.className = "btn-secondary"; actBtn.style.cssText = "width:100%;font-size:14px;padding:11px;"; - actBtn.textContent = "📄 Акт сдачи-приёмки"; + actBtn.textContent = "📄 Акт №3 · Сдача-приёмка сборки"; actBtn.addEventListener("click", () => { haptic && haptic("impact"); location.hash = `#/assembly/${data.id}/contract`; @@ -235,5 +1004,218 @@ const AssemblyDetailScreen = (function () { } } + async function _loadExtras(assemblyId, panel, badge, canEdit) { + panel.innerHTML = `
Загружаем…
`; + try { + const r = await _api("assembly_extras_list", { assembly_id: assemblyId }); + const items = r.extras || []; + + function renderList() { + const total = items.reduce((s, x) => s + Number(x.amount || 0), 0); + if (badge) badge.textContent = items.length + ? `· ${items.length} поз. ${total > 0 ? "/ " + Math.round(total).toLocaleString("ru-RU") + " ₽" : ""}` + : ""; + + panel.innerHTML = ""; + + // Список + const STATUS_LABELS = { + pending: { icon: "⏳", text: "На согласовании", color: "#E67E22" }, + approved: { icon: "✅", text: "Согласовано", color: "#27AE60" }, + rejected: { icon: "❌", text: "Отклонено", color: "#C0392B" }, + }; + + if (items.length) { + const listEl = document.createElement("div"); + listEl.style.cssText = "margin-bottom:10px;"; + for (const item of items) { + const itemEl = document.createElement("div"); + itemEl.style.cssText = "padding:8px 0;border-bottom:1px solid var(--border);"; + const photoUrl = item.receipt_photo + ? `${BACKEND_URL}/api/photo/${encodeURIComponent(assemblyId)}/${encodeURIComponent(item.receipt_photo)}` + : null; + const sl = STATUS_LABELS[item.status] || STATUS_LABELS.pending; + itemEl.innerHTML = ` +
+ ${photoUrl ? ` + + ` : `
`} +
+
${escHtml(item.description || "—")}
+
${item.amount ? Number(item.amount).toLocaleString("ru-RU") + " ₽" : "сумма не указана"}
+
${sl.icon} ${sl.text}
+
${escHtml(item.added_by_name || "")}
+
+ ${canEdit ? `` : ""} +
+ ${(isMgr && item.status === "pending") ? ` +
+ + +
` : ""} + `; + if (canEdit) { + itemEl.querySelector(`[data-del]`)?.addEventListener("click", async (e) => { + if (!confirm("Удалить запись?")) return; + const res = await _api("assembly_extra_delete", { assembly_id: assemblyId, extra_id: e.target.dataset.del }); + if (res.ok) { + const idx = items.findIndex(x => x.id === e.target.dataset.del); + if (idx >= 0) items.splice(idx, 1); + renderList(); + } + }); + } + if (isMgr) { + itemEl.querySelectorAll("[data-act]").forEach(btn => { + btn.addEventListener("click", async () => { + haptic && haptic("impact"); + btn.disabled = true; + const res = await _api("assembly_extra_approve", { + assembly_id: assemblyId, + extra_id: btn.dataset.eid, + action: btn.dataset.act, + }); + if (res.ok) { + const itm = items.find(x => x.id === btn.dataset.eid); + if (itm) itm.status = res.status; + renderList(); + } else btn.disabled = false; + }); + }); + } + listEl.appendChild(itemEl); + } + // Итоги: согласовано + ожидает + const approvedTotal = items.filter(x => x.status === "approved").reduce((s, x) => s + Number(x.amount || 0), 0); + const pendingTotal = items.filter(x => x.status === "pending").reduce((s, x) => s + Number(x.amount || 0), 0); + if (total > 0) { + const totEl = document.createElement("div"); + totEl.style.cssText = "padding:8px 0;font-size:13px;"; + totEl.innerHTML = ` + ${approvedTotal > 0 ? `
+ ✅ Согласовано:${Math.round(approvedTotal).toLocaleString("ru-RU")} ₽ +
` : ""} + ${pendingTotal > 0 ? `
+ ⏳ Ожидает:${Math.round(pendingTotal).toLocaleString("ru-RU")} ₽ +
` : ""} + `; + listEl.appendChild(totEl); + } + panel.appendChild(listEl); + } + + if (!canEdit) return; + + // Форма добавления + const form = document.createElement("div"); + form.innerHTML = ` +
+ Добавить позицию
+ +
+ + +
+ +
+
+ +
+ `; + panel.appendChild(form); + + let _receiptB64 = null; + let _receiptFn = null; + + const receiptInput = form.querySelector("#extra-receipt-input"); + const parseStatus = form.querySelector("#extra-parse-status"); + const receiptPreview = form.querySelector("#extra-receipt-preview"); + + form.querySelector("#extra-receipt-btn").addEventListener("click", () => receiptInput.click()); + + receiptInput.addEventListener("change", async () => { + const file = receiptInput.files[0]; + if (!file) return; + parseStatus.textContent = "Загружаем чек…"; + _receiptFn = file.name; + _receiptB64 = await new Promise((res, rej) => { + const reader = new FileReader(); + reader.onload = e => res(e.target.result); + reader.onerror = rej; + reader.readAsDataURL(file); + }); + receiptPreview.innerHTML = ``; + // AI парсинг суммы + parseStatus.textContent = "🔍 Распознаём сумму…"; + try { + const pr = await _api("assembly_receipt_parse", { photo_b64: _receiptB64 }); + if (pr.amount && pr.amount > 0) { + form.querySelector("#extra-amount").value = Math.round(pr.amount); + parseStatus.textContent = `✅ Сумма распознана: ${Math.round(pr.amount).toLocaleString("ru-RU")} ₽`; + } else { + parseStatus.textContent = "Сумма не распознана — введите вручную"; + } + } catch (e) { + parseStatus.textContent = "Сумма не распознана — введите вручную"; + } + receiptInput.value = ""; + }); + + form.querySelector("#extra-add-btn").addEventListener("click", async () => { + haptic && haptic("impact"); + const desc = form.querySelector("#extra-desc").value.trim(); + const amount = parseFloat(form.querySelector("#extra-amount").value) || 0; + const addStatus = form.querySelector("#extra-add-status"); + if (!desc) { form.querySelector("#extra-desc").style.borderColor = "var(--danger,red)"; return; } + form.querySelector("#extra-desc").style.borderColor = ""; + const addBtn = form.querySelector("#extra-add-btn"); + addBtn.disabled = true; addBtn.textContent = "Сохраняем…"; + try { + const res = await _api("assembly_extra_add", { + assembly_id: assemblyId, + description: desc, + amount, + receipt_b64: _receiptB64 || null, + }); + if (res.ok) { + haptic && haptic("success"); + items.push(res.extra); + form.querySelector("#extra-desc").value = ""; + form.querySelector("#extra-amount").value = ""; + receiptPreview.innerHTML = ""; + parseStatus.textContent = ""; + _receiptB64 = null; + renderList(); + } else { + addStatus.textContent = res.msg || res.error; + } + } catch (e) { addStatus.textContent = e.message; } + finally { addBtn.disabled = false; addBtn.textContent = "Добавить"; } + }); + } + + renderList(); + } catch (e) { + panel.innerHTML = `
Ошибка: ${escHtml(e.message)}
`; + } + } + + async function _loadExpeditorList(select, currentExpTgId) { + try { + const res = await _api("staff_list", { role: "expeditor" }); + const list = res.staff || []; + select.innerHTML = `` + + list.map(u => ``).join(""); + } catch (e) { + select.innerHTML = ``; + } + } + return { mount }; })(); diff --git a/miniapp/assets/client_timeline.js b/miniapp/assets/client_timeline.js new file mode 100644 index 0000000..efd6a99 --- /dev/null +++ b/miniapp/assets/client_timeline.js @@ -0,0 +1,228 @@ +/* ============================================================ + Таймлайн заказа клиента — #/c/assembly/:id/timeline + Доступен клиенту, менеджеру, назначенному сборщику. + ============================================================ */ + +const ClientTimeline = (function () { + + function escHtml(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + + function fmtDate(iso) { + if (!iso) return null; + try { + return new Date(iso).toLocaleDateString("ru-RU", { + day: "numeric", month: "long", + hour: "2-digit", minute: "2-digit", + }); + } catch { return iso.slice(0, 16).replace("T", " "); } + } + + async function _api(path, body = {}) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const res = await fetch(`${BACKEND_URL}/api/${path}`, { + method: "POST", signal: ctrl.signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + initData: tg?.initData || "", + initDataUnsafe: tg?.initDataUnsafe || null, + ...body, + }), + }); + if (!res.ok) throw new Error(`Ошибка сервера (${res.status})`); + return await res.json(); + } catch (e) { + if (e.name === "AbortError") throw new Error("Сервер не отвечает"); + throw e; + } finally { clearTimeout(t); } + } + + const STATUS_COLORS = { + created: "#8e8e8e", + scheduled: "#2980B9", + in_progress: "#F39C12", + done: "#27AE60", + cancelled: "#C0392B", + }; + const STATUS_LABELS = { + created: "Создана", + scheduled: "Запланирована", + in_progress: "В процессе", + done: "Завершена", + cancelled: "Отменена", + }; + + function mount(container, assemblyId) { + container.innerHTML = ""; + document.body.classList.remove("has-bottom-nav"); + const oldNav = document.getElementById("bottom-nav"); + if (oldNav) oldNav.remove(); + + const h = document.createElement("header"); + h.className = "podbor-header"; + h.innerHTML = ` + +
Мой заказ
+
+ `; + h.querySelector(".podbor-back").addEventListener("click", () => { + haptic && haptic("impact"); + history.back(); + }); + container.appendChild(h); + + const screen = document.createElement("div"); + screen.className = "podbor-screen"; + screen.style.cssText = "padding:0 0 48px;"; + screen.innerHTML = `
`; + container.appendChild(screen); + + _api("client_order_timeline", { assembly_id: assemblyId }) + .then(data => { + if (data.error) { + screen.innerHTML = `
${escHtml(data.error)}
`; + return; + } + + screen.innerHTML = ""; + + // Шапка — название + статус + const statusColor = STATUS_COLORS[data.status] || "#8e8e8e"; + const statusText = STATUS_LABELS[data.status] || data.status; + const titleEl = document.createElement("div"); + titleEl.style.cssText = "padding:16px 16px 12px;border-bottom:1px solid var(--border);"; + titleEl.innerHTML = ` +
+ ${escHtml(data.client_name || "Заказ")} +
+ ${data.address ? ` +
+ 📍 ${escHtml(data.address)} +
` : ""} +
+ ${escHtml(statusText)} +
+ `; + screen.appendChild(titleEl); + + // Подсказка прогресса + const milestones = data.milestones || []; + const doneCount = milestones.filter(m => m.done).length; + const total = milestones.length; + const pct = total ? Math.round((doneCount / total) * 100) : 0; + + const progressEl = document.createElement("div"); + progressEl.style.cssText = "padding:12px 16px;border-bottom:1px solid var(--border);"; + progressEl.innerHTML = ` +
+ Выполнено этапов + + ${doneCount} / ${total} + +
+
+
+
+ `; + screen.appendChild(progressEl); + + // Таймлайн + const tlWrap = document.createElement("div"); + tlWrap.style.cssText = "padding:16px;"; + + milestones.forEach((ms, idx) => { + const isLast = idx === milestones.length - 1; + + const row = document.createElement("div"); + row.style.cssText = "display:flex;gap:12px;"; + + // Левая колонка: точка + линия + const lineCol = document.createElement("div"); + lineCol.style.cssText = "display:flex;flex-direction:column;align-items:center;width:36px;flex-shrink:0;"; + + const dot = document.createElement("div"); + dot.style.cssText = ` + width:36px;height:36px;border-radius:50%;flex-shrink:0; + display:flex;align-items:center;justify-content:center; + font-size:17px; + background:${ms.done ? "var(--accent)" : "var(--surface)"}; + border:2px solid ${ms.done ? "var(--accent)" : "var(--border)"}; + `; + dot.textContent = ms.done ? ms.icon : "○"; + + const connLine = document.createElement("div"); + if (!isLast) { + connLine.style.cssText = ` + flex:1;width:2px;min-height:20px;margin:4px 0; + background:${ms.done ? "var(--accent)" : "var(--border)"}; + opacity:${ms.done ? "1" : "0.4"}; + `; + } + + lineCol.appendChild(dot); + lineCol.appendChild(connLine); + + // Правая колонка: контент + const content = document.createElement("div"); + content.style.cssText = ` + padding:4px 0 ${isLast ? "0" : "20px"}; + flex:1;min-width:0; + `; + content.innerHTML = ` +
+ ${escHtml(ms.title)} +
+ ${ms.ts ? ` +
+ ${escHtml(fmtDate(ms.ts) || "")} +
` : (!ms.done ? ` +
ожидается
` : "")} + ${ms.detail ? ` +
+ ${escHtml(ms.detail)} +
` : ""} + `; + + row.appendChild(lineCol); + row.appendChild(content); + tlWrap.appendChild(row); + }); + + screen.appendChild(tlWrap); + + // Кнопка «Назад к карточке сборки» + const backBtn = document.createElement("div"); + backBtn.style.cssText = "margin:0 16px;"; + backBtn.innerHTML = ` + + `; + backBtn.querySelector("button").addEventListener("click", () => { + haptic && haptic("impact"); + history.back(); + }); + screen.appendChild(backBtn); + }) + .catch(e => { + screen.innerHTML = `
Ошибка: ${escHtml(e.message)}
`; + }); + } + + return { mount }; +})(); diff --git a/miniapp/assets/expeditor_dashboard.js b/miniapp/assets/expeditor_dashboard.js new file mode 100644 index 0000000..53d85d9 --- /dev/null +++ b/miniapp/assets/expeditor_dashboard.js @@ -0,0 +1,391 @@ +/* ============================================================ + ExpeditorDashboard #/expeditor + Act4Screen #/expeditor/act/:assemblyId + Signature modes: telegram_otp | canvas + ============================================================ */ +const ExpeditorDashboard = (function () { + "use strict"; + + const ROOM_PRESETS = [ + { group: "Жилые", items: ["Гостиная","Спальня","Детская","Кабинет"] }, + { group: "Кухня", items: ["Кухня","Кухня-гостиная","Столовая"] }, + { group: "Санузел", items: ["Ванная","Санузел","Совмещённый"] }, + { group: "Хранение", items: ["Прихожая","Коридор","Кладовая","Гардероб"] }, + { group: "Другое", items: ["Балкон","Лоджия","Терраса","Доп. помещение"] }, + ]; + + function escHtml(s) { + return String(s == null ? "" : s) + .replace(/&/g,"&").replace(//g,">").replace(/"/g,"""); + } + function el(html) { + const t = document.createElement("template"); + t.innerHTML = html.trim(); + return t.content.firstChild; + } + function fmtDate(s) { + if (!s) return ""; + const d = new Date(s); + return isNaN(d) ? s : d.toLocaleDateString("ru-RU",{day:"2-digit",month:"2-digit",year:"numeric"}); + } + async function _api(path, body) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 30000); + try { + const res = await fetch(BACKEND_URL + "/api/" + path, { + method: "POST", signal: ctrl.signal, + headers: {"Content-Type":"application/json"}, + body: JSON.stringify(Object.assign({ + initData: (typeof Platform !== "undefined" ? Platform.initData : ""), + initDataUnsafe: (typeof Platform !== "undefined" ? Platform.initDataUnsafe : null), + }, body)), + }); + if (!res.ok) throw new Error("HTTP " + res.status); + return await res.json(); + } catch(e) { if (e.name === "AbortError") throw new Error("Таймаут"); throw e; } + finally { clearTimeout(t); } + } + + // ── MAIN LIST ──────────────────────────────────────────────────────────── + async function mount(container) { + container.innerHTML = ""; + document.body.classList.remove("has-bottom-nav"); + const nav = document.getElementById("bottom-nav"); if (nav) nav.remove(); + const icons = window.ICONS || {}; + const header = el( + "
" + + "" + + "
Маршруты и акты
" + + "
" + ); + header.querySelector(".podbor-back").addEventListener("click", () => { + if (typeof haptic !== "undefined") haptic("impact"); + history.back(); + }); + const screen = el("
"); + container.appendChild(header); + container.appendChild(screen); + screen.innerHTML = "
"; + try { + const data = await _api("expeditor_inbox", {}); + if (data.error) throw new Error(data.error); + _renderList(screen, data.assemblies || []); + } catch(e) { + screen.innerHTML = "
Ошибка: " + escHtml(e.message) + "
"; + } + } + + + function _groupByDate(items) { + var today = new Date(); today.setHours(0,0,0,0); + var tom = new Date(today); tom.setDate(tom.getDate()+1); + var groups = {}, order = []; + items.forEach(function(a) { + var d = a.scheduled_at ? new Date(a.scheduled_at) : null; + var label; + if (!d || isNaN(d)) { + label = "Без даты"; + } else { + var day = new Date(d); day.setHours(0,0,0,0); + if (day.getTime() === today.getTime()) label = "Сегодня"; + else if (day.getTime() === tom.getTime()) label = "Завтра"; + else label = day.toLocaleDateString("ru-RU",{day:"2-digit",month:"long",weekday:"short"}); + } + if (!groups[label]) { groups[label] = []; order.push(label); } + groups[label].push(a); + }); + return {groups: groups, order: order}; + } + + function _renderList(screen, items) { + screen.innerHTML = ""; + if (!items.length) { + screen.innerHTML = + "
" + + "
🚚
" + + "
Маршрутов нет
" + + "
Менеджер назначит вас на доставку
"; + return; + } + const pending = items.filter(a => !a.is_signed); + const done = items.filter(a => a.is_signed); + if (pending.length) { + screen.appendChild(el("
К приёмке (" + pending.length + ")
")); + var gd = _groupByDate(pending); + gd.order.forEach(function(label) { + screen.appendChild(el('
📅 ' + label + '
')); + gd.groups[label].forEach(function(a) { screen.appendChild(_card(a, false)); }); + }); + } + if (done.length) { + screen.appendChild(el("
Подписано (" + done.length + ")
")); + done.forEach(a => screen.appendChild(_card(a, true))); + } + } + + function _card(a, signed) { + const badge = signed + ? "✅ Подписан" + : "⏳ Ожидает"; + const card = el( + "
" + + "
" + + "
" + escHtml(a.client_name || "—") + "
" + + badge + "
" + + "
📍 " + escHtml(a.address || "адрес не указан") + "
" + + (a.scheduled_at ? "
📅 " + fmtDate(a.scheduled_at) + "
" : "") + + (signed && a.signed_at ? "
Подписан " + fmtDate(a.signed_at) + "
" : "") + + "
" + ); + card.addEventListener("click", () => { + if (typeof haptic !== "undefined") haptic("selection"); + location.hash = "#/expeditor/act/" + a.id; + }); + return card; + } + + // ── ACT4 SCREEN ────────────────────────────────────────────────────────── + async function mountAct(container, assemblyId) { + container.innerHTML = ""; + document.body.classList.remove("has-bottom-nav"); + const nav = document.getElementById("bottom-nav"); if (nav) nav.remove(); + const icons = window.ICONS || {}; + const header = el( + "
" + + "" + + "
Акт приёмки
" + + "
" + ); + header.querySelector(".podbor-back").addEventListener("click", () => { + if (typeof haptic !== "undefined") haptic("impact"); + history.back(); + }); + const screen = el("
"); + container.appendChild(header); + container.appendChild(screen); + screen.innerHTML = "
"; + try { + const data = await _api("act4_preview", {assembly_id: assemblyId}); + if (data.error) throw new Error(data.error); + _renderAct(screen, data, assemblyId); + } catch(e) { + screen.innerHTML = "
Ошибка: " + escHtml(e.message) + "
"; + } + } + + function _renderAct(screen, act, assemblyId) { + screen.innerHTML = ""; + // Client info + screen.appendChild(el( + "
" + + "
" + escHtml(act.client_name || "—") + "
" + + "
📍 " + escHtml(act.address || "—") + "
" + + (act.client_phone ? "
📞 " + escHtml(act.client_phone) + "
" : "") + + "
Акт № " + escHtml(act.act_num) + " · " + escHtml(act.act_date) + "
" + + "
" + )); + // Already signed banner + if (act.is_signed) { + screen.appendChild(el( + "
" + + "
✅ Акт подписан
" + + "
" + escHtml(act.signed_by_name) + " · " + fmtDate(act.signed_at) + " · " + escHtml(act.signed_via || "") + "
" + + "
" + )); + } + // Items list (existing) + const itemsList = act.items || []; + const itemsWrap = el("
"); + if (itemsList.length) { + itemsList.forEach(item => itemsWrap.appendChild(_itemCard(item))); + } + screen.appendChild(itemsWrap); + // If not signed — show signature section + if (!act.is_signed) { + _renderSignatureSection(screen, assemblyId); + } + } + + function _itemCard(item) { + const cond = item.condition === "damaged" + ? "⚠ Повреждение" + : "✓ OK"; + return el( + "
" + + "
" + escHtml(item.name || "Позиция") + "
" + + "
Кол-во: " + escHtml(String(item.qty || 1)) + "
" + + "
" + cond + "
" + ); + } + + // ── SIGNATURE SECTION ──────────────────────────────────────────────────── + function _renderSignatureSection(screen, assemblyId) { + const wrap = el("
"); + screen.appendChild(wrap); + const tabs = el( + "
" + + "" + + "" + + "
" + ); + const otpPanel = el("
"); + const canvasPanel = el("
"); + _buildOtpPanel(otpPanel, assemblyId, wrap); + _buildCanvasPanel(canvasPanel, assemblyId, wrap); + tabs.querySelectorAll(".sig-tab").forEach(btn => { + btn.addEventListener("click", () => { + const tgt = btn.dataset.tab; + tabs.querySelectorAll(".sig-tab").forEach(b => { + const active = b.dataset.tab === tgt; + b.style.background = active ? "var(--accent)" : "none"; + b.style.color = active ? "#fff" : "var(--ink)"; + b.style.borderColor = active ? "var(--accent)" : "var(--border)"; + }); + [otpPanel, canvasPanel].forEach(p => { + p.style.display = p.dataset.panel === tgt ? "" : "none"; + }); + }); + }); + wrap.appendChild(tabs); + wrap.appendChild(otpPanel); + wrap.appendChild(canvasPanel); + } + + function _buildOtpPanel(panel, assemblyId, wrap) { + panel.innerHTML = ""; + panel.appendChild(el( + "
Бот пришлёт 6-значный код в этот чат. Введите его для подписи.
" + )); + const nameField = el( + "
" + + "
" + ); + panel.appendChild(nameField); + const sendBtn = el( + "" + ); + const codeSection = el("
"); + const codeField = el( + "
" + + "
" + ); + const verifyBtn = el( + "" + ); + const errEl = el("
"); + codeSection.appendChild(codeField); + codeSection.appendChild(verifyBtn); + codeSection.appendChild(errEl); + panel.appendChild(sendBtn); + panel.appendChild(codeSection); + + sendBtn.addEventListener("click", async () => { + sendBtn.disabled = true; sendBtn.textContent = "Отправляем…"; + try { + const data = await _api("act4_request_otp", {assembly_id: assemblyId}); + if (data.error) { sendBtn.textContent = "Ошибка: " + data.error; sendBtn.disabled = false; return; } + sendBtn.textContent = "✅ Код отправлен — проверьте Telegram"; + codeSection.style.display = ""; + panel.querySelector("#otpInput").focus(); + } catch(e) { sendBtn.textContent = "Ошибка: " + e.message; sendBtn.disabled = false; } + }); + + verifyBtn.addEventListener("click", async () => { + const code = panel.querySelector("#otpInput").value.trim(); + const name = panel.querySelector("#otpName").value.trim(); + errEl.textContent = ""; + if (code.length < 6) { errEl.textContent = "Введите 6-значный код"; return; } + verifyBtn.disabled = true; verifyBtn.textContent = "Проверяем…"; + try { + const data = await _api("act4_verify_otp", {assembly_id: assemblyId, code, signed_by_name: name}); + if (data.error) { + const msgs = {invalid_code:"Неверный код",code_expired:"Код устарел, запросите новый",act_not_found:"Акт не найден"}; + errEl.textContent = msgs[data.error] || data.error; + verifyBtn.disabled = false; verifyBtn.textContent = "Подтвердить"; return; + } + if (typeof haptic !== "undefined") haptic("success"); + wrap.innerHTML = "
Акт подписан
" + escHtml(data.signed_by_name) + "
"; + } catch(e) { errEl.textContent = e.message; verifyBtn.disabled = false; verifyBtn.textContent = "Подтвердить"; } + }); + } + + function _buildCanvasPanel(panel, assemblyId, wrap) { + panel.innerHTML = ""; + panel.appendChild(el("
Нарисуйте подпись пальцем на экране.
")); + const nameField = el( + "
" + + "
" + ); + panel.appendChild(nameField); + const canvasWrap = el( + "
" + + "" + + "" + + "
" + ); + panel.appendChild(canvasWrap); + const saveBtn = el( + "" + ); + const errEl = el("
"); + panel.appendChild(saveBtn); + panel.appendChild(errEl); + + // Init canvas after DOM insertion (needs layout) + requestAnimationFrame(() => { + const canvas = panel.querySelector("#sigCanvas"); + if (!canvas) return; + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + const ctx = canvas.getContext("2d"); + ctx.scale(dpr, dpr); + ctx.lineWidth = 2.5; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.strokeStyle = "#1a1a1a"; + let drawing = false, lastX = 0, lastY = 0, hasStrokes = false; + + function pos(e) { + const r = canvas.getBoundingClientRect(); + const src = e.touches ? e.touches[0] : e; + return [src.clientX - r.left, src.clientY - r.top]; + } + canvas.addEventListener("pointerdown", e => { + drawing = true; [lastX, lastY] = pos(e); + ctx.beginPath(); ctx.moveTo(lastX, lastY); + e.preventDefault(); + }); + canvas.addEventListener("pointermove", e => { + if (!drawing) return; + const [x, y] = pos(e); + ctx.lineTo(x, y); ctx.stroke(); + lastX = x; lastY = y; hasStrokes = true; + e.preventDefault(); + }); + canvas.addEventListener("pointerup", () => { drawing = false; }); + canvas.addEventListener("pointerleave",() => { drawing = false; }); + + panel.querySelector("#clearCanvas").addEventListener("click", () => { + ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr); + hasStrokes = false; + }); + + saveBtn.addEventListener("click", async () => { + if (!hasStrokes) { errEl.textContent = "Нарисуйте подпись"; return; } + const name = panel.querySelector("#canvasName").value.trim(); + const b64 = canvas.toDataURL("image/png").replace("data:image/png;base64,", ""); + saveBtn.disabled = true; saveBtn.textContent = "Сохраняем…"; errEl.textContent = ""; + try { + const data = await _api("act4_save_signature", {assembly_id: assemblyId, signature_b64: b64, signed_by_name: name}); + if (data.error) throw new Error(data.error); + if (typeof haptic !== "undefined") haptic("success"); + wrap.innerHTML = "
Акт подписан
" + escHtml(data.signed_by_name) + "
"; + } catch(e) { errEl.textContent = e.message; saveBtn.disabled = false; saveBtn.textContent = "Подписать"; } + }); + }); + } + + return { mount, mountAct }; +})(); diff --git a/miniapp/assets/feedback.js b/miniapp/assets/feedback.js new file mode 100644 index 0000000..6e2d02d --- /dev/null +++ b/miniapp/assets/feedback.js @@ -0,0 +1,404 @@ +/* ============================================================ + Система оценок — виджет + экран #/feedback/my + Используется в: assembly_detail.js, app.js (замерщик, менеджер) + ============================================================ */ + +const FeedbackModule = (function () { + + function escHtml(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + + async function _api(path, body = {}) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const res = await fetch(`${BACKEND_URL}/api/${path}`, { + method: "POST", signal: ctrl.signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + initData: tg?.initData || "", + initDataUnsafe: tg?.initDataUnsafe || null, + ...body, + }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.json(); + } catch (e) { + if (e.name === "AbortError") throw new Error("Сервер не отвечает"); + throw e; + } finally { clearTimeout(t); } + } + + // ── Отрисовка звёздочек (только чтение) ──────────────────────── + function starsHtml(avg, size) { + if (avg == null) return ""; + const sz = size || 14; + const full = Math.floor(avg); + const half = (avg - full) >= 0.4 ? 1 : 0; + const empty = 5 - full - half; + return ( + "★".repeat(full) + + (half ? "½" : "") + + "☆".repeat(empty) + ).split("").map((c, i) => { + const col = i < full ? "#F39C12" : (c === "½" ? "#F39C12" : "#ddd"); + return `${c === "½" ? "★" : c}`; + }).join(""); + } + + // ── Интерактивный виджет звёзд ───────────────────────────────── + // Возвращает {el, getValue()} + function createStarWidget(label, sublabel) { + const wrap = document.createElement("div"); + wrap.style.cssText = "margin-bottom:14px;"; + wrap.innerHTML = ` +
+ ${escHtml(label)} +
+ ${sublabel ? `
${escHtml(sublabel)}
` : ""} +
+ ${[1,2,3,4,5].map(i => ` + + `).join("")} +
+ `; + const row = wrap.querySelector(".fb-stars"); + const btns = [...row.querySelectorAll("button")]; + let selected = 0; + + function paint(n) { + btns.forEach((b, i) => { + b.style.color = i < n ? "#F39C12" : "#ddd"; + }); + } + btns.forEach((btn, idx) => { + btn.addEventListener("mouseenter", () => paint(idx + 1)); + btn.addEventListener("mouseleave", () => paint(selected)); + btn.addEventListener("click", () => { + selected = idx + 1; + row.dataset.value = selected; + haptic && haptic("impact"); + paint(selected); + }); + }); + + return { + el: wrap, + getValue: () => selected, + isValid: () => selected >= 1, + }; + } + + // ── Форма оценки после сборки (для клиента) ──────────────────── + // container — DOM-элемент куда рендерить + // config = { assemblerName, assemblerTgId, managerName, managerTgId, + // assemblyId, onSubmit() } + function mountAssemblyFeedback(container, cfg) { + container.innerHTML = ""; + container.style.cssText = "margin:12px 16px 0;padding:14px;background:var(--surface);" + + "border:2px solid var(--accent);border-radius:14px;"; + + const title = document.createElement("div"); + title.style.cssText = "font-size:14px;font-weight:700;color:var(--ink);margin-bottom:2px;"; + title.textContent = "⭐ Оцените нашу работу"; + const sub = document.createElement("div"); + sub.style.cssText = "font-size:12px;color:var(--muted);margin-bottom:12px;"; + sub.textContent = "Займёт 10 секунд — помогает нам становиться лучше"; + container.appendChild(title); + container.appendChild(sub); + + const wAsm = cfg.assemblerName + ? createStarWidget(`👷 ${cfg.assemblerName}`, "Качество сборки") + : null; + const wMgr = cfg.managerName + ? createStarWidget(`🗂 ${cfg.managerName}`, "Работа менеджера") + : null; + const wSvc = createStarWidget("🏠 Сервис в целом", "Насколько довольны компанией?"); + + if (wAsm) container.appendChild(wAsm.el); + if (wMgr) container.appendChild(wMgr.el); + container.appendChild(wSvc.el); + + // Комментарий + const cmtWrap = document.createElement("div"); + cmtWrap.style.cssText = "margin-bottom:10px;"; + cmtWrap.innerHTML = ` + + `; + container.appendChild(cmtWrap); + + const sendBtn = document.createElement("button"); + sendBtn.className = "btn-primary"; + sendBtn.style.cssText = "width:100%;font-size:14px;padding:11px;"; + sendBtn.textContent = "Отправить оценку"; + const statusEl = document.createElement("div"); + statusEl.style.cssText = "font-size:12px;color:var(--muted);min-height:16px;margin-top:6px;"; + container.appendChild(sendBtn); + container.appendChild(statusEl); + + sendBtn.addEventListener("click", async () => { + // Нужна хотя бы одна оценка + const hasAny = (wAsm && wAsm.isValid()) || (wMgr && wMgr.isValid()) || wSvc.isValid(); + if (!hasAny) { statusEl.textContent = "Поставьте хотя бы одну звезду"; return; } + + haptic && haptic("impact"); + sendBtn.disabled = true; sendBtn.textContent = "Отправляем…"; + const comment = container.querySelector("#fb-comment")?.value.trim() || ""; + + const ratings = []; + if (wAsm && wAsm.isValid()) { + ratings.push({ target_tg_id: cfg.assemblerTgId, target_role: "assembler", + stars: wAsm.getValue() }); + } + if (wMgr && wMgr.isValid()) { + ratings.push({ target_tg_id: cfg.managerTgId, target_role: "manager", + stars: wMgr.getValue() }); + } + if (wSvc.isValid()) { + ratings.push({ target_role: "service", stars: wSvc.getValue(), comment }); + } + + try { + const res = await _api("feedback_submit", { + ref_id: cfg.assemblyId, + ref_type: "assembly", + ratings, + }); + if (res.ok) { + haptic && haptic("success"); + container.innerHTML = ` +
+
🙏
+
Спасибо за оценку!
+
+ Ваш отзыв помогает нам работать лучше +
+
`; + if (cfg.onSubmit) cfg.onSubmit(); + } else { + statusEl.textContent = res.msg || res.error || "Ошибка"; + sendBtn.disabled = false; sendBtn.textContent = "Отправить оценку"; + } + } catch (e) { + statusEl.textContent = e.message; + sendBtn.disabled = false; sendBtn.textContent = "Отправить оценку"; + } + }); + } + + // ── Форма оценки замерщиком → менеджера (после завершения замера) ── + // container — куда рендерить + // cfg = { managerName, managerTgId, measurementId, onSubmit() } + function mountMeasurerFeedback(container, cfg) { + container.innerHTML = ""; + container.style.cssText = "margin:12px 0 0;padding:12px;background:var(--surface);" + + "border:1px solid var(--border);border-radius:12px;"; + + const title = document.createElement("div"); + title.style.cssText = "font-size:13px;font-weight:700;color:var(--ink);margin-bottom:8px;"; + title.textContent = "💬 Оценка заявки от менеджера"; + container.appendChild(title); + + const w = createStarWidget( + `🗂 ${cfg.managerName || "Менеджер"}`, + "Насколько полно была подготовлена заявка?" + ); + container.appendChild(w.el); + + const sendBtn = document.createElement("button"); + sendBtn.className = "btn-secondary"; + sendBtn.style.cssText = "width:100%;font-size:13px;padding:9px;"; + sendBtn.textContent = "Оценить"; + const statusEl = document.createElement("div"); + statusEl.style.cssText = "font-size:11px;color:var(--muted);min-height:14px;margin-top:4px;"; + container.appendChild(sendBtn); + container.appendChild(statusEl); + + sendBtn.addEventListener("click", async () => { + if (!w.isValid()) { statusEl.textContent = "Поставьте оценку"; return; } + haptic && haptic("impact"); + sendBtn.disabled = true; sendBtn.textContent = "…"; + try { + const res = await _api("feedback_submit", { + ref_id: cfg.measurementId, + ref_type: "measurement", + ratings: [{ target_tg_id: cfg.managerTgId, target_role: "manager", stars: w.getValue() }], + }); + if (res.ok) { + container.innerHTML = `
✅ Оценка отправлена
`; + if (cfg.onSubmit) cfg.onSubmit(); + } else { + statusEl.textContent = res.error || "Ошибка"; + sendBtn.disabled = false; sendBtn.textContent = "Оценить"; + } + } catch (e) { + statusEl.textContent = e.message; + sendBtn.disabled = false; sendBtn.textContent = "Оценить"; + } + }); + } + + // ── Форма оценки менеджером → замерщика ──────────────────────── + // cfg = { measurerName, measurerTgId, measurementId, onSubmit() } + function mountManagerFeedback(container, cfg) { + container.innerHTML = ""; + container.style.cssText = "margin:8px 0 0;padding:12px;background:var(--surface);" + + "border:1px solid var(--border);border-radius:12px;"; + + const w = createStarWidget( + `📐 ${cfg.measurerName || "Замерщик"}`, + "Качество замера и документации" + ); + container.appendChild(w.el); + + const sendBtn = document.createElement("button"); + sendBtn.className = "btn-secondary"; + sendBtn.style.cssText = "width:100%;font-size:13px;padding:9px;"; + sendBtn.textContent = "Оценить замерщика"; + const statusEl = document.createElement("div"); + statusEl.style.cssText = "font-size:11px;color:var(--muted);min-height:14px;margin-top:4px;"; + container.appendChild(sendBtn); + container.appendChild(statusEl); + + sendBtn.addEventListener("click", async () => { + if (!w.isValid()) { statusEl.textContent = "Поставьте оценку"; return; } + haptic && haptic("impact"); + sendBtn.disabled = true; sendBtn.textContent = "…"; + try { + const res = await _api("feedback_submit", { + ref_id: cfg.measurementId, + ref_type: "measurement", + ratings: [{ target_tg_id: cfg.measurerTgId, target_role: "measurer", stars: w.getValue() }], + }); + if (res.ok) { + container.innerHTML = `
✅ Оценка отправлена
`; + if (cfg.onSubmit) cfg.onSubmit(); + } else { + statusEl.textContent = res.error || "Ошибка"; + sendBtn.disabled = false; sendBtn.textContent = "Оценить замерщика"; + } + } catch (e) { + statusEl.textContent = e.message; + sendBtn.disabled = false; sendBtn.textContent = "Оценить замерщика"; + } + }); + } + + // ── Экран «Мои оценки» — #/feedback/my ───────────────────────── + function mountMyScreen(container) { + container.innerHTML = ""; + document.body.classList.remove("has-bottom-nav"); + const oldNav = document.getElementById("bottom-nav"); + if (oldNav) oldNav.remove(); + + const h = document.createElement("header"); + h.className = "podbor-header"; + h.innerHTML = ` + +
Мои оценки
+
+ `; + h.querySelector(".podbor-back").addEventListener("click", () => { + haptic && haptic("impact"); history.back(); + }); + container.appendChild(h); + + const screen = document.createElement("div"); + screen.className = "podbor-screen"; + screen.style.cssText = "padding:0 0 48px;"; + screen.innerHTML = `
`; + container.appendChild(screen); + + _api("feedback_my").then(data => { + if (data.error) { + screen.innerHTML = `
${escHtml(data.error)}
`; + return; + } + screen.innerHTML = ""; + + if (!data.total) { + screen.innerHTML = ` +
+ Оценок пока нет.
Они появятся после завершения работ. +
`; + return; + } + + // Общий балл (среднее по всем ролям) + const allVals = (data.aggregated || []).map(a => a.avg); + const overall = allVals.length + ? (allVals.reduce((s, v) => s + v, 0) / allVals.length).toFixed(1) + : null; + + const heroEl = document.createElement("div"); + heroEl.style.cssText = "padding:20px 16px;text-align:center;border-bottom:1px solid var(--border);"; + heroEl.innerHTML = ` +
${overall || "—"}
+
${starsHtml(parseFloat(overall), 18)}
+
${data.total} оценок
+ `; + screen.appendChild(heroEl); + + // По ролям + for (const agg of (data.aggregated || [])) { + const rowEl = document.createElement("div"); + rowEl.style.cssText = "padding:12px 16px;border-bottom:1px solid var(--border);" + + "display:flex;justify-content:space-between;align-items:center;"; + rowEl.innerHTML = ` +
+
${escHtml(agg.label)}
+
${agg.count} оценок
+
+
+
${agg.avg}
+
${starsHtml(agg.avg, 13)}
+
+ `; + screen.appendChild(rowEl); + } + + // Комментарии + if (data.comments && data.comments.length) { + const cmtHead = document.createElement("div"); + cmtHead.className = "section-head"; + cmtHead.style.marginTop = "16px"; + cmtHead.innerHTML = `Комментарии`; + screen.appendChild(cmtHead); + + for (const c of data.comments) { + const cEl = document.createElement("div"); + cEl.style.cssText = "margin:0 16px 8px;padding:10px 12px;background:var(--surface);" + + "border:1px solid var(--border);border-radius:10px;"; + cEl.innerHTML = ` +
+ ${escHtml(c.role || "Клиент")} + ${"★".repeat(parseInt(c.stars)||0)} +
+
${escHtml(c.comment)}
+ `; + screen.appendChild(cEl); + } + } + }).catch(e => { + screen.innerHTML = `
Ошибка: ${escHtml(e.message)}
`; + }); + } + + return { + starsHtml, + createStarWidget, + mountAssemblyFeedback, + mountMeasurerFeedback, + mountManagerFeedback, + mountMyScreen, + }; +})(); diff --git a/miniapp/assets/finance_summary.js b/miniapp/assets/finance_summary.js new file mode 100644 index 0000000..0c1b2d7 --- /dev/null +++ b/miniapp/assets/finance_summary.js @@ -0,0 +1,287 @@ +/* ============================================================ + Финансовая сводка менеджера — #/admin/finance + ============================================================ */ + +const FinanceSummary = (function () { + + function escHtml(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + + function fmt(n) { + if (n == null || n === "") return "—"; + return Number(n).toLocaleString("ru-RU", { maximumFractionDigits: 0 }) + " ₽"; + } + + function fmtDate(iso) { + if (!iso) return "—"; + try { + return new Date(iso).toLocaleDateString("ru-RU", { day: "numeric", month: "short" }); + } catch { return iso.slice(0, 10); } + } + + async function _api(path, body = {}) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 20000); + try { + const res = await fetch(`${BACKEND_URL}/api/${path}`, { + method: "POST", signal: ctrl.signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + initData: tg?.initData || "", + initDataUnsafe: tg?.initDataUnsafe || null, + ...body, + }), + }); + if (!res.ok) throw new Error(`Ошибка сервера (${res.status})`); + return await res.json(); + } catch (e) { + if (e.name === "AbortError") throw new Error("Сервер не отвечает"); + throw e; + } finally { clearTimeout(t); } + } + + let _currentPeriod = "current_month"; + + function mount(container) { + container.innerHTML = ""; + document.body.classList.remove("has-bottom-nav"); + const oldNav = document.getElementById("bottom-nav"); + if (oldNav) oldNav.remove(); + + const h = document.createElement("header"); + h.className = "podbor-header"; + h.innerHTML = ` + +
Финансы
+
+ `; + h.querySelector(".podbor-back").addEventListener("click", () => { + haptic && haptic("impact"); + history.back(); + }); + container.appendChild(h); + + // Period switcher + const periodWrap = document.createElement("div"); + periodWrap.style.cssText = "padding:12px 16px;border-bottom:1px solid var(--border);"; + periodWrap.innerHTML = ` +
+ + + +
+ `; + container.appendChild(periodWrap); + + const screen = document.createElement("div"); + screen.className = "podbor-screen"; + screen.style.cssText = "padding:0 0 48px;"; + container.appendChild(screen); + + periodWrap.querySelectorAll(".fs-period-btn").forEach(btn => { + btn.addEventListener("click", () => { + haptic && haptic("impact"); + _currentPeriod = btn.dataset.p; + periodWrap.querySelectorAll(".fs-period-btn").forEach(b => { + const active = b.dataset.p === _currentPeriod; + b.style.background = active ? "var(--accent)" : "var(--surface)"; + b.style.color = active ? "#fff" : "var(--ink)"; + }); + _load(screen, _currentPeriod); + }); + }); + + _load(screen, _currentPeriod); + } + + function _load(screen, period) { + screen.innerHTML = `
`; + _api("manager_finance_summary", { period }).then(data => { + if (data.error) { + screen.innerHTML = `
${escHtml(data.error)}
`; + return; + } + _render(screen, data); + }).catch(e => { + screen.innerHTML = `
Ошибка: ${escHtml(e.message)}
`; + }); + } + + function _kpiCard(icon, label, value, sub, valueColor) { + return ` +
+
${icon}
+
${escHtml(label)}
+
+ ${escHtml(value)} +
+ ${sub ? `
${escHtml(sub)}
` : ""} +
+ `; + } + + function _render(screen, data) { + screen.innerHTML = ""; + + // Period label + const titleEl = document.createElement("div"); + titleEl.style.cssText = "padding:12px 16px 0;"; + titleEl.innerHTML = ` +
+ ${escHtml(data.period_label)} +
+ `; + screen.appendChild(titleEl); + + // KPI grid — 2 колонки + const kpiGrid = document.createElement("div"); + kpiGrid.style.cssText = "display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:12px 16px;"; + + const noRevMsg = data.revenue_client === 0 ? "нет цен кухни" : null; + + kpiGrid.innerHTML = [ + _kpiCard("📐", "Замеры", `${data.meas_done} / ${data.meas_total}`, + data.meas_total ? `${data.meas_done} выполнено` : "нет замеров"), + _kpiCard("🔨", "Сборки", `${data.asm_done} / ${data.asm_total}`, + data.asm_active > 0 ? `${data.asm_active} в работе` : ""), + _kpiCard("💰", "Выручка", data.revenue_client ? fmt(data.revenue_client) : "—", + noRevMsg || (data.asm_done > 0 ? `${data.asm_done} сдано` : "нет завершённых"), + data.revenue_client > 0 ? "var(--accent)" : undefined), + _kpiCard("👷", "Выплаты мастерам", data.payout_assembler ? fmt(data.payout_assembler) : "—", + data.payout_assembler > 0 ? `от ${fmt(data.revenue_client)}` : noRevMsg), + _kpiCard("📊", "Маржа", data.margin ? fmt(data.margin) : "—", + data.revenue_client > 0 + ? `${Math.round(data.margin / data.revenue_client * 100)}% от выручки` + : noRevMsg, + data.margin > 0 ? "#27AE60" : data.margin < 0 ? "#C0392B" : undefined), + _kpiCard("🧾", "Доп работы", data.extras_total ? fmt(data.extras_total) : "—", + data.extras_count > 0 ? `${data.extras_count} позиций одобрено` : "нет доп работ"), + ].join(""); + + screen.appendChild(kpiGrid); + + // Детали сборок с финансами + const asmList = data.asm_list || []; + if (asmList.length) { + const headEl = document.createElement("div"); + headEl.className = "section-head"; + headEl.style.cssText = "margin-top:8px;"; + headEl.innerHTML = `Сборки с финансами · ${asmList.length}`; + screen.appendChild(headEl); + + for (const asm of asmList) { + const card = document.createElement("div"); + card.style.cssText = "margin:0 16px 8px;padding:10px 12px;background:var(--surface);" + + "border:1px solid var(--border);border-radius:12px;cursor:pointer;"; + card.innerHTML = ` +
+
+
+ ${escHtml(asm.client_name || "Клиент")} +
+
+ ${escHtml(fmtDate(asm.completed_at))} + ${asm.address ? " · " + escHtml(asm.address.slice(0, 35)) + (asm.address.length > 35 ? "…" : "") : ""} +
+
+
+
${fmt(asm.client_pay)}
+
мастеру ${fmt(asm.asm_pay)}
+
+
+
+ + Кухня: ${fmt(asm.kitchen_price)} + + + Маржа: ${fmt(asm.margin)} + +
+ `; + card.addEventListener("click", () => { + haptic && haptic("impact"); + location.hash = `#/c/assembly/${encodeURIComponent(asm.id)}`; + }); + screen.appendChild(card); + } + } else if (data.asm_done === 0) { + const emptyEl = document.createElement("div"); + emptyEl.style.cssText = "margin:16px;text-align:center;color:var(--muted);font-size:13px;"; + emptyEl.textContent = "Завершённых сборок с ценой кухни за этот период нет"; + screen.appendChild(emptyEl); + } + + // Итоговая строка (если есть данные) + if (data.revenue_client > 0) { + const totalEl = document.createElement("div"); + totalEl.style.cssText = "margin:8px 16px 0;padding:12px;background:var(--surface);" + + "border:2px solid var(--accent);border-radius:12px;"; + totalEl.innerHTML = ` +
Итого за период
+
+
+
Выручка
+
+ ${fmt(data.revenue_client)} +
+
+
+
Мастерам
+
+ ${fmt(data.payout_assembler)} +
+
+
+
Маржа
+
+ ${fmt(data.margin)} +
+
+ ${data.extras_total > 0 ? ` +
+
Доп работы
+
+ ${fmt(data.extras_total)} +
+
` : ""} +
+ `; + screen.appendChild(totalEl); + } + } + + return { mount }; +})(); diff --git a/miniapp/assets/invoice.js b/miniapp/assets/invoice.js new file mode 100644 index 0000000..72173c5 --- /dev/null +++ b/miniapp/assets/invoice.js @@ -0,0 +1,215 @@ +/* InvoiceScreen #/master/invoice/:measurementId + Rooms: chip grid (3 cols, mono-add) + list with rename/remove */ +const InvoiceScreen = (function () { + 'use strict'; + + const ROOM_GROUPS = [ + ['Гостиная','Спальня','Детская'], + ['Кабинет','Кухня','Кухня-гостиная'], + ['Ванная','Санузел','Прихожая'], + ['Коридор','Кладовая','Балкон'], + ['Лоджия','Столовая','Доп. помещение'], + ]; + const ALL_CHIPS = ROOM_GROUPS.flat(); + + function escHtml(s){return String(s==null?'':s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');} + function el(html){const t=document.createElement('template');t.innerHTML=html.trim();return t.content.firstChild;} + function fmtMoney(n){return Math.round(n||0).toLocaleString('ru-RU')+' ₽';} + const FEE_BASE=2500,FEE_EXTRA=1000; + function calcTotal(rooms){if(!rooms.length)return 0;return FEE_BASE+Math.max(0,rooms.length-1)*FEE_EXTRA;} + + async function _api(path,body){ + const ctrl=new AbortController();const t=setTimeout(()=>ctrl.abort(),30000); + try{ + const res=await fetch(BACKEND_URL+'/api/'+path,{method:'POST',signal:ctrl.signal, + headers:{'Content-Type':'application/json'}, + body:JSON.stringify(Object.assign({ + initData:(typeof Platform!=='undefined'?Platform.initData:''), + initDataUnsafe:(typeof Platform!=='undefined'?Platform.initDataUnsafe:null), + },body))}); + if(!res.ok)throw new Error('HTTP '+res.status);return await res.json(); + }catch(e){if(e.name==='AbortError')throw new Error('Timeout');throw e;} + finally{clearTimeout(t);} + } + + async function mount(container,measurementId){ + container.innerHTML=''; + document.body.classList.remove('has-bottom-nav'); + const nav=document.getElementById('bottom-nav');if(nav)nav.remove(); + const icons=window.ICONS||{}; + const header=el('
Счёт на оплату
'); + header.querySelector('.podbor-back').addEventListener('click',()=>{if(typeof haptic!=='undefined')haptic('impact');history.back();}); + const screen=el('
'); + container.appendChild(header);container.appendChild(screen); + screen.innerHTML='
'; + try{ + const data=await _api('measurement_detail',{measurement_id:measurementId}); + if(data.error)throw new Error(data.error); + _renderForm(screen,data,measurementId); + }catch(e){screen.innerHTML='
Ошибка: '+escHtml(e.message)+'
';} + } + + function _renderForm(screen,meas,measurementId){ + screen.innerHTML=''; + const existingFee=parseFloat(meas.measurement_fee)||0; + const rooms=[];let nextId=0; + + // Client card + screen.appendChild(el( + '
'+ + '
Клиент
'+ + '
'+escHtml(meas.client_name||'—')+'
'+ + (meas.client_phone?'
'+escHtml(meas.client_phone)+'
':'')+ + (meas.address?'
📍 '+escHtml(meas.address)+'
':'')+ + '
' + )); + + // Already invoiced warning + if(existingFee>0){ + const eb=el('
'+ + '
⚠ Счёт уже выставлен
'+ + '
'+fmtMoney(existingFee)+'
'+ + '
'); + eb.querySelector('#reviseBtn').addEventListener('click',()=>{eb.remove();if(typeof haptic!=='undefined')haptic('impact');}); + screen.appendChild(eb); + } + + // Rooms list + const listWrap=el('
'); + screen.appendChild(listWrap); + + // Total bar + const totalWrap=el('
Итого
0 ₽
'); + screen.appendChild(totalWrap); + const totalEl=totalWrap.querySelector('#totalAmt'); + + // Issue button + const bw=el('
'); + const rb=el('
'); + const issueBtn=bw.querySelector('#issueBtn'); + + // ── CHIP GRID ────────────────────────────────────────────────────────── + const chipLabel=el('
Выберите помещения
'); + const chipGrid=el('
'); + + function updateTotal(){ + totalEl.textContent=rooms.length?fmtMoney(calcTotal(rooms)):'0 ₽'; + issueBtn.disabled=!rooms.length; + issueBtn.style.opacity=rooms.length?'1':'0.45'; + } + + function removeRoom(id){ + const idx=rooms.findIndex(r=>r.id===id);if(idx===-1)return; + rooms.splice(idx,1); + const card=listWrap.querySelector('[data-room-id="'+id+'"]'); + if(card)card.remove(); + updateTotal(); + } + + function addRoomCard(room){ + const isBase=rooms.length===1&&rooms[0].id===room.id; + const price=isBase?FEE_BASE:FEE_EXTRA; + const card=el( + '
'+ + ''+ + ''+fmtMoney(price)+''+ + ''+ + '
' + ); + card.querySelector('input').addEventListener('input',e=>{room.name=e.target.value;}); + card.querySelector('button').addEventListener('click',()=>{ + if(typeof haptic!=='undefined')haptic('selection'); + removeRoom(room.id); + // re-render first card price if needed + _refreshPriceLabels(); + }); + listWrap.appendChild(card); + } + + function _refreshPriceLabels(){ + const cards=listWrap.querySelectorAll('[data-room-id]'); + cards.forEach((card,i)=>{ + const span=card.querySelector('span'); + if(span)span.textContent=fmtMoney(i===0?FEE_BASE:FEE_EXTRA); + }); + } + + // Chip click: count how many rooms have this base name, auto-number + function addFromChip(chipName){ + const existing=rooms.filter(r=>r.name===chipName||r.name.startsWith(chipName+' ')).length; + let name=chipName; + if(existing===1)name=chipName+' 2'; + else if(existing>1)name=chipName+' '+(existing+1); + const room={id:nextId++,name}; + rooms.push(room); + addRoomCard(room); + updateTotal(); + } + + ALL_CHIPS.forEach(chipName=>{ + const chip=el( + '' + ); + chip.addEventListener('click',()=>{ + if(typeof haptic!=='undefined')haptic('selection'); + addFromChip(chipName); + // flash chip + chip.style.background='var(--accent)';chip.style.color='#fff';chip.style.borderColor='var(--accent)'; + setTimeout(()=>{chip.style.background='';chip.style.color='';chip.style.borderColor='';},180); + }); + chipGrid.appendChild(chip); + }); + + // Pre-fill if rooms_count set + const existingCount=parseInt(meas.rooms_count)||0; + for(let i=0;i{ + if(typeof haptic!=='undefined')haptic('impact'); + issueBtn.disabled=true;issueBtn.textContent='Создаём счёт…'; + const names=rooms.map((r,i)=>r.name||(i===0?'Основное помещение':'Помещение '+(i+1))); + _api('invoice_create',{measurement_id:measurementId,rooms_count:rooms.length,rooms_names:names}) + .then(data=>{ + if(data.error)throw new Error(data.error); + _renderResult(rb,data);issueBtn.style.display='none'; + chipLabel.style.display='none';chipGrid.style.display='none'; + }) + .catch(e=>{ + rb.innerHTML='
Ошибка: '+escHtml(e.message)+'
'; + issueBtn.disabled=false;issueBtn.textContent='Выставить счёт'; + }); + }); + updateTotal(); + } + + function _renderResult(container,data){ + container.innerHTML=''; + const qr=data.qr_b64 + ?'
QR для оплаты (СБП)
QR
' + :''; + container.appendChild(el( + '
'+ + '
✅ Счёт выставлен
'+ + '
'+fmtMoney(data.amount)+'
'+ + '
'+ + '
Получатель: '+escHtml(data.ip_name||'—')+'
'+ + '
ИНН: '+escHtml(data.ip_inn||'—')+'
'+ + '
Банк: '+escHtml(data.bank_name||'—')+'
'+ + '
БИК: '+escHtml(data.bic||'—')+'
'+ + '
Р/С: '+escHtml(data.rs||'—')+'
'+ + (data.ks?'
К/С: '+escHtml(data.ks)+'
':'')+ + '
Назначение: '+escHtml(data.purpose||'—')+'
'+ + '
'+qr+'
' + )); + } + + return{mount}; +})(); diff --git a/miniapp/assets/me.js b/miniapp/assets/me.js index aae3549..11a6dc5 100644 --- a/miniapp/assets/me.js +++ b/miniapp/assets/me.js @@ -105,20 +105,44 @@ const MeScreen = (function () { container.appendChild(screen); } + const EQUIPMENT_ITEMS = [ + { key: "tablet", label: "Планшет с ПО для замеров", icon: "📱" }, + { key: "laser_tape", label: "Лазерная рулетка (интеграция с ПО)", icon: "📡" }, + { key: "angle_meter", label: "Угломер", icon: "📐" }, + { key: "tape", label: "Обычная рулетка", icon: "📏" }, + { key: "laser_level", label: "Лазерный уровень", icon: "🔴" }, + ]; + function renderStaffMe(container, me) { const u = me.user || {}; const caps = me.capabilities || {}; + const eqList = me.equipment || []; + const eqOk = me.equipment_ok !== false; + const chips = [ caps.measurer && roleChip("замерщик", "blue"), caps.assembler && roleChip("сборщик", "green"), ].filter(Boolean).join(""); + // Бейдж укомплектованности + const eqBadge = caps.measurer ? ` +
+
+ ${eqOk ? "✅ Укомплектован — допуск к замерам открыт" : "⚠️ Не укомплектован — допуск ограничен"} +
+ ${!eqOk ? `
+ Заполните список оборудования ниже +
` : ""} +
` : ""; + const screen = document.createElement("div"); screen.className = "podbor-screen"; screen.innerHTML = ` ${avatarBlock(u.avatar_initial || "?", u.full_name || "Сотрудник", "")} - -
${chips}
+
${chips}
+ ${eqBadge}
Мои задачи
@@ -128,14 +152,84 @@ const MeScreen = (function () { ${caps.assembler ? `` : ""}
+ + ${caps.measurer ? ` +
+
Оборудование
+
+ Все 5 пунктов обязательны для допуска к замерам +
+
+ +
+
` : ""} `; + screen.querySelectorAll("[data-href]").forEach(btn => { btn.addEventListener("click", () => { haptic && haptic("impact"); location.hash = btn.dataset.href; }); }); + container.appendChild(screen); + + // Чеклист оборудования + if (caps.measurer) { + const checklist = screen.querySelector("#eq-checklist"); + EQUIPMENT_ITEMS.forEach(item => { + const checked = eqList.includes(item.key); + const row = document.createElement("label"); + row.style.cssText = `display:flex;align-items:center;gap:10px;padding:10px 0; + border-bottom:1px solid var(--border);cursor:pointer;`; + row.innerHTML = ` + + ${item.icon} + ${escHtml(item.label)} + `; + checklist.appendChild(row); + }); + + // Кнопка сохранить + const saveBtn = screen.querySelector("#eq-save-btn"); + const statusEl = screen.querySelector("#eq-status"); + saveBtn.addEventListener("click", async () => { + haptic && haptic("impact"); + saveBtn.disabled = true; + saveBtn.textContent = "Сохраняем…"; + const selected = Array.from(checklist.querySelectorAll("input[data-key]:checked")) + .map(cb => cb.dataset.key); + try { + const res = await _fetchWithTimeout(`${BACKEND_URL}/api/equipment_save`, { + initData: typeof Platform !== "undefined" ? Platform.initData : (window.tg?.initData || ""), + initDataUnsafe: typeof Platform !== "undefined" ? Platform.initDataUnsafe : null, + equipment: selected, + }); + if (res.ok) { + statusEl.style.color = res.equipment_ok ? "#27AE60" : "#E74C3C"; + statusEl.textContent = res.equipment_ok + ? "✅ Сохранено. Допуск открыт." + : "⚠️ Сохранено. Заполните все пункты для допуска."; + // Обновить бейдж + const badge = container.querySelector("#equipment-block")?.closest(".podbor-screen")?.querySelector("[style*='Укомплектован']") || + container.querySelector("[style*='допуск']"); + } else { + statusEl.style.color = "#E74C3C"; + statusEl.textContent = "Ошибка: " + (res.error || "неизвестно"); + } + } catch (e) { + statusEl.style.color = "#E74C3C"; + statusEl.textContent = "Ошибка: " + e.message; + } finally { + saveBtn.disabled = false; + saveBtn.textContent = "Сохранить оборудование"; + } + }); + } } function renderClientMe(container, me) { diff --git a/miniapp/assets/measurer_dashboard.js b/miniapp/assets/measurer_dashboard.js new file mode 100644 index 0000000..69ea9bb --- /dev/null +++ b/miniapp/assets/measurer_dashboard.js @@ -0,0 +1,198 @@ +/* ============================================================ + MeasurerDashboard — личная статистика замерщика + #/master/measurer-stats + ============================================================ */ + +const MeasurerDashboard = (function () { + "use strict"; + + function escHtml(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + function el(html) { + const t = document.createElement("template"); + t.innerHTML = html.trim(); + return t.content.firstChild; + } + function fmtMoney(n) { + return Math.round(n || 0).toLocaleString("ru-RU") + " ₽"; + } + function fmtMonth(ym) { + try { + const d = new Date(ym + "-01"); + return d.toLocaleDateString("ru-RU", { month: "long", year: "numeric" }); + } catch { return ym; } + } + + async function _api(path, body = {}) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 30000); + try { + const res = await fetch(`${BACKEND_URL}/api/${path}`, { + method: "POST", signal: ctrl.signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + initData: (typeof Platform !== "undefined" ? Platform.initData : (window.tg?.initData || "")), + initDataUnsafe: (typeof Platform !== "undefined" ? Platform.initDataUnsafe : null), + ...body, + }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.json(); + } catch (e) { + if (e.name === "AbortError") throw new Error("Таймаут"); + throw e; + } finally { clearTimeout(t); } + } + + async function mount(container) { + container.innerHTML = ""; + document.body.classList.remove("has-bottom-nav"); + document.getElementById("bottom-nav")?.remove(); + + const h = el(` +
+ +
Мои замеры
+ +
+ `); + h.querySelector(".podbor-back").addEventListener("click", () => { haptic && haptic("impact"); history.back(); }); + + const yearEl = el(` +
+ + +
+ `); + + const screen = el(`
`); + container.appendChild(h); + container.appendChild(yearEl); + container.appendChild(screen); + + const load = async (year) => { + screen.innerHTML = `
`; + try { + const data = await _api("measurer_earnings", { year }); + if (data.error) { + screen.innerHTML = `
${escHtml(data.error)}
`; + return; + } + _render(screen, data); + } catch (e) { + screen.innerHTML = `
Ошибка: ${escHtml(e.message)}
`; + } + }; + + yearEl.querySelector("#yearSelect").addEventListener("change", function () { load(this.value); }); + h.querySelector("#reloadBtn").addEventListener("click", () => { haptic && haptic("impact"); load(yearEl.querySelector("#yearSelect").value); }); + load("2026"); + } + + function _render(screen, data) { + screen.innerHTML = ""; + + const months = data.months || {}; + const monthKeys = Object.keys(months).sort().reverse(); + + const now = new Date(); + const curYM = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; + const prevD = new Date(now.getFullYear(), now.getMonth() - 1, 1); + const prevYM = `${prevD.getFullYear()}-${String(prevD.getMonth() + 1).padStart(2, "0")}`; + + const curMonth = months[curYM] || null; + const prevMonth = months[prevYM] || null; + + // Hero + screen.appendChild(el(` +
+
Всего за период
+
${escHtml(fmtMoney(data.total_amount))}
+
+ ${escHtml(String(data.total_measurements))} замеров + ${data.total_amount > 0 ? ` · ${escHtml(fmtMoney(data.total_amount / data.total_measurements))} в среднем` : ""} +
+
+ `)); + + // Мини-карточки + if (curMonth || prevMonth) { + const row = el(`
`); + const mini = (label, m) => !m + ? el(`
+
${escHtml(label)}
+
`) + : el(`
+
${escHtml(label)}
+
${escHtml(fmtMoney(m.total_amount))}
+
${m.measurements} замеров · ${m.paid} оплачено
`); + row.appendChild(mini("Текущий месяц", curMonth)); + row.appendChild(mini("Прошлый месяц", prevMonth)); + screen.appendChild(row); + } + + if (!monthKeys.length) { + screen.appendChild(el(` +
+
📐
+
Замеров за этот период нет
+
Данные появятся после выставления счёта за замер
+
+ `)); + return; + } + + // Таблица по месяцам + screen.appendChild(el(`
📅 По месяцам
`)); + const maxAmt = Math.max(...monthKeys.map(k => months[k].total_amount), 1); + + monthKeys.forEach(ym => { + const m = months[ym]; + const pct = Math.round((m.total_amount / maxAmt) * 100); + const isCur = ym === curYM; + + screen.appendChild(el(` +
+
+
+ ${escHtml(fmtMonth(ym))} + ${isCur ? `сейчас` : ""} +
+
+
+ ${m.total_amount > 0 ? escHtml(fmtMoney(m.total_amount)) : "—"} +
+
+ ${m.measurements} замеров · ${m.paid} со счётом +
+
+
+ ${m.total_amount > 0 ? ` +
+
+
` : ""} +
+ `)); + }); + + screen.appendChild(el(` +
+ 💡 Сумма учитывается когда вы выставляете счёт за замер через кнопку «💳 Выставить счёт» в карточке клиента +
+ `)); + screen.appendChild(el(`
`)); + } + + return { mount }; +})(); diff --git a/miniapp/assets/request.js b/miniapp/assets/request.js index ef8ce43..e132703 100644 --- a/miniapp/assets/request.js +++ b/miniapp/assets/request.js @@ -388,8 +388,26 @@ const MeasurementRequest = (function () { sel.innerHTML = `` + measurers.map(m => - `` + `` ).join(""); + + // Алерт при выборе неукомплектованного замерщика + sel.addEventListener("change", () => { + const opt = sel.options[sel.selectedIndex]; + if (opt && opt.dataset.eqOk === "0") { + if (hint) { + hint.style.color = "#E74C3C"; + hint.textContent = "⚠️ Замерщик не укомплектован — не все инструменты заполнены в профиле. Рекомендуется выбрать другого или попросить заполнить профиль."; + } + } else { + if (hint) { + hint.style.color = ""; + hint.textContent = "Замерщик получит уведомление в Telegram"; + } + } + }); } function _renderManagerSelect() { diff --git a/miniapp/assets/staff_clients.js b/miniapp/assets/staff_clients.js index 2be690e..2b421f0 100644 --- a/miniapp/assets/staff_clients.js +++ b/miniapp/assets/staff_clients.js @@ -386,13 +386,91 @@ const StaffClients = (function () { _openScheduleOverlay(m.id, "measurement", c.client_name, () => mount(container)); }); } + + // Кнопка «💳 Выставить счёт» — только для замерщика (is_measurer) + if (data.is_measurer) { + const invoiceBtn = document.createElement("button"); + invoiceBtn.className = "btn-secondary"; + invoiceBtn.style.cssText = "width:100%;padding:10px;font-size:13px;margin-top:8px;"; + invoiceBtn.textContent = "💳 Выставить счёт"; + invoiceBtn.addEventListener("click", () => { + haptic && haptic("impact"); + location.hash = `#/master/invoice/${m.id}`; + }); + mCard.appendChild(invoiceBtn); + } + screen.appendChild(mCard); }); } + // Подбор техники — загружается для первого замера с podbor_lead_id + const measWithPodbor = c.measurements.find(m => m.podbor_lead_id); + const firstMeasId = c.measurements[0]?.id; + const pobdorMeasId = measWithPodbor?.id || firstMeasId; + + if (pobdorMeasId) { + const pobdorSection = el(` +
+
🛒 Подбор техники
+
+
Загружаем…
+
+
+ `); + screen.appendChild(pobdorSection); + + // Асинхронная загрузка подбора + _loadPodbor(pobdorMeasId, pobdorSection.querySelector("#podbor-content")); + } + screen.appendChild(el(`
`)); } + async function _loadPodbor(measurementId, container) { + try { + const res = await _api("assembler_client_podbor", { measurement_id: measurementId }); + if (!res.ok || !res.has_podbor) { + container.innerHTML = `
Подбор техники не назначен
`; + return; + } + const items = res.items || []; + if (!items.length) { + container.innerHTML = `
Варианты ещё не добавлены
`; + return; + } + + const STATUS_LABEL = { draft: "Черновик", sent: "Отправлен", reviewed: "Просмотрен", done: "Выбор сделан" }; + container.innerHTML = ` +
+ Статус: ${escHtml(STATUS_LABEL[res.proposal_status] || res.proposal_status || "—")} + · ${items.length} позиций +
+ `; + + items.forEach(item => { + const card = el(` +
+ ${item.image_url ? `` : + `
`} +
+
${escHtml(item.category)}
+
${escHtml(item.name)}
+ ${item.price ? `
${Number(item.price).toLocaleString("ru-RU")} ₽
` : ""} +
+ ${item.voted ? `
` : ""} +
+ `); + container.appendChild(card); + }); + + } catch (e) { + container.innerHTML = `
Ошибка загрузки подбора
`; + } + } + /* ── Оверлей выбора даты/времени ───────────────────────────── */ function _openScheduleOverlay(itemId, type, clientName, onSuccess) { document.getElementById("schedule-overlay")?.remove(); diff --git a/miniapp/assets/staff_roster.js b/miniapp/assets/staff_roster.js new file mode 100644 index 0000000..2d981b8 --- /dev/null +++ b/miniapp/assets/staff_roster.js @@ -0,0 +1,183 @@ +/* ============================================================ + Обзор команды — #/admin/staff + Доступен: менеджер. + ============================================================ */ + +const StaffRoster = (function () { + + function escHtml(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + + async function _api(path, body = {}) { + const res = await fetch(`${BACKEND_URL}/api/${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null, ...body }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + } + + const ROLE_LABELS = { + assembler: "Сборщик", + measurer: "Замерщик", + expeditor: "Экспедитор", + }; + + function mount(container) { + container.innerHTML = ""; + document.body.classList.remove("has-bottom-nav"); + const oldNav = document.getElementById("bottom-nav"); + if (oldNav) oldNav.remove(); + + const h = document.createElement("header"); + h.className = "podbor-header"; + h.innerHTML = ` + +
Команда
+
+ `; + h.querySelector(".podbor-back").addEventListener("click", () => { + haptic && haptic("impact"); + history.back(); + }); + container.appendChild(h); + + const screen = document.createElement("div"); + screen.className = "podbor-screen"; + screen.style.padding = "0 0 32px"; + screen.innerHTML = `
`; + container.appendChild(screen); + + _api("staff_roster").then(data => { + if (data.error) { + screen.innerHTML = `
${escHtml(data.error)}
`; + return; + } + const staff = data.staff || []; + if (!staff.length) { + screen.innerHTML = `
Сотрудников пока нет
`; + return; + } + + screen.innerHTML = ""; + + // Разбиваем по ролям для отображения + const groups = [ + { key: "assembler", label: "🔨 Сборщики", items: staff.filter(s => s.roles.includes("assembler")) }, + { key: "measurer", label: "📐 Замерщики", items: staff.filter(s => s.roles.includes("measurer") && !s.roles.includes("assembler")) }, + { key: "expeditor", label: "📦 Экспедиторы", items: staff.filter(s => s.roles.includes("expeditor") && !s.roles.includes("assembler") && !s.roles.includes("measurer")) }, + ].filter(g => g.items.length); + + for (const group of groups) { + const headEl = document.createElement("div"); + headEl.className = "section-head"; + headEl.style.marginTop = "16px"; + headEl.innerHTML = `${group.label} · ${group.items.length}`; + screen.appendChild(headEl); + + for (const person of group.items) { + const card = document.createElement("div"); + card.style.cssText = "margin:0 16px 8px;padding:12px;background:var(--surface);border:1px solid var(--border);border-radius:12px;"; + + // Статус-теги + const tags = []; + if (person.on_probation) tags.push(`Испытательный срок`); + if (person.equipment_ok === false) tags.push(`⚠️ Не укомплектован`); + if (person.equipment_ok === true) tags.push(`✅ Оборудование OK`); + + // Нагрузка + const loadBits = []; + if (person.active_assemblies > 0) + loadBits.push(`🔨 ${person.active_assemblies} сборок`); + if (person.month_measures > 0) + loadBits.push(`📐 ${person.month_measures} замеров (мес.)`); + + const rolesStr = person.roles + .filter(r => r !== "manager" && r !== "client") + .map(r => ROLE_LABELS[r] || r) + .join(", "); + + const starsEl = (person.avg_stars != null && typeof FeedbackModule !== "undefined") + ? `
${FeedbackModule.starsHtml(person.avg_stars, 13)} + ${Number(person.avg_stars).toFixed(1)}
` + : ""; + + card.innerHTML = ` +
+
+
${escHtml(person.full_name)}
+
${escHtml(rolesStr)}${person.tg_username ? ` · @${escHtml(person.tg_username)}` : ""}
+ ${starsEl} +
+ ${loadBits.length ? `
${loadBits.join("
")}
` : `
Свободен
`} +
+ ${tags.length ? `
${tags.join("")}
` : ""} + `; + + // Клик → действия (toggle испытательного срока) + if (person.roles.includes("assembler")) { + card.style.cursor = "pointer"; + card.addEventListener("click", () => _showPersonActions(person, card)); + } + + screen.appendChild(card); + } + } + }).catch(e => { + screen.innerHTML = `
Ошибка: ${escHtml(e.message)}
`; + }); + } + + function _showPersonActions(person, card) { + haptic && haptic("impact"); + // Inline toggle испытательного срока прямо на карточке + const existing = card.querySelector(".roster-actions"); + if (existing) { existing.remove(); return; } + + const actEl = document.createElement("div"); + actEl.className = "roster-actions"; + actEl.style.cssText = "margin-top:10px;padding-top:10px;border-top:1px solid var(--border);display:flex;gap:8px;flex-wrap:wrap;"; + + const probBtn = document.createElement("button"); + probBtn.className = person.on_probation ? "btn-primary" : "btn-secondary"; + probBtn.style.cssText = "font-size:12px;padding:7px 12px;"; + probBtn.textContent = person.on_probation ? "✅ Снять испытательный" : "📋 Назначить испытательный"; + + probBtn.addEventListener("click", async (e) => { + e.stopPropagation(); + probBtn.disabled = true; + try { + const res = await _api("assembler_set_probation", { + assembler_tg_id: person.tg_id, + on_probation: !person.on_probation, + }); + if (res.ok) { + person.on_probation = !person.on_probation; + actEl.remove(); + // Перезапускаем экран + mount(document.getElementById("app")); + } + } catch (e) { probBtn.disabled = false; } + }); + + actEl.appendChild(probBtn); + + if (person.tg_username) { + const msgBtn = document.createElement("a"); + msgBtn.href = `https://t.me/${person.tg_username}`; + msgBtn.target = "_blank"; + msgBtn.className = "btn-secondary"; + msgBtn.style.cssText = "font-size:12px;padding:7px 12px;text-decoration:none;display:inline-block;"; + msgBtn.textContent = "✉️ Написать"; + actEl.appendChild(msgBtn); + } + + card.appendChild(actEl); + } + + return { mount }; +})(); diff --git a/miniapp/index.html b/miniapp/index.html index ef8f6f0..0d5f542 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -43,10 +43,10 @@ - + - + @@ -56,9 +56,17 @@ - - + + + + + - + + + + + + diff --git a/tests/expeditor_scenarios.md b/tests/expeditor_scenarios.md new file mode 100644 index 0000000..a8fc90d Binary files /dev/null and b/tests/expeditor_scenarios.md differ diff --git a/tests/test_manager.py b/tests/test_manager.py new file mode 100644 index 0000000..5ca1551 --- /dev/null +++ b/tests/test_manager.py @@ -0,0 +1,300 @@ +""" +Тест кабинета менеджера — полная проверка всех API-модулей +с реальной Telegram-аутентификацией. + +Запуск: python -X utf8 tests/test_manager.py +""" + +import hmac +import hashlib +import json +import time +import sys +import urllib.request +import urllib.parse +import urllib.error +from typing import Any + +# ─── Конфигурация ─────────────────────────────────────────────────────────── +BOT_TOKEN = "8281503057:AAEXmOepY8quH8E3RqOjFbgn7owV1ngnbGA" +ADMIN_TG_ID = 5937498515 +ADMIN_USERNAME = "wasrusgen" +ADMIN_NAME = "Руслан" +BASE_URL = "https://api.wasrusgen1.pro" + +# ─── Генерация валидного initData ─────────────────────────────────────────── + +def make_init_data(tg_id: int, username: str, first_name: str) -> str: + user_obj = json.dumps({ + "id": tg_id, + "first_name": first_name, + "username": username, + "language_code": "ru", + "allows_write_to_pm": True, + }, separators=(",", ":")) + + fields = { + "auth_date": str(int(time.time())), + "user": user_obj, + } + + data_check_string = "\n".join(f"{k}={fields[k]}" for k in sorted(fields)) + secret_key = hmac.new(b"WebAppData", BOT_TOKEN.encode(), hashlib.sha256).digest() + sig = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest() + + params = {**fields, "hash": sig} + return urllib.parse.urlencode(params) + + +INIT_DATA = make_init_data(ADMIN_TG_ID, ADMIN_USERNAME, ADMIN_NAME) + +# ─── HTTP-хелперы ─────────────────────────────────────────────────────────── + +def post(path: str, payload: dict, timeout=15) -> tuple[int, Any]: + url = f"{BASE_URL}{path}" + data = json.dumps(payload).encode() + try: + req = urllib.request.Request( + url, data=data, + headers={"Content-Type": "application/json", "User-Agent": "zov-manager-test/1.0"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as r: + return r.status, json.loads(r.read()) + except urllib.error.HTTPError as e: + try: + return e.code, json.loads(e.read()) + except Exception: + return e.code, {} + except Exception as e: + return None, {"_net_error": str(e)} + + +# ─── Отчёт ────────────────────────────────────────────────────────────────── + +RESULTS: list[tuple[bool, str, str]] = [] # (ok, test_name, detail) + + +def ok(name: str, detail: str = ""): + RESULTS.append((True, name, detail)) + icon = "✅" + print(f" {icon} {name}" + (f" — {detail}" if detail else "")) + + +def fail(name: str, detail: str = ""): + RESULTS.append((False, name, detail)) + icon = "❌" + print(f" {icon} {name}" + (f" — {detail}" if detail else "")) + + +def section(title: str): + print(f"\n{'─'*55}") + print(f" {title}") + print(f"{'─'*55}") + + +# ─── Тесты ────────────────────────────────────────────────────────────────── + +def test_auth(): + section("🔐 Аутентификация") + status, data = post("/api/me", { + "initData": INIT_DATA, + "role": "manager", + }) + if status != 200 or "error" in data: + fail("POST /api/me — вход менеджера", f"status={status} error={data.get('error','?')}") + return False + role = data.get("role", "?") + name = data.get("name", "?") + ok("POST /api/me — вход менеджера", f"role={role} name={name}") + if role not in ("manager", "admin"): + fail("Роль должна быть manager или admin", f"получили: {role}") + return False + ok("Роль подтверждена", role) + return True + + +def test_clients(): + section("👥 Модуль Клиенты") + + # Список клиентов + status, data = post("/api/clients", {"initData": INIT_DATA}) + if status != 200 or "error" in data: + fail("POST /api/clients — список", f"status={status} {data.get('error','')}") + else: + clients = data.get("clients", []) + ok("POST /api/clients — список", f"{len(clients)} клиентов") + + # Проверяем структуру первого клиента + if clients: + c = clients[0] + required_fields = ["client_name", "client_phone"] + missing = [f for f in required_fields if f not in c] + if missing: + fail("Структура клиента — обязательные поля", f"отсутствуют: {missing}") + else: + ok("Структура клиента — обязательные поля", "client_name, client_phone ✓") + + +def test_measurements(): + section("📐 Модуль Замеры") + + # Входящие заявки + status, data = post("/api/measurement_inbox", {"initData": INIT_DATA}) + if status != 200 or "error" in data: + fail("POST /api/measurement_inbox", f"status={status} {data.get('error','')}") + else: + items = data.get("requests", data.get("items", [])) + ok("POST /api/measurement_inbox", f"{len(items)} заявок") + + # Список замеров + status, data = post("/api/measurements", {"initData": INIT_DATA}) + if status != 200 or "error" in data: + fail("POST /api/measurements — список", f"status={status} {data.get('error','')}") + else: + items = data.get("measurements", []) + ok("POST /api/measurements — список", f"{len(items)} замеров") + if items: + m = items[0] + ok("Первый замер — ID", m.get("id", "?")[:8] + "…") + + # Следующий номер + status, data = post("/api/measurement_next_no", {"initData": INIT_DATA}) + if status != 200 or "error" in data: + fail("POST /api/measurement_next_no", f"status={status} {data.get('error','')}") + else: + ok("POST /api/measurement_next_no", f"следующий №{data.get('next_no','?')}") + + +def test_assembly(): + section("🔧 Модуль Сборки") + + status, data = post("/api/assembly_list", {"initData": INIT_DATA}) + if status != 200 or "error" in data: + fail("POST /api/assembly_list", f"status={status} {data.get('error','')}") + else: + items = data.get("assemblies", []) + ok("POST /api/assembly_list", f"{len(items)} сборок") + if items: + a = items[0] + has_status = "status" in a + has_address = "address" in a + if has_status and has_address: + ok("Структура сборки", f"status={a['status']} address={a['address'][:20]}…") + else: + fail("Структура сборки — поля status/address", f"has_status={has_status} has_address={has_address}") + + +def test_proposals(): + section("📋 Модуль Предложения") + + status, data = post("/api/proposal_list", {"initData": INIT_DATA}) + if status != 200 or "error" in data: + fail("POST /api/proposal_list", f"status={status} {data.get('error','')}") + else: + items = data.get("proposals", data.get("items", [])) + ok("POST /api/proposal_list", f"{len(items)} предложений") + + +def test_manager_pending(): + section("📬 Менеджер — входящие задачи") + + status, data = post("/api/manager_pending", {"initData": INIT_DATA}) + if status != 200 or "error" in data: + fail("POST /api/manager_pending", f"status={status} {data.get('error','')}") + else: + count = len(data.get("items", data.get("pending", []))) + ok("POST /api/manager_pending", f"{count} задач в очереди") + + +def test_staff_list(): + section("👷 Сотрудники") + + for role in ["measurer", "assembler"]: + status, data = post("/api/staff_list", {"initData": INIT_DATA, "role": role}) + if status != 200 or "error" in data: + fail(f"POST /api/staff_list role={role}", f"status={status} {data.get('error','')}") + else: + staff = data.get("staff", []) + ok(f"POST /api/staff_list role={role}", f"{len(staff)} сотрудников") + + +def test_shipments_arrivals(): + section("📦 Отгрузки и поступления") + + for endpoint in ["/api/shipments", "/api/arrivals"]: + status, data = post(endpoint, {"initData": INIT_DATA}) + if status != 200 or "error" in data: + fail(f"POST {endpoint}", f"status={status} {data.get('error','')}") + else: + key = "shipments" if "shipments" in endpoint else "arrivals" + items = data.get(key, data.get("items", data.get("rows", []))) + ok(f"POST {endpoint}", f"{len(items)} записей") + + +def test_no_500_on_bad_input(): + section("🛡️ Устойчивость — невалидные данные не дают 500") + + bad_cases = [ + ("/api/measurement_detail", {"initData": INIT_DATA, "measurement_id": "nonexistent-000"}), + ("/api/assembly_detail", {"initData": INIT_DATA, "assembly_id": "nonexistent-000"}), + ("/api/client_create", {"initData": INIT_DATA, "client_name": "", "client_phone": ""}), + ("/api/assembly_create", {"initData": INIT_DATA, "client_name": "", "address": "", "scope_of_work": ""}), + ] + for path, payload in bad_cases: + status, data = post(path, payload) + if status == 500: + fail(f"POST {path} с плохими данными → 500!", f"ответ: {str(data)[:80]}") + else: + ok(f"POST {path} с плохими данными → не 500", f"status={status} error={data.get('error','?')[:40]}") + + +# ─── Main ─────────────────────────────────────────────────────────────────── + +def main(): + print(f"\n{'='*55}") + print(f" ТЕСТ КАБИНЕТА МЕНЕДЖЕРА — @wasrusgen1bot") + print(f" Пользователь: @{ADMIN_USERNAME} (id={ADMIN_TG_ID})") + print(f" Сервер: {BASE_URL}") + print(f"{'='*55}") + + t0 = time.time() + + auth_ok = test_auth() + if not auth_ok: + print("\n🚫 Аутентификация провалена — дальнейшие тесты невозможны.\n") + sys.exit(1) + + test_clients() + test_measurements() + test_assembly() + test_proposals() + test_manager_pending() + test_staff_list() + test_shipments_arrivals() + test_no_500_on_bad_input() + + elapsed = time.time() - t0 + passed = sum(1 for ok_, _, _ in RESULTS if ok_) + failed = len(RESULTS) - passed + + print(f"\n{'='*55}") + print(f" ИТОГО: {passed} ✅ / {failed} ❌ ({elapsed:.1f}s)") + print(f"{'='*55}\n") + + if failed: + print("📋 ЗАМЕЧАНИЯ К УСТРАНЕНИЮ:\n") + for ok_, name, detail in RESULTS: + if not ok_: + print(f" ❌ {name}") + if detail: + print(f" → {detail}") + print() + sys.exit(1) + else: + print("✅ Кабинет менеджера работает штатно.\n") + sys.exit(0) + + +if __name__ == "__main__": + main()