Files
tech-service-portal/app/routers/orders.py
T

292 lines
13 KiB
Python

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))