Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
"""Инструмент и инвентарь: каталог, склады, остатки, выдача сотрудникам (лично), журнал движения, заявки на покупку.
|
||||
|
||||
Править может только администратор склада (или администратор). Сотрудник видит каталог, то, что выдано ему,
|
||||
и свои заявки. Остатки складов видят также инженеры, модераторы — для планирования."""
|
||||
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)
|
||||
Reference in New Issue
Block a user