diff --git a/backend-py/app/main.py b/backend-py/app/main.py
index f534472..4774993 100644
--- a/backend-py/app/main.py
+++ b/backend-py/app/main.py
@@ -105,6 +105,8 @@ async def _dispatch_post(request: Request):
"podbor": _handle_podbor,
"clients": _handle_clients,
"lead": _handle_lead,
+ "grant_role": _handle_grant_role,
+ "staff_list": _handle_staff_list,
"ping": lambda b: {"pong": True, "time": _now_iso()},
"seed_admin": lambda b: _handle_seed_admin(),
"test_ai": lambda b: _handle_test_ai(),
@@ -177,6 +179,20 @@ async def api_measurement_detail(request: Request):
return _handle_measurement_detail(body)
+@app.post("/api/grant_role")
+async def api_grant_role(request: Request):
+ """Админ выдаёт роль другому пользователю.
+ body: {initData, target_tg_id, role: 'measurer'|'assembler'|'manager'|'client', action: 'grant'|'revoke'}"""
+ body = await _safe_json(request)
+ return _handle_grant_role(body)
+
+
+@app.post("/api/staff_list")
+async def api_staff_list(request: Request):
+ body = await _safe_json(request)
+ return _handle_staff_list(body)
+
+
@app.get("/api/photo/{measurement_id}/{filename}")
async def api_photo(measurement_id: str, filename: str):
"""Отдаёт фото замера. Защита от path traversal — только разрешённые id и имена."""
@@ -393,16 +409,48 @@ def _handle_me(body: dict[str, Any]) -> dict[str, Any]:
tg_user = auth["user"]
tg_id = tg_user["id"]
start_param = body.get("startParam") or auth.get("start_param")
- explicit_role = body.get("role") if body.get("role") in ("manager", "client") else None
+ explicit_role = body.get("role") if body.get("role") in ("manager", "client", "staff") else None
user = sheets.get_or_create_user(tg_user, start_param, explicit_role)
+ roles = sheets.parse_roles(user.get("role", ""))
- if user.get("role") == "manager":
+ # Staff (замерщик / сборщик) — отдельный кабинет, доступен только тем у кого роль выдана
+ if explicit_role == "staff":
+ has_measurer = "measurer" in roles
+ has_assembler = "assembler" in roles
+ if not (has_measurer or has_assembler):
+ return {
+ "role": "staff",
+ "roles": roles,
+ "error": "no_staff_role",
+ "user": {
+ "tg_id": tg_id,
+ "full_name": user.get("full_name", ""),
+ "avatar_initial": _initial(user.get("full_name") or tg_user.get("first_name", "")),
+ },
+ }
+ full_name = user.get("full_name", "") or tg_user.get("first_name", "")
+ return {
+ "role": "staff",
+ "roles": roles,
+ "user": {
+ "tg_id": tg_id,
+ "full_name": full_name,
+ "avatar_initial": _initial(full_name),
+ },
+ "capabilities": {
+ "measurer": has_measurer,
+ "assembler": has_assembler,
+ },
+ }
+
+ if "manager" in roles:
m = sheets.get_manager_profile(tg_id) or {
"full_name": user.get("full_name", ""), "salon": "",
"is_zov_employee": False, "status": "lapsed", "active_until": None,
}
return {
"role": "manager",
+ "roles": roles,
"user": {
"tg_id": tg_id,
"full_name": m.get("full_name") or user.get("full_name", ""),
@@ -429,6 +477,7 @@ def _handle_me(body: dict[str, Any]) -> dict[str, Any]:
full_name = c.get("full_name") or user.get("full_name", "")
return {
"role": "client",
+ "roles": roles,
"user": {
"tg_id": tg_id,
"full_name": full_name,
@@ -764,6 +813,63 @@ def _handle_lead(body: dict[str, Any]) -> dict[str, Any]:
}
+def _handle_grant_role(body: dict[str, Any]) -> dict[str, Any]:
+ """Только админ может выдавать/отзывать роли."""
+ cfg = get_config()
+ auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
+ if not auth or not auth.get("user"):
+ return {"error": "invalid_init_data"}
+ tg_id = auth["user"]["id"]
+ if int(tg_id) != int(cfg.admin_tg_id):
+ return {"error": "admin_only"}
+
+ target = body.get("target_tg_id")
+ role = body.get("role", "").strip()
+ action = body.get("action", "grant")
+ if not target or not role:
+ return {"error": "missing_fields"}
+ try:
+ target_int = int(target)
+ except (TypeError, ValueError):
+ return {"error": "bad_target"}
+
+ if role not in sheets.VALID_ROLES:
+ return {"error": "unknown_role", "valid": sorted(sheets.VALID_ROLES)}
+
+ if action == "revoke":
+ changed = sheets.revoke_role(target_int, role)
+ else:
+ changed = sheets.grant_role(target_int, role)
+
+ sheets.log_event("role_changed", tg_id, {"target": target_int, "role": role, "action": action, "changed": changed})
+
+ updated_user = sheets.find_user(target_int) or {}
+ return {
+ "ok": True,
+ "target_tg_id": target_int,
+ "changed": changed,
+ "roles": sheets.parse_roles(updated_user.get("role", "")),
+ }
+
+
+def _handle_staff_list(body: dict[str, Any]) -> dict[str, Any]:
+ """Список сотрудников с указанной ролью — для dropdown «выбрать замерщика»."""
+ 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"}
+
+ role = (body.get("role") or "").strip()
+ if role not in sheets.VALID_ROLES:
+ return {"error": "unknown_role"}
+
+ return {"ok": True, "role": role, "staff": sheets.list_users_with_role(role)}
+
+
def _handle_measurement_detail(body: dict[str, Any]) -> dict[str, Any]:
"""Возвращает один замер целиком — для детальной страницы и печати."""
cfg = get_config()
diff --git a/backend-py/app/sheets.py b/backend-py/app/sheets.py
index ebbfd0d..f3dea50 100644
--- a/backend-py/app/sheets.py
+++ b/backend-py/app/sheets.py
@@ -108,7 +108,87 @@ def find_user(tg_id: int) -> dict[str, Any] | None:
return None
full_name = (f"{row.get('first_name', '')} {row.get('last_name', '')}".strip()
or row.get("tg_username", ""))
- return {**row, "full_name": full_name}
+ roles = parse_roles(row.get("role", ""))
+ return {**row, "full_name": full_name, "roles": roles}
+
+
+# ---- Multi-role helpers ----
+
+VALID_ROLES = {"manager", "client", "measurer", "assembler"}
+
+
+def parse_roles(role_str: str) -> list[str]:
+ """Парсит CSV-роли: 'manager,measurer' → ['manager', 'measurer'].
+ Старые однострочные значения тоже работают: 'manager' → ['manager']."""
+ if not role_str:
+ return []
+ parts = [p.strip() for p in str(role_str).split(",") if p.strip()]
+ return [p for p in parts if p in VALID_ROLES]
+
+
+def has_role(user: dict[str, Any] | None, role: str) -> bool:
+ if not user:
+ return False
+ return role in parse_roles(user.get("role", ""))
+
+
+def primary_role(user: dict[str, Any] | None) -> str:
+ """Первая (главная) роль для legacy-кода: manager > measurer > assembler > client."""
+ if not user:
+ return ""
+ roles = parse_roles(user.get("role", ""))
+ for r in ("manager", "measurer", "assembler", "client"):
+ if r in roles:
+ return r
+ return roles[0] if roles else ""
+
+
+def grant_role(tg_id: int, role: str) -> bool:
+ """Добавляет роль пользователю (если её ещё нет). Возвращает True если что-то изменилось."""
+ if role not in VALID_ROLES:
+ return False
+ user = find_user(tg_id)
+ if not user:
+ return False
+ current = parse_roles(user.get("role", ""))
+ if role in current:
+ return False
+ current.append(role)
+ return update_cell_by_key("Users", "tg_id", tg_id, "role", ",".join(current))
+
+
+def revoke_role(tg_id: int, role: str) -> bool:
+ user = find_user(tg_id)
+ if not user:
+ return False
+ current = parse_roles(user.get("role", ""))
+ if role not in current:
+ return False
+ current.remove(role)
+ new_val = ",".join(current) if current else "client" # fallback роль
+ return update_cell_by_key("Users", "tg_id", tg_id, "role", new_val)
+
+
+def list_users_with_role(role: str) -> list[dict[str, Any]]:
+ """Все пользователи, у которых есть указанная роль (для dropdown «выбрать замерщика»)."""
+ s = sheet("Users")
+ rows = s.get_all_values()
+ if not rows:
+ return []
+ headers = rows[0]
+ out: list[dict[str, Any]] = []
+ for r in rows[1:]:
+ row = dict(zip(headers, r + [""] * (len(headers) - len(r))))
+ 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", ""))
+ out.append({
+ "tg_id": row.get("tg_id"),
+ "full_name": full_name,
+ "tg_username": row.get("tg_username", ""),
+ "roles": parse_roles(row.get("role", "")),
+ })
+ return out
def get_or_create_user(tg_user: dict[str, Any], start_param: str | None,
@@ -122,14 +202,18 @@ def get_or_create_user(tg_user: dict[str, Any], start_param: str | None,
if existing:
update_cell_by_key("Users", "tg_id", tg_id, "last_seen_at", now_str)
- # Админ всегда manager
- if tg_id == admin_id and existing.get("role") != "manager":
- update_cell_by_key("Users", "tg_id", tg_id, "role", "manager")
+ # Админ всегда имеет роль manager (могут быть и другие)
+ if tg_id == admin_id and not has_role(existing, "manager"):
+ grant_role(tg_id, "manager")
ensure_admin_manager(tg_user)
- existing["role"] = "manager"
- elif explicit_role and tg_id != admin_id and existing.get("role") != explicit_role:
- update_cell_by_key("Users", "tg_id", tg_id, "role", explicit_role)
- existing["role"] = explicit_role
+ existing["roles"] = parse_roles((find_user(tg_id) or {}).get("role", ""))
+ # explicit_role из query (?role=manager|client|staff) — не перетираем уже выданные роли,
+ # только добавляем если человек впервые открыл эту секцию
+ elif explicit_role and explicit_role in VALID_ROLES and not has_role(existing, explicit_role):
+ # client/manager — стандартные роли любой может получить через выбор в боте
+ if explicit_role in ("manager", "client"):
+ grant_role(tg_id, explicit_role)
+ existing["roles"] = parse_roles((find_user(tg_id) or {}).get("role", ""))
return existing
# Новый пользователь
@@ -148,7 +232,7 @@ def get_or_create_user(tg_user: dict[str, Any], start_param: str | None,
tg_user.get("username", ""),
tg_user.get("first_name", ""),
tg_user.get("last_name", ""),
- role,
+ role, # хранится как CSV; для новых = одна роль
now_str,
now_str,
invite_code,
diff --git a/bot/handlers/start.py b/bot/handlers/start.py
index d38b1cc..a112ee6 100644
--- a/bot/handlers/start.py
+++ b/bot/handlers/start.py
@@ -36,7 +36,7 @@ def _wapp(miniapp_url: str, role: str) -> WebAppInfo:
# ============================================================
-# Reply keyboard — выбор роли. Оба кнопки сразу открывают MiniApp.
+# Reply keyboard — выбор роли. Три кнопки, все WebApp.
# ============================================================
def role_choice_kb(miniapp_url: str) -> ReplyKeyboardMarkup:
@@ -46,6 +46,9 @@ def role_choice_kb(miniapp_url: str) -> ReplyKeyboardMarkup:
KeyboardButton(text="👤 Я менеджер", web_app=_wapp(miniapp_url, "manager")),
KeyboardButton(text="🏠 Я клиент", web_app=_wapp(miniapp_url, "client")),
],
+ [
+ KeyboardButton(text="🔧 Я сотрудник", web_app=_wapp(miniapp_url, "staff")),
+ ],
],
resize_keyboard=True,
is_persistent=True,
@@ -61,7 +64,8 @@ def role_choice_kb(miniapp_url: str) -> ReplyKeyboardMarkup:
async def cmd_start(message: Message, config: Config) -> None:
await message.answer(
"👋 Здравствуйте, я бот-помощник от Руслана ВАСИЛЬЕВА.\n\n"
- "Выберите, кто вы — кабинет откроется одним тапом.",
+ "Выберите, кто вы — кабинет откроется одним тапом.\n\n"
+ "«Сотрудник» — для замерщиков и сборщиков ЗОВ. Если вы менеджер или клиент — выбирайте свою роль.",
reply_markup=role_choice_kb(config.miniapp_url),
)
@@ -74,3 +78,21 @@ async def cmd_menu(message: Message, config: Config) -> None:
@router.message(Command("hide"))
async def cmd_hide(message: Message) -> None:
await message.answer("Клавиатура скрыта. Вернуть — /menu", reply_markup=ReplyKeyboardRemove())
+
+
+# ============================================================
+# /whoami — сотрудник присылает свой ID куратору, чтобы тот выдал роль
+# ============================================================
+
+@router.message(Command("whoami"))
+async def cmd_whoami(message: Message) -> None:
+ user = message.from_user
+ if not user:
+ return
+ await message.answer(
+ f"Ваш Telegram ID: {user.id}\n"
+ f"Username: @{user.username or '—'}\n"
+ f"Имя: {user.first_name or ''} {user.last_name or ''}".strip()
+ + "\n\n"
+ "Перешлите это сообщение куратору @wasrusgen чтобы вам выдали роль замерщика/сборщика."
+ )
diff --git a/miniapp/assets/app.js b/miniapp/assets/app.js
index 8cd5aad..32a9a2c 100644
--- a/miniapp/assets/app.js
+++ b/miniapp/assets/app.js
@@ -343,6 +343,72 @@ function buildMenu(items) {
return menu;
}
+/* ----------------- Staff (замерщик / сборщик) ----------------- */
+function renderStaff(me) {
+ app.innerHTML = "";
+
+ if (me.error === "no_staff_role") {
+ app.appendChild(el(`
+
Чтобы получить роль замерщика или сборщика — отправьте куратору ваш Telegram ID.
+${me.user?.tg_id || "—"}
+ В боте отправьте /whoami и перешлите ответ
+ @wasrusgen.
+