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