582 lines
17 KiB
Python
582 lines
17 KiB
Python
from datetime import date, datetime
|
|
from decimal import Decimal
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
|
|
from app.services.vehicle_identity import normalize_license_plate, validate_vin
|
|
|
|
|
|
class ServiceCenterCreate(BaseModel):
|
|
legal_name: str | None = None
|
|
display_name: str
|
|
country: str | None = None
|
|
city: str | None = None
|
|
address: str | None = None
|
|
phone: str | None = None
|
|
business_registration_number: str | None = None
|
|
telegram_chat_id: str | None = None
|
|
contact_phone: str | None = None
|
|
description: str | None = None
|
|
specializations: list[str] | None = None
|
|
working_hours: str | None = None
|
|
facade_photo_url: str | None = None
|
|
document_photo_urls: list[str] | None = None
|
|
additional_photo_urls: list[str] | None = None
|
|
contact_person: str | None = None
|
|
|
|
|
|
class ServiceCenterRead(ServiceCenterCreate):
|
|
id: int
|
|
name: str
|
|
verification_status: str
|
|
owner_user_id: int | None = None
|
|
employee_role: str | None = None
|
|
employee_status: str | None = None
|
|
created_at: datetime
|
|
verified_at: datetime | None = None
|
|
suspended_at: datetime | None = None
|
|
rating_avg: Decimal | None = None
|
|
reviews_count: int = 0
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
class ServiceCenterPublicRead(BaseModel):
|
|
id: int
|
|
display_name: str | None = None
|
|
name: str
|
|
country: str | None = None
|
|
city: str | None = None
|
|
address: str | None = None
|
|
phone: str | None = None
|
|
description: str | None = None
|
|
specializations: list[str] | None = None
|
|
working_hours: str | None = None
|
|
facade_photo_url: str | None = None
|
|
verification_status: str
|
|
rating_avg: Decimal | None = None
|
|
reviews_count: int = 0
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
class ServiceCenterVerificationCreate(BaseModel):
|
|
submitted_documents: list[dict] | None = None
|
|
comment: str | None = None
|
|
|
|
|
|
class ServiceCenterVerificationRead(ServiceCenterVerificationCreate):
|
|
id: int
|
|
service_center_id: int
|
|
status: str
|
|
reviewed_by: int | None = None
|
|
reviewed_at: datetime | None = None
|
|
created_at: datetime
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
class ServiceEmployeeInvite(BaseModel):
|
|
telegram_id: int
|
|
role: str = "receptionist"
|
|
permissions: dict | None = None
|
|
expires_in_hours: int = Field(default=72, ge=0, le=720)
|
|
|
|
|
|
class ServiceEmployeeRead(BaseModel):
|
|
id: int
|
|
service_center_id: int
|
|
user_id: int
|
|
role: str
|
|
permissions: dict | None = None
|
|
status: str
|
|
invite_token: str | None = None
|
|
invite_expires_at: datetime | None = None
|
|
invite_revoked_at: datetime | None = None
|
|
activated_at: datetime | None = None
|
|
created_at: datetime
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
class VehicleAccessGrant(BaseModel):
|
|
service_center_id: int | None = None
|
|
user_id: int | None = None
|
|
role: str = "viewer"
|
|
|
|
|
|
class VehicleAccessRead(BaseModel):
|
|
id: int
|
|
vehicle_id: int
|
|
user_id: int
|
|
role: str
|
|
status: str
|
|
created_at: datetime
|
|
revoked_at: datetime | None = None
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
class VehicleCreate(BaseModel):
|
|
name: str
|
|
make: str | None = None
|
|
model: str | None = None
|
|
trim: str | None = None
|
|
generation: str | None = None
|
|
body_type: str | None = None
|
|
year: int | None = None
|
|
license_plate: str | None = None
|
|
license_plate_country: str | None = None
|
|
vin: str | None = None
|
|
current_odometer: int | None = None
|
|
fuel_type: str | None = None
|
|
engine_volume_l: Decimal | None = None
|
|
transmission: str | None = None
|
|
drive_type: str | None = None
|
|
engine_oil_type: str | None = None
|
|
engine_oil_volume_l: Decimal | None = None
|
|
transmission_fluid_type: str | None = None
|
|
transmission_fluid_volume_l: Decimal | None = None
|
|
coolant_type: str | None = None
|
|
brake_fluid_type: str | None = None
|
|
tire_pressure_front_bar: Decimal | None = None
|
|
tire_pressure_rear_bar: Decimal | None = None
|
|
tire_size: str | None = None
|
|
oil_change_interval_km: int | None = None
|
|
oil_change_interval_months: int | None = None
|
|
fuel_tank_volume_l: Decimal | None = None
|
|
target_consumption_l_per_100km: Decimal | None = None
|
|
purchase_date: date | None = None
|
|
purchase_price: Decimal | None = None
|
|
purchase_currency: str | None = None
|
|
purchase_type: str = "unknown"
|
|
currency: str = "RUB"
|
|
include_depreciation: bool = False
|
|
expected_ownership_months: int | None = None
|
|
expected_residual_value: Decimal | None = None
|
|
loan_principal: Decimal | None = None
|
|
loan_down_payment: Decimal | None = None
|
|
loan_term_months: int | None = None
|
|
loan_annual_interest_rate: Decimal | None = None
|
|
loan_first_payment_date: date | None = None
|
|
loan_payment_day: int | None = None
|
|
loan_payment_type: str = "annuity"
|
|
loan_currency: str | None = None
|
|
loan_comment: str | None = None
|
|
notes: str | None = None
|
|
|
|
@field_validator("vin")
|
|
@classmethod
|
|
def validate_vin_field(cls, value: str | None) -> str | None:
|
|
return validate_vin(value)
|
|
|
|
|
|
class VehicleUpdate(BaseModel):
|
|
name: str | None = None
|
|
make: str | None = None
|
|
model: str | None = None
|
|
trim: str | None = None
|
|
generation: str | None = None
|
|
body_type: str | None = None
|
|
year: int | None = None
|
|
license_plate: str | None = None
|
|
license_plate_country: str | None = None
|
|
vin: str | None = None
|
|
current_odometer: int | None = None
|
|
fuel_type: str | None = None
|
|
engine_volume_l: Decimal | None = None
|
|
transmission: str | None = None
|
|
drive_type: str | None = None
|
|
fuel_tank_volume_l: Decimal | None = None
|
|
target_consumption_l_per_100km: Decimal | None = None
|
|
purchase_date: date | None = None
|
|
purchase_price: Decimal | None = None
|
|
purchase_currency: str | None = None
|
|
purchase_type: str | None = None
|
|
currency: str | None = None
|
|
include_depreciation: bool | None = None
|
|
expected_ownership_months: int | None = None
|
|
expected_residual_value: Decimal | None = None
|
|
engine_oil_type: str | None = None
|
|
engine_oil_volume_l: Decimal | None = None
|
|
transmission_fluid_type: str | None = None
|
|
transmission_fluid_volume_l: Decimal | None = None
|
|
coolant_type: str | None = None
|
|
brake_fluid_type: str | None = None
|
|
tire_pressure_front_bar: Decimal | None = None
|
|
tire_pressure_rear_bar: Decimal | None = None
|
|
tire_size: str | None = None
|
|
oil_change_interval_km: int | None = None
|
|
oil_change_interval_months: int | None = None
|
|
loan_principal: Decimal | None = None
|
|
loan_down_payment: Decimal | None = None
|
|
loan_term_months: int | None = None
|
|
loan_annual_interest_rate: Decimal | None = None
|
|
loan_first_payment_date: date | None = None
|
|
loan_payment_day: int | None = None
|
|
loan_payment_type: str | None = None
|
|
loan_currency: str | None = None
|
|
loan_comment: str | None = None
|
|
notes: str | None = None
|
|
|
|
@field_validator("vin")
|
|
@classmethod
|
|
def validate_vin_field(cls, value: str | None) -> str | None:
|
|
return validate_vin(value)
|
|
|
|
|
|
class VehicleRead(BaseModel):
|
|
id: int
|
|
owner_id: int
|
|
name: str
|
|
make: str | None = None
|
|
model: str | None = None
|
|
trim: str | None = None
|
|
generation: str | None = None
|
|
body_type: str | None = None
|
|
year: int | None = None
|
|
license_plate_display: str | None = None
|
|
license_plate_country: str | None = None
|
|
vin_normalized: str | None = None
|
|
current_odometer: int | None = None
|
|
fuel_type: str | None = None
|
|
engine_volume_l: Decimal | None = None
|
|
transmission: str | None = None
|
|
drive_type: str | None = None
|
|
fuel_tank_volume_l: Decimal | None = None
|
|
target_consumption_l_per_100km: Decimal | None = None
|
|
purchase_date: date | None = None
|
|
purchase_price: Decimal | None = None
|
|
purchase_currency: str | None = None
|
|
purchase_type: str = "unknown"
|
|
currency: str = "RUB"
|
|
include_depreciation: bool = False
|
|
expected_ownership_months: int | None = None
|
|
expected_residual_value: Decimal | None = None
|
|
engine_oil_type: str | None = None
|
|
engine_oil_volume_l: Decimal | None = None
|
|
transmission_fluid_type: str | None = None
|
|
transmission_fluid_volume_l: Decimal | None = None
|
|
coolant_type: str | None = None
|
|
brake_fluid_type: str | None = None
|
|
tire_pressure_front_bar: Decimal | None = None
|
|
tire_pressure_rear_bar: Decimal | None = None
|
|
tire_size: str | None = None
|
|
oil_change_interval_km: int | None = None
|
|
oil_change_interval_months: int | None = None
|
|
loan_principal: Decimal | None = None
|
|
loan_down_payment: Decimal | None = None
|
|
loan_term_months: int | None = None
|
|
loan_annual_interest_rate: Decimal | None = None
|
|
loan_first_payment_date: date | None = None
|
|
loan_payment_day: int | None = None
|
|
loan_payment_type: str = "annuity"
|
|
loan_currency: str | None = None
|
|
loan_comment: str | None = None
|
|
notes: str | None = None
|
|
created_at: datetime
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
class ServiceVisitCreate(BaseModel):
|
|
vehicle_id: int
|
|
visit_date: date
|
|
odometer: int | None = None
|
|
notes: str | None = None
|
|
total_cost: Decimal | None = None
|
|
currency: str = "RUB"
|
|
|
|
|
|
class ServiceVisitRead(ServiceVisitCreate):
|
|
id: int
|
|
service_center_id: int
|
|
work_order_number: str | None = None
|
|
owner_id: int | None = None
|
|
created_by_employee_id: int | None = None
|
|
assigned_employee_id: int | None = None
|
|
status: str
|
|
customer_complaint: str | None = None
|
|
diagnosis: str | None = None
|
|
service_comment: str | None = None
|
|
owner_comment: str | None = None
|
|
recommendations_text: str | None = None
|
|
attachment_urls: list[str] | None = None
|
|
labor_total: Decimal = Decimal("0")
|
|
product_total: Decimal = Decimal("0")
|
|
discount_total: Decimal = Decimal("0")
|
|
final_total: Decimal = Decimal("0")
|
|
approval_required: bool = False
|
|
version: int = 1
|
|
completed_snapshot: dict | None = None
|
|
opened_at: datetime | None = None
|
|
approved_at: datetime | None = None
|
|
completed_at: datetime | None = None
|
|
owner_resolved_at: datetime | None = None
|
|
created_at: datetime
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
class ServiceWorkItemCreate(BaseModel):
|
|
work_type: str = "other"
|
|
title: str
|
|
category: str | None = None
|
|
description: str | None = None
|
|
quantity: Decimal = Decimal("1")
|
|
unit: str = "pcs"
|
|
unit_price: Decimal | None = None
|
|
discount: Decimal = Decimal("0")
|
|
parts: list[dict] | None = None
|
|
oil_brand: str | None = None
|
|
oil_viscosity: str | None = None
|
|
oil_volume: Decimal | None = None
|
|
next_due_odometer: int | None = None
|
|
next_due_date: date | None = None
|
|
price: Decimal | None = None
|
|
warranty_days: int | None = None
|
|
warranty_odometer_km: int | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def validate_item(self) -> "ServiceWorkItemCreate":
|
|
if self.quantity <= 0:
|
|
raise ValueError("quantity must be positive")
|
|
if self.discount < 0:
|
|
raise ValueError("discount must be non-negative")
|
|
if self.unit_price is not None and self.unit_price < 0:
|
|
raise ValueError("unit_price must be non-negative")
|
|
if self.price is not None and self.price < 0:
|
|
raise ValueError("price must be non-negative")
|
|
return self
|
|
|
|
|
|
class ServiceWorkItemRead(ServiceWorkItemCreate):
|
|
id: int
|
|
service_visit_id: int
|
|
total: Decimal | None = None
|
|
created_at: datetime
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
class ServiceProductItemCreate(BaseModel):
|
|
title: str
|
|
category: str | None = None
|
|
product_type: str = "other"
|
|
brand: str | None = None
|
|
sku: str | None = None
|
|
quantity: Decimal = Decimal("1")
|
|
unit: str = "pcs"
|
|
unit_price: Decimal = Decimal("0")
|
|
discount: Decimal = Decimal("0")
|
|
volume: Decimal | None = None
|
|
viscosity: str | None = None
|
|
specification: str | None = None
|
|
used_volume: Decimal | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def validate_product(self) -> "ServiceProductItemCreate":
|
|
if self.quantity <= 0:
|
|
raise ValueError("quantity must be positive")
|
|
if self.unit_price < 0 or self.discount < 0:
|
|
raise ValueError("price and discount must be non-negative")
|
|
return self
|
|
|
|
|
|
class ServiceProductItemRead(ServiceProductItemCreate):
|
|
id: int
|
|
service_visit_id: int
|
|
total: Decimal
|
|
created_at: datetime
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
class WorkOrderUpdate(BaseModel):
|
|
odometer: int | None = None
|
|
assigned_employee_id: int | None = None
|
|
customer_complaint: str | None = None
|
|
diagnosis: str | None = None
|
|
notes: str | None = None
|
|
service_comment: str | None = None
|
|
owner_comment: str | None = None
|
|
recommendations_text: str | None = None
|
|
attachment_urls: list[str] | None = None
|
|
discount_total: Decimal | None = None
|
|
approval_required: bool | None = None
|
|
|
|
|
|
class WorkOrderDecision(BaseModel):
|
|
comment: str | None = None
|
|
confirm_lower_odometer: bool = False
|
|
|
|
|
|
class WorkOrderStatusHistoryRead(BaseModel):
|
|
id: int
|
|
service_visit_id: int
|
|
from_status: str | None = None
|
|
to_status: str
|
|
changed_by_user_id: int | None = None
|
|
comment: str | None = None
|
|
created_at: datetime
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
class WorkOrderCorrectionCreate(BaseModel):
|
|
reason: str = Field(min_length=3, max_length=4000)
|
|
proposed_changes: dict | None = None
|
|
owner_approval_required: bool = True
|
|
|
|
|
|
class WorkOrderCorrectionRead(WorkOrderCorrectionCreate):
|
|
id: int
|
|
service_visit_id: int
|
|
requested_by_user_id: int | None = None
|
|
status: str
|
|
created_version: int
|
|
resolved_at: datetime | None = None
|
|
created_at: datetime
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
class VehicleDataChangeRequestCreate(BaseModel):
|
|
vehicle_id: int
|
|
field_name: str
|
|
new_value: str | None = None
|
|
|
|
|
|
class VehicleDataChangeRequestRead(VehicleDataChangeRequestCreate):
|
|
id: int
|
|
requested_by_service_center_id: int | None = None
|
|
requested_by_employee_id: int | None = None
|
|
old_value: str | None = None
|
|
status: str
|
|
owner_user_id: int
|
|
created_at: datetime
|
|
resolved_at: datetime | None = None
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
class VehicleSearchRequest(BaseModel):
|
|
license_plate: str | None = None
|
|
country_code: str | None = None
|
|
vin: str | None = None
|
|
|
|
@field_validator("vin")
|
|
@classmethod
|
|
def validate_vin_field(cls, value: str | None) -> str | None:
|
|
return validate_vin(value)
|
|
|
|
@field_validator("license_plate")
|
|
@classmethod
|
|
def normalize_plate_field(cls, value: str | None) -> str | None:
|
|
return normalize_license_plate(value)
|
|
|
|
|
|
class VehicleSearchResult(BaseModel):
|
|
vehicle_id: int | None = None
|
|
make: str | None = None
|
|
model: str | None = None
|
|
year: int | None = None
|
|
masked_license_plate: str | None = None
|
|
masked_vin: str | None = None
|
|
access_status: str = "none"
|
|
|
|
|
|
class CarServiceLinkCreate(BaseModel):
|
|
car_id: int
|
|
service_center_id: int
|
|
external_vehicle_ref: str | None = None
|
|
access_level: str = "basic"
|
|
is_active: bool = True
|
|
|
|
|
|
class CarServiceLinkRead(CarServiceLinkCreate):
|
|
id: int
|
|
status: str = "pending"
|
|
requested_by_user_id: int | None = None
|
|
approved_by_user_id: int | None = None
|
|
approved_at: datetime | None = None
|
|
revoked_at: datetime | None = None
|
|
created_at: datetime
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
class ServiceCenterAccessRequest(BaseModel):
|
|
car_id: int
|
|
access_level: str = "basic"
|
|
external_vehicle_ref: str | None = None
|
|
|
|
@field_validator("access_level")
|
|
@classmethod
|
|
def validate_access_level(cls, value: str) -> str:
|
|
allowed = {"basic", "service_history", "full"}
|
|
if value not in allowed:
|
|
raise ValueError(f"access_level must be one of {', '.join(sorted(allowed))}")
|
|
return value
|
|
|
|
|
|
class ServiceCenterReviewCreate(BaseModel):
|
|
rating: int = Field(ge=1, le=5)
|
|
text: str | None = None
|
|
photo_urls: list[str] | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def validate_review(self) -> "ServiceCenterReviewCreate":
|
|
if self.text is not None and len(self.text.strip()) < 3:
|
|
raise ValueError("review text is too short")
|
|
return self
|
|
|
|
|
|
class ServiceCenterReviewRead(ServiceCenterReviewCreate):
|
|
id: int
|
|
service_center_id: int
|
|
user_id: int
|
|
status: str
|
|
service_response: str | None = None
|
|
service_responded_at: datetime | None = None
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
class ServiceCenterReviewCommentCreate(BaseModel):
|
|
text: str = Field(min_length=2, max_length=2000)
|
|
|
|
|
|
class ServiceCenterReviewCommentRead(ServiceCenterReviewCommentCreate):
|
|
id: int
|
|
review_id: int
|
|
user_id: int
|
|
status: str
|
|
created_at: datetime
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
class ServiceInboxCreate(BaseModel):
|
|
source_chat_id: str | None = None
|
|
raw_text: str
|
|
car_id: int | None = None
|
|
service_center_id: int | None = None
|
|
|
|
|
|
class ServiceInboxRead(ServiceInboxCreate):
|
|
id: int
|
|
parsed_status: str
|
|
parsed_payload: str | None = None
|
|
error: str | None = None
|
|
created_at: datetime
|
|
|
|
|
|
class AdminModerationDecision(BaseModel):
|
|
comment: str | None = None
|
|
reason: str | None = None
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|