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

358 lines
17 KiB
Python

"""Инструмент и инвентарь: каталог, склады, остатки, выдача сотрудникам (лично), журнал движения, заявки на покупку.
Править может только администратор склада (или администратор). Сотрудник видит каталог, то, что выдано ему,
и свои заявки. Остатки складов видят также инженеры, модераторы — для планирования."""
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)