Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android

This commit is contained in:
darkorion
2026-10-13 09:09:53 +03:00
commit 67e411eea8
246 changed files with 23152 additions and 0 deletions
+205
View File
@@ -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)