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