"""Инструмент и инвентарь: каталог, склады, остатки, выдача сотрудникам (лично), журнал движения, заявки на покупку. Править может только администратор склада (или администратор). Сотрудник видит каталог, то, что выдано ему, и свои заявки. Остатки складов видят также инженеры, модераторы — для планирования.""" 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 from ..db import get_db from ..models import InventoryItem, InventoryMove, Stock, ToolRequest, User, Warehouse from ..security import current_user, is_engineer, is_manager, is_storekeeper, require_storekeeper from .catalog import commit, get_or_404 router = APIRouter(tags=["inventory"]) EPS = 1e-9 def can_see_stock(u: User) -> bool: return is_storekeeper(u) or is_manager(u) or is_engineer(u) def user_names(db: Session) -> dict[int, str]: return {i: (n or un) for i, n, un in db.execute(select(User.id, User.full_name, User.username))} # ---------- склады ---------- def wh_out(db: Session, w: Warehouse) -> S.WarehouseOut: out = S.WarehouseOut.model_validate(w) cnt, total = db.execute(select(func.count(Stock.id), func.coalesce(func.sum(Stock.qty), 0)) .where(Stock.warehouse_id == w.id, Stock.qty > EPS)).one() out.positions, out.total_qty = cnt, float(total) return out @router.get("/warehouses", response_model=list[S.WarehouseOut]) def list_warehouses(db: Session = Depends(get_db), _: User = Depends(current_user)): return [wh_out(db, w) for w in db.scalars(select(Warehouse).order_by(Warehouse.name)).all()] @router.post("/warehouses", response_model=S.WarehouseOut, status_code=201) def create_warehouse(data: S.WarehouseIn, db: Session = Depends(get_db), _: User = Depends(require_storekeeper)): w = Warehouse(**data.model_dump()) db.add(w) commit(db) return wh_out(db, w) @router.patch("/warehouses/{id}", response_model=S.WarehouseOut) def update_warehouse(id: int, data: S.partial(S.WarehouseIn), db: Session = Depends(get_db), _: User = Depends(require_storekeeper)): w = get_or_404(db, Warehouse, id) for k, v in data.model_dump(exclude_unset=True).items(): if v is not None: setattr(w, k, v) commit(db) return wh_out(db, w) @router.put("/warehouses/{id}/zone", response_model=S.WarehouseOut) def set_zone(id: int, data: S.ZoneIn, db: Session = Depends(get_db), _: User = Depends(require_storekeeper)): """Зона склада на карте завода (прямоугольник в % от картинки); null — убрать.""" w = get_or_404(db, Warehouse, id) z = data.zone if z and (z.x + z.w > 100.5 or z.y + z.h > 100.5): raise HTTPException(422, "Зона выходит за пределы карты") w.zone = z.model_dump() if z else None commit(db) return wh_out(db, w) @router.delete("/warehouses/{id}", status_code=204) def delete_warehouse(id: int, db: Session = Depends(get_db), _: User = Depends(require_storekeeper)): w = get_or_404(db, Warehouse, id) if db.scalar(select(Stock.id).where(Stock.warehouse_id == id, Stock.qty > EPS).limit(1)): raise HTTPException(409, "На складе есть инвентарь — сначала переместите или спишите его") db.execute(Stock.__table__.delete().where(Stock.warehouse_id == id)) if db.scalar(select(InventoryMove.id).where((InventoryMove.from_warehouse_id == id) | (InventoryMove.to_warehouse_id == id)).limit(1)): w.is_active = False # есть история движения — склад только скрываем else: db.delete(w) commit(db) # ---------- каталог ---------- def item_totals(db: Session) -> dict[int, tuple[float, float]]: rows = db.execute(select(Stock.item_id, Stock.warehouse_id, func.sum(Stock.qty)).where(Stock.qty > EPS) .group_by(Stock.item_id, Stock.warehouse_id)).all() out: dict[int, list[float]] = {} for item_id, wh, q in rows: t = out.setdefault(item_id, [0.0, 0.0]) t[0 if wh else 1] += float(q) return {k: (v[0], v[1]) for k, v in out.items()} @router.get("/inventory/items", response_model=list[S.InventoryItemOut]) def list_items(q: Optional[str] = Query(None, max_length=100), db: Session = Depends(get_db), u: User = Depends(current_user)): stmt = select(InventoryItem).order_by(InventoryItem.category, InventoryItem.name) if q and q.strip(): like = f"%{q.strip().lower()}%" stmt = stmt.where(func.lower(InventoryItem.name).like(like) | func.lower(InventoryItem.category).like(like) | func.lower(InventoryItem.inventory_no).like(like)) totals = item_totals(db) if can_see_stock(u) else {} out = [] for it in db.scalars(stmt).all(): o = S.InventoryItemOut.model_validate(it) o.in_stock, o.issued = totals.get(it.id, (0.0, 0.0)) out.append(o) return out @router.post("/inventory/items", response_model=S.InventoryItemOut, status_code=201) def create_item(data: S.InventoryItemIn, db: Session = Depends(get_db), _: User = Depends(require_storekeeper)): it = InventoryItem(**data.model_dump()) db.add(it) commit(db) return it @router.patch("/inventory/items/{id}", response_model=S.InventoryItemOut) def update_item(id: int, data: S.partial(S.InventoryItemIn), db: Session = Depends(get_db), _: User = Depends(require_storekeeper)): it = get_or_404(db, InventoryItem, id) for k, v in data.model_dump(exclude_unset=True).items(): if v is not None: setattr(it, k, v) commit(db) return it @router.delete("/inventory/items/{id}", status_code=204) def delete_item(id: int, db: Session = Depends(get_db), _: User = Depends(require_storekeeper)): it = get_or_404(db, InventoryItem, id) if db.scalar(select(Stock.id).where(Stock.item_id == id, Stock.qty > EPS).limit(1)): raise HTTPException(409, "Позиция есть на складе или выдана — сначала спишите её") db.execute(Stock.__table__.delete().where(Stock.item_id == id)) if db.scalar(select(InventoryMove.id).where(InventoryMove.item_id == id).limit(1)): it.is_active = False # история движения сохраняется, позиция скрывается else: db.delete(it) commit(db) # ---------- остатки ---------- def stock_rows(db: Session, stmt) -> list[S.StockOut]: names = user_names(db) whs = dict(db.execute(select(Warehouse.id, Warehouse.name)).all()) out = [] for st, it in db.execute(stmt.join(InventoryItem, InventoryItem.id == Stock.item_id) .add_columns(InventoryItem).where(Stock.qty > EPS) .order_by(InventoryItem.category, InventoryItem.name)).all(): out.append(S.StockOut(item_id=it.id, item_name=it.name, category=it.category, unit=it.unit, inventory_no=it.inventory_no, warehouse_id=st.warehouse_id, warehouse_name=whs.get(st.warehouse_id), user_id=st.user_id, user_name=names.get(st.user_id), qty=st.qty, since=st.since)) return out @router.get("/inventory/stock", response_model=list[S.StockOut]) def stock(warehouse_id: Optional[int] = None, user_id: Optional[int] = None, item_id: Optional[int] = None, db: Session = Depends(get_db), u: User = Depends(current_user)): """Остатки. Сотрудник без прав склада видит только выданное ему.""" if not can_see_stock(u): if warehouse_id or (user_id and user_id != u.id): raise HTTPException(403, "Вы видите только инвентарь, выданный вам") user_id = u.id stmt = select(Stock) if warehouse_id: stmt = stmt.where(Stock.warehouse_id == warehouse_id) if user_id: stmt = stmt.where(Stock.user_id == user_id) if item_id: stmt = stmt.where(Stock.item_id == item_id) return stock_rows(db, stmt) @router.get("/me/inventory", response_model=list[S.StockOut]) def my_inventory(db: Session = Depends(get_db), u: User = Depends(current_user)): return stock_rows(db, select(Stock).where(Stock.user_id == u.id)) # ---------- движение ---------- def check_place(db: Session, p: S.Place | None, what: str) -> S.Place: if not p or bool(p.warehouse_id) == bool(p.user_id): raise HTTPException(422, f"{what}: укажите склад или сотрудника (одно из двух)") if p.warehouse_id: w = get_or_404(db, Warehouse, p.warehouse_id) if not w.is_active: raise HTTPException(422, "Склад закрыт") else: usr = get_or_404(db, User, p.user_id) if not usr.is_active: raise HTTPException(422, "Сотрудник отключён") return p def holding(db: Session, item_id: int, p: S.Place) -> Stock | None: stmt = select(Stock).where(Stock.item_id == item_id) if p.warehouse_id: stmt = stmt.where(Stock.warehouse_id == p.warehouse_id, Stock.user_id.is_(None)) else: stmt = stmt.where(Stock.user_id == p.user_id, Stock.warehouse_id.is_(None)) return db.scalar(stmt) def take(db: Session, item: InventoryItem, p: S.Place, qty: float): st = holding(db, item.id, p) have = st.qty if st else 0 if have + EPS < qty: raise HTTPException(422, f"Недостаточно: «{item.name}» в наличии {have:g} {item.unit}") st.qty = round(have - qty, 6) st.since = config.now() def put(db: Session, item: InventoryItem, p: S.Place, qty: float): st = holding(db, item.id, p) if not st: st = Stock(item_id=item.id, warehouse_id=p.warehouse_id, user_id=p.user_id, qty=0) db.add(st) st.qty = round((st.qty or 0) + qty, 6) st.since = config.now() def move_kind(src: S.Place, dst: S.Place) -> str: if src.warehouse_id and dst.user_id: return "issue" if src.user_id and dst.warehouse_id: return "return" return "transfer" @router.post("/inventory/moves", response_model=list[S.StockOut], status_code=201) def make_move(data: S.MoveIn, db: Session = Depends(get_db), me: User = Depends(require_storekeeper)): """Поступление, выдача/возврат/перемещение, списание. Возвращает остатки позиции после операции.""" item = get_or_404(db, InventoryItem, data.item_id) src = dst = None if data.kind in ("move", "writeoff"): src = check_place(db, data.src, "Откуда") if data.kind in ("move", "receive"): dst = check_place(db, data.dst, "Куда") if data.kind == "receive" and not dst.warehouse_id: raise HTTPException(422, "Поступление оформляется на склад") if src and dst and src.model_dump() == dst.model_dump(): raise HTTPException(422, "Откуда и куда совпадают") if src: take(db, item, src, data.qty) if dst: put(db, item, dst, data.qty) kind = move_kind(src, dst) if data.kind == "move" else data.kind db.add(InventoryMove(item_id=item.id, qty=data.qty, kind=kind, from_warehouse_id=src.warehouse_id if src else None, from_user_id=src.user_id if src else None, to_warehouse_id=dst.warehouse_id if dst else None, to_user_id=dst.user_id if dst else None, by_user_id=me.id, note=data.note.strip())) commit(db) if kind == "issue": push.notify([dst.user_id], "Вам выдан инвентарь", f"{item.name}: {data.qty:g} {item.unit}", "/#/my-tools") return stock_rows(db, select(Stock).where(Stock.item_id == item.id)) KIND_RU = {"receive": "Поступление", "issue": "Выдача", "return": "Возврат", "transfer": "Перемещение", "writeoff": "Списание"} @router.get("/inventory/moves", response_model=list[S.MoveOut]) def list_moves(item_id: Optional[int] = None, warehouse_id: Optional[int] = None, user_id: Optional[int] = None, limit: int = Query(200, ge=1, le=1000), db: Session = Depends(get_db), u: User = Depends(current_user)): if not can_see_stock(u): if warehouse_id or (user_id and user_id != u.id): raise HTTPException(403, "Недостаточно прав") user_id = u.id stmt = select(InventoryMove).order_by(InventoryMove.at.desc(), InventoryMove.id.desc()).limit(limit) if item_id: stmt = stmt.where(InventoryMove.item_id == item_id) if warehouse_id: stmt = stmt.where((InventoryMove.from_warehouse_id == warehouse_id) | (InventoryMove.to_warehouse_id == warehouse_id)) if user_id: stmt = stmt.where((InventoryMove.from_user_id == user_id) | (InventoryMove.to_user_id == user_id)) names = user_names(db) whs = dict(db.execute(select(Warehouse.id, Warehouse.name)).all()) items = {i.id: i for i in db.scalars(select(InventoryItem)).all()} def place(w, usr): if w: return "склад «" + whs.get(w, "?") + "»" if usr: return names.get(usr, "?") return "—" out = [] for m in db.scalars(stmt).all(): it = items.get(m.item_id) out.append(S.MoveOut(id=m.id, item_id=m.item_id, item_name=it.name if it else "?", unit=it.unit if it else "", qty=m.qty, kind=m.kind, from_text=place(m.from_warehouse_id, m.from_user_id), to_text=place(m.to_warehouse_id, m.to_user_id), by_name=names.get(m.by_user_id), at=m.at, note=m.note)) return out # ---------- заявки на инструмент ---------- REQ_RU = {"new": "Новая", "approved": "Одобрена", "ordered": "Заказано", "done": "Получено", "rejected": "Отклонена"} def can_handle_requests(u: User) -> bool: return is_storekeeper(u) or is_manager(u) def req_out(r: ToolRequest, names: dict) -> S.ToolRequestOut: o = S.ToolRequestOut.model_validate(r) o.author_name, o.handled_by_name = names.get(r.author_id), names.get(r.handled_by_id) return o @router.get("/tool-requests", response_model=list[S.ToolRequestOut]) def list_requests(status: Optional[S.RequestStatus] = None, mine: bool = False, db: Session = Depends(get_db), u: User = Depends(current_user)): """Ответственные (склад, модераторы, админ) видят все заявки, остальные — только свои.""" stmt = select(ToolRequest).order_by(ToolRequest.id.desc()) if mine or not can_handle_requests(u): stmt = stmt.where(ToolRequest.author_id == u.id) if status: stmt = stmt.where(ToolRequest.status == status) names = user_names(db) return [req_out(r, names) for r in db.scalars(stmt).all()] @router.post("/tool-requests", response_model=S.ToolRequestOut, status_code=201) def create_request(data: S.ToolRequestIn, db: Session = Depends(get_db), u: User = Depends(current_user)): r = ToolRequest(author_id=u.id, what=data.what.strip(), why=data.why.strip(), link=data.link, qty=data.qty) db.add(r) commit(db) keepers = [x for x, in db.execute(select(User.id).where(User.is_active.is_(True), User.is_storekeeper.is_(True) | User.is_admin.is_(True)))] push.notify([k for k in keepers if k != u.id], "Новая заявка на инструмент", f"{u.full_name or u.username}: {r.what[:100]}", "/#/requests") return req_out(r, user_names(db)) @router.post("/tool-requests/{id}/answer", response_model=S.ToolRequestOut) def answer_request(id: int, data: S.ToolRequestAnswer, db: Session = Depends(get_db), u: User = Depends(current_user)): if not can_handle_requests(u): raise HTTPException(403, "Заявки рассматривает администратор склада") r = get_or_404(db, ToolRequest, id) if data.status == "rejected" and not data.answer.strip(): raise HTTPException(422, "Укажите причину отклонения") r.status, r.answer, r.handled_by_id, r.updated_at = data.status, data.answer.strip(), u.id, config.now() commit(db) push.notify([r.author_id], f"Заявка на инструмент: {REQ_RU[r.status]}", r.what[:100], "/#/requests") return req_out(r, user_names(db)) @router.delete("/tool-requests/{id}", status_code=204) def delete_request(id: int, db: Session = Depends(get_db), u: User = Depends(current_user)): r = get_or_404(db, ToolRequest, id) if not (can_handle_requests(u) or (r.author_id == u.id and r.status == "new")): raise HTTPException(403, "Удалить можно только свою новую заявку") db.delete(r) commit(db)