diff --git a/app/admin/views.py b/app/admin/views.py index 99fe5f8..d7b0d6a 100755 --- a/app/admin/views.py +++ b/app/admin/views.py @@ -24,17 +24,52 @@ def create_oil_price(): price_from_supplier = request.json["price_from_supplier"] price_for_customer = request.json["price_for_customer"] price_for_employee = request.json["price_for_employee"] - price_same_day = request.json["price_same_day"] - price_prime = request.json["price_prime"] - price_emergency= request.json["price_emergency"] + + # Legacy single-tier pricing (for backward compatibility, use tier1 values) + 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( price_from_supplier=price_from_supplier, price_for_customer=price_for_customer, price_for_employee=price_for_employee, - price_same_day=price_same_day, - price_prime=price_prime, - price_emergency=price_emergency, + # Legacy columns (use tier1 for backward compatibility) + price_same_day=price_same_day_tier1, + 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, ) # new_admin_oil_price = Pricing_Oil_Oil( diff --git a/app/classes/auth.py b/app/classes/auth.py index c080aed..5a975bb 100755 --- a/app/classes/auth.py +++ b/app/classes/auth.py @@ -51,12 +51,15 @@ class Auth_User(UserMixin, db.Model): self.confirmed = confirmed self.active = active + @property def is_authenticated(self): return True + @property def is_active(self): return True + @property def is_anonymous(self): return False diff --git a/app/classes/auto.py b/app/classes/auto.py index 48fb14d..e1b3237 100755 --- a/app/classes/auto.py +++ b/app/classes/auto.py @@ -17,7 +17,7 @@ class Auto_Update(db.Model): class Auto_Temp(db.Model): __tablename__ = 'auto_temp' __table_args__ = {"schema": "public"} - + id = db.Column(db.Integer, primary_key=True, autoincrement=True, @@ -38,7 +38,7 @@ class Auto_Temp_schema(ma.SQLAlchemyAutoSchema): class Auto_Delivery(db.Model): __tablename__ = 'auto_delivery' __table_args__ = {"schema": "public"} - + id = db.Column(db.Integer, primary_key=True, autoincrement=True, @@ -57,12 +57,14 @@ class Auto_Delivery(db.Model): estimated_gallons_left_prev_day = db.Column(db.DECIMAL(6, 2)) tank_height = 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) #0 = waiting #1 = waiting for delivery auto_status = 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): @@ -73,7 +75,7 @@ class Auto_Delivery_schema(ma.SQLAlchemyAutoSchema): class Tickets_Auto_Delivery(db.Model): __tablename__ = 'auto_tickets' __table_args__ = {"schema": "public"} - + id = db.Column(db.Integer, primary_key=True, autoincrement=True, @@ -98,8 +100,9 @@ class Tickets_Auto_Delivery(db.Model): payment_type = db.Column(db.INTEGER, nullable=True) payment_card_id = 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 Meta: model = Tickets_Auto_Delivery diff --git a/app/classes/competitor.py b/app/classes/competitor.py new file mode 100644 index 0000000..76a59f3 --- /dev/null +++ b/app/classes/competitor.py @@ -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 diff --git a/app/classes/customer.py b/app/classes/customer.py index 45f95cc..a08b5a7 100755 --- a/app/classes/customer.py +++ b/app/classes/customer.py @@ -95,7 +95,7 @@ class Customer_Description(db.Model): customer_id = db.Column(db.INTEGER) account_number = db.Column(db.VARCHAR(25)) 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)) diff --git a/app/classes/delivery.py b/app/classes/delivery.py index 1e2ce8a..364c7f4 100755 --- a/app/classes/delivery.py +++ b/app/classes/delivery.py @@ -59,6 +59,11 @@ class Delivery_Delivery(db.Model): same_day = 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 # credit = 1 # credit/cash = 2 diff --git a/app/classes/pricing.py b/app/classes/pricing.py index ea3dd76..8e6bb03 100755 --- a/app/classes/pricing.py +++ b/app/classes/pricing.py @@ -17,9 +17,33 @@ class Pricing_Oil_Oil(db.Model): price_from_supplier = db.Column(db.DECIMAL(6, 2)) price_for_customer = 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_prime = 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()) diff --git a/app/classes/ticker.py b/app/classes/ticker.py new file mode 100644 index 0000000..5b4e54c --- /dev/null +++ b/app/classes/ticker.py @@ -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 diff --git a/app/common/decorators.py b/app/common/decorators.py index 8943a2b..8c4e289 100755 --- a/app/common/decorators.py +++ b/app/common/decorators.py @@ -20,7 +20,7 @@ def login_required(f): def admin_required(f): @wraps(f) 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 f(*args, **kwargs) return decorated_function diff --git a/app/customer/views.py b/app/customer/views.py index cc47b12..c2d5a42 100755 --- a/app/customer/views.py +++ b/app/customer/views.py @@ -588,17 +588,14 @@ def edit_customer_tank(customer_id): response_last_tank_inspection = data.get("last_tank_inspection", None) response_tank_size = data.get("tank_size", 0) - # --- FIX APPLIED HERE --- - # 1. Get the value from the request. Default to 0 if it's missing. - 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 + # Updated to allow string values + response_customer_fill_location = data.get("fill_location") get_customer_tank.last_tank_inspection = response_last_tank_inspection get_customer_tank.tank_size = response_tank_size - get_customer_description.fill_location = response_customer_fill_location + + if response_customer_fill_location is not None: + get_customer_description.fill_location = response_customer_fill_location if get_customer.customer_automatic == 1: get_auto_info = db.session.query(Auto_Delivery).filter(Auto_Delivery.customer_id == customer_id).first() diff --git a/app/delivery/views.py b/app/delivery/views.py index 2cd6d7a..0826762 100755 --- a/app/delivery/views.py +++ b/app/delivery/views.py @@ -1,4 +1,5 @@ import logging +from decimal import Decimal from flask import request from flask_login import current_user 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. +@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"]) @common_login_required def move_deliveries(): @@ -507,6 +611,11 @@ def edit_a_delivery(delivery_id): get_delivery.prime = 1 if data.get("prime") 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 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 --- driver_id = data.get("driver_employee_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) base_price = gallons_for_calc * get_today_price.price_for_customer - # Add fees + # Add fees using tier pricing total_price = base_price 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: - 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: - 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.pre_charge_amount = total_price # Price including fees @@ -603,6 +718,11 @@ def create_a_delivery(user_id): same_day_info = request.json["same_day"] emergency_info = request.json["emergency"] 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"] cash_payment = request.json["cash"] @@ -718,18 +838,23 @@ def create_a_delivery(user_id): precharge_amount = int(gallons_ordered) * get_today_price.price_for_customer - # if prime/emergency/sameday - if same_day_asked == 1 and prime_asked == 0: - total_precharge_amount = precharge_amount + get_today_price.price_same_day - - elif prime_asked == 1 and same_day_asked == 0: - total_precharge_amount = precharge_amount + get_today_price.price_prime - - elif emergency_asked == 1: - total_precharge_amount = precharge_amount + get_today_price.price_emergency - - else: - total_precharge_amount = precharge_amount + get_today_price.price_prime + get_today_price.price_same_day + # Calculate fees using tier pricing + total_precharge_amount = precharge_amount + + if same_day_asked == 1: + 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 + + if prime_asked == 1: + 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 + + if emergency_asked == 1: + 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( @@ -757,6 +882,9 @@ def create_a_delivery(user_id): prime=prime_asked, same_day=same_day_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_card_id=card_id_from_customer, pre_charge_amount=total_precharge_amount, @@ -955,17 +1083,23 @@ def calculate_total(delivery_id): .first()) 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: priceprime = 0 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: priceemergency = 0 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: pricesameday = 0 diff --git a/app/delivery_data/views.py b/app/delivery_data/views.py index eeaa3b9..637d233 100755 --- a/app/delivery_data/views.py +++ b/app/delivery_data/views.py @@ -4,7 +4,7 @@ from datetime import datetime from decimal import Decimal from app.delivery_data import delivery_data 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.delivery import Delivery_Delivery from app.classes.employee import Employee_Employee @@ -29,8 +29,12 @@ def office_finalize_delivery(delivery_id): get_delivery = db.session \ .query(Delivery_Delivery) \ .filter(Delivery_Delivery.id == delivery_id) \ + .filter(Delivery_Delivery.id == delivery_id) \ .first() + if get_delivery is None: + return error_response("Delivery not found", 404) + get_customer = db.session \ .query(Customer_Customer) \ .filter(Customer_Customer.id == get_delivery.customer_id) \ @@ -60,7 +64,12 @@ def office_finalize_delivery(delivery_id): .filter(Employee_Employee.id == get_delivery.driver_employee_id) .first()) - get_stats_employee = (db.session + 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 .query(Stats_Employee_Oil) .filter(Stats_Employee_Oil.employee_id == get_delivery.driver_employee_id) .first()) @@ -89,7 +98,7 @@ def office_finalize_delivery(delivery_id): .filter(Stats_Customer.customer_id == get_customer.id) .first()) - if get_stats_employee is None: + if get_driver and get_stats_employee is None: create_stats = Stats_Employee_Oil( employee_id = get_delivery.driver_employee_id, total_deliveries = 0, @@ -116,10 +125,11 @@ def office_finalize_delivery(delivery_id): fill_location = request.json.get("fill_location") - # update driver - get_delivery.driver_last_name = get_driver.employee_last_name - get_delivery.driver_first_name = get_driver.employee_first_name - get_delivery.driver_employee_id = get_driver.id + # update driver if found + if get_driver: + get_delivery.driver_last_name = get_driver.employee_last_name + get_delivery.driver_first_name = get_driver.employee_first_name + get_delivery.driver_employee_id = get_driver.id # update delivery get_delivery.when_delivered = now @@ -128,12 +138,13 @@ def office_finalize_delivery(delivery_id): get_delivery.check_number = check_number get_delivery.delivery_status = 10 - # update stats employee - current_deliveres = get_stats_employee.total_deliveries + 1 - get_stats_employee.total_deliveries = current_deliveres - - current_gallons_delivered = Decimal(get_stats_employee.total_gallons_delivered) + Decimal(gallons_delivered) - get_stats_employee.total_gallons_delivered = current_gallons_delivered + # update stats employee if found + if get_stats_employee: + current_deliveres = get_stats_employee.total_deliveries + 1 + get_stats_employee.total_deliveries = current_deliveres + + current_gallons_delivered = Decimal(get_stats_employee.total_gallons_delivered) + Decimal(gallons_delivered) + get_stats_employee.total_gallons_delivered = current_gallons_delivered # update stats customer current_deliveres = int(get_stats_customer.oil_deliveries) + 1 @@ -148,7 +159,8 @@ def office_finalize_delivery(delivery_id): db.session.add(get_customer_description) db.session.add(get_stats_customer) - db.session.add(get_stats_employee) + if get_stats_employee: + db.session.add(get_stats_employee) db.session.add(get_delivery) db.session.commit() diff --git a/app/info/views.py b/app/info/views.py index 93b59af..a2deccf 100755 --- a/app/info/views.py +++ b/app/info/views.py @@ -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.delivery import Delivery_Delivery 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 logger = logging.getLogger(__name__) +from datetime import datetime, timedelta +from flask import request @info.route("/price/oil/tiers", methods=["GET"]) @@ -84,3 +89,116 @@ def get_company(): 'name': get_data_company.company_name, '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}) diff --git a/app/schemas/customer.py b/app/schemas/customer.py index ce9665e..95350e5 100644 --- a/app/schemas/customer.py +++ b/app/schemas/customer.py @@ -1,4 +1,4 @@ -from marshmallow import Schema, fields, validate, EXCLUDE +from marshmallow import Schema, fields, validate, EXCLUDE, pre_load class CreateCustomerSchema(Schema): @@ -67,6 +67,18 @@ class UpdateCustomerSchema(Schema): class Meta: 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( validate=validate.Length(min=1, max=250) ) @@ -106,9 +118,9 @@ class UpdateCustomerSchema(Schema): allow_none=True, validate=validate.Length(max=2000) ) - customer_fill_location = fields.Int( + customer_fill_location = fields.Str( allow_none=True, - validate=validate.Range(min=0, max=10) + validate=validate.Length(max=250) ) diff --git a/app/schemas/utils.py b/app/schemas/utils.py index 89998f8..80255fa 100644 --- a/app/schemas/utils.py +++ b/app/schemas/utils.py @@ -33,6 +33,7 @@ def validate_request(schema_class): # Attach validated data to request object for easy access request.validated_data = validated_data except ValidationError as err: + print(f"DEBUG: Validation Failed: {err.messages}") return error_response("Validation failed", 400, details=str(err.messages)) return f(*args, **kwargs) diff --git a/app/stats/views.py b/app/stats/views.py index 80f53ca..d0bee1b 100755 --- a/app/stats/views.py +++ b/app/stats/views.py @@ -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 + }) diff --git a/migrations/versions/3d217261c994_add_5_tier_pricing_support_for_service_.py b/migrations/versions/3d217261c994_add_5_tier_pricing_support_for_service_.py new file mode 100644 index 0000000..12e9ecf --- /dev/null +++ b/migrations/versions/3d217261c994_add_5_tier_pricing_support_for_service_.py @@ -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') diff --git a/migrations/versions/c7d2e8f1a3b9_add_kfactor_history.py b/migrations/versions/c7d2e8f1a3b9_add_kfactor_history.py new file mode 100644 index 0000000..89aac99 --- /dev/null +++ b/migrations/versions/c7d2e8f1a3b9_add_kfactor_history.py @@ -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')