05/08 Phase 4
This commit is contained in:
@@ -119,9 +119,35 @@ def create_tenant_app(config_override=None):
|
||||
from app.tenant.inventory.routes import inventory_bp
|
||||
from app.tenant.marketing.routes import marketing_bp
|
||||
from app.tenant.reports.routes import reports_bp
|
||||
from app.tenant.pay_periods.routes import pay_periods_bp
|
||||
flask_app.register_blueprint(inventory_bp)
|
||||
flask_app.register_blueprint(marketing_bp)
|
||||
flask_app.register_blueprint(reports_bp)
|
||||
flask_app.register_blueprint(pay_periods_bp)
|
||||
|
||||
# ── APScheduler ───────────────────────────────────────────
|
||||
flask_app.config.setdefault("SCHEDULER_API_ENABLED", False)
|
||||
flask_app.config.setdefault("SCHEDULER_TIMEZONE", "UTC")
|
||||
from app.scheduler_jobs import send_appointment_reminders, send_review_requests
|
||||
if not scheduler.running:
|
||||
scheduler.init_app(flask_app)
|
||||
scheduler.add_job(
|
||||
id="appointment_reminders",
|
||||
func=send_appointment_reminders,
|
||||
args=[flask_app],
|
||||
trigger="interval",
|
||||
minutes=5,
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
id="review_requests",
|
||||
func=send_review_requests,
|
||||
args=[flask_app],
|
||||
trigger="interval",
|
||||
minutes=10,
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.start()
|
||||
|
||||
# ── Import all models for Migrate ─────────────────────────
|
||||
import app.models # noqa: F401
|
||||
|
||||
@@ -115,6 +115,22 @@ def create():
|
||||
log_tenant_action("appointment.create", "appointment", appt.id,
|
||||
{"customer": customer_id, "staff": staff_id,
|
||||
"start": start_raw})
|
||||
|
||||
# Schedule 24h and 2h email reminders
|
||||
from app.models.salon import AppointmentReminder as AR
|
||||
for hours, rtype in [(24, "24h"), (2, "2h")]:
|
||||
remind_at = start_time - timedelta(hours=hours)
|
||||
if remind_at > datetime.now(timezone.utc):
|
||||
db.session.add(AR(
|
||||
tenant_id=g.tenant.id,
|
||||
location_id=g.location.id,
|
||||
appointment_id=appt.id,
|
||||
reminder_type=rtype,
|
||||
scheduled_for=remind_at,
|
||||
channel="email",
|
||||
status="pending",
|
||||
))
|
||||
|
||||
db.session.commit()
|
||||
flash("Appointment created.", "success")
|
||||
return redirect(url_for("appointments.index",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
app/tenant/pay_periods/routes.py
|
||||
Pay period management: calculate, list, approve, mark paid.
|
||||
Engine: hourly (rate × hours), salary (fixed), guarantee (max of guarantee vs commission).
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone, date, timedelta
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
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 Staff, StaffPayPeriod, StaffClocking, CommissionLog
|
||||
from app.decorators import require_role, demo_readonly
|
||||
from app.tenant.utils import log_tenant_action
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
pay_periods_bp = Blueprint("pay_periods", __name__, url_prefix="/pay-periods")
|
||||
|
||||
|
||||
def _period_bounds(period_type: str, ref_date: date):
|
||||
"""Return (start, end) date for the pay period containing ref_date."""
|
||||
if period_type == "weekly":
|
||||
start = ref_date - timedelta(days=ref_date.weekday())
|
||||
end = start + timedelta(days=6)
|
||||
elif period_type == "biweekly":
|
||||
# Anchor biweekly to 2025-01-06 (a Monday)
|
||||
anchor = date(2025, 1, 6)
|
||||
delta = (ref_date - anchor).days
|
||||
week_num = delta // 7
|
||||
period_num = week_num // 2
|
||||
start = anchor + timedelta(weeks=period_num * 2)
|
||||
end = start + timedelta(days=13)
|
||||
else: # monthly
|
||||
start = ref_date.replace(day=1)
|
||||
if start.month == 12:
|
||||
end = date(start.year + 1, 1, 1) - timedelta(days=1)
|
||||
else:
|
||||
end = date(start.year, start.month + 1, 1) - timedelta(days=1)
|
||||
return start, end
|
||||
|
||||
|
||||
def calculate_pay_period(staff: Staff, period_start: date, period_end: date) -> dict:
|
||||
"""
|
||||
Calculate pay for a staff member over the given period.
|
||||
Returns a dict with base_amount, commission_amount, guarantee_topup, total_amount.
|
||||
"""
|
||||
# Total minutes clocked in the period
|
||||
start_dt = datetime.combine(period_start, datetime.min.time()).replace(tzinfo=timezone.utc)
|
||||
end_dt = datetime.combine(period_end, datetime.max.time()).replace(tzinfo=timezone.utc)
|
||||
|
||||
clockings = StaffClocking.query.filter_by(
|
||||
tenant_id=staff.tenant_id, staff_id=staff.id
|
||||
).filter(
|
||||
StaffClocking.clocked_in_at >= start_dt,
|
||||
StaffClocking.clocked_in_at <= end_dt,
|
||||
StaffClocking.clocked_out_at.isnot(None),
|
||||
).all()
|
||||
|
||||
total_minutes = sum(c.total_minutes or 0 for c in clockings)
|
||||
total_hours = Decimal(total_minutes) / Decimal(60)
|
||||
|
||||
# Commission for the period
|
||||
comm_logs = CommissionLog.query.filter_by(
|
||||
tenant_id=staff.tenant_id, staff_id=staff.id
|
||||
).filter(
|
||||
CommissionLog.period.isnot(None),
|
||||
).all()
|
||||
|
||||
# Filter by date range using the transaction's created_at via join
|
||||
from app.models.salon import Transaction
|
||||
comm_rows = (
|
||||
db.session.query(CommissionLog)
|
||||
.join(Transaction, CommissionLog.transaction_id == Transaction.id)
|
||||
.filter(
|
||||
CommissionLog.tenant_id == staff.tenant_id,
|
||||
CommissionLog.staff_id == staff.id,
|
||||
Transaction.created_at >= start_dt,
|
||||
Transaction.created_at <= end_dt,
|
||||
Transaction.voided_at.is_(None),
|
||||
).all()
|
||||
)
|
||||
commission_amount = sum(
|
||||
Decimal(str(c.amount)) for c in comm_rows
|
||||
)
|
||||
|
||||
pay_type = staff.pay_type
|
||||
base_amount = Decimal("0")
|
||||
guarantee_topup = Decimal("0")
|
||||
|
||||
if pay_type == "hourly":
|
||||
rate = Decimal(str(staff.hourly_rate or 0))
|
||||
base_amount = (rate * total_hours).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
|
||||
elif pay_type == "salary":
|
||||
base_amount = Decimal(str(staff.salary_amount or 0))
|
||||
|
||||
elif pay_type == "guarantee":
|
||||
guarantee = Decimal(str(staff.guarantee_amount or 0))
|
||||
if commission_amount >= guarantee:
|
||||
base_amount = commission_amount
|
||||
commission_amount = Decimal("0") # rolled into base
|
||||
else:
|
||||
base_amount = guarantee
|
||||
guarantee_topup = (guarantee - commission_amount).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
commission_amount = Decimal("0") # topup covers the gap
|
||||
|
||||
# Commission on top of hourly/salary (if enabled)
|
||||
if pay_type in ("hourly", "salary") and not staff.commission_enabled:
|
||||
commission_amount = Decimal("0")
|
||||
|
||||
total_amount = (base_amount + commission_amount + guarantee_topup).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
|
||||
return {
|
||||
"pay_type": pay_type,
|
||||
"base_amount": float(base_amount),
|
||||
"commission_amount": float(commission_amount),
|
||||
"guarantee_topup": float(guarantee_topup),
|
||||
"total_amount": float(total_amount),
|
||||
"total_hours": float(total_hours),
|
||||
}
|
||||
|
||||
|
||||
@pay_periods_bp.route("/")
|
||||
@login_required
|
||||
@require_role("tenant_admin", "tenant_manager")
|
||||
def index():
|
||||
periods = (
|
||||
StaffPayPeriod.query.filter_by(tenant_id=g.tenant.id)
|
||||
.order_by(StaffPayPeriod.period_start.desc())
|
||||
.limit(100)
|
||||
.all()
|
||||
)
|
||||
staff_list = Staff.query.filter_by(
|
||||
tenant_id=g.tenant.id, is_active=True
|
||||
).filter(Staff.deleted_at.is_(None)).order_by(Staff.name).all()
|
||||
return render_template(
|
||||
"tenant/pay_periods/index.html",
|
||||
periods=periods,
|
||||
staff_list=staff_list,
|
||||
)
|
||||
|
||||
|
||||
@pay_periods_bp.route("/calculate", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@require_role("tenant_admin")
|
||||
@demo_readonly
|
||||
def calculate():
|
||||
staff_list = Staff.query.filter_by(
|
||||
tenant_id=g.tenant.id, is_active=True
|
||||
).filter(Staff.deleted_at.is_(None)).order_by(Staff.name).all()
|
||||
|
||||
results = []
|
||||
period_start = None
|
||||
period_end = None
|
||||
|
||||
if request.method == "POST":
|
||||
period_start_raw = request.form.get("period_start", "")
|
||||
period_end_raw = request.form.get("period_end", "")
|
||||
staff_ids = request.form.getlist("staff_ids")
|
||||
|
||||
try:
|
||||
period_start = date.fromisoformat(period_start_raw)
|
||||
period_end = date.fromisoformat(period_end_raw)
|
||||
except ValueError:
|
||||
flash("Invalid date range.", "danger")
|
||||
return render_template(
|
||||
"tenant/pay_periods/calculate.html",
|
||||
staff_list=staff_list,
|
||||
results=[], period_start=None, period_end=None,
|
||||
)
|
||||
|
||||
if period_end < period_start:
|
||||
flash("End date must be after start date.", "danger")
|
||||
return render_template(
|
||||
"tenant/pay_periods/calculate.html",
|
||||
staff_list=staff_list,
|
||||
results=[], period_start=period_start, period_end=period_end,
|
||||
)
|
||||
|
||||
target_staff = [s for s in staff_list
|
||||
if not staff_ids or str(s.id) in staff_ids]
|
||||
|
||||
for member in target_staff:
|
||||
calc = calculate_pay_period(member, period_start, period_end)
|
||||
|
||||
# Check if a period record already exists
|
||||
existing = StaffPayPeriod.query.filter_by(
|
||||
tenant_id=g.tenant.id,
|
||||
staff_id=member.id,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# Update draft; never overwrite approved/paid
|
||||
if existing.status == "draft":
|
||||
existing.pay_type = calc["pay_type"]
|
||||
existing.base_amount = calc["base_amount"]
|
||||
existing.commission_amount = calc["commission_amount"]
|
||||
existing.guarantee_topup = calc["guarantee_topup"]
|
||||
existing.total_amount = calc["total_amount"]
|
||||
pp = existing
|
||||
else:
|
||||
pp = existing
|
||||
else:
|
||||
pp = StaffPayPeriod(
|
||||
tenant_id=g.tenant.id,
|
||||
staff_id=member.id,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
**{k: v for k, v in calc.items()},
|
||||
)
|
||||
db.session.add(pp)
|
||||
|
||||
results.append({"staff": member, "calc": calc, "pay_period": pp})
|
||||
|
||||
db.session.commit()
|
||||
log_tenant_action(
|
||||
"pay_period.calculate", "pay_period", None,
|
||||
{"period": f"{period_start}–{period_end}",
|
||||
"staff_count": len(results)},
|
||||
)
|
||||
logger.info(
|
||||
"Pay periods calculated: tenant=%s period=%s–%s count=%d",
|
||||
g.tenant.id, period_start, period_end, len(results),
|
||||
)
|
||||
flash(f"Calculated pay for {len(results)} staff member(s).", "success")
|
||||
|
||||
return render_template(
|
||||
"tenant/pay_periods/calculate.html",
|
||||
staff_list=staff_list,
|
||||
results=results,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
)
|
||||
|
||||
|
||||
@pay_periods_bp.route("/<int:pp_id>/approve", methods=["POST"])
|
||||
@login_required
|
||||
@require_role("tenant_admin")
|
||||
@demo_readonly
|
||||
def approve(pp_id):
|
||||
pp = StaffPayPeriod.query.filter_by(
|
||||
id=pp_id, tenant_id=g.tenant.id
|
||||
).first_or_404()
|
||||
if pp.status != "draft":
|
||||
flash("Only draft periods can be approved.", "warning")
|
||||
return redirect(url_for("pay_periods.index"))
|
||||
pp.status = "approved"
|
||||
log_tenant_action("pay_period.approve", "pay_period", pp.id,
|
||||
{"staff": pp.staff_id, "total": float(pp.total_amount)})
|
||||
db.session.commit()
|
||||
logger.info("Pay period approved: id=%s staff=%s", pp.id, pp.staff_id)
|
||||
flash("Pay period approved.", "success")
|
||||
return redirect(url_for("pay_periods.index"))
|
||||
|
||||
|
||||
@pay_periods_bp.route("/<int:pp_id>/mark-paid", methods=["POST"])
|
||||
@login_required
|
||||
@require_role("tenant_admin")
|
||||
@demo_readonly
|
||||
def mark_paid(pp_id):
|
||||
pp = StaffPayPeriod.query.filter_by(
|
||||
id=pp_id, tenant_id=g.tenant.id
|
||||
).first_or_404()
|
||||
if pp.status != "approved":
|
||||
flash("Only approved periods can be marked as paid.", "warning")
|
||||
return redirect(url_for("pay_periods.index"))
|
||||
pp.status = "paid"
|
||||
pp.notes = request.form.get("notes", pp.notes)
|
||||
log_tenant_action("pay_period.mark_paid", "pay_period", pp.id,
|
||||
{"staff": pp.staff_id, "total": float(pp.total_amount)})
|
||||
db.session.commit()
|
||||
logger.info("Pay period marked paid: id=%s staff=%s", pp.id, pp.staff_id)
|
||||
flash("Pay period marked as paid.", "success")
|
||||
return redirect(url_for("pay_periods.index"))
|
||||
@@ -212,6 +212,37 @@ def submit():
|
||||
)
|
||||
db.session.add(comm_log)
|
||||
|
||||
# ── Inventory auto-deduction for product line items ─────
|
||||
for item in db.session.query(TransactionItem).filter_by(
|
||||
transaction_id=txn.id
|
||||
).filter(TransactionItem.product_id.isnot(None)).all():
|
||||
from app.models.salon import Inventory, InventoryLog, Product as _Prod
|
||||
prod = _Prod.query.get(item.product_id)
|
||||
if prod and prod.sku:
|
||||
inv = Inventory.query.filter_by(
|
||||
tenant_id=g.tenant.id,
|
||||
location_id=g.location.id,
|
||||
sku=prod.sku,
|
||||
).first()
|
||||
elif prod:
|
||||
inv = Inventory.query.filter_by(
|
||||
tenant_id=g.tenant.id,
|
||||
location_id=g.location.id,
|
||||
name=prod.name,
|
||||
).first()
|
||||
else:
|
||||
inv = None
|
||||
|
||||
if inv and inv.qty_on_hand > 0:
|
||||
inv.qty_on_hand -= item.qty
|
||||
db.session.add(InventoryLog(
|
||||
tenant_id=g.tenant.id,
|
||||
location_id=g.location.id,
|
||||
inventory_id=inv.id,
|
||||
delta=-item.qty,
|
||||
reason=f"POS sale txn#{txn.id}",
|
||||
))
|
||||
|
||||
log_tenant_action("transaction.create", "transaction", txn.id,
|
||||
{"total": float(total), "payment": payment_method})
|
||||
db.session.commit()
|
||||
|
||||
@@ -136,6 +136,14 @@ def edit(staff_id):
|
||||
member.phone = phone
|
||||
member.staff_type = request.form.get("staff_type", member.staff_type)
|
||||
member.is_active = request.form.get("is_active") == "1"
|
||||
# Pay structure fields
|
||||
member.pay_type = request.form.get("pay_type", member.pay_type)
|
||||
member.pay_period = request.form.get("pay_period", member.pay_period)
|
||||
member.commission_enabled = request.form.get("commission_enabled") == "1"
|
||||
member.hourly_rate = _parse_decimal(request.form.get("hourly_rate", ""))
|
||||
member.salary_amount = _parse_decimal(request.form.get("salary_amount", ""))
|
||||
member.guarantee_amount = _parse_decimal(request.form.get("guarantee_amount", ""))
|
||||
member.commission_rate = _parse_decimal(request.form.get("commission_rate", ""))
|
||||
|
||||
# Update location assignments
|
||||
StaffLocation.query.filter_by(
|
||||
@@ -192,4 +200,4 @@ def reset_passcode(staff_id):
|
||||
"success")
|
||||
return redirect(url_for("staff.index"))
|
||||
|
||||
return render_template("tenant/staff/reset_passcode.html", staff=member)
|
||||
return render_template("tenant/staff/reset_passcode.html", staff=member)
|
||||
|
||||
Reference in New Issue
Block a user