Replace simple exponential smoothing with a rolling-average K-factor system backed by a new auto_kfactor_history table. Budget fills are detected and excluded from calculations, outliers beyond 2-sigma are flagged, and confidence scores track data quality per customer. Adds backfill endpoint, auto-create for missing estimation records, and manual house_factor PUT endpoints for both auto and regular customers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
247 lines
9.2 KiB
Python
247 lines
9.2 KiB
Python
import logging
|
|
from fastapi import APIRouter, Depends
|
|
from fastapi.responses import JSONResponse
|
|
from fastapi.encoders import jsonable_encoder
|
|
from pydantic import BaseModel
|
|
from database import session
|
|
from datetime import date, timedelta
|
|
from decimal import Decimal
|
|
from sqlalchemy import func
|
|
|
|
from app.models.auto import Auto_Delivery, Tickets_Auto_Delivery, Auto_Temp
|
|
from app.models.customer import Customer_Customer
|
|
from app.models.delivery import Delivery
|
|
from app.models.auth import Auth_User
|
|
from app.auth import get_current_user
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
HOT_WATER_DAILY_USAGE = Decimal('1.0')
|
|
HDD_FORECAST_DAYS = 7
|
|
|
|
|
|
class HouseFactorUpdate(BaseModel):
|
|
house_factor: float
|
|
|
|
|
|
router = APIRouter(
|
|
prefix="/delivery",
|
|
tags=["delivery"],
|
|
responses={404: {"description": "Not found"}},
|
|
)
|
|
|
|
|
|
def _get_avg_hdd(days: int = HDD_FORECAST_DAYS) -> Decimal:
|
|
"""Get average HDD over the last N days as a forecast proxy."""
|
|
cutoff = date.today() - timedelta(days=days)
|
|
rows = session.query(Auto_Temp.temp_avg).filter(
|
|
Auto_Temp.todays_date > cutoff
|
|
).all()
|
|
if not rows:
|
|
return Decimal('0')
|
|
total = sum(max(0, 65 - float(r.temp_avg)) for r in rows)
|
|
return Decimal(str(round(total / len(rows), 2)))
|
|
|
|
|
|
def _enrich_auto(auto_obj, avg_hdd: Decimal) -> dict:
|
|
"""Add computed gallons_per_day and days_remaining to a serialized auto delivery."""
|
|
data = jsonable_encoder(auto_obj)
|
|
k = Decimal(str(auto_obj.house_factor)) if auto_obj.house_factor else Decimal('0')
|
|
hot_water = HOT_WATER_DAILY_USAGE if auto_obj.hot_water_summer == 1 else Decimal('0')
|
|
daily_burn = k * avg_hdd + hot_water
|
|
|
|
data['gallons_per_day'] = float(round(daily_burn, 2))
|
|
data['avg_hdd'] = float(avg_hdd)
|
|
data['hot_water_summer'] = auto_obj.hot_water_summer
|
|
if daily_burn > 0 and auto_obj.estimated_gallons_left is not None:
|
|
days_left = int(auto_obj.estimated_gallons_left / daily_burn)
|
|
data['days_remaining'] = min(days_left, 999)
|
|
else:
|
|
data['days_remaining'] = 999
|
|
|
|
return data
|
|
|
|
|
|
@router.get("/all/customers", status_code=200)
|
|
def get_delivery_customers(current_user: Auth_User = Depends(get_current_user)):
|
|
logger.info(f"GET /delivery/all/customers - User: {current_user.username}")
|
|
automatics = (
|
|
session.query(Auto_Delivery)
|
|
.filter(Auto_Delivery.auto_status.in_([1, 3]))
|
|
.order_by(Auto_Delivery.estimated_gallons_left.asc())
|
|
.all()
|
|
)
|
|
|
|
avg_hdd = _get_avg_hdd()
|
|
enriched = [_enrich_auto(a, avg_hdd) for a in automatics]
|
|
|
|
return JSONResponse(content=enriched, status_code=200)
|
|
|
|
|
|
@router.get("/driver/{driver_employee_id}", status_code=200)
|
|
def get_delivery_for_specific_driver(driver_employee_id: int, current_user: Auth_User = Depends(get_current_user)):
|
|
logger.info(f"GET /delivery/driver/{driver_employee_id} - User: {current_user.username}")
|
|
automatics = (
|
|
session.query(Delivery)
|
|
.filter(Delivery.driver_employee_id == driver_employee_id)
|
|
.filter(Delivery.automatic == 1)
|
|
.filter(Delivery.delivery_status == 0)
|
|
.all()
|
|
)
|
|
|
|
return JSONResponse(content=jsonable_encoder(automatics), status_code=200)
|
|
|
|
@router.get("/delivery/{ticket_id}", status_code=200)
|
|
def get_delivery_by_openticket(ticket_id, current_user: Auth_User = Depends(get_current_user)):
|
|
logger.info(f"GET /delivery/delivery/{ticket_id} - User: {current_user.username}")
|
|
get_delivery = (
|
|
session.query(Auto_Delivery)
|
|
.filter(Auto_Delivery.id == ticket_id)
|
|
.first()
|
|
)
|
|
|
|
return JSONResponse(content=jsonable_encoder(get_delivery), status_code=200)
|
|
|
|
|
|
@router.get("/finddelivery/{ticket_id}", status_code=200)
|
|
def get_delivery_by_findticket(ticket_id, current_user: Auth_User = Depends(get_current_user)):
|
|
logger.info(f"GET /delivery/finddelivery/{ticket_id} - User: {current_user.username}")
|
|
get_delivery = (
|
|
session.query(Auto_Delivery)
|
|
.filter(Auto_Delivery.open_ticket_id == ticket_id)
|
|
.first()
|
|
)
|
|
|
|
return JSONResponse(content=jsonable_encoder(get_delivery), status_code=200)
|
|
|
|
|
|
|
|
@router.get("/autoticket/{delivery_id_order}", status_code=200)
|
|
def get_auto_by_ticket(delivery_id_order, current_user: Auth_User = Depends(get_current_user)):
|
|
logger.info(f"GET /delivery/autoticket/{delivery_id_order} - User: {current_user.username}")
|
|
get_delivery = (
|
|
session.query(Tickets_Auto_Delivery)
|
|
.filter(Tickets_Auto_Delivery.id == delivery_id_order)
|
|
.first()
|
|
)
|
|
|
|
return JSONResponse(content=jsonable_encoder(get_delivery), status_code=200)
|
|
|
|
|
|
@router.get("/all/profile/{customer_id}", status_code=200)
|
|
def get_autos_customers(customer_id, current_user: Auth_User = Depends(get_current_user)):
|
|
logger.info(f"GET /delivery/all/profile/{customer_id} - User: {current_user.username}")
|
|
get_delivery = (
|
|
session.query(Tickets_Auto_Delivery)
|
|
.filter(Tickets_Auto_Delivery.customer_id == customer_id)
|
|
.order_by(Tickets_Auto_Delivery.id.desc())
|
|
.limit(5)
|
|
.all()
|
|
)
|
|
|
|
return JSONResponse(content=jsonable_encoder(get_delivery), status_code=200)
|
|
|
|
@router.get("/all/profile/profile/{customer_id}", status_code=200)
|
|
def get_autos_customers_extended(customer_id, current_user: Auth_User = Depends(get_current_user)):
|
|
logger.info(f"GET /delivery/all/profile/profile/{customer_id} - User: {current_user.username}")
|
|
get_delivery = (
|
|
session.query(Tickets_Auto_Delivery)
|
|
.filter(Tickets_Auto_Delivery.customer_id == customer_id)
|
|
.order_by(Tickets_Auto_Delivery.id.desc())
|
|
.limit(25)
|
|
.all()
|
|
)
|
|
|
|
return JSONResponse(content=jsonable_encoder(get_delivery), status_code=200)
|
|
|
|
|
|
@router.get("/auto/customer/{customer_id}", status_code=200)
|
|
def get_auto_delivery_by_customer(customer_id: int, current_user: Auth_User = Depends(get_current_user)):
|
|
logger.info(f"GET /delivery/auto/customer/{customer_id} - User: {current_user.username}")
|
|
get_auto_delivery = (
|
|
session.query(Auto_Delivery)
|
|
.filter(Auto_Delivery.customer_id == customer_id)
|
|
.first()
|
|
)
|
|
|
|
if not get_auto_delivery:
|
|
# Auto-create record from customer data
|
|
customer = session.query(Customer_Customer).filter(
|
|
Customer_Customer.id == customer_id
|
|
).first()
|
|
if not customer:
|
|
return JSONResponse(content={"error": "Customer not found"}, status_code=404)
|
|
|
|
# Use division average K-factor as default
|
|
div_avg = session.query(func.avg(Auto_Delivery.house_factor)).filter(
|
|
Auto_Delivery.house_factor.isnot(None),
|
|
Auto_Delivery.house_factor > 0
|
|
).scalar()
|
|
default_k = float(div_avg) if div_avg else 0.12
|
|
|
|
get_auto_delivery = Auto_Delivery(
|
|
customer_id=customer.id,
|
|
account_number=customer.account_number,
|
|
customer_town=customer.customer_town,
|
|
customer_state=customer.customer_state,
|
|
customer_address=customer.customer_address,
|
|
customer_zip=customer.customer_zip,
|
|
customer_full_name=f"{customer.customer_first_name} {customer.customer_last_name}".strip(),
|
|
estimated_gallons_left=Decimal('100'),
|
|
estimated_gallons_left_prev_day=Decimal('100'),
|
|
tank_size='275',
|
|
house_factor=Decimal(str(round(default_k, 4))),
|
|
auto_status=1,
|
|
hot_water_summer=0,
|
|
confidence_score=20,
|
|
k_factor_source='default'
|
|
)
|
|
session.add(get_auto_delivery)
|
|
session.commit()
|
|
session.refresh(get_auto_delivery)
|
|
logger.info(f"Auto-created Auto_Delivery record for customer {customer_id}")
|
|
|
|
avg_hdd = _get_avg_hdd()
|
|
enriched = _enrich_auto(get_auto_delivery, avg_hdd)
|
|
|
|
return JSONResponse(content=enriched, status_code=200)
|
|
|
|
|
|
@router.put("/update_status/{auto_id}", status_code=200)
|
|
def update_auto_status(auto_id: int, current_user: Auth_User = Depends(get_current_user)):
|
|
logger.info(f"PUT /delivery/update_status/{auto_id} - User: {current_user.username}")
|
|
update_status = (
|
|
session.query(Auto_Delivery)
|
|
.filter(Auto_Delivery.id == auto_id)
|
|
.first()
|
|
)
|
|
|
|
if update_status:
|
|
update_status.auto_status = 3
|
|
session.commit()
|
|
return {"message": "Auto status updated to 3"}
|
|
return {"error": "Auto delivery not found"}
|
|
|
|
|
|
@router.put("/auto/customer/{customer_id}/house_factor", status_code=200)
|
|
def update_house_factor(customer_id: int, body: HouseFactorUpdate, current_user: Auth_User = Depends(get_current_user)):
|
|
logger.info(f"PUT /delivery/auto/customer/{customer_id}/house_factor - User: {current_user.username}")
|
|
auto_delivery = (
|
|
session.query(Auto_Delivery)
|
|
.filter(Auto_Delivery.customer_id == customer_id)
|
|
.first()
|
|
)
|
|
|
|
if not auto_delivery:
|
|
return JSONResponse(content={"error": "Auto delivery record not found"}, status_code=404)
|
|
|
|
auto_delivery.house_factor = Decimal(str(round(body.house_factor, 4)))
|
|
auto_delivery.k_factor_source = 'manual'
|
|
session.commit()
|
|
session.refresh(auto_delivery)
|
|
|
|
avg_hdd = _get_avg_hdd()
|
|
enriched = _enrich_auto(auto_delivery, avg_hdd)
|
|
|
|
return JSONResponse(content=enriched, status_code=200)
|