Complete CarPass product flows

This commit is contained in:
VPN SaaS Dev
2026-05-14 21:19:37 +09:00
parent a83f55c646
commit c0014ab4ea
28 changed files with 3006 additions and 159 deletions

View File

@@ -1,7 +1,7 @@
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.models.expense import ExpenseCategory, ServiceType
@@ -14,18 +14,27 @@ class FuelEntryBase(BaseModel):
total_cost: Decimal | None = None
station: str | None = None
fuel_brand: str | None = None
is_full_tank: bool = True
is_full_tank: bool | None = None
notes: str | None = None
@model_validator(mode="after")
def fill_total_cost(self) -> "FuelEntryBase":
if self.odometer < 0:
raise ValueError("odometer must be non-negative")
if self.liters <= 0:
raise ValueError("liters must be positive")
if self.price_per_liter <= 0:
raise ValueError("price_per_liter must be positive")
if self.total_cost is None:
self.total_cost = self.liters * self.price_per_liter
if self.total_cost <= 0:
raise ValueError("total_cost must be positive")
return self
class FuelEntryCreate(FuelEntryBase):
car_id: int
confirm_lower_odometer: bool = False
class FuelEntryUpdate(BaseModel):
@@ -38,6 +47,7 @@ class FuelEntryUpdate(BaseModel):
fuel_brand: str | None = None
is_full_tank: bool | None = None
notes: str | None = None
confirm_lower_odometer: bool = False
class FuelEntryRead(FuelEntryBase):
@@ -61,9 +71,20 @@ class ServiceEntryBase(BaseModel):
next_due_odometer: int | None = None
notes: str | None = None
@model_validator(mode="after")
def validate_service(self) -> "ServiceEntryBase":
if self.odometer is not None and self.odometer < 0:
raise ValueError("odometer must be non-negative")
if self.total_cost < 0:
raise ValueError("total_cost must be non-negative")
if not self.title.strip():
raise ValueError("title is required")
return self
class ServiceEntryCreate(ServiceEntryBase):
car_id: int
confirm_lower_odometer: bool = False
class ServiceEntryUpdate(BaseModel):
@@ -77,6 +98,7 @@ class ServiceEntryUpdate(BaseModel):
next_due_date: date | None = None
next_due_odometer: int | None = None
notes: str | None = None
confirm_lower_odometer: bool = False
class ServiceEntryRead(ServiceEntryBase):
@@ -99,19 +121,36 @@ class ExpenseEntryBase(BaseModel):
period_end: date | None = None
period_months: int | None = None
is_recurring: bool = False
policy_number: str | None = None
insurance_type: str | None = None
payment_period_months: int | None = None
document_urls: list[str] | None = None
metadata_json: dict | None = None
notes: str | None = None
@model_validator(mode="after")
def validate_period(self) -> "ExpenseEntryBase":
if self.total_cost <= 0:
raise ValueError("total_cost must be positive")
if self.odometer is not None and self.odometer < 0:
raise ValueError("odometer must be non-negative")
if self.period_months is not None and self.period_months < 1:
raise ValueError("period_months must be positive")
if self.payment_period_months is not None and self.payment_period_months < 1:
raise ValueError("payment_period_months must be positive")
if self.period_start and self.period_end and self.period_end < self.period_start:
raise ValueError("period_end must be after period_start")
if self.category == ExpenseCategory.insurance:
if self.period_start and self.period_end:
return self
if self.period_months or self.payment_period_months:
return self
return self
class ExpenseEntryCreate(ExpenseEntryBase):
car_id: int
confirm_lower_odometer: bool = False
class ExpenseEntryUpdate(BaseModel):
@@ -126,7 +165,20 @@ class ExpenseEntryUpdate(BaseModel):
period_end: date | None = None
period_months: int | None = None
is_recurring: bool | None = None
policy_number: str | None = None
insurance_type: str | None = None
payment_period_months: int | None = None
document_urls: list[str] | None = None
metadata_json: dict | None = None
notes: str | None = None
confirm_lower_odometer: bool = False
@field_validator("total_cost")
@classmethod
def validate_total_cost(cls, value: Decimal | None) -> Decimal | None:
if value is not None and value <= 0:
raise ValueError("total_cost must be positive")
return value
class ExpenseEntryRead(ExpenseEntryBase):
@@ -151,11 +203,23 @@ class OwnershipStats(BaseModel):
service_cost: Decimal
total_cost: Decimal
expenses_cost: Decimal = Decimal("0")
repair_cost: Decimal = Decimal("0")
fixed_costs: Decimal = Decimal("0")
variable_costs: Decimal = Decimal("0")
recurring_costs: Decimal = Decimal("0")
one_time_costs: Decimal = Decimal("0")
forecast_next_month: Decimal = Decimal("0")
depreciation_cost: Decimal = Decimal("0")
loan_principal_cost: Decimal = Decimal("0")
loan_interest_cost: Decimal = Decimal("0")
total_cost_without_credit: Decimal = Decimal("0")
total_cost_with_credit: Decimal = Decimal("0")
cost_per_day: Decimal = Decimal("0")
cost_per_month: Decimal = Decimal("0")
current_month_cost: Decimal = Decimal("0")
previous_month_cost: Decimal = Decimal("0")
month_over_month_change_pct: float | None = None
cost_warning: str | None = None
cost_by_category: dict[str, Decimal] = Field(default_factory=dict)
categories: list[OwnershipCategoryBreakdown] = Field(default_factory=list)
liters: Decimal
@@ -179,5 +243,25 @@ class OdometerPrediction(BaseModel):
avg_price_per_liter: float | None = None
price_samples: int = 0
price_confidence: float = 0
average_full_tank_distance: float | None = None
average_fuel_consumption_full_tank: float | None = None
average_cost_per_full_tank: float | None = None
last_full_tank_distance: int | None = None
full_tank_warning: str | None = None
confidence: float
insight: str
class OdometerHistoryRead(BaseModel):
id: int
car_id: int
previous_odometer: int | None = None
new_odometer: int
source_record_type: str
source_record_id: int | None = None
changed_at: datetime
changed_by: int | None = None
confirmation_required: bool
user_confirmed: bool
model_config = ConfigDict(from_attributes=True)