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