feat: Implement multi-tenancy support in middleware and security layers

- Enhanced TenantMiddleware to validate tenant information from JWT tokens.
- Added LicenseValidationMiddleware to check tenant licenses before processing requests.
- Updated security utilities to extract tenant information from tokens and validate company access.
- Introduced CompanyStore to manage active company state and handle company switching in the frontend.
- Modified API routes to include company_id in requests for better resource management.
- Improved logging and error handling throughout the middleware and API layers.
- Updated frontend components to reflect changes in company management and selection.
- Added new API route for fetching user's companies with proper authentication handling.
This commit is contained in:
2025-11-11 14:00:56 -06:00
parent e1eb6bbd01
commit 52b8fcd434
242 changed files with 7067 additions and 3274 deletions

View File

@@ -1,6 +1,7 @@
"""
Módulo de Client & Provider
"""
from .routes import router
__all__ = ["router"]

View File

@@ -2,6 +2,7 @@
DTOs (Data Transfer Objects) para módulo de clientes y proveedores
Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
"""
from pydantic import BaseModel, Field, EmailStr
from typing import Optional
from datetime import datetime
@@ -11,11 +12,18 @@ from decimal import Decimal
# DTOs para dirección
class ClientProviderAddressDTO(BaseModel):
"""DTO para dirección de cliente/proveedor"""
municipality: Optional[str] = Field(None, max_length=150, description="Municipality")
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")
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")
@@ -33,26 +41,49 @@ class ClientProviderAddressDTO(BaseModel):
# 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")
program_number: Optional[str] = Field(
None, max_length=40, description="Program number"
)
prosec: Optional[int] = Field(None, description="PROSEC")
prosec_authorization: Optional[str] = Field(None, max_length=20, description="PROSEC authorization")
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")
manufacturer_id: Optional[str] = Field(
None, max_length=25, description="Manufacturer ID"
)
tax_id: Optional[str] = Field(None, max_length=30, description="Tax ID")
broker: Optional[str] = Field(None, max_length=6, description="Broker")
import_broker: Optional[str] = Field(None, max_length=6, description="Import 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")
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")
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")
autse_number: Optional[str] = Field(
None, max_length=300, description="AUTSE number"
)
class Config:
from_attributes = True
@@ -61,26 +92,43 @@ class ClientProviderProgramsDTO(BaseModel):
# DTOs principales
class ClientProviderCreateDTO(BaseModel):
"""DTO para crear cliente/proveedor"""
client_id: str = Field(..., max_length=8, description="Client ID")
type_nat_foreign: Optional[str] = Field(None, max_length=1, description="Type national/foreign")
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")
curp: Optional[str] = Field(None, max_length=19, description="CURP")
client_or_provider: Optional[str] = Field(None, max_length=1, description="Client or provider")
client_or_provider: Optional[str] = Field(
None, max_length=1, 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")
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")
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_national_provider: Optional[str] = Field(None, max_length=2, description="Is national provider")
is_national_provider: Optional[str] = Field(
None, max_length=2, description="Is national provider"
)
enabled_disabled: Optional[int] = Field(None, description="Enabled/Disabled status")
# Nested DTOs
address: Optional[ClientProviderAddressDTO] = Field(None, description="Address information")
programs: Optional[ClientProviderProgramsDTO] = Field(None, description="Programs information")
address: Optional[ClientProviderAddressDTO] = Field(
None, description="Address information"
)
programs: Optional[ClientProviderProgramsDTO] = Field(
None, description="Programs information"
)
class Config:
from_attributes = True
@@ -88,25 +136,42 @@ class ClientProviderCreateDTO(BaseModel):
class ClientProviderUpdateDTO(BaseModel):
"""DTO para actualizar cliente/proveedor"""
type_nat_foreign: Optional[str] = Field(None, max_length=1, description="Type national/foreign")
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")
curp: Optional[str] = Field(None, max_length=19, description="CURP")
client_or_provider: Optional[str] = Field(None, max_length=1, description="Client or provider")
client_or_provider: Optional[str] = Field(
None, max_length=1, 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")
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")
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_national_provider: Optional[str] = Field(None, max_length=2, description="Is national provider")
is_national_provider: Optional[str] = Field(
None, max_length=2, description="Is national provider"
)
enabled_disabled: Optional[int] = Field(None, description="Enabled/Disabled status")
# Nested DTOs
address: Optional[ClientProviderAddressDTO] = Field(None, description="Address information")
programs: Optional[ClientProviderProgramsDTO] = Field(None, description="Programs information")
address: Optional[ClientProviderAddressDTO] = Field(
None, description="Address information"
)
programs: Optional[ClientProviderProgramsDTO] = Field(
None, description="Programs information"
)
class Config:
from_attributes = True
@@ -114,6 +179,7 @@ class ClientProviderUpdateDTO(BaseModel):
class ClientProviderResponseDTO(BaseModel):
"""DTO para respuesta de cliente/proveedor"""
client_id: str
type_nat_foreign: Optional[str] = None
name: Optional[str] = None
@@ -130,7 +196,7 @@ class ClientProviderResponseDTO(BaseModel):
incoterm: Optional[str] = None
is_national_provider: Optional[str] = None
enabled_disabled: Optional[int] = None
# Nested DTOs
address: Optional[ClientProviderAddressDTO] = None
programs: Optional[ClientProviderProgramsDTO] = None
@@ -142,6 +208,7 @@ class ClientProviderResponseDTO(BaseModel):
# 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
@@ -155,6 +222,7 @@ class ClientProviderBasicDTO(BaseModel):
class ClientProviderListDTO(BaseModel):
"""DTO para lista de clientes/proveedores"""
clients: list[ClientProviderBasicDTO]
total: int
page: int
@@ -162,4 +230,3 @@ class ClientProviderListDTO(BaseModel):
class Config:
from_attributes = True

View File

@@ -1,9 +1,18 @@
"""
Modelos ORM para gestión de clientes y proveedores
"""
from typing import Optional
from decimal import Decimal
from sqlalchemy import Integer, String, SmallInteger, Numeric, ForeignKey, PrimaryKeyConstraint, ForeignKeyConstraint
from sqlalchemy import (
Integer,
String,
SmallInteger,
Numeric,
ForeignKey,
PrimaryKeyConstraint,
ForeignKeyConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.database import Base
@@ -12,21 +21,28 @@ class ClientProvider(Base):
"""
Modelo para la tabla GClientesPro - Información de clientes y proveedores
"""
__tablename__ = "client_provider"
__table_args__ = (
PrimaryKeyConstraint('id', name='client_provider_pkey'),
ForeignKeyConstraint(['tenant_id'], ['a76.tenants.id'], name='fk_client_provider_tenant'),
ForeignKeyConstraint(['company_id'], ['a76.company.id'], name='fk_client_provider_company'),
{"schema": "a76"}
PrimaryKeyConstraint("id", name="client_provider_pkey"),
ForeignKeyConstraint(
["tenant_id"], ["a76.tenants.id"], name="fk_client_provider_tenant"
),
ForeignKeyConstraint(
["company_id"], ["a76.company.id"], name="fk_client_provider_company"
),
{"schema": "a76"},
)
# Primary key
id: Mapped[int] = mapped_column(Integer, primary_key=True)
tenant_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
company_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
# Basic information
type_nat_foreign: Mapped[Optional[str]] = mapped_column(String(1)) # TIPO NACIONAL/EXTRANJERO
type_nat_foreign: Mapped[Optional[str]] = mapped_column(
String(1)
) # TIPO NACIONAL/EXTRANJERO
name: Mapped[Optional[str]] = mapped_column(String(256))
short_name: Mapped[Optional[str]] = mapped_column(String(10))
rfc: Mapped[Optional[str]] = mapped_column(String(30))
@@ -40,31 +56,45 @@ class ClientProvider(Base):
position: Mapped[Optional[str]] = mapped_column(String(30))
incoterm: Mapped[Optional[str]] = mapped_column(String(19))
is_national_provider: Mapped[Optional[str]] = mapped_column(String(2))
enabled_disabled: Mapped[Optional[int]] = mapped_column(SmallInteger)
enabled_disabled: Mapped[Optional[int]] = mapped_column(SmallInteger)
# Relationships
address: Mapped[Optional["ClientProviderAddress"]] = relationship(back_populates="client_provider", uselist=False, cascade="all, delete-orphan")
programs: Mapped[Optional["ClientProviderPrograms"]] = relationship(back_populates="client_provider", uselist=False, cascade="all, delete-orphan")
address: Mapped[Optional["ClientProviderAddress"]] = relationship(
back_populates="client_provider", uselist=False, cascade="all, delete-orphan"
)
programs: Mapped[Optional["ClientProviderPrograms"]] = relationship(
back_populates="client_provider", uselist=False, cascade="all, delete-orphan"
)
class ClientProviderAddress(Base):
"""
Modelo para la tabla GClientesPro_Direccion - Dirección de clientes y proveedores
"""
__tablename__ = "client_provider_address"
__table_args__ = (
PrimaryKeyConstraint('id', name='client_provider_address_pkey'),
ForeignKeyConstraint(['tenant_id'], ['a76.tenants.id'], name='fk_client_provider_address_tenant'),
ForeignKeyConstraint(['client_id'], ['a76.client_provider.id'], ondelete='CASCADE', name='fk_client_provider_address_client'),
{"schema": "a76"}
PrimaryKeyConstraint("id", name="client_provider_address_pkey"),
ForeignKeyConstraint(
["tenant_id"], ["a76.tenants.id"], name="fk_client_provider_address_tenant"
),
ForeignKeyConstraint(
["client_id"],
["a76.client_provider.id"],
ondelete="CASCADE",
name="fk_client_provider_address_client",
),
{"schema": "a76"},
)
# Primary key (foreign key)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
client_id: Mapped[int] = mapped_column(Integer, ForeignKey('a76.client_provider.id', ondelete='CASCADE'))
client_id: Mapped[int] = mapped_column(
Integer, ForeignKey("a76.client_provider.id", ondelete="CASCADE")
)
tenant_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
# Address information
municipality: Mapped[Optional[str]] = mapped_column(String(150))
streets: Mapped[Optional[str]] = mapped_column(String(100))
@@ -80,7 +110,7 @@ class ClientProviderAddress(Base):
email: Mapped[Optional[str]] = mapped_column(String(100))
contact: Mapped[Optional[str]] = mapped_column(String(50))
reference: Mapped[Optional[str]] = mapped_column(String(250))
# Relationship
client_provider: Mapped["ClientProvider"] = relationship(back_populates="address")
@@ -89,20 +119,30 @@ class ClientProviderPrograms(Base):
"""
Modelo para la tabla GClientesPro_Programas - Programas de clientes y proveedores
"""
__tablename__ = "client_provider_programs"
__table_args__ = (
PrimaryKeyConstraint('id', name='client_provider_programs_pkey'),
ForeignKeyConstraint(['tenant_id'], ['a76.tenants.id'], name='fk_client_provider_programs_tenant'),
ForeignKeyConstraint(['client_id'], ['a76.client_provider.id'], ondelete='CASCADE', name='fk_client_provider_programs_client'),
{"schema": "a76"}
PrimaryKeyConstraint("id", name="client_provider_programs_pkey"),
ForeignKeyConstraint(
["tenant_id"], ["a76.tenants.id"], name="fk_client_provider_programs_tenant"
),
ForeignKeyConstraint(
["client_id"],
["a76.client_provider.id"],
ondelete="CASCADE",
name="fk_client_provider_programs_client",
),
{"schema": "a76"},
)
# Primary key (foreign key)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
client_id: Mapped[int] = mapped_column(Integer, ForeignKey('a76.client_provider.id', ondelete='CASCADE'))
client_id: Mapped[int] = mapped_column(
Integer, ForeignKey("a76.client_provider.id", ondelete="CASCADE")
)
tenant_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
# Program information
program: Mapped[Optional[str]] = mapped_column(String(7))
program_number: Mapped[Optional[str]] = mapped_column(String(40))
@@ -124,8 +164,6 @@ class ClientProviderPrograms(Base):
subassembly_service: Mapped[Optional[int]] = mapped_column(SmallInteger)
autse_dates: Mapped[Optional[int]] = mapped_column()
autse_number: Mapped[Optional[str]] = mapped_column(String(300))
# Relationship
client_provider: Mapped["ClientProvider"] = relationship(back_populates="programs")

View File

@@ -1,6 +1,7 @@
"""
Endpoints API para gestión de clientes y proveedores
"""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from typing import List, Optional
@@ -9,21 +10,23 @@ from core.database import get_core_db
from core.security import get_current_user, has_role
from .service import ClientProviderService
from .dto import (
ClientProviderCreateDTO,
ClientProviderUpdateDTO,
ClientProviderCreateDTO,
ClientProviderUpdateDTO,
ClientProviderResponseDTO,
ClientProviderBasicDTO,
ClientProviderListDTO
ClientProviderListDTO,
)
router = APIRouter(prefix="/clients-providers")
@router.post("/", response_model=ClientProviderResponseDTO, status_code=status.HTTP_201_CREATED)
@router.post(
"/", response_model=ClientProviderResponseDTO, status_code=status.HTTP_201_CREATED
)
async def create_client_provider(
client_data: ClientProviderCreateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
current_user: dict = Depends(get_current_user),
):
"""
Create a new client or provider in the system
@@ -46,12 +49,16 @@ async def create_client_provider(
@router.get("/", response_model=ClientProviderListDTO)
async def list_clients_providers(
skip: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(100, ge=1, le=1000, description="Maximum number of records to return"),
limit: int = Query(
100, ge=1, le=1000, description="Maximum number of records to return"
),
search: Optional[str] = Query(None, description="Search text for name, RFC, or ID"),
client_or_provider: Optional[str] = Query(None, regex="^[CP]$", description="Filter by type: C=Client, P=Provider"),
client_or_provider: Optional[str] = Query(
None, regex="^[CP]$", description="Filter by type: C=Client, P=Provider"
),
enabled_only: bool = Query(False, description="Show only enabled records"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
current_user: dict = Depends(get_current_user),
):
"""
List clients and providers with optional filters and pagination
@@ -64,7 +71,9 @@ async def list_clients_providers(
raise HTTPException(status_code=403, detail="Access denied: Tenant or Company not found")
service = ClientProviderService(db)
return service.list_clients_providers(skip, limit, search, client_or_provider, enabled_only)
return service.list_clients_providers(
skip, limit, search, client_or_provider, enabled_only
)
@router.get("/clients", response_model=List[ClientProviderBasicDTO])
@@ -72,7 +81,7 @@ async def get_clients_only(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
current_user: dict = Depends(get_current_user),
):
"""
Get only clients (client_or_provider = 'C')
@@ -93,7 +102,7 @@ async def get_providers_only(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
current_user: dict = Depends(get_current_user),
):
"""
Get only providers (client_or_provider = 'P')
@@ -113,7 +122,7 @@ async def get_providers_only(
async def search_by_rfc(
rfc: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
current_user: dict = Depends(get_current_user),
):
"""
Search clients/providers by RFC
@@ -133,7 +142,7 @@ async def search_by_rfc(
async def get_client_provider(
client_id: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
current_user: dict = Depends(get_current_user),
):
"""
Get client/provider by ID with all related information
@@ -148,7 +157,9 @@ async def get_client_provider(
service = ClientProviderService(db)
client = service.get_client_provider(client_id)
if not client:
raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found")
raise HTTPException(
status_code=404, detail=f"Client/Provider with ID '{client_id}' not found"
)
return client
@@ -157,7 +168,7 @@ async def update_client_provider(
client_id: str,
client_data: ClientProviderUpdateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
current_user: dict = Depends(get_current_user),
):
"""
Update client/provider information
@@ -172,7 +183,9 @@ async def update_client_provider(
service = ClientProviderService(db)
client = service.update_client_provider(client_id, client_data)
if not client:
raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found")
raise HTTPException(
status_code=404, detail=f"Client/Provider with ID '{client_id}' not found"
)
return client
@@ -180,11 +193,11 @@ async def update_client_provider(
async def delete_client_provider(
client_id: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
current_user: dict = Depends(get_current_user),
):
"""
Delete client/provider from the system
Note: This will completely remove the client/provider and all related data.
"""
# Validate access to the tenant and company
@@ -196,14 +209,16 @@ async def delete_client_provider(
service = ClientProviderService(db)
if not service.delete_client_provider(client_id):
raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found")
raise HTTPException(
status_code=404, detail=f"Client/Provider with ID '{client_id}' not found"
)
@router.patch("/{client_id}/toggle-status", response_model=ClientProviderResponseDTO)
async def toggle_client_provider_status(
client_id: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
current_user: dict = Depends(get_current_user),
):
"""
Toggle client/provider enabled/disabled status
@@ -218,7 +233,9 @@ async def toggle_client_provider_status(
service = ClientProviderService(db)
client = service.toggle_status(client_id)
if not client:
raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found")
raise HTTPException(
status_code=404, detail=f"Client/Provider with ID '{client_id}' not found"
)
return client
@@ -227,7 +244,7 @@ async def toggle_client_provider_status(
async def get_client_provider_address(
client_id: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
current_user: dict = Depends(get_current_user),
):
"""
Get only address information for a client/provider
@@ -242,19 +259,18 @@ async def get_client_provider_address(
service = ClientProviderService(db)
client = service.get_client_provider(client_id)
if not client:
raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found")
return {
"client_id": client.client_id,
"address": client.address
}
raise HTTPException(
status_code=404, detail=f"Client/Provider with ID '{client_id}' not found"
)
return {"client_id": client.client_id, "address": client.address}
@router.get("/{client_id}/programs", response_model=dict)
async def get_client_provider_programs(
client_id: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
current_user: dict = Depends(get_current_user),
):
"""
Get only programs information for a client/provider
@@ -269,19 +285,18 @@ async def get_client_provider_programs(
service = ClientProviderService(db)
client = service.get_client_provider(client_id)
if not client:
raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found")
return {
"client_id": client.client_id,
"programs": client.programs
}
raise HTTPException(
status_code=404, detail=f"Client/Provider with ID '{client_id}' not found"
)
return {"client_id": client.client_id, "programs": client.programs}
@router.get("/{client_id}/basic", response_model=ClientProviderBasicDTO)
async def get_client_provider_basic_info(
client_id: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
current_user: dict = Depends(get_current_user),
):
"""
Get basic information for a client/provider (without address and programs)
@@ -296,14 +311,15 @@ async def get_client_provider_basic_info(
service = ClientProviderService(db)
client = service.get_client_provider(client_id)
if not client:
raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found")
raise HTTPException(
status_code=404, detail=f"Client/Provider with ID '{client_id}' not found"
)
return ClientProviderBasicDTO(
client_id=client.client_id,
name=client.name,
short_name=client.short_name,
rfc=client.rfc,
client_or_provider=client.client_or_provider,
enabled_disabled=client.enabled_disabled
enabled_disabled=client.enabled_disabled,
)

View File

@@ -1,6 +1,7 @@
"""
Capa de servicio para lógica de negocio de clientes y proveedores
"""
from sqlalchemy.orm import Session, joinedload
from sqlalchemy.exc import IntegrityError
from sqlalchemy import or_, and_
@@ -10,13 +11,13 @@ import logging
from .models import ClientProvider, ClientProviderAddress, ClientProviderPrograms
from .dto import (
ClientProviderCreateDTO,
ClientProviderUpdateDTO,
ClientProviderCreateDTO,
ClientProviderUpdateDTO,
ClientProviderResponseDTO,
ClientProviderBasicDTO,
ClientProviderListDTO,
ClientProviderAddressDTO,
ClientProviderProgramsDTO
ClientProviderProgramsDTO,
)
logger = logging.getLogger(__name__)
@@ -24,29 +25,38 @@ logger = logging.getLogger(__name__)
class ClientProviderService:
"""Servicio para gestión de clientes y proveedores"""
def __init__(self, db: Session):
self.db = db
def create_client_provider(self, client_data: ClientProviderCreateDTO) -> ClientProviderResponseDTO:
def create_client_provider(
self, client_data: ClientProviderCreateDTO
) -> ClientProviderResponseDTO:
"""
Crea un nuevo cliente/proveedor en el sistema
Args:
client_data: Datos del cliente/proveedor a crear
Returns:
ClientProviderResponseDTO con información del cliente/proveedor creado
Raises:
HTTPException: Si el cliente ya existe o error en la creación
"""
try:
# Verificar que no exista el cliente
existing = self.db.query(ClientProvider).filter(ClientProvider.client_id == client_data.client_id).first()
existing = (
self.db.query(ClientProvider)
.filter(ClientProvider.client_id == client_data.client_id)
.first()
)
if existing:
raise HTTPException(status_code=400, detail=f"Client with ID '{client_data.client_id}' already exists")
raise HTTPException(
status_code=400,
detail=f"Client with ID '{client_data.client_id}' already exists",
)
# Crear cliente/proveedor principal
db_client = ClientProvider(
client_id=client_data.client_id,
@@ -64,92 +74,106 @@ class ClientProviderService:
position=client_data.position,
incoterm=client_data.incoterm,
is_national_provider=client_data.is_national_provider,
enabled_disabled=client_data.enabled_disabled
enabled_disabled=client_data.enabled_disabled,
)
self.db.add(db_client)
self.db.flush() # Para obtener el ID antes del commit
# Crear dirección si se proporciona
if client_data.address:
db_address = ClientProviderAddress(
client_id=client_data.client_id,
**client_data.address.model_dump(exclude_unset=True)
**client_data.address.model_dump(exclude_unset=True),
)
self.db.add(db_address)
# Crear programas si se proporciona
if client_data.programs:
db_programs = ClientProviderPrograms(
client_id=client_data.client_id,
**client_data.programs.model_dump(exclude_unset=True)
**client_data.programs.model_dump(exclude_unset=True),
)
self.db.add(db_programs)
self.db.commit()
self.db.refresh(db_client)
logger.info(f"Client/Provider created: {db_client.client_id} - {db_client.name}")
logger.info(
f"Client/Provider created: {db_client.client_id} - {db_client.name}"
)
return self._get_client_with_relations(client_data.client_id)
except IntegrityError as e:
self.db.rollback()
logger.error(f"IntegrityError creating client/provider: {str(e)}")
raise HTTPException(status_code=400, detail="Client/Provider with this ID already exists")
raise HTTPException(
status_code=400, detail="Client/Provider with this ID already exists"
)
except HTTPException:
raise
except Exception as e:
self.db.rollback()
logger.error(f"Error creating client/provider: {str(e)}")
raise HTTPException(status_code=500, detail="Error creating client/provider")
def get_client_provider(self, client_id: str) -> Optional[ClientProviderResponseDTO]:
raise HTTPException(
status_code=500, detail="Error creating client/provider"
)
def get_client_provider(
self, client_id: str
) -> Optional[ClientProviderResponseDTO]:
"""
Obtiene un cliente/proveedor por ID
Args:
client_id: ID del cliente/proveedor
Returns:
ClientProviderResponseDTO o None si no existe
"""
return self._get_client_with_relations(client_id)
def _get_client_with_relations(self, client_id: str) -> Optional[ClientProviderResponseDTO]:
def _get_client_with_relations(
self, client_id: str
) -> Optional[ClientProviderResponseDTO]:
"""Método privado para obtener cliente con relaciones"""
client = self.db.query(ClientProvider).options(
joinedload(ClientProvider.address),
joinedload(ClientProvider.programs)
).filter(ClientProvider.client_id == client_id).first()
client = (
self.db.query(ClientProvider)
.options(
joinedload(ClientProvider.address), joinedload(ClientProvider.programs)
)
.filter(ClientProvider.client_id == client_id)
.first()
)
if not client:
return None
return ClientProviderResponseDTO.model_validate(client)
def list_clients_providers(
self,
skip: int = 0,
limit: int = 100,
self,
skip: int = 0,
limit: int = 100,
search: Optional[str] = None,
client_or_provider: Optional[str] = None,
enabled_only: bool = False
enabled_only: bool = False,
) -> ClientProviderListDTO:
"""
Lista clientes/proveedores con filtros
Args:
skip: Número de registros a omitir
limit: Número máximo de registros a retornar
search: Texto de búsqueda (nombre, RFC, ID)
client_or_provider: Filtrar por tipo (C=Cliente, P=Proveedor)
enabled_only: Si True, solo retorna activos
Returns:
ClientProviderListDTO con la lista paginada
"""
query = self.db.query(ClientProvider)
# Aplicar filtros
if search:
search_pattern = f"%{search}%"
@@ -158,56 +182,72 @@ class ClientProviderService:
ClientProvider.name.ilike(search_pattern),
ClientProvider.short_name.ilike(search_pattern),
ClientProvider.rfc.ilike(search_pattern),
ClientProvider.client_id.ilike(search_pattern)
ClientProvider.client_id.ilike(search_pattern),
)
)
if client_or_provider:
query = query.filter(ClientProvider.client_or_provider == client_or_provider)
query = query.filter(
ClientProvider.client_or_provider == client_or_provider
)
if enabled_only:
query = query.filter(ClientProvider.enabled_disabled == 1)
# Contar total
total = query.count()
# Aplicar paginación
clients = query.offset(skip).limit(limit).all()
# Convertir a DTOs básicos
client_dtos = [ClientProviderBasicDTO.model_validate(client) for client in clients]
client_dtos = [
ClientProviderBasicDTO.model_validate(client) for client in clients
]
return ClientProviderListDTO(
clients=client_dtos,
total=total,
page=(skip // limit) + 1 if limit > 0 else 1,
size=len(client_dtos)
size=len(client_dtos),
)
def update_client_provider(self, client_id: str, client_data: ClientProviderUpdateDTO) -> Optional[ClientProviderResponseDTO]:
def update_client_provider(
self, client_id: str, client_data: ClientProviderUpdateDTO
) -> Optional[ClientProviderResponseDTO]:
"""
Actualiza un cliente/proveedor
Args:
client_id: ID del cliente/proveedor a actualizar
client_data: Datos a actualizar
Returns:
ClientProviderResponseDTO actualizado o None si no existe
"""
client = self.db.query(ClientProvider).filter(ClientProvider.client_id == client_id).first()
client = (
self.db.query(ClientProvider)
.filter(ClientProvider.client_id == client_id)
.first()
)
if not client:
return None
try:
# Actualizar campos del cliente principal
update_data = client_data.model_dump(exclude_unset=True, exclude={'address', 'programs'})
update_data = client_data.model_dump(
exclude_unset=True, exclude={"address", "programs"}
)
for field, value in update_data.items():
setattr(client, field, value)
# Actualizar dirección
if client_data.address:
address = self.db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == client_id).first()
address = (
self.db.query(ClientProviderAddress)
.filter(ClientProviderAddress.client_id == client_id)
.first()
)
if address:
# Actualizar dirección existente
address_data = client_data.address.model_dump(exclude_unset=True)
@@ -217,13 +257,17 @@ class ClientProviderService:
# Crear nueva dirección
address = ClientProviderAddress(
client_id=client_id,
**client_data.address.model_dump(exclude_unset=True)
**client_data.address.model_dump(exclude_unset=True),
)
self.db.add(address)
# Actualizar programas
if client_data.programs:
programs = self.db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == client_id).first()
programs = (
self.db.query(ClientProviderPrograms)
.filter(ClientProviderPrograms.client_id == client_id)
.first()
)
if programs:
# Actualizar programas existentes
programs_data = client_data.programs.model_dump(exclude_unset=True)
@@ -233,34 +277,40 @@ class ClientProviderService:
# Crear nuevos programas
programs = ClientProviderPrograms(
client_id=client_id,
**client_data.programs.model_dump(exclude_unset=True)
**client_data.programs.model_dump(exclude_unset=True),
)
self.db.add(programs)
self.db.commit()
logger.info(f"Client/Provider updated: {client_id}")
return self._get_client_with_relations(client_id)
except Exception as e:
self.db.rollback()
logger.error(f"Error updating client/provider {client_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error updating client/provider")
raise HTTPException(
status_code=500, detail="Error updating client/provider"
)
def delete_client_provider(self, client_id: str) -> bool:
"""
Elimina un cliente/proveedor
Args:
client_id: ID del cliente/proveedor a eliminar
Returns:
True si se eliminó, False si no existe
"""
client = self.db.query(ClientProvider).filter(ClientProvider.client_id == client_id).first()
client = (
self.db.query(ClientProvider)
.filter(ClientProvider.client_id == client_id)
.first()
)
if not client:
return False
try:
self.db.delete(client) # Las relaciones se eliminan en cascada
self.db.commit()
@@ -269,41 +319,61 @@ class ClientProviderService:
except Exception as e:
self.db.rollback()
logger.error(f"Error deleting client/provider {client_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error deleting client/provider")
def get_clients_only(self, skip: int = 0, limit: int = 100) -> List[ClientProviderBasicDTO]:
raise HTTPException(
status_code=500, detail="Error deleting client/provider"
)
def get_clients_only(
self, skip: int = 0, limit: int = 100
) -> List[ClientProviderBasicDTO]:
"""Obtiene solo clientes (C)"""
query = self.db.query(ClientProvider).filter(ClientProvider.client_or_provider == 'C')
query = self.db.query(ClientProvider).filter(
ClientProvider.client_or_provider == "C"
)
clients = query.offset(skip).limit(limit).all()
return [ClientProviderBasicDTO.model_validate(client) for client in clients]
def get_providers_only(self, skip: int = 0, limit: int = 100) -> List[ClientProviderBasicDTO]:
def get_providers_only(
self, skip: int = 0, limit: int = 100
) -> List[ClientProviderBasicDTO]:
"""Obtiene solo proveedores (P)"""
query = self.db.query(ClientProvider).filter(ClientProvider.client_or_provider == 'P')
query = self.db.query(ClientProvider).filter(
ClientProvider.client_or_provider == "P"
)
providers = query.offset(skip).limit(limit).all()
return [ClientProviderBasicDTO.model_validate(provider) for provider in providers]
return [
ClientProviderBasicDTO.model_validate(provider) for provider in providers
]
def search_by_rfc(self, rfc: str) -> List[ClientProviderBasicDTO]:
"""Busca clientes/proveedores por RFC"""
clients = self.db.query(ClientProvider).filter(ClientProvider.rfc.ilike(f"%{rfc}%")).all()
clients = (
self.db.query(ClientProvider)
.filter(ClientProvider.rfc.ilike(f"%{rfc}%"))
.all()
)
return [ClientProviderBasicDTO.model_validate(client) for client in clients]
def toggle_status(self, client_id: str) -> Optional[ClientProviderResponseDTO]:
"""Cambia el estado habilitado/deshabilitado"""
client = self.db.query(ClientProvider).filter(ClientProvider.client_id == client_id).first()
client = (
self.db.query(ClientProvider)
.filter(ClientProvider.client_id == client_id)
.first()
)
if not client:
return None
# Toggle status (1 = habilitado, 0 = deshabilitado)
client.enabled_disabled = 1 if client.enabled_disabled == 0 else 0
try:
self.db.commit()
logger.info(f"Client/Provider status toggled: {client_id} -> {client.enabled_disabled}")
logger.info(
f"Client/Provider status toggled: {client_id} -> {client.enabled_disabled}"
)
return self._get_client_with_relations(client_id)
except Exception as e:
self.db.rollback()
logger.error(f"Error toggling status for {client_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error updating status")