05/08 Phase 4
This commit is contained in:
@@ -1,17 +1,174 @@
|
||||
"""
|
||||
app/tenant/inventory/routes.py
|
||||
Phase 4 stub — implemented in Phase 4.
|
||||
Inventory management: list, create, edit, adjust stock, reorder alerts.
|
||||
Phase 4: automatic deduction on POS sale handled by pos/routes.py.
|
||||
"""
|
||||
from flask import Blueprint, render_template, g
|
||||
import logging
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
|
||||
from flask_login import login_required
|
||||
from app.decorators import require_role
|
||||
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():
|
||||
return render_template("tenant/feature_unavailable.html",
|
||||
feature="Inventory")
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user