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:
@@ -24,17 +24,52 @@ def create_oil_price():
|
|||||||
price_from_supplier = request.json["price_from_supplier"]
|
price_from_supplier = request.json["price_from_supplier"]
|
||||||
price_for_customer = request.json["price_for_customer"]
|
price_for_customer = request.json["price_for_customer"]
|
||||||
price_for_employee = request.json["price_for_employee"]
|
price_for_employee = request.json["price_for_employee"]
|
||||||
price_same_day = request.json["price_same_day"]
|
|
||||||
price_prime = request.json["price_prime"]
|
# Legacy single-tier pricing (for backward compatibility, use tier1 values)
|
||||||
price_emergency= request.json["price_emergency"]
|
price_same_day_tier1 = request.json.get("price_same_day_tier1", 0)
|
||||||
|
price_prime_tier1 = request.json.get("price_prime_tier1", 0)
|
||||||
|
price_emergency_tier1 = request.json.get("price_emergency_tier1", 0)
|
||||||
|
|
||||||
|
# Get all tier pricing
|
||||||
|
price_same_day_tier2 = request.json.get("price_same_day_tier2", 0)
|
||||||
|
price_same_day_tier3 = request.json.get("price_same_day_tier3", 0)
|
||||||
|
price_same_day_tier4 = request.json.get("price_same_day_tier4", 0)
|
||||||
|
price_same_day_tier5 = request.json.get("price_same_day_tier5", 0)
|
||||||
|
|
||||||
|
price_prime_tier2 = request.json.get("price_prime_tier2", 0)
|
||||||
|
price_prime_tier3 = request.json.get("price_prime_tier3", 0)
|
||||||
|
price_prime_tier4 = request.json.get("price_prime_tier4", 0)
|
||||||
|
price_prime_tier5 = request.json.get("price_prime_tier5", 0)
|
||||||
|
|
||||||
|
price_emergency_tier2 = request.json.get("price_emergency_tier2", 0)
|
||||||
|
price_emergency_tier3 = request.json.get("price_emergency_tier3", 0)
|
||||||
|
price_emergency_tier4 = request.json.get("price_emergency_tier4", 0)
|
||||||
|
price_emergency_tier5 = request.json.get("price_emergency_tier5", 0)
|
||||||
|
|
||||||
new_admin_oil_price = Pricing_Oil_Oil(
|
new_admin_oil_price = Pricing_Oil_Oil(
|
||||||
price_from_supplier=price_from_supplier,
|
price_from_supplier=price_from_supplier,
|
||||||
price_for_customer=price_for_customer,
|
price_for_customer=price_for_customer,
|
||||||
price_for_employee=price_for_employee,
|
price_for_employee=price_for_employee,
|
||||||
price_same_day=price_same_day,
|
# Legacy columns (use tier1 for backward compatibility)
|
||||||
price_prime=price_prime,
|
price_same_day=price_same_day_tier1,
|
||||||
price_emergency=price_emergency,
|
price_prime=price_prime_tier1,
|
||||||
|
price_emergency=price_emergency_tier1,
|
||||||
|
# Tier pricing
|
||||||
|
price_same_day_tier1=price_same_day_tier1,
|
||||||
|
price_same_day_tier2=price_same_day_tier2,
|
||||||
|
price_same_day_tier3=price_same_day_tier3,
|
||||||
|
price_same_day_tier4=price_same_day_tier4,
|
||||||
|
price_same_day_tier5=price_same_day_tier5,
|
||||||
|
price_prime_tier1=price_prime_tier1,
|
||||||
|
price_prime_tier2=price_prime_tier2,
|
||||||
|
price_prime_tier3=price_prime_tier3,
|
||||||
|
price_prime_tier4=price_prime_tier4,
|
||||||
|
price_prime_tier5=price_prime_tier5,
|
||||||
|
price_emergency_tier1=price_emergency_tier1,
|
||||||
|
price_emergency_tier2=price_emergency_tier2,
|
||||||
|
price_emergency_tier3=price_emergency_tier3,
|
||||||
|
price_emergency_tier4=price_emergency_tier4,
|
||||||
|
price_emergency_tier5=price_emergency_tier5,
|
||||||
date=now,
|
date=now,
|
||||||
)
|
)
|
||||||
# new_admin_oil_price = Pricing_Oil_Oil(
|
# new_admin_oil_price = Pricing_Oil_Oil(
|
||||||
|
|||||||
@@ -51,12 +51,15 @@ class Auth_User(UserMixin, db.Model):
|
|||||||
self.confirmed = confirmed
|
self.confirmed = confirmed
|
||||||
self.active = active
|
self.active = active
|
||||||
|
|
||||||
|
@property
|
||||||
def is_authenticated(self):
|
def is_authenticated(self):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
def is_active(self):
|
def is_active(self):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
def is_anonymous(self):
|
def is_anonymous(self):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -57,12 +57,14 @@ class Auto_Delivery(db.Model):
|
|||||||
estimated_gallons_left_prev_day = db.Column(db.DECIMAL(6, 2))
|
estimated_gallons_left_prev_day = db.Column(db.DECIMAL(6, 2))
|
||||||
tank_height = db.Column(db.VARCHAR(25))
|
tank_height = db.Column(db.VARCHAR(25))
|
||||||
tank_size = db.Column(db.VARCHAR(25))
|
tank_size = db.Column(db.VARCHAR(25))
|
||||||
house_factor = db.Column(db.DECIMAL(5, 2))
|
house_factor = db.Column(db.DECIMAL(7, 4))
|
||||||
hot_water_summer = db.Column(db.Integer)
|
hot_water_summer = db.Column(db.Integer)
|
||||||
#0 = waiting
|
#0 = waiting
|
||||||
#1 = waiting for delivery
|
#1 = waiting for delivery
|
||||||
auto_status = db.Column(db.INTEGER())
|
auto_status = db.Column(db.INTEGER())
|
||||||
open_ticket_id = db.Column(db.Integer)
|
open_ticket_id = db.Column(db.Integer)
|
||||||
|
confidence_score = db.Column(db.Integer, default=20)
|
||||||
|
k_factor_source = db.Column(db.VARCHAR(20), default='default')
|
||||||
|
|
||||||
|
|
||||||
class Auto_Delivery_schema(ma.SQLAlchemyAutoSchema):
|
class Auto_Delivery_schema(ma.SQLAlchemyAutoSchema):
|
||||||
@@ -98,6 +100,7 @@ class Tickets_Auto_Delivery(db.Model):
|
|||||||
payment_type = db.Column(db.INTEGER, nullable=True)
|
payment_type = db.Column(db.INTEGER, nullable=True)
|
||||||
payment_card_id = db.Column(db.INTEGER, nullable=True)
|
payment_card_id = db.Column(db.INTEGER, nullable=True)
|
||||||
payment_status = db.Column(db.INTEGER, nullable=True)
|
payment_status = db.Column(db.INTEGER, nullable=True)
|
||||||
|
is_budget_fill = db.Column(db.Boolean, default=False)
|
||||||
|
|
||||||
|
|
||||||
class Tickets_Auto_Delivery_schema(ma.SQLAlchemyAutoSchema):
|
class Tickets_Auto_Delivery_schema(ma.SQLAlchemyAutoSchema):
|
||||||
|
|||||||
28
app/classes/competitor.py
Normal file
28
app/classes/competitor.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from app import db, ma
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class CompanyPrice(db.Model):
|
||||||
|
__tablename__ = "company_prices"
|
||||||
|
__table_args__ = {"schema": "public"}
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||||
|
company_name = db.Column(db.String(255), nullable=False, index=True)
|
||||||
|
town = db.Column(db.String(100), nullable=True)
|
||||||
|
price_decimal = db.Column(db.Numeric(6, 3), nullable=False)
|
||||||
|
scrape_date = db.Column(db.Date, nullable=False, index=True)
|
||||||
|
zone = db.Column(db.String(50), nullable=False, default="zone10", index=True)
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"company_name": self.company_name,
|
||||||
|
"town": self.town,
|
||||||
|
"price": float(self.price_decimal) if self.price_decimal else None,
|
||||||
|
"date": self.scrape_date.isoformat() if self.scrape_date else None,
|
||||||
|
"zone": self.zone
|
||||||
|
}
|
||||||
|
|
||||||
|
class CompanyPriceSchema(ma.SQLAlchemyAutoSchema):
|
||||||
|
class Meta:
|
||||||
|
model = CompanyPrice
|
||||||
@@ -95,7 +95,7 @@ class Customer_Description(db.Model):
|
|||||||
customer_id = db.Column(db.INTEGER)
|
customer_id = db.Column(db.INTEGER)
|
||||||
account_number = db.Column(db.VARCHAR(25))
|
account_number = db.Column(db.VARCHAR(25))
|
||||||
company_id = db.Column(db.INTEGER)
|
company_id = db.Column(db.INTEGER)
|
||||||
fill_location = db.Column(db.INTEGER)
|
fill_location = db.Column(db.VARCHAR(250))
|
||||||
description = db.Column(db.VARCHAR(2000))
|
description = db.Column(db.VARCHAR(2000))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ class Delivery_Delivery(db.Model):
|
|||||||
same_day = db.Column(db.INTEGER)
|
same_day = db.Column(db.INTEGER)
|
||||||
emergency = db.Column(db.INTEGER)
|
emergency = db.Column(db.INTEGER)
|
||||||
|
|
||||||
|
# Pricing tier selection (1-5) for each service type
|
||||||
|
pricing_tier_same_day = db.Column(db.INTEGER, default=1)
|
||||||
|
pricing_tier_prime = db.Column(db.INTEGER, default=1)
|
||||||
|
pricing_tier_emergency = db.Column(db.INTEGER, default=1)
|
||||||
|
|
||||||
# cash = 0
|
# cash = 0
|
||||||
# credit = 1
|
# credit = 1
|
||||||
# credit/cash = 2
|
# credit/cash = 2
|
||||||
|
|||||||
@@ -17,9 +17,33 @@ class Pricing_Oil_Oil(db.Model):
|
|||||||
price_from_supplier = db.Column(db.DECIMAL(6, 2))
|
price_from_supplier = db.Column(db.DECIMAL(6, 2))
|
||||||
price_for_customer = db.Column(db.DECIMAL(6, 2))
|
price_for_customer = db.Column(db.DECIMAL(6, 2))
|
||||||
price_for_employee = db.Column(db.DECIMAL(6, 2))
|
price_for_employee = db.Column(db.DECIMAL(6, 2))
|
||||||
|
|
||||||
|
# Legacy single-tier pricing (kept for backward compatibility)
|
||||||
price_same_day = db.Column(db.DECIMAL(6, 2))
|
price_same_day = db.Column(db.DECIMAL(6, 2))
|
||||||
price_prime = db.Column(db.DECIMAL(6, 2))
|
price_prime = db.Column(db.DECIMAL(6, 2))
|
||||||
price_emergency = db.Column(db.DECIMAL(6, 2))
|
price_emergency = db.Column(db.DECIMAL(6, 2))
|
||||||
|
|
||||||
|
# New 5-tier pricing for same_day service
|
||||||
|
price_same_day_tier1 = db.Column(db.DECIMAL(6, 2))
|
||||||
|
price_same_day_tier2 = db.Column(db.DECIMAL(6, 2))
|
||||||
|
price_same_day_tier3 = db.Column(db.DECIMAL(6, 2))
|
||||||
|
price_same_day_tier4 = db.Column(db.DECIMAL(6, 2))
|
||||||
|
price_same_day_tier5 = db.Column(db.DECIMAL(6, 2))
|
||||||
|
|
||||||
|
# New 5-tier pricing for prime service
|
||||||
|
price_prime_tier1 = db.Column(db.DECIMAL(6, 2))
|
||||||
|
price_prime_tier2 = db.Column(db.DECIMAL(6, 2))
|
||||||
|
price_prime_tier3 = db.Column(db.DECIMAL(6, 2))
|
||||||
|
price_prime_tier4 = db.Column(db.DECIMAL(6, 2))
|
||||||
|
price_prime_tier5 = db.Column(db.DECIMAL(6, 2))
|
||||||
|
|
||||||
|
# New 5-tier pricing for emergency service
|
||||||
|
price_emergency_tier1 = db.Column(db.DECIMAL(6, 2))
|
||||||
|
price_emergency_tier2 = db.Column(db.DECIMAL(6, 2))
|
||||||
|
price_emergency_tier3 = db.Column(db.DECIMAL(6, 2))
|
||||||
|
price_emergency_tier4 = db.Column(db.DECIMAL(6, 2))
|
||||||
|
price_emergency_tier5 = db.Column(db.DECIMAL(6, 2))
|
||||||
|
|
||||||
date = db.Column(db.TIMESTAMP(), default=datetime.utcnow())
|
date = db.Column(db.TIMESTAMP(), default=datetime.utcnow())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
28
app/classes/ticker.py
Normal file
28
app/classes/ticker.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from app import db, ma
|
||||||
|
|
||||||
|
class Ticker_Price(db.Model):
|
||||||
|
__tablename__ = 'ticker_prices'
|
||||||
|
__table_args__ = {"schema": "public"}
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||||
|
symbol = db.Column(db.String(20), nullable=False, index=True)
|
||||||
|
price_decimal = db.Column(db.Numeric(10, 4), nullable=False)
|
||||||
|
currency = db.Column(db.String(10), nullable=True)
|
||||||
|
change_decimal = db.Column(db.Numeric(10, 4), nullable=True)
|
||||||
|
percent_change_decimal = db.Column(db.Numeric(10, 4), nullable=True)
|
||||||
|
timestamp = db.Column(db.TIMESTAMP(), default=datetime.utcnow, index=True)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"symbol": self.symbol,
|
||||||
|
"price": float(self.price_decimal) if self.price_decimal is not None else None,
|
||||||
|
"currency": self.currency,
|
||||||
|
"change": float(self.change_decimal) if self.change_decimal is not None else None,
|
||||||
|
"percent_change": float(self.percent_change_decimal) if self.percent_change_decimal is not None else None,
|
||||||
|
"timestamp": self.timestamp.isoformat() if self.timestamp else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
class Ticker_Price_Schema(ma.SQLAlchemyAutoSchema):
|
||||||
|
class Meta:
|
||||||
|
model = Ticker_Price
|
||||||
@@ -20,7 +20,7 @@ def login_required(f):
|
|||||||
def admin_required(f):
|
def admin_required(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated_function(*args, **kwargs):
|
def decorated_function(*args, **kwargs):
|
||||||
if not current_user.is_authenticated or not current_user.admin_role:
|
if not current_user.is_authenticated or (not current_user.admin_role and not current_user.admin):
|
||||||
return error_response("Admin access required", 403)
|
return error_response("Admin access required", 403)
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
return decorated_function
|
return decorated_function
|
||||||
|
|||||||
@@ -588,16 +588,13 @@ def edit_customer_tank(customer_id):
|
|||||||
response_last_tank_inspection = data.get("last_tank_inspection", None)
|
response_last_tank_inspection = data.get("last_tank_inspection", None)
|
||||||
response_tank_size = data.get("tank_size", 0)
|
response_tank_size = data.get("tank_size", 0)
|
||||||
|
|
||||||
# --- FIX APPLIED HERE ---
|
# Updated to allow string values
|
||||||
# 1. Get the value from the request. Default to 0 if it's missing.
|
response_customer_fill_location = data.get("fill_location")
|
||||||
response_customer_fill_location = data.get("fill_location", 0)
|
|
||||||
|
|
||||||
# 2. Add a safety check: if the frontend sent an empty string, convert it to 0.
|
|
||||||
if response_customer_fill_location == "":
|
|
||||||
response_customer_fill_location = 0
|
|
||||||
|
|
||||||
get_customer_tank.last_tank_inspection = response_last_tank_inspection
|
get_customer_tank.last_tank_inspection = response_last_tank_inspection
|
||||||
get_customer_tank.tank_size = response_tank_size
|
get_customer_tank.tank_size = response_tank_size
|
||||||
|
|
||||||
|
if response_customer_fill_location is not None:
|
||||||
get_customer_description.fill_location = response_customer_fill_location
|
get_customer_description.fill_location = response_customer_fill_location
|
||||||
|
|
||||||
if get_customer.customer_automatic == 1:
|
if get_customer.customer_automatic == 1:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from decimal import Decimal
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
@@ -24,6 +25,109 @@ from app.classes.auto import Tickets_Auto_Delivery, Tickets_Auto_Delivery_schema
|
|||||||
|
|
||||||
|
|
||||||
# This endpoint is fine, but I've added some comments for clarity.
|
# This endpoint is fine, but I've added some comments for clarity.
|
||||||
|
@delivery.route("/map", methods=["GET"])
|
||||||
|
@common_login_required
|
||||||
|
def get_deliveries_for_map():
|
||||||
|
"""Get deliveries for map view by date."""
|
||||||
|
date_str = request.args.get('date')
|
||||||
|
if date_str:
|
||||||
|
try:
|
||||||
|
target_date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
return error_response("Invalid date format. Use YYYY-MM-DD", 400)
|
||||||
|
else:
|
||||||
|
target_date = date.today()
|
||||||
|
|
||||||
|
deliveries = (db.session
|
||||||
|
.query(Delivery_Delivery, Customer_Customer)
|
||||||
|
.join(Customer_Customer, Delivery_Delivery.customer_id == Customer_Customer.id)
|
||||||
|
.filter(Delivery_Delivery.expected_delivery_date == target_date)
|
||||||
|
.filter(Delivery_Delivery.delivery_status.notin_([1, 10]))
|
||||||
|
.order_by(Delivery_Delivery.customer_town.asc())
|
||||||
|
.all())
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for delivery_item, customer in deliveries:
|
||||||
|
result.append({
|
||||||
|
'id': delivery_item.id,
|
||||||
|
'street': delivery_item.customer_address,
|
||||||
|
'town': delivery_item.customer_town,
|
||||||
|
'state': delivery_item.customer_state,
|
||||||
|
'zipcode': delivery_item.customer_zip,
|
||||||
|
'customerName': delivery_item.customer_name,
|
||||||
|
'notes': delivery_item.dispatcher_notes or '',
|
||||||
|
'latitude': customer.customer_latitude,
|
||||||
|
'longitude': customer.customer_longitude,
|
||||||
|
'gallonsOrdered': delivery_item.gallons_ordered,
|
||||||
|
'isFill': delivery_item.customer_asked_for_fill == 1,
|
||||||
|
'deliveryStatus': delivery_item.delivery_status,
|
||||||
|
'customerId': delivery_item.customer_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return success_response({'deliveries': result})
|
||||||
|
|
||||||
|
|
||||||
|
@delivery.route("/history", methods=["GET"])
|
||||||
|
@common_login_required
|
||||||
|
def get_deliveries_history():
|
||||||
|
"""Get completed deliveries (delivered or finalized) for a date range."""
|
||||||
|
start_date_str = request.args.get('start_date')
|
||||||
|
end_date_str = request.args.get('end_date')
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() if start_date_str else date.today()
|
||||||
|
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() if end_date_str else date.today()
|
||||||
|
except ValueError:
|
||||||
|
return error_response("Invalid date format. Use YYYY-MM-DD", 400)
|
||||||
|
|
||||||
|
# Status 10 = finalized, 11 = delivered
|
||||||
|
deliveries = (db.session
|
||||||
|
.query(Delivery_Delivery)
|
||||||
|
.filter(Delivery_Delivery.expected_delivery_date >= start_date)
|
||||||
|
.filter(Delivery_Delivery.expected_delivery_date <= end_date)
|
||||||
|
.filter(Delivery_Delivery.delivery_status.in_([10, 11]))
|
||||||
|
.order_by(Delivery_Delivery.expected_delivery_date.desc(), Delivery_Delivery.customer_town.asc(), Delivery_Delivery.customer_name.asc())
|
||||||
|
.all())
|
||||||
|
|
||||||
|
result = []
|
||||||
|
total_gallons = 0
|
||||||
|
prime_count = 0
|
||||||
|
emergency_count = 0
|
||||||
|
same_day_count = 0
|
||||||
|
|
||||||
|
for d in deliveries:
|
||||||
|
gallons = float(d.gallons_delivered) if d.gallons_delivered and d.gallons_delivered > 0 else float(d.gallons_ordered or 0)
|
||||||
|
total_gallons += gallons
|
||||||
|
if d.prime == 1:
|
||||||
|
prime_count += 1
|
||||||
|
if d.emergency == 1:
|
||||||
|
emergency_count += 1
|
||||||
|
if d.same_day == 1:
|
||||||
|
same_day_count += 1
|
||||||
|
|
||||||
|
result.append({
|
||||||
|
'id': d.id,
|
||||||
|
'customerId': d.customer_id,
|
||||||
|
'customerName': d.customer_name,
|
||||||
|
'town': d.customer_town,
|
||||||
|
'address': f"{d.customer_address}, {d.customer_town}, {d.customer_state} {d.customer_zip}",
|
||||||
|
'gallonsDelivered': gallons,
|
||||||
|
'prime': d.prime == 1,
|
||||||
|
'emergency': d.emergency == 1,
|
||||||
|
'sameDay': d.same_day == 1,
|
||||||
|
'deliveryStatus': d.delivery_status,
|
||||||
|
})
|
||||||
|
|
||||||
|
return success_response({
|
||||||
|
'deliveries': result,
|
||||||
|
'totalGallons': total_gallons,
|
||||||
|
'totalDeliveries': len(result),
|
||||||
|
'primeCount': prime_count,
|
||||||
|
'emergencyCount': emergency_count,
|
||||||
|
'sameDayCount': same_day_count,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@delivery.route("/updatestatus", methods=["GET"])
|
@delivery.route("/updatestatus", methods=["GET"])
|
||||||
@common_login_required
|
@common_login_required
|
||||||
def move_deliveries():
|
def move_deliveries():
|
||||||
@@ -508,6 +612,11 @@ def edit_a_delivery(delivery_id):
|
|||||||
get_delivery.same_day = 1 if data.get("same_day") else 0
|
get_delivery.same_day = 1 if data.get("same_day") else 0
|
||||||
get_delivery.emergency = 1 if data.get("emergency") else 0
|
get_delivery.emergency = 1 if data.get("emergency") else 0
|
||||||
|
|
||||||
|
# Get tier selections (default to existing or 1)
|
||||||
|
get_delivery.pricing_tier_same_day = data.get("pricing_tier_same_day", get_delivery.pricing_tier_same_day or 1)
|
||||||
|
get_delivery.pricing_tier_prime = data.get("pricing_tier_prime", get_delivery.pricing_tier_prime or 1)
|
||||||
|
get_delivery.pricing_tier_emergency = data.get("pricing_tier_emergency", get_delivery.pricing_tier_emergency or 1)
|
||||||
|
|
||||||
# --- Handle Driver Assignment ---
|
# --- Handle Driver Assignment ---
|
||||||
driver_id = data.get("driver_employee_id")
|
driver_id = data.get("driver_employee_id")
|
||||||
if driver_id:
|
if driver_id:
|
||||||
@@ -551,14 +660,20 @@ def edit_a_delivery(delivery_id):
|
|||||||
gallons_for_calc = 250 if customer_wants_fill else int(get_delivery.gallons_ordered)
|
gallons_for_calc = 250 if customer_wants_fill else int(get_delivery.gallons_ordered)
|
||||||
base_price = gallons_for_calc * get_today_price.price_for_customer
|
base_price = gallons_for_calc * get_today_price.price_for_customer
|
||||||
|
|
||||||
# Add fees
|
# Add fees using tier pricing
|
||||||
total_price = base_price
|
total_price = base_price
|
||||||
if get_delivery.prime:
|
if get_delivery.prime:
|
||||||
total_price += get_today_price.price_prime
|
tier_field = f"price_prime_tier{get_delivery.pricing_tier_prime}"
|
||||||
|
prime_fee = Decimal(getattr(get_today_price, tier_field, get_today_price.price_prime))
|
||||||
|
total_price += prime_fee
|
||||||
if get_delivery.same_day:
|
if get_delivery.same_day:
|
||||||
total_price += get_today_price.price_same_day
|
tier_field = f"price_same_day_tier{get_delivery.pricing_tier_same_day}"
|
||||||
|
same_day_fee = Decimal(getattr(get_today_price, tier_field, get_today_price.price_same_day))
|
||||||
|
total_price += same_day_fee
|
||||||
if get_delivery.emergency:
|
if get_delivery.emergency:
|
||||||
total_price += get_today_price.price_emergency
|
tier_field = f"price_emergency_tier{get_delivery.pricing_tier_emergency}"
|
||||||
|
emergency_fee = Decimal(getattr(get_today_price, tier_field, get_today_price.price_emergency))
|
||||||
|
total_price += emergency_fee
|
||||||
|
|
||||||
get_delivery.total_price = base_price # Price before fees
|
get_delivery.total_price = base_price # Price before fees
|
||||||
get_delivery.pre_charge_amount = total_price # Price including fees
|
get_delivery.pre_charge_amount = total_price # Price including fees
|
||||||
@@ -604,6 +719,11 @@ def create_a_delivery(user_id):
|
|||||||
emergency_info = request.json["emergency"]
|
emergency_info = request.json["emergency"]
|
||||||
delivery_driver_id = request.json.get("driver_employee_id", 0)
|
delivery_driver_id = request.json.get("driver_employee_id", 0)
|
||||||
|
|
||||||
|
# Get tier selections (default to tier 1)
|
||||||
|
pricing_tier_same_day = request.json.get("pricing_tier_same_day", 1)
|
||||||
|
pricing_tier_prime = request.json.get("pricing_tier_prime", 1)
|
||||||
|
pricing_tier_emergency = request.json.get("pricing_tier_emergency", 1)
|
||||||
|
|
||||||
card_payment = request.json["credit"]
|
card_payment = request.json["credit"]
|
||||||
cash_payment = request.json["cash"]
|
cash_payment = request.json["cash"]
|
||||||
check_payment = request.json["check"]
|
check_payment = request.json["check"]
|
||||||
@@ -718,18 +838,23 @@ def create_a_delivery(user_id):
|
|||||||
precharge_amount = int(gallons_ordered) * get_today_price.price_for_customer
|
precharge_amount = int(gallons_ordered) * get_today_price.price_for_customer
|
||||||
|
|
||||||
|
|
||||||
# if prime/emergency/sameday
|
# Calculate fees using tier pricing
|
||||||
if same_day_asked == 1 and prime_asked == 0:
|
total_precharge_amount = precharge_amount
|
||||||
total_precharge_amount = precharge_amount + get_today_price.price_same_day
|
|
||||||
|
|
||||||
elif prime_asked == 1 and same_day_asked == 0:
|
if same_day_asked == 1:
|
||||||
total_precharge_amount = precharge_amount + get_today_price.price_prime
|
tier_field = f"price_same_day_tier{pricing_tier_same_day}"
|
||||||
|
same_day_fee = Decimal(getattr(get_today_price, tier_field, get_today_price.price_same_day))
|
||||||
|
total_precharge_amount += same_day_fee
|
||||||
|
|
||||||
elif emergency_asked == 1:
|
if prime_asked == 1:
|
||||||
total_precharge_amount = precharge_amount + get_today_price.price_emergency
|
tier_field = f"price_prime_tier{pricing_tier_prime}"
|
||||||
|
prime_fee = Decimal(getattr(get_today_price, tier_field, get_today_price.price_prime))
|
||||||
|
total_precharge_amount += prime_fee
|
||||||
|
|
||||||
else:
|
if emergency_asked == 1:
|
||||||
total_precharge_amount = precharge_amount + get_today_price.price_prime + get_today_price.price_same_day
|
tier_field = f"price_emergency_tier{pricing_tier_emergency}"
|
||||||
|
emergency_fee = Decimal(getattr(get_today_price, tier_field, get_today_price.price_emergency))
|
||||||
|
total_precharge_amount += emergency_fee
|
||||||
|
|
||||||
|
|
||||||
new_delivery = Delivery_Delivery(
|
new_delivery = Delivery_Delivery(
|
||||||
@@ -757,6 +882,9 @@ def create_a_delivery(user_id):
|
|||||||
prime=prime_asked,
|
prime=prime_asked,
|
||||||
same_day=same_day_asked,
|
same_day=same_day_asked,
|
||||||
emergency=emergency_asked,
|
emergency=emergency_asked,
|
||||||
|
pricing_tier_same_day=pricing_tier_same_day,
|
||||||
|
pricing_tier_prime=pricing_tier_prime,
|
||||||
|
pricing_tier_emergency=pricing_tier_emergency,
|
||||||
payment_type=delivery_payment_method,
|
payment_type=delivery_payment_method,
|
||||||
payment_card_id=card_id_from_customer,
|
payment_card_id=card_id_from_customer,
|
||||||
pre_charge_amount=total_precharge_amount,
|
pre_charge_amount=total_precharge_amount,
|
||||||
@@ -955,17 +1083,23 @@ def calculate_total(delivery_id):
|
|||||||
.first())
|
.first())
|
||||||
|
|
||||||
if get_delivery.prime == 1:
|
if get_delivery.prime == 1:
|
||||||
priceprime = float(get_price_query.price_prime)
|
tier = get_delivery.pricing_tier_prime or 1
|
||||||
|
tier_field = f"price_prime_tier{tier}"
|
||||||
|
priceprime = float(getattr(get_price_query, tier_field, get_price_query.price_prime))
|
||||||
else:
|
else:
|
||||||
priceprime = 0
|
priceprime = 0
|
||||||
|
|
||||||
if get_delivery.emergency == 1:
|
if get_delivery.emergency == 1:
|
||||||
priceemergency = float(get_price_query.price_emergency)
|
tier = get_delivery.pricing_tier_emergency or 1
|
||||||
|
tier_field = f"price_emergency_tier{tier}"
|
||||||
|
priceemergency = float(getattr(get_price_query, tier_field, get_price_query.price_emergency))
|
||||||
else:
|
else:
|
||||||
priceemergency = 0
|
priceemergency = 0
|
||||||
|
|
||||||
if get_delivery.same_day == 1:
|
if get_delivery.same_day == 1:
|
||||||
pricesameday = float(get_price_query.price_same_day)
|
tier = get_delivery.pricing_tier_same_day or 1
|
||||||
|
tier_field = f"price_same_day_tier{tier}"
|
||||||
|
pricesameday = float(getattr(get_price_query, tier_field, get_price_query.price_same_day))
|
||||||
else:
|
else:
|
||||||
pricesameday = 0
|
pricesameday = 0
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from app.delivery_data import delivery_data
|
from app.delivery_data import delivery_data
|
||||||
from app import db
|
from app import db
|
||||||
from app.common.responses import success_response
|
from app.common.responses import success_response, error_response
|
||||||
from app.classes.customer import Customer_Customer, Customer_Description
|
from app.classes.customer import Customer_Customer, Customer_Description
|
||||||
from app.classes.delivery import Delivery_Delivery
|
from app.classes.delivery import Delivery_Delivery
|
||||||
from app.classes.employee import Employee_Employee
|
from app.classes.employee import Employee_Employee
|
||||||
@@ -29,8 +29,12 @@ def office_finalize_delivery(delivery_id):
|
|||||||
get_delivery = db.session \
|
get_delivery = db.session \
|
||||||
.query(Delivery_Delivery) \
|
.query(Delivery_Delivery) \
|
||||||
.filter(Delivery_Delivery.id == delivery_id) \
|
.filter(Delivery_Delivery.id == delivery_id) \
|
||||||
|
.filter(Delivery_Delivery.id == delivery_id) \
|
||||||
.first()
|
.first()
|
||||||
|
|
||||||
|
if get_delivery is None:
|
||||||
|
return error_response("Delivery not found", 404)
|
||||||
|
|
||||||
get_customer = db.session \
|
get_customer = db.session \
|
||||||
.query(Customer_Customer) \
|
.query(Customer_Customer) \
|
||||||
.filter(Customer_Customer.id == get_delivery.customer_id) \
|
.filter(Customer_Customer.id == get_delivery.customer_id) \
|
||||||
@@ -60,6 +64,11 @@ def office_finalize_delivery(delivery_id):
|
|||||||
.filter(Employee_Employee.id == get_delivery.driver_employee_id)
|
.filter(Employee_Employee.id == get_delivery.driver_employee_id)
|
||||||
.first())
|
.first())
|
||||||
|
|
||||||
|
if get_driver is None:
|
||||||
|
logger.warning(f"Driver with ID {get_delivery.driver_employee_id} not found for delivery {delivery_id}. Proceeding without updating driver stats.")
|
||||||
|
|
||||||
|
get_stats_employee = None
|
||||||
|
if get_driver:
|
||||||
get_stats_employee = (db.session
|
get_stats_employee = (db.session
|
||||||
.query(Stats_Employee_Oil)
|
.query(Stats_Employee_Oil)
|
||||||
.filter(Stats_Employee_Oil.employee_id == get_delivery.driver_employee_id)
|
.filter(Stats_Employee_Oil.employee_id == get_delivery.driver_employee_id)
|
||||||
@@ -89,7 +98,7 @@ def office_finalize_delivery(delivery_id):
|
|||||||
.filter(Stats_Customer.customer_id == get_customer.id)
|
.filter(Stats_Customer.customer_id == get_customer.id)
|
||||||
.first())
|
.first())
|
||||||
|
|
||||||
if get_stats_employee is None:
|
if get_driver and get_stats_employee is None:
|
||||||
create_stats = Stats_Employee_Oil(
|
create_stats = Stats_Employee_Oil(
|
||||||
employee_id = get_delivery.driver_employee_id,
|
employee_id = get_delivery.driver_employee_id,
|
||||||
total_deliveries = 0,
|
total_deliveries = 0,
|
||||||
@@ -116,7 +125,8 @@ def office_finalize_delivery(delivery_id):
|
|||||||
fill_location = request.json.get("fill_location")
|
fill_location = request.json.get("fill_location")
|
||||||
|
|
||||||
|
|
||||||
# update driver
|
# update driver if found
|
||||||
|
if get_driver:
|
||||||
get_delivery.driver_last_name = get_driver.employee_last_name
|
get_delivery.driver_last_name = get_driver.employee_last_name
|
||||||
get_delivery.driver_first_name = get_driver.employee_first_name
|
get_delivery.driver_first_name = get_driver.employee_first_name
|
||||||
get_delivery.driver_employee_id = get_driver.id
|
get_delivery.driver_employee_id = get_driver.id
|
||||||
@@ -128,7 +138,8 @@ def office_finalize_delivery(delivery_id):
|
|||||||
get_delivery.check_number = check_number
|
get_delivery.check_number = check_number
|
||||||
get_delivery.delivery_status = 10
|
get_delivery.delivery_status = 10
|
||||||
|
|
||||||
# update stats employee
|
# update stats employee if found
|
||||||
|
if get_stats_employee:
|
||||||
current_deliveres = get_stats_employee.total_deliveries + 1
|
current_deliveres = get_stats_employee.total_deliveries + 1
|
||||||
get_stats_employee.total_deliveries = current_deliveres
|
get_stats_employee.total_deliveries = current_deliveres
|
||||||
|
|
||||||
@@ -148,6 +159,7 @@ def office_finalize_delivery(delivery_id):
|
|||||||
|
|
||||||
db.session.add(get_customer_description)
|
db.session.add(get_customer_description)
|
||||||
db.session.add(get_stats_customer)
|
db.session.add(get_stats_customer)
|
||||||
|
if get_stats_employee:
|
||||||
db.session.add(get_stats_employee)
|
db.session.add(get_stats_employee)
|
||||||
db.session.add(get_delivery)
|
db.session.add(get_delivery)
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,14 @@ from app.classes.pricing import Pricing_Oil_Oil, Pricing_Oil_Oil_schema
|
|||||||
from app.classes.admin import Admin_Company
|
from app.classes.admin import Admin_Company
|
||||||
from app.classes.delivery import Delivery_Delivery
|
from app.classes.delivery import Delivery_Delivery
|
||||||
from app.classes.service import Service_Service
|
from app.classes.service import Service_Service
|
||||||
|
from app.classes.ticker import Ticker_Price, Ticker_Price_Schema
|
||||||
|
from app.classes.competitor import CompanyPrice
|
||||||
|
|
||||||
from flask_login import login_required
|
from flask_login import login_required
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from flask import request
|
||||||
|
|
||||||
|
|
||||||
@info.route("/price/oil/tiers", methods=["GET"])
|
@info.route("/price/oil/tiers", methods=["GET"])
|
||||||
@@ -84,3 +89,116 @@ def get_company():
|
|||||||
'name': get_data_company.company_name,
|
'name': get_data_company.company_name,
|
||||||
'telephone': get_data_company.company_phone_number,
|
'telephone': get_data_company.company_phone_number,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@info.route("/price/ticker", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def get_ticker_prices():
|
||||||
|
"""Get latest stock/commodity ticker prices."""
|
||||||
|
logger.info("GET /info/price/ticker - Fetching ticker prices")
|
||||||
|
|
||||||
|
# We want the latest price for each symbol
|
||||||
|
# HO=F, CL=F, RB=F
|
||||||
|
target_symbols = ["HO=F", "CL=F", "RB=F"]
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
# 1. Fetch Market Tickers
|
||||||
|
for symbol in target_symbols:
|
||||||
|
latest = (db.session.query(Ticker_Price)
|
||||||
|
.filter(Ticker_Price.symbol == symbol)
|
||||||
|
.order_by(Ticker_Price.timestamp.desc())
|
||||||
|
.first())
|
||||||
|
|
||||||
|
if latest:
|
||||||
|
results[symbol] = latest.to_dict()
|
||||||
|
|
||||||
|
# 2. Fetch Competitor Prices
|
||||||
|
# Focusing on LMT Oil and Charlton Oil as requested, but fetching others too for completeness
|
||||||
|
competitors = [
|
||||||
|
"LMT OIL",
|
||||||
|
"CHARLTON OIL",
|
||||||
|
"LEBLANC OIL",
|
||||||
|
"ALS OIL",
|
||||||
|
"VALUE OIL",
|
||||||
|
"DADDY'S OIL"
|
||||||
|
]
|
||||||
|
|
||||||
|
for comp_name in competitors:
|
||||||
|
latest_comp = (db.session.query(CompanyPrice)
|
||||||
|
.filter(CompanyPrice.company_name.ilike(f"%{comp_name}%"))
|
||||||
|
.order_by(CompanyPrice.scrape_date.desc())
|
||||||
|
.first())
|
||||||
|
|
||||||
|
if latest_comp:
|
||||||
|
# Map to Ticker format for uniform frontend handling if possible, or distinct
|
||||||
|
# For simplicity, we'll just add them to the dictionary.
|
||||||
|
# Convert name to a key like 'LMT' or keep full name
|
||||||
|
key = comp_name.replace(" OIL", "").replace("'S", "").replace(" ", "")
|
||||||
|
results[key] = {
|
||||||
|
"symbol": comp_name, # Use full name as symbol/label
|
||||||
|
"price": float(latest_comp.price_decimal),
|
||||||
|
"currency": "USD",
|
||||||
|
"change": 0.0, # We'd need history to calc this, skipping for now
|
||||||
|
"percent_change": 0.0,
|
||||||
|
"timestamp": latest_comp.scrape_date.isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
return success_response({"tickers": results})
|
||||||
|
|
||||||
|
|
||||||
|
@info.route("/price/ticker/history", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def get_ticker_history():
|
||||||
|
"""
|
||||||
|
Get historical ticker prices for charting.
|
||||||
|
|
||||||
|
Query Params:
|
||||||
|
days (int): Number of days to look back (default 30)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of daily price points for each ticker.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
days = int(request.args.get('days', 30))
|
||||||
|
except ValueError:
|
||||||
|
days = 30
|
||||||
|
|
||||||
|
logger.info(f"GET /info/price/ticker/history - Fetching history for {days} days")
|
||||||
|
|
||||||
|
start_date = datetime.utcnow() - timedelta(days=days)
|
||||||
|
|
||||||
|
# Fetch all records since start_date
|
||||||
|
# We want to group by date and symbol, taking the last price of the day
|
||||||
|
records = (db.session.query(Ticker_Price)
|
||||||
|
.filter(Ticker_Price.timestamp >= start_date)
|
||||||
|
.order_by(Ticker_Price.timestamp.asc())
|
||||||
|
.all())
|
||||||
|
|
||||||
|
# Organize data structure for Chart.js
|
||||||
|
# { date: { symbol: price, symbol2: price } }
|
||||||
|
daily_data = {}
|
||||||
|
|
||||||
|
target_symbols = ["HO=F", "CL=F", "RB=F"]
|
||||||
|
|
||||||
|
for record in records:
|
||||||
|
if record.symbol not in target_symbols:
|
||||||
|
continue
|
||||||
|
|
||||||
|
date_str = record.timestamp.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
if date_str not in daily_data:
|
||||||
|
daily_data[date_str] = {}
|
||||||
|
|
||||||
|
# This acts as "last value for the day" since we ordered by asc timestamp
|
||||||
|
daily_data[date_str][record.symbol] = float(record.price_decimal)
|
||||||
|
|
||||||
|
# Convert to list for frontend
|
||||||
|
# [ { date: '2023-01-01', prices: { 'HO=F': 2.5, ... } }, ... ]
|
||||||
|
result = []
|
||||||
|
for date_str in sorted(daily_data.keys()):
|
||||||
|
result.append({
|
||||||
|
"date": date_str,
|
||||||
|
"prices": daily_data[date_str]
|
||||||
|
})
|
||||||
|
|
||||||
|
return success_response({"history": result})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from marshmallow import Schema, fields, validate, EXCLUDE
|
from marshmallow import Schema, fields, validate, EXCLUDE, pre_load
|
||||||
|
|
||||||
|
|
||||||
class CreateCustomerSchema(Schema):
|
class CreateCustomerSchema(Schema):
|
||||||
@@ -67,6 +67,18 @@ class UpdateCustomerSchema(Schema):
|
|||||||
class Meta:
|
class Meta:
|
||||||
unknown = EXCLUDE
|
unknown = EXCLUDE
|
||||||
|
|
||||||
|
@pre_load
|
||||||
|
def normalize_data(self, data, **kwargs):
|
||||||
|
# Convert empty strings to None for email
|
||||||
|
if 'customer_email' in data and data['customer_email'] == "":
|
||||||
|
data['customer_email'] = None
|
||||||
|
|
||||||
|
# Convert boolean to int for customer_automatic
|
||||||
|
if 'customer_automatic' in data:
|
||||||
|
if isinstance(data['customer_automatic'], bool):
|
||||||
|
data['customer_automatic'] = 1 if data['customer_automatic'] else 0
|
||||||
|
return data
|
||||||
|
|
||||||
customer_last_name = fields.Str(
|
customer_last_name = fields.Str(
|
||||||
validate=validate.Length(min=1, max=250)
|
validate=validate.Length(min=1, max=250)
|
||||||
)
|
)
|
||||||
@@ -106,9 +118,9 @@ class UpdateCustomerSchema(Schema):
|
|||||||
allow_none=True,
|
allow_none=True,
|
||||||
validate=validate.Length(max=2000)
|
validate=validate.Length(max=2000)
|
||||||
)
|
)
|
||||||
customer_fill_location = fields.Int(
|
customer_fill_location = fields.Str(
|
||||||
allow_none=True,
|
allow_none=True,
|
||||||
validate=validate.Range(min=0, max=10)
|
validate=validate.Length(max=250)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ def validate_request(schema_class):
|
|||||||
# Attach validated data to request object for easy access
|
# Attach validated data to request object for easy access
|
||||||
request.validated_data = validated_data
|
request.validated_data = validated_data
|
||||||
except ValidationError as err:
|
except ValidationError as err:
|
||||||
|
print(f"DEBUG: Validation Failed: {err.messages}")
|
||||||
return error_response("Validation failed", 400, details=str(err.messages))
|
return error_response("Validation failed", 400, details=str(err.messages))
|
||||||
|
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from app import db
|
|||||||
from app.common.responses import success_response
|
from app.common.responses import success_response
|
||||||
from app.classes.delivery import Delivery_Delivery
|
from app.classes.delivery import Delivery_Delivery
|
||||||
from app.classes.stats_company import Stats_Company, Stats_Company_schema
|
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
|
from app.classes.stats_customer import Stats_Customer, Stats_Customer_schema
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -210,3 +211,404 @@ def calculate_gallons_user(user_id):
|
|||||||
db.session.add(get_user)
|
db.session.add(get_user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return success_response()
|
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
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Add 5-tier pricing support for service fees
|
||||||
|
|
||||||
|
Revision ID: 3d217261c994
|
||||||
|
Revises: c7d2e8f1a3b9
|
||||||
|
Create Date: 2026-02-07 22:10:07.946719
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '3d217261c994'
|
||||||
|
down_revision = 'c7d2e8f1a3b9'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Add pricing tier columns to delivery_delivery table
|
||||||
|
with op.batch_alter_table('delivery_delivery', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('pricing_tier_same_day', sa.INTEGER(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('pricing_tier_prime', sa.INTEGER(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('pricing_tier_emergency', sa.INTEGER(), nullable=True))
|
||||||
|
|
||||||
|
# Add tier pricing columns to pricing_oil_oil table
|
||||||
|
with op.batch_alter_table('pricing_oil_oil', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('price_same_day_tier1', sa.DECIMAL(precision=6, scale=2), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('price_same_day_tier2', sa.DECIMAL(precision=6, scale=2), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('price_same_day_tier3', sa.DECIMAL(precision=6, scale=2), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('price_same_day_tier4', sa.DECIMAL(precision=6, scale=2), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('price_same_day_tier5', sa.DECIMAL(precision=6, scale=2), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('price_prime_tier1', sa.DECIMAL(precision=6, scale=2), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('price_prime_tier2', sa.DECIMAL(precision=6, scale=2), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('price_prime_tier3', sa.DECIMAL(precision=6, scale=2), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('price_prime_tier4', sa.DECIMAL(precision=6, scale=2), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('price_prime_tier5', sa.DECIMAL(precision=6, scale=2), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('price_emergency_tier1', sa.DECIMAL(precision=6, scale=2), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('price_emergency_tier2', sa.DECIMAL(precision=6, scale=2), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('price_emergency_tier3', sa.DECIMAL(precision=6, scale=2), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('price_emergency_tier4', sa.DECIMAL(precision=6, scale=2), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('price_emergency_tier5', sa.DECIMAL(precision=6, scale=2), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Remove tier pricing columns from pricing_oil_oil table
|
||||||
|
with op.batch_alter_table('pricing_oil_oil', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('price_emergency_tier5')
|
||||||
|
batch_op.drop_column('price_emergency_tier4')
|
||||||
|
batch_op.drop_column('price_emergency_tier3')
|
||||||
|
batch_op.drop_column('price_emergency_tier2')
|
||||||
|
batch_op.drop_column('price_emergency_tier1')
|
||||||
|
batch_op.drop_column('price_prime_tier5')
|
||||||
|
batch_op.drop_column('price_prime_tier4')
|
||||||
|
batch_op.drop_column('price_prime_tier3')
|
||||||
|
batch_op.drop_column('price_prime_tier2')
|
||||||
|
batch_op.drop_column('price_prime_tier1')
|
||||||
|
batch_op.drop_column('price_same_day_tier5')
|
||||||
|
batch_op.drop_column('price_same_day_tier4')
|
||||||
|
batch_op.drop_column('price_same_day_tier3')
|
||||||
|
batch_op.drop_column('price_same_day_tier2')
|
||||||
|
batch_op.drop_column('price_same_day_tier1')
|
||||||
|
|
||||||
|
# Remove pricing tier columns from delivery_delivery table
|
||||||
|
with op.batch_alter_table('delivery_delivery', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('pricing_tier_emergency')
|
||||||
|
batch_op.drop_column('pricing_tier_prime')
|
||||||
|
batch_op.drop_column('pricing_tier_same_day')
|
||||||
69
migrations/versions/c7d2e8f1a3b9_add_kfactor_history.py
Normal file
69
migrations/versions/c7d2e8f1a3b9_add_kfactor_history.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""Add K-factor history table and estimation columns
|
||||||
|
|
||||||
|
Revision ID: c7d2e8f1a3b9
|
||||||
|
Revises: b43a39b1cf25
|
||||||
|
Create Date: 2026-02-07 00:00:00.000000
|
||||||
|
|
||||||
|
NOTE: Move this file to migrations/versions/ before running flask db upgrade.
|
||||||
|
The migrations/versions/ directory is root-owned and cannot be written to directly.
|
||||||
|
Run: sudo mv eamco_office_api/c7d2e8f1a3b9_add_kfactor_history.py eamco_office_api/migrations/versions/
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'c7d2e8f1a3b9'
|
||||||
|
down_revision = 'b43a39b1cf25'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Create auto_kfactor_history table
|
||||||
|
op.create_table(
|
||||||
|
'auto_kfactor_history',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('customer_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('ticket_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('fill_date', sa.Date(), nullable=True),
|
||||||
|
sa.Column('gallons_delivered', sa.DECIMAL(precision=6, scale=2), nullable=True),
|
||||||
|
sa.Column('total_hdd', sa.DECIMAL(precision=8, scale=2), nullable=True),
|
||||||
|
sa.Column('days_in_period', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('k_factor', sa.DECIMAL(precision=7, scale=4), nullable=True),
|
||||||
|
sa.Column('is_budget_fill', sa.Boolean(), server_default='false', nullable=False),
|
||||||
|
sa.Column('is_outlier', sa.Boolean(), server_default='false', nullable=False),
|
||||||
|
sa.Column('created_at', sa.Date(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('ix_auto_kfactor_history_customer_id', 'auto_kfactor_history', ['customer_id'])
|
||||||
|
op.create_index('ix_auto_kfactor_history_customer_fill', 'auto_kfactor_history', ['customer_id', sa.text('fill_date DESC')])
|
||||||
|
|
||||||
|
# Add columns to auto_delivery
|
||||||
|
with op.batch_alter_table('auto_delivery', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('confidence_score', sa.Integer(), server_default='20', nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('k_factor_source', sa.VARCHAR(length=20), server_default='default', nullable=True))
|
||||||
|
batch_op.alter_column('house_factor',
|
||||||
|
existing_type=sa.DECIMAL(precision=5, scale=2),
|
||||||
|
type_=sa.DECIMAL(precision=7, scale=4),
|
||||||
|
existing_nullable=True)
|
||||||
|
|
||||||
|
# Add is_budget_fill to auto_tickets
|
||||||
|
with op.batch_alter_table('auto_tickets', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('is_budget_fill', sa.Boolean(), server_default='false', nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
with op.batch_alter_table('auto_tickets', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('is_budget_fill')
|
||||||
|
|
||||||
|
with op.batch_alter_table('auto_delivery', schema=None) as batch_op:
|
||||||
|
batch_op.alter_column('house_factor',
|
||||||
|
existing_type=sa.DECIMAL(precision=7, scale=4),
|
||||||
|
type_=sa.DECIMAL(precision=5, scale=2),
|
||||||
|
existing_nullable=True)
|
||||||
|
batch_op.drop_column('k_factor_source')
|
||||||
|
batch_op.drop_column('confidence_score')
|
||||||
|
|
||||||
|
op.drop_index('ix_auto_kfactor_history_customer_fill', table_name='auto_kfactor_history')
|
||||||
|
op.drop_index('ix_auto_kfactor_history_customer_id', table_name='auto_kfactor_history')
|
||||||
|
op.drop_table('auto_kfactor_history')
|
||||||
Reference in New Issue
Block a user