292 lines
13 KiB
Python
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))
|