- Fixed client-profile GET endpoint to prevent 500 errors - Made ClientProfileResponse fields optional (id, created_at, updated_at) - Returns empty profile data instead of creating DB entry on GET - Added new attachment model and schemas for file handling - Added file handler core utility for upload management
202 lines
7.4 KiB
Python
202 lines
7.4 KiB
Python
"""
|
|
Client Profile Schemas - ServiceManagerWeb
|
|
Esquemas de validación para el perfil empresarial de clientes
|
|
"""
|
|
|
|
from pydantic import BaseModel, Field, validator, EmailStr
|
|
from typing import Optional
|
|
from decimal import Decimal
|
|
from datetime import datetime
|
|
import uuid
|
|
|
|
|
|
class ClientProfileBase(BaseModel):
|
|
"""Schema base para ClientProfile."""
|
|
|
|
# === INFORMACIÓN GENERAL ===
|
|
business_name: Optional[str] = Field(None, max_length=255, description="Razón social")
|
|
commercial_name: Optional[str] = Field(None, max_length=255, description="Nombre comercial")
|
|
client_code: Optional[str] = Field(None, max_length=50, description="Clave de cliente")
|
|
client_type: Optional[str] = Field(None, max_length=50, description="Tipo de cliente")
|
|
rfc: Optional[str] = Field(None, max_length=13, description="RFC (México)")
|
|
tax_id: Optional[str] = Field(None, max_length=50, description="ID fiscal general")
|
|
|
|
# === UBICACIÓN ===
|
|
country: Optional[str] = Field(None, max_length=100, description="País")
|
|
state: Optional[str] = Field(None, max_length=100, description="Estado/Provincia")
|
|
city: Optional[str] = Field(None, max_length=100, description="Ciudad")
|
|
address: Optional[str] = Field(None, description="Dirección completa")
|
|
external_number: Optional[str] = Field(None, max_length=20, description="Número exterior")
|
|
internal_number: Optional[str] = Field(None, max_length=20, description="Número interior")
|
|
postal_code: Optional[str] = Field(None, max_length=10, description="Código postal")
|
|
neighborhood: Optional[str] = Field(None, max_length=100, description="Colonia")
|
|
|
|
# === CONTACTO ===
|
|
main_phone: Optional[str] = Field(None, max_length=20, description="Teléfono principal")
|
|
secondary_phone: Optional[str] = Field(None, max_length=20, description="Teléfono secundario")
|
|
direct_phone: Optional[str] = Field(None, max_length=20, description="Teléfono directo")
|
|
phone_extension: Optional[str] = Field(None, max_length=10, description="Extensión")
|
|
fax: Optional[str] = Field(None, max_length=20, description="Fax")
|
|
|
|
# === INFORMACIÓN ADICIONAL ===
|
|
business_hours: Optional[str] = Field(None, max_length=255, description="Horario de atención")
|
|
website: Optional[str] = Field(None, max_length=255, description="Página web")
|
|
main_email: Optional[EmailStr] = Field(None, description="Email principal")
|
|
billing_email: Optional[EmailStr] = Field(None, description="Email de facturación")
|
|
|
|
# === MARKETING ===
|
|
advertising_medium: Optional[str] = Field(None, max_length=255, description="Medio de publicidad")
|
|
nationality: Optional[str] = Field(None, max_length=100, description="Nacionalidad")
|
|
|
|
# === CONFIGURACIÓN EMPRESARIAL ===
|
|
logo_url: Optional[str] = Field(None, max_length=500, description="URL del logo")
|
|
company_representative: Optional[str] = Field(None, max_length=255, description="Representante de empresa")
|
|
legal_representative: Optional[str] = Field(None, max_length=255, description="Representante legal")
|
|
|
|
# === FINANZAS/FACTURACIÓN ===
|
|
credit_limit: Optional[Decimal] = Field(None, description="Límite de crédito")
|
|
payment_terms: Optional[str] = Field(None, max_length=100, description="Términos de pago")
|
|
preferred_currency: str = Field("MXN", max_length=3, description="Moneda preferida")
|
|
|
|
# === METADATOS ===
|
|
send_to_billing: bool = Field(False, description="Enviar a facturación")
|
|
is_active_client: bool = Field(True, description="Cliente activo")
|
|
is_prospect: bool = Field(False, description="Es prospecto")
|
|
notes: Optional[str] = Field(None, description="Notas adicionales")
|
|
|
|
@validator('rfc')
|
|
def validate_rfc(cls, v):
|
|
"""Validar formato de RFC mexicano."""
|
|
if v is None:
|
|
return v
|
|
|
|
v = v.strip().upper()
|
|
if len(v) < 10 or len(v) > 13:
|
|
raise ValueError('RFC debe tener entre 10 y 13 caracteres')
|
|
|
|
# Validación básica de formato RFC
|
|
import re
|
|
rfc_pattern = r'^[A-ZÑ&]{3,4}[0-9]{6}[A-Z0-9]{3}$'
|
|
if not re.match(rfc_pattern, v):
|
|
raise ValueError('Formato de RFC inválido')
|
|
|
|
return v
|
|
|
|
@validator('postal_code')
|
|
def validate_postal_code(cls, v):
|
|
"""Validar código postal."""
|
|
if v is None:
|
|
return v
|
|
|
|
v = v.strip()
|
|
if not v.isdigit() or len(v) != 5:
|
|
raise ValueError('Código postal debe tener 5 dígitos')
|
|
|
|
return v
|
|
|
|
@validator('website')
|
|
def validate_website(cls, v):
|
|
"""Validar formato de sitio web."""
|
|
if v is None:
|
|
return v
|
|
|
|
v = v.strip()
|
|
if not v.startswith(('http://', 'https://')):
|
|
v = f"https://{v}"
|
|
|
|
import re
|
|
url_pattern = r'^https?://.+\..+'
|
|
if not re.match(url_pattern, v):
|
|
raise ValueError('Formato de sitio web inválido')
|
|
|
|
return v
|
|
|
|
@validator('preferred_currency')
|
|
def validate_currency(cls, v):
|
|
"""Validar código de moneda."""
|
|
valid_currencies = ['MXN', 'USD', 'EUR', 'GBP', 'CAD']
|
|
if v not in valid_currencies:
|
|
raise ValueError(f'Moneda debe ser una de: {", ".join(valid_currencies)}')
|
|
return v
|
|
|
|
|
|
class ClientProfileCreate(ClientProfileBase):
|
|
"""Schema para crear un perfil de cliente."""
|
|
pass
|
|
|
|
|
|
class ClientProfileUpdate(ClientProfileBase):
|
|
"""Schema para actualizar un perfil de cliente."""
|
|
pass
|
|
|
|
|
|
class ClientProfileResponse(ClientProfileBase):
|
|
"""Schema de respuesta para ClientProfile."""
|
|
|
|
id: Optional[uuid.UUID] = None
|
|
tenant_id: uuid.UUID
|
|
created_at: Optional[datetime] = None
|
|
updated_at: Optional[datetime] = None
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
@property
|
|
def full_address(self) -> str:
|
|
"""Dirección completa formateada."""
|
|
address_parts = []
|
|
|
|
if self.address:
|
|
address_parts.append(self.address)
|
|
|
|
if self.external_number:
|
|
if self.internal_number:
|
|
address_parts.append(f"#{self.external_number}-{self.internal_number}")
|
|
else:
|
|
address_parts.append(f"#{self.external_number}")
|
|
|
|
if self.neighborhood:
|
|
address_parts.append(f"Col. {self.neighborhood}")
|
|
|
|
if self.city and self.state:
|
|
address_parts.append(f"{self.city}, {self.state}")
|
|
|
|
if self.postal_code:
|
|
address_parts.append(f"C.P. {self.postal_code}")
|
|
|
|
if self.country:
|
|
address_parts.append(self.country)
|
|
|
|
return ", ".join(address_parts)
|
|
|
|
@property
|
|
def display_name(self) -> str:
|
|
"""Nombre para mostrar."""
|
|
return self.commercial_name or self.business_name or "Sin nombre"
|
|
|
|
|
|
class ClientProfileSummary(BaseModel):
|
|
"""Schema resumido para listados."""
|
|
|
|
id: uuid.UUID
|
|
tenant_id: uuid.UUID
|
|
business_name: Optional[str]
|
|
commercial_name: Optional[str]
|
|
rfc: Optional[str]
|
|
client_code: Optional[str]
|
|
city: Optional[str]
|
|
state: Optional[str]
|
|
main_phone: Optional[str]
|
|
main_email: Optional[str]
|
|
is_active_client: bool
|
|
is_prospect: bool
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
@property
|
|
def display_name(self) -> str:
|
|
"""Nombre para mostrar."""
|
|
return self.commercial_name or self.business_name or "Sin nombre" |