358 lines
17 KiB
Python
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)
|