05/08 Phase 4

This commit is contained in:
2026-05-08 10:15:40 -04:00
parent 959f54ed84
commit bf9b54f6b5
26 changed files with 1509 additions and 42 deletions
+284
View File
@@ -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"))