258 lines
15 KiB
Python
258 lines
15 KiB
Python
"""Инциденты (аварии) и сообщения о неисправности (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}
|