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