feat: 5-tier pricing, market ticker integration, and delivery stats
Major update spanning pricing, market data, and analytics: - Pricing: Replace single-price service fees with 5-tier pricing for same-day, prime, and emergency deliveries across create/edit/finalize - Market: Add Ticker_Price and CompanyPrice models with endpoints for live commodity prices (HO, CL, RB) and competitor price tracking - Stats: Add daily/weekly/monthly gallons endpoints with multi-year comparison and YoY totals for the stats dashboard - Delivery: Add map and history endpoints, fix finalize null-driver crash - Schema: Change fill_location from INTEGER to VARCHAR(250), add pre_load normalization for customer updates, fix admin auth check Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ from app import db
|
||||
from app.common.responses import success_response
|
||||
from app.classes.delivery import Delivery_Delivery
|
||||
from app.classes.stats_company import Stats_Company, Stats_Company_schema
|
||||
from sqlalchemy import func, and_, extract
|
||||
from app.classes.stats_customer import Stats_Customer, Stats_Customer_schema
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -210,3 +211,404 @@ def calculate_gallons_user(user_id):
|
||||
db.session.add(get_user)
|
||||
db.session.commit()
|
||||
return success_response()
|
||||
|
||||
|
||||
@stats.route("/deliveries/daily", methods=["GET"])
|
||||
def get_daily_delivery_stats():
|
||||
"""
|
||||
Get daily delivery stats for a date range.
|
||||
Query Params: start_date (YYYY-MM-DD), end_date (YYYY-MM-DD)
|
||||
"""
|
||||
start_date_str = request.args.get('start_date')
|
||||
end_date_str = request.args.get('end_date')
|
||||
|
||||
logger.info(f"GET /stats/deliveries/daily - Fetching stats from {start_date_str} to {end_date_str}")
|
||||
|
||||
try:
|
||||
start_date = datetime.datetime.strptime(start_date_str, '%Y-%m-%d').date()
|
||||
end_date = datetime.datetime.strptime(end_date_str, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
return success_response({'error': 'Invalid date format. Use YYYY-MM-DD'}, 400)
|
||||
|
||||
# Query daily aggregates
|
||||
daily_stats = db.session.query(
|
||||
Delivery_Delivery.when_delivered,
|
||||
func.sum(Delivery_Delivery.gallons_delivered).label('total_gallons'),
|
||||
func.count(Delivery_Delivery.id).label('total_count')
|
||||
).filter(
|
||||
Delivery_Delivery.delivery_status == 10, # Delivered status
|
||||
Delivery_Delivery.when_delivered >= start_date,
|
||||
Delivery_Delivery.when_delivered <= end_date
|
||||
).group_by(
|
||||
Delivery_Delivery.when_delivered
|
||||
).order_by(
|
||||
Delivery_Delivery.when_delivered
|
||||
).all()
|
||||
|
||||
result = []
|
||||
for day_stat in daily_stats:
|
||||
result.append({
|
||||
'date': day_stat.when_delivered.strftime('%Y-%m-%d'),
|
||||
'gallons': float(day_stat.total_gallons or 0),
|
||||
'count': int(day_stat.total_count or 0)
|
||||
})
|
||||
|
||||
return success_response({'daily_stats': result})
|
||||
|
||||
|
||||
@stats.route("/deliveries/totals", methods=["GET"])
|
||||
def get_delivery_totals():
|
||||
"""
|
||||
Get aggregated totals for a specific period compared to previous years.
|
||||
Query Params: period (day, month, year), date (YYYY-MM-DD)
|
||||
"""
|
||||
period = request.args.get('period', 'day')
|
||||
date_str = request.args.get('date')
|
||||
|
||||
logger.info(f"GET /stats/deliveries/totals - Fetching {period} totals for {date_str}")
|
||||
|
||||
try:
|
||||
target_date = datetime.datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
# Default to today if invalid
|
||||
target_date = date.today()
|
||||
|
||||
response_data = []
|
||||
|
||||
# specific years to check - current year and last 3 years
|
||||
years_to_check = [target_date.year, target_date.year - 1, target_date.year - 2, target_date.year - 3]
|
||||
|
||||
for year in years_to_check:
|
||||
# Calculate start/end based on period for this specific year
|
||||
if period == 'day':
|
||||
start = datetime.date(year, target_date.month, target_date.day)
|
||||
end = start
|
||||
elif period == 'month':
|
||||
start = datetime.date(year, target_date.month, 1)
|
||||
# Logic to get end of month
|
||||
next_month = start.replace(day=28) + datetime.timedelta(days=4)
|
||||
end = next_month - datetime.timedelta(days=next_month.day)
|
||||
elif period == 'year':
|
||||
start = datetime.date(year, 1, 1)
|
||||
end = datetime.date(year, 12, 31)
|
||||
else:
|
||||
return success_response({'error': 'Invalid period'}, 400)
|
||||
|
||||
stats = db.session.query(
|
||||
func.sum(Delivery_Delivery.gallons_delivered).label('total_gallons'),
|
||||
func.count(Delivery_Delivery.id).label('total_count')
|
||||
).filter(
|
||||
Delivery_Delivery.delivery_status == 10,
|
||||
Delivery_Delivery.when_delivered >= start,
|
||||
Delivery_Delivery.when_delivered <= end
|
||||
).first()
|
||||
|
||||
response_data.append({
|
||||
'year': year,
|
||||
'gallons': float(stats.total_gallons or 0) if stats else 0,
|
||||
'count': int(stats.total_count or 0) if stats else 0,
|
||||
'period_label': start.strftime('%Y-%m-%d') if period == 'day' else start.strftime('%b %Y') if period == 'month' else str(year)
|
||||
})
|
||||
|
||||
return success_response({'totals': response_data})
|
||||
|
||||
|
||||
# ============================================
|
||||
# Stats Dashboard Endpoints (for Charts)
|
||||
# ============================================
|
||||
|
||||
MONTH_NAMES = [
|
||||
'', 'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
]
|
||||
|
||||
|
||||
@stats.route("/gallons/daily", methods=["GET"])
|
||||
def get_gallons_daily():
|
||||
"""
|
||||
Get daily gallons delivered for time-series chart.
|
||||
Query Params:
|
||||
- start_date: YYYY-MM-DD
|
||||
- end_date: YYYY-MM-DD
|
||||
- years: comma-separated list of years (e.g., 2024,2025,2026)
|
||||
Returns daily gallons grouped by date for each requested year.
|
||||
"""
|
||||
from flask import request
|
||||
|
||||
start_date_str = request.args.get('start_date')
|
||||
end_date_str = request.args.get('end_date')
|
||||
years_str = request.args.get('years', str(date.today().year))
|
||||
|
||||
logger.info(f"GET /stats/gallons/daily - start={start_date_str}, end={end_date_str}, years={years_str}")
|
||||
|
||||
try:
|
||||
start_date = datetime.datetime.strptime(start_date_str, '%Y-%m-%d').date()
|
||||
end_date = datetime.datetime.strptime(end_date_str, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
return success_response({'ok': False, 'error': 'Invalid date format. Use YYYY-MM-DD'})
|
||||
|
||||
try:
|
||||
years = [int(y.strip()) for y in years_str.split(',')]
|
||||
except ValueError:
|
||||
return success_response({'ok': False, 'error': 'Invalid years format'})
|
||||
|
||||
result_years = []
|
||||
|
||||
for year in years:
|
||||
# Adjust dates to the specific year while keeping month/day
|
||||
year_start = start_date.replace(year=year)
|
||||
year_end = end_date.replace(year=year)
|
||||
|
||||
daily_data = db.session.query(
|
||||
func.date(Delivery_Delivery.when_delivered).label('delivery_date'),
|
||||
func.sum(Delivery_Delivery.gallons_delivered).label('gallons'),
|
||||
func.count(Delivery_Delivery.id).label('deliveries')
|
||||
).filter(
|
||||
Delivery_Delivery.delivery_status == 10,
|
||||
Delivery_Delivery.when_delivered >= year_start,
|
||||
Delivery_Delivery.when_delivered <= year_end
|
||||
).group_by(
|
||||
func.date(Delivery_Delivery.when_delivered)
|
||||
).order_by(
|
||||
func.date(Delivery_Delivery.when_delivered)
|
||||
).all()
|
||||
|
||||
data_points = []
|
||||
for row in daily_data:
|
||||
data_points.append({
|
||||
'date': row.delivery_date.strftime('%Y-%m-%d') if row.delivery_date else None,
|
||||
'gallons': float(row.gallons or 0),
|
||||
'deliveries': int(row.deliveries or 0)
|
||||
})
|
||||
|
||||
result_years.append({
|
||||
'year': year,
|
||||
'data': data_points
|
||||
})
|
||||
|
||||
return success_response({'years': result_years})
|
||||
|
||||
|
||||
@stats.route("/gallons/weekly", methods=["GET"])
|
||||
def get_gallons_weekly():
|
||||
"""
|
||||
Get weekly aggregated gallons delivered.
|
||||
Query Params:
|
||||
- start_date: YYYY-MM-DD
|
||||
- end_date: YYYY-MM-DD
|
||||
- years: comma-separated list of years
|
||||
Returns weekly gallons grouped by week number for each requested year.
|
||||
"""
|
||||
from flask import request
|
||||
|
||||
start_date_str = request.args.get('start_date')
|
||||
end_date_str = request.args.get('end_date')
|
||||
years_str = request.args.get('years', str(date.today().year))
|
||||
|
||||
logger.info(f"GET /stats/gallons/weekly - start={start_date_str}, end={end_date_str}, years={years_str}")
|
||||
|
||||
try:
|
||||
start_date = datetime.datetime.strptime(start_date_str, '%Y-%m-%d').date()
|
||||
end_date = datetime.datetime.strptime(end_date_str, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
return success_response({'ok': False, 'error': 'Invalid date format. Use YYYY-MM-DD'})
|
||||
|
||||
try:
|
||||
years = [int(y.strip()) for y in years_str.split(',')]
|
||||
except ValueError:
|
||||
return success_response({'ok': False, 'error': 'Invalid years format'})
|
||||
|
||||
result_years = []
|
||||
|
||||
for year in years:
|
||||
year_start = start_date.replace(year=year)
|
||||
year_end = end_date.replace(year=year)
|
||||
|
||||
# Group by ISO week number
|
||||
weekly_data = db.session.query(
|
||||
extract('week', Delivery_Delivery.when_delivered).label('week_num'),
|
||||
func.min(Delivery_Delivery.when_delivered).label('week_start'),
|
||||
func.max(Delivery_Delivery.when_delivered).label('week_end'),
|
||||
func.sum(Delivery_Delivery.gallons_delivered).label('gallons'),
|
||||
func.count(Delivery_Delivery.id).label('deliveries')
|
||||
).filter(
|
||||
Delivery_Delivery.delivery_status == 10,
|
||||
Delivery_Delivery.when_delivered >= year_start,
|
||||
Delivery_Delivery.when_delivered <= year_end
|
||||
).group_by(
|
||||
extract('week', Delivery_Delivery.when_delivered)
|
||||
).order_by(
|
||||
extract('week', Delivery_Delivery.when_delivered)
|
||||
).all()
|
||||
|
||||
data_points = []
|
||||
for row in weekly_data:
|
||||
data_points.append({
|
||||
'week_start': row.week_start.strftime('%Y-%m-%d') if row.week_start else None,
|
||||
'week_end': row.week_end.strftime('%Y-%m-%d') if row.week_end else None,
|
||||
'week_number': int(row.week_num) if row.week_num else 0,
|
||||
'gallons': float(row.gallons or 0),
|
||||
'deliveries': int(row.deliveries or 0)
|
||||
})
|
||||
|
||||
result_years.append({
|
||||
'year': year,
|
||||
'data': data_points
|
||||
})
|
||||
|
||||
return success_response({'years': result_years})
|
||||
|
||||
|
||||
@stats.route("/gallons/monthly", methods=["GET"])
|
||||
def get_gallons_monthly():
|
||||
"""
|
||||
Get monthly aggregated gallons delivered.
|
||||
Query Params:
|
||||
- year: primary year to display
|
||||
- compare_years: comma-separated list of years to compare
|
||||
Returns monthly gallons for each requested year.
|
||||
"""
|
||||
from flask import request
|
||||
|
||||
year = request.args.get('year', type=int, default=date.today().year)
|
||||
compare_years_str = request.args.get('compare_years', '')
|
||||
|
||||
logger.info(f"GET /stats/gallons/monthly - year={year}, compare_years={compare_years_str}")
|
||||
|
||||
years = [year]
|
||||
if compare_years_str:
|
||||
try:
|
||||
compare_years = [int(y.strip()) for y in compare_years_str.split(',')]
|
||||
years.extend(compare_years)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
result_years = []
|
||||
|
||||
for y in years:
|
||||
monthly_data = db.session.query(
|
||||
extract('month', Delivery_Delivery.when_delivered).label('month_num'),
|
||||
func.sum(Delivery_Delivery.gallons_delivered).label('gallons'),
|
||||
func.count(Delivery_Delivery.id).label('deliveries')
|
||||
).filter(
|
||||
Delivery_Delivery.delivery_status == 10,
|
||||
extract('year', Delivery_Delivery.when_delivered) == y
|
||||
).group_by(
|
||||
extract('month', Delivery_Delivery.when_delivered)
|
||||
).order_by(
|
||||
extract('month', Delivery_Delivery.when_delivered)
|
||||
).all()
|
||||
|
||||
data_points = []
|
||||
for row in monthly_data:
|
||||
month_num = int(row.month_num) if row.month_num else 0
|
||||
data_points.append({
|
||||
'month': month_num,
|
||||
'month_name': MONTH_NAMES[month_num] if 1 <= month_num <= 12 else '',
|
||||
'gallons': float(row.gallons or 0),
|
||||
'deliveries': int(row.deliveries or 0)
|
||||
})
|
||||
|
||||
result_years.append({
|
||||
'year': y,
|
||||
'data': data_points
|
||||
})
|
||||
|
||||
return success_response({'years': result_years})
|
||||
|
||||
|
||||
@stats.route("/totals/comparison", methods=["GET"])
|
||||
def get_totals_comparison():
|
||||
"""
|
||||
Get today/week-to-date/month-to-date/year-to-date totals with previous year comparison.
|
||||
Returns totals for current period compared to same period last year with % change.
|
||||
"""
|
||||
logger.info("GET /stats/totals/comparison - Fetching comparison totals")
|
||||
|
||||
today = date.today()
|
||||
current_year = today.year
|
||||
compare_year = current_year - 1
|
||||
|
||||
def get_period_totals(start_date, end_date):
|
||||
"""Helper to get gallons, deliveries, revenue for a date range."""
|
||||
result = db.session.query(
|
||||
func.coalesce(func.sum(Delivery_Delivery.gallons_delivered), 0).label('gallons'),
|
||||
func.count(Delivery_Delivery.id).label('deliveries'),
|
||||
func.coalesce(func.sum(Delivery_Delivery.final_price), 0).label('revenue')
|
||||
).filter(
|
||||
Delivery_Delivery.delivery_status == 10,
|
||||
Delivery_Delivery.when_delivered >= start_date,
|
||||
Delivery_Delivery.when_delivered <= end_date
|
||||
).first()
|
||||
return {
|
||||
'gallons': float(result.gallons or 0),
|
||||
'deliveries': int(result.deliveries or 0),
|
||||
'revenue': float(result.revenue or 0)
|
||||
}
|
||||
|
||||
def calculate_comparison(current_val, previous_val):
|
||||
"""Calculate % change and direction."""
|
||||
if previous_val == 0:
|
||||
change_percent = 100.0 if current_val > 0 else 0.0
|
||||
else:
|
||||
change_percent = ((current_val - previous_val) / previous_val) * 100
|
||||
|
||||
if change_percent > 0:
|
||||
direction = 'up'
|
||||
elif change_percent < 0:
|
||||
direction = 'down'
|
||||
else:
|
||||
direction = 'neutral'
|
||||
|
||||
return {
|
||||
'current': current_val,
|
||||
'previous': previous_val,
|
||||
'change_percent': round(change_percent, 1),
|
||||
'change_direction': direction
|
||||
}
|
||||
|
||||
def build_period_comparison(current_start, current_end, prev_start, prev_end):
|
||||
"""Build comparison object for a period."""
|
||||
current = get_period_totals(current_start, current_end)
|
||||
previous = get_period_totals(prev_start, prev_end)
|
||||
return {
|
||||
'gallons': calculate_comparison(current['gallons'], previous['gallons']),
|
||||
'deliveries': calculate_comparison(current['deliveries'], previous['deliveries']),
|
||||
'revenue': calculate_comparison(current['revenue'], previous['revenue'])
|
||||
}
|
||||
|
||||
# Today comparison
|
||||
today_current = today
|
||||
today_prev = today.replace(year=compare_year)
|
||||
today_comparison = build_period_comparison(today_current, today_current, today_prev, today_prev)
|
||||
|
||||
# Week-to-date comparison (Monday to today)
|
||||
monday_current = get_monday_date(today)
|
||||
monday_prev = monday_current.replace(year=compare_year)
|
||||
today_prev_week = today.replace(year=compare_year)
|
||||
wtd_comparison = build_period_comparison(monday_current, today, monday_prev, today_prev_week)
|
||||
|
||||
# Month-to-date comparison
|
||||
first_of_month_current = today.replace(day=1)
|
||||
first_of_month_prev = first_of_month_current.replace(year=compare_year)
|
||||
today_prev_month = today.replace(year=compare_year)
|
||||
mtd_comparison = build_period_comparison(first_of_month_current, today, first_of_month_prev, today_prev_month)
|
||||
|
||||
# Year-to-date comparison
|
||||
first_of_year_current = today.replace(month=1, day=1)
|
||||
first_of_year_prev = first_of_year_current.replace(year=compare_year)
|
||||
today_prev_year = today.replace(year=compare_year)
|
||||
ytd_comparison = build_period_comparison(first_of_year_current, today, first_of_year_prev, today_prev_year)
|
||||
|
||||
comparison_data = {
|
||||
'today': today_comparison,
|
||||
'week_to_date': wtd_comparison,
|
||||
'month_to_date': mtd_comparison,
|
||||
'year_to_date': ytd_comparison
|
||||
}
|
||||
|
||||
return success_response({
|
||||
'comparison': comparison_data,
|
||||
'current_year': current_year,
|
||||
'compare_year': compare_year
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user