""" DTOs (Data Transfer Objects) para módulo de clientes y proveedores Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS """ from decimal import Decimal from typing import List, Literal, Optional from pydantic import BaseModel, ConfigDict, Field, model_validator from .validators import is_valid_rfc, is_valid_tax_id # Mensajes de error reutilizados por las validaciones de identificador fiscal _RFC_FORMAT_ERROR = "El RFC no tiene el formato correcto. Ejemplo: XAXX010101000." _TAX_ID_FORMAT_ERROR = ( "El TAX-ID debe tener formato: 2 dígitos, guión y resto (ej. 12-3456789). Máx 30 caracteres." ) def _validate_fiscal_format(rfc: Optional[str], tax_id: Optional[str]) -> None: """ Valida el formato de cada identificador fiscal de forma independiente (no excluyente): un registro puede traer RFC y TAX-ID a la vez; cada uno valida solo si tiene valor. """ if (rfc or "").strip() and not is_valid_rfc(rfc): raise ValueError(_RFC_FORMAT_ERROR) if (tax_id or "").strip() and not is_valid_tax_id(tax_id): raise ValueError(_TAX_ID_FORMAT_ERROR) def _require_fiscal_id_by_procedencia( rfc: Optional[str], tax_id: Optional[str], type_nat_foreign: Optional[str] ) -> None: """Exige el identificador que corresponde a la procedencia: E ⇒ TAX-ID, otro ⇒ RFC.""" is_foreign = (type_nat_foreign or "").strip().upper().startswith("E") if is_foreign and not (tax_id or "").strip(): raise ValueError("El TAX-ID es obligatorio para registros extranjeros.") if not is_foreign and not (rfc or "").strip(): raise ValueError("El RFC es obligatorio para registros nacionales.") # DTOs para dirección class ClientProviderAddressDTO(BaseModel): """DTO para dirección de cliente/proveedor""" municipality: Optional[str] = Field( None, max_length=150, description="Municipality" ) streets: Optional[str] = Field(None, max_length=100, description="Streets") neighborhood: Optional[str] = Field(None, max_length=40, description="Neighborhood") interior_number: Optional[str] = Field( None, max_length=20, description="Interior number" ) exterior_number: Optional[str] = Field( None, max_length=20, description="Exterior number" ) postal_code: Optional[str] = Field(None, max_length=15, description="Postal code") city: Optional[str] = Field(None, max_length=30, description="City") state: Optional[str] = Field(None, max_length=30, description="State") country: Optional[str] = Field(None, max_length=3, description="Country code") phone: Optional[str] = Field(None, max_length=30, description="Phone number") fax_number: Optional[str] = Field(None, max_length=30, description="Fax number") email: Optional[str] = Field(None, max_length=100, description="Email address") contact: Optional[str] = Field(None, max_length=50, description="Contact person") reference: Optional[str] = Field(None, max_length=250, description="Reference") model_config = ConfigDict(from_attributes=True) # DTOs para programas class ClientProviderProgramsDTO(BaseModel): """DTO para programas de cliente/proveedor""" program: Optional[str] = Field(None, max_length=7, description="Program") program_number: Optional[str] = Field( None, max_length=40, description="Program number" ) prosec: Optional[str] = Field(None, max_length=8, description="PROSEC") prosec_authorization: Optional[str] = Field( None, max_length=20, description="PROSEC authorization" ) secon_auth_date: Optional[int] = Field(None, description="SECON authorization date") manufacturer_id: Optional[str] = Field( None, max_length=25, description="Manufacturer ID" ) broker: Optional[str] = Field(None, max_length=6, description="Broker") import_broker: Optional[str] = Field( None, max_length=6, description="Import broker" ) transfer_key: Optional[str] = Field(None, max_length=8, description="Transfer key") secon_authorization: Optional[str] = Field( None, max_length=20, description="SECON authorization" ) applied_proportion: Optional[Decimal] = Field( None, description="Applied proportion" ) is_certified_company: Optional[str] = Field( None, max_length=1, description="Is certified company" ) certified_company_registry: Optional[str] = Field( None, max_length=40, description="Certified company registry" ) donation_auth_number: Optional[str] = Field( None, max_length=50, description="Donation authorization number" ) ctpat_svi: Optional[str] = Field(None, max_length=100, description="CTPAT SVI") tax_registry_number: Optional[str] = Field( None, max_length=40, description="Tax registry number" ) subassembly_service: Optional[int] = Field(None, description="Subassembly service") autse_dates: Optional[int] = Field(None, description="AUTSE dates") autse_number: Optional[str] = Field( None, max_length=300, description="AUTSE number" ) model_config = ConfigDict(from_attributes=True) # DTOs principales class ClientProviderCreateDTO(BaseModel): """DTO para crear cliente/proveedor""" type_nat_foreign: Optional[str] = Field( None, max_length=1, description="Type national/foreign" ) name: Optional[str] = Field(None, max_length=256, description="Name") short_name: Optional[str] = Field(None, max_length=10, description="Short name") rfc: Optional[str] = Field(None, max_length=30, description="RFC (nacional)") tax_id: Optional[str] = Field( None, max_length=30, description="TAX-ID (identificador fiscal extranjero)" ) curp: Optional[str] = Field(None, max_length=19, description="CURP") client_or_provider: Optional[Literal["client", "provider", "both"]] = Field( None, description="Client or provider" ) linking: Optional[str] = Field(None, max_length=1, description="Linking") transform_subassembly: Optional[str] = Field( None, max_length=1, description="Transform subassembly" ) extra_information: Optional[str] = Field( None, max_length=399, description="Extra information" ) web_key: Optional[str] = Field(None, max_length=40, description="Web key") responsible: Optional[str] = Field( None, max_length=80, description="Responsible person" ) position: Optional[str] = Field(None, max_length=30, description="Position") incoterm: Optional[str] = Field(None, max_length=19, description="Incoterm") is_active: Optional[bool] = Field(None, description="Enabled/Disabled status") # Nested DTOs address: Optional[ClientProviderAddressDTO] = Field( None, description="Address information" ) programs: Optional[ClientProviderProgramsDTO] = Field( None, description="Programs information" ) @model_validator(mode="after") def validate_rfc_or_tax_id_format(self): # Valida formato de ambos campos y exige el que corresponda a la procedencia _validate_fiscal_format(self.rfc, self.tax_id) _require_fiscal_id_by_procedencia(self.rfc, self.tax_id, self.type_nat_foreign) return self model_config = ConfigDict(from_attributes=True) class ClientProviderUpdateDTO(BaseModel): """DTO para actualizar cliente/proveedor""" type_nat_foreign: Optional[str] = Field( None, max_length=1, description="Type national/foreign" ) name: Optional[str] = Field(None, max_length=256, description="Name") short_name: Optional[str] = Field(None, max_length=10, description="Short name") rfc: Optional[str] = Field(None, max_length=30, description="RFC (nacional)") tax_id: Optional[str] = Field( None, max_length=30, description="TAX-ID (identificador fiscal extranjero)" ) curp: Optional[str] = Field(None, max_length=19, description="CURP") client_or_provider: Optional[Literal["client", "provider", "both"]] = Field( None, description="Client or provider" ) linking: Optional[str] = Field(None, max_length=1, description="Linking") transform_subassembly: Optional[str] = Field( None, max_length=1, description="Transform subassembly" ) extra_information: Optional[str] = Field( None, max_length=399, description="Extra information" ) web_key: Optional[str] = Field(None, max_length=40, description="Web key") responsible: Optional[str] = Field( None, max_length=80, description="Responsible person" ) position: Optional[str] = Field(None, max_length=30, description="Position") incoterm: Optional[str] = Field(None, max_length=19, description="Incoterm") is_active: Optional[bool] = Field(None, description="Enabled/Disabled status") # Nested DTOs address: Optional[ClientProviderAddressDTO] = Field( None, description="Address information" ) programs: Optional[ClientProviderProgramsDTO] = Field( None, description="Programs information" ) @model_validator(mode="after") def validate_rfc_or_tax_id_format(self): # Update es parcial (PATCH): solo se valida formato de lo que venga, sin exigir requerido _validate_fiscal_format(self.rfc, self.tax_id) return self model_config = ConfigDict(from_attributes=True) class ClientProviderResponseDTO(BaseModel): """DTO para respuesta de cliente/proveedor""" id: int type_nat_foreign: Optional[str] = None name: Optional[str] = None short_name: Optional[str] = None rfc: Optional[str] = None tax_id: Optional[str] = None curp: Optional[str] = None client_or_provider: Optional[str] = None linking: Optional[str] = None transform_subassembly: Optional[str] = None extra_information: Optional[str] = None web_key: Optional[str] = None responsible: Optional[str] = None position: Optional[str] = None incoterm: Optional[str] = None is_active: Optional[bool] = None tenant_id: int company_id: int # Nested DTOs address: Optional[ClientProviderAddressDTO] = None programs: Optional[ClientProviderProgramsDTO] = None model_config = ConfigDict(from_attributes=True) # DTOs para respuestas específicas class ClientProviderBasicDTO(BaseModel): """DTO para información básica de cliente/proveedor""" client_id: str name: Optional[str] = None short_name: Optional[str] = None rfc: Optional[str] = None tax_id: Optional[str] = None client_or_provider: Optional[str] = None is_active: Optional[bool] = None model_config = ConfigDict(from_attributes=True) class ClientProviderListDTO(BaseModel): """DTO para lista de clientes/proveedores""" clients: list[ClientProviderBasicDTO] total: int page: int size: int model_config = ConfigDict(from_attributes=True) class ClientProviderPaginatedResponseDTO(BaseModel): """DTO para respuesta paginada de clientes/proveedores""" items: List[ClientProviderResponseDTO] total: int page: int page_size: int model_config = ConfigDict(from_attributes=True)