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

This commit is contained in:
darkorion
2026-10-13 09:09:53 +03:00
commit c298f5a830
247 changed files with 23200 additions and 0 deletions
+257
View File
@@ -0,0 +1,257 @@
"""Инциденты (аварии) и сообщения о неисправности (kind=fault) на оборудовании.
Сотрудник фиксирует инцидент, придя на заявку: что произошло, какие узлы затронуты (из заведённых в карточке машины),
была ли замена. Заменённый узел сбрасывается, как при ручном сбросе. Пока инцидент открыт, машина в состоянии «Авария»."""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .. import config, push, schemas as S, scope, service
from ..db import get_db
from ..models import Equipment, Incident, IncidentUnit, ServiceUnit, User
from datetime import timedelta
from ..security import current_user, is_engineer, is_manager, require_engineer, require_manager
from .catalog import commit, get_or_404
from .inventory import user_names
router = APIRouter(tags=["incidents"])
def inc_out(db: Session, i: Incident, names: dict | None = None) -> S.IncidentOut:
names = names if names is not None else user_names(db)
o = S.IncidentOut.model_validate(i)
eq = db.get(Equipment, i.equipment_id)
o.equipment_name = eq.name if eq else None
o.author_name, o.closed_by_name = names.get(i.author_id), names.get(i.closed_by_id)
if i.cause_unit_id:
su = db.get(ServiceUnit, i.cause_unit_id)
o.cause_unit_name = su.name if su else None
return o
@router.get("/incidents", response_model=list[S.IncidentOut])
def list_incidents(equipment_id: Optional[int] = None, status: Optional[str] = Query(None, pattern="^(open|closed)$"),
mine: bool = False, source: Optional[str] = Query(None, pattern="^(staff|public|api)$"),
q: Optional[str] = Query(None, max_length=100), cranes: Optional[bool] = None,
kind: Optional[str] = Query(None, pattern="^(incident|fault)$"),
limit: int = Query(200, ge=1, le=1000), db: Session = Depends(get_db), u: User = Depends(current_user)):
stmt = select(Incident).order_by(Incident.occurred_at.desc(), Incident.id.desc()).limit(limit)
visible = scope.equipment_ids(db, u) # оборудование чужих отделов не показываем
if visible is not None:
stmt = stmt.where(Incident.equipment_id.in_(visible))
if equipment_id:
stmt = stmt.where(Incident.equipment_id == equipment_id)
if status:
stmt = stmt.where(Incident.status == status)
if mine:
stmt = stmt.where(Incident.author_id == u.id)
if source:
stmt = stmt.where(Incident.source == source)
if kind:
stmt = stmt.where(Incident.kind == kind)
if cranes is not None: # true — только краны, false — всё, кроме кранов (крановое хозяйство — отдельная служба)
stmt = stmt.where(Incident.equipment_id.in_(select(Equipment.id).where(Equipment.is_crane.is_(cranes))))
if q and q.strip():
like = f"%{q.strip().lower()}%"
eq_ids = [i for i, in db.execute(select(Equipment.id).where(func.lower(Equipment.name).like(like) | func.lower(Equipment.inventory_no).like(like)))]
stmt = stmt.where(Incident.equipment_id.in_(eq_ids) | func.lower(Incident.title).like(like))
names = user_names(db)
return [inc_out(db, i, names) for i in db.scalars(stmt).all()]
@router.get("/incidents/{id}", response_model=S.IncidentOut)
def get_incident(id: int, db: Session = Depends(get_db), _: User = Depends(current_user)):
return inc_out(db, get_or_404(db, Incident, id))
@router.post("/incidents", response_model=S.IncidentOut, status_code=201)
def create_incident(data: S.IncidentIn, db: Session = Depends(get_db), u: User = Depends(current_user)):
eq = get_or_404(db, Equipment, data.equipment_id)
at = data.occurred_at or config.now()
if at > config.now():
raise HTTPException(422, "Время инцидента не может быть в будущем")
seen = set()
units = []
for x in data.units:
if x.unit_id in seen:
continue
seen.add(x.unit_id)
su = db.get(ServiceUnit, x.unit_id)
if not su or su.equipment_id != eq.id:
raise HTTPException(422, "Узел не относится к этой машине")
units.append((su, x.replaced and data.kind == "incident")) # при неисправности замены нет — машина работает
fault = data.kind == "fault"
i = Incident(kind=data.kind, equipment_id=eq.id, author_id=u.id, occurred_at=at, title=data.title.strip(), hours_at_open=eq.meter_hours,
description=data.description.strip(), replacement_note="" if fault else data.replacement_note.strip(),
replaced=not fault and (data.replaced or any(r for _, r in units)))
for su, r in units:
i.units.append(IncidentUnit(unit_id=su.id, unit_name=su.name, replaced=r))
db.add(i)
db.flush()
for su, r in units:
if r: # замена узла — ресурс отсчитывается заново
service.reset_unit(db, su, at=at, kind="incident", user_id=u.id,
note=f"Замена при отказе №{i.id}: {i.title}"[:1000])
commit(db)
notify(db, eq, i, u.full_name or u.username, skip=u.id)
return inc_out(db, i)
KIND_RU = {"incident": "Отказ", "fault": "Неисправность"}
def notify(db: Session, eq: Equipment, i: Incident, who: str, skip: int | None = None):
"""Уведомить инженеров и руководителей, которые видят эту машину (с учётом отделов)."""
staff = [x.id for x in db.scalars(select(User).where(User.is_active.is_(True))).all()
if x.id != skip and (is_manager(x) or is_engineer(x)) and scope.user_sees(db, x, eq)]
push.notify(staff, f"{KIND_RU.get(i.kind, 'Инцидент')}: {eq.name}", f"{who}: {i.title[:100]}", f"/#/equipment/{eq.id}")
@router.post("/incidents/{id}/escalate", response_model=S.IncidentOut)
def escalate(id: int, data: S.IncidentNoteIn, db: Session = Depends(get_db), u: User = Depends(current_user)):
"""Сообщение о неисправности → инцидент: ситуация ухудшилась, оборудование остановлено. Машина получает «Аварию»."""
i = get_or_404(db, Incident, id)
if i.kind != "fault":
raise HTTPException(409, "Это уже отказ")
if i.status == "closed":
raise HTTPException(409, "Сообщение закрыто")
i.kind = "incident"
eq = db.get(Equipment, i.equipment_id)
# момент и наработка отказа — сейчас; время «работы с неисправностью» = escalated_at − occurred_at
i.escalated_at, i.hours_at_failure, i.precursor = config.now(), eq.meter_hours, "fault_report"
if data.note.strip():
i.description = (i.description + "\n\n" if i.description else "") + f"Переведено в отказ: {data.note.strip()}"
commit(db)
notify(db, eq, i, u.full_name or u.username, skip=u.id)
return inc_out(db, i)
def failure_at(i: Incident):
"""Момент отказа: для переведённой неисправности — момент перевода, иначе — время регистрации."""
return i.escalated_at or i.occurred_at
def apply_failure_data(db: Session, i: Incident, data: S.IncidentCloseIn, u: User, *, closing: bool):
"""Поля записи об отказе: проверки времён, узел-причина, заменённые узлы, показание при вводе."""
now = config.now()
sent = data.model_dump(exclude_unset=True)
failure = i.kind == "incident"
start = failure_at(i)
if "repair_started_at" in sent or (closing and failure and i.repair_started_at is None):
i.repair_started_at = data.repair_started_at or (start if failure else None)
if "restored_at" in sent or (closing and failure and i.restored_at is None):
i.restored_at = data.restored_at or (now if failure else None)
if "hours_at_failure" in sent or (closing and failure and i.hours_at_failure is None):
i.hours_at_failure = data.hours_at_failure if data.hours_at_failure is not None else i.hours_at_open
# хронология: отказ ≤ начало работ ≤ ввод ≤ сейчас
seq = [("момент отказа", start), ("начало работ", i.repair_started_at), ("ввод в эксплуатацию", i.restored_at)]
last_name, last = None, None
for name, t in seq:
if t is None:
continue
if t > now + timedelta(minutes=1):
raise HTTPException(422, f"«{name.capitalize()}» не может быть в будущем")
if last is not None and t < last.replace(second=0, microsecond=0): # в форме время с точностью до минуты
raise HTTPException(422, f"«{name.capitalize()}» не может быть раньше, чем «{last_name}»")
last_name, last = name, t
for k in ("parts_wait_hours", "action", "nature", "cause", "precursor"):
if k in sent:
setattr(i, k, getattr(data, k) if getattr(data, k) is not None else ("" if k != "parts_wait_hours" else None))
if i.escalated_at and not i.precursor:
i.precursor = "fault_report" # отказ получен переводом из неисправности
if closing and failure and not i.cause:
i.cause = "unknown"
if "cause_unit_id" in sent or "cause_unit_other" in sent:
if data.cause_unit_id:
su = db.get(ServiceUnit, data.cause_unit_id)
if not su or su.equipment_id != i.equipment_id:
raise HTTPException(422, "Узел-причина не относится к этой машине")
i.cause_unit_id = data.cause_unit_id or None
i.cause_unit_other = "" if data.cause_unit_id else data.cause_unit_other.strip()
at = i.restored_at or now
if data.meter_at_restore is not None and data.meter_at_restore != i.meter_at_restore:
eq = db.get(Equipment, i.equipment_id)
try:
service.record_meter(db, eq, data.meter_at_restore, at=at, user_id=u.id, note=f"Ввод в эксплуатацию после отказа №{i.id}")
except ValueError as e:
raise HTTPException(422, str(e))
i.meter_at_restore = data.meter_at_restore
for uid in dict.fromkeys(data.replaced_units): # замена узла при ремонте — ресурс отсчитывается заново
su = db.get(ServiceUnit, uid)
if not su or su.equipment_id != i.equipment_id:
raise HTTPException(422, "Узел не относится к этой машине")
service.reset_unit(db, su, at=at, hours=data.meter_at_restore, kind="incident", user_id=u.id, note=f"Замена при отказе №{i.id}: {i.title}"[:1000])
iu = next((x for x in i.units if x.unit_id == su.id), None)
if iu:
iu.replaced = True
else:
i.units.append(IncidentUnit(unit_id=su.id, unit_name=su.name, replaced=True))
i.replaced = True
@router.post("/incidents/{id}/close", response_model=S.IncidentOut)
def close_incident(id: int, data: S.IncidentCloseIn, db: Session = Depends(get_db), u: User = Depends(current_user)):
"""Закрыть отказ или неисправность. Для отказа — данные для анализа надёжности (см. IncidentCloseIn)."""
i = get_or_404(db, Incident, id)
if not (i.author_id == u.id or is_manager(u) or is_engineer(u)):
raise HTTPException(403, "Закрыть может автор или инженер")
if i.status == "closed":
raise HTTPException(409, "Запись уже закрыта")
apply_failure_data(db, i, data, u, closing=True)
i.status, i.closed_at, i.closed_by_id, i.close_note = "closed", config.now(), u.id, data.note.strip()
commit(db)
return inc_out(db, i)
@router.patch("/incidents/{id}/failure", response_model=S.IncidentOut)
def edit_failure(id: int, data: S.IncidentCloseIn, db: Session = Depends(get_db), u: User = Depends(require_engineer)):
"""Инженер или руководитель уточняет данные отказа (в том числе у закрытой записи)."""
i = get_or_404(db, Incident, id)
apply_failure_data(db, i, data, u, closing=False)
if "note" in data.model_fields_set and i.status == "closed":
i.close_note = data.note.strip()
commit(db)
return inc_out(db, i)
@router.delete("/incidents/{id}", status_code=204)
def delete_incident(id: int, db: Session = Depends(get_db), _: User = Depends(require_manager)):
db.delete(get_or_404(db, Incident, id))
commit(db)
# ---------- сводное состояние машин (карта, карточка) ----------
def equipment_health(db: Session) -> dict[int, dict]:
"""По каждой машине: открытые инциденты и неисправности и худшее состояние узлов (due/soon/ok).
Состояние: alarm (открыт инцидент) > fault (открыто сообщение о неисправности) > due > soon > ok."""
cnt = {}
for eid, kind, n in db.execute(select(Incident.equipment_id, Incident.kind, func.count()).where(Incident.status == "open")
.group_by(Incident.equipment_id, Incident.kind)).all():
cnt[(eid, kind)] = n
open_cnt = {eid: n for (eid, k), n in cnt.items() if k != "fault"}
fault_cnt = {eid: n for (eid, k), n in cnt.items() if k == "fault"}
eqs = {e.id: e for e in db.scalars(select(Equipment)).all()}
out = {eid: {"open_incidents": open_cnt.get(eid, 0), "open_faults": fault_cnt.get(eid, 0), "due": 0, "soon": 0} for eid in eqs}
rates: dict[int, float | None] = {}
for su in db.scalars(select(ServiceUnit).where(ServiceUnit.is_active.is_(True))).all():
eq = eqs.get(su.equipment_id)
if not eq:
continue
if su.equipment_id not in rates:
rates[su.equipment_id] = service.daily_rate(db, su.equipment_id)
st = service.evaluate(su, eq.meter_hours or 0, rates[su.equipment_id])["state"]
if st in ("due", "soon"):
out[su.equipment_id][st] += 1
for v in out.values():
v["state"] = ("alarm" if v["open_incidents"] else "fault" if v["open_faults"] else "due" if v["due"]
else "soon" if v["soon"] else "ok")
return out
@router.get("/equipment-health")
def health(db: Session = Depends(get_db), u: User = Depends(current_user)):
"""{equipment_id: {state: alarm|fault|due|soon|ok, open_incidents, open_faults, due, soon}}"""
visible = scope.equipment_ids(db, u)
return {k: v for k, v in equipment_health(db).items() if visible is None or k in visible}