175 lines
6.7 KiB
Python
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
|