Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
"""Портал: иконки оборудования, карта завода (оборудование и зоны складов), карточка сотрудника."""
|
||||
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)
|
||||
Reference in New Issue
Block a user