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

This commit is contained in:
darkorion
2026-10-13 09:09:53 +03:00
commit c298f5a830
247 changed files with 23200 additions and 0 deletions
View File
+86
View File
@@ -0,0 +1,86 @@
"""AI-помощник: статус, чат (любой вошедший пользователь, если включено), настройки и проверка (администратор)."""
from typing import Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from .. import assistant as A, settings_store as ss
from ..db import get_db
from ..models import User
from ..security import current_user, require_portal_admin as require_admin
from .catalog import commit
router = APIRouter(prefix="/assistant", tags=["assistant"])
class Msg(BaseModel):
role: Literal["user", "assistant"]
content: str = Field(max_length=4000)
class ChatIn(BaseModel):
messages: list[Msg] = Field(min_length=1, max_length=40)
class ConfigIn(BaseModel):
enabled: Optional[bool] = None
provider: Optional[Literal["openai", "anthropic", "gigachat"]] = None
base_url: Optional[str] = Field(default=None, max_length=300)
model: Optional[str] = Field(default=None, max_length=200)
api_key: Optional[str] = Field(default=None, max_length=2000) # пустая строка — удалить ключ; null — не менять
scope: Optional[str] = Field(default=None, max_length=2000) # доп. указания помощнику от администратора
@router.get("/status")
def status(db: Session = Depends(get_db), _: User = Depends(current_user)):
return {"enabled": A.ready(db)}
@router.post("/chat")
async def chat(data: ChatIn, request: Request, db: Session = Depends(get_db), u: User = Depends(current_user)):
token = request.headers.get("authorization", "")[7:]
try:
return await A.chat(request.app, db, u, token, [m.model_dump() for m in data.messages])
except A.AssistantError as e:
raise HTTPException(429 if "Слишком много" in str(e) else 503 if "выключен" in str(e) or "провайдер" in str(e).lower() else 422, str(e))
@router.get("/config")
def get_config(db: Session = Depends(get_db), _: User = Depends(require_admin)):
return A.public_cfg(db)
@router.put("/config")
def put_config(data: ConfigIn, db: Session = Depends(get_db), _: User = Depends(require_admin)):
d = data.model_dump(exclude_unset=True)
if d.get("base_url") and not d["base_url"].strip().lower().startswith(("https://", "http://")):
raise HTTPException(422, "Адрес провайдера должен начинаться с https:// (или http:// для локальной модели)")
for k, v in d.items():
if v is None:
continue
ss.set_(db, "assistant_" + k, ("1" if v else "0") if isinstance(v, bool) else str(v).strip())
commit(db)
c = A.cfg(db)
if c["enabled"] and (not c["model"] or not (c["api_key"] or "localhost" in c["base_url"] or "127.0.0.1" in c["base_url"])):
ss.set_(db, "assistant_enabled", "0")
commit(db)
raise HTTPException(422, "Для включения укажите модель и ключ API (без ключа — только локальная модель)")
return A.public_cfg(db)
@router.post("/test")
async def test(request: Request, db: Session = Depends(get_db), u: User = Depends(require_admin)):
"""Проверка подключения: короткий вопрос по сервису и вопрос не по теме (должен быть отклонён)."""
c = A.cfg(db)
if not c["model"]:
raise HTTPException(422, "Укажите модель")
try:
token = request.headers.get("authorization", "")[7:]
if not A.ready(db): # проверяем и до включения: временно считаем помощника включённым
c["enabled"] = True
ok = await A.chat(request.app, db, u, token, [{"role": "user", "content": "Какие у меня задания на сегодня и где их посмотреть?"}], cfg_override=c)
off = await A.complete(c, A.system_prompt(u, c["scope"]), [{"role": "user", "content": "Напиши стихотворение про весну"}])
except A.AssistantError as e:
raise HTTPException(502, str(e))
return {"ok": True, "answer": ok["reply"][:600], "tools": ok["tools"], "offtopic_filtered": off.strip().upper().startswith("OFFTOPIC")}
+85
View File
@@ -0,0 +1,85 @@
"""Резервные копии (только администратор): список, создание, скачивание, загрузка, восстановление, удаление."""
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from .. import backup as B, config
from ..db import get_db
from ..models import User
from ..security import require_admin
router = APIRouter(prefix="/backups", tags=["backups"])
MAX_UPLOAD_MB = 2048
class CreateIn(BaseModel):
include_media: bool = False
note: str = Field(default="", max_length=200)
class RestoreIn(BaseModel):
confirm: bool = False
def _err(e: B.BackupError):
return HTTPException(409 if "демонстрацион" in str(e) else 422, str(e))
@router.get("")
def list_(db: Session = Depends(get_db), _: User = Depends(require_admin)):
return {"items": B.list_backups(), "folder": str(B.folder().resolve()),
"auto_hours": int(B.setting(db, "backup_auto_hours") or 0), "keep": int(B.setting(db, "backup_keep") or 14),
"media": B.setting(db, "backup_media") == "1", "next_auto": B.next_auto(db), "now": config.iso(config.now())}
@router.post("", status_code=201)
def create(data: CreateIn, db: Session = Depends(get_db), _: User = Depends(require_admin)):
try:
p = B.create(db, include_media=data.include_media, note=data.note.strip() or "ручная копия")
except B.BackupError as e:
raise _err(e)
return next(x for x in B.list_backups() if x["name"] == p.name)
@router.get("/{name}")
def download(name: str, _: User = Depends(require_admin)):
try:
f = B.path_of(name)
except B.BackupError as e:
raise HTTPException(404, str(e))
return FileResponse(f, media_type="application/zip", filename=f.name)
@router.delete("/{name}", status_code=204)
def delete(name: str, _: User = Depends(require_admin)):
try:
B.path_of(name).unlink()
except B.BackupError as e:
raise HTTPException(404, str(e))
@router.post("/upload", status_code=201)
async def upload(request: Request, _: User = Depends(require_admin)):
"""Загрузить файл копии (тело запроса — zip), например с другого сервера, чтобы потом восстановить."""
data = b""
async for chunk in request.stream():
data += chunk
if len(data) > MAX_UPLOAD_MB * 1024 * 1024:
raise HTTPException(413, "Файл слишком большой")
try:
p = B.save_upload(data)
except B.BackupError as e:
raise _err(e)
return next(x for x in B.list_backups() if x["name"] == p.name)
@router.post("/{name}/restore")
def restore(name: str, data: RestoreIn, db: Session = Depends(get_db), _: User = Depends(require_admin)):
"""Заменить ВСЕ данные продуктовой базы содержимым копии. Текущее состояние сначала сохраняется отдельной копией."""
if not data.confirm:
raise HTTPException(422, "Подтвердите восстановление (confirm: true)")
try:
return B.restore(db, name)
except B.BackupError as e:
raise _err(e)
+516
View File
@@ -0,0 +1,516 @@
"""Авторизация и справочники: пользователи, должности, графики, места, оборудование, чек-листы, шаблоны нарядов."""
import time
from datetime import timedelta
from fastapi.encoders import jsonable_encoder
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .. import schemas as S, logic, config, push, mode, demo, scope, settings_store as ss
from ..db import get_db, session_for
from ..models import (RefreshToken, User, Position, Department, ShiftSchedule, Location, Equipment, Checklist, ChecklistItem,
OrderTemplate)
from ..security import (current_user, require_manager, require_engineer, hash_password, verify_password, is_portal_admin, is_root,
make_token, new_refresh_token, hash_refresh)
router = APIRouter()
def commit(db: Session):
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(409, "Конфликт данных: дубликат или запись используется")
def get_or_404(db: Session, model, id: int):
obj = db.get(model, id)
if not obj:
raise HTTPException(404, "Не найдено")
return obj
# ---------- auth ----------
_fails: dict[str, list[float]] = {} # ключ -> время неудачных попыток
MAX_TRACKED = 5000 # предел размера словаря (защита памяти от перебора случайных логинов)
def _prune(t: float):
for k in [k for k, v in _fails.items() if not v or t - v[-1] >= config.LOGIN_WINDOW_SEC]:
del _fails[k]
while len(_fails) > MAX_TRACKED: # всё ещё много — выбрасываем самые старые записи
_fails.pop(next(iter(_fails)))
def _check_rate(key: str, limit: int):
t = time.time()
if len(_fails) > MAX_TRACKED:
_prune(t)
lst = [x for x in _fails.get(key, []) if t - x < config.LOGIN_WINDOW_SEC]
if lst:
_fails[key] = lst
else:
_fails.pop(key, None)
if len(lst) >= limit:
raise HTTPException(429, "Слишком много неудачных попыток входа, попробуйте позже",
headers={"Retry-After": str(config.LOGIN_WINDOW_SEC)})
def _login_keys(ip: str, username: str) -> list[tuple[str, int]]:
"""Три корзины: пара «адрес+логин» (основная блокировка), весь адрес (перебор по многим логинам) и логин
целиком (мягкий общий предел, выше основного). Так посторонний не может заблокировать вход настоящему
пользователю с другого адреса, но перебор с одного адреса и распределённый перебор по одному логину ограничены."""
user = username.lower()[:64]
return [(f"ipuser:{ip}|{user}", config.LOGIN_MAX_FAILS),
(f"ip:{ip}", config.LOGIN_MAX_FAILS * 6),
(f"user:{user}", config.LOGIN_MAX_FAILS * 4)]
_DUMMY_HASH = hash_password("dummy-password-for-timing")
def issue_tokens(db: Session, u: User, device: str = "", access: str | None = None) -> S.TokenOut:
"""Refresh-токен хранится в той БД, где лежит пользователь u; access — по умолчанию для той же БД."""
raw, h = new_refresh_token()
db.add(RefreshToken(user_id=u.id, token_hash=h, device=device[:100],
expires_at=config.now() + timedelta(days=config.REFRESH_TTL_DAYS)))
db.commit()
if access is None:
access = make_token(u, demo=bool(db.info.get("demo")), rid=None)
return S.TokenOut(access_token=access, refresh_token=raw, expires_in=config.ACCESS_TTL_MIN * 60)
def tokens_for_real(db: Session, u: User, device: str = "") -> S.TokenOut:
"""Сессия пользователя боевой БД. Если он сейчас в демо — access-токен ведёт в демо-БД (к его копии там),
refresh остаётся в боевой БД: при выходе из демо следующее обновление токена вернёт его к боевым данным."""
if mode.user_in_demo(u.id):
return issue_tokens(db, u, device, access=make_token(demo.demo_user_id_for(u), demo=True, rid=u.id))
return issue_tokens(db, u, device, access=make_token(u))
def revoke_all(db: Session, user_id: int):
db.execute(update(RefreshToken).where(RefreshToken.user_id == user_id).values(revoked=True))
@router.post("/auth/login", response_model=S.TokenOut, tags=["auth"])
def login(data: S.LoginIn, request: Request, db: Session = Depends(get_db)):
ip = request.client.host if request.client else "?"
keys = _login_keys(ip, data.username)
for k, limit in keys:
_check_rate(k, limit)
u = db.scalar(select(User).where(User.username == data.username))
if not u:
verify_password(data.password, _DUMMY_HASH) # одинаковое время ответа для существующих и несуществующих логинов
if not u or not u.is_active or not verify_password(data.password, u.password_hash):
# демонстрационные учётные записи (есть только в демо-БД) — пока демо-БД существует
if mode.State.active:
with session_for(True) as ds:
du = ds.scalar(select(User).where(User.username == data.username))
if du and du.is_active and verify_password(data.password, du.password_hash) and not (u and u.is_active):
_fails.pop(keys[0][0], None)
return issue_tokens(ds, du, data.device)
for k, _ in keys:
_fails.setdefault(k, []).append(time.time())
raise HTTPException(401, "Неверный логин или пароль")
_fails.pop(keys[0][0], None) # успешный вход сбрасывает только пару «адрес+логин»
return tokens_for_real(db, u, data.device)
@router.post("/auth/refresh", response_model=S.TokenOut, tags=["auth"])
def refresh(data: S.RefreshIn, db: Session = Depends(get_db)):
"""Обмен refresh-токена на новую пару (ротация). Повторное использование старого токена отзывает все сессии."""
if db.info.get("demo"): # обновление всегда начинаем с боевой БД
db.close()
db = session_for(False)
h = hash_refresh(data.refresh_token)
rt = db.scalar(select(RefreshToken).where(RefreshToken.token_hash == h))
if not rt and mode.State.active: # сессия демонстрационной учётной записи
with session_for(True) as ds:
drt = ds.scalar(select(RefreshToken).where(RefreshToken.token_hash == h))
if drt:
return _rotate(ds, drt, data.device)
if not rt:
raise HTTPException(401, "Недействительный refresh-токен")
return _rotate(db, rt, data.device)
def _rotate(db: Session, rt: RefreshToken, device: str) -> S.TokenOut:
if rt.revoked:
revoke_all(db, rt.user_id) # возможная кража токена
db.commit()
raise HTTPException(401, "Сессия отозвана, войдите заново")
u = db.get(User, rt.user_id)
if rt.expires_at < config.now() or not u or not u.is_active:
raise HTTPException(401, "Сессия истекла, войдите заново")
rt.revoked = True
if db.info.get("demo"):
return issue_tokens(db, u, device or rt.device)
return tokens_for_real(db, u, device or rt.device)
@router.post("/auth/logout", status_code=204, tags=["auth"])
def logout(data: S.RefreshIn, db: Session = Depends(get_db)):
h = hash_refresh(data.refresh_token)
for demo_db in ((False, True) if mode.State.active else (False,)):
with session_for(demo_db) as s:
rt = s.scalar(select(RefreshToken).where(RefreshToken.token_hash == h))
if rt:
rt.revoked = True
s.commit()
return
@router.post("/auth/change-password", response_model=S.TokenOut, tags=["auth"])
def change_password(data: S.PasswordIn, db: Session = Depends(get_db), u: User = Depends(current_user)):
"""Смена своего пароля; все остальные сессии завершаются, возвращается новая пара токенов."""
if db.info.get("demo"):
raise HTTPException(409, "В демонстрационном режиме пароль не меняется — выйдите из демо")
if not verify_password(data.old_password, u.password_hash):
raise HTTPException(403, "Старый пароль неверен")
u.password_hash = hash_password(data.new_password)
revoke_all(db, u.id)
return issue_tokens(db, u)
def user_out(u: User) -> S.UserOut:
out = S.UserOut.model_validate(u)
out.on_shift = logic.user_on_shift(u)
out.has_photo = bool(u.photo)
out.demo_mode = mode.is_demo_request()
out.portal_admin, out.is_root = is_portal_admin(u), is_root(u)
return out
@router.get("/me", response_model=S.UserOut)
def me(db: Session = Depends(get_db), u: User = Depends(current_user)):
out = user_out(u)
out.crane_map = scope.crane_map_enabled(db)
from ..security import is_engineer, is_manager
out.reliability = ss.get(db, "reliability") != "0" and (is_manager(u) or is_engineer(u))
return out
# ---------- generic CRUD ----------
def crud(path: str, Model, In, Out, write_dep, prep=None, scope=None):
"""scope(db, user) — условие отбора для списка (например, оборудование только своего отдела) или None."""
Patch = S.partial(In)
@router.get(path, response_model=list[Out], name=f"list_{path}")
def list_(db: Session = Depends(get_db), u: User = Depends(current_user)):
stmt = select(Model).order_by(Model.id)
cond = scope(db, u) if scope else None
return db.scalars(stmt if cond is None else stmt.where(cond)).all()
@router.get(path + "/{id}", response_model=Out, name=f"get_{path}")
def get_(id: int, db: Session = Depends(get_db), _: User = Depends(current_user)):
return get_or_404(db, Model, id)
@router.post(path, response_model=Out, status_code=201, name=f"create_{path}")
def create_(data: In, db: Session = Depends(get_db), _: User = Depends(write_dep)):
d = data.model_dump()
obj = Model(**(prep(d, None) if prep else d))
db.add(obj)
commit(db)
return obj
@router.patch(path + "/{id}", response_model=Out, name=f"update_{path}")
def update_(id: int, data: Patch, db: Session = Depends(get_db), _: User = Depends(write_dep)):
obj = get_or_404(db, Model, id)
ch = data.model_dump(exclude_unset=True)
for k, v in (prep(ch, obj) if prep else ch).items():
setattr(obj, k, v)
commit(db)
return obj
@router.delete(path + "/{id}", status_code=204, name=f"delete_{path}")
def delete_(id: int, db: Session = Depends(get_db), _: User = Depends(write_dep)):
db.delete(get_or_404(db, Model, id))
commit(db)
def prep_schedule(d: dict, cur: ShiftSchedule | None) -> dict:
"""Проверка графика и приведение шагов цикла к JSON."""
kind = d.get("kind", cur.kind if cur else "weekly")
if "pattern" in d and d["pattern"] is not None:
d["pattern"] = jsonable_encoder(d["pattern"])
pattern = d.get("pattern", cur.pattern if cur else None)
if kind == "pattern":
if not pattern or not any(x["type"] == "work" for x in pattern):
raise HTTPException(422, "В цикле должна быть хотя бы одна рабочая смена")
if kind in ("cycle", "pattern") and not d.get("cycle_start") and not (cur and cur.cycle_start):
d["cycle_start"] = config.now().date()
return d
crud("/schedules", ShiftSchedule, S.ScheduleIn, S.ScheduleOut, require_manager, prep_schedule)
@router.post("/schedules/preview", response_model=list[S.PreviewDay], tags=["schedules"])
def preview_schedule(data: S.SchedulePreviewIn, _: User = Depends(require_manager)):
"""Календарь графика на N дней (для конструктора; сохранять график не нужно)."""
d = prep_schedule(data.schedule.model_dump(), None)
sch = ShiftSchedule(**d)
start = data.from_date or config.now().date()
out = []
for i in range(data.days):
day = start + timedelta(days=i)
sh = logic.shift_starting(sch, day)
out.append(S.PreviewDay(date=day, weekday=day.weekday(), work=bool(sh),
start=sh[0] if sh else None, end=sh[1] if sh else None,
label=sh[2] if sh else ""))
return out
crud("/positions", Position, S.PositionIn, S.PositionOut, require_manager)
# ---------- отделы ----------
def dep_out(db: Session, d: Department) -> S.DepartmentOut:
o = S.DepartmentOut.model_validate(d)
o.members = db.query(User).filter(User.department_id == d.id, User.is_active.is_(True)).count()
return o
def check_dep(db: Session, d: Department):
if d.head_user_id and not db.get(User, d.head_user_id):
raise HTTPException(422, "Руководитель не найден")
seen, p = {d.id}, d.parent_id
while p: # отдел не может входить сам в себя (в том числе через цепочку)
if p in seen:
raise HTTPException(422, "Отдел не может входить сам в себя")
seen.add(p)
par = db.get(Department, p)
if not par:
raise HTTPException(422, "Вышестоящий отдел не найден")
p = par.parent_id
@router.get("/departments", response_model=list[S.DepartmentOut], tags=["departments"])
def list_departments(db: Session = Depends(get_db), _: User = Depends(current_user)):
return [dep_out(db, d) for d in db.scalars(select(Department).order_by(Department.name)).all()]
@router.get("/departments/{id}", response_model=S.DepartmentOut, tags=["departments"])
def get_department(id: int, db: Session = Depends(get_db), _: User = Depends(current_user)):
return dep_out(db, get_or_404(db, Department, id))
@router.post("/departments", response_model=S.DepartmentOut, status_code=201, tags=["departments"])
def create_department(data: S.DepartmentIn, db: Session = Depends(get_db), _: User = Depends(require_manager)):
d = Department(**data.model_dump())
db.add(d)
try:
db.flush()
except IntegrityError:
db.rollback()
raise HTTPException(409, "Отдел с таким названием уже есть")
check_dep(db, d)
commit(db)
return dep_out(db, d)
@router.patch("/departments/{id}", response_model=S.DepartmentOut, tags=["departments"])
def update_department(id: int, data: S.partial(S.DepartmentIn), db: Session = Depends(get_db), _: User = Depends(require_manager)):
d = get_or_404(db, Department, id)
for k, v in data.model_dump(exclude_unset=True).items():
if v is None and k == "name":
continue
setattr(d, k, v)
check_dep(db, d)
commit(db)
return dep_out(db, d)
@router.delete("/departments/{id}", status_code=204, tags=["departments"])
def delete_department(id: int, db: Session = Depends(get_db), _: User = Depends(require_manager)):
"""Сотрудники удаляемого отдела остаются без отдела, вложенные отделы поднимаются на уровень выше."""
d = get_or_404(db, Department, id)
db.execute(update(User).where(User.department_id == id).values(department_id=None))
db.execute(update(Equipment).where(Equipment.department_id == id).values(department_id=d.parent_id))
db.execute(update(Department).where(Department.parent_id == id).values(parent_id=d.parent_id))
db.delete(d)
commit(db)
crud("/locations", Location, S.LocationIn, S.LocationOut, require_engineer)
def prep_equipment(d: dict, cur: Equipment | None) -> dict:
"""PATCH-схема создаётся без валидаторов — иконку проверяем здесь."""
if d.get("icon") and not S.ICON_RE.fullmatch(d["icon"]):
raise HTTPException(422, "Недопустимая иконка")
if "icon" in d and d["icon"] is None:
d["icon"] = ""
return d
crud("/equipment", Equipment, S.EquipmentIn, S.EquipmentOut, require_engineer, prep_equipment, scope.equipment_filter)
# ---------- users ----------
@router.get("/users", response_model=list[S.UserOut])
def list_users(db: Session = Depends(get_db), _: User = Depends(current_user)):
return [user_out(u) for u in db.scalars(select(User).order_by(User.id)).all()]
def check_user_refs(db: Session, fields: dict):
if fields.get("department_id") and not db.get(Department, fields["department_id"]):
raise HTTPException(422, "Отдел не найден")
if fields.get("position_id") and not db.get(Position, fields["position_id"]):
raise HTTPException(422, "Должность не найдена")
def guard_user_edit(me: User, target: User | None, fields: dict):
"""Модератор не может трогать администраторов и выдавать права администратора/модератора.
Права администратора портала выдаёт и снимает только администратор по умолчанию (admin)."""
want = fields.get("portal_admin")
if want is not None and (bool(want) if target is None else bool(target.portal_admin) != bool(want)):
if not is_root(me):
raise HTTPException(403, "Права администратора портала выдаёт только администратор по умолчанию (admin)")
if target is not None and is_root(target) and (fields.get("is_admin") is False or fields.get("is_active") is False):
raise HTTPException(403, "Администратора по умолчанию нельзя лишить прав или отключить")
if me.is_admin:
return
if target is not None and (target.is_admin or (target.is_moderator and target.id != me.id)):
raise HTTPException(403, "Модератор не может изменять администраторов и других модераторов")
if fields.get("is_admin") or fields.get("is_moderator"):
raise HTTPException(403, "Права администратора и модератора выдаёт только администратор")
@router.post("/users", response_model=S.UserOut, status_code=201)
def create_user(data: S.UserIn, db: Session = Depends(get_db), me: User = Depends(require_manager)):
d = data.model_dump()
guard_user_edit(me, None, d)
check_user_refs(db, d)
d["password_hash"] = hash_password(d.pop("password"))
u = User(**d)
db.add(u)
commit(db)
db.refresh(u)
return user_out(u)
@router.patch("/users/{id}", response_model=S.UserOut)
def update_user(id: int, data: S.UserPatch, db: Session = Depends(get_db), me: User = Depends(require_manager)):
u = get_or_404(db, User, id)
changes = data.model_dump(exclude_unset=True)
guard_user_edit(me, u, changes)
check_user_refs(db, changes)
for k, v in changes.items():
if k == "password":
if v:
u.password_hash = hash_password(v)
revoke_all(db, u.id)
else:
setattr(u, k, v)
commit(db)
db.refresh(u)
return user_out(u)
@router.delete("/users/{id}", status_code=204)
def delete_user(id: int, db: Session = Depends(get_db), me: User = Depends(require_manager)):
if id == me.id:
raise HTTPException(400, "Нельзя удалить себя")
u = get_or_404(db, User, id)
guard_user_edit(me, u, {})
db.delete(u)
commit(db)
# ---------- checklists ----------
def fill_items(cl: Checklist, items: list[S.ChecklistItemIn]):
cl.items.clear()
for i, it in enumerate(items):
d = it.model_dump()
if d["answer_type"] != "choice":
d["options"] = None
elif not d["options"]:
raise HTTPException(422, f"Пункт «{it.text}»: для списка нужны варианты ответа")
cl.items.append(ChecklistItem(position=i, **d))
@router.get("/checklists", response_model=list[S.ChecklistOut])
def list_checklists(db: Session = Depends(get_db), _: User = Depends(current_user)):
return db.scalars(select(Checklist).order_by(Checklist.id)).all()
@router.get("/checklists/{id}", response_model=S.ChecklistOut)
def get_checklist(id: int, db: Session = Depends(get_db), _: User = Depends(current_user)):
return get_or_404(db, Checklist, id)
@router.post("/checklists", response_model=S.ChecklistOut, status_code=201)
def create_checklist(data: S.ChecklistIn, db: Session = Depends(get_db), _: User = Depends(require_engineer)):
cl = Checklist(name=data.name, description=data.description, kind=data.kind, only_due=data.only_due)
fill_items(cl, data.items)
db.add(cl)
commit(db)
return cl
@router.put("/checklists/{id}", response_model=S.ChecklistOut)
def update_checklist(id: int, data: S.ChecklistIn, db: Session = Depends(get_db),
_: User = Depends(require_engineer)):
cl = get_or_404(db, Checklist, id)
cl.name, cl.description, cl.kind, cl.only_due = data.name, data.description, data.kind, data.only_due
fill_items(cl, data.items)
commit(db)
return cl
@router.delete("/checklists/{id}", status_code=204)
def delete_checklist(id: int, db: Session = Depends(get_db), _: User = Depends(require_engineer)):
db.delete(get_or_404(db, Checklist, id))
commit(db)
# ---------- шаблоны нарядов ----------
@router.get("/order-templates", response_model=list[S.OrderTemplateOut])
def list_templates(db: Session = Depends(get_db), _: User = Depends(require_engineer)):
return db.scalars(select(OrderTemplate).order_by(OrderTemplate.id)).all()
@router.post("/order-templates", response_model=S.OrderTemplateOut, status_code=201)
def create_template(data: S.OrderTemplateIn, db: Session = Depends(get_db), _: User = Depends(require_engineer)):
d = data.model_dump()
cl = db.get(Checklist, d["checklist_id"])
if cl and cl.kind == "regular" and not d.get("equipment_id"):
raise HTTPException(422, "Для регламентного обслуживания выберите оборудование")
d["start_date"] = d["start_date"] or config.now().date()
t = OrderTemplate(**d)
logic.recalc_next_run(t)
db.add(t)
commit(db)
return t
@router.patch("/order-templates/{id}", response_model=S.OrderTemplateOut)
def update_template(id: int, data: S.partial(S.OrderTemplateIn), db: Session = Depends(get_db),
_: User = Depends(require_engineer)):
t = get_or_404(db, OrderTemplate, id)
for k, v in data.model_dump(exclude_unset=True).items():
if k == "start_date" and v is None:
continue
setattr(t, k, v)
logic.recalc_next_run(t)
commit(db)
return t
@router.delete("/order-templates/{id}", status_code=204)
def delete_template(id: int, db: Session = Depends(get_db), _: User = Depends(require_engineer)):
db.delete(get_or_404(db, OrderTemplate, id))
commit(db)
@router.post("/order-templates/{id}/run-now", status_code=201)
def run_now(id: int, db: Session = Depends(get_db), _: User = Depends(require_engineer)):
"""Создать наряд из шаблона прямо сейчас (не сдвигая расписание)."""
t = get_or_404(db, OrderTemplate, id)
try:
o = logic.run_template(db, t)
except ValueError as e:
raise HTTPException(422, str(e))
commit(db)
push.notify_assigned(db, o)
return {"order_id": o.id}
+145
View File
@@ -0,0 +1,145 @@
"""Демонстрационный режим — персональный: в демо-БД работают только те, кто его включил (или кого включил администратор).
Остальные в это время работают с боевыми данными. Подробности — app/demo.py, app/mode.py."""
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import demo, mode, schemas as S
from ..db import get_db, session_for
from ..models import User
from ..security import current_user, require_admin
from .catalog import issue_tokens, tokens_for_real
router = APIRouter(prefix="/demo", tags=["demo"])
class ConfirmIn(BaseModel):
confirm: bool = False
class ImpersonateIn(BaseModel):
user_id: int
class UserDemoIn(BaseModel):
on: bool
def real_admin(request: Request, u: User = Depends(require_admin)) -> User:
"""Администратор в боевой БД (запрос может прийти и из демо-сессии — тогда берём его настоящую учётную запись)."""
rid = getattr(request.state, "real_user_id", None)
if rid is None:
raise HTTPException(403, "Демонстрационная учётная запись не может управлять демо-режимом")
with session_for(False) as s:
ru = s.get(User, rid)
if not ru or not ru.is_admin or not ru.is_active:
raise HTTPException(403, "Только для администратора")
s.expunge(ru)
return ru
def _real_tokens(real_id: int, device: str) -> S.TokenOut:
with session_for(False) as s:
u = s.get(User, real_id)
if not u or not u.is_active:
raise HTTPException(404, "Пользователь не найден")
return tokens_for_real(s, u, device)
@router.get("/status")
def status():
"""Публичный: экрану входа — есть ли сейчас демо-учётные записи. Сами записи здесь не раскрываются."""
return {"active": mode.State.active}
@router.get("/me")
def my_demo(request: Request, u: User = Depends(current_user)):
"""Для баннера: в демо ли текущая сессия и можно ли из него выйти самому."""
return {"in_demo": bool(getattr(request.state, "demo", False)),
"can_leave": getattr(request.state, "real_user_id", None) is not None}
@router.get("")
def info(request: Request, db: Session = Depends(get_db), admin: User = Depends(real_admin)):
"""Панель администратора: состояние демо-БД, кто сейчас в демо, демо-учётные записи."""
with session_for(False) as s:
people = [{"id": x.id, "username": x.username, "full_name": x.full_name, "is_admin": x.is_admin,
"in_demo": mode.user_in_demo(x.id)} for x in s.scalars(select(User).where(User.is_active.is_(True)).order_by(User.full_name, User.username))]
out = {"active": mode.State.active, "since": mode.State.since, "in_demo": bool(getattr(request.state, "demo", False)),
"users": people, "password": demo.DEMO_PASSWORD}
if mode.State.active:
with session_for(True) as ds:
out["accounts"] = demo.demo_accounts(ds)
return out
@router.post("/enable", response_model=S.TokenOut)
def enable(data: ConfirmIn, admin: User = Depends(real_admin)):
"""Войти в демо самому. Боевые данные не меняются, остальные пользователи продолжают работать как обычно."""
if not data.confirm:
raise HTTPException(422, "Требуется подтверждение")
try:
demo.enable_for(admin, owner=admin)
except demo.DemoError as e:
raise HTTPException(409, str(e))
return _real_tokens(admin.id, "demo")
@router.post("/leave", response_model=S.TokenOut)
def leave(request: Request, u: User = Depends(current_user)):
"""Выйти из демо (любой пользователь, которого перевели в демо) и вернуться к боевым данным."""
rid = getattr(request.state, "real_user_id", None)
if rid is None:
raise HTTPException(409, "Это демонстрационная учётная запись — просто выйдите из неё")
demo.disable_for(rid)
return _real_tokens(rid, "web")
@router.post("/users/{rid}")
def set_user(rid: int, data: UserDemoIn, admin: User = Depends(real_admin)):
"""Включить или выключить демо другому пользователю (например, если кто-то случайно включил и не может выйти)."""
with session_for(False) as s:
target = s.get(User, rid)
if not target or not target.is_active:
raise HTTPException(404, "Пользователь не найден")
s.expunge(target)
if data.on:
demo.enable_for(target, owner=admin)
else:
demo.disable_for(rid)
return {"id": rid, "in_demo": mode.user_in_demo(rid)}
@router.post("/reset")
def reset(db: Session = Depends(get_db), admin: User = Depends(real_admin)):
"""Пересоздать тестовые данные (кто в демо — остаётся в демо)."""
try:
db.close() # освободить демо-БД до удаления файла (важно на Windows)
demo.reset(admin)
except demo.DemoError as e:
raise HTTPException(409, str(e))
return {"ok": True}
@router.post("/destroy")
def destroy(db: Session = Depends(get_db), admin: User = Depends(real_admin)):
"""Удалить демо-данные целиком: все возвращаются к боевым данным."""
try:
db.close() # освободить демо-БД до удаления файла (важно на Windows)
demo.destroy()
except demo.DemoError as e:
raise HTTPException(409, str(e))
return {"ok": True}
@router.post("/impersonate", response_model=S.TokenOut)
def impersonate(data: ImpersonateIn, request: Request, admin: User = Depends(real_admin)):
"""Войти под демо-пользователем без пароля (показать роли). Только в демо-сессии; администраторов выбрать нельзя."""
if not getattr(request.state, "demo", False):
raise HTTPException(409, "Сначала войдите в демо-режим")
with session_for(True) as ds:
target = ds.get(User, data.user_id)
if not target or target.is_admin or not target.is_active:
raise HTTPException(404, "Пользователь не найден")
return issue_tokens(ds, target, "demo-impersonate")
+177
View File
@@ -0,0 +1,177 @@
"""Инциденты извне: публичная форма без входа (/report) и API для внешних систем по ключу доступа.
Публичная форма: место → машины этого места (и вложенных мест) → описание и контакты. Ограничение частоты по адресу
и ловушка для ботов. Включается/выключается администратором (настройка public_incidents).
API: заголовок X-API-Key; ключи выпускает администратор, в БД хранится только хэш."""
import hashlib
import secrets
import time
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import config, push, schemas as S, settings_store as ss
from ..db import get_db
from ..models import ApiKey, Equipment, Incident, Location, User
from ..security import is_engineer, is_manager, require_portal_admin as require_admin
from .catalog import commit, get_or_404
from .incidents import inc_out
router = APIRouter(tags=["external"])
PUBLIC_LIMIT = 5 # отправок с одного адреса
PUBLIC_WINDOW = 600 # за 10 минут
PUBLIC_GLOBAL = 200 # всего публичных инцидентов в час (защита от массового спама)
_hits: dict[str, list[float]] = {}
def _rate(key: str, limit: int, window: int):
t = time.time()
lst = [x for x in _hits.get(key, []) if t - x < window]
if len(lst) >= limit:
raise HTTPException(429, "Слишком много обращений, попробуйте позже", headers={"Retry-After": str(window)})
lst.append(t)
_hits[key] = lst
if len(_hits) > 5000: # предел памяти
for k in [k for k, v in _hits.items() if not v or t - v[-1] > PUBLIC_WINDOW][:2500]:
_hits.pop(k, None)
def public_enabled(db: Session) -> bool:
return ss.get(db, "public_incidents") not in ("0", "false", "False")
def descendants(db: Session, loc_id: int) -> set[int]:
"""Место и все вложенные в него места."""
children: dict[int | None, list[int]] = {}
for i, p in db.execute(select(Location.id, Location.parent_id)):
children.setdefault(p, []).append(i)
out, stack = set(), [loc_id]
while stack:
x = stack.pop()
if x in out:
continue
out.add(x)
stack.extend(children.get(x, []))
return out
def notify_staff(db: Session, eq: Equipment, i: Incident, who: str):
from .incidents import notify
notify(db, eq, i, who)
def short_title(text: str) -> str:
first = text.strip().splitlines()[0] if text.strip() else "Инцидент"
return first[:120] + ("…" if len(first) > 120 else "")
# ---------- публичная форма ----------
@router.get("/public/report-options")
def report_options(db: Session = Depends(get_db)):
"""Места и активные машины для публичной формы (только названия — без внутренних данных)."""
if not public_enabled(db):
return {"enabled": False, "locations": [], "equipment": []}
return {
"enabled": True,
"locations": [{"id": i, "name": n, "parent_id": p} for i, n, p in db.execute(select(Location.id, Location.name, Location.parent_id).order_by(Location.name))],
"equipment": [{"id": e.id, "name": e.name, "inventory_no": e.inventory_no, "location_id": e.location_id}
for e in db.scalars(select(Equipment).where(Equipment.is_active.is_(True)).order_by(Equipment.name))],
}
@router.post("/public/incidents", status_code=201)
def public_incident(data: S.PublicIncidentIn, request: Request, db: Session = Depends(get_db)):
if not public_enabled(db):
raise HTTPException(403, "Приём обращений через форму отключён")
ip = request.client.host if request.client else "?"
_rate("ip:" + ip, PUBLIC_LIMIT, PUBLIC_WINDOW)
_rate("all", PUBLIC_GLOBAL, 3600)
if data.website: # ловушка заполнена — бот; отвечаем как обычно, но ничего не сохраняем
return {"ok": True, "number": None}
loc = get_or_404(db, Location, data.location_id)
eq = get_or_404(db, Equipment, data.equipment_id)
if not eq.is_active or eq.location_id not in descendants(db, loc.id):
raise HTTPException(422, "Эта машина не относится к выбранному месту")
title = data.title.strip() or short_title(data.description)
i = Incident(kind=data.kind, equipment_id=eq.id, occurred_at=config.now(), title=title, description=data.description.strip(), hours_at_open=eq.meter_hours,
source="public", reporter_name=data.reporter_name.strip(), reporter_contact=data.reporter_contact.strip())
db.add(i)
commit(db)
notify_staff(db, eq, i, f"Обращение: {i.reporter_name}")
return {"ok": True, "number": i.id}
# ---------- API для внешних систем ----------
def _hash(key: str) -> str:
return hashlib.sha256(key.encode()).hexdigest()
def api_key(x_api_key: str = Header(default="", alias="X-API-Key"), db: Session = Depends(get_db)) -> ApiKey:
k = db.scalar(select(ApiKey).where(ApiKey.key_hash == _hash(x_api_key))) if x_api_key else None
if not k or not k.is_active:
raise HTTPException(401, "Нужен действующий ключ доступа (заголовок X-API-Key)")
k.last_used_at = config.now()
return k
@router.get("/external/equipment")
def external_equipment(db: Session = Depends(get_db), k: ApiKey = Depends(api_key)):
"""Справочник машин для сопоставления: id, название, инвентарный номер, место."""
locs = dict(db.execute(select(Location.id, Location.name)).all())
out = [{"id": e.id, "name": e.name, "inventory_no": e.inventory_no, "location": locs.get(e.location_id, "")}
for e in db.scalars(select(Equipment).where(Equipment.is_active.is_(True)).order_by(Equipment.name))]
commit(db)
return out
@router.post("/external/incidents", response_model=S.IncidentOut, status_code=201)
def external_incident(data: S.ExternalIncidentIn, db: Session = Depends(get_db), k: ApiKey = Depends(api_key)):
"""Завести инцидент (kind=incident) или сообщение о неисправности (kind=fault) из внешней системы."""
if data.equipment_id:
eq = get_or_404(db, Equipment, data.equipment_id)
elif data.inventory_no and data.inventory_no.strip():
eq = db.scalar(select(Equipment).where(Equipment.inventory_no == data.inventory_no.strip()))
if not eq:
raise HTTPException(404, "Машина с таким инвентарным номером не найдена")
else:
raise HTTPException(422, "Укажите equipment_id или inventory_no")
if data.external_id: # повтор той же заявки не создаёт дубликат
dup = db.scalar(select(Incident).where(Incident.api_key_id == k.id, Incident.external_id == data.external_id))
if dup:
return inc_out(db, dup)
at = data.occurred_at or config.now()
if at > config.now():
raise HTTPException(422, "Время инцидента не может быть в будущем")
i = Incident(kind=data.kind, equipment_id=eq.id, occurred_at=at, title=data.title.strip(), description=data.description.strip(), source="api", hours_at_open=eq.meter_hours,
reporter_name=data.reporter_name.strip() or k.name, reporter_contact=data.reporter_contact.strip(),
external_id=data.external_id, api_key_id=k.id)
db.add(i)
commit(db)
notify_staff(db, eq, i, k.name)
return inc_out(db, i)
# ---------- управление ключами (администратор) ----------
@router.get("/api-keys", response_model=list[S.ApiKeyOut])
def list_keys(db: Session = Depends(get_db), _: User = Depends(require_admin)):
return db.scalars(select(ApiKey).order_by(ApiKey.id)).all()
@router.post("/api-keys", response_model=S.ApiKeyOut, status_code=201)
def create_key(data: S.ApiKeyIn, db: Session = Depends(get_db), me: User = Depends(require_admin)):
raw = "cl_" + secrets.token_urlsafe(32)
k = ApiKey(name=data.name.strip(), key_hash=_hash(raw), prefix=raw[:10], created_by_id=me.id)
db.add(k)
commit(db)
out = S.ApiKeyOut.model_validate(k)
out.key = raw # показывается один раз
return out
@router.delete("/api-keys/{id}", status_code=204)
def revoke_key(id: int, db: Session = Depends(get_db), _: User = Depends(require_admin)):
k = get_or_404(db, ApiKey, id)
k.is_active = False # инциденты сохраняют ссылку на ключ
commit(db)
+235
View File
@@ -0,0 +1,235 @@
"""Комментарии, фото-фиксация, настройки сервера, push-подписки."""
import mimetypes
import shutil
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse
from sqlalchemy import select, func
from sqlalchemy.orm import Session
from .. import schemas as S, config, logic, push, settings_store as ss
from ..db import get_db
from ..models import Comment, Photo, Order, OrderItem, User, PushSubscription, DeviceToken
from ..security import current_user, require_admin, is_manager, is_portal_admin
from .catalog import commit
from .orders import load, exec_guard, start, names, can_execute, to_out
router = APIRouter()
# ---------- комментарии ----------
def participants(o: Order) -> set[int]:
return {x for x in (o.assignee_user_id, o.executor_id, o.reviewer_user_id) if x}
@router.get("/orders/{id}/comments", response_model=list[S.CommentOut])
def list_comments(id: int, db: Session = Depends(get_db), u: User = Depends(current_user)):
load(db, id, u)
nm = names(db)["user"]
rows = db.scalars(select(Comment).where(Comment.order_id == id).order_by(Comment.id)).all()
return [S.CommentOut(id=c.id, user_id=c.user_id, user_name=nm.get(c.user_id), text=c.text,
created_at=c.created_at) for c in rows]
@router.post("/orders/{id}/comments", response_model=S.CommentOut, status_code=201)
def add_comment(id: int, data: S.CommentIn, db: Session = Depends(get_db), u: User = Depends(current_user)):
o = load(db, id, u)
c = Comment(order_id=id, user_id=u.id, text=data.text.strip())
db.add(c)
commit(db)
who = u.full_name or u.username
push.notify(participants(o) - {u.id}, f"Комментарий к наряду №{o.id}", f"{who}: {c.text[:120]}", f"/#/order/{o.id}")
return S.CommentOut(id=c.id, user_id=u.id, user_name=who, text=c.text, created_at=c.created_at)
@router.delete("/comments/{id}", status_code=204)
def delete_comment(id: int, db: Session = Depends(get_db), u: User = Depends(current_user)):
c = db.get(Comment, id)
if not c:
raise HTTPException(404, "Не найдено")
if c.user_id != u.id and not is_manager(u):
raise HTTPException(403, "Можно удалять только свои комментарии")
db.delete(c)
commit(db)
# ---------- фото ----------
MAGIC = [(b"\xff\xd8\xff", "image/jpeg", ".jpg"), (b"\x89PNG\r\n\x1a\n", "image/png", ".png")]
def sniff(data: bytes):
for sig, ct, ext in MAGIC:
if data.startswith(sig):
return ct, ext
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "image/webp", ".webp"
return None
@router.post("/orders/{id}/items/{item_id}/photos", response_model=S.OrderOut, status_code=201)
async def upload_photo(id: int, item_id: int, request: Request, db: Session = Depends(get_db),
u: User = Depends(current_user)):
"""Тело запроса — сам файл (image/jpeg|png|webp), без multipart."""
o = load(db, id, u)
exec_guard(o, u)
item = next((i for i in o.items if i.id == item_id), None)
if not item:
raise HTTPException(404, "Пункт не найден")
if item.photo == "none":
raise HTTPException(400, "Для этого пункта фото не предусмотрено")
key = (request.headers.get("idempotency-key") or "")[:64] or None
if key and any(p.client_key == key for p in item.photos):
return to_out(o, u, names(db), with_items=True) # повторная отправка той же фотографии
limit = int(ss.get(db, "max_photo_mb")) * 1024 * 1024
data = b""
async for chunk in request.stream():
data += chunk
if len(data) > limit:
raise HTTPException(413, f"Файл больше {limit // 1024 // 1024} МБ")
kind = sniff(data)
if not kind:
raise HTTPException(415, "Допустимы JPEG, PNG, WebP")
ct, ext = kind
root = ss.photo_root(db)
rel = Path(f"{config.now():%Y/%m}") / (ss.new_name() + ext)
(root / rel).parent.mkdir(parents=True, exist_ok=True)
(root / rel).write_bytes(data)
db.add(Photo(order_id=o.id, item_id=item.id, path=rel.as_posix(), content_type=ct, size=len(data),
user_id=u.id, client_key=key))
start(o, u)
commit(db)
db.refresh(item)
return to_out(o, u, names(db), with_items=True)
def photo_for(db: Session, id: int, u: User) -> Photo:
p = db.get(Photo, id)
if not p:
raise HTTPException(404, "Фото не найдено")
load(db, p.order_id, u) # проверка доступа к наряду
return p
@router.get("/photos/{id}")
def get_photo(id: int, db: Session = Depends(get_db), u: User = Depends(current_user)):
p = photo_for(db, id, u)
f = ss.photo_root(db) / p.path
if not f.is_file():
raise HTTPException(404, "Файл отсутствует на сервере")
return FileResponse(f, media_type=p.content_type, headers={"Cache-Control": "private, max-age=86400"})
@router.delete("/photos/{id}", response_model=S.OrderOut)
def delete_photo(id: int, db: Session = Depends(get_db), u: User = Depends(current_user)):
p = photo_for(db, id, u)
o = load(db, p.order_id, u)
if not (is_manager(u) or (p.user_id == u.id and can_execute(u, o))):
raise HTTPException(403, "Нельзя удалить это фото")
f = ss.photo_root(db) / p.path
db.delete(p)
commit(db)
f.unlink(missing_ok=True)
return to_out(o, u, names(db), with_items=True)
# ---------- настройки сервера (только админ) ----------
def dir_stats(root: Path) -> dict:
files = [x for x in root.rglob("*") if x.is_file()] if root.exists() else []
free = shutil.disk_usage(root if root.exists() else Path(".")).free
return {"files": len(files), "bytes": sum(x.stat().st_size for x in files), "disk_free": free}
def settings_payload(db: Session) -> dict:
return {**ss.all_editable(db), "photo_stats": dir_stats(ss.photo_root(db)),
"vapid_public": push.public_key(db), "now": config.iso(config.now()),
"fcm_enabled": push.fcm_enabled(db), "timezone": str(config.TZ)}
@router.get("/settings")
def get_settings(request: Request, db: Session = Depends(get_db), u: User = Depends(require_admin)):
"""Вместе с настройками отдаёт, каким сервер видит адрес клиента: по нему проверяют цепочку прокси (NPM).
Администратор без прав администратора портала получает только то, что нужно мастеру первичной настройки."""
if not is_portal_admin(u):
return {k: ss.get(db, k) for k in ("setup_done", "public_incidents", "crane_map", "reliability")} | {"limited": True}
return {**settings_payload(db), "client_ip": request.client.host if request.client else None,
"forwarded_for": request.headers.get("x-forwarded-for", "")}
@router.put("/settings")
def put_settings(data: S.SettingsIn, db: Session = Depends(get_db), u: User = Depends(require_admin)):
ch = data.model_dump(exclude_none=True)
if not is_portal_admin(u) and set(ch) - {"setup_done"}: # мастеру можно отмечать шаги, остальное — администратору портала
raise HTTPException(403, "Настройки сервера доступны только администраторам портала")
if ch.get("fcm_credentials_path"):
if not Path(ch["fcm_credentials_path"]).is_file():
raise HTTPException(422, "Файл ключа сервисного аккаунта Firebase не найден на сервере")
if "photo_dir" in ch:
d = Path(ch["photo_dir"].strip())
try:
d.mkdir(parents=True, exist_ok=True)
probe = d / ".write_test"
probe.write_text("ok")
probe.unlink()
except OSError as e:
raise HTTPException(422, f"Каталог недоступен для записи: {e}")
ch["photo_dir"] = str(d)
for k, v in ch.items():
ss.set_(db, k, ("1" if v else "0") if isinstance(v, bool) else str(v))
commit(db)
return settings_payload(db)
# ---------- push ----------
@router.get("/push/key")
def push_key(db: Session = Depends(get_db), _: User = Depends(current_user)):
return {"key": push.public_key(db)}
@router.post("/push/subscribe", status_code=204)
def push_subscribe(data: S.PushSubIn, db: Session = Depends(get_db), u: User = Depends(current_user)):
if not all(isinstance(data.keys.get(k), str) and 0 < len(data.keys[k]) <= 200 for k in ("p256dh", "auth")):
raise HTTPException(422, "Нужны ключи p256dh и auth")
sub = db.scalar(select(PushSubscription).where(PushSubscription.endpoint == data.endpoint))
if sub:
sub.user_id, sub.p256dh, sub.auth = u.id, data.keys["p256dh"], data.keys["auth"]
else:
db.add(PushSubscription(user_id=u.id, endpoint=data.endpoint, p256dh=data.keys["p256dh"],
auth=data.keys["auth"]))
commit(db)
@router.post("/push/unsubscribe", status_code=204)
def push_unsubscribe(data: S.PushSubIn, db: Session = Depends(get_db), u: User = Depends(current_user)):
sub = db.scalar(select(PushSubscription).where(PushSubscription.endpoint == data.endpoint,
PushSubscription.user_id == u.id))
if sub:
db.delete(sub)
commit(db)
@router.post("/push/test", status_code=204)
def push_test(u: User = Depends(current_user)):
push.notify([u.id], "Проверка уведомлений", "Если вы это видите — всё работает", "/")
@router.post("/push/device", status_code=204, tags=["push"])
def register_device(data: S.DeviceIn, db: Session = Depends(get_db), u: User = Depends(current_user)):
"""Android: регистрация FCM-токена устройства (вызывать при входе и при onNewToken)."""
d = db.scalar(select(DeviceToken).where(DeviceToken.token == data.token))
if d:
d.user_id, d.platform = u.id, data.platform
else:
db.add(DeviceToken(user_id=u.id, token=data.token, platform=data.platform))
commit(db)
@router.delete("/push/device", status_code=204, tags=["push"])
def unregister_device(data: S.DeviceIn, db: Session = Depends(get_db), u: User = Depends(current_user)):
"""Android: вызывать при выходе из аккаунта."""
d = db.scalar(select(DeviceToken).where(DeviceToken.token == data.token, DeviceToken.user_id == u.id))
if d:
db.delete(d)
commit(db)
+67
View File
@@ -0,0 +1,67 @@
"""Обратная связь по порталу: отправить может любой сотрудник, читать и отвечать — только администраторы портала."""
from datetime import timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .. import config, push, schemas as S
from ..db import get_db
from ..models import Feedback, User
from ..security import current_user, is_portal_admin, require_portal_admin
from .catalog import commit, get_or_404
from .inventory import user_names
router = APIRouter(tags=["feedback"])
PER_HOUR = 20 # не больше сообщений в час от одного сотрудника
KIND_RU = {"idea": "Идея", "bug": "Ошибка", "other": "Другое"}
def fb_out(f: Feedback, names: dict) -> S.FeedbackOut:
o = S.FeedbackOut.model_validate(f)
o.user_name, o.answered_by_name = names.get(f.user_id), names.get(f.answered_by_id)
return o
@router.post("/feedback", status_code=201)
def send(data: S.FeedbackIn, db: Session = Depends(get_db), u: User = Depends(current_user)):
since = config.now() - timedelta(hours=1)
if db.scalar(select(func.count()).select_from(Feedback).where(Feedback.user_id == u.id, Feedback.created_at >= since)) >= PER_HOUR:
raise HTTPException(429, "Слишком много сообщений, попробуйте позже")
f = Feedback(user_id=u.id, kind=data.kind, text=data.text.strip(), page=data.page.strip()[:200], client=data.client)
db.add(f)
commit(db)
admins = [x.id for x in db.scalars(select(User).where(User.is_active.is_(True), User.is_admin.is_(True))) if is_portal_admin(x) and x.id != u.id]
push.notify(admins, f"Обратная связь: {KIND_RU.get(f.kind, f.kind)}", f"{u.full_name or u.username}: {f.text[:100]}", "/#/feedback")
return {"ok": True, "id": f.id}
@router.get("/feedback", response_model=list[S.FeedbackOut])
def list_(status: Optional[str] = Query(None, pattern="^(new|taken|done|rejected|open)$"), limit: int = Query(300, ge=1, le=2000),
db: Session = Depends(get_db), _: User = Depends(require_portal_admin)):
stmt = select(Feedback).order_by(Feedback.created_at.desc(), Feedback.id.desc()).limit(limit)
if status == "open":
stmt = stmt.where(Feedback.status.in_(("new", "taken")))
elif status:
stmt = stmt.where(Feedback.status == status)
names = user_names(db)
return [fb_out(f, names) for f in db.scalars(stmt)]
@router.patch("/feedback/{id}", response_model=S.FeedbackOut)
def update(id: int, data: S.FeedbackPatch, db: Session = Depends(get_db), u: User = Depends(require_portal_admin)):
f = get_or_404(db, Feedback, id)
if data.status is not None:
f.status = data.status
if data.answer is not None:
f.answer = data.answer.strip()
f.answered_by_id, f.answered_at = u.id, config.now()
commit(db)
return fb_out(f, user_names(db))
@router.delete("/feedback/{id}", status_code=204)
def delete(id: int, db: Session = Depends(get_db), _: User = Depends(require_portal_admin)):
db.delete(get_or_404(db, Feedback, id))
commit(db)
+98
View File
@@ -0,0 +1,98 @@
"""История машины: всё, что с ней происходило — инциденты, обслуживание узлов, наработка, наряды, суточные задания."""
from datetime import date, datetime, time, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import config, service
from ..db import get_db
from ..models import DailyTask, Equipment, Incident, MeterReading, Order, ServiceEvent, ServiceUnit, TaskEvent, User
from ..security import current_user
from .catalog import get_or_404
from .inventory import user_names
router = APIRouter(tags=["history"])
TYPES = {"incident": "Отказ / неисправность", "service": "Обслуживание узла", "meter": "Наработка", "order": "Наряд", "task": "Задача"}
ORDER_RU = {"new": "создан", "completed": "выполнен", "cancelled": "отменён"}
TASK_RU = {"assigned": "назначено", "in_progress": "в работе", "paused": "приостановлено", "done": "выполнено", "cancelled": "отменено"}
SVC_RU = {"initial": "начальное состояние", "order": "обслужен по наряду", "reset": "сброс (обслужен вне наряда)", "incident": "заменён при отказе"}
SOURCE_RU = {"public": "через публичную форму", "api": "из внешней системы"}
def collect(db: Session, eq: Equipment, types: set[str], start: datetime | None, end: datetime | None) -> list[dict]:
names = user_names(db)
ev: list[dict] = []
def add(at, typ, title, text="", who=None, **ref):
if at is None or (start and at < start) or (end and at >= end):
return
ev.append({"at": at, "type": typ, "type_ru": TYPES[typ], "title": title, "text": text, "user_name": names.get(who), **ref})
if "incident" in types:
for i in db.scalars(select(Incident).where(Incident.equipment_id == eq.id)).all():
units = ", ".join(u.unit_name + (" (заменён)" if u.replaced else "") for u in i.units)
src = SOURCE_RU.get(i.source, "")
text = "; ".join(x for x in (i.description, f"узлы: {units}" if units else "",
("замена: " + (i.replacement_note or "да")) if i.replaced else "",
f"{src}: {i.reporter_name} {i.reporter_contact}".strip() if src else "") if x)
what = "Неисправность" if i.kind == "fault" else "Отказ"
add(i.occurred_at, "incident", f"{what}: {i.title}", text, i.author_id, incident_id=i.id,
severity="warn" if i.kind == "fault" else "bad")
if i.status == "closed":
add(i.closed_at, "incident", f"{what} закрыт{'а' if i.kind == 'fault' else ''}: {i.title}", i.close_note,
i.closed_by_id, incident_id=i.id, severity="ok")
if "service" in types:
units = {u.id: u for u in db.scalars(select(ServiceUnit).where(ServiceUnit.equipment_id == eq.id)).all()}
if units:
for e in db.scalars(select(ServiceEvent).where(ServiceEvent.unit_id.in_(units))).all():
add(e.at, "service", f"{units[e.unit_id].name}: {SVC_RU.get(e.kind, e.kind)}",
f"при наработке {service.num(e.hours)} м/ч" + (f"; {e.note}" if e.note and e.kind != "order" else ""),
e.user_id, unit_id=e.unit_id, order_id=e.order_id, severity="warn" if e.kind == "incident" else "info")
if "meter" in types:
for m in db.scalars(select(MeterReading).where(MeterReading.equipment_id == eq.id)).all():
src = {"manual": "внесено вручную", "order": f"по наряду №{m.order_id}", "correction": "коррекция счётчика"}.get(m.source, m.source)
add(m.at, "meter", f"Наработка {service.num(m.hours)} м/ч", src + (f"; {m.note}" if m.note else ""), m.user_id, order_id=m.order_id)
if "order" in types:
for o in db.scalars(select(Order).where(Order.equipment_id == eq.id)).all():
add(o.created_at, "order", f"Наряд №{o.id} создан: {o.title}", "", None, order_id=o.id)
if o.status == "completed":
add(o.completed_at, "order", f"Наряд №{o.id} выполнен: {o.title}", o.review_comment, o.executor_id, order_id=o.id, severity="ok")
elif o.status == "cancelled":
add(o.updated_at or o.created_at, "order", f"Наряд №{o.id} отменён: {o.title}", "", None, order_id=o.id)
if "task" in types:
tasks = {t.id: t for t in db.scalars(select(DailyTask).where(DailyTask.equipment_id == eq.id)).all()}
if tasks:
for e in db.scalars(select(TaskEvent).where(TaskEvent.task_id.in_(tasks))).all():
t = tasks[e.task_id]
if e.kind == "transfer":
what = f"передано: {names.get(e.from_user_id, '—')} → {names.get(e.to_user_id, '—')}"
else:
what = TASK_RU.get(e.to_status, e.to_status)
add(e.at, "task", f"Задание «{t.title}»: {what}", e.comment, e.user_id, task_id=t.id,
severity="ok" if e.to_status == "done" and e.kind != "transfer" else "")
ev.sort(key=lambda x: x["at"], reverse=True)
return ev
@router.get("/equipment/{id}/history")
def equipment_history(id: int, types: Optional[str] = Query(None, description="через запятую: incident,service,meter,order,task"),
date_from: Optional[date] = Query(None, alias="from"), date_to: Optional[date] = Query(None, alias="to"),
q: Optional[str] = Query(None, max_length=100), limit: int = Query(300, ge=1, le=2000),
db: Session = Depends(get_db), _: User = Depends(current_user)):
"""Хронология машины (новые сверху) с фильтрами по типу события, периоду (даты включительно) и тексту."""
eq = get_or_404(db, Equipment, id)
want = set(TYPES) if types is None else {t for t in types.split(",") if t in TYPES} # пустой выбор — пусто
start = datetime.combine(date_from, time.min) if date_from else None
end = datetime.combine(date_to + timedelta(days=1), time.min) if date_to else None
rows = collect(db, eq, want, start, end)
if q and q.strip():
s = q.strip().lower()
rows = [r for r in rows if s in (r["title"] + " " + r["text"] + " " + (r["user_name"] or "")).lower()]
counts = {t: 0 for t in TYPES}
for r in rows:
counts[r["type"]] += 1
return {"equipment": {"id": eq.id, "name": eq.name}, "total": len(rows), "counts": counts, "types": TYPES,
"rows": [{**r, "at": config.iso(r["at"])} for r in rows[:limit]]}
+257
View File
@@ -0,0 +1,257 @@
"""Инциденты (аварии) и сообщения о неисправности (kind=fault) на оборудовании.
Сотрудник фиксирует инцидент, придя на заявку: что произошло, какие узлы затронуты (из заведённых в карточке машины),
была ли замена. Заменённый узел сбрасывается, как при ручном сбросе. Пока инцидент открыт, машина в состоянии «Авария»."""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .. import config, push, schemas as S, scope, service
from ..db import get_db
from ..models import Equipment, Incident, IncidentUnit, ServiceUnit, User
from datetime import timedelta
from ..security import current_user, is_engineer, is_manager, require_engineer, require_manager
from .catalog import commit, get_or_404
from .inventory import user_names
router = APIRouter(tags=["incidents"])
def inc_out(db: Session, i: Incident, names: dict | None = None) -> S.IncidentOut:
names = names if names is not None else user_names(db)
o = S.IncidentOut.model_validate(i)
eq = db.get(Equipment, i.equipment_id)
o.equipment_name = eq.name if eq else None
o.author_name, o.closed_by_name = names.get(i.author_id), names.get(i.closed_by_id)
if i.cause_unit_id:
su = db.get(ServiceUnit, i.cause_unit_id)
o.cause_unit_name = su.name if su else None
return o
@router.get("/incidents", response_model=list[S.IncidentOut])
def list_incidents(equipment_id: Optional[int] = None, status: Optional[str] = Query(None, pattern="^(open|closed)$"),
mine: bool = False, source: Optional[str] = Query(None, pattern="^(staff|public|api)$"),
q: Optional[str] = Query(None, max_length=100), cranes: Optional[bool] = None,
kind: Optional[str] = Query(None, pattern="^(incident|fault)$"),
limit: int = Query(200, ge=1, le=1000), db: Session = Depends(get_db), u: User = Depends(current_user)):
stmt = select(Incident).order_by(Incident.occurred_at.desc(), Incident.id.desc()).limit(limit)
visible = scope.equipment_ids(db, u) # оборудование чужих отделов не показываем
if visible is not None:
stmt = stmt.where(Incident.equipment_id.in_(visible))
if equipment_id:
stmt = stmt.where(Incident.equipment_id == equipment_id)
if status:
stmt = stmt.where(Incident.status == status)
if mine:
stmt = stmt.where(Incident.author_id == u.id)
if source:
stmt = stmt.where(Incident.source == source)
if kind:
stmt = stmt.where(Incident.kind == kind)
if cranes is not None: # true — только краны, false — всё, кроме кранов (крановое хозяйство — отдельная служба)
stmt = stmt.where(Incident.equipment_id.in_(select(Equipment.id).where(Equipment.is_crane.is_(cranes))))
if q and q.strip():
like = f"%{q.strip().lower()}%"
eq_ids = [i for i, in db.execute(select(Equipment.id).where(func.lower(Equipment.name).like(like) | func.lower(Equipment.inventory_no).like(like)))]
stmt = stmt.where(Incident.equipment_id.in_(eq_ids) | func.lower(Incident.title).like(like))
names = user_names(db)
return [inc_out(db, i, names) for i in db.scalars(stmt).all()]
@router.get("/incidents/{id}", response_model=S.IncidentOut)
def get_incident(id: int, db: Session = Depends(get_db), _: User = Depends(current_user)):
return inc_out(db, get_or_404(db, Incident, id))
@router.post("/incidents", response_model=S.IncidentOut, status_code=201)
def create_incident(data: S.IncidentIn, db: Session = Depends(get_db), u: User = Depends(current_user)):
eq = get_or_404(db, Equipment, data.equipment_id)
at = data.occurred_at or config.now()
if at > config.now():
raise HTTPException(422, "Время инцидента не может быть в будущем")
seen = set()
units = []
for x in data.units:
if x.unit_id in seen:
continue
seen.add(x.unit_id)
su = db.get(ServiceUnit, x.unit_id)
if not su or su.equipment_id != eq.id:
raise HTTPException(422, "Узел не относится к этой машине")
units.append((su, x.replaced and data.kind == "incident")) # при неисправности замены нет — машина работает
fault = data.kind == "fault"
i = Incident(kind=data.kind, equipment_id=eq.id, author_id=u.id, occurred_at=at, title=data.title.strip(), hours_at_open=eq.meter_hours,
description=data.description.strip(), replacement_note="" if fault else data.replacement_note.strip(),
replaced=not fault and (data.replaced or any(r for _, r in units)))
for su, r in units:
i.units.append(IncidentUnit(unit_id=su.id, unit_name=su.name, replaced=r))
db.add(i)
db.flush()
for su, r in units:
if r: # замена узла — ресурс отсчитывается заново
service.reset_unit(db, su, at=at, kind="incident", user_id=u.id,
note=f"Замена при отказе №{i.id}: {i.title}"[:1000])
commit(db)
notify(db, eq, i, u.full_name or u.username, skip=u.id)
return inc_out(db, i)
KIND_RU = {"incident": "Отказ", "fault": "Неисправность"}
def notify(db: Session, eq: Equipment, i: Incident, who: str, skip: int | None = None):
"""Уведомить инженеров и руководителей, которые видят эту машину (с учётом отделов)."""
staff = [x.id for x in db.scalars(select(User).where(User.is_active.is_(True))).all()
if x.id != skip and (is_manager(x) or is_engineer(x)) and scope.user_sees(db, x, eq)]
push.notify(staff, f"{KIND_RU.get(i.kind, 'Инцидент')}: {eq.name}", f"{who}: {i.title[:100]}", f"/#/equipment/{eq.id}")
@router.post("/incidents/{id}/escalate", response_model=S.IncidentOut)
def escalate(id: int, data: S.IncidentNoteIn, db: Session = Depends(get_db), u: User = Depends(current_user)):
"""Сообщение о неисправности → инцидент: ситуация ухудшилась, оборудование остановлено. Машина получает «Аварию»."""
i = get_or_404(db, Incident, id)
if i.kind != "fault":
raise HTTPException(409, "Это уже отказ")
if i.status == "closed":
raise HTTPException(409, "Сообщение закрыто")
i.kind = "incident"
eq = db.get(Equipment, i.equipment_id)
# момент и наработка отказа — сейчас; время «работы с неисправностью» = escalated_at − occurred_at
i.escalated_at, i.hours_at_failure, i.precursor = config.now(), eq.meter_hours, "fault_report"
if data.note.strip():
i.description = (i.description + "\n\n" if i.description else "") + f"Переведено в отказ: {data.note.strip()}"
commit(db)
notify(db, eq, i, u.full_name or u.username, skip=u.id)
return inc_out(db, i)
def failure_at(i: Incident):
"""Момент отказа: для переведённой неисправности — момент перевода, иначе — время регистрации."""
return i.escalated_at or i.occurred_at
def apply_failure_data(db: Session, i: Incident, data: S.IncidentCloseIn, u: User, *, closing: bool):
"""Поля записи об отказе: проверки времён, узел-причина, заменённые узлы, показание при вводе."""
now = config.now()
sent = data.model_dump(exclude_unset=True)
failure = i.kind == "incident"
start = failure_at(i)
if "repair_started_at" in sent or (closing and failure and i.repair_started_at is None):
i.repair_started_at = data.repair_started_at or (start if failure else None)
if "restored_at" in sent or (closing and failure and i.restored_at is None):
i.restored_at = data.restored_at or (now if failure else None)
if "hours_at_failure" in sent or (closing and failure and i.hours_at_failure is None):
i.hours_at_failure = data.hours_at_failure if data.hours_at_failure is not None else i.hours_at_open
# хронология: отказ ≤ начало работ ≤ ввод ≤ сейчас
seq = [("момент отказа", start), ("начало работ", i.repair_started_at), ("ввод в эксплуатацию", i.restored_at)]
last_name, last = None, None
for name, t in seq:
if t is None:
continue
if t > now + timedelta(minutes=1):
raise HTTPException(422, f"«{name.capitalize()}» не может быть в будущем")
if last is not None and t < last.replace(second=0, microsecond=0): # в форме время с точностью до минуты
raise HTTPException(422, f"«{name.capitalize()}» не может быть раньше, чем «{last_name}»")
last_name, last = name, t
for k in ("parts_wait_hours", "action", "nature", "cause", "precursor"):
if k in sent:
setattr(i, k, getattr(data, k) if getattr(data, k) is not None else ("" if k != "parts_wait_hours" else None))
if i.escalated_at and not i.precursor:
i.precursor = "fault_report" # отказ получен переводом из неисправности
if closing and failure and not i.cause:
i.cause = "unknown"
if "cause_unit_id" in sent or "cause_unit_other" in sent:
if data.cause_unit_id:
su = db.get(ServiceUnit, data.cause_unit_id)
if not su or su.equipment_id != i.equipment_id:
raise HTTPException(422, "Узел-причина не относится к этой машине")
i.cause_unit_id = data.cause_unit_id or None
i.cause_unit_other = "" if data.cause_unit_id else data.cause_unit_other.strip()
at = i.restored_at or now
if data.meter_at_restore is not None and data.meter_at_restore != i.meter_at_restore:
eq = db.get(Equipment, i.equipment_id)
try:
service.record_meter(db, eq, data.meter_at_restore, at=at, user_id=u.id, note=f"Ввод в эксплуатацию после отказа №{i.id}")
except ValueError as e:
raise HTTPException(422, str(e))
i.meter_at_restore = data.meter_at_restore
for uid in dict.fromkeys(data.replaced_units): # замена узла при ремонте — ресурс отсчитывается заново
su = db.get(ServiceUnit, uid)
if not su or su.equipment_id != i.equipment_id:
raise HTTPException(422, "Узел не относится к этой машине")
service.reset_unit(db, su, at=at, hours=data.meter_at_restore, kind="incident", user_id=u.id, note=f"Замена при отказе №{i.id}: {i.title}"[:1000])
iu = next((x for x in i.units if x.unit_id == su.id), None)
if iu:
iu.replaced = True
else:
i.units.append(IncidentUnit(unit_id=su.id, unit_name=su.name, replaced=True))
i.replaced = True
@router.post("/incidents/{id}/close", response_model=S.IncidentOut)
def close_incident(id: int, data: S.IncidentCloseIn, db: Session = Depends(get_db), u: User = Depends(current_user)):
"""Закрыть отказ или неисправность. Для отказа — данные для анализа надёжности (см. IncidentCloseIn)."""
i = get_or_404(db, Incident, id)
if not (i.author_id == u.id or is_manager(u) or is_engineer(u)):
raise HTTPException(403, "Закрыть может автор или инженер")
if i.status == "closed":
raise HTTPException(409, "Запись уже закрыта")
apply_failure_data(db, i, data, u, closing=True)
i.status, i.closed_at, i.closed_by_id, i.close_note = "closed", config.now(), u.id, data.note.strip()
commit(db)
return inc_out(db, i)
@router.patch("/incidents/{id}/failure", response_model=S.IncidentOut)
def edit_failure(id: int, data: S.IncidentCloseIn, db: Session = Depends(get_db), u: User = Depends(require_engineer)):
"""Инженер или руководитель уточняет данные отказа (в том числе у закрытой записи)."""
i = get_or_404(db, Incident, id)
apply_failure_data(db, i, data, u, closing=False)
if "note" in data.model_fields_set and i.status == "closed":
i.close_note = data.note.strip()
commit(db)
return inc_out(db, i)
@router.delete("/incidents/{id}", status_code=204)
def delete_incident(id: int, db: Session = Depends(get_db), _: User = Depends(require_manager)):
db.delete(get_or_404(db, Incident, id))
commit(db)
# ---------- сводное состояние машин (карта, карточка) ----------
def equipment_health(db: Session) -> dict[int, dict]:
"""По каждой машине: открытые инциденты и неисправности и худшее состояние узлов (due/soon/ok).
Состояние: alarm (открыт инцидент) > fault (открыто сообщение о неисправности) > due > soon > ok."""
cnt = {}
for eid, kind, n in db.execute(select(Incident.equipment_id, Incident.kind, func.count()).where(Incident.status == "open")
.group_by(Incident.equipment_id, Incident.kind)).all():
cnt[(eid, kind)] = n
open_cnt = {eid: n for (eid, k), n in cnt.items() if k != "fault"}
fault_cnt = {eid: n for (eid, k), n in cnt.items() if k == "fault"}
eqs = {e.id: e for e in db.scalars(select(Equipment)).all()}
out = {eid: {"open_incidents": open_cnt.get(eid, 0), "open_faults": fault_cnt.get(eid, 0), "due": 0, "soon": 0} for eid in eqs}
rates: dict[int, float | None] = {}
for su in db.scalars(select(ServiceUnit).where(ServiceUnit.is_active.is_(True))).all():
eq = eqs.get(su.equipment_id)
if not eq:
continue
if su.equipment_id not in rates:
rates[su.equipment_id] = service.daily_rate(db, su.equipment_id)
st = service.evaluate(su, eq.meter_hours or 0, rates[su.equipment_id])["state"]
if st in ("due", "soon"):
out[su.equipment_id][st] += 1
for v in out.values():
v["state"] = ("alarm" if v["open_incidents"] else "fault" if v["open_faults"] else "due" if v["due"]
else "soon" if v["soon"] else "ok")
return out
@router.get("/equipment-health")
def health(db: Session = Depends(get_db), u: User = Depends(current_user)):
"""{equipment_id: {state: alarm|fault|due|soon|ok, open_incidents, open_faults, due, soon}}"""
visible = scope.equipment_ids(db, u)
return {k: v for k, v in equipment_health(db).items() if visible is None or k in visible}
+357
View File
@@ -0,0 +1,357 @@
"""Инструмент и инвентарь: каталог, склады, остатки, выдача сотрудникам (лично), журнал движения, заявки на покупку.
Править может только администратор склада (или администратор). Сотрудник видит каталог, то, что выдано ему,
и свои заявки. Остатки складов видят также инженеры, модераторы — для планирования."""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .. import config, push, schemas as S
from ..db import get_db
from ..models import InventoryItem, InventoryMove, Stock, ToolRequest, User, Warehouse
from ..security import current_user, is_engineer, is_manager, is_storekeeper, require_storekeeper
from .catalog import commit, get_or_404
router = APIRouter(tags=["inventory"])
EPS = 1e-9
def can_see_stock(u: User) -> bool:
return is_storekeeper(u) or is_manager(u) or is_engineer(u)
def user_names(db: Session) -> dict[int, str]:
return {i: (n or un) for i, n, un in db.execute(select(User.id, User.full_name, User.username))}
# ---------- склады ----------
def wh_out(db: Session, w: Warehouse) -> S.WarehouseOut:
out = S.WarehouseOut.model_validate(w)
cnt, total = db.execute(select(func.count(Stock.id), func.coalesce(func.sum(Stock.qty), 0))
.where(Stock.warehouse_id == w.id, Stock.qty > EPS)).one()
out.positions, out.total_qty = cnt, float(total)
return out
@router.get("/warehouses", response_model=list[S.WarehouseOut])
def list_warehouses(db: Session = Depends(get_db), _: User = Depends(current_user)):
return [wh_out(db, w) for w in db.scalars(select(Warehouse).order_by(Warehouse.name)).all()]
@router.post("/warehouses", response_model=S.WarehouseOut, status_code=201)
def create_warehouse(data: S.WarehouseIn, db: Session = Depends(get_db), _: User = Depends(require_storekeeper)):
w = Warehouse(**data.model_dump())
db.add(w)
commit(db)
return wh_out(db, w)
@router.patch("/warehouses/{id}", response_model=S.WarehouseOut)
def update_warehouse(id: int, data: S.partial(S.WarehouseIn), db: Session = Depends(get_db),
_: User = Depends(require_storekeeper)):
w = get_or_404(db, Warehouse, id)
for k, v in data.model_dump(exclude_unset=True).items():
if v is not None:
setattr(w, k, v)
commit(db)
return wh_out(db, w)
@router.put("/warehouses/{id}/zone", response_model=S.WarehouseOut)
def set_zone(id: int, data: S.ZoneIn, db: Session = Depends(get_db), _: User = Depends(require_storekeeper)):
"""Зона склада на карте завода (прямоугольник в % от картинки); null — убрать."""
w = get_or_404(db, Warehouse, id)
z = data.zone
if z and (z.x + z.w > 100.5 or z.y + z.h > 100.5):
raise HTTPException(422, "Зона выходит за пределы карты")
w.zone = z.model_dump() if z else None
commit(db)
return wh_out(db, w)
@router.delete("/warehouses/{id}", status_code=204)
def delete_warehouse(id: int, db: Session = Depends(get_db), _: User = Depends(require_storekeeper)):
w = get_or_404(db, Warehouse, id)
if db.scalar(select(Stock.id).where(Stock.warehouse_id == id, Stock.qty > EPS).limit(1)):
raise HTTPException(409, "На складе есть инвентарь — сначала переместите или спишите его")
db.execute(Stock.__table__.delete().where(Stock.warehouse_id == id))
if db.scalar(select(InventoryMove.id).where((InventoryMove.from_warehouse_id == id) | (InventoryMove.to_warehouse_id == id)).limit(1)):
w.is_active = False # есть история движения — склад только скрываем
else:
db.delete(w)
commit(db)
# ---------- каталог ----------
def item_totals(db: Session) -> dict[int, tuple[float, float]]:
rows = db.execute(select(Stock.item_id, Stock.warehouse_id, func.sum(Stock.qty)).where(Stock.qty > EPS)
.group_by(Stock.item_id, Stock.warehouse_id)).all()
out: dict[int, list[float]] = {}
for item_id, wh, q in rows:
t = out.setdefault(item_id, [0.0, 0.0])
t[0 if wh else 1] += float(q)
return {k: (v[0], v[1]) for k, v in out.items()}
@router.get("/inventory/items", response_model=list[S.InventoryItemOut])
def list_items(q: Optional[str] = Query(None, max_length=100), db: Session = Depends(get_db), u: User = Depends(current_user)):
stmt = select(InventoryItem).order_by(InventoryItem.category, InventoryItem.name)
if q and q.strip():
like = f"%{q.strip().lower()}%"
stmt = stmt.where(func.lower(InventoryItem.name).like(like) | func.lower(InventoryItem.category).like(like)
| func.lower(InventoryItem.inventory_no).like(like))
totals = item_totals(db) if can_see_stock(u) else {}
out = []
for it in db.scalars(stmt).all():
o = S.InventoryItemOut.model_validate(it)
o.in_stock, o.issued = totals.get(it.id, (0.0, 0.0))
out.append(o)
return out
@router.post("/inventory/items", response_model=S.InventoryItemOut, status_code=201)
def create_item(data: S.InventoryItemIn, db: Session = Depends(get_db), _: User = Depends(require_storekeeper)):
it = InventoryItem(**data.model_dump())
db.add(it)
commit(db)
return it
@router.patch("/inventory/items/{id}", response_model=S.InventoryItemOut)
def update_item(id: int, data: S.partial(S.InventoryItemIn), db: Session = Depends(get_db),
_: User = Depends(require_storekeeper)):
it = get_or_404(db, InventoryItem, id)
for k, v in data.model_dump(exclude_unset=True).items():
if v is not None:
setattr(it, k, v)
commit(db)
return it
@router.delete("/inventory/items/{id}", status_code=204)
def delete_item(id: int, db: Session = Depends(get_db), _: User = Depends(require_storekeeper)):
it = get_or_404(db, InventoryItem, id)
if db.scalar(select(Stock.id).where(Stock.item_id == id, Stock.qty > EPS).limit(1)):
raise HTTPException(409, "Позиция есть на складе или выдана — сначала спишите её")
db.execute(Stock.__table__.delete().where(Stock.item_id == id))
if db.scalar(select(InventoryMove.id).where(InventoryMove.item_id == id).limit(1)):
it.is_active = False # история движения сохраняется, позиция скрывается
else:
db.delete(it)
commit(db)
# ---------- остатки ----------
def stock_rows(db: Session, stmt) -> list[S.StockOut]:
names = user_names(db)
whs = dict(db.execute(select(Warehouse.id, Warehouse.name)).all())
out = []
for st, it in db.execute(stmt.join(InventoryItem, InventoryItem.id == Stock.item_id)
.add_columns(InventoryItem).where(Stock.qty > EPS)
.order_by(InventoryItem.category, InventoryItem.name)).all():
out.append(S.StockOut(item_id=it.id, item_name=it.name, category=it.category, unit=it.unit,
inventory_no=it.inventory_no, warehouse_id=st.warehouse_id,
warehouse_name=whs.get(st.warehouse_id), user_id=st.user_id,
user_name=names.get(st.user_id), qty=st.qty, since=st.since))
return out
@router.get("/inventory/stock", response_model=list[S.StockOut])
def stock(warehouse_id: Optional[int] = None, user_id: Optional[int] = None, item_id: Optional[int] = None,
db: Session = Depends(get_db), u: User = Depends(current_user)):
"""Остатки. Сотрудник без прав склада видит только выданное ему."""
if not can_see_stock(u):
if warehouse_id or (user_id and user_id != u.id):
raise HTTPException(403, "Вы видите только инвентарь, выданный вам")
user_id = u.id
stmt = select(Stock)
if warehouse_id:
stmt = stmt.where(Stock.warehouse_id == warehouse_id)
if user_id:
stmt = stmt.where(Stock.user_id == user_id)
if item_id:
stmt = stmt.where(Stock.item_id == item_id)
return stock_rows(db, stmt)
@router.get("/me/inventory", response_model=list[S.StockOut])
def my_inventory(db: Session = Depends(get_db), u: User = Depends(current_user)):
return stock_rows(db, select(Stock).where(Stock.user_id == u.id))
# ---------- движение ----------
def check_place(db: Session, p: S.Place | None, what: str) -> S.Place:
if not p or bool(p.warehouse_id) == bool(p.user_id):
raise HTTPException(422, f"{what}: укажите склад или сотрудника (одно из двух)")
if p.warehouse_id:
w = get_or_404(db, Warehouse, p.warehouse_id)
if not w.is_active:
raise HTTPException(422, "Склад закрыт")
else:
usr = get_or_404(db, User, p.user_id)
if not usr.is_active:
raise HTTPException(422, "Сотрудник отключён")
return p
def holding(db: Session, item_id: int, p: S.Place) -> Stock | None:
stmt = select(Stock).where(Stock.item_id == item_id)
if p.warehouse_id:
stmt = stmt.where(Stock.warehouse_id == p.warehouse_id, Stock.user_id.is_(None))
else:
stmt = stmt.where(Stock.user_id == p.user_id, Stock.warehouse_id.is_(None))
return db.scalar(stmt)
def take(db: Session, item: InventoryItem, p: S.Place, qty: float):
st = holding(db, item.id, p)
have = st.qty if st else 0
if have + EPS < qty:
raise HTTPException(422, f"Недостаточно: «{item.name}» в наличии {have:g} {item.unit}")
st.qty = round(have - qty, 6)
st.since = config.now()
def put(db: Session, item: InventoryItem, p: S.Place, qty: float):
st = holding(db, item.id, p)
if not st:
st = Stock(item_id=item.id, warehouse_id=p.warehouse_id, user_id=p.user_id, qty=0)
db.add(st)
st.qty = round((st.qty or 0) + qty, 6)
st.since = config.now()
def move_kind(src: S.Place, dst: S.Place) -> str:
if src.warehouse_id and dst.user_id:
return "issue"
if src.user_id and dst.warehouse_id:
return "return"
return "transfer"
@router.post("/inventory/moves", response_model=list[S.StockOut], status_code=201)
def make_move(data: S.MoveIn, db: Session = Depends(get_db), me: User = Depends(require_storekeeper)):
"""Поступление, выдача/возврат/перемещение, списание. Возвращает остатки позиции после операции."""
item = get_or_404(db, InventoryItem, data.item_id)
src = dst = None
if data.kind in ("move", "writeoff"):
src = check_place(db, data.src, "Откуда")
if data.kind in ("move", "receive"):
dst = check_place(db, data.dst, "Куда")
if data.kind == "receive" and not dst.warehouse_id:
raise HTTPException(422, "Поступление оформляется на склад")
if src and dst and src.model_dump() == dst.model_dump():
raise HTTPException(422, "Откуда и куда совпадают")
if src:
take(db, item, src, data.qty)
if dst:
put(db, item, dst, data.qty)
kind = move_kind(src, dst) if data.kind == "move" else data.kind
db.add(InventoryMove(item_id=item.id, qty=data.qty, kind=kind,
from_warehouse_id=src.warehouse_id if src else None, from_user_id=src.user_id if src else None,
to_warehouse_id=dst.warehouse_id if dst else None, to_user_id=dst.user_id if dst else None,
by_user_id=me.id, note=data.note.strip()))
commit(db)
if kind == "issue":
push.notify([dst.user_id], "Вам выдан инвентарь", f"{item.name}: {data.qty:g} {item.unit}", "/#/my-tools")
return stock_rows(db, select(Stock).where(Stock.item_id == item.id))
KIND_RU = {"receive": "Поступление", "issue": "Выдача", "return": "Возврат", "transfer": "Перемещение", "writeoff": "Списание"}
@router.get("/inventory/moves", response_model=list[S.MoveOut])
def list_moves(item_id: Optional[int] = None, warehouse_id: Optional[int] = None, user_id: Optional[int] = None,
limit: int = Query(200, ge=1, le=1000), db: Session = Depends(get_db), u: User = Depends(current_user)):
if not can_see_stock(u):
if warehouse_id or (user_id and user_id != u.id):
raise HTTPException(403, "Недостаточно прав")
user_id = u.id
stmt = select(InventoryMove).order_by(InventoryMove.at.desc(), InventoryMove.id.desc()).limit(limit)
if item_id:
stmt = stmt.where(InventoryMove.item_id == item_id)
if warehouse_id:
stmt = stmt.where((InventoryMove.from_warehouse_id == warehouse_id) | (InventoryMove.to_warehouse_id == warehouse_id))
if user_id:
stmt = stmt.where((InventoryMove.from_user_id == user_id) | (InventoryMove.to_user_id == user_id))
names = user_names(db)
whs = dict(db.execute(select(Warehouse.id, Warehouse.name)).all())
items = {i.id: i for i in db.scalars(select(InventoryItem)).all()}
def place(w, usr):
if w:
return "склад «" + whs.get(w, "?") + "»"
if usr:
return names.get(usr, "?")
return "—"
out = []
for m in db.scalars(stmt).all():
it = items.get(m.item_id)
out.append(S.MoveOut(id=m.id, item_id=m.item_id, item_name=it.name if it else "?", unit=it.unit if it else "",
qty=m.qty, kind=m.kind, from_text=place(m.from_warehouse_id, m.from_user_id),
to_text=place(m.to_warehouse_id, m.to_user_id), by_name=names.get(m.by_user_id),
at=m.at, note=m.note))
return out
# ---------- заявки на инструмент ----------
REQ_RU = {"new": "Новая", "approved": "Одобрена", "ordered": "Заказано", "done": "Получено", "rejected": "Отклонена"}
def can_handle_requests(u: User) -> bool:
return is_storekeeper(u) or is_manager(u)
def req_out(r: ToolRequest, names: dict) -> S.ToolRequestOut:
o = S.ToolRequestOut.model_validate(r)
o.author_name, o.handled_by_name = names.get(r.author_id), names.get(r.handled_by_id)
return o
@router.get("/tool-requests", response_model=list[S.ToolRequestOut])
def list_requests(status: Optional[S.RequestStatus] = None, mine: bool = False,
db: Session = Depends(get_db), u: User = Depends(current_user)):
"""Ответственные (склад, модераторы, админ) видят все заявки, остальные — только свои."""
stmt = select(ToolRequest).order_by(ToolRequest.id.desc())
if mine or not can_handle_requests(u):
stmt = stmt.where(ToolRequest.author_id == u.id)
if status:
stmt = stmt.where(ToolRequest.status == status)
names = user_names(db)
return [req_out(r, names) for r in db.scalars(stmt).all()]
@router.post("/tool-requests", response_model=S.ToolRequestOut, status_code=201)
def create_request(data: S.ToolRequestIn, db: Session = Depends(get_db), u: User = Depends(current_user)):
r = ToolRequest(author_id=u.id, what=data.what.strip(), why=data.why.strip(), link=data.link, qty=data.qty)
db.add(r)
commit(db)
keepers = [x for x, in db.execute(select(User.id).where(User.is_active.is_(True),
User.is_storekeeper.is_(True) | User.is_admin.is_(True)))]
push.notify([k for k in keepers if k != u.id], "Новая заявка на инструмент",
f"{u.full_name or u.username}: {r.what[:100]}", "/#/requests")
return req_out(r, user_names(db))
@router.post("/tool-requests/{id}/answer", response_model=S.ToolRequestOut)
def answer_request(id: int, data: S.ToolRequestAnswer, db: Session = Depends(get_db), u: User = Depends(current_user)):
if not can_handle_requests(u):
raise HTTPException(403, "Заявки рассматривает администратор склада")
r = get_or_404(db, ToolRequest, id)
if data.status == "rejected" and not data.answer.strip():
raise HTTPException(422, "Укажите причину отклонения")
r.status, r.answer, r.handled_by_id, r.updated_at = data.status, data.answer.strip(), u.id, config.now()
commit(db)
push.notify([r.author_id], f"Заявка на инструмент: {REQ_RU[r.status]}", r.what[:100], "/#/requests")
return req_out(r, user_names(db))
@router.delete("/tool-requests/{id}", status_code=204)
def delete_request(id: int, db: Session = Depends(get_db), u: User = Depends(current_user)):
r = get_or_404(db, ToolRequest, id)
if not (can_handle_requests(u) or (r.author_id == u.id and r.status == "new")):
raise HTTPException(403, "Удалить можно только свою новую заявку")
db.delete(r)
commit(db)
+291
View File
@@ -0,0 +1,291 @@
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func, or_
from sqlalchemy.orm import Session
from .. import schemas as S, logic, config, push, service
from sqlalchemy.orm import object_session
from ..db import get_db
from ..models import Order, User, Position, Equipment, Location, Comment
from ..security import current_user, require_engineer
from .catalog import commit
router = APIRouter(prefix="/orders")
ACTIVE = ("new", "in_progress", "review")
def names(db: Session) -> dict:
return {
"user": {i: (n or un) for i, n, un in db.execute(select(User.id, User.full_name, User.username))},
"pos": dict(db.execute(select(Position.id, Position.name)).all()),
"eq": dict(db.execute(select(Equipment.id, Equipment.name)).all()),
"loc": dict(db.execute(select(Location.id, Location.name)).all()),
"cm": dict(db.execute(select(Comment.order_id, func.count()).group_by(Comment.order_id)).all()),
}
def can_execute(u: User, o: Order) -> bool:
if o.status not in ("new", "in_progress") or u.is_moderator:
return False # модератор наряды не выполняет
if o.executor_id is not None:
return o.executor_id == u.id
if o.assignee_user_id is not None:
return o.assignee_user_id == u.id
return bool(o.assignee_position_id and u.position_id == o.assignee_position_id and logic.user_on_shift(u))
def to_out(o: Order, u: User, nm: dict, with_items: bool = False) -> S.OrderOut:
now = config.now()
assignee = nm["user"].get(o.assignee_user_id) or nm["pos"].get(o.assignee_position_id)
reviewer = nm["user"].get(o.reviewer_user_id) or nm["pos"].get(o.reviewer_position_id)
return S.OrderOut(
id=o.id, title=o.title, status=o.status,
is_overdue=bool(o.due_at and o.due_at < now and o.status in ACTIVE),
template_id=o.template_id, checklist_id=o.checklist_id,
equipment_id=o.equipment_id, equipment_name=nm["eq"].get(o.equipment_id),
location_id=o.location_id, location_name=nm["loc"].get(o.location_id),
assignee_user_id=o.assignee_user_id, assignee_position_id=o.assignee_position_id,
assignee_name=assignee,
reviewer_user_id=o.reviewer_user_id, reviewer_position_id=o.reviewer_position_id,
reviewer_name=reviewer,
executor_id=o.executor_id, executor_name=nm["user"].get(o.executor_id),
review_comment=o.review_comment, created_at=o.created_at, updated_at=o.updated_at, due_at=o.due_at,
started_at=o.started_at, submitted_at=o.submitted_at, completed_at=o.completed_at,
comments_count=nm["cm"].get(o.id, 0),
done_count=sum(logic.item_done(i) for i in o.items), total_count=len(o.items),
can_execute=can_execute(u, o),
can_review=o.status == "review" and logic.can_review(u, o),
items=[S.OrderItemOut.model_validate(i) for i in o.items] if with_items else None,
)
def load(db: Session, id: int, u: User) -> Order:
q = select(Order).where(Order.id == id)
flt = logic.visible_orders_filter(u)
if flt is not None:
q = q.where(flt)
o = db.scalar(q)
if not o:
raise HTTPException(404, "Наряд не найден")
return o
@router.get("", response_model=list[S.OrderOut])
def list_orders(status: Optional[str] = None, mine: bool = False, overdue: bool = False,
equipment_id: Optional[int] = None, template_id: Optional[int] = None,
to_review: bool = False, q: Optional[str] = Query(None, max_length=100, description="Поиск по названию наряда и оборудованию"),
updated_since: Optional[S.datetime] = None, include_items: bool = False,
limit: int = Query(200, le=1000), offset: int = 0,
db: Session = Depends(get_db), u: User = Depends(current_user)):
search, q = (q or "").strip(), select(Order)
flt = logic.visible_orders_filter(u)
if flt is not None:
q = q.where(flt)
if search:
like = "%" + search.lower().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
conds = [func.lower(Order.title).like(like, escape="\\"),
Order.equipment_id.in_(select(Equipment.id).where(func.lower(Equipment.name).like(like, escape="\\")))]
if search.lstrip("#").isdigit(): # "12" или "#12" — номер наряда
conds.append(Order.id == int(search.lstrip("#")))
q = q.where(or_(*conds))
if status:
q = q.where(Order.status.in_(status.split(",")))
if equipment_id:
q = q.where(Order.equipment_id == equipment_id)
if template_id:
q = q.where(Order.template_id == template_id)
if mine:
q = q.where((Order.executor_id == u.id) | (Order.assignee_user_id == u.id) |
((Order.assignee_position_id == u.position_id) & Order.executor_id.is_(None)))
if overdue:
q = q.where(Order.due_at < config.now(), Order.status.in_(ACTIVE))
if updated_since:
q = q.where(func.coalesce(Order.updated_at, Order.created_at) > updated_since)
q = q.order_by(Order.due_at.is_(None), Order.due_at, Order.id.desc()).limit(limit).offset(offset)
rows = db.scalars(q).all()
nm = names(db)
out = [to_out(o, u, nm, with_items=include_items) for o in rows]
if to_review:
out = [o for o in out if o.can_review]
return out
@router.get("/stats")
def stats(db: Session = Depends(get_db), u: User = Depends(current_user)):
q = select(Order.status, func.count()).group_by(Order.status)
flt = logic.visible_orders_filter(u)
if flt is not None:
q = q.where(flt)
by_status = dict(db.execute(q).all())
oq = select(func.count()).select_from(Order).where(Order.due_at < config.now(), Order.status.in_(ACTIVE))
if flt is not None:
oq = oq.where(flt)
return {"by_status": by_status, "overdue": db.scalar(oq)}
@router.get("/{id}", response_model=S.OrderOut)
def get_order(id: int, db: Session = Depends(get_db), u: User = Depends(current_user)):
return to_out(load(db, id, u), u, names(db), with_items=True)
@router.post("", response_model=S.OrderOut, status_code=201)
def create_order(data: S.OrderIn, db: Session = Depends(get_db), u: User = Depends(require_engineer)):
try:
o = logic.create_order(db, **data.model_dump())
except ValueError as e:
raise HTTPException(422, str(e))
commit(db)
push.notify_assigned(db, o)
return to_out(o, u, names(db), with_items=True)
def exec_guard(o: Order, u: User):
if not can_execute(u, o):
raise HTTPException(403, "Вы не можете выполнять этот наряд (не назначен, не на смене или уже закрыт)")
def start(o: Order, u: User):
if o.executor_id is None:
o.executor_id = u.id
o.executor_position_id = u.position_id
if o.status == "new":
o.status = "in_progress"
o.started_at = config.now()
@router.post("/{id}/start", response_model=S.OrderOut)
def start_order(id: int, db: Session = Depends(get_db), u: User = Depends(current_user)):
o = load(db, id, u)
exec_guard(o, u)
start(o, u)
commit(db)
return to_out(o, u, names(db), with_items=True)
@router.put("/{id}/items/{item_id}", response_model=S.OrderOut)
def answer_item(id: int, item_id: int, data: S.AnswerIn, db: Session = Depends(get_db),
u: User = Depends(current_user)):
o = load(db, id, u)
exec_guard(o, u)
item = next((i for i in o.items if i.id == item_id), None)
if not item:
raise HTTPException(404, "Пункт не найден")
try:
item.value, item.out_of_range = logic.validate_answer(item, data.value)
except ValueError as e:
raise HTTPException(422, str(e))
item.answered_at = config.now() if item.value is not None else None
start(o, u)
commit(db)
return to_out(o, u, names(db), with_items=True)
def submit_problem(o: Order) -> str:
missing = [i for i in o.items if i.required and not logic.item_done(i)]
if missing:
return f"Не заполнены обязательные пункты: {len(missing)}"
if any(i.photo == "required" and not i.photos for i in o.items):
return "Не приложены обязательные фото"
return ""
def do_submit(o: Order, u: User):
start(o, u)
o.submitted_at = config.now()
if o.reviewer_user_id or o.reviewer_position_id:
o.status = "review"
else:
o.status, o.completed_at = "completed", o.submitted_at
service.apply_completed_order(object_session(o), o)
@router.post("/{id}/submit", response_model=S.OrderOut)
def submit(id: int, db: Session = Depends(get_db), u: User = Depends(current_user)):
o = load(db, id, u)
exec_guard(o, u)
problem = submit_problem(o)
if problem:
raise HTTPException(422, problem)
do_submit(o, u)
commit(db)
if o.status == "review":
push.notify(push.users_for_review(db, o.reviewer_user_id, o.reviewer_position_id),
"Наряд на проверку", o.title, f"/#/order/{o.id}")
return to_out(o, u, names(db), with_items=True)
@router.post("/{id}/review", response_model=S.OrderOut)
def review(id: int, data: S.ReviewIn, db: Session = Depends(get_db), u: User = Depends(current_user)):
o = load(db, id, u)
if o.status != "review" or not logic.can_review(u, o):
raise HTTPException(403, "Наряд не ожидает вашей проверки")
o.reviewed_by_id = u.id
o.review_comment = data.comment
if data.approve:
o.status, o.completed_at = "completed", config.now()
service.apply_completed_order(db, o)
else:
if not data.comment.strip():
raise HTTPException(422, "Укажите причину возврата")
o.status, o.submitted_at = "in_progress", None
o.returns = (o.returns or 0) + 1
commit(db)
if not data.approve:
push.notify([o.executor_id], "Наряд возвращён на доработку", f"{o.title}: {data.comment}", f"/#/order/{o.id}")
return to_out(o, u, names(db), with_items=True)
@router.post("/{id}/cancel", response_model=S.OrderOut)
def cancel(id: int, db: Session = Depends(get_db), u: User = Depends(require_engineer)):
o = load(db, id, u)
if o.status == "completed":
raise HTTPException(400, "Наряд уже завершён")
o.status = "cancelled"
commit(db)
return to_out(o, u, names(db), with_items=True)
@router.post("/{id}/sync", response_model=S.SyncOut)
def sync(id: int, data: S.SyncIn, db: Session = Depends(get_db), u: User = Depends(current_user)):
"""Пакетная отправка накопленных офлайн ответов одним запросом.
Каждый ответ применяется независимо. Если у пункта уже есть более поздний ответ
(answered_at на сервере новее, чем у клиента) — статус `stale`, значение не меняется.
Затем, если submit=true и всё заполнено, наряд сдаётся. Повторная отправка того же пакета безопасна.
"""
o = load(db, id, u)
exec_guard(o, u)
by_id = {i.id: i for i in o.items}
results = []
for r in data.items:
item = by_id.get(r.item_id)
if not item:
results.append(S.SyncItemResult(item_id=r.item_id, status="error", detail="Пункт не найден"))
continue
if r.answered_at and item.answered_at and item.answered_at > r.answered_at and item.value != r.value:
results.append(S.SyncItemResult(item_id=r.item_id, status="stale",
detail="На сервере более свежий ответ"))
continue
try:
item.value, item.out_of_range = logic.validate_answer(item, r.value)
except ValueError as e:
results.append(S.SyncItemResult(item_id=r.item_id, status="error", detail=str(e)))
continue
item.answered_at = (r.answered_at or config.now()) if item.value is not None else None
results.append(S.SyncItemResult(item_id=r.item_id, status="ok"))
if results:
start(o, u)
submitted, err = False, ""
if data.submit and o.status in ("new", "in_progress"):
err = submit_problem(o)
if not err:
do_submit(o, u)
submitted = True
commit(db)
if submitted and o.status == "review":
push.notify(push.users_for_review(db, o.reviewer_user_id, o.reviewer_position_id),
"Наряд на проверку", o.title, f"/#/order/{o.id}")
return S.SyncOut(results=results, submitted=submitted, submit_error=err,
order=to_out(o, u, names(db), with_items=True))
+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)
+84
View File
@@ -0,0 +1,84 @@
"""Раздел «Анализ надёжности» (инженеры и руководители; включается в настройках сервера)."""
from datetime import datetime, timedelta
from typing import Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import config, reliability as R, schemas as S, scope, settings_store as ss
from ..db import get_db
from ..models import Equipment, User
from ..security import require_engineer
router = APIRouter(prefix="/reliability", tags=["reliability"])
def enabled(db: Session) -> bool:
return ss.get(db, "reliability") != "0"
def guard(db: Session = Depends(get_db), u: User = Depends(require_engineer)) -> User:
if not enabled(db):
raise HTTPException(404, "Анализ надёжности выключен в настройках сервера")
return u
def period(date_from: Optional[datetime], date_to: Optional[datetime]) -> tuple[datetime, datetime]:
end = S._dt_in(date_to) if date_to else config.now()
start = S._dt_in(date_from) if date_from else end - timedelta(days=365)
if start >= end:
raise HTTPException(422, "Начало периода должно быть раньше конца")
return start, end
def machines(db: Session, u: User, department_id: Optional[int], cranes: Optional[bool], equipment_id: Optional[int]) -> set[int]:
"""Машины, которые видит пользователь (отделы), с фильтрами отчёта."""
stmt = select(Equipment.id)
cond = scope.equipment_filter(db, u)
if cond is not None:
stmt = stmt.where(cond)
if department_id:
stmt = stmt.where(Equipment.department_id.in_(scope.dept_tree(db, department_id)))
if cranes is not None:
stmt = stmt.where(Equipment.is_crane.is_(cranes))
if equipment_id:
stmt = stmt.where(Equipment.id == equipment_id)
return set(db.scalars(stmt))
@router.get("/dictionaries")
def dictionaries(_: User = Depends(guard)):
"""Справочники формы закрытия отказа: причины, характер, что сделано, признаки заранее."""
return {"cause": R.CAUSE_RU, "nature": R.NATURE_RU, "action": R.ACTION_RU, "precursor": R.PRECURSOR_RU}
@router.get("")
def summary(date_from: Optional[datetime] = Query(None, alias="from"), date_to: Optional[datetime] = Query(None, alias="to"),
department_id: Optional[int] = None, cranes: Optional[bool] = None, equipment_id: Optional[int] = None,
confidence: float = Query(0.9, ge=0.5, le=0.99), horizon_days: int = Query(30, ge=1, le=365),
db: Session = Depends(get_db), u: User = Depends(guard)):
"""Показатели надёжности по машинам и парку за период: λ, θ, оценки по малоотказному опыту, Tв, Kг, прогноз, причины."""
start, end = period(date_from, date_to)
return R.analyze(db, start, end, machines(db, u, department_id, cranes, equipment_id), confidence, horizon_days)
@router.get("/drift")
def drift(days: int = Query(180, ge=14, le=1095), horizon: int = Query(90, ge=7, le=730), department_id: Optional[int] = None,
cranes: Optional[bool] = None, equipment_id: Optional[int] = None, db: Session = Depends(get_db), u: User = Depends(guard)):
"""«Параметр — граница работоспособности»: дрейф числовых пунктов чек-листов к границе нормы и выходы за неё."""
return R.drift(db, machines(db, u, department_id, cranes, equipment_id), days, horizon)
@router.get("/export")
def export(kind: Literal["failures", "service"] = "failures",
date_from: Optional[datetime] = Query(None, alias="from"), date_to: Optional[datetime] = Query(None, alias="to"),
department_id: Optional[int] = None, cranes: Optional[bool] = None, equipment_id: Optional[int] = None,
db: Session = Depends(get_db), u: User = Depends(guard)):
"""CSV для расчётов: failures — отказы и неисправности (строка на запись); service — история обслуживания узлов."""
start, end = period(date_from, date_to)
ids = machines(db, u, department_id, cranes, equipment_id)
text = (R.export_failures if kind == "failures" else R.export_service_events)(db, start, end, ids)
name = f"{'failures' if kind == 'failures' else 'service_events'}_{start:%Y%m%d}_{end:%Y%m%d}.csv"
return Response(text, media_type="text/csv; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="{name}"'})
+135
View File
@@ -0,0 +1,135 @@
from datetime import timedelta
from typing import Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response
from sqlalchemy.orm import Session
from .. import config, report_exec as RE, report_people as RP, reports as R, schemas as S
from ..db import get_db
from ..models import Department, Equipment, Location, Position, User
from ..security import require_engineer
router = APIRouter(prefix="/reports", tags=["reports"])
CATALOG = [{"id": "completed-orders", "title": "Выполненные наряды",
"description": "Список: машина, чек-лист, кто выполнил, должность, когда. Период обязателен."},
{"id": "equipment-state", "title": "Текущее состояние",
"description": "Обслуживаемые узлы машин: что требует обслуживания сейчас, что скоро, остаток и прогноз."},
{"id": "execution", "title": "Выполнение работ",
"description": "По оборудованию: процент выполнения чек-листов, суточных осмотров, ТО и заданий, отклонения и комментарии."},
{"id": "people", "title": "По сотрудникам",
"description": "Количество и качество выполненных нарядов и задач, трудоёмкость; списком и календарём."}]
@router.get("")
def list_reports(_: User = Depends(require_engineer)):
return CATALOG
@router.get("/completed-orders")
def completed_orders(
date_from: S.datetime = Query(..., alias="from", description="Начало периода (дата и время)"),
date_to: S.datetime = Query(..., alias="to", description="Конец периода (дата и время)"),
equipment_id: Optional[int] = None, position_id: Optional[int] = None, user_id: Optional[int] = None,
include_review: bool = False, format: Literal["json", "pdf", "csv"] = "json",
db: Session = Depends(get_db), u: User = Depends(require_engineer)):
if date_to <= date_from:
raise HTTPException(422, "Конец периода должен быть позже начала")
if date_to - date_from > timedelta(days=366):
raise HTTPException(422, "Период не должен превышать 366 дней")
f = R.Filters(date_from, date_to, equipment_id, position_id, user_id, include_review)
rows = R.completed_orders(db, f)
stamp = config.now().strftime("%Y%m%d_%H%M")
if format == "pdf":
data = R.to_pdf("Отчёт: выполненные наряды", f, R.describe_filters(db, f), rows, u.full_name or u.username)
return Response(data, media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="completed_orders_{stamp}.pdf"'})
if format == "csv":
return Response(R.to_csv(rows), media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="completed_orders_{stamp}.csv"'})
return {"from": config.iso(date_from), "to": config.iso(date_to), "total": len(rows), "truncated": len(rows) >= R.MAX_ROWS,
"rows": [{**r, "finished_at": config.iso(r["finished_at"])} for r in rows]}
@router.get("/equipment-state")
def equipment_state(equipment_id: Optional[int] = None, location_id: Optional[int] = None,
state: Literal["", "due", "soon", "attention", "ok"] = "", include_inactive: bool = False,
format: Literal["json", "pdf", "csv"] = "json",
db: Session = Depends(get_db), u: User = Depends(require_engineer)):
"""Текущее состояние обслуживаемых узлов: что требует обслуживания, что скоро, остаток и прогноз."""
f = R.StateFilters(equipment_id, location_id, state, include_inactive)
all_rows = R.equipment_state(db, f) # сводка считается по всем узлам выбранных машин/мест, независимо от фильтра состояния
from .. import scope
visible = scope.equipment_ids(db, u) # оборудование чужих отделов не показываем
if visible is not None:
all_rows = [r for r in all_rows if r["equipment_id"] in visible]
rows = R.filter_state(all_rows, state)
stamp = config.now().strftime("%Y%m%d_%H%M")
if format == "csv":
return Response(R.state_csv(rows), media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="equipment_state_{stamp}.csv"'})
if format == "pdf":
txt = []
if equipment_id and (e := db.get(Equipment, equipment_id)):
txt.append(f"машина: {e.name}")
if location_id and (loc := db.get(Location, location_id)):
txt.append(f"место: {loc.name}")
txt.append({"": "все узлы", "due": "только «Требуется»", "soon": "только «Скоро»",
"attention": "«Требуется» и «Скоро»", "ok": "только «В норме»"}[state])
data = R.state_pdf(f, txt, rows, u.full_name or u.username)
return Response(data, media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="equipment_state_{stamp}.pdf"'})
summary = {k: sum(1 for r in all_rows if r["state"] == k) for k in ("due", "soon", "ok", "nodata")}
return {"generated_at": config.iso(config.now()), "total": len(rows), "summary": summary, "rows": rows}
@router.get("/execution")
def execution(date_from: S.datetime = Query(..., alias="from"), date_to: S.datetime = Query(..., alias="to"),
equipment_id: Optional[int] = None, location_id: Optional[int] = None,
format: Literal["json", "pdf", "csv"] = "json",
db: Session = Depends(get_db), u: User = Depends(require_engineer)):
"""Выполнение чек-листов, суточных осмотров, ТО и суточных заданий по оборудованию: % и отклонения."""
if date_to <= date_from:
raise HTTPException(422, "Конец периода должен быть позже начала")
if date_to - date_from > timedelta(days=366):
raise HTTPException(422, "Период не должен превышать 366 дней")
data = RE.execution(db, date_from, date_to, equipment_id, location_id)
stamp = config.now().strftime("%Y%m%d_%H%M")
if format == "csv":
return Response(RE.to_csv(data), media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="execution_{stamp}.csv"'})
if format == "pdf":
txt = []
if equipment_id and (e := db.get(Equipment, equipment_id)):
txt.append(f"машина: {e.name}")
if location_id and (loc := db.get(Location, location_id)):
txt.append(f"место: {loc.name}")
return Response(RE.to_pdf(data, date_from, date_to, txt, u.full_name or u.username), media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="execution_{stamp}.pdf"'})
return {"from": config.iso(date_from), "to": config.iso(date_to), **data}
@router.get("/people")
def people(date_from: S.datetime = Query(..., alias="from"), date_to: S.datetime = Query(..., alias="to"),
department_id: Optional[int] = None, position_id: Optional[int] = None, user_id: Optional[int] = None,
format: Literal["json", "pdf", "csv", "csv_calendar"] = "json",
db: Session = Depends(get_db), u: User = Depends(require_engineer)):
"""По сотрудникам: количество и качество выполненных нарядов и задач за период, трудоёмкость, календарь по дням."""
if date_to <= date_from:
raise HTTPException(422, "Конец периода должен быть позже начала")
if date_to - date_from > timedelta(days=366):
raise HTTPException(422, "Период не должен превышать 366 дней")
data = RP.people(db, date_from, date_to, department_id, position_id, user_id)
stamp = config.now().strftime("%Y%m%d_%H%M")
if format in ("csv", "csv_calendar"):
return Response(RP.to_csv(data, calendar=format == "csv_calendar"), media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="people_{stamp}.csv"'})
if format == "pdf":
txt = []
for model, val, label in ((Department, department_id, "отдел"), (Position, position_id, "должность"), (User, user_id, "сотрудник")):
if val and (x := db.get(model, val)):
txt.append(f"{label}: {getattr(x, 'full_name', None) or getattr(x, 'name', None) or getattr(x, 'username', '')}")
return Response(RP.to_pdf(data, date_from, date_to, txt, u.full_name or u.username), media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="people_{stamp}.pdf"'})
return {"from": config.iso(date_from), "to": config.iso(date_to), **data}
+135
View File
@@ -0,0 +1,135 @@
"""Регламентное обслуживание: узлы машин, их правила, наработка (моточасы), сброс и история."""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import config, schemas as S, service
from ..db import get_db
from ..models import Equipment, MeterReading, ServiceEvent, ServiceUnit, User
from ..security import current_user, require_engineer
from .catalog import commit, get_or_404
router = APIRouter(tags=["service"])
def unit_out(u: ServiceUnit, eq: Equipment, rate: float | None) -> S.ServiceUnitOut:
out = S.ServiceUnitOut.model_validate(u)
for k, v in service.evaluate(u, eq.meter_hours or 0, rate).items():
setattr(out, k, v)
return out
def check_rule(u: ServiceUnit):
"""Проверка правила после создания/правки (в PATCH поля приходят частично)."""
try:
S.ServiceUnitIn(name=u.name, mode=u.mode, interval_hours=u.interval_hours, interval_value=u.interval_value,
interval_unit=u.interval_unit).check_rule()
except ValueError as e:
raise HTTPException(422, str(e))
@router.get("/equipment/{id}/service-units", response_model=list[S.ServiceUnitOut])
def list_units(id: int, db: Session = Depends(get_db), _: User = Depends(current_user)):
eq = get_or_404(db, Equipment, id)
rate = service.daily_rate(db, id)
units = db.scalars(select(ServiceUnit).where(ServiceUnit.equipment_id == id).order_by(ServiceUnit.id)).all()
out = [unit_out(u, eq, rate) for u in units]
return sorted(out, key=lambda x: (not x.is_active, service.state_rank(x.state), -x.percent))
@router.post("/equipment/{id}/service-units", response_model=S.ServiceUnitOut, status_code=201)
def create_unit(id: int, data: S.ServiceUnitIn, db: Session = Depends(get_db), me: User = Depends(require_engineer)):
eq = get_or_404(db, Equipment, id)
try:
data.check_rule()
except ValueError as e:
raise HTTPException(422, str(e))
d = data.model_dump(exclude={"last_service_at", "last_service_hours"})
if data.mode == "hours":
d["interval_value"] = d["interval_unit"] = None
elif data.mode == "calendar":
d["interval_hours"] = None
u = ServiceUnit(equipment_id=id, **d)
at = data.last_service_at or config.now()
hours = eq.meter_hours or 0 if data.last_service_hours is None else data.last_service_hours
if hours > (eq.meter_hours or 0):
raise HTTPException(422, "Наработка при последнем обслуживании не может быть больше текущей наработки машины")
u.last_service_at, u.last_service_hours = at, hours
db.add(u)
db.flush()
db.add(ServiceEvent(unit_id=u.id, at=at, hours=hours, kind="initial", user_id=me.id, note="Начальное состояние"))
commit(db)
return unit_out(u, eq, service.daily_rate(db, id))
@router.patch("/service-units/{id}", response_model=S.ServiceUnitOut)
def update_unit(id: int, data: S.ServiceUnitPatch, db: Session = Depends(get_db), _: User = Depends(require_engineer)):
u = get_or_404(db, ServiceUnit, id)
for k, v in data.model_dump(exclude_unset=True).items():
if v is None and k in ("name", "mode", "warn_percent", "is_active"):
continue
setattr(u, k, v)
if u.mode == "hours":
u.interval_value = u.interval_unit = None
elif u.mode == "calendar":
u.interval_hours = None
check_rule(u)
commit(db)
eq = db.get(Equipment, u.equipment_id)
return unit_out(u, eq, service.daily_rate(db, eq.id))
@router.delete("/service-units/{id}", status_code=204)
def delete_unit(id: int, db: Session = Depends(get_db), _: User = Depends(require_engineer)):
u = get_or_404(db, ServiceUnit, id)
service.unlink_unit(db, id)
db.delete(u)
commit(db)
@router.post("/service-units/{id}/reset", response_model=S.ServiceUnitOut)
def reset(id: int, data: S.ServiceResetIn, db: Session = Depends(get_db), me: User = Depends(require_engineer)):
"""Сброс узла: обслуживание или замена выполнены отдельно от наряда либо при внештатной ситуации."""
u = get_or_404(db, ServiceUnit, id)
eq = db.get(Equipment, u.equipment_id)
if data.hours is not None and data.hours > (eq.meter_hours or 0):
raise HTTPException(422, "Наработка не может быть больше текущей наработки машины — сначала внесите показание счётчика")
if data.at and data.at > config.now():
raise HTTPException(422, "Дата обслуживания не может быть в будущем")
service.reset_unit(db, u, at=data.at, hours=data.hours, kind="reset", user_id=me.id, note=data.note.strip())
commit(db)
return unit_out(u, eq, service.daily_rate(db, eq.id))
@router.get("/service-units/{id}/events", response_model=list[S.ServiceEventOut])
def unit_events(id: int, limit: int = Query(100, ge=1, le=500), db: Session = Depends(get_db), _: User = Depends(current_user)):
get_or_404(db, ServiceUnit, id)
names = {i: (n or un) for i, n, un in db.execute(select(User.id, User.full_name, User.username))}
rows = db.scalars(select(ServiceEvent).where(ServiceEvent.unit_id == id).order_by(ServiceEvent.at.desc(), ServiceEvent.id.desc()).limit(limit)).all()
out = []
for e in rows:
o = S.ServiceEventOut.model_validate(e)
o.user_name = names.get(e.user_id)
out.append(o)
return out
# ---------- наработка (моточасы) ----------
@router.get("/equipment/{id}/meter", response_model=list[S.MeterOut])
def meter_history(id: int, limit: int = Query(50, ge=1, le=500), db: Session = Depends(get_db), _: User = Depends(current_user)):
get_or_404(db, Equipment, id)
return db.scalars(select(MeterReading).where(MeterReading.equipment_id == id)
.order_by(MeterReading.at.desc(), MeterReading.id.desc()).limit(limit)).all()
@router.post("/equipment/{id}/meter", response_model=S.EquipmentOut, status_code=201)
def add_meter(id: int, data: S.MeterIn, db: Session = Depends(get_db), me: User = Depends(require_engineer)):
eq = get_or_404(db, Equipment, id)
if data.at and data.at > config.now():
raise HTTPException(422, "Время показания не может быть в будущем")
try:
service.record_meter(db, eq, data.hours, at=data.at, user_id=me.id, note=data.note.strip(), correction=data.correction)
except ValueError as e:
raise HTTPException(422, str(e))
commit(db)
return eq
+271
View File
@@ -0,0 +1,271 @@
"""Суточные задания — простые поручения на день (не наряд, без чек-листа).
Статусы: назначен → в работе → приостановлен / выполнен / отменён. Переход в «приостановлен», «выполнен» и «отменён»
обязательно с комментарием. К заданию можно привязать машину или её узел и приложить фото.
Инженер, модератор и администратор ставят задания кому угодно; остальные — только себе."""
from datetime import date
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from .. import config, media, push, schemas as S
from ..db import get_db
from ..models import DailyTask, Equipment, ServiceUnit, TaskEvent, TaskPhoto, User
from ..security import current_user, is_engineer, is_manager
from .catalog import commit, get_or_404
from .inventory import user_names
router = APIRouter(tags=["tasks"])
STATUS_RU = {"assigned": "Назначен", "in_progress": "В работе", "paused": "Приостановлен", "done": "Выполнен", "cancelled": "Отменён"}
FINAL = ("done", "cancelled")
NEED_COMMENT = ("paused", "done", "cancelled")
ALLOWED = {
"assigned": {"in_progress", "paused", "done", "cancelled"},
"in_progress": {"paused", "done", "cancelled"},
"paused": {"in_progress", "done", "cancelled"},
"done": {"assigned"}, # вернуть в работу — только инженер
"cancelled": {"assigned"},
}
def is_boss(u: User) -> bool:
return is_manager(u) or is_engineer(u)
def can_see(u: User, t: DailyTask) -> bool:
return is_boss(u) or u.id in (t.assignee_id, t.created_by_id)
def can_edit(u: User, t: DailyTask) -> bool:
return t.status not in FINAL and (is_boss(u) or t.created_by_id == u.id)
def can_change(u: User, t: DailyTask) -> bool:
if t.status in FINAL:
return is_boss(u)
return is_boss(u) or u.id in (t.assignee_id, t.created_by_id)
def can_transfer(u: User, t: DailyTask) -> bool:
"""Передать задание другому: исполнитель, заказчик (кто поставил) или инженер/модератор/админ (проверяющие)."""
return t.status not in FINAL and (is_boss(u) or u.id in (t.assignee_id, t.created_by_id))
def load(db: Session, id: int, u: User) -> DailyTask:
t = db.get(DailyTask, id)
if not t or not can_see(u, t):
raise HTTPException(404, "Задание не найдено")
return t
def check_links(db: Session, d: dict, cur: DailyTask | None = None):
eq_id = d.get("equipment_id", cur.equipment_id if cur else None)
unit_id = d.get("unit_id", cur.unit_id if cur else None)
if eq_id:
get_or_404(db, Equipment, eq_id)
if unit_id:
su = db.get(ServiceUnit, unit_id)
if not su:
raise HTTPException(422, "Узел не найден")
if eq_id and su.equipment_id != eq_id:
raise HTTPException(422, "Узел не относится к выбранной машине")
d["equipment_id"] = su.equipment_id
def to_out(db: Session, t: DailyTask, u: User, names: dict, with_events: bool = False) -> S.TaskOut:
eq = db.get(Equipment, t.equipment_id) if t.equipment_id else None
su = db.get(ServiceUnit, t.unit_id) if t.unit_id else None
evs = db.scalars(select(TaskEvent).where(TaskEvent.task_id == t.id).order_by(TaskEvent.at, TaskEvent.id)).all()
last = next((e.comment for e in reversed(evs) if e.comment), "")
photos = [p for p, in db.execute(select(TaskPhoto.id).where(TaskPhoto.task_id == t.id).order_by(TaskPhoto.id))]
return S.TaskOut(
id=t.id, title=t.title, description=t.description, day=t.day, assignee_id=t.assignee_id,
assignee_name=names.get(t.assignee_id), created_by_id=t.created_by_id, created_by_name=names.get(t.created_by_id),
equipment_id=t.equipment_id, equipment_name=eq.name if eq else None, unit_id=t.unit_id,
unit_name=su.name if su else None, status=t.status, created_at=t.created_at, updated_at=t.updated_at,
finished_at=t.finished_at, labor_hours=t.labor_hours, last_comment=last, photos=photos, can_change=can_change(u, t), can_edit=can_edit(u, t),
can_transfer=can_transfer(u, t),
events=[S.TaskEventOut(at=e.at, user_name=names.get(e.user_id), from_status=e.from_status, to_status=e.to_status,
comment=e.comment, kind=e.kind or "status", from_user_name=names.get(e.from_user_id),
to_user_name=names.get(e.to_user_id)) for e in evs] if with_events else None)
@router.get("/tasks", response_model=list[S.TaskOut])
def list_tasks(day: Optional[date] = None, date_from: Optional[date] = Query(None, alias="from"),
date_to: Optional[date] = Query(None, alias="to"), assignee_id: Optional[int] = None,
equipment_id: Optional[int] = None, status: Optional[S.TaskStatus] = None, mine: bool = False,
created_by_me: bool = False,
active: bool = False, limit: int = Query(300, ge=1, le=2000),
db: Session = Depends(get_db), u: User = Depends(current_user)):
stmt = select(DailyTask).order_by(DailyTask.day.desc(), DailyTask.id.desc()).limit(limit)
if mine or not is_boss(u):
stmt = stmt.where(or_(DailyTask.assignee_id == u.id, DailyTask.created_by_id == u.id) if not mine
else DailyTask.assignee_id == u.id)
if created_by_me:
stmt = stmt.where(DailyTask.created_by_id == u.id)
if day:
stmt = stmt.where(DailyTask.day == day)
if date_from:
stmt = stmt.where(DailyTask.day >= date_from)
if date_to:
stmt = stmt.where(DailyTask.day <= date_to)
if assignee_id:
stmt = stmt.where(DailyTask.assignee_id == assignee_id)
if equipment_id:
stmt = stmt.where(DailyTask.equipment_id == equipment_id)
if status:
stmt = stmt.where(DailyTask.status == status)
if active:
stmt = stmt.where(DailyTask.status.not_in(FINAL))
names = user_names(db)
return [to_out(db, t, u, names) for t in db.scalars(stmt).all()]
@router.get("/tasks/{id}", response_model=S.TaskOut)
def get_task(id: int, db: Session = Depends(get_db), u: User = Depends(current_user)):
return to_out(db, load(db, id, u), u, user_names(db), with_events=True)
@router.post("/tasks", response_model=S.TaskOut, status_code=201)
def create_task(data: S.TaskIn, db: Session = Depends(get_db), u: User = Depends(current_user)):
d = data.model_dump()
d["assignee_id"] = d["assignee_id"] or u.id
if d["assignee_id"] != u.id and not is_boss(u):
raise HTTPException(403, "Ставить задания другим может инженер, модератор или администратор")
a = get_or_404(db, User, d["assignee_id"])
if not a.is_active:
raise HTTPException(422, "Сотрудник отключён")
check_links(db, d)
d["day"] = d["day"] or config.now().date()
t = DailyTask(**d, created_by_id=u.id)
db.add(t)
db.flush()
db.add(TaskEvent(task_id=t.id, user_id=u.id, from_status="", to_status="assigned"))
commit(db)
if t.assignee_id != u.id:
push.notify([t.assignee_id], "Новое задание", t.title[:120], f"/#/task/{t.id}")
return to_out(db, t, u, user_names(db), with_events=True)
@router.patch("/tasks/{id}", response_model=S.TaskOut)
def update_task(id: int, data: S.TaskPatch, db: Session = Depends(get_db), u: User = Depends(current_user)):
t = load(db, id, u)
if not can_edit(u, t):
raise HTTPException(403, "Изменять задание может его автор или инженер, пока оно не завершено")
d = data.model_dump(exclude_unset=True)
if d.get("assignee_id") and d["assignee_id"] != t.assignee_id:
if not is_boss(u):
raise HTTPException(403, "Переназначить задание может инженер, модератор или администратор")
get_or_404(db, User, d["assignee_id"])
if "unit_id" in d or "equipment_id" in d:
if d.get("equipment_id") is None and "equipment_id" in d:
d["unit_id"] = None
check_links(db, d, t)
for k, v in d.items():
if v is None and k in ("title", "day", "assignee_id", "description"):
continue
setattr(t, k, v)
t.updated_at = config.now()
commit(db)
return to_out(db, t, u, user_names(db), with_events=True)
@router.post("/tasks/{id}/status", response_model=S.TaskOut)
def change_status(id: int, data: S.TaskStatusIn, db: Session = Depends(get_db), u: User = Depends(current_user)):
t = load(db, id, u)
if not can_change(u, t):
raise HTTPException(403, "Нет прав менять статус этого задания")
if data.status == t.status:
raise HTTPException(409, "Задание уже в этом статусе")
if data.status not in ALLOWED[t.status]:
raise HTTPException(422, f"Нельзя перевести из «{STATUS_RU[t.status]}» в «{STATUS_RU[data.status]}»")
comment = data.comment.strip()
if (data.status in NEED_COMMENT or t.status in FINAL) and not comment:
raise HTTPException(422, f"Для статуса «{STATUS_RU[data.status]}» нужен комментарий")
db.add(TaskEvent(task_id=t.id, user_id=u.id, from_status=t.status, to_status=data.status, comment=comment))
t.status, t.updated_at = data.status, config.now()
t.finished_at = config.now() if data.status in FINAL else None
commit(db)
who = u.full_name or u.username
notify = {t.assignee_id, t.created_by_id} - {u.id, None}
push.notify(list(notify), f"Задание: {STATUS_RU[t.status]}", f"{who}: {t.title[:100]}", f"/#/task/{t.id}")
return to_out(db, t, u, user_names(db), with_events=True)
@router.post("/tasks/{id}/transfer", response_model=S.TaskOut)
def transfer(id: int, data: S.TaskTransferIn, db: Session = Depends(get_db), u: User = Depends(current_user)):
"""Передать задание другому сотруднику. Комментарий обязателен; начатое задание снова становится «назначен»."""
t = load(db, id, u)
if not can_transfer(u, t):
raise HTTPException(403, "Передать задание может исполнитель, тот, кто его поставил, или инженер (пока оно не завершено)")
if data.assignee_id == t.assignee_id:
raise HTTPException(409, "Задание уже у этого сотрудника")
to = get_or_404(db, User, data.assignee_id)
if not to.is_active:
raise HTTPException(422, "Сотрудник отключён")
comment = data.comment.strip()
if not comment:
raise HTTPException(422, "Укажите причину передачи")
old = t.assignee_id
db.add(TaskEvent(task_id=t.id, user_id=u.id, kind="transfer", from_status=t.status, to_status="assigned", comment=comment,
from_user_id=old, to_user_id=to.id))
t.assignee_id, t.status, t.updated_at = to.id, "assigned", config.now()
commit(db)
who = u.full_name or u.username
push.notify([to.id], "Вам передано задание", f"{who}: {t.title[:100]}", f"/#/task/{t.id}")
push.notify(list({old, t.created_by_id} - {u.id, to.id, None}), "Задание передано",
f"{t.title[:80]} → {to.full_name or to.username}", f"/#/task/{t.id}")
return to_out(db, t, u, user_names(db), with_events=True)
@router.delete("/tasks/{id}", status_code=204)
def delete_task(id: int, db: Session = Depends(get_db), u: User = Depends(current_user)):
t = load(db, id, u)
if not (is_boss(u) or (t.created_by_id == u.id and t.status == "assigned")):
raise HTTPException(403, "Удалить можно только своё ещё не начатое задание")
for p in db.scalars(select(TaskPhoto).where(TaskPhoto.task_id == t.id)).all():
media.remove(db, "tasks", p.path)
db.delete(p)
db.execute(TaskEvent.__table__.delete().where(TaskEvent.task_id == t.id))
db.delete(t)
commit(db)
# ---------- фото ----------
@router.post("/tasks/{id}/photos", response_model=S.TaskOut, status_code=201)
async def add_photo(id: int, request: Request, db: Session = Depends(get_db), u: User = Depends(current_user)):
t = load(db, id, u)
if not (is_boss(u) or u.id in (t.assignee_id, t.created_by_id)):
raise HTTPException(403, "Нет прав добавлять фото")
data, ct, ext = await media.read_image(db, request)
name = media.save(db, "tasks", data, ext)
db.add(TaskPhoto(task_id=t.id, path=name, content_type=ct, size=len(data), user_id=u.id))
t.updated_at = config.now()
commit(db)
return to_out(db, t, u, user_names(db), with_events=True)
def photo_for(db: Session, pid: int, u: User) -> TaskPhoto:
p = db.get(TaskPhoto, pid)
if not p:
raise HTTPException(404, "Фото не найдено")
load(db, p.task_id, u)
return p
@router.get("/task-photos/{pid}")
def get_photo(pid: int, db: Session = Depends(get_db), u: User = Depends(current_user)):
return media.respond(db, "tasks", photo_for(db, pid, u).path)
@router.delete("/task-photos/{pid}", status_code=204)
def delete_photo(pid: int, db: Session = Depends(get_db), u: User = Depends(current_user)):
p = photo_for(db, pid, u)
if not (is_boss(u) or p.user_id == u.id):
raise HTTPException(403, "Удалить можно только своё фото")
media.remove(db, "tasks", p.path)
db.delete(p)
commit(db)