""" 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("//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("//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("//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