Merge branch 'features/Creacion-formularios_catalogos_generales' into development

This commit is contained in:
AlexeerCT
2025-12-24 15:05:09 -06:00
279 changed files with 23252 additions and 3644 deletions

View File

@@ -14,5 +14,6 @@ class ClassificationConcept(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
classification: Mapped[str] = mapped_column(
String(30), nullable=False) # CLASIFICACION

View File

@@ -1,10 +1,15 @@
from typing import List, Optional, Tuple, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
import logging
from .models import ClassificationConcept
from .dto import ClassificationConceptCreate, ClassificationConceptUpdate
logger = logging.getLogger(__name__)
class ClassificationConceptService:
@staticmethod
@@ -76,6 +81,14 @@ class ClassificationConceptService:
if not db_obj:
return False
db.delete(db_obj)
db.commit()
return True
try:
db.delete(db_obj)
db.commit()
return True
except IntegrityError as e:
db.rollback()
logger.error(f"Error de integridad al eliminar clasificación de concepto {id}: {str(e)}")
raise HTTPException(
status_code=400,
detail="No se puede eliminar esta clasificación porque tiene registros relacionados (pedimentos, facturas, etc.). Primero debe eliminar o reasignar esos registros."
)

View File

@@ -2,7 +2,7 @@
Rutas para gestión de empresa
"""
from typing import List
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
@@ -17,7 +17,78 @@ from .service import CompanyService
# Main router that includes base CRUD
router = APIRouter(prefix="/company")
# Custom endpoints
@router.post(
"", # Se suma al prefix, queda POST /api/v1/a76/company
response_model=CompanyResponseDTO,
status_code=status.HTTP_201_CREATED,
summary="Create a new company",
)
async def create_company(
data: CompanyCreateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
service = CompanyService(db)
return service.create_company_manually(data, tenant_id=tenant_id)
@router.get(
"", # GET /api/v1/a76/company with pagination
response_model=dict,
summary="Get companies with pagination",
)
async def list_companies(
page: int = 1,
page_size: int = 50,
name: Optional[str] = None,
rfc: Optional[str] = None,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Get paginated list of companies for current tenant with optional filters"""
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
skip = (page - 1) * page_size
filters = {}
if name:
filters["name"] = name
if rfc:
filters["rfc"] = rfc
service = CompanyService(db)
items, total = service.get_all(
db,
tenant_id,
company_id=0, # Not used for companies
skip=skip,
limit=page_size,
filters=filters if filters else None
)
total_pages = (total + page_size - 1) // page_size
return {
"items": [CompanyResponseDTO.model_validate(item) for item in items],
"total": total,
"page": page,
"page_size": page_size,
"pages": total_pages,
}
@router.get(
"/my-companies",
response_model=List[CompanyResponseDTO],
@@ -166,17 +237,90 @@ async def get_program_info(
"prosec": company.prosec,
"prosec_authorization": company.prosec_authorization,
}
# Base CRUD routes using TenantCRUDRoutes
base_router = TenantCRUDRoutes(
service=CompanyService,
create_schema=CompanyCreateDTO,
update_schema=CompanyUpdateDTO,
response_schema=CompanyResponseDTO,
prefix="",
tags=[],
id_name="id",
enable_list=True,
enable_filters=True,
).router
router.include_router(base_router)
@router.get(
"/{company_id}",
response_model=CompanyResponseDTO,
summary="Get company by ID",
)
async def get_company(
company_id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Get a specific company by ID"""
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
company = CompanyService.get_by_id(db, company_id, tenant_id, 0)
if not company:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Company not found",
)
return CompanyResponseDTO.model_validate(company)
@router.put(
"/{company_id}",
response_model=CompanyResponseDTO,
summary="Update company",
)
async def update_company(
company_id: int,
data: CompanyUpdateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Update a company"""
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
updated_company = CompanyService.update(
db, company_id, tenant_id, 0, data
)
if not updated_company:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Company not found",
)
return CompanyResponseDTO.model_validate(updated_company)
@router.delete(
"/{company_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete company",
)
async def delete_company(
company_id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Delete a company"""
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
success = CompanyService.delete(db, company_id, tenant_id, 0)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Company not found",
)
return None

View File

@@ -64,6 +64,7 @@ class CompanyService:
.first()
)
# ESTE ES EL MÉTODO VIEJO QUE CAUSABA PROBLEMAS (Lo dejamos por si acaso)
@staticmethod
def create(
db: Session,
@@ -71,7 +72,7 @@ class CompanyService:
tenant_id: int,
company_id: int,
) -> Company:
"""Create a new company"""
"""Create a new company (MÉTODO GENÉRICO - NO USAR PARA CREACIÓN MANUAL)"""
try:
db_company = Company(
**company_data.model_dump(exclude_unset=True),
@@ -136,10 +137,20 @@ class CompanyService:
db.delete(company)
db.commit()
return True
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError deleting company {company_id}: {str(e)}")
# Check if it's a foreign key constraint
if "foreign key constraint" in str(e).lower():
raise HTTPException(
status_code=400,
detail="No se puede eliminar la empresa porque tiene registros relacionados (facturas, conceptos, etc.)"
)
raise HTTPException(status_code=400, detail="Error al eliminar la empresa")
except Exception as e:
db.rollback()
logger.error(f"Error deleting company {company_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error deleting company")
raise HTTPException(status_code=500, detail="Error al eliminar la empresa")
# Custom methods
def get_companies_by_tenant(self, tenant_id: int) -> List[Company]:
@@ -159,3 +170,31 @@ class CompanyService:
.first()
is not None
)
def create_company_manually(self, data: CompanyCreateDTO, tenant_id: int) -> Company:
try:
# 1. Preparar datos
obj_data = data.model_dump(exclude_unset=True)
# 2. Crear objeto SQLAlchemy
db_obj = Company(**obj_data, tenant_id=tenant_id)
# 3. Guardar
self.db.add(db_obj)
self.db.commit()
self.db.refresh(db_obj)
return db_obj
except IntegrityError as e:
self.db.rollback()
logger.error(f"IntegrityError creating company manually: {str(e)}")
raise HTTPException(
status_code=400,
detail="Error de integridad: Es posible que esta empresa ya exista.",
)
except Exception as e:
self.db.rollback()
logger.error(f"Error creating company manually: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error creando empresa: {str(e)}")

View File

@@ -4,6 +4,7 @@ from pydantic import BaseModel, Field, ConfigDict
class ConceptBase(BaseModel):
code: str = Field(..., max_length=15, description="Concept Code (CLAVE)")
company_id: int = Field(..., description="Company ID")
description: Optional[str] = Field(
None, max_length=120, description="Description")
description_en: Optional[str] = Field(
@@ -18,7 +19,6 @@ class ConceptBase(BaseModel):
section: Optional[int] = Field(None, description="Section")
classification: Optional[str] = Field(
None, max_length=30, description="Classification")
company_id: int = Field(..., description="Company ID")
class ConceptCreate(ConceptBase):
@@ -32,7 +32,7 @@ class ConceptUpdate(BaseModel):
detailed_description: Optional[str] = Field(None, max_length=1000)
priority: Optional[int] = None
priority_ame: Optional[int] = None
first_total: Optional[bool] = None
first_total: Optional[bool] = None
type: Optional[str] = Field(None, max_length=9)
is_printed: Optional[bool] = None
section: Optional[int] = None

View File

@@ -18,25 +18,39 @@ class Concept(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(
Integer, nullable=False) # IDEMPRESA
code: Mapped[str] = mapped_column(String(15), nullable=False) # CLAVE
description: Mapped[Optional[str]] = mapped_column(
String(120), nullable=True) # DESCRIPCION
description_en: Mapped[Optional[str]] = mapped_column(
String(120), nullable=True) # DESCRIPCIONINGLES
detailed_description: Mapped[Optional[str]] = mapped_column(
String(1000), nullable=True) # DESCRIPCIONDETALLADA
priority: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True) # PRIORIDAD
priority_ame: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True) # PRIORIDADAME
first_total: Mapped[Optional[bool]] = mapped_column(
Boolean, nullable=True) # PRIMERTOTAL
type: Mapped[Optional[str]] = mapped_column(
String(9), nullable=True) # TIPO
is_printed: Mapped[Optional[bool]] = mapped_column(
Boolean, nullable=True) # SEIMPRIME
section: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True) # SECCION
classification: Mapped[Optional[str]] = mapped_column(String(30), ForeignKey(
"a76.classification_concepts.classification"), nullable=True) # CLASIFICACION

View File

@@ -1,10 +1,16 @@
from typing import List, Optional, Tuple, Dict, Any
import logging
from sqlalchemy.orm import Session
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
from .models import Concept
from .dto import ConceptCreate, ConceptUpdate
logger = logging.getLogger(__name__)
class ConceptService:
@staticmethod
@@ -74,6 +80,20 @@ class ConceptService:
if not db_obj:
return False
db.delete(db_obj)
db.commit()
return True
try:
db.delete(db_obj)
db.commit()
return True
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError deleting concept {id}: {str(e)}")
if "foreign key constraint" in str(e).lower():
raise HTTPException(
status_code=400,
detail="No se puede eliminar el concepto porque tiene registros relacionados"
)
raise HTTPException(status_code=400, detail="Error al eliminar el concepto")
except Exception as e:
db.rollback()
logger.error(f"Error deleting concept {id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error al eliminar el concepto")

View File

@@ -4,8 +4,7 @@ from pydantic import BaseModel, Field, ConfigDict
class CustomsBrokerConceptBase(BaseModel):
broker_key: str = Field(..., max_length=5,
description="Customs Broker Key (CLAVEAA)")
broker_key: str = Field(..., max_length=5, description="Customs Broker Key (CLAVEAA)")
concept: str = Field(..., max_length=15, description="Concept")
amount: Optional[Decimal] = Field(None, description="Amount")
priority: Optional[int] = Field(None, description="Priority")

View File

@@ -9,17 +9,23 @@ from core.database import Base
class CustomsBrokerConcept(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "customs_broker_concepts"
__table_args__ = (
UniqueConstraint("broker_key", "concept", name="uq_broker_concept"),
UniqueConstraint("broker_key", "concept", "company_id", name="uq_broker_concept"),
{"schema": "a76"}
)
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(Integer, nullable=False)
broker_key: Mapped[str] = mapped_column(
String(5), nullable=False) # CLAVEAA
concept: Mapped[str] = mapped_column(
String(15), nullable=False) # CONCEPTO
amount: Mapped[Optional[Decimal]] = mapped_column(
Numeric(11, 2), nullable=True) # IMPORTE
priority: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True) # PRIORIDAD

View File

@@ -7,7 +7,7 @@ router = TenantCRUDRoutes(
create_schema=CustomsBrokerConceptCreate,
update_schema=CustomsBrokerConceptUpdate,
response_schema=CustomsBrokerConceptResponse,
prefix="/customs-broker-concepts",
prefix="/customs-broker-concepts",
tags=["a76.general_catalogs.customs_broker_concepts"],
resource_name="Customs Broker Concept",
enable_list=True,

View File

@@ -1,10 +1,16 @@
from typing import List, Optional, Tuple, Dict, Any
import logging
from sqlalchemy.orm import Session
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
from .models import CustomsBrokerConcept
from .dto import CustomsBrokerConceptCreate, CustomsBrokerConceptUpdate
logger = logging.getLogger(__name__)
class CustomsBrokerConceptService:
@staticmethod
@@ -76,6 +82,20 @@ class CustomsBrokerConceptService:
if not db_obj:
return False
db.delete(db_obj)
db.commit()
return True
try:
db.delete(db_obj)
db.commit()
return True
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError deleting customs broker concept {id}: {str(e)}")
if "foreign key constraint" in str(e).lower():
raise HTTPException(
status_code=400,
detail="No se puede eliminar el concepto porque tiene registros relacionados"
)
raise HTTPException(status_code=400, detail="Error al eliminar el concepto")
except Exception as e:
db.rollback()
logger.error(f"Error deleting customs broker concept {id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error al eliminar el concepto")

View File

@@ -340,7 +340,7 @@ class DodaUpdateDTO(BaseModel):
class DodaResponseDTO(BaseModel):
"""DTO para responder con datos de un DODA"""
sys_id: int
id: int
integration_number: Optional[str] = None
doda_date: Optional[int] = None
doda_time: Optional[int] = None
@@ -383,7 +383,7 @@ class DodaResponseDTO(BaseModel):
class DodaDetailResponseDTO(BaseModel):
"""DTO detallado para responder con todos los datos de un DODA"""
sys_id: int
id: int
integration_number: Optional[str] = None
doda_date: Optional[int] = None
doda_time: Optional[int] = None

View File

@@ -151,19 +151,19 @@ class DodaContainerSeal(Base, TenantScopedMixin, TimestampMixin):
{"schema": "a76"},
)
# Primary key
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
# Foreign key and line info
container_id: Mapped[int] = mapped_column(Integer, nullable=False)
doda_id: Mapped[int] = mapped_column(Integer, nullable=False)
seal_line: Mapped[int] = mapped_column(Integer, nullable=False)
# Seal information
seal_value: Mapped[Optional[str]] = mapped_column(String(21))
# Relationships
container: Mapped["DodaContainer"] = relationship(
"DodaContainer", back_populates="seals_detail"
)

View File

@@ -131,14 +131,21 @@ class DodaService:
db: Session, id: int, tenant_id: int, company_id: int
) -> bool:
"""Delete a DODA"""
try:
db_doda = DodaService.get_by_id(db, id, tenant_id, company_id)
if not db_doda:
return False
db_doda = DodaService.get_by_id(db, id, tenant_id, company_id)
if not db_doda:
return False
try:
db.delete(db_doda)
db.commit()
return True
except IntegrityError as e:
db.rollback()
logger.error(f"Error de integridad al eliminar DODA {id}: {str(e)}")
raise HTTPException(
status_code=400,
detail="No se puede eliminar este DODA porque tiene registros relacionados. Primero debe eliminar o reasignar esos registros."
)
except Exception as e:
db.rollback()
logger.error(f"Error deleting DODA: {str(e)}")

View File

@@ -68,7 +68,7 @@ class ElectronicNoticeUpdateDTO(BaseModel):
class ElectronicNoticeResponseDTO(BaseModel):
"""DTO para responder con datos de un aviso electrónico"""
sys_id: int
id: int
notice_number: Optional[str] = None
year: Optional[str] = None
patent: Optional[str] = None

View File

@@ -30,22 +30,27 @@ class EquivalencyItemResponse(EquivalencyItemBase):
class EquivalencyBase(BaseModel):
identifier: str = Field(..., max_length=10, description="Identifier")
fraccion_mex: str = Field(..., max_length=10, description="Fraccion MX (Identifier)")
fraccion_us: str = Field(..., max_length=100, description="Fraccion US (External Field)")
description: Optional[str] = Field(
None, max_length=200, description="Description")
class EquivalencyCreate(EquivalencyBase):
items: Optional[List[EquivalencyItemCreate]] = []
pass
class EquivalencyUpdate(BaseModel):
identifier: Optional[str] = Field(None, max_length=10)
fraccion_mex: Optional[str] = Field(None, max_length=10)
fraccion_us: Optional[str] = Field(None, max_length=100)
description: Optional[str] = Field(None, max_length=200)
class EquivalencyResponse(EquivalencyBase):
id: int
items: List[EquivalencyItemResponse] = []
tenant_id: int
company_id: int
model_config = ConfigDict(from_attributes=True)
model_config = ConfigDict(from_attributes=True)

View File

@@ -16,7 +16,9 @@ class Equivalency(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
identifier: Mapped[str] = mapped_column(String(10), nullable=False)
description: Mapped[Optional[str]] = mapped_column(
String(200), nullable=True)
@@ -39,11 +41,15 @@ class EquivalencyItem(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
equivalency_id: Mapped[int] = mapped_column(
Integer, ForeignKey("a76.equivalencies.id"), nullable=False)
original_field: Mapped[str] = mapped_column(
String(100), nullable=False) # Relation to Unit of Measure
external_field: Mapped[str] = mapped_column(String(100), nullable=False)
equivalency: Mapped["Equivalency"] = relationship(back_populates="items")
unit_of_measure: Mapped["UnitOfMeasure"] = relationship()

View File

@@ -1,6 +1,7 @@
from typing import List, Optional, Tuple, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
from .models import Equivalency, EquivalencyItem
@@ -20,14 +21,26 @@ class EquivalencyService:
query = db.query(Equivalency).filter(
Equivalency.tenant_id == tenant_id,
Equivalency.company_id == company_id
)
).options(joinedload(Equivalency.items))
if filters:
# Add filters here if needed
pass
if 'fraccion_mex' in filters:
query = query.filter(Equivalency.identifier.ilike(f"%{filters['fraccion_mex']}%"))
if 'description' in filters:
query = query.filter(Equivalency.description.ilike(f"%{filters['description']}%"))
total = query.count()
items = query.offset(skip).limit(limit).all()
# Map internal fields to DTO fields
for item in items:
item.fraccion_mex = item.identifier
# Try to find the first item to get fraccion_us
if item.items:
item.fraccion_us = item.items[0].external_field
else:
item.fraccion_us = ""
return items, total
@staticmethod
@@ -37,11 +50,20 @@ class EquivalencyService:
tenant_id: int,
company_id: int
) -> Optional[Equivalency]:
return db.query(Equivalency).filter(
item = db.query(Equivalency).filter(
Equivalency.id == id,
Equivalency.tenant_id == tenant_id,
Equivalency.company_id == company_id
).first()
).options(joinedload(Equivalency.items)).first()
if item:
item.fraccion_mex = item.identifier
if item.items:
item.fraccion_us = item.items[0].external_field
else:
item.fraccion_us = ""
return item
@staticmethod
def create(
@@ -50,50 +72,122 @@ class EquivalencyService:
tenant_id: int,
company_id: int
) -> Equivalency:
db_obj = Equivalency(
identifier=data.identifier,
description=data.description,
tenant_id=tenant_id,
company_id=company_id
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
try:
# Create Parent
db_obj = Equivalency(
identifier=data.fraccion_mex,
description=data.description,
tenant_id=tenant_id,
company_id=company_id
)
db.add(db_obj)
db.flush() # Flush to get ID
if data.items:
for item_data in data.items:
item = EquivalencyItem(
**item_data.model_dump(),
equivalency_id=db_obj.id,
tenant_id=tenant_id,
company_id=company_id
)
db.add(item)
# Create Child Item (mapping fraccion_mex -> original_field, fraccion_us -> external_field)
item = EquivalencyItem(
equivalency_id=db_obj.id,
original_field=data.fraccion_mex, # Must exist in units_of_measure
external_field=data.fraccion_us,
tenant_id=tenant_id,
company_id=company_id
)
db.add(item)
db.commit()
db.refresh(db_obj)
return db_obj
# Map for response
db_obj.fraccion_mex = db_obj.identifier
db_obj.fraccion_us = item.external_field
return db_obj
except IntegrityError as e:
db.rollback()
error_msg = str(e.orig) if hasattr(e, 'orig') else str(e)
print(f"IntegrityError in create: {error_msg}")
if "units_of_measure" in error_msg:
raise HTTPException(
status_code=400,
detail=f"La Fracción MX '{data.fraccion_mex}' no es válida. Debe existir en el catálogo de Unidades de Medida."
)
if "uq_equivalency_identifier" in error_msg:
raise HTTPException(
status_code=400,
detail=f"Ya existe una equivalencia para la Fracción MX '{data.fraccion_mex}'."
)
raise HTTPException(status_code=400, detail=f"Error al guardar: {error_msg}")
except Exception as e:
db.rollback()
print(f"Error in create: {str(e)}")
raise e
@staticmethod
def update(
db: Session,
id: int,
data: EquivalencyUpdate,
tenant_id: int,
data: EquivalencyUpdate,
company_id: int
) -> Optional[Equivalency]:
db_obj = EquivalencyService.get_by_id(db, id, tenant_id, company_id)
if not db_obj:
return None
update_dict = data.model_dump(exclude_unset=True)
for key, value in update_dict.items():
if key != 'items': # Handle items separately if needed, or ignore for now as per original code
setattr(db_obj, key, value)
try:
if data.fraccion_mex:
db_obj.identifier = data.fraccion_mex
if data.description:
db_obj.description = data.description
db.commit()
db.refresh(db_obj)
return db_obj
# Update Item
item = None
if db_obj.items:
item = db_obj.items[0]
if item:
if data.fraccion_us:
item.external_field = data.fraccion_us
if data.fraccion_mex:
item.original_field = data.fraccion_mex
else:
# Create if missing
if data.fraccion_us or data.fraccion_mex:
item = EquivalencyItem(
equivalency_id=db_obj.id,
original_field=data.fraccion_mex or db_obj.identifier,
external_field=data.fraccion_us or "",
tenant_id=tenant_id,
company_id=company_id
)
db.add(item)
db.commit()
db.refresh(db_obj)
# Map for response
db_obj.fraccion_mex = db_obj.identifier
if db_obj.items:
db_obj.fraccion_us = db_obj.items[0].external_field
else:
db_obj.fraccion_us = ""
return db_obj
except IntegrityError as e:
db.rollback()
error_msg = str(e.orig) if hasattr(e, 'orig') else str(e)
print(f"IntegrityError in update: {error_msg}")
if "units_of_measure" in error_msg:
raise HTTPException(
status_code=400,
detail=f"La Fracción MX '{data.fraccion_mex or db_obj.identifier}' no es válida. Debe existir en el catálogo de Unidades de Medida."
)
raise HTTPException(status_code=400, detail=f"Error al actualizar: {error_msg}")
except Exception as e:
db.rollback()
print(f"Error in update: {str(e)}")
raise e
@staticmethod
def delete(
@@ -110,7 +204,6 @@ class EquivalencyService:
db.commit()
return True
class EquivalencyItemService:
@staticmethod
def get_all(
@@ -125,11 +218,6 @@ class EquivalencyItemService:
EquivalencyItem.tenant_id == tenant_id,
EquivalencyItem.company_id == company_id
)
if filters and "equivalency_id" in filters:
query = query.filter(
EquivalencyItem.equivalency_id == filters["equivalency_id"])
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
@@ -154,13 +242,6 @@ class EquivalencyItemService:
tenant_id: int,
company_id: int
) -> EquivalencyItem:
# Note: equivalency_id should be in data or handled by the caller if it's a nested route
# But TenantCRUDRoutes for child resources might pass it in filters or we need to handle it.
# For now, assuming it's in data or we don't use child resource feature yet.
# If using child resource, the parent_id is usually passed in the path.
# But TenantCRUDRoutes passes the body.
db_obj = EquivalencyItem(
**data.model_dump(),
tenant_id=tenant_id,
@@ -180,8 +261,9 @@ class EquivalencyItemService:
company_id: int
) -> EquivalencyItem:
db_obj = EquivalencyItem(
**data.model_dump(),
equivalency_id=equivalency_id,
original_field=data.original_field,
external_field=data.external_field,
tenant_id=tenant_id,
company_id=company_id
)
@@ -194,19 +276,17 @@ class EquivalencyItemService:
def update(
db: Session,
id: int,
data: EquivalencyItemUpdate,
tenant_id: int,
data: EquivalencyItemUpdate,
company_id: int
) -> Optional[EquivalencyItem]:
db_obj = EquivalencyItemService.get_by_id(
db, id, tenant_id, company_id)
db_obj = EquivalencyItemService.get_by_id(db, id, tenant_id, company_id)
if not db_obj:
return None
update_dict = data.model_dump(exclude_unset=True)
for key, value in update_dict.items():
for key, value in data.model_dump(exclude_unset=True).items():
setattr(db_obj, key, value)
db.commit()
db.refresh(db_obj)
return db_obj
@@ -218,11 +298,9 @@ class EquivalencyItemService:
tenant_id: int,
company_id: int
) -> bool:
db_obj = EquivalencyItemService.get_by_id(
db, id, tenant_id, company_id)
db_obj = EquivalencyItemService.get_by_id(db, id, tenant_id, company_id)
if not db_obj:
return False
db.delete(db_obj)
db.commit()
return True

View File

@@ -24,7 +24,8 @@ class ErrorClassification(Base, TenantScopedMixin, TimestampMixin):
# Primary key
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
Integer, primary_key=True, autoincrement=True
)
# Classification code (unique)
code: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
@@ -60,7 +61,8 @@ class ErrorCatalog(Base, TenantScopedMixin, TimestampMixin):
# Primary key
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
Integer, primary_key=True, autoincrement=True
)
# Error code (unique)
code: Mapped[str] = mapped_column(String(15), nullable=False, unique=True)
@@ -77,4 +79,4 @@ class ErrorCatalog(Base, TenantScopedMixin, TimestampMixin):
)
def __repr__(self):
return f"<ErrorCatalog(id={self.id}, code={self.code}, description={self.description}, classification_id={self.classification_id})>"
return f"<ErrorCatalog(id={self.id}, code={self.code}, description={self.description}, classification_id={self.classification_id})>"

View File

@@ -30,6 +30,9 @@ class ExchangeRate(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
date: Mapped[datetime] = mapped_column(DateTime)
value: Mapped[Optional[Decimal]] = mapped_column(DECIMAL(13, 6))
local_currency: Mapped[Optional[str]] = mapped_column(String(7))
foreign_currency: Mapped[Optional[str]] = mapped_column(String(7))

View File

@@ -1,9 +1,6 @@
from typing import Optional
from pydantic import BaseModel, Field, ConfigDict
# Identifier DTOs
class IdentifierBase(BaseModel):
code: str = Field(..., max_length=2, description="Identifier Code (CLAVE)")
description: Optional[str] = Field(
@@ -11,28 +8,23 @@ class IdentifierBase(BaseModel):
level: Optional[str] = Field(None, max_length=1, description="Level")
complement: Optional[str] = Field(
None, max_length=5000, description="Complement")
company_id: int = Field(..., description="Company ID")
class IdentifierCreate(IdentifierBase):
pass
class IdentifierUpdate(BaseModel):
code: Optional[str] = Field(None, max_length=2)
description: Optional[str] = Field(None, max_length=1000)
level: Optional[str] = Field(None, max_length=1)
complement: Optional[str] = Field(None, max_length=5000)
class IdentifierResponse(IdentifierBase):
id: int
tenant_id: int
company_id: int
model_config = ConfigDict(from_attributes=True)
# Identifier Detail DTOs
class IdentifierDetailBase(BaseModel):
invoice_consecutive: Optional[int] = Field(
@@ -47,13 +39,10 @@ class IdentifierDetailBase(BaseModel):
None, max_length=51, description="Complement 2")
complement3: Optional[str] = Field(
None, max_length=50, description="Complement 3")
company_id: int = Field(..., description="Company ID")
class IdentifierDetailCreate(IdentifierDetailBase):
pass
class IdentifierDetailUpdate(BaseModel):
invoice_consecutive: Optional[int] = None
part_line: Optional[int] = None
@@ -63,9 +52,9 @@ class IdentifierDetailUpdate(BaseModel):
complement2: Optional[str] = Field(None, max_length=51)
complement3: Optional[str] = Field(None, max_length=50)
class IdentifierDetailResponse(IdentifierDetailBase):
id: int
tenant_id: int
company_id: int
model_config = ConfigDict(from_attributes=True)
model_config = ConfigDict(from_attributes=True)

View File

@@ -14,11 +14,15 @@ class Identifier(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(2), nullable=False) # CLAVE
description: Mapped[Optional[str]] = mapped_column(
String(1000), nullable=True) # DESCRIPCION
level: Mapped[Optional[str]] = mapped_column(
String(1), nullable=True) # NIVEL
complement: Mapped[Optional[str]] = mapped_column(
String(5000), nullable=True) # COMPLEMENTO

View File

@@ -68,8 +68,8 @@ class IdentifierService:
def update(
db: Session,
id: int,
data: IdentifierUpdate,
tenant_id: int,
data: IdentifierUpdate,
company_id: int
) -> Optional[Identifier]:
db_obj = IdentifierService.get_by_id(db, id, tenant_id, company_id)

View File

@@ -2,7 +2,6 @@ from typing import Optional
from decimal import Decimal
from pydantic import BaseModel, Field, ConfigDict
class INPCBase(BaseModel):
year: str = Field(..., max_length=4, description="Year (YYYY)")
month: str = Field(..., max_length=2, description="Month (MM)")
@@ -12,7 +11,6 @@ class INPCBase(BaseModel):
class INPCCreate(INPCBase):
pass
class INPCUpdate(BaseModel):
year: Optional[str] = Field(None, max_length=4)
month: Optional[str] = Field(None, max_length=2)
@@ -21,5 +19,7 @@ class INPCUpdate(BaseModel):
class INPCResponse(INPCBase):
id: int
tenant_id: int
company_id: int
model_config = ConfigDict(from_attributes=True)
model_config = ConfigDict(from_attributes=True)

View File

@@ -16,7 +16,10 @@ class INPC(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
year: Mapped[str] = mapped_column(String(4), nullable=False) # ANIO
month: Mapped[str] = mapped_column(String(2), nullable=False) # MES
value: Mapped[Optional[Decimal]] = mapped_column(
Numeric(19, 8), nullable=True) # VALOR

View File

@@ -1,20 +1,16 @@
from fastapi import APIRouter
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from .models import INPC
from .dto import INPCCreate, INPCResponse, INPCUpdate
from .service import INPCService
router = APIRouter(prefix="/inpc", tags=["a76.general_catalogs.inpc"])
inpc_crud = TenantCRUDRoutes(
# Usamos TenantCRUDRoutes directamente
router = TenantCRUDRoutes(
service=INPCService,
create_schema=INPCCreate,
update_schema=INPCUpdate,
response_schema=INPCResponse,
prefix="",
tags=["INPC"],
prefix="/inpc",
tags=["a76.general_catalogs.inpc"],
resource_name="INPC",
enable_list=True,
)
router.include_router(inpc_crud.router)
).router

View File

@@ -63,8 +63,8 @@ class INPCService:
def update(
db: Session,
id: int,
data: INPCUpdate,
tenant_id: int,
data: INPCUpdate,
company_id: int
) -> Optional[INPC]:
db_obj = INPCService.get_by_id(db, id, tenant_id, company_id)

View File

@@ -21,3 +21,5 @@ class LegendResponse(LegendBase):
id: int
model_config = ConfigDict(from_attributes=True)
tenant_id : int
company_id : int

View File

@@ -15,6 +15,8 @@ class Legend(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
code: Mapped[int] = mapped_column(Integer, nullable=False) # CLAVELEY
description: Mapped[Optional[str]] = mapped_column(
String(2000), nullable=True) # DESCLEYENDA

View File

@@ -4,17 +4,13 @@ from .models import Legend
from .dto import LegendCreate, LegendResponse, LegendUpdate
from .service import LegendService
router = APIRouter(prefix="/legends", tags=["a76.general_catalogs.legends"])
legend_crud = TenantCRUDRoutes(
router = TenantCRUDRoutes(
service=LegendService,
create_schema=LegendCreate,
update_schema=LegendUpdate,
response_schema=LegendResponse,
prefix="",
tags=["Legends"],
prefix="/legends",
tags=["a76.general_catalogs.legends"],
resource_name="Legend",
enable_list=True,
)
router.include_router(legend_crud.router)
).router

View File

@@ -1,10 +1,15 @@
from typing import List, Optional, Tuple, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
import logging
from .models import Legend
from .dto import LegendCreate, LegendUpdate
logger = logging.getLogger(__name__)
class LegendService:
@staticmethod
@@ -63,8 +68,8 @@ class LegendService:
def update(
db: Session,
id: int,
data: LegendUpdate,
tenant_id: int,
data: LegendUpdate,
company_id: int
) -> Optional[Legend]:
db_obj = LegendService.get_by_id(db, id, tenant_id, company_id)
@@ -90,6 +95,14 @@ class LegendService:
if not db_obj:
return False
db.delete(db_obj)
db.commit()
return True
try:
db.delete(db_obj)
db.commit()
return True
except IntegrityError as e:
db.rollback()
logger.error(f"Error de integridad al eliminar leyenda {id}: {str(e)}")
raise HTTPException(
status_code=400,
detail="No se puede eliminar esta leyenda porque tiene registros relacionados (pedimentos, facturas, etc.). Primero debe eliminar o reasignar esos registros."
)

View File

@@ -27,5 +27,7 @@ class MultiCurrencyTypeUpdate(BaseModel):
class MultiCurrencyTypeResponse(MultiCurrencyTypeBase):
id: int
company_id: int
tenant_id: int
model_config = ConfigDict(from_attributes=True)

View File

@@ -18,13 +18,18 @@ class MultiCurrencyType(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
currency_type_code: Mapped[str] = mapped_column(
String(3), ForeignKey("public.currency_types.code"), nullable=False)
country_key: Mapped[Optional[str]] = mapped_column(
String(3), ForeignKey("public.countries.m3_key"), nullable=True)
conversion_factor: Mapped[Optional[Decimal]] = mapped_column(
Numeric(13, 6), nullable=True)
publication_date: Mapped[int] = mapped_column(Integer, nullable=False)
currency_type: Mapped["CurrencyType"] = relationship()
country: Mapped[Optional["Country"]] = relationship()

View File

@@ -31,4 +31,4 @@ class Package(Base, TenantScopedMixin, TimestampMixin):
plurals: Mapped[Optional[str]] = mapped_column(String(4))
plural_in: Mapped[Optional[str]] = mapped_column(String(4))
code_ace: Mapped[Optional[str]] = mapped_column(String(4))
code_aamex: Mapped[Optional[str]] = mapped_column(String(9))
code_aamex: Mapped[Optional[str]] = mapped_column(String(9))

View File

@@ -3,11 +3,16 @@ Service layer for Packages (GBultos).
"""
from typing import Optional, Tuple, List, Dict, Any
import logging
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
from . import dto, models
logger = logging.getLogger(__name__)
class PackageService:
"""Service for Package CRUD operations with tenant support"""
@@ -109,6 +114,20 @@ class PackageService:
if not package:
return False
db.delete(package)
db.commit()
return True
try:
db.delete(package)
db.commit()
return True
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError deleting package {package_id}: {str(e)}")
if "foreign key constraint" in str(e).lower():
raise HTTPException(
status_code=400,
detail="No se puede eliminar el bulto porque tiene registros relacionados"
)
raise HTTPException(status_code=400, detail="Error al eliminar el bulto")
except Exception as e:
db.rollback()
logger.error(f"Error deleting package {package_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error al eliminar el bulto")

View File

@@ -63,8 +63,8 @@ class PortService:
def update(
db: Session,
id: int,
data: PortUpdate,
tenant_id: int,
data: PortUpdate,
company_id: int
) -> Optional[Port]:
db_obj = PortService.get_by_id(db, id, tenant_id, company_id)

View File

@@ -56,32 +56,3 @@ class PrevalidatorResponseDTO(BaseModel):
class Config:
from_attributes = True
class PrevalidatorUpdateDTO(BaseModel):
"""DTO para actualizar un prevalidador"""
customs_prevalidator: Optional[str] = Field(
None, max_length=20, description="Customs prevalidator"
)
patent_prevalidator: Optional[str] = Field(
None, max_length=20, description="Patent prevalidator"
)
description: Optional[str] = Field(
None, max_length=50, description="Description"
)
class Config:
from_attributes = True
class PrevalidatorResponseDTO(BaseModel):
"""DTO para responder con datos de un prevalidador"""
code: str
customs_prevalidator: Optional[str] = None
patent_prevalidator: Optional[str] = None
description: Optional[str] = None
class Config:
from_attributes = True

View File

@@ -31,7 +31,9 @@ class Prevalidator(Base, TenantScopedMixin, TimestampMixin):
# Prevalidator information
customs_prevalidator: Mapped[Optional[str]] = mapped_column(String(20))
patent_prevalidator: Mapped[Optional[str]] = mapped_column(String(20))
description: Mapped[Optional[str]] = mapped_column(String(50))
def __repr__(self):

View File

@@ -120,8 +120,8 @@ class PrevalidatorService:
def update(
db: Session,
prevalidator_id: int,
prevalidator_data: PrevalidatorUpdateDTO,
tenant_id: int,
prevalidator_data: PrevalidatorUpdateDTO,
company_id: int
) -> Optional[Prevalidator]:
"""Update a prevalidator"""

View File

@@ -11,6 +11,7 @@ class SignatureCreateDTO(BaseModel):
"""DTO para crear una firma"""
code: str = Field(..., max_length=10, description="Signature code")
signature: Optional[str] = Field(
None, max_length=1000, description="Signature")
photo_path: Optional[str] = Field(
@@ -39,6 +40,8 @@ class SignatureResponseDTO(BaseModel):
code: str
signature: Optional[str] = None
photo_path: Optional[str] = None
tenant_id: int
company_id: int
class Config:
from_attributes = True

View File

@@ -32,6 +32,7 @@ class Signature(Base, TenantScopedMixin, TimestampMixin):
# Signature information
signature: Mapped[Optional[str]] = mapped_column(String(1000))
photo_path: Mapped[Optional[str]] = mapped_column(String(1000))
def __repr__(self):

View File

@@ -5,6 +5,8 @@ Capa de servicio para lógica de negocio de firmas
import logging
from typing import Any, Dict, List, Optional, Tuple
from fastapi import HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from . import dto, models
@@ -81,10 +83,18 @@ class SignatureService:
new_signature = models.Signature(
**signature_data.model_dump(), tenant_id=tenant_id, company_id=company_id
)
db.add(new_signature)
db.commit()
db.refresh(new_signature)
return new_signature
try:
db.add(new_signature)
db.commit()
db.refresh(new_signature)
return new_signature
except IntegrityError as exc:
db.rollback()
# Constraint names: signatures_code_unique (code, tenant_id, company_id)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ya existe una firma con ese código para esta compañía",
) from exc
@staticmethod
def update(

View File

@@ -2,27 +2,23 @@ from typing import Optional
from decimal import Decimal
from pydantic import BaseModel, Field, ConfigDict
class UnitConversionBase(BaseModel):
from_unit_code: str = Field(..., max_length=5,
description="Source Unit Code")
to_unit_code: str = Field(..., max_length=5,
description="Target Unit Code")
conversion_factor: Optional[Decimal] = Field(
None, description="Conversion Factor")
# 👇 OJO: Son códigos (Strings), no IDs
from_unit_code: str = Field(..., max_length=5, description="Source Unit Code")
to_unit_code: str = Field(..., max_length=5, description="Target Unit Code")
conversion_factor: Optional[Decimal] = Field(None, description="Conversion Factor")
class UnitConversionCreate(UnitConversionBase):
pass
class UnitConversionUpdate(BaseModel):
from_unit_code: Optional[str] = Field(None, max_length=5)
to_unit_code: Optional[str] = Field(None, max_length=5)
conversion_factor: Optional[Decimal] = None
class UnitConversionResponse(UnitConversionBase):
id: int
tenant_id: int
company_id: int
model_config = ConfigDict(from_attributes=True)
model_config = ConfigDict(from_attributes=True)

View File

@@ -27,8 +27,11 @@ class UnitConversion(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
from_unit_code: Mapped[str] = mapped_column(String(5), nullable=False)
to_unit_code: Mapped[str] = mapped_column(String(5), nullable=False)
conversion_factor: Mapped[Optional[Decimal]] = mapped_column(
Numeric(13, 6), nullable=True)

View File

@@ -1,6 +1,9 @@
from typing import List, Optional, Tuple, Dict, Any
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .models import UnitConversion
from .dto import UnitConversionCreate, UnitConversionUpdate
@@ -51,10 +54,18 @@ class UnitConversionService:
tenant_id=tenant_id,
company_id=company_id
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
try:
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
except IntegrityError as exc:
db.rollback()
# Puede ser FK de unidades o duplicado de (from_unit_code, to_unit_code, tenant, company)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Verifica que los códigos de unidad existan y que la conversión no esté duplicada",
) from exc
@staticmethod
def update(
@@ -72,9 +83,16 @@ class UnitConversionService:
for key, value in update_dict.items():
setattr(db_obj, key, value)
db.commit()
db.refresh(db_obj)
return db_obj
try:
db.commit()
db.refresh(db_obj)
return db_obj
except IntegrityError as exc:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Verifica que los códigos de unidad existan y que la conversión no esté duplicada",
) from exc
@staticmethod
def delete(

View File

@@ -181,4 +181,4 @@ class UnitOfMeasureGeneral(Base, TenantScopedMixin, TimestampMixin):
String(4), nullable=True) # CLAVEACE
customs_unit: Mapped[Optional["UnitOfMeasureCustoms"]] = relationship()
ace_unit: Mapped[Optional["UnitOfMeasureACE"]] = relationship()
ace_unit: Mapped[Optional["UnitOfMeasureACE"]] = relationship(overlaps="customs_unit")

View File

@@ -1,6 +1,11 @@
from typing import List, Optional, Tuple, Dict, Any, Type
from sqlalchemy import Sequence
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
import logging
logger = logging.getLogger(__name__)
from .models import (
UnitOfMeasureACE, UnitOfMeasureOMA, UnitOfMeasureAmerican, UnitOfMeasureCustoms,
@@ -102,9 +107,17 @@ class BaseService:
if not db_obj:
return False
db.delete(db_obj)
db.commit()
return True
try:
db.delete(db_obj)
db.commit()
return True
except IntegrityError as e:
db.rollback()
logger.error(f"Error de integridad al eliminar {cls.model.__name__} {id}: {str(e)}")
raise HTTPException(
status_code=400,
detail="No se puede eliminar esta unidad de medida porque tiene registros relacionados. Primero debe eliminar o reasignar esos registros."
)
class UnitOfMeasureACEService(BaseService):