"""Портал: иконки оборудования, карта завода (оборудование и зоны складов), карточка сотрудника.""" from datetime import timedelta from pathlib import Path from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Query, Request from sqlalchemy import or_, select from sqlalchemy.orm import Session from .. import config, logic, media, schemas as S, scope, settings_store as ss from ..db import get_db from ..models import DailyTask, Equipment, Order, Stock, User, Warehouse from ..security import current_user, require_admin, require_engineer from .catalog import commit, get_or_404, user_out from .incidents import equipment_health from .inventory import can_see_stock, stock_rows, wh_out router = APIRouter(tags=["portal"]) ICONS_DIR = Path(__file__).resolve().parent.parent / "static" / "icons" ICON_TITLES = { "pump": "Насос", "motor": "Электродвигатель", "compressor": "Компрессор", "conveyor": "Конвейер", "boiler": "Котёл", "fan": "Вентилятор", "crane": "Кран", "press": "Пресс", "tank": "Ёмкость", "valve": "Задвижка", "generator": "Генератор", "transformer": "Трансформатор", "lathe": "Станок", "forklift": "Погрузчик", "panel": "Электрощит", "hvac": "Кондиционер", "mixer": "Мешалка", "furnace": "Печь", "gear": "Редуктор", "robot": "Робот", } # ---------- иконки оборудования ---------- @router.get("/icons") def builtin_icons(_: User = Depends(current_user)): """Встроенный набор иконок: [{id: "builtin:pump", name, url}]""" out = [] for f in sorted(ICONS_DIR.glob("*.svg")): out.append({"id": "builtin:" + f.stem, "name": ICON_TITLES.get(f.stem, f.stem), "url": f"/static/icons/{f.name}"}) return out @router.put("/equipment/{id}/icon", response_model=S.EquipmentOut) async def upload_icon(id: int, request: Request, db: Session = Depends(get_db), _: User = Depends(require_engineer)): """Загрузить своё фото/картинку как иконку. Тело — сам файл (JPEG/PNG/WebP), до 2 МБ.""" eq = get_or_404(db, Equipment, id) data, _ct, ext = await media.read_image(db, request, max_mb=2) old = eq.icon eq.icon = "file:" + media.save(db, "equipment", data, ext) commit(db) if old.startswith("file:"): media.remove(db, "equipment", old[5:]) return eq @router.get("/equipment/{id}/icon") def get_icon(id: int, db: Session = Depends(get_db), _: User = Depends(current_user)): eq = get_or_404(db, Equipment, id) if not eq.icon.startswith("file:"): raise HTTPException(404, "Своя картинка не загружена") return media.respond(db, "equipment", eq.icon[5:]) # ---------- карта завода ---------- MapKind = Literal["plant", "crane"] def map_image_key(db: Session, kind: str) -> str: """Ключ настройки с картинкой: у карты кранов своя картинка, если загружена, иначе — карта завода.""" return "crane_map_image" if kind == "crane" and ss.get(db, "crane_map_image") else "map_image" @router.get("/map") def plant_map(kind: MapKind = "plant", db: Session = Depends(get_db), u: User = Depends(current_user)): """Всё для отрисовки карты: есть ли картинка, оборудование с координатами и состоянием, зоны складов. kind=crane — отдельная карта кранов (если включена в настройках); тогда краны на карте завода не показываются.""" cranes = scope.crane_map_enabled(db) if kind == "crane" and not cranes: raise HTTPException(404, "Карта кранов выключена в настройках") health = equipment_health(db) stmt = select(Equipment).where(Equipment.is_active.is_(True)).order_by(Equipment.name) if cranes: stmt = stmt.where(Equipment.is_crane.is_(kind == "crane")) cond = scope.equipment_filter(db, u) # оборудование чужих отделов не показываем if cond is not None: stmt = stmt.where(cond) eqs = db.scalars(stmt).all() key = map_image_key(db, kind) return { "kind": kind, "crane_map": cranes, "own_image": key == "crane_map_image", "has_image": bool(ss.get(db, key)), "image_version": ss.get(db, key)[:8], "equipment": [{"id": e.id, "name": e.name, "icon": e.icon, "x": e.map_x, "y": e.map_y, **health.get(e.id, {"state": "ok", "open_incidents": 0, "due": 0, "soon": 0})} for e in eqs], "warehouses": [wh_out(db, w).model_dump() for w in db.scalars(select(Warehouse).where(Warehouse.is_active.is_(True)) .order_by(Warehouse.name)).all()], "can_edit_equipment": bool(u.is_admin), "can_edit_warehouses": bool(u.is_admin or u.is_storekeeper), "can_see_stock": can_see_stock(u), } @router.get("/map/image") def map_image(kind: MapKind = "plant", db: Session = Depends(get_db), _: User = Depends(current_user)): return media.respond(db, "map", ss.get(db, map_image_key(db, kind))) @router.put("/map/image", status_code=204) async def upload_map(request: Request, kind: MapKind = "plant", db: Session = Depends(get_db), _: User = Depends(require_admin)): """Картинка карты завода (план цеха, схема территории) или карты кранов (kind=crane). Только администратор, до 20 МБ.""" data, _ct, ext = await media.read_image(db, request, max_mb=20) key = "crane_map_image" if kind == "crane" else "map_image" old = ss.get(db, key) ss.set_(db, key, media.save(db, "map", data, ext)) commit(db) if old: media.remove(db, "map", old) @router.put("/equipment/{id}/map", response_model=S.EquipmentOut) def place_marker(id: int, data: S.MapMarkerIn, db: Session = Depends(get_db), _: User = Depends(require_admin)): """Поставить иконку оборудования на карту (x, y в % от картинки) или убрать (null).""" eq = get_or_404(db, Equipment, id) if (data.x is None) != (data.y is None): raise HTTPException(422, "Нужны обе координаты или ни одной") eq.map_x, eq.map_y = data.x, data.y commit(db) return eq # ---------- карточка сотрудника ---------- def can_edit_profile(me: User, target: User) -> bool: if me.id == target.id or me.is_admin: return True return me.is_moderator and not target.is_admin @router.get("/users/{id}/card") def user_card(id: int, db: Session = Depends(get_db), me: User = Depends(current_user)): """ФИО, фото, должность, график (ближайшие смены), что умеет, текущие задачи и наряды, выданный инвентарь.""" u = get_or_404(db, User, id) now = config.now() sch = u.position.schedule if u.position else None shifts = [] if sch: for i in range(14): sh = logic.shift_starting(sch, now.date() + timedelta(days=i)) if sh and sh[1] > now: shifts.append({"start": config.iso(sh[0]), "end": config.iso(sh[1]), "label": sh[2]}) if len(shifts) >= 7: break tasks = db.scalars(select(DailyTask).where(DailyTask.assignee_id == id, DailyTask.status.not_in(("done", "cancelled"))) .order_by(DailyTask.day, DailyTask.id)).all() orders = db.scalars(select(Order).where(Order.status.in_(("new", "in_progress", "review")), or_(Order.executor_id == id, Order.assignee_user_id == id)) .order_by(Order.due_at, Order.id).limit(50)).all() show_inv = me.id == id or can_see_stock(me) return { "user": user_out(u).model_dump(mode="json"), "schedule": {"name": sch.name if sch else None, "shifts": shifts, "always": u.position is not None and sch is None}, "tasks": [{"id": t.id, "title": t.title, "day": t.day.isoformat(), "status": t.status} for t in tasks], "orders": [{"id": o.id, "title": o.title, "status": o.status, "due_at": config.iso(o.due_at)} for o in orders], "inventory": [r.model_dump(mode="json") for r in stock_rows(db, select(Stock).where(Stock.user_id == id))] if show_inv else None, "can_edit": can_edit_profile(me, u), } @router.patch("/users/{id}/profile", response_model=S.UserOut) def update_profile(id: int, data: S.ProfileIn, db: Session = Depends(get_db), me: User = Depends(current_user)): """«Что умею» и телефон: сам сотрудник, модератор или администратор.""" u = get_or_404(db, User, id) if not can_edit_profile(me, u): raise HTTPException(403, "Нет прав изменять карточку") for k, v in data.model_dump(exclude_unset=True).items(): if v is not None: setattr(u, k, v.strip()) commit(db) return user_out(u) @router.put("/users/{id}/photo", response_model=S.UserOut) async def upload_user_photo(id: int, request: Request, db: Session = Depends(get_db), me: User = Depends(current_user)): u = get_or_404(db, User, id) if not can_edit_profile(me, u): raise HTTPException(403, "Нет прав изменять карточку") data, _ct, ext = await media.read_image(db, request, max_mb=5) old = u.photo u.photo = media.save(db, "users", data, ext) commit(db) media.remove(db, "users", old) return user_out(u) @router.delete("/users/{id}/photo", response_model=S.UserOut) def delete_user_photo(id: int, db: Session = Depends(get_db), me: User = Depends(current_user)): u = get_or_404(db, User, id) if not can_edit_profile(me, u): raise HTTPException(403, "Нет прав изменять карточку") media.remove(db, "users", u.photo) u.photo = "" commit(db) return user_out(u) @router.get("/users/{id}/photo") def get_user_photo(id: int, db: Session = Depends(get_db), _: User = Depends(current_user)): return media.respond(db, "users", get_or_404(db, User, id).photo)