Files
MyPOS/app/tenant/inventory/routes.py
T
2026-05-08 10:15:40 -04:00

175 lines
6.7 KiB
Python

"""
app/tenant/inventory/routes.py
Inventory management: list, create, edit, adjust stock, reorder alerts.
Phase 4: automatic deduction on POS sale handled by pos/routes.py.
"""
import logging
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
from flask_login import login_required
from app.extensions import db
from app.models.salon import Inventory, InventoryLog
from app.decorators import require_role, demo_readonly, tenant_feature_required
from app.tenant.utils import log_tenant_action
logger = logging.getLogger(__name__)
inventory_bp = Blueprint("inventory", __name__, url_prefix="/inventory")
@inventory_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
@tenant_feature_required("inventory")
def index():
items = Inventory.query.filter_by(
tenant_id=g.tenant.id, location_id=g.location.id
).order_by(Inventory.category, Inventory.name).all()
low_stock = [i for i in items if i.qty_on_hand <= i.reorder_level]
return render_template("tenant/inventory/index.html",
items=items, low_stock=low_stock)
@inventory_bp.route("/new", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
@tenant_feature_required("inventory")
def create():
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/inventory/form.html",
mode="create", error="Name is required.")
try:
qty = int(request.form.get("qty_on_hand", 0))
reorder = int(request.form.get("reorder_level", 5))
except ValueError:
return render_template("tenant/inventory/form.html",
mode="create", error="Invalid quantity or reorder level.")
item = Inventory(
tenant_id=g.tenant.id,
location_id=g.location.id,
name=name,
sku=request.form.get("sku", "").strip() or None,
category=request.form.get("category", "").strip() or None,
qty_on_hand=qty,
reorder_level=reorder,
cost_price=_parse_decimal(request.form.get("cost_price", "")),
sale_price=_parse_decimal(request.form.get("sale_price", "")),
)
db.session.add(item)
db.session.flush()
if qty > 0:
db.session.add(InventoryLog(
tenant_id=g.tenant.id, location_id=g.location.id,
inventory_id=item.id, delta=qty, reason="Initial stock",
))
log_tenant_action("inventory.create", "inventory", item.id, {"name": name})
db.session.commit()
flash(f"Item \'{name}\' added to inventory.", "success")
return redirect(url_for("inventory.index"))
return render_template("tenant/inventory/form.html", mode="create")
@inventory_bp.route("/<int:item_id>/edit", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
@tenant_feature_required("inventory")
def edit(item_id):
item = Inventory.query.filter_by(
id=item_id, tenant_id=g.tenant.id, location_id=g.location.id
).first_or_404()
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/inventory/form.html",
mode="edit", item=item, error="Name is required.")
try:
reorder = int(request.form.get("reorder_level", item.reorder_level))
except ValueError:
return render_template("tenant/inventory/form.html",
mode="edit", item=item,
error="Invalid reorder level.")
item.name = name
item.sku = request.form.get("sku", "").strip() or None
item.category = request.form.get("category", "").strip() or None
item.reorder_level = reorder
item.cost_price = _parse_decimal(request.form.get("cost_price", ""))
item.sale_price = _parse_decimal(request.form.get("sale_price", ""))
log_tenant_action("inventory.edit", "inventory", item.id, {"name": name})
db.session.commit()
flash(f"\'{name}\' updated.", "success")
return redirect(url_for("inventory.index"))
return render_template("tenant/inventory/form.html", mode="edit", item=item)
@inventory_bp.route("/<int:item_id>/adjust", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
@tenant_feature_required("inventory")
def adjust(item_id):
item = Inventory.query.filter_by(
id=item_id, tenant_id=g.tenant.id, location_id=g.location.id
).first_or_404()
if request.method == "POST":
try:
delta = int(request.form.get("delta", 0))
except ValueError:
return render_template("tenant/inventory/adjust.html",
item=item, error="Invalid adjustment quantity.")
reason = request.form.get("reason", "").strip() or "Manual adjustment"
new_qty = item.qty_on_hand + delta
if new_qty < 0:
return render_template("tenant/inventory/adjust.html",
item=item,
error=f"Adjustment would result in negative stock ({new_qty}).")
item.qty_on_hand = new_qty
db.session.add(InventoryLog(
tenant_id=g.tenant.id, location_id=g.location.id,
inventory_id=item.id, delta=delta, reason=reason,
))
log_tenant_action("inventory.adjust", "inventory", item.id,
{"delta": delta, "new_qty": new_qty, "reason": reason})
db.session.commit()
logger.info("Inventory adjusted: item=%s delta=%s new_qty=%s",
item.id, delta, new_qty)
flash(f"Stock adjusted: {item.name} now has {new_qty} units.", "success")
return redirect(url_for("inventory.index"))
return render_template("tenant/inventory/adjust.html", item=item)
@inventory_bp.route("/<int:item_id>/log")
@login_required
@require_role("tenant_admin", "tenant_manager")
@tenant_feature_required("inventory")
def log(item_id):
item = Inventory.query.filter_by(
id=item_id, tenant_id=g.tenant.id, location_id=g.location.id
).first_or_404()
entries = InventoryLog.query.filter_by(
inventory_id=item_id, tenant_id=g.tenant.id
).order_by(InventoryLog.created_at.desc()).limit(100).all()
return render_template("tenant/inventory/log.html", item=item, entries=entries)
def _parse_decimal(value):
if not value or not str(value).strip():
return None
try:
return float(value)
except ValueError:
return None