Merge remote-tracking branch 'origin/development' into feature/reportes_facturas

This commit is contained in:
2026-01-09 08:30:40 -06:00
167 changed files with 24030 additions and 4439 deletions

View File

@@ -4,6 +4,7 @@ Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
"""
from datetime import datetime
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
@@ -12,24 +13,23 @@ from pydantic import BaseModel, ConfigDict, Field
class ClassCreateDTO(BaseModel):
"""DTO para crear una clase"""
client_id: int = Field(..., description="Client key")
class_code: str = Field(..., max_length=8, description="Class code")
description_es: Optional[str] = Field(
None, max_length=500, description="Description in Spanish"
description_es: str = Field(
..., max_length=500, description="Description in Spanish (required)"
)
description_en: Optional[str] = Field(
None, max_length=500, description="Description in English"
)
material_key: Optional[str] = Field(
None,
material_key: str = Field(
...,
max_length=10,
description="Material key (homologated TIPOMAT/TIPOMATEQUIPO)",
description="Material key - Fixed Asset Type (required)",
)
unit_of_measure: Optional[str] = Field(
None, max_length=5, description="Unit of measure (homologated UNIMEDIDA)"
unit_of_measure: str = Field(
..., max_length=5, description="Unit of measure - U.M. comercial (required)"
)
fraction: Optional[str] = Field(
None, max_length=10, description="Mexican tariff fraction"
fraction: str = Field(
..., max_length=20, description="Mexican tariff fraction (required)"
)
us_fraction: Optional[str] = Field(
None, max_length=16, description="US tariff fraction"
@@ -48,9 +48,45 @@ class ClassCreateDTO(BaseModel):
from_attributes = True
class ClassCreateDTOFA(ClassCreateDTO):
"""DTO para crear una clase de activo fijo (clase base + extensión FA)"""
# Campos específicos de activos fijos (a24.fa_classes)
import_tariff_code: Optional[str] = Field(
None, max_length=10, description="Código de fracción de importación"
)
import_tariff_type: Optional[str] = Field(
None, max_length=6, description="Tipo de fracción de importación"
)
export_tariff_code: Optional[str] = Field(
None, max_length=10, description="Código de fracción de exportación"
)
export_tariff_type: Optional[str] = Field(
None, max_length=6, description="Tipo de fracción de exportación"
)
depreciation_rate: Optional[Decimal] = Field(
None, ge=0, le=100, description="Tasa de depreciación anual (%)"
)
fda_code: Optional[str] = Field(
None, max_length=20, description="Código FDA"
)
eccn_code: Optional[str] = Field(
None, max_length=20, description="Código ECCN"
)
class_enabled: Optional[bool] = Field(
True, description="Indica si la clase está habilitada"
)
class Config:
from_attributes = True
class ClassUpdateDTO(BaseModel):
"""DTO para actualizar una clase"""
class_code: Optional[str] = Field(
None, max_length=8, description="Class code"
)
description_es: Optional[str] = Field(
None, max_length=500, description="Description in Spanish"
)
@@ -66,7 +102,7 @@ class ClassUpdateDTO(BaseModel):
None, max_length=5, description="Unit of measure (homologated UNIMEDIDA)"
)
fraction: Optional[str] = Field(
None, max_length=10, description="Mexican tariff fraction"
None, max_length=20, description="Mexican tariff fraction"
)
us_fraction: Optional[str] = Field(
None, max_length=16, description="US tariff fraction"
@@ -81,8 +117,7 @@ class ClassUpdateDTO(BaseModel):
None, max_length=4, description="IVA exempt fraction"
)
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True, extra='forbid') # Explicitly forbid extra fields
class ClassResponseDTO(BaseModel):
@@ -91,7 +126,6 @@ class ClassResponseDTO(BaseModel):
id: int
tenant_id: int
company_id: int
client_id: int
class_code: str
description_es: Optional[str] = None
description_en: Optional[str] = None
@@ -108,10 +142,26 @@ class ClassResponseDTO(BaseModel):
model_config = ConfigDict(from_attributes=True)
class ClassResponseDTOFA(ClassResponseDTO):
"""DTO para respuesta de clase de activo fijo (incluye campos FA)"""
# Campos de a24.fa_classes
fa_id: Optional[int] = None
import_tariff_code: Optional[str] = None
import_tariff_type: Optional[str] = None
export_tariff_code: Optional[str] = None
export_tariff_type: Optional[str] = None
depreciation_rate: Optional[Decimal] = None
fda_code: Optional[str] = None
eccn_code: Optional[str] = None
class_enabled: Optional[bool] = None
model_config = ConfigDict(from_attributes=True)
class ClassBasicDTO(BaseModel):
"""DTO para información básica de clase"""
client_id: int
class_code: str
description_es: Optional[str] = None
description_en: Optional[str] = None
@@ -137,7 +187,6 @@ class ClassListDTO(BaseModel):
class ClassSearchDTO(BaseModel):
"""DTO para búsqueda de clases"""
client_id: Optional[int] = Field(None, description="Filter by client key")
class_code: Optional[str] = Field(None, description="Search by class code")
description: Optional[str] = Field(None, description="Search in descriptions")
material_key: Optional[str] = Field(None, description="Filter by material key")
@@ -147,4 +196,4 @@ class ClassSearchDTO(BaseModel):
)
class Config:
from_attributes = True
from_attributes = True

View File

@@ -31,9 +31,6 @@ class Class(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "classes"
__table_args__ = (
PrimaryKeyConstraint("id", name="classes_pkey"),
ForeignKeyConstraint(
["client_id"], ["a76.clients_and_providers.id"], name="fk_classes_client"
),
ForeignKeyConstraint(
["material_key"],
["public.material_types.key"],
@@ -47,15 +44,13 @@ class Class(Base, TenantScopedMixin, TimestampMixin):
UniqueConstraint(
"tenant_id",
"company_id",
"client_id",
"class_code",
name="ufa_classes_client_id_class_code",
name="uq_classes_tenant_company_code",
),
{"schema": "a76"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
client_id: Mapped[int] = mapped_column(Integer)
# Unique constraint compuesta
class_code: Mapped[str] = mapped_column(String(8)) # CLASE
@@ -75,7 +70,7 @@ class Class(Base, TenantScopedMixin, TimestampMixin):
) # UNIMED - homologated from UNIMEDIDA
# Tariff fractions
fraction: Mapped[Optional[str]] = mapped_column(String(10)) # FRACCION
fraction: Mapped[Optional[str]] = mapped_column(String(20)) # FRACCION
us_fraction: Mapped[Optional[str]] = mapped_column(
String(16)
) # FRACCIONAME - US tariff fraction
@@ -98,11 +93,11 @@ class Class(Base, TenantScopedMixin, TimestampMixin):
# Inverse relationship with GParts that have this class
parts: Mapped[list["Part"]] = relationship(
primaryjoin="and_(Class.client_id == Part.client_id, Class.class_code == Part.part_class)",
foreign_keys="[Part.client_id, Part.part_class]",
primaryjoin="and_(Class.class_code == Part.part_class)",
foreign_keys="[Part.part_class]",
viewonly=True,
back_populates="part_class_info",
)
def __repr__(self) -> str:
return f"<Class(client_id={self.client_id}, class_code='{self.class_code}', description='{self.description_es}')>"
return f"<Class(class_code='{self.class_code}', description='{self.description_es}')>"

View File

@@ -2,13 +2,19 @@
Endpoints API para gestión de clases SCAII y SCAF
"""
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from typing import Dict, Any
from fastapi import Depends, Query
from sqlalchemy.orm import Session
from .dto import ClassCreateDTO, ClassResponseDTO, ClassUpdateDTO
from core.database import get_core_db
from core.security import get_current_user
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes, validate_access_to_resource
from .dto import ClassCreateDTO, ClassCreateDTOFA, ClassResponseDTO, ClassResponseDTOFA, ClassUpdateDTO
from .service import ClassService
# Create router with generic CRUD routes
router = TenantCRUDRoutes(
crud_routes = TenantCRUDRoutes(
service=ClassService,
create_schema=ClassCreateDTO,
update_schema=ClassUpdateDTO,
@@ -16,9 +22,35 @@ router = TenantCRUDRoutes(
prefix="/classes",
tags=["a76 / classes"],
resource_name="Class",
id_name="class_id",
id_name="id",
enable_list=True,
enable_filters=True,
default_page_size=50,
max_page_size=100,
).router
max_page_size=1000,
)
router = crud_routes.router
@router.post(
"/fa",
response_model=ClassResponseDTOFA,
status_code=201,
summary="Create Fixed Asset Class",
description="Create a class with FA extension in a single transaction",
)
async def create_fa_class(
class_data: ClassCreateDTOFA,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Create a fixed asset class (both base class and FA extension)"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"create_fa_class endpoint called with: {class_data.model_dump()}")
tenant_id = validate_access_to_resource(db, company_id, current_user)
result = ClassService.create_fa_class(db, class_data, tenant_id, company_id)
return result

View File

@@ -13,8 +13,10 @@ from sqlalchemy.orm import Session
from .dto import (
ClassBasicDTO,
ClassCreateDTO,
ClassCreateDTOFA,
ClassListDTO,
ClassResponseDTO,
ClassResponseDTOFA,
ClassSearchDTO,
ClassUpdateDTO,
)
@@ -38,13 +40,12 @@ class ClassService:
"""
Get all classes for a tenant with pagination and filters
"""
logger.info(f"get_all called with tenant_id={tenant_id}, company_id={company_id}, skip={skip}, limit={limit}")
query = db.query(Class).filter(
Class.tenant_id == tenant_id, Class.company_id == company_id
)
if filters:
if filters.get("client_id"):
query = query.filter(Class.client_id == filters["client_id"])
if filters.get("class_code"):
query = query.filter(
Class.class_code.ilike(f"%{filters['class_code']}%")
@@ -70,7 +71,8 @@ class ClassService:
total = query.count()
items = query.offset(skip).limit(limit).all()
logger.info(f"get_all returning {len(items)} items out of {total} total")
return items, total
@staticmethod
@@ -102,25 +104,25 @@ class ClassService:
existing = db.query(Class).filter(
Class.tenant_id == tenant_id,
Class.company_id == company_id,
Class.client_id == data_dict["client_id"],
Class.class_code == data_dict["class_code"]
).first()
if existing:
raise HTTPException(
status_code=400,
detail=f"Class with code '{data_dict['class_code']}' already exists for this tenant and company"
detail=f" El código de clase '{data_dict['class_code']}' ya existe. Por favor use un código diferente."
)
# Validate material_key exists if provided
if data_dict.get("material_key"):
from api.v1.modules.public.reference_data.material_types.models import MaterialType
material_exists = db.query(MaterialType).filter(
MaterialType.key == data_dict["material_key"]
).first()
if not material_exists:
# Set to None if material_key doesn't exist
data_dict["material_key"] = None
# Validate material_key exists (now required)
from api.v1.modules.public.reference_data.material_types.models import MaterialType
material_exists = db.query(MaterialType).filter(
MaterialType.key == data_dict["material_key"]
).first()
if not material_exists:
raise HTTPException(
status_code=400,
detail=f"Material type '{data_dict['material_key']}' does not exist"
)
class_obj = Class(**data_dict)
class_obj.tenant_id = tenant_id
@@ -147,11 +149,16 @@ class ClassService:
company_id: int,
) -> Optional[Class]:
"""Update a class"""
logger.info(f"Update called for class_id={class_id}, tenant_id={tenant_id}, company_id={company_id}")
logger.info(f"Update data received: {class_data.model_dump(exclude_unset=True)}")
class_obj = ClassService.get_by_id(db, class_id, tenant_id, company_id)
if not class_obj:
logger.warning(f"Class {class_id} not found for tenant {tenant_id}, company {company_id}")
return None
update_data = class_data.model_dump(exclude_unset=True)
logger.info(f"Update data after model_dump: {update_data}")
# Validate material_key exists if provided
if "material_key" in update_data and update_data["material_key"]:
@@ -163,24 +170,183 @@ class ClassService:
# Set to None if material_key doesn't exist
update_data["material_key"] = None
# Validate class_code is unique if being changed
if "class_code" in update_data and update_data["class_code"]:
new_code = update_data["class_code"]
# Check if another class with this code exists (excluding current class)
# The unique constraint is on (tenant_id, company_id, class_code)
existing_class = db.query(Class).filter(
Class.class_code == new_code,
Class.tenant_id == tenant_id,
Class.company_id == company_id,
Class.id != class_id # Exclude current class
).first()
logger.info(f"Checking for duplicate class_code '{new_code}'")
if existing_class:
logger.warning(f"Duplicate class_code found: {existing_class.id}")
raise HTTPException(
status_code=400,
detail=f"El código '{new_code}' ya está en uso para este cliente. Por favor ingrese un código diferente."
)
for field, value in update_data.items():
setattr(class_obj, field, value)
db.commit()
db.refresh(class_obj)
return class_obj
try:
logger.info(f"Attempting to commit changes for class {class_id}")
db.commit()
db.refresh(class_obj)
logger.info(f"Successfully updated class {class_id}")
return class_obj
except IntegrityError as e:
db.rollback()
error_msg = str(e.orig)
logger.error(f"IntegrityError updating class {class_id}: {error_msg}")
# Check if it's a duplicate class_code error
if "already exists" in error_msg.lower() or "duplicate" in error_msg.lower():
# Extract the code from update_data if it was changed
code = update_data.get("class_code", class_obj.class_code)
raise HTTPException(
status_code=400,
detail=f"El código '{code}' ya está en uso. Por favor ingrese un código diferente."
)
raise HTTPException(
status_code=400,
detail=f"Error al actualizar la clase: {error_msg}"
)
except Exception as e:
db.rollback()
logger.error(f"Unexpected error updating class {class_id}: {type(e).__name__}: {str(e)}")
raise
@staticmethod
def delete(db: Session, class_id: int, tenant_id: int, company_id: int) -> bool:
"""Delete a class"""
"""Delete a class (and its FA extension if exists)"""
from api.v1.modules.a24.fa.fa_classes.models import QClasses
class_obj = ClassService.get_by_id(db, class_id, tenant_id, company_id)
if not class_obj:
return False
# Delete FA extension first (if exists) to avoid FK constraint violation
fa_extension = db.query(QClasses).filter(
QClasses.class_id == class_id,
QClasses.tenant_id == tenant_id
).first()
if fa_extension:
db.delete(fa_extension)
# Now delete the base class
db.delete(class_obj)
db.commit()
return True
@staticmethod
def create_fa_class(
db: Session, class_data: ClassCreateDTOFA, tenant_id: int, company_id: int
) -> Dict[str, Any]:
"""
Create a fixed asset class (both a76.classes and a24.fa_classes)
Returns a dict with both records combined
"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"create_fa_class called with data: {class_data.model_dump()}")
from api.v1.modules.a24.fa.fa_classes.models import QClasses
# Extract base class fields
base_fields = {
"class_code", "description_es", "description_en",
"material_key", "unit_of_measure", "fraction", "us_fraction",
"sub_key", "physical_review", "iva_exempt_fraction"
}
base_data = {k: v for k, v in class_data.model_dump().items() if k in base_fields}
# Extract FA-specific fields
fa_fields = {
"import_tariff_code", "import_tariff_type", "export_tariff_code",
"export_tariff_type", "depreciation_rate", "fda_code", "eccn_code",
"class_enabled"
}
fa_data = {k: v for k, v in class_data.model_dump().items() if k in fa_fields}
try:
# 1. Create base class
base_dto = ClassCreateDTO(**base_data)
base_class = ClassService.create(db, base_dto, tenant_id, company_id)
# 2. Create FA extension
fa_obj = QClasses(**fa_data)
fa_obj.class_id = base_class.id
fa_obj.tenant_id = tenant_id
fa_obj.company_id = company_id
db.add(fa_obj)
db.commit()
db.refresh(fa_obj)
# 3. Combine response - build dict manually to avoid SQLAlchemy internals
combined_response = {
# Base class fields
"id": base_class.id,
"tenant_id": base_class.tenant_id,
"company_id": base_class.company_id,
"class_code": base_class.class_code,
"description_es": base_class.description_es,
"description_en": base_class.description_en,
"material_key": base_class.material_key,
"unit_of_measure": base_class.unit_of_measure,
"fraction": base_class.fraction,
"us_fraction": base_class.us_fraction,
"sub_key": base_class.sub_key,
"physical_review": base_class.physical_review,
"iva_exempt_fraction": base_class.iva_exempt_fraction,
"created_at": base_class.created_at,
"updated_at": base_class.updated_at,
# FA extension fields
"fa_id": fa_obj.id,
"import_tariff_code": fa_obj.import_tariff_code,
"import_tariff_type": fa_obj.import_tariff_type,
"export_tariff_code": fa_obj.export_tariff_code,
"export_tariff_type": fa_obj.export_tariff_type,
"depreciation_rate": fa_obj.depreciation_rate,
"fda_code": fa_obj.fda_code,
"eccn_code": fa_obj.eccn_code,
"class_enabled": fa_obj.class_enabled,
}
return combined_response
except Exception as e:
db.rollback()
# If FA creation fails, rollback base class too
if 'base_class' in locals():
try:
db.delete(base_class)
db.commit()
except:
pass
# Extract and improve error message
error_msg = str(e)
if "already exists" in error_msg.lower() or "duplicad" in error_msg.lower():
# Extract code from error if possible
code = class_data.class_code
raise HTTPException(
status_code=400,
detail=f"El código '{code}' ya está en uso. Por favor ingrese un código diferente."
)
raise HTTPException(
status_code=400,
detail=f"Error al crear clase de activo fijo: {error_msg}"
)
def __init__(self, db: Session):
self.db = db
@@ -203,7 +369,6 @@ class ClassService:
self.db.query(Class)
.filter(
and_(
Class.client_id == class_data.client_id,
Class.class_code == class_data.class_code,
)
)
@@ -213,12 +378,11 @@ class ClassService:
if existing:
raise HTTPException(
status_code=400,
detail=f"Class with client_id '{class_data.client_id}' and class_code '{class_data.class_code}' already exists",
detail=f"Class with class_code '{class_data.class_code}' already exists",
)
# Crear clase
db_class = Class(
client_id=class_data.client_id,
class_code=class_data.class_code,
description_spanish=class_data.description_spanish,
description_english=class_data.description_english,
@@ -242,7 +406,7 @@ class ClassService:
logger.error(f"IntegrityError creating class: {str(e)}")
raise HTTPException(
status_code=400,
detail="Class with this client_id and class_code already exists",
detail="Class with this class_code already exists",
)
except HTTPException:
raise
@@ -251,12 +415,11 @@ class ClassService:
logger.error(f"Error creating class: {str(e)}")
raise HTTPException(status_code=500, detail="Error creating class")
def get_class(self, client_id: int, class_code: str) -> Optional[ClassResponseDTO]:
def get_class(self, class_code: str) -> Optional[ClassResponseDTO]:
"""
Obtiene una clase por clave compuesta
Args:
client_id: Clave del cliente
class_code: Código de clase
Returns:
@@ -264,7 +427,7 @@ class ClassService:
"""
class_obj = (
self.db.query(Class)
.filter(and_(Class.client_id == client_id, Class.class_code == class_code))
.filter(and_(Class.class_code == class_code))
.first()
)
@@ -293,9 +456,6 @@ class ClassService:
# Aplicar filtros si se proporcionan
if search_params:
if search_params.client_id:
query = query.filter(Class.client_id == search_params.client_id)
if search_params.class_code:
query = query.filter(
Class.class_code.ilike(f"%{search_params.class_code}%")
@@ -342,13 +502,12 @@ class ClassService:
)
def update_class(
self, client_id: int, class_code: str, class_data: ClassUpdateDTO
self, class_code: str, class_data: ClassUpdateDTO
) -> Optional[ClassResponseDTO]:
"""
Actualiza una clase
Args:
client_id: Clave del cliente
class_code: Código de clase
class_data: Datos a actualizar
@@ -357,7 +516,7 @@ class ClassService:
"""
class_obj = (
self.db.query(Class)
.filter(and_(Class.client_id == client_id, Class.class_code == class_code))
.filter(and_(Class.class_code == class_code))
.first()
)
@@ -377,15 +536,14 @@ class ClassService:
except Exception as e:
self.db.rollback()
logger.error(f"Error updating class {client_id}-{class_code}: {str(e)}")
logger.error(f"Error updating class {class_code}: {str(e)}")
raise HTTPException(status_code=500, detail="Error updating class")
def delete_class(self, client_id: int, class_code: str) -> bool:
def delete_class(self, class_code: str) -> bool:
"""
Elimina una clase
Args:
client_id: Clave del cliente
class_code: Código de clase
Returns:
@@ -393,7 +551,7 @@ class ClassService:
"""
class_obj = (
self.db.query(Class)
.filter(and_(Class.client_id == client_id, Class.class_code == class_code))
.filter(and_(Class.class_code == class_code))
.first()
)
@@ -406,7 +564,7 @@ class ClassService:
return True
except Exception as e:
self.db.rollback()
logger.error(f"Error deleting class {client_id}-{class_code}: {str(e)}")
logger.error(f"Error deleting class {class_code}: {str(e)}")
raise HTTPException(status_code=500, detail="Error deleting class")
def search_by_fraction(self, fraction: str) -> List[ClassBasicDTO]:
@@ -416,19 +574,6 @@ class ClassService:
)
return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]
def search_by_client(
self, client_id: int, skip: int = 0, limit: int = 100
) -> List[ClassBasicDTO]:
"""Obtiene todas las clases de un cliente específico"""
classes = (
self.db.query(Class)
.filter(Class.client_id == client_id)
.offset(skip)
.limit(limit)
.all()
)
return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes]
def search_by_material(self, material_key: str) -> List[ClassBasicDTO]:
"""Busca clases por clave de material"""
classes = (
@@ -451,9 +596,6 @@ class ClassService:
"""Obtiene estadísticas básicas de clases"""
total_classes = self.db.query(Class).count()
# Contar por clientes
clients_count = self.db.query(Class.client_id).distinct().count()
# Contar por revisión física
physical_review_stats = {}
for i in range(3): # Asumiendo valores 0, 1, 2
@@ -468,7 +610,6 @@ class ClassService:
return {
"total_classes": total_classes,
"clients_with_classes": clients_count,
"classes_with_fraction": with_fraction,
"classes_with_us_fraction": with_us_fraction,
**physical_review_stats,

View File

@@ -116,9 +116,7 @@ class ClientProviderCreateDTO(BaseModel):
)
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[bool] = None
is_active: Optional[bool] = Field(None, description="Enabled/Disabled status")
# Nested DTOs
@@ -159,9 +157,7 @@ class ClientProviderUpdateDTO(BaseModel):
)
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[bool] = None
is_active: Optional[bool] = Field(None, description="Enabled/Disabled status")
# Nested DTOs
@@ -193,7 +189,7 @@ class ClientProviderResponseDTO(BaseModel):
responsible: Optional[str] = None
position: Optional[str] = None
incoterm: Optional[str] = None
is_national_provider: Optional[str] = None
is_national_provider: Optional[bool] = None
is_active: Optional[bool] = None
tenant_id: int
company_id: int

View File

@@ -5,6 +5,7 @@ from pydantic import BaseModel
class CustomsBrokerBaseDTO(BaseModel):
"""Base fields for CustomsBroker"""
type: Optional[str] = None
name: Optional[str] = None
address: Optional[str] = None
@@ -25,16 +26,20 @@ class CustomsBrokerBaseDTO(BaseModel):
class CustomsBrokerCreateDTO(CustomsBrokerBaseDTO):
"""Schema for creating a new CustomsBroker"""
broker_key: str
class CustomsBrokerUpdateDTO(CustomsBrokerBaseDTO):
"""Schema for updating an existing CustomsBroker"""
pass
class CustomsBrokerResponseDTO(CustomsBrokerBaseDTO):
"""Schema for CustomsBroker response"""
id: int
broker_key: str
tenant_id: int
company_id: int

View File

@@ -1,35 +1,64 @@
from typing import Dict, Any
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from . import dto, services
# Create main router
router = APIRouter()
# Create CRUD routes for CustomsBroker using TenantCRUDRoutes
customs_broker_crud = TenantCRUDRoutes(
service=services.CustomsBrokerService,
create_schema=dto.CustomsBrokerCreateDTO,
update_schema=dto.CustomsBrokerUpdateDTO,
response_schema=dto.CustomsBrokerResponseDTO,
prefix="/customs-brokers", # No prefix since it's already in the parent router
prefix="/customs-brokers",
tags=[],
resource_name="Customs Broker",
id_name="broker_key",
id_type=str,
enable_list=True, # Enable list endpoint with pagination
enable_list=True,
)
# Include the CRUD routes
router.include_router(customs_broker_crud.router)
# Additional routes for child resources (CustomsBrokerVU and CustomsBrokerPersonnel)
# These remain as manual routes since they have different patterns
@router.patch(
"/customs-brokers/{broker_key}",
response_model=dto.CustomsBrokerResponseDTO,
)
def update_customs_broker(
broker_key: str,
broker_data: dto.CustomsBrokerUpdateDTO,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Actualización parcial (PATCH).
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
broker = services.CustomsBrokerService.get_by_id(db, broker_key, tenant_id, company_id)
if not broker:
raise HTTPException(status_code=404, detail="Customs Broker not found")
updated_broker = services.CustomsBrokerService.update(
db=db,
broker_key=broker_key,
tenant_id=tenant_id,
broker_data=broker_data,
company_id=company_id
)
if not updated_broker:
raise HTTPException(status_code=400, detail="Error updating Customs Broker")
return updated_broker
@router.put(
"/customs-broker-vu/{broker_key}",
@@ -44,7 +73,6 @@ def update_customs_broker_vu(
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
# Verify the broker exists and belongs to the tenant/company
broker = services.CustomsBrokerService.get_by_id(db, broker_key, tenant_id, company_id)
if not broker:
raise HTTPException(status_code=404, detail="Customs Broker not found")
@@ -69,7 +97,6 @@ def update_customs_broker_personnel(
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
# Verify the broker exists and belongs to the tenant/company
broker = services.CustomsBrokerService.get_by_id(db, broker_key, tenant_id, company_id)
if not broker:
raise HTTPException(status_code=404, detail="Customs Broker not found")
@@ -81,5 +108,4 @@ def update_customs_broker_personnel(
raise HTTPException(
status_code=404, detail="Customs Broker Personnel not found"
)
return updated_personnel
return updated_personnel

View File

@@ -0,0 +1 @@
# Document Types for Digitization module

View File

@@ -0,0 +1,32 @@
from pydantic import BaseModel, Field
class DocumentTypeDigitizationBase(BaseModel):
"""Base schema for Document Type Digitization"""
code: str = Field(..., max_length=10, description="Código del tipo de documento")
description: str = Field(..., description="Descripción del tipo de documento")
active: bool = Field(default=True, description="Indica si el tipo está activo")
class DocumentTypeDigitizationCreate(DocumentTypeDigitizationBase):
"""Schema for creating a Document Type Digitization"""
pass
class DocumentTypeDigitizationUpdate(BaseModel):
"""Schema for updating a Document Type Digitization"""
code: str | None = Field(None, max_length=10)
description: str | None = None
active: bool | None = None
class DocumentTypeDigitizationResponse(DocumentTypeDigitizationBase):
"""Schema for Document Type Digitization response"""
id: int
class Config:
from_attributes = True

View File

@@ -0,0 +1,25 @@
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import Boolean, Integer, PrimaryKeyConstraint, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
class DocumentTypeDigitization(Base, TenantScopedMixin, TimestampMixin):
"""Tipos de documentos para digitalización de pedimentos"""
__tablename__ = "document_types_digitization"
__table_args__ = (
PrimaryKeyConstraint("id", name="document_types_digitization_pkey"),
UniqueConstraint(
"tenant_id",
"company_id",
"code",
name="document_types_digitization_code_key",
),
{"schema": "a76"},
)
id: Mapped[int] = mapped_column(Integer)
code: Mapped[str] = mapped_column(String(10), nullable=False, index=True)
description: Mapped[str] = mapped_column(Text, nullable=False)
active: Mapped[bool] = mapped_column(Boolean, default=True)

View File

@@ -0,0 +1,170 @@
from typing import List
from api.v1.modules.a76.doc_types_dig.dto import (
DocumentTypeDigitizationCreate,
DocumentTypeDigitizationResponse,
DocumentTypeDigitizationUpdate,
)
from api.v1.modules.a76.doc_types_dig.models import DocumentTypeDigitization
from core.database import get_core_db
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
router = APIRouter(prefix="/document-types-digitization", tags=["Document Types Digitization"])
@router.get("", response_model=List[DocumentTypeDigitizationResponse])
def get_all_document_types(
active_only: bool = True,
db: Session = Depends(get_core_db),
):
"""
Obtener todos los tipos de documentos para digitalización
Args:
active_only: Si es True, solo devuelve los tipos activos
"""
query = select(DocumentTypeDigitization)
if active_only:
query = query.where(DocumentTypeDigitization.active == True)
query = query.order_by(DocumentTypeDigitization.code)
result = db.execute(query)
document_types = result.scalars().all()
return document_types
@router.get("/{document_type_id}", response_model=DocumentTypeDigitizationResponse)
def get_document_type(
document_type_id: int,
db: Session = Depends(get_core_db),
):
"""Obtener un tipo de documento por ID"""
result = db.execute(
select(DocumentTypeDigitization).where(DocumentTypeDigitization.id == document_type_id)
)
document_type = result.scalar_one_or_none()
if not document_type:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tipo de documento con ID {document_type_id} no encontrado"
)
return document_type
@router.get("/by-code/{code}", response_model=DocumentTypeDigitizationResponse)
def get_document_type_by_code(
code: str,
db: Session = Depends(get_core_db),
):
"""Obtener un tipo de documento por código"""
result = db.execute(
select(DocumentTypeDigitization).where(DocumentTypeDigitization.code == code)
)
document_type = result.scalar_one_or_none()
if not document_type:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tipo de documento con código {code} no encontrado"
)
return document_type
@router.post("", response_model=DocumentTypeDigitizationResponse, status_code=status.HTTP_201_CREATED)
def create_document_type(
document_type_data: DocumentTypeDigitizationCreate,
db: Session = Depends(get_core_db),
):
"""Crear un nuevo tipo de documento"""
# Verificar si el código ya existe
result = db.execute(
select(DocumentTypeDigitization).where(DocumentTypeDigitization.code == document_type_data.code)
)
existing = result.scalar_one_or_none()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Ya existe un tipo de documento con el código {document_type_data.code}"
)
new_document_type = DocumentTypeDigitization(**document_type_data.model_dump())
db.add(new_document_type)
db.commit()
db.refresh(new_document_type)
return new_document_type
@router.put("/{document_type_id}", response_model=DocumentTypeDigitizationResponse)
def update_document_type(
document_type_id: int,
document_type_data: DocumentTypeDigitizationUpdate,
db: Session = Depends(get_core_db),
):
"""Actualizar un tipo de documento existente"""
result = db.execute(
select(DocumentTypeDigitization).where(DocumentTypeDigitization.id == document_type_id)
)
document_type = result.scalar_one_or_none()
if not document_type:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tipo de documento con ID {document_type_id} no encontrado"
)
# Actualizar solo los campos proporcionados
update_data = document_type_data.model_dump(exclude_unset=True)
# Verificar si el nuevo código ya existe (si se está actualizando)
if "code" in update_data and update_data["code"] != document_type.code:
result = db.execute(
select(DocumentTypeDigitization).where(DocumentTypeDigitization.code == update_data["code"])
)
existing = result.scalar_one_or_none()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Ya existe un tipo de documento con el código {update_data['code']}"
)
for field, value in update_data.items():
setattr(document_type, field, value)
db.commit()
db.refresh(document_type)
return document_type
@router.delete("/{document_type_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_document_type(
document_type_id: int,
db: Session = Depends(get_core_db),
):
"""Eliminar un tipo de documento (soft delete, marca como inactivo)"""
result = db.execute(
select(DocumentTypeDigitization).where(DocumentTypeDigitization.id == document_type_id)
)
document_type = result.scalar_one_or_none()
if not document_type:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tipo de documento con ID {document_type_id} no encontrado"
)
# Soft delete - solo marcar como inactivo
document_type.active = False
db.commit()
return None

View File

@@ -0,0 +1,3 @@
"""
Depreciation Catalog Module
"""

View File

@@ -0,0 +1,38 @@
"""
Depreciation Catalog DTOs
"""
from pydantic import BaseModel, Field, ConfigDict
from datetime import datetime
class DepreciationCatalogCreate(BaseModel):
"""DTO for creating a depreciation catalog entry"""
fraction: str = Field(..., max_length=10)
description: str = Field(..., max_length=500)
depreciation_rate: float = Field(..., ge=0, le=100)
model_config = ConfigDict(from_attributes=True)
class DepreciationCatalogUpdate(BaseModel):
"""DTO for updating a depreciation catalog entry"""
fraction: str | None = Field(None, max_length=10)
description: str | None = Field(None, max_length=500)
depreciation_rate: float | None = Field(None, ge=0, le=100)
model_config = ConfigDict(from_attributes=True)
class DepreciationCatalogResponse(BaseModel):
"""DTO for depreciation catalog response"""
id: int
tenant_id: int
company_id: int
fraction: str
description: str
depreciation_rate: float
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,25 @@
"""
Depreciation Catalog Model
"""
from sqlalchemy import Column, Integer, String, Numeric, ForeignKey, PrimaryKeyConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class DepreciationCatalog(Base, TenantScopedMixin, TimestampMixin):
"""Depreciation Catalog Model"""
__tablename__ = 'depreciation_catalog'
__table_args__ = (
PrimaryKeyConstraint("id", name="depreciation_catalog_pkey"),
{'schema': 'a76'}
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# Fields
fraction: Mapped[str] = mapped_column(String(10), nullable=False, index=True)
description: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
depreciation_rate: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False)

View File

@@ -0,0 +1,67 @@
"""
Depreciation Catalog Routes
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from typing import Any, Dict, Optional
from core.database import get_core_db
from core.security import get_current_user
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes, validate_access_to_resource
from api.v1.modules.a76.general_catalogs.depreciation_catalog.service import DepreciationCatalogService
from api.v1.modules.a76.general_catalogs.depreciation_catalog.dto import (
DepreciationCatalogCreate,
DepreciationCatalogUpdate,
DepreciationCatalogResponse
)
# Create base router with generic CRUD routes (disabled list because we'll create a custom one)
base_router = TenantCRUDRoutes(
service=DepreciationCatalogService,
create_schema=DepreciationCatalogCreate,
update_schema=DepreciationCatalogUpdate,
response_schema=DepreciationCatalogResponse,
prefix="/depreciation-catalog",
tags=["a76 / general catalogs / depreciation catalog"],
resource_name="DepreciationCatalog",
id_name="depreciation_catalog_id",
enable_list=False, # Disable default list, we'll add custom one
enable_filters=False,
default_page_size=100,
max_page_size=1000,
)
router = APIRouter(prefix="/depreciation-catalog", tags=["a76 / general catalogs / depreciation catalog"])
# Custom list endpoint with search filter
@router.get(
"/",
response_model=Dict[str, Any],
summary="List Depreciation Catalog",
description="Get paginated list of Depreciation Catalog entries with optional search filter",
)
async def list_depreciation_catalog(
company_id: int = Query(..., description="Company ID"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(100, ge=1, le=1000, description="Page size"),
search: Optional[str] = Query(None, description="Search in fraction or description"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
items, total = DepreciationCatalogService.get_all(
db, tenant_id, company_id, page, page_size, search
)
return {
"items": [DepreciationCatalogResponse.model_validate(item) for item in items],
"total": total,
"page": page,
"page_size": page_size,
"pages": (total + page_size - 1) // page_size,
}
# Include other CRUD routes from base router
router.include_router(base_router.router)

View File

@@ -0,0 +1,34 @@
"""
Datos semilla para Catálogo de Depreciación
Basado en artículo 34 de la Ley del Impuesto Sobre la Renta
"""
from decimal import Decimal
# Formato: (fraction, description, depreciation_rate)
seed = [
("I", "a) Para inmuebles declarados como monumentos arqueológicos, artísticos, históricos o patrimoniales, conforme a la Ley Federal sobre Monumentos y Zonas Arqueológicos, Artísticos e Históricos, que cuenten con el certificado de restauración expedido por el Instituto Nacional de Antropología e Historia o el Instituto Nacional de Bellas Artes.", Decimal("10.00")),
("I", "b) En los demás casos.", Decimal("5.00")),
("II", "a) Para bombas de suministro de combustible a trenes.", Decimal("3.00")),
("II", "b) Para vías férreas.", Decimal("5.00")),
("II", "c) Para carros de ferrocarril, locomotoras, armones y autoarmones.", Decimal("6.00")),
("II", "d) Para maquinaria niveladora de vías, desclavadoras, esmeriles para vías, gatos de motor para levantar la vía, removedora, insertadora y taladradora de durmientes.", Decimal("7.00")),
("II", "e) Para el equipo de comunicación, señalización y telemando.", Decimal("10.00")),
("III", "Para mobiliario y equipo de oficina.", Decimal("10.00")),
("IV", "Para embarcaciones.", Decimal("6.00")),
("IX", "Para semovientes y vegetales.", Decimal("100.00")),
("V", "a) Para los dedicados a la aerofumigación agrícola.", Decimal("25.00")),
("V", "b) Para los demás.", Decimal("10.00")),
("VI", "Para automóviles, autobuses, camiones de carga, tractocamiones, montacargas y remolques.", Decimal("25.00")),
("VII", "Para computadoras personales de escritorio y portátiles; servidores; impresoras, lectores ópticos, graficadores, lectores de código de barras, digitalizadores, unidades de almacenamiento externo y concentradores de redes de cómputo.", Decimal("30.00")),
("VIII", "Para dados, troqueles, moldes, matrices y herramental.", Decimal("35.00")),
("X", "a) Para torres de transmisión y cables, excepto los de fibra óptica.", Decimal("5.00")),
("X", "b) Para sistemas de radio, incluyendo equipo de transmisión y manejo que utiliza el espectro radioeléctrico, tales como el de radiotransmisión de microonda digital o analógica, torres de microondas y guías de onda.", Decimal("8.00")),
("X", "c) Para equipo utilizado en la transmisión, tales como circuitos de la planta interna que no forman parte de la conmutación y cuyas funciones se enfocan hacia las troncales que llegan a la central telefónica, incluye multiplexores, equipos concentradores y ruteadores.", Decimal("10.00")),
("X", "d) Para equipo de la central telefónica destinado a la conmutación de llamadas de tecnología distinta a la electromecánica.", Decimal("25.00")),
("X", "e) Para los demás.", Decimal("10.00")),
("XI", "a) Para el segmento satelital en el espacio, incluyendo el cuerpo principal del satélite, los transpondedores, las antenas para la transmisión y recepción de comunicaciones digitales y análogas, y el equipo de monitoreo en el satélite.", Decimal("8.00")),
("XI", "b) Para el equipo satelital en tierra, incluyendo las antenas para la transmisión y recepción de comunicaciones digitales y análogas y el equipo para el monitoreo del satélite.", Decimal("10.00")),
("XII", "Para adaptaciones que se realicen a instalaciones que impliquen adiciones o mejoras al activo fijo, siempre que dichas adaptaciones tengan como finalidad facilitar a las personas con discapacidad a que se refiere el artículo 186 de esta Ley, el acceso y uso de las instalaciones del contribuyente.", Decimal("100.00")),
("XIII", "Para maquinaria y equipo para la generación de energía proveniente de fuentes renovables o de sistemas de cogeneración de electricidad eficiente.", Decimal("100.00")),
]

View File

@@ -0,0 +1,91 @@
"""
Depreciation Catalog Service
"""
from sqlalchemy.orm import Session
from sqlalchemy import or_, func
from api.v1.modules.a76.general_catalogs.depreciation_catalog.models import DepreciationCatalog
class DepreciationCatalogService:
"""Service for depreciation catalog operations"""
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: int,
page: int = 1,
page_size: int = 100,
search: str | None = None
):
"""Get all depreciation catalog entries with optional search"""
query = db.query(DepreciationCatalog).filter(
DepreciationCatalog.tenant_id == tenant_id,
DepreciationCatalog.company_id == company_id
)
# Apply search filter if provided
if search:
search_filter = or_(
DepreciationCatalog.fraction.ilike(f"%{search}%"),
DepreciationCatalog.description.ilike(f"%{search}%"),
func.cast(DepreciationCatalog.depreciation_rate, db.String).ilike(f"%{search}%")
)
query = query.filter(search_filter)
# Get total count before pagination
total = query.count()
# Apply pagination
offset = (page - 1) * page_size
items = query.order_by(
DepreciationCatalog.fraction,
DepreciationCatalog.depreciation_rate
).offset(offset).limit(page_size).all()
return items, total
@staticmethod
def get_by_id(db: Session, id: int, tenant_id: int, company_id: int):
"""Get depreciation catalog entry by ID"""
return db.query(DepreciationCatalog).filter(
DepreciationCatalog.id == id,
DepreciationCatalog.tenant_id == tenant_id,
DepreciationCatalog.company_id == company_id
).first()
@staticmethod
def create(db: Session, data: dict):
"""Create new depreciation catalog entry"""
entry = DepreciationCatalog(**data)
db.add(entry)
db.commit()
db.refresh(entry)
return entry
@staticmethod
def update(db: Session, id: int, tenant_id: int, company_id: int, data: dict):
"""Update depreciation catalog entry"""
entry = DepreciationCatalogService.get_by_id(db, id, tenant_id, company_id)
if not entry:
return None
for key, value in data.items():
if value is not None:
setattr(entry, key, value)
db.commit()
db.refresh(entry)
return entry
@staticmethod
def delete(db: Session, id: int, tenant_id: int, company_id: int):
"""Delete depreciation catalog entry"""
entry = DepreciationCatalogService.get_by_id(db, id, tenant_id, company_id)
if not entry:
return False
db.delete(entry)
db.commit()
return True

View File

@@ -1,10 +1,18 @@
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from typing import Any, Dict, Optional
from fastapi import Depends, Query
from sqlalchemy.orm import Session
from api.v1.common.tenant_crud_routes import (
TenantCRUDRoutes,
validate_access_to_resource,
get_core_db,
get_current_user,
)
from .dto import ExchangeRateCreateDTO, ExchangeRateResponseDTO, ExchangeRateUpdateDTO
from .services import ExchangeRateService
# Create router using TenantCRUDRoutes factory
router = TenantCRUDRoutes(
route_handler = TenantCRUDRoutes(
service=ExchangeRateService,
create_schema=ExchangeRateCreateDTO,
update_schema=ExchangeRateUpdateDTO,
@@ -13,8 +21,48 @@ router = TenantCRUDRoutes(
tags=[],
resource_name="Exchange Rate",
id_name="id", # Using numeric ID
enable_list=True, # Enable GET /exchange-rate with pagination
enable_list=False, # Disable default list to provide custom one with filters
enable_filters=False,
default_page_size=50,
max_page_size=100,
).router
)
router = route_handler.router
@router.get(
"/",
response_model=Dict[str, Any],
summary="List Exchange Rates",
description="Get paginated list of exchange rates with optional date filter",
)
async def list_exchange_rates(
company_id: int = Query(..., description="Company ID"),
date: Optional[str] = Query(None, description="Filter by date (YYYY-MM-DD)"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(
50,
ge=1,
le=100,
description="Page size",
),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
skip = (page - 1) * page_size
filters = {}
if date:
filters["date"] = date
items, total = ExchangeRateService.get_all(
db, tenant_id, company_id, skip, page_size, filters
)
return {
"items": [ExchangeRateResponseDTO.model_validate(item) for item in items],
"total": total,
"page": page,
"page_size": page_size,
}

View File

@@ -1,6 +1,8 @@
from typing import Optional, Tuple, List, Dict, Any
from datetime import datetime, time
from sqlalchemy.orm import Session
from sqlalchemy import cast, Date
from . import dto, models
@@ -26,8 +28,23 @@ class ExchangeRateService:
# Apply filters if provided
if filters:
if filters.get("date"):
query = query.filter(
models.ExchangeRate.date == filters["date"])
# Use range query to utilize index on (tenant_id, company_id, date) efficiently
# filters["date"] is expected to be 'YYYY-MM-DD'
try:
date_str = filters["date"]
date_val = datetime.strptime(date_str, "%Y-%m-%d").date()
start_date = datetime.combine(date_val, time.min)
end_date = datetime.combine(date_val, time.max)
query = query.filter(
models.ExchangeRate.date >= start_date,
models.ExchangeRate.date <= end_date,
)
except (ValueError, TypeError):
# Fallback to cast if date format is invalid or logic fails, though validation should catch this
query = query.filter(
cast(models.ExchangeRate.date, Date) == filters["date"]
)
if filters.get("local_currency"):
query = query.filter(
models.ExchangeRate.local_currency == filters["local_currency"]
@@ -38,8 +55,12 @@ class ExchangeRateService:
)
total = query.count()
exchange_rates = query.order_by(
models.ExchangeRate.date.desc()).offset(skip).limit(limit).all()
exchange_rates = (
query.order_by(models.ExchangeRate.date.desc())
.offset(skip)
.limit(limit)
.all()
)
return exchange_rates, total
@@ -67,7 +88,9 @@ class ExchangeRateService:
) -> models.ExchangeRate:
"""Create a new exchange rate"""
new_exchange_rate = models.ExchangeRate(
**exchange_rate_data.model_dump(), tenant_id=tenant_id, company_id=company_id
**exchange_rate_data.model_dump(),
tenant_id=tenant_id,
company_id=company_id
)
db.add(new_exchange_rate)
db.commit()

View File

@@ -0,0 +1,3 @@
"""
__init__.py para módulo de catálogo FDA
"""

View File

@@ -0,0 +1,59 @@
"""
DTOs para el catálogo de claves FDA
"""
from pydantic import BaseModel, Field
from typing import Optional
class FDACatalogCreate(BaseModel):
"""DTO para crear una entrada en el catálogo FDA"""
fda_key: str = Field(..., max_length=20, description="Clave FDA")
description: str = Field(..., max_length=500, description="Descripción")
fda_code: Optional[str] = Field(None, max_length=50, description="Código FDA")
requirements: Optional[str] = Field(None, max_length=500, description="Requerimientos")
manufacturer_number: Optional[str] = Field(None, max_length=50, description="Número de fabricante")
country_of_production: Optional[str] = Field(None, max_length=100, description="País de producción")
storage_status: Optional[str] = Field(None, max_length=100, description="Estatus de almacenaje")
warehouse_code: Optional[str] = Field(None, max_length=20, description="Código de almacén")
call_atl: Optional[str] = Field(None, max_length=20, description="CallAtl")
model_config = {
"from_attributes": True
}
class FDACatalogUpdate(BaseModel):
"""DTO para actualizar una entrada en el catálogo FDA"""
fda_key: Optional[str] = Field(None, max_length=20, description="Clave FDA")
description: Optional[str] = Field(None, max_length=500, description="Descripción")
fda_code: Optional[str] = Field(None, max_length=50, description="Código FDA")
requirements: Optional[str] = Field(None, max_length=500, description="Requerimientos")
manufacturer_number: Optional[str] = Field(None, max_length=50, description="Número de fabricante")
country_of_production: Optional[str] = Field(None, max_length=100, description="País de producción")
storage_status: Optional[str] = Field(None, max_length=100, description="Estatus de almacenaje")
warehouse_code: Optional[str] = Field(None, max_length=20, description="Código de almacén")
call_atl: Optional[str] = Field(None, max_length=20, description="CallAtl")
model_config = {
"from_attributes": True
}
class FDACatalogResponse(BaseModel):
"""DTO para respuesta del catálogo FDA"""
id: int
fda_key: str
description: str
fda_code: Optional[str]
requirements: Optional[str]
manufacturer_number: Optional[str]
country_of_production: Optional[str]
storage_status: Optional[str]
warehouse_code: Optional[str]
call_atl: Optional[str]
created_at: Optional[str]
updated_at: Optional[str]
model_config = {
"from_attributes": True
}

View File

@@ -0,0 +1,29 @@
"""
Modelo para el catálogo de claves FDA
"""
from typing import Optional
from sqlalchemy import String, Integer, UniqueConstraint, PrimaryKeyConstraint
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
class FDACatalog(Base, TenantScopedMixin, TimestampMixin):
"""Catálogo de claves FDA"""
__tablename__ = "fda_catalog"
__table_args__ = (
PrimaryKeyConstraint("id", name="fda_catalog_pkey"),
UniqueConstraint('tenant_id', 'company_id', 'fda_key', name='idx_fda_catalog_unique'),
{'schema': 'a76'}
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
fda_key: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
description: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
fda_code: Mapped[Optional[str]] = mapped_column(String(50))
requirements: Mapped[Optional[str]] = mapped_column(String(500))
manufacturer_number: Mapped[Optional[str]] = mapped_column(String(50))
country_of_production: Mapped[Optional[str]] = mapped_column(String(100))
storage_status: Mapped[Optional[str]] = mapped_column(String(100))
warehouse_code: Mapped[Optional[str]] = mapped_column(String(20))
call_atl: Mapped[Optional[str]] = mapped_column(String(20))

View File

@@ -0,0 +1,69 @@
"""
Rutas para el catálogo de claves FDA
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import Any, Dict, Optional
from api.v1.modules.a76.general_catalogs.fda_catalog.service import FDACatalogService
from api.v1.modules.a76.general_catalogs.fda_catalog.dto import FDACatalogResponse
from core.database import get_core_db
from core.security import get_current_user
from api.v1.common.tenant_crud_routes import validate_access_to_resource
router = APIRouter(
prefix="/fda-catalog",
tags=["a76 / general catalogs / fda catalog"]
)
@router.get("/")
async def list_fda_catalog(
company_id: int = Query(..., description="Company ID"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(50, ge=1, le=1000, description="Page size"),
search: Optional[str] = Query(None, description="Search in FDA key, description or code"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Listar entradas del catálogo FDA con búsqueda y paginación"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
filters = {"search": search} if search else {}
result = FDACatalogService.get_all(db, tenant_id, company_id, page, page_size, filters)
return {
"items": result["items"],
"total": result["total"],
"page": result["page"],
"page_size": result["page_size"],
"pages": result["pages"]
}
@router.get("/{id}")
async def get_fda_catalog(
id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Obtener una entrada del catálogo FDA por ID"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
entry = FDACatalogService.get_by_id(db, tenant_id, company_id, id)
if not entry:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Entrada no encontrada")
return {
"id": entry.id,
"fda_key": entry.fda_key,
"description": entry.description,
"fda_code": entry.fda_code,
"requirements": entry.requirements,
"manufacturer_number": entry.manufacturer_number,
"country_of_production": entry.country_of_production,
"storage_status": entry.storage_status,
"warehouse_code": entry.warehouse_code,
"call_atl": entry.call_atl
}

View File

@@ -0,0 +1,15 @@
"""
Datos semilla para Catálogo FDA
Catálogo de clasificación de la Food and Drug Administration (FDA)
para importaciones/exportaciones de productos regulados.
"""
seed = [
# (fda_key, description, fda_code, requirements, manufacturer_number, country_of_production)
("3012", "Dispositivos médicos clase I", "MED-I", "Registro FDA", "MFR001", "US"),
("3013", "Dispositivos médicos clase II", "MED-II", "Registro FDA + 510(k)", "MFR002", "US"),
("3014", "Suplementos alimenticios", "SUP-01", "Registro Establecimiento", "MFR003", "MX"),
("3015", "Medicamentos de venta libre", "OTC-01", "Monografía FDA", "MFR004", "US"),
("3016", "Cosméticos", "COS-01", "Registro Voluntario", "MFR005", "MX"),
]

View File

@@ -0,0 +1,108 @@
"""
Servicio para el catálogo de claves FDA
"""
from sqlalchemy import select
from sqlalchemy.orm import Session
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDACatalog
from api.v1.modules.a76.general_catalogs.fda_catalog.dto import FDACatalogCreate, FDACatalogUpdate
class FDACatalogService:
"""Servicio CRUD para catálogo de FDA"""
@staticmethod
def get_all(db: Session, tenant_id: int, company_id: int, page: int = 1, page_size: int = 50, filters: dict = None):
"""Obtener todas las entradas del catálogo FDA con paginación y búsqueda"""
if filters is None:
filters = {}
query = select(FDACatalog).where(
(FDACatalog.tenant_id == tenant_id) &
(FDACatalog.company_id == company_id)
)
# Búsqueda multi-campo
search = filters.get('search', '').strip()
if search:
search_pattern = f"%{search}%"
query = query.where(
(FDACatalog.fda_key.ilike(search_pattern)) |
(FDACatalog.description.ilike(search_pattern)) |
(FDACatalog.fda_code.ilike(search_pattern))
)
# Contar total
total_query = select(FDACatalog).where(
(FDACatalog.tenant_id == tenant_id) &
(FDACatalog.company_id == company_id)
)
if search:
total_query = total_query.where(
(FDACatalog.fda_key.ilike(search_pattern)) |
(FDACatalog.description.ilike(search_pattern)) |
(FDACatalog.fda_code.ilike(search_pattern))
)
total = db.execute(select(FDACatalog).distinct()).scalars().all().__len__()
# Paginación
offset = (page - 1) * page_size
query = query.offset(offset).limit(page_size)
items = db.execute(query).scalars().all()
return {
"items": items,
"total": len(items),
"page": page,
"page_size": page_size,
"pages": (len(items) + page_size - 1) // page_size
}
@staticmethod
def get_by_id(db: Session, tenant_id: int, company_id: int, id: int):
"""Obtener una entrada por ID"""
return db.execute(
select(FDACatalog).where(
(FDACatalog.id == id) &
(FDACatalog.tenant_id == tenant_id) &
(FDACatalog.company_id == company_id)
)
).scalar_one_or_none()
@staticmethod
def create(db: Session, tenant_id: int, company_id: int, data: FDACatalogCreate):
"""Crear una nueva entrada"""
entry = FDACatalog(
tenant_id=tenant_id,
company_id=company_id,
**data.model_dump()
)
db.add(entry)
db.commit()
db.refresh(entry)
return entry
@staticmethod
def update(db: Session, tenant_id: int, company_id: int, id: int, data: FDACatalogUpdate):
"""Actualizar una entrada"""
entry = FDACatalogService.get_by_id(db, tenant_id, company_id, id)
if not entry:
return None
for field, value in data.model_dump(exclude_unset=True).items():
setattr(entry, field, value)
db.commit()
db.refresh(entry)
return entry
@staticmethod
def delete(db: Session, tenant_id: int, company_id: int, id: int):
"""Eliminar una entrada"""
entry = FDACatalogService.get_by_id(db, tenant_id, company_id, id)
if not entry:
return False
db.delete(entry)
db.commit()
return True

View File

@@ -0,0 +1,8 @@
"""
Módulo de fracciones arancelarias (Tariff Fractions)
"""
from .models import TariffFraction
from .routes import router
__all__ = ["TariffFraction", "router"]

View File

@@ -0,0 +1,97 @@
"""
DTOs (Data Transfer Objects) para fracciones arancelarias
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class TariffFractionCreateDTO(BaseModel):
"""DTO para crear una fracción arancelaria"""
code: str = Field(..., max_length=10, description="Código completo de la fracción")
fraction: str = Field(..., max_length=15, description="Fracción formateada")
description: Optional[str] = Field(None, max_length=1000, description="Descripción")
nico: Optional[str] = Field(None, max_length=10, description="Código NICO")
umt: Optional[str] = Field(None, max_length=10, description="Unidad de medida de tarifa")
adv_impo: Optional[str] = Field(None, max_length=20, description="Ad valorem importación")
adv_expo: Optional[str] = Field(None, max_length=20, description="Ad valorem exportación")
class Config:
from_attributes = True
class TariffFractionUpdateDTO(BaseModel):
"""DTO para actualizar una fracción arancelaria"""
fraction: Optional[str] = Field(None, max_length=15, description="Fracción formateada")
description: Optional[str] = Field(None, max_length=1000, description="Descripción")
nico: Optional[str] = Field(None, max_length=10, description="Código NICO")
umt: Optional[str] = Field(None, max_length=10, description="Unidad de medida de tarifa")
adv_impo: Optional[str] = Field(None, max_length=20, description="Ad valorem importación")
adv_expo: Optional[str] = Field(None, max_length=20, description="Ad valorem exportación")
class Config:
from_attributes = True
class TariffFractionResponseDTO(BaseModel):
"""DTO para respuesta de fracción arancelaria"""
id: int
tenant_id: int
company_id: int
code: str
fraction: str
description: Optional[str] = None
nico: Optional[str] = None
umt: Optional[str] = None
adv_impo: Optional[str] = None
adv_expo: Optional[str] = None
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class TariffFractionBasicDTO(BaseModel):
"""DTO para información básica de fracción arancelaria"""
code: str
fraction: str
description: Optional[str] = None
nico: Optional[str] = None
umt: Optional[str] = None
adv_impo: Optional[str] = None
adv_expo: Optional[str] = None
class Config:
from_attributes = True
class TariffFractionListDTO(BaseModel):
"""DTO para lista de fracciones arancelarias"""
items: list[TariffFractionBasicDTO]
total: int
page: int
page_size: int
pages: int
class Config:
from_attributes = True
class TariffFractionSearchDTO(BaseModel):
"""DTO para búsqueda de fracciones arancelarias"""
code: Optional[str] = Field(None, description="Buscar por código")
fraction: Optional[str] = Field(None, description="Buscar por fracción")
description: Optional[str] = Field(None, description="Buscar en descripción")
nico: Optional[str] = Field(None, description="Filtrar por NICO")
umt: Optional[str] = Field(None, description="Filtrar por UMT")
class Config:
from_attributes = True

View File

@@ -0,0 +1,49 @@
"""
Modelos ORM para fracciones arancelarias (SITAR-SCAII)
"""
from typing import Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import Integer, PrimaryKeyConstraint, String, Numeric
from sqlalchemy.orm import Mapped, mapped_column
class TariffFraction(Base, TenantScopedMixin, TimestampMixin):
"""
Modelo para fracciones arancelarias mexicanas (SITAR-SCAII)
Corresponde a la tabla sFracciones
"""
__tablename__ = "tariff_fractions"
__table_args__ = (
PrimaryKeyConstraint("id", name="tariff_fractions_pkey"),
{"schema": "a76", "extend_existing": True},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# Código completo de la fracción (ej: 01012101)
code: Mapped[str] = mapped_column(String(10), unique=True, index=True)
# Fracción formateada (ej: 0101.21.01)
fraction: Mapped[str] = mapped_column(String(15), index=True)
# Descripción de la fracción
description: Mapped[Optional[str]] = mapped_column(String(1000))
# Código NICO
nico: Mapped[Optional[str]] = mapped_column(String(10))
# Unidad de medida de tarifa (UMT)
umt: Mapped[Optional[str]] = mapped_column(String(10))
# Ad valorem de importación
adv_impo: Mapped[Optional[str]] = mapped_column(String(20))
# Ad valorem de exportación
adv_expo: Mapped[Optional[str]] = mapped_column(String(20))
def __repr__(self) -> str:
return f"<TariffFraction(code='{self.code}', fraction='{self.fraction}', description='{self.description[:50]}...')>"

View File

@@ -0,0 +1,74 @@
"""
Endpoints API para fracciones arancelarias
"""
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes, validate_access_to_resource
from .dto import (
TariffFractionCreateDTO,
TariffFractionResponseDTO,
TariffFractionUpdateDTO,
)
from .service import TariffFractionService
# Create base router with generic CRUD routes (disabled list because we'll create a custom one)
base_router = TenantCRUDRoutes(
service=TariffFractionService,
create_schema=TariffFractionCreateDTO,
update_schema=TariffFractionUpdateDTO,
response_schema=TariffFractionResponseDTO,
prefix="/tariff-fractions",
tags=["a76 / general catalogs / tariff fractions"],
resource_name="TariffFraction",
id_name="tariff_fraction_id",
enable_list=False, # Disable default list, we'll add custom one
enable_filters=False,
default_page_size=50,
max_page_size=10000,
)
router = APIRouter(prefix="/tariff-fractions", tags=["a76 / general catalogs / tariff fractions"])
# Custom list endpoint with search filter
@router.get(
"/",
response_model=Dict[str, Any],
summary="List Tariff Fractions",
description="Get paginated list of Tariff Fractions with optional search filter",
)
async def list_tariff_fractions(
company_id: int = Query(..., description="Company ID"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(50, ge=1, le=10000, description="Page size"),
search: Optional[str] = Query(None, description="Search in code, fraction, description, nico, or umt"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
skip = (page - 1) * page_size
filters = {}
if search:
filters["search"] = search
items, total = TariffFractionService.get_all(
db, tenant_id, company_id, skip, page_size, filters
)
return {
"items": [TariffFractionResponseDTO.model_validate(item) for item in items],
"total": total,
"page": page,
"page_size": page_size,
"pages": (total + page_size - 1) // page_size,
}
# Include other CRUD routes from base router
router.include_router(base_router.router)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,190 @@
"""
Service para fracciones arancelarias
"""
from typing import List, Optional, Tuple, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
import logging
from .models import TariffFraction
from .dto import TariffFractionCreateDTO, TariffFractionUpdateDTO
logger = logging.getLogger(__name__)
class TariffFractionService:
"""Service para gestionar fracciones arancelarias"""
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[TariffFraction], int]:
"""Obtiene todas las fracciones arancelarias con filtros opcionales"""
query = db.query(TariffFraction).filter(
TariffFraction.tenant_id == tenant_id,
TariffFraction.company_id == company_id,
)
# Aplicar filtros
if filters:
# Si hay un filtro 'search', buscar en múltiples campos
if filters.get("search"):
search_term = f"%{filters['search']}%"
query = query.filter(
TariffFraction.code.ilike(search_term) |
TariffFraction.fraction.ilike(search_term) |
TariffFraction.description.ilike(search_term) |
TariffFraction.nico.ilike(search_term) |
TariffFraction.umt.ilike(search_term)
)
else:
# Filtros individuales
if filters.get("code"):
query = query.filter(TariffFraction.code.ilike(f"%{filters['code']}%"))
if filters.get("fraction"):
query = query.filter(TariffFraction.fraction.ilike(f"%{filters['fraction']}%"))
if filters.get("description"):
query = query.filter(TariffFraction.description.ilike(f"%{filters['description']}%"))
if filters.get("nico"):
query = query.filter(TariffFraction.nico.ilike(f"%{filters['nico']}%"))
if filters.get("umt"):
query = query.filter(TariffFraction.umt.ilike(f"%{filters['umt']}%"))
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
@staticmethod
def get_by_id(
db: Session,
tariff_fraction_id: int,
tenant_id: int,
company_id: int,
) -> Optional[TariffFraction]:
"""Obtiene una fracción arancelaria por ID"""
return (
db.query(TariffFraction)
.filter(
TariffFraction.id == tariff_fraction_id,
TariffFraction.tenant_id == tenant_id,
TariffFraction.company_id == company_id,
)
.first()
)
@staticmethod
def get_by_code(
db: Session,
code: str,
tenant_id: int,
company_id: int,
) -> Optional[TariffFraction]:
"""Obtiene una fracción arancelaria por código"""
return (
db.query(TariffFraction)
.filter(
TariffFraction.code == code,
TariffFraction.tenant_id == tenant_id,
TariffFraction.company_id == company_id,
)
.first()
)
@staticmethod
def create(
db: Session,
tariff_fraction_data: TariffFractionCreateDTO,
tenant_id: int,
company_id: int,
) -> TariffFraction:
"""Crea una nueva fracción arancelaria"""
try:
tariff_fraction = TariffFraction(
**tariff_fraction_data.model_dump(),
tenant_id=tenant_id,
company_id=company_id,
)
db.add(tariff_fraction)
db.commit()
db.refresh(tariff_fraction)
return tariff_fraction
except IntegrityError as e:
db.rollback()
logger.error(f"Error creating tariff fraction: {e}")
raise HTTPException(
status_code=400,
detail="Tariff fraction with this code already exists",
)
@staticmethod
def update(
db: Session,
tariff_fraction_id: int,
tariff_fraction_data: TariffFractionUpdateDTO,
tenant_id: int,
company_id: int,
) -> Optional[TariffFraction]:
"""Actualiza una fracción arancelaria existente"""
tariff_fraction = TariffFractionService.get_by_id(
db, tariff_fraction_id, tenant_id, company_id
)
if not tariff_fraction:
return None
try:
update_data = tariff_fraction_data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(tariff_fraction, key, value)
db.commit()
db.refresh(tariff_fraction)
return tariff_fraction
except IntegrityError as e:
db.rollback()
logger.error(f"Error updating tariff fraction: {e}")
raise HTTPException(
status_code=400,
detail="Error updating tariff fraction",
)
@staticmethod
def delete(
db: Session,
tariff_fraction_id: int,
tenant_id: int,
company_id: int,
) -> bool:
"""Elimina una fracción arancelaria"""
tariff_fraction = TariffFractionService.get_by_id(
db, tariff_fraction_id, tenant_id, company_id
)
if not tariff_fraction:
return False
try:
db.delete(tariff_fraction)
db.commit()
return True
except IntegrityError as e:
db.rollback()
logger.error(f"Error deleting tariff fraction: {e}")
raise HTTPException(
status_code=400,
detail="Cannot delete tariff fraction - may be in use",
)

View File

@@ -0,0 +1,49 @@
"""
Datos semilla para Unidades de Medida Comerciales
Migrado desde frontend para centralizar en backend
"""
# Formato: (code, description, description_en, customs_code, american_code, ace_code, oma_code)
seed = [
('BARR', 'BARRIL', 'BARREL', '8', 'BBL', '', 'BLL'),
('BD FT', 'PIE TABLA', 'BD FEET', '5', 'FT', '', 'BFT'),
('BOLS', 'BOLSA', 'BAG', '6', 'PCS', '', 'BG'),
('BTL', 'BOTELLA', 'BOTTLE', '21', 'PCS', '', 'BO'),
('BULT', 'BULTO', 'BULK', '6', 'PCS', '', 'VQ'),
('CAJA', 'CAJA', 'BOX', '20', '', '', 'BX'),
('CARAT', 'CARAT', 'CARAT', '22', '', '', 'HE'),
('CBZA', 'CABEZA', 'HEAD', '7', 'PCS', '', 'Z4'),
('CIEN', 'CIENTO', 'CIEN', '18', '', '', 'CEN'),
('CM', 'CENTIMETRO', 'CM', '3', 'CM', '', 'CMT'),
('CM2', 'CENTIMETRO CUADRADO', 'CM2', '4', 'CM2', '', 'CMK'),
('DEC', 'DECENA', '', '17', '', '', 'DC'),
('DM', 'DECIMETRO', 'DM', '3', '', '', 'DMT'),
('DM2', 'DECIMETRO CUADRADO', 'SQ DM', '4', '', '', 'DMK'),
('DOCE', 'DOCENA', 'DOZ', '19', 'DOZ', 'DZ', 'DZN'),
('FOZ', 'ONZA LIQUIDA', 'FOZ', '8', 'FOZ', '', 'OZA'),
('FT', 'PIES', 'FT', '3', 'FT', '', 'LF'),
('FT2', 'PIE CUADRADO', 'FT2', '4', 'SFT', '', 'FTK'),
('GAL', 'GALON', 'GAL', '8', 'GAL', '', 'GLL'),
('GR', 'GRAMO', 'GRAM', '2', '', '', 'GRM'),
('IN', 'PULGADA', 'IN', '3', '', '', 'LI'),
('IN2', 'PULGADA CUADRADA', 'IN2', '4', '', '', 'INK'),
('JGO', 'JUEGO', 'SET', '12', '', '', 'SET'),
('KGS', 'KILOGRAMOS', 'KGS', '1', 'KG2', '', 'KGM'),
('LB', 'LIBRAS', 'LB', '1', '', '', 'LBR'),
('LT', 'LITRO', 'LT', '8', 'L', '', 'LTR'),
('M2', 'METRO CUADRADO', 'M2', '4', 'M2', '', 'MTK'),
('M3', 'METRO CUBICO', 'M3', '5', 'M3', '', 'MTQ'),
('MI', 'MILLA', 'MILE', '3', 'KM', '', 'SMI'),
('MILLR', 'MILLAR', 'MILLR', '11', '', '', 'MIL'),
('MT', 'METROS', 'MT', '3', 'M', '', 'MTR'),
('OZ', 'ONZA', 'OZ', '8', 'FOZ', '', 'OZ'),
('PAR', 'PAR', 'PAIR', '9', '', '', 'PB'),
('PQ', 'PAQUETE', 'PACKAGE', '6', 'PCS', '', 'PK_1'),
('PZA', 'PIEZA', 'PCS', '6', 'PCS', '', 'C62_1'),
('QGL', 'CUARTO DE GALON', 'QGL', '8', '', '', 'QT'),
('ROLL', 'ROLLO', 'ROLL', '6', '', '', 'RO'),
('TON', 'TONELADA', 'TON', '14', 'TON', '', 'TNE_1'),
('TOZ', 'ONZA TROY', 'TOZ', '1', 'TOZ', '', 'APZ'),
('YD', 'YARDA', 'YD', '3', 'YD', '', 'YRD'),
('YD2', 'YARDA CUADRADA', 'YD2', '4', 'SYD', '', 'YDK'),
]

View File

@@ -0,0 +1,8 @@
"""
US Tariff Fractions Catalog Module
"""
from .models import USTariffFraction
from .routes import router
__all__ = ["USTariffFraction", "router"]

View File

@@ -0,0 +1,48 @@
"""
DTOs para fracciones arancelarias americanas
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, ConfigDict
class USTariffFractionCreateDTO(BaseModel):
"""DTO para crear fracción arancelaria americana"""
code: str = Field(..., max_length=16, description="Código de fracción americana")
prefix: Optional[str] = Field(None, max_length=10, description="Prefijo")
type_code: Optional[str] = Field(None, max_length=10, description="Código de tipo")
ad_valorem: Optional[float] = Field(None, description="Porcentaje ad valorem")
fixed_cost: Optional[float] = Field(None, description="Tasa fija")
unit_of_measure: Optional[str] = Field(None, max_length=10, description="Unidad de medida")
description: Optional[str] = Field(None, description="Descripción")
class USTariffFractionUpdateDTO(BaseModel):
"""DTO para actualizar fracción arancelaria americana"""
prefix: Optional[str] = Field(None, max_length=10)
type_code: Optional[str] = Field(None, max_length=10)
ad_valorem: Optional[float] = None
fixed_cost: Optional[float] = None
unit_of_measure: Optional[str] = Field(None, max_length=10)
description: Optional[str] = None
class USTariffFractionResponseDTO(BaseModel):
"""DTO para respuesta de fracción arancelaria americana"""
model_config = ConfigDict(from_attributes=True)
id: int
code: str
prefix: Optional[str] = None
type_code: Optional[str] = None
ad_valorem: Optional[float] = None
fixed_cost: Optional[float] = None
unit_of_measure: Optional[str] = None
description: Optional[str] = None
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,62 @@
"""
Modelos para fracciones arancelarias americanas
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import String, Numeric, TIMESTAMP, func
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class USTariffFraction(Base):
"""Modelo para fracciones arancelarias americanas (US HTS codes)"""
__tablename__ = "us_tariff_fractions"
__table_args__ = {"schema": "a76"}
# Primary Key
id: Mapped[int] = mapped_column(primary_key=True, index=True)
# Tenant/Company
tenant_id: Mapped[int] = mapped_column(index=True, nullable=False)
company_id: Mapped[int] = mapped_column(index=True, nullable=False)
# Datos principales
code: Mapped[str] = mapped_column(
String(16), nullable=False, comment="Código de fracción americana"
)
prefix: Mapped[Optional[str]] = mapped_column(
String(10), comment="Prefijo de clasificación"
)
type_code: Mapped[Optional[str]] = mapped_column(
String(10), comment="Código de tipo"
)
ad_valorem: Mapped[Optional[float]] = mapped_column(
Numeric(10, 2), comment="Porcentaje ad valorem"
)
fixed_cost: Mapped[Optional[float]] = mapped_column(
Numeric(15, 8), comment="Tasa fija"
)
unit_of_measure: Mapped[Optional[str]] = mapped_column(
String(10), comment="Unidad de medida"
)
description: Mapped[Optional[str]] = mapped_column(
String, comment="Descripción de la fracción"
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
def __repr__(self):
return f"<USTariffFraction {self.code}>"

View File

@@ -0,0 +1,73 @@
"""
Endpoints API para fracciones arancelarias americanas
"""
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes, validate_access_to_resource
from .dto import (
USTariffFractionCreateDTO,
USTariffFractionResponseDTO,
USTariffFractionUpdateDTO,
)
from .service import USTariffFractionService
# Create base router with generic CRUD routes (disabled list because we'll create a custom one)
base_router = TenantCRUDRoutes(
service=USTariffFractionService,
create_schema=USTariffFractionCreateDTO,
update_schema=USTariffFractionUpdateDTO,
response_schema=USTariffFractionResponseDTO,
prefix="/us-tariff-fractions",
tags=["a76 / general catalogs / us tariff fractions"],
resource_name="USTariffFraction",
id_name="us_tariff_fraction_id",
enable_list=False, # Disable default list, we'll add custom one
enable_filters=False,
default_page_size=50,
max_page_size=10000,
)
router = APIRouter(prefix="/us-tariff-fractions", tags=["a76 / general catalogs / us tariff fractions"])
# Custom list endpoint with search filter
@router.get(
"/",
response_model=Dict[str, Any],
summary="List US Tariff Fractions",
description="Get paginated list of US Tariff Fractions with optional search filter",
)
async def list_us_tariff_fractions(
company_id: int = Query(..., description="Company ID"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(50, ge=1, le=10000, description="Page size"),
search: Optional[str] = Query(None, description="Search in code, description, or prefix"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
skip = (page - 1) * page_size
filters = {}
if search:
filters["search"] = search
items, total = USTariffFractionService.get_all(
db, tenant_id, company_id, skip, page_size, filters
)
return {
"items": [USTariffFractionResponseDTO.model_validate(item) for item in items],
"total": total,
"page": page,
"page_size": page_size,
"pages": (total + page_size - 1) // page_size,
}
# Include other CRUD routes from base router
router.include_router(base_router.router)

View File

@@ -0,0 +1,417 @@
"""
Datos semilla para Fracciones Arancelarias de Estados Unidos
Catálogo de fracciones HTS/Schedule B para importaciones/exportaciones con EE.UU.
Total: 406 registros
Estructura: (code, prefix, type_code, ad_valorem, fixed_cost, unit_of_measure, description)
"""
seed = [
('0902300090', '', 'PO', '0.00', '0.00000000', 'PZA', 'BOLSA DE TE')
,('2508400150', '', 'PO', '0.00', '0.00000000', '', '')
,('2520200000', '', 'PO', '0.00', '0.00000000', '', '')
,('2525200000', '', 'PO', '0.00', '0.00000000', 'KGS', 'PITMENT BASED ON TITANIUM DIOXIDE')
,('2526200000', '', 'PO', '0.00', '0.00000000', '', '')
,('2707999090', '', 'PO', '0.00', '0.00000000', '', '')
,('2710121550', '', 'PO', '0.00', '0.00000000', '', '')
,('2710129000', '', 'PO', '0.00', '0.00000000', 'LT', 'MOLD RELEASE')
,('2710129050', '', 'PO', '0.00', '0.00000000', 'LT', '')
,('2710190650', '', 'PO', '0.00', '0.00000000', 'LT', 'MOLD RELEASE')
,('2710199000', '', 'PO', '0.00', '0.00000000', '', '')
,('2712902000', '', 'PO', '0.00', '0.00000000', '', '')
,('2839905000', '', 'PO', '0.00', '0.00000000', '', '')
,('2905120050', '', 'PO', '0.00', '0.00000000', '', '')
,('2905145050', '', 'PO', '0.00', '0.00000000', '', '')
,('2909430000', '', 'PO', '0.00', '0.00000000', '', '')
,('2909496000', '', 'PO', '0.00', '0.00000000', '', '')
,('2914115000', '', 'PO', '0.00', '0.00000000', 'LT', 'BUTYL ACETATE')
,('2914120000', '', 'PO', '0.00', '0.00000000', '', '')
,('2915905050', '', 'PO', '0.00', '0.00000000', '', '')
,('2924293600', '', 'PO', '0.00', '0.00000000', '', '')
,('2929108090', '', 'PO', '0.00', '0.00000000', 'KGS', 'Urethane, Iso Side')
,('3206110000', '', 'PO', '0.00', '0.00000000', '', '')
,('3206190000', '', 'PO', '0.00', '0.00000000', '', '')
,('3206495060', '', 'PO', '0.00', '0.00000000', 'KGS', 'RESIN')
,('3206496050', '', 'PO', '0.00', '0.00000000', '', 'PIGMENTO COLORANTE')
,('3208100000', '', 'PO', '0.00', '0.00000000', '', '')
,('3208200000', '', 'PO', '0.00', '0.00000000', 'LT', 'LACA ALQUIDICA NITROCELULOSA')
,('3208900000', '', 'PO', '0.00', '0.00000000', '', 'PAINT')
,('3209100000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('3209900000', 'S', 'PO', '0.00', '0.00000000', 'LT', 'LIQUID PAINT')
,('3212900050', '', 'PO', '0.00', '0.00000000', '', '')
,('3214100020', '', 'PO', '0.00', '0.00000000', '', '')
,('3215905000', '', 'PO', '0.00', '0.00000000', '', '')
,('3402205100', '', 'PO', '0.00', '0.00000000', '', '')
,('3402905050', '', 'PO', '0.00', '0.00000000', '', '')
,('3403195000', '', 'PO', '0.00', '0.00000000', 'LT', 'Mold Release, Stoner, GL')
,('3403990000', '', 'PO', '0.00', '0.00000000', '', 'PASTE FOR POLISHING METALS')
,('3404905150', '', 'PO', '0.00', '0.00000000', '', '')
,('3405400000', '', 'PO', '0.00', '0.00000000', '', '')
,('3405900000', '', 'PO', '0.00', '0.00000000', '', '')
,('3506105000', 'US', '', '0.00', '0.00000000', 'PZA', 'ADHESIVO EN AEROSOL')
,('3506915000', '', 'PO', '0.00', '0.00000000', 'LT', 'Adhesive')
,('3506990000', '', 'PO', '0.00', '0.00000000', 'LT', 'Adhesive')
,('3802.20.00.00', '', 'PO', '0.00', '0.00000000', 'KGS', '')
,('3811900000', '', 'PO', '0.00', '0.00000000', '', '')
,('3814005090', '', 'PO', '0.00', '0.00000000', 'LT', 'THINNER')
,('3815901000', '', 'PO', '0.00', '0.00000000', '', '')
,('3815903000', '', 'PO', '0.00', '0.00000000', '', '')
,('3815905000', '', 'PO', '0.00', '0.00000000', 'PZA', 'CATALYST')
,('3820000000', '', 'PO', '0.00', '0.00000000', '', '')
,('3824910000', '', 'PO', '0.00', '0.00000000', 'LT', 'LIQUIDO PARA PAVONAR METAL A BASE DE ACIDOS')
,('3824991900', '', 'PO', '0.00', '0.00000000', '', '')
,('3824999297', '', 'PO', '0.00', '0.00000000', 'PZA', 'WELDING ANTI SPLATTER')
,('3825900000', '', 'PO', '0.00', '0.00000000', '', '')
,('3903190000', '', 'PO', '0.00', '0.00000000', 'KGS', '')
,('3903905000', '', 'PO', '0.00', '0.00000000', '', '')
,('3905300000', '', 'PO', '0.00', '0.00000000', '', '')
,('3906905050', 'US', '', '0.00', '0.00000000', 'PZA', 'POLIMERO ACRILICO')
,('3907200000', '', 'PO', '0.00', '0.00000000', 'KGS', 'POLYURETHANE BASED RESIN')
,('3907210000', '', 'PO', '0.00', '0.00000000', 'PZA', 'POLYURETHANE-BASED RESIN')
,('3907300000', 'US', '', '0.00', '0.00000000', 'KGS', 'RESINA EPOXICA A Y B')
,('3907915000', '', 'PO', '0.00', '0.00000000', 'KGS', 'resin')
,('3907995050', '', 'PO', '0.00', '0.00000000', '', '')
,('3909100000', '', 'PO', '0.00', '0.00000000', 'KGS', 'GRANULATED PLASTIC (PLASTIC RESIN)')
,('3909310000', '', 'PO', '0.00', '0.00000000', 'KGS', 'ISOCIANATE')
,('3909390000', '', 'PO', '0.00', '0.00000000', '', '')
,('3909505000', '', 'PO', '0.00', '0.00000000', 'LT', 'POLYURETHANE ELASTOMER PART A AND B')
,('3909506000', '', 'PO', '0.00', '0.00000000', 'KGS', '')
,('3909900000', '', 'PO', '0.00', '0.00000000', '', '')
,('3910000000', '', 'PO', '0.00', '0.00000000', 'LT', 'SEALER CONDITIONER PART B')
,('3911902500', '', 'PO', '0.00', '0.00000000', '', '')
,('3911909050', '', 'PO', '0.00', '0.00000000', '', '')
,('3915900090', '', 'PO', '0.00', '0.00000000', 'KGS', 'DESPERDICIO DE PLASTICO')
,('3917230000', '', 'PO', '0.00', '0.00000000', 'PZA', 'PLASTIC HOSE')
,('3917320050', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('39173299', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('3917330000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('39173399', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('3917390050', '', 'PO', '0.00', '0.00000000', 'PZA', 'PLASTIC TUBE')
,('3919101050', 'US', '', '0.00', '0.00000000', 'PZA', 'CINTA ADHESIVA')
,('3919102055', 'US', '', '0.00', '0.00000000', 'PZA', 'CINTA ADHESIVA (PLASTICA)')
,('3919905060', '', '', '0.00', '0.00000000', 'PZA', 'CINTA ADHESIVA (PLASTICO TRANSPARENTE)')
,('3920100000', '', 'PO', '0.00', '0.00000000', 'PZA', 'burbuja')
,('3920200055', '', 'PO', '0.00', '0.00000000', '', '')
,('3920515000', '', 'PO', '0.00', '0.00000000', 'PZA', 'ACRYLIC POLYMER SHEET')
,('3921135000', '', 'PO', '0.00', '0.00000000', '', '')
,('3921905050', 'S', '', '0.00', '0.00000000', 'PZA', 'PELICULA DE PLASTICO PARA FLEJAR')
,('3923109000', '', 'PO', '0.00', '0.00000000', 'PZA', 'CAJA DE PLASTICO')
,('3923210095', '', 'PO', '0.00', '0.00000000', '', '')
,('3923290000', '', 'PO', '0.00', '0.00000000', 'PZA', 'Bag')
,('3923300090', '', 'PO', '0.00', '0.00000000', '', '')
,('3923500000', '', 'PO', '0.00', '0.00000000', '', '')
,('3923900080', 'S', 'PO', '0.00', '0.00000000', 'PZA', 'Plastic container')
,('3926209050', '', 'PO', '0.00', '0.00000000', '', '')
,('3926400090', 'S', 'PO', '0.00', '0.00000000', 'PZA', '')
,('3926902100', 'US', '', '0.00', '0.00000000', 'PZA', 'PROTECTORES DE PLASTICO P/OIDO')
,('3926903500', 'S', 'PO', '0.00', '0.00000000', 'PZA', '')
,('3926909985', 'S', 'PO', '0.00', '0.00000000', 'PZA', 'PLASTIC')
,('3926909987', 'S', 'PO', '0.00', '0.00000000', '', '')
,('3926909989', 'S', 'PO', '0.00', '0.00000000', 'PZA', '')
,('3926909990', '', 'PO', '0.00', '0.00000000', 'PZA', 'PLACTIC BASE')
,('3926909995', 'S', 'PO', '0.00', '0.00000000', '', 'PLASTIC DOME')
,('3926909996', '', 'PO', '0.00', '0.00000000', 'PZA', 'Foam Plug, Hosiery Leg')
,('4015190002', 'US', '', '0.00', '0.00000000', 'PZA', 'GUANTES DE CAUCHO')
,('4016930000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('4016935050', '', 'PO', '0.00', '0.00000000', '', '')
,('4016992000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('4016993510', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('4016996000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('4016996050', '', 'PO', '0.00', '0.00000000', 'PZA', 'BALL')
,('4201003000', 'S', 'PO', '0.00', '0.00000000', 'PZA', 'Dog leashes, collars, muzzles, harnesses and similar dog equipment')
,('4201006000', '', 'PO', '0.00', '0.00000000', '', '')
,('4410190060', '', 'PO', '0.00', '0.00000000', '', '')
,('4415109000', '', 'PO', '0.00', '0.00000000', '', '')
,('4415208000', 'S', 'PO', '0.00', '0.00000000', 'PZA', 'WOOD PALLETS')
,('4417004000', '', 'PO', '0.00', '0.00000000', '', 'Paint brush and paint roller handles')
,('4421904000', '', 'PO', '5.10', '0.00000000', '', 'NECKCAP WOOD')
,('4421909750', 'US', '', '0.00', '0.00000000', 'PZA', 'PALILLO DE MADERA')
,('4421914000', '', 'PO', '0.00', '0.00000000', '', '')
,('4421994000', '', 'PO', '0.00', '0.00000000', '', '')
,('4421999880', '', 'PO', '0.00', '0.00000000', '', '')
,('4503106000', '', 'PO', '0.00', '0.00000000', '', '')
,('4802693000', '', 'PO', '0.00', '0.00000000', '', '')
,('4804394000', '', 'PO', '0.00', '0.00000000', '', 'Wrapping paper')
,('4804590000', 'S', 'PO', '0.00', '0.00000000', '', '')
,('4805400000', '', 'PO', '0.00', '0.00000000', '', '')
,('4808100000', 'S', 'PO', '0.00', '0.00000000', 'PZA', 'CORRUGATED CARTON SEPARATOR')
,('4808906000', '', 'PO', '0.00', '0.00000000', '', '')
,('4810137040', '', 'PO', '0.00', '0.00000000', 'PZA', 'ROLLO DE CABLE DE ACER')
,('4811412100', '', 'PO', '0.00', '0.00000000', '', '')
,('4817100000', '', 'PO', '0.00', '0.00000000', '', '')
,('4818200020', 'US', '', '0.00', '0.00000000', 'PZA', 'TOALLAS DESECHABLES DE PAPEL TISSUE')
,('4819100040', 'S', 'PO', '0.00', '0.00000000', 'PZA', 'BOX')
,('4819504060', '', 'PO', '0.00', '0.00000000', '', '')
,('4821104000', '', 'PO', '0.00', '0.00000000', 'PZA', 'LABEL')
,('4821904000', '', 'PO', '0.00', '0.00000000', '', '')
,('4822900000', 'S', 'PO', '0.00', '0.00000000', 'PZA', 'CARTON BOX')
,('4823700040', '', 'PO', '0.00', '0.00000000', '', '')
,('4823901000', '', 'PO', '0.00', '0.00000000', 'PZA', 'PAPER CUP')
,('4823908600', '', 'PO', '0.00', '0.00000000', 'PZA', 'BASES DE CARTON PRENSADO')
,('4823908850', 'US', '', '0.00', '0.00000000', 'PZA', 'CINTA ADHESIVA DE PAPEL')
,('4901990091', '', 'PO', '0.00', '0.00000000', 'PZA', 'MANUALES')
,('4911100080', '', 'PO', '0.00', '0.00000000', '', '')
,('4911998000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('5407619975', '', 'PO', '0.00', '0.00000000', '', '')
,('5508200000', '', 'PO', '0.00', '0.00000000', '', '')
,('5703308085', '', 'PO', '0.00', '0.00000000', 'PZA', 'PASTO SINTETICO (MUESTRAS)')
,('5806310000', '', 'PO', '0.00', '0.00000000', 'MT', 'CINTA TEXTIL')
,('5806322000', '', 'PO', '0.00', '0.00000000', 'MT', 'VELCRO')
,('59039001', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('5903903090', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('5911102000', '', 'PO', '0.00', '0.00000000', 'MT', '')
,('5911900040', '', 'PO', '0.00', '0.00000000', 'PZA', 'Cords, braids and the like of a kind used in industry as packing or lubricating material')
,('5911900080', '', 'PO', '0.00', '0.00000000', 'MT', 'COVER FORM 3/4 FEMALE FOAM LINEN')
,('6116100000', 'US', '', '0.00', '0.00000000', 'PZA', 'GUANTES DE ALGOHODON CON RECUBRIMIENTO DE NITRILIO')
,('6116920000', 'US', '', '0.00', '0.00000000', 'PZA', 'GUANTES DE ALGOHODON')
,('6116929400', '', 'PO', '0.00', '0.00000000', '', '')
,('6306192120', '', 'PO', '0.00', '0.00000000', '', '')
,('6307100000', 'US', '', '0.00', '0.00000000', 'PZA', 'FIBRA SINTETICA P/LIMPIAR')
,('6307909089', 'S', 'PO', '0.00', '0.00000000', '', 'BAG')
,('6307909889', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('6307909891', '', 'PO', '0.00', '0.00000000', 'PZA', 'bolsa de tela')
,('6307909995', 'US', '', '0.00', '0.00000000', 'PZA', 'MASCARILLA DE PROTECCION CONTRA EL POLVO')
,('6403919015', '', 'PO', '0.00', '0.00000000', '', '')
,('6403999031', '', 'PO', '10.00', '0.00000000', 'PAR', 'LEATHER SHOES')
,('6406109090', '', 'PO', '0.00', '0.00000000', 'PZA', 'HUARACHE')
,('6506106075', '', 'PO', '0.00', '0.00000000', 'PZA', 'PROTECTION HELMET')
,('6804220000', 'US', '', '0.00', '0.00000000', 'PZA', 'DISCO ABRASIVO CIRCULARES')
,('6805100000', 'US', '', '0.00', '0.00000000', 'PZA', 'DISCO ABRASIVO EN ROLLO')
,('6805100199', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('6805300000', 'US', '', '0.00', '0.00000000', 'PZA', 'LIJA CON SOPORTE DE PLASTICO CELULAR')
,('6805305000', '', 'PO', '0.00', '0.00000000', '', '')
,('7007190000', '', 'PO', '0.00', '0.00000000', 'PZA', 'TEMPERED GLASS BASE')
,('7009921000', '', 'PO', '0.00', '0.00000000', 'PZA', 'ESPEJO ENMARCADO')
,('7010905055', '', 'PO', '0.00', '0.00000000', '', '')
,('7019905050', '', 'PO', '0.00', '0.00000000', '', 'FIBRA DE VIDRIO')
,('7019905150', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('7020006000', '', 'PO', '0.00', '0.00000000', 'PZA', 'BASE DE VIDRIO')
,('7020009990', '', 'PO', '0.00', '0.00000000', '', 'GLASS BOARD')
,('7206900000', '', 'PO', '0.00', '0.00000000', 'PZA', 'TUBO')
,('7215100080', '', 'PO', '0.00', '0.00000000', '', '')
,('7215905000', '', 'PO', '0.00', '0.00000000', '', '')
,('7226928050', '', 'PO', '0.00', '0.00000000', '', '')
,('7228400000', '', 'PO', '0.00', '0.00000000', 'PZA', 'METAL BAR')
,('7228608000', '', 'PO', '0.00', '0.00000000', '', '')
,('7304598080', '', 'PO', '0.00', '0.00000000', '', '')
,('7306200000', '', 'PO', '0.00', '0.00000000', '', '')
,('7306305090', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('7306905000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('7307225000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('7307929000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('7308909590', '', 'PO', '0.00', '0.00000000', 'PZA', 'RACK')
,('7309000090', '', 'PO', '0.00', '0.00000000', 'PZA', 'TANQUE HERMETICO DE ACERO')
,('7310100050', '', 'PO', '0.00', '0.00000000', '', '')
,('7310290050', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('7312109090', '', 'PO', '0.00', '0.00000000', '', '')
,('7315827000', '', 'PO', '0.00', '0.00000000', '', 'CHAIN')
,('7317007500', '', 'PO', '0.00', '0.00000000', '', '')
,('7318130060', '', 'PO', '0.00', '0.00000000', '', 'EYE HOOK')
,('73181504', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('7318150400', '', 'PO', '0.00', '0.00000000', '', '')
,('7318152000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('7318158066', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('7318158085', '', '', '0.00', '0.00000000', 'PZA', 'TORNILLO DE ACERO CON TUERCA')
,('7318158688', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('7318159000', 'S', 'PO', '0.00', '0.00000000', 'PZA', 'TORNILLO DE ACERO MARIPOSA')
,('7318160085', '', 'PO', '0.00', '0.00000000', '', '')
,('7318190000', '', 'PO', '0.00', '0.00000000', 'PZA', 'REMACHE')
,('7318210090', '', 'PO', '0.00', '0.00000000', '', '')
,('7318220000', '', 'PO', '0.00', '0.00000000', '', '')
,('7318230000', '', 'PO', '0.00', '0.00000000', '', '')
,('7318240000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('7318290000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('7319909000', '', 'PO', '0.00', '0.00000000', '', '')
,('7320205060', '', 'PO', '0.00', '0.00000000', '', '')
,('7321811000', '', 'PO', '0.00', '0.00000000', '', '')
,('7325995000', '', 'PO', '0.00', '0.00000000', '', '')
,('7326901000', '', 'PO', '0.00', '0.00000000', '', 'HOOK')
,('7326908605', '', 'PO', '0.00', '0.00000000', '', '')
,('7326908688', 'S', 'PO', '0.00', '0.00000000', 'PZA', 'METAL INSERTS')
,('7326908695', '', 'PO', '0.00', '0.00000000', 'PZA', 'STEEL FASTENER')
,('73269099', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('7412200090', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('74122001', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('7415100000', '', 'PO', '0.00', '0.00000000', '', '')
,('7415390000', '', 'PO', '0.00', '0.00000000', '', '')
,('7419995050', '', 'PO', '0.00', '0.00000000', '', '')
,('7607196000', '', 'PO', '0.00', '0.00000000', 'PZA', 'aluminium')
,('7609000000', '', 'PO', '0.00', '0.00000000', '', '')
,('7616109090', '', 'PO', '0.00', '0.00000000', 'PZA', 'tachuela')
,('7616995090', 'S', 'PO', '0.00', '0.00000000', '', 'SOPORTE DE ALUMINIO')
,('7616995190', 'S', 'PO', '0.00', '0.00000000', 'PZA', '')
,('7618000000', '', 'PO', '0.00', '0.00000000', '', '')
,('7806008000', '', 'PO', '0.00', '0.00000000', '', '')
,('7907006000', '', 'PO', '0.00', '0.00000000', '', '')
,('8021230000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8202200060', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8203109000', '', 'PO', '0.00', '0.00000000', 'PZA', 'ESTUCHE DE LIMAS')
,('8203208000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('82032099', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8204110060', '', 'PO', '0.00', '0.00000000', '', 'WRENCH')
,('8205511500', '', 'PO', '0.00', '0.00000000', '', 'CEPILLO DE ALAMBRE')
,('8205599000', 'US', '', '0.00', '0.00000000', 'PZA', 'DESPACHADOR DE CINTA MANUAL')
,('8205700060', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8205700090', '', 'PO', '0.00', '0.00000000', '', '')
,('8205906000', '', 'PO', '0.00', '0.00000000', '', '')
,('8207502055', '', 'PO', '0.00', '0.00000000', '', 'DRILL BIT')
,('8207907585', '', 'PO', '0.00', '0.00000000', '', '')
,('8208906000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8302200000', '', 'PO', '0.00', '0.00000000', '', 'RUEDAS PARA BASE DE MANIQUI')
,('8302426000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8302498090', 'S', 'PO', '0.00', '0.00000000', 'PZA', 'Kit, Knob,Hand')
,('8302500000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8309900000', '', 'PO', '0.00', '0.00000000', 'PZA', 'TAPA METALICA')
,('8309900090', 'S', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8310000000', '', 'PO', '0.00', '0.00000000', '', '')
,('8413600090', '', 'PO', '0.00', '0.00000000', 'PZA', 'PUMPS OF MACHINE MOLDING')
,('8413919060', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8413919080', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8413919096', '', 'PO', '0.00', '0.00000000', 'PZA', 'DIAPHRAGM FOR PUMP')
,('8414.59.6595', '', 'PO', '0.00', '0.00000000', '', '')
,('8414100000', '', 'PO', '0.00', '0.00000000', 'PZA', 'VACUM PUMP')
,('8414510090', '', 'PO', '0.00', '0.00000000', '', '')
,('8414519090', '', 'PO', '0.00', '0.00000000', 'PZA', 'FAN')
,('8414596595', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8414809000', '', 'PO', '0.00', '0.00000000', 'PZA', 'DUST COLLECTOR')
,('8414909080', '', 'PO', '0.00', '0.00000000', 'PZA', 'DISPOSITIVO DE FILTRACCION PARA BOMBA NEUMATICA')
,('8419390180', '', 'PO', '0.00', '0.00000000', '', '')
,('8421210000', '', 'PO', '0.00', '0.00000000', 'PZA', 'FEEDER-DISASSEMBLY DISASSEMBLED')
,('8421230000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8421290065', '', 'PO', '0.00', '0.00000000', 'PZA', 'FILTER')
,('8421390115', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8421390190', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8421398015', '', 'PO', '0.00', '0.00000000', 'PZA', 'FILTER')
,('8421398040', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8421398090', '', 'PO', '0.00', '0.00000000', '', '')
,('8421990180', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8422309191', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8422401190', '', 'PO', '0.00', '0.00000000', '', '')
,('8422409180', '', 'PO', '0.00', '0.00000000', 'PZA', 'STRAPPING MACHINE')
,('8424209000', '', 'PO', '0.00', '0.00000000', 'PZA', 'PISTOLA AEROGRAFICA')
,('8424890000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8424900100', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8424900500', '', 'PO', '0.00', '0.00000000', '', '')
,('8424902000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8424909080', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8425110000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8427108095', '', 'PO', '0.00', '0.00000000', 'PZA', 'Pallet jack')
,('8427900000', '', 'PO', '0.00', '0.00000000', 'PZA', 'PALLET JACK')
,('8456111050', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8459290090', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8461500090', '', 'PO', '0.00', '0.00000000', 'PZA', 'BELT SAW')
,('8461508090', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8465910091', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8465930012', '', 'PO', '0.00', '0.00000000', '', '')
,('8466306085', '', 'PO', '0.00', '0.00000000', 'PZA', 'CILINDRO NEUMATICO')
,('8466925090', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8467.19.5090', '', 'PO', '0.00', '0.00000000', '', '')
,('8467111080', 'US', '', '0.00', '0.00000000', 'PZA', 'PULIDOR NEUMATICO MANUAL')
,('8467195090', '', 'PO', '0.00', '0.00000000', '', '')
,('8467210070', '', 'PO', '0.00', '0.00000000', '', 'ROTARY')
,('8467220090', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8467290090', '', 'PO', '0.00', '0.00000000', '', '')
,('8467895090', '', 'PO', '0.00', '0.00000000', 'PZA', 'Pneumatic screwdriver')
,('8467920050', '', 'PO', '0.00', '0.00000000', '', 'pulidor')
,('8467920090', 'US', '', '0.00', '0.00000000', 'PZA', 'DISCO DE URETANO P/ PULIDOR NEUMATICO')
,('8471410150', '', 'PO', '0.00', '0.00000000', '', '')
,('8471500150', '', 'PO', '0.00', '0.00000000', '', 'CPU')
,('8471608000', '', 'PO', '0.00', '0.00000000', '', 'SCANNER')
,('8471609050', '', 'PO', '0.00', '0.00000000', 'PZA', 'SCANNER')
,('8477590100', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8477800000', '', 'PO', '0.00', '0.00000000', 'PZA', 'MACHINE FOR FILLING AIR BAGS')
,('8477800100', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8477900000', '', 'PO', '0.00', '0.00000000', 'PZA', 'Presses setup the mold during blowing process')
,('8477902580', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8477908595', '', 'PO', '0.00', '0.00000000', 'PZA', 'DISC')
,('8477908695', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8479820040', '', 'PO', '0.00', '0.00000000', '', 'ROTARY MACHINE')
,('8479820080', '', 'PO', '0.00', '0.00000000', 'PZA', 'APARATO AGITADOR')
,('8479830100', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8479899199', '', 'PO', '0.00', '0.00000000', 'PZA', 'PORTABLE DOCK PLATE')
,('8479899499', '', 'PO', '0.00', '0.00000000', 'PZA', 'MAQUINA APLICADORA')
,('8479899599', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8479899797', '', 'PO', '0.00', '0.00000000', 'PZA', 'COLECTOR DE POLVO')
,('8479909496', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('84799099', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8480718045', 'S', 'PO', '0.00', '0.00000000', 'PZA', 'MOLDE')
,('8480718060', '', 'PO', '0.00', '0.00000000', 'PZA', 'TIG TORCH')
,('8480719090', '', 'PO', '0.00', '0.00000000', 'PZA', 'Semi-finished mold')
,('8481100090', '', 'PO', '0.00', '0.00000000', '', '')
,('8481200080', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('84812099', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8481400000', '', 'PO', '0.00', '0.00000000', '', '')
,('8481809020', '', 'PO', '0.00', '0.00000000', '', '')
,('8481809050', '', 'PO', '0.00', '0.00000000', 'PZA', 'NOZZLE, CONE DUT')
,('8481909085', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8483308090', '', 'PO', '0.00', '0.00000000', 'PZA', 'BUJES')
,('8484200000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8484900000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8501106080', '', 'PO', '0.00', '0.00000000', 'PZA', 'MOTOR ELECTRICO INCLUYE ACCESORIOS')
,('8504404000', '', 'PO', '0.00', '0.00000000', 'PZA', 'CONTROLADOR DE VELOCIDAD')
,('8504409580', '', 'PO', '0.00', '0.00000000', 'PZA', 'BATTERY CHARGER')
,('8505110090', '', 'PO', '0.00', '0.00000000', 'PZA', 'INSERT')
,('8505200000', '', 'PO', '0.00', '0.00000000', 'PZA', 'BARRIER')
,('8507208091', '', 'PO', '0.00', '0.00000000', '', '')
,('8512902000', 'S', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8514908000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8515390040', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8515800080', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('85160808000', '', 'PO', '0.00', '0.00000000', '', '')
,('8516808000', '', 'PO', '0.00', '0.00000000', '', '')
,('8517620010', '', 'PO', '0.00', '0.00000000', '', 'WIRELESS ACCESS POINT')
,('8523520010', '', 'PO', '0.00', '0.00000000', 'PZA', 'RF Label')
,('8525805050', '', 'PO', '0.00', '0.00000000', 'PZA', 'CAMARA DE CIRCUITO CERRADO')
,('8533408070', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8536100040', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8536490050', '', 'PO', '0.00', '0.00000000', '', '')
,('8536507000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8536908585', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8537103000', 'S', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8537109070', '', 'PO', '0.00', '0.00000000', '', 'DIGITAL PANEL CONTROL')
,('8543908885', 'S', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8544190000', '', 'PO', '0.00', '0.00000000', '', '')
,('8544429090', '', 'PO', '0.00', '0.00000000', 'PZA', 'SENSOR CABLE')
,('8547200000', '', 'PO', '0.00', '0.00000000', '', '')
,('8713100000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('8716805090', '', 'PO', '0.00', '0.00000000', 'PZA', 'UTILITY CART')
,('9018390050', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('9023000000', '', 'PO', '0.00', '0.00000000', '', '')
,('9024800000', '', 'PO', '0.00', '0.00000000', '', 'TESTING MACHINE')
,('9026102010', '', 'PO', '0.00', '0.00000000', '', 'DIGITAL VACUUM GAUGE')
,('9026106000', '', 'PO', '0.00', '0.00000000', 'PZA', 'FLOAT & GAUGE')
,('9026204000', '', 'PO', '0.00', '0.00000000', '', '')
,('9026208000', '', 'PO', '0.00', '0.00000000', 'PZA', 'REGULATOR')
,('9027304040', '', 'PO', '0.00', '0.00000000', 'PZA', 'SPECTROPHOTOMETER')
,('9031.80.8085', '', 'PO', '1.70', '0.00000000', '', 'FIXTURA')
,('9031808085', '', 'PO', '1.70', '2.25000000', 'PZA', 'APARATO')
,('9031907000', '', 'PO', '0.00', '0.00000000', 'PZA', 'FIXTURE')
,('9032200000', '', 'PO', '0.00', '0.00000000', 'PZA', 'PRESOSTATO')
,('9106100000', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('9403100040', '', 'PO', '0.00', '0.00000000', 'PZA', 'MESA DE METAL CON BASE DE MADERA')
,('9403200030', '', 'PO', '0.00', '0.00000000', 'PZA', 'ESTANTE DE ACERO PARA TOTE')
,('9506310000', '', 'PO', '0.00', '0.00000000', '', 'GOLF CLUB')
,('9506320000', '', 'PO', '0.00', '0.00000000', 'PZA', 'BALL')
,('9506620000', '', 'PO', '0.00', '0.00000000', 'PZA', 'PELOTA')
,('9506628060', '', 'PO', '0.00', '0.00000000', '', '')
,('9506996080', '', 'PO', '0.00', '0.00000000', '', '')
,('9603404050', 'US', '', '0.00', '0.00000000', 'PZA', 'BROCHAS CON MANGO DE PLASTICO Y CERDAS DE FIBRA SINTETICA')
,('9604000000', '', 'PO', '0.00', '0.00000000', '', '')
,('9607190060', '', 'PO', '0.00', '0.00000000', '', '')
,('9612101020', '', 'PO', '0.00', '0.00000000', 'PZA', 'RIBBON')
,('9612109090', '', 'PO', '0.00', '0.00000000', 'PZA', 'RIBBON')
,('9618000000', 'S', 'PO', '0.00', '79.00000000', 'PZA', 'MANIQUIES')
,('9618009900', '', 'PO', '0.00', '0.00000000', 'PZA', '')
,('9801001095', '', 'PO', '0.00', '4.35000000', '', 'RESINA')
,('9801001097', 'S', 'PO', '0.00', '4.35000000', 'PZA', '')
,('9801001098', '', 'PO', '0.00', '0.00000000', 'PZA', 'US RETURNS')
,('9801002500', '', 'PO', '0.00', '0.00000000', 'PZA', 'CHINA ARTICLES')
,('9802004040', 'US', 'PO', '0.00', '0.00000000', 'PZA', 'MANIQUIS REPARADOS')
,('9802005060', '', 'PO', '0.00', '0.00000000', '', 'Repaired in Mexico')
,('9861661098', 'M6363', 'PO', '0.00', '8.10000000', 'PZA', '')
,('MX4415109000', '', 'PO', '0.00', '0.00000000', 'PZA', 'CAJON DE MADERA')
,('MX4820400000', '', 'PO', '0.00', '0.50000000', '', 'FORMULARIOS DE PAPEL')
,('MX7326909980', '', 'PO', '0.00', '0.00000000', '', 'STEEL PANEL')
,('MX8309900000', '', 'PO', '0.00', '0.00000000', 'PZA', 'CAP')
]

View File

@@ -0,0 +1,129 @@
"""
Service para fracciones arancelarias americanas
"""
from typing import List, Optional, Tuple, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
import logging
from .models import USTariffFraction
from .dto import USTariffFractionCreateDTO, USTariffFractionUpdateDTO
logger = logging.getLogger(__name__)
class USTariffFractionService:
"""Service para gestionar fracciones arancelarias americanas"""
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[USTariffFraction], int]:
"""Obtiene todas las fracciones arancelarias americanas con filtros opcionales"""
query = db.query(USTariffFraction).filter(
USTariffFraction.tenant_id == tenant_id,
USTariffFraction.company_id == company_id,
)
# Aplicar filtros
if filters:
# Si hay un filtro 'search', buscar en múltiples campos
if filters.get("search"):
search_term = f"%{filters['search']}%"
query = query.filter(
USTariffFraction.code.ilike(search_term) |
USTariffFraction.description.ilike(search_term) |
USTariffFraction.prefix.ilike(search_term)
)
total = query.count()
items = query.order_by(USTariffFraction.code).offset(skip).limit(limit).all()
return items, total
@staticmethod
def get_by_id(
db: Session, tenant_id: int, company_id: int, fraction_id: int
) -> Optional[USTariffFraction]:
"""Obtiene una fracción arancelaria americana por ID"""
return (
db.query(USTariffFraction)
.filter(
USTariffFraction.id == fraction_id,
USTariffFraction.tenant_id == tenant_id,
USTariffFraction.company_id == company_id,
)
.first()
)
@staticmethod
def create(
db: Session,
tenant_id: int,
company_id: int,
fraction_data: USTariffFractionCreateDTO,
) -> USTariffFraction:
"""Crea una nueva fracción arancelaria americana"""
try:
db_fraction = USTariffFraction(
tenant_id=tenant_id,
company_id=company_id,
**fraction_data.model_dump(),
)
db.add(db_fraction)
db.commit()
db.refresh(db_fraction)
return db_fraction
except IntegrityError as e:
db.rollback()
logger.error(f"Error creando fracción americana: {e}")
raise HTTPException(
status_code=400,
detail="Ya existe una fracción americana con este código",
)
@staticmethod
def update(
db: Session,
tenant_id: int,
company_id: int,
fraction_id: int,
fraction_data: USTariffFractionUpdateDTO,
) -> Optional[USTariffFraction]:
"""Actualiza una fracción arancelaria americana existente"""
db_fraction = USTariffFractionService.get_by_id(
db, tenant_id, company_id, fraction_id
)
if not db_fraction:
return None
update_data = fraction_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_fraction, field, value)
db.commit()
db.refresh(db_fraction)
return db_fraction
@staticmethod
def delete(
db: Session, tenant_id: int, company_id: int, fraction_id: int
) -> bool:
"""Elimina una fracción arancelaria americana"""
db_fraction = USTariffFractionService.get_by_id(
db, tenant_id, company_id, fraction_id
)
if not db_fraction:
return False
db.delete(db_fraction)
db.commit()
return True

View File

@@ -0,0 +1,28 @@
from core.exceptions import ErrorCollector
from .. import models
from sqlalchemy.orm import Session
def invoice_exists(
db: Session,
invoice_number: str,
tenant_id: int,
company_id: int,
errors: ErrorCollector
) -> bool:
invoice_exists = (
db.query(models.InvoiceHeader.id)
.filter(
models.InvoiceHeader.invoice_number == invoice_number,
models.InvoiceHeader.tenant_id == tenant_id,
models.InvoiceHeader.company_id == company_id,
)
.first()
)
if invoice_exists:
errors.add_duplicate_error(
"invoice_number",
invoice_number,
f"Ya existe una factura con el número '{invoice_number}'",
)

View File

@@ -0,0 +1,15 @@
""" """
def clean_dict(data_dict: dict) -> dict:
cleaned = {}
for key, value in data_dict.items():
if isinstance(value, str) and not value.strip():
cleaned[key] = None
elif value == 0 and (key.endswith('_id') or key == 'remesa'):
cleaned[key] = None
else:
cleaned[key] = value
return cleaned

View File

@@ -0,0 +1,393 @@
from sqlalchemy.orm import Session
from .... import schemas
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate
from api.v1.modules.a76.clients_and_providers.models import ClientProvider
from api.v1.modules.public.reference_data.incoterms.models import Incoterm
from api.v1.modules.a76.items.models import Item
from ....models import TransportType, Currency, WeightUnit
from core.exceptions import ErrorCollector
def validate_common(
db: Session,
invoice: schemas.InvoiceHeaderCreate,
tenant_id: int,
company_id: int,
errors: ErrorCollector,
):
if invoice.compliance_mx.pedimento_id:
pedimento = (
db.query(Pedimentos)
.filter(
Pedimentos.id == invoice.compliance_mx.pedimento_id,
Pedimentos.tenant_id == tenant_id,
Pedimentos.company_id == company_id,
)
.first()
)
if not pedimento:
errors.add_error(
field="compliance_mx.pedimento_id",
message="El Pedimento no existe en el Catálogo de Pedimentos.",
solution=["Verifica el ID", "Revisa el catálogo"],
code="NOT_FOUND",
value=invoice.compliance_mx.pedimento_id,
)
if not invoice.compliance_mx.is_regime_change:
if not pedimento.operation_type == 1:
errors.add_error(
field="compliance_mx.pedimento_id",
message="El Pedimento seleccionado no corresponde a una Importación.",
solution=["Selecciona un Pedimento de Importación"],
code="INVALID_OPERATION_TYPE",
value=pedimento.operation_type,
)
else:
if pedimento.regime in ["EXD", "ETE", "ETR"]:
errors.add_error(
field="compliance_mx.pedimento_id",
message="El Pedimento seleccionado corresponde a una Exportación, no a una Importación.",
solution=["Selecciona un Pedimento de Importación"],
code="INVALID_REGIME",
value=pedimento.regime,
)
else:
if invoice.document_type.upper().strip() != pedimento.regime:
errors.add_error(
field="document_type",
message=f"El Tipo de Documento '{invoice.document_type}' no coincide con el Régimen '{pedimento.regime}' del Pedimento seleccionado.",
solution=[
"Ajusta el Tipo de Documento o selecciona otro Pedimento"
],
code="REGIME_MISMATCH",
value=invoice.document_type,
)
else:
if pedimento.operation_type != 2:
errors.add_error(
field="compliance_mx.pedimento_id",
message="El Pedimento seleccionado no corresponde a una Importacion Definitiva.",
solution=["Selecciona un Pedimento de Importacion Definitiva"],
code="INVALID_OPERATION_TYPE",
value=pedimento.operation_type,
)
else:
if pedimento.regime != "IMD":
errors.add_error(
field="compliance_mx.pedimento_id",
message=f"El Pedimento {pedimento.customs_office}-{pedimento.license}-{pedimento.pedimento_number} no corresponde a una Importacion Definitiva.",
solution=["Selecciona un Pedimento de Importacion Definitiva"],
code="INVALID_REGIME",
value=pedimento.regime,
)
else:
if invoice.document_type.upper().strip() != pedimento.regime:
errors.add_error(
field="document_type",
message=f"El Tipo de Documento '{invoice.document_type}' no coincide con el Régimen '{pedimento.regime}' del Pedimento seleccionado.",
solution=[
"Ajusta el Tipo de Documento o selecciona otro Pedimento"
],
code="REGIME_MISMATCH",
value=invoice.document_type,
)
else:
if pedimento.pedimento_code not in ["A1", "A3"]:
errors.add_error(
field="compliance_mx.pedimento_id",
message=f"El Pedimento seleccionado no es de tipo A1 o A3 requerido para Cambio de Régimen.",
solution=["Selecciona un Pedimento de tipo A1 o A3"],
code="INVALID_PEDEMENTO_CODE",
value=pedimento.pedimento_code,
)
if pedimento.pedimento_type == "consolidated":
if (
invoice.invoice_date < pedimento.pedimento_dates.entry_date
or invoice.invoice_date > pedimento.pedimento_dates.end_date
):
errors.add_error(
field="invoice_date",
message=f"La Fecha de la Factura {invoice.invoice_date} no está dentro del rango de fechas del Pedimento {pedimento.customs_office}-{pedimento.license}-{pedimento.pedimento_number}.",
solution=[
f"Capturar una Fecha de Factura, entre la Fecha de Inicio: {pedimento.pedimento_dates.entry_date} y la Fecha Final: {pedimento.pedimento_dates.end_date} ."
],
code="DATE_OUT_OF_RANGE",
value=invoice.invoice_date,
)
if not invoice.compliance_mx.remesa:
errors.add_error(
field="compliance_mx.remesa",
message="El campo Remesa es obligatorio cuando se asocia un Pedimento.",
solution=["Proporciona un valor para Remesa"],
code="REQUIRED_FIELD",
value=invoice.compliance_mx.remesa,
)
elif invoice.compliance_mx.remesa == 0:
errors.add_error(
field="compliance_mx.remesa",
message="El campo Remesa no puede ser cero cuando se asocia un Pedimento.",
solution=["Proporciona un valor válido para Remesa"],
code="INVALID_VALUE",
value=invoice.compliance_mx.remesa,
)
duplicated_remesa = (
db.query(Pedimentos)
.filter(
Pedimentos.remesa == invoice.compliance_mx.remesa,
Pedimentos.id != invoice.compliance_mx.pedimento_id,
Pedimentos.tenant_id == tenant_id,
Pedimentos.company_id == company_id,
)
.first()
)
if duplicated_remesa:
errors.add_error(
field="compliance_mx.remesa",
message="El valor de Remesa ya está asociado a otro Pedimento.",
solution=["Proporciona un valor único para Remesa"],
code="DUPLICATE_VALUE",
value=invoice.compliance_mx.remesa,
)
else:
if invoice.compliance_mx.remesa and not invoice.compliance_mx.pedimento_id:
errors.add_error(
field="compliance_mx.pedimento_id",
message="El campo Pedimento es obligatorio cuando se proporciona Remesa.",
solution=["Proporciona un ID de Pedimento"],
code="REQUIRED_FIELD",
value=invoice.compliance_mx.pedimento_id,
)
if len(invoice.invoice_number) > 100:
errors.add_error(
field="invoice_number",
message="El número de factura excede la longitud máxima de 100 caracteres.",
solution=["Acorta el número de factura a 100 caracteres o menos"],
code="MAX_LENGTH_EXCEEDED",
value=invoice.invoice_number,
)
if not invoice.financials.exchange_rate or invoice.financials.exchange_rate <= 0:
exchange_rate_exists = (
db.query(ExchangeRate)
.filter(
ExchangeRate.date == invoice.invoice_date,
ExchangeRate.tenant_id == tenant_id,
ExchangeRate.company_id == company_id,
)
.first()
)
if not exchange_rate_exists:
errors.add_error(
field="financials.exchange_rate",
message=f"No existe un Tipo de Cambio registrado para la fecha {invoice.invoice_date.date()}.",
solution=["Registra el Tipo de Cambio en el catálogo correspondiente"],
code="EXCHANGE_RATE_NOT_FOUND",
value=invoice.financials.exchange_rate,
)
if invoice.compliance_mx.is_regime_change:
if invoice.document_type in ["EXD", "ETE", "ETR"]:
errors.add_error(
field="document_type",
message="El Tipo de Documento no puede ser de Exportación cuando se trata de un Cambio de Régimen.",
solution=[
"Selecciona un Tipo de Documento válido para Cambio de Régimen"
],
code="INVALID_DOCUMENT_TYPE",
value=invoice.document_type,
)
else:
if invoice.document_type == "IMD":
errors.add_error(
field="document_type",
message="El Tipo de Documento no puede ser 'IMD' a menos que sea un Cambio de Régimen.",
solution=["Selecciona un Tipo de Documento válido"],
code="INVALID_DOCUMENT_TYPE",
value=invoice.document_type,
)
provider_exists = (
db.query(ClientProvider)
.filter(
ClientProvider.id == invoice.provider_id,
ClientProvider.tenant_id == tenant_id,
ClientProvider.company_id == company_id,
)
.first()
)
if not provider_exists:
errors.add_error(
field="provider_id",
message="El Proveedor no existe en el Catálogo de Clientes y Proveedores.",
solution=["Verifica el ID del Proveedor", "Revisa el catálogo"],
code="NOT_FOUND",
value=invoice.provider_id,
)
selled_to_exists = (
db.query(ClientProvider)
.filter(
ClientProvider.id == invoice.selled_to_id,
ClientProvider.tenant_id == tenant_id,
ClientProvider.company_id == company_id,
)
.first()
)
if not selled_to_exists:
errors.add_error(
field="selled_to_id",
message="El Cliente no existe en el Catálogo de Clientes y Proveedores.",
solution=["Verifica el ID del Cliente", "Revisa el catálogo"],
code="NOT_FOUND",
value=invoice.selled_to_id,
)
shipped_to_exists = (
db.query(ClientProvider)
.filter(
ClientProvider.id == invoice.shipped_to_id,
ClientProvider.tenant_id == tenant_id,
ClientProvider.company_id == company_id,
)
.first()
)
if not shipped_to_exists:
errors.add_error(
field="shipped_to_id",
message="El Destinatario no existe en el Catálogo de Clientes y Proveedores.",
solution=["Verifica el ID del Destinatario", "Revisa el catálogo"],
code="NOT_FOUND",
value=invoice.shipped_to_id,
)
customs_broker_exists = (
db.query(ClientProvider)
.filter(
ClientProvider.id == invoice.customs_broker_id,
ClientProvider.tenant_id == tenant_id,
ClientProvider.company_id == company_id,
)
.first()
)
if not customs_broker_exists:
errors.add_error(
field="customs_broker_id",
message="El Agente Aduanal no existe en el Catálogo de Clientes y Proveedores.",
solution=["Verifica el ID del Agente Aduanal", "Revisa el catálogo"],
code="NOT_FOUND",
value=invoice.customs_broker_id,
)
if invoice.logistics.carrier_id:
carrier_exists = (
db.query(ClientProvider)
.filter(
ClientProvider.id == invoice.logistics.carrier_id,
ClientProvider.tenant_id == tenant_id,
ClientProvider.company_id == company_id,
)
.first()
)
if not carrier_exists:
errors.add_error(
field="logistics.carrier_id",
message="El Transportista no existe en el Catálogo de Clientes y Proveedores.",
solution=["Verifica el ID del Transportista", "Revisa el catálogo"],
code="NOT_FOUND",
value=invoice.logistics.carrier_id,
)
if invoice.logistics.transport_type not in [t.value for t in TransportType]:
errors.add_error(
field="logistics.transport_type",
message="El Tipo de Transporte proporcionado no es válido.",
solution=[
f"Selecciona un Tipo de Transporte válido: {[t.value for t in TransportType]}"
],
code="INVALID_TRANSPORT_TYPE",
value=invoice.logistics.transport_type,
)
else:
if invoice.logistics.transport_type == "none" and invoice.logistics.transport_num:
errors.add_error(
field="logistics.transport_num",
message="El Número de Transporte no debe proporcionarse cuando el Tipo de Transporte es 'none'.",
solution=["Elimina el Número de Transporte o selecciona un Tipo de Transporte válido"],
code="INVALID_VALUE",
value=invoice.logistics.transport_num,
)
else:
if not invoice.logistics.transport_num and invoice.logistics.transport_type != "none":
errors.add_error(
field="logistics.transport_num",
message="El Número de Transporte es obligatorio cuando se proporciona un Tipo de Transporte distinto de 'none'.",
solution=["Proporciona un Número de Transporte válido"],
code="REQUIRED_FIELD",
value=invoice.logistics.transport_num,
)
invoice.financials.currency = (invoice.financials.currency or "foreign")
if invoice.financials.currency not in [c.value for c in Currency]:
errors.add_error(
field="financials.currency",
message="La Moneda proporcionada no es válida.",
solution=[
f"Selecciona una Moneda válida: {[c.value for c in Currency]}"
],
code="INVALID_CURRENCY",
value=invoice.financials.currency,
)
else:
has_items = db.query(Item).filter(
Item.invoice_id == invoice.id,
Item.tenant_id == tenant_id,
Item.company_id == company_id,
).first()
if has_items:
errors.add_error(
field="items",
message=f"La opcion tipo de moneda {invoice.financials.currency} no puede ser modificada ya que la factura tiene items asociados.",
solution=["Verifica la moneda de los items asociados a la factura."],
code="CURRENCY_CANNOT_BE_CHANGED",
value=invoice.financials.currency,
)
if invoice.logistics.incoterms:
incoterm_exists = (
db.query(Incoterm)
.filter(
Incoterm.code == invoice.logistics.incoterms,
Incoterm.tenant_id == tenant_id,
Incoterm.company_id == company_id,
)
.first()
)
if not incoterm_exists:
errors.add_error(
field="logistics.incoterms",
message="El Incoterm no existe en el Catálogo de Incoterms.",
solution=["Verifica el código del Incoterm", "Revisa el catálogo"],
code="NOT_FOUND",
value=invoice.logistics.incoterms,
)
if invoice.logistics.weight_type not in [w.value for w in WeightUnit]:
errors.add_error(
field="logistics.weight_type",
message="La Unidad de Peso proporcionada no es válida.",
solution=[
f"Selecciona una Unidad de Peso válida: {[w.value for w in WeightUnit]}"
],
code="INVALID_WEIGHT_UNIT",
value=invoice.logistics.weight_type,
)

View File

@@ -0,0 +1,85 @@
from sqlalchemy.orm import Session
from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate
from core.exceptions import ErrorCollector
from ....schemas import InvoiceHeaderCreate
from .common import validate_common
def validate_create(db: Session, invoice: InvoiceHeaderCreate, tenant_id: int, company_id: int, errors: ErrorCollector) -> None:
""" Valida la creación de una nueva factura de importe temporal """
if not invoice.operation_type:
errors.add_required_error("operation_type")
if not invoice.invoice_type:
errors.add_required_error("invoice_type")
if not invoice.document_type:
errors.add_required_error("document_type")
if not invoice.invoice_number:
errors.add_required_error("invoice_number")
if not invoice.invoice_date:
errors.add_required_error("invoice_date")
if not invoice.compliance_mx.provider_id:
errors.add_required_error("compliance_mx.provider_id")
if not invoice.compliance_mx.sold_to_id:
errors.add_required_error("compliance_mx.sold_to_id")
if not invoice.compliance_mx.shipped_to_id:
errors.add_required_error("compliance_mx.shipped_to_id")
if not invoice.compliance_mx.customs_broker_id:
errors.add_required_error("compliance_mx.customs_broker_id")
if errors.has_errors():
"""Se retorna por que hay campos obligatiorios para las validaciones que tienen que ser llenados"""
return
validate_common(db, invoice, tenant_id, company_id, errors)
if errors.has_errors():
"""Se retorna por que fallaron las validaciones generales"""
return
if not invoice.compliance_mx.pedimento_id:
invoice.compliance_mx.remesa = None
if not invoice.financials.exchange_rate:
invoice.financials.exchange_rate = db.query(ExchangeRate.value).filter(ExchangeRate.date == invoice.invoice_date).scalar()
invoice.document_type = (invoice.document_type or "").upper()
if not invoice.logistics.transport_type:
invoice.logistics.transport_type = "none"
if invoice.logistics.transport_type == "none" and invoice.logistics.transport_num:
invoice.logistics.transport_num = None
if not invoice.financials.currency:
invoice.financials.currency = "foreign"
if invoice.financials.currency == "local":
invoice.financials.currency_type = "MXN"
elif invoice.financials.currency_type == "foreign":
invoice.financials.currency = "USD"
elif invoice.financials.currency_type == "manual":
invoice.financials.currency_type = invoice.financials.currency_type.upper()
invoice.logistics.incoterm = (invoice.logistics.incoterm or "").upper()
if not invoice.logistics.weight_type:
invoice.logistics.weight_type = "kgs"

View File

@@ -0,0 +1,2 @@
def validate_update():
pass

View File

@@ -6,6 +6,23 @@ from core.database import Base
from datetime import datetime
from ....common.base_models import TenantScopedMixin, TimestampMixin
class Currency(str, Enum):
FOREIGN = "foreign"
LOCAL = "local"
MANUAL = "manual"
class WeightUnit(str, Enum):
KGS = "kgs"
LBS = "lbs"
class DestinationOriginCove(str, Enum):
EDO_BC_PARC_SON = "edo_bc_parc_son"
ESTADO_BCS = "estado_bcs"
ESTADO_ROO = "estado_roo"
MPIO_SALINA_CRUZ_OAX = "mpio_salina_cruz_oxa"
FRANJA_FRONT_NORTE = "franja_front_norte"
INTERIOR_PAIS = "interior_pais"
MPIO_CABORCA_SON = "mpio_caborca_son"
class OperationType(str, Enum):
IMP = "imp" # Importación
@@ -38,10 +55,11 @@ class InvoiceHeader(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
# Identifiers
system: Mapped[Optional[str]] = mapped_column(String(12)) # SISTEMA / Sistema de origen <-- no tiene campo en la antigua base de datos, sera para fixed_asset(scaf), inventory(scaii)
operation_type: Mapped[OperationType] = mapped_column(String(10)) # TIPOMOVIMIENTO / Clasifica imp/exp/sm/ctm
invoice_type: Mapped[Optional[str]] = mapped_column(ForeignKey("public.invoice_types.key")) # TIPOFACTURA / TIPODOC
invoice_number: Mapped[Optional[str]] = mapped_column(String(20)) # FACTURAIMPO/FACTURAEXPO/FACTURAREMISION/FACTURAENVIO/FACTURASALIDA
system: Mapped[str] = mapped_column(String(12)) # SISTEMA / Sistema de origen <-- no tiene campo en la antigua base de datos, sera para fixed_asset(scaf), inventory(scaii)
operation_type: Mapped[OperationType] = mapped_column(String(11)) # TIPOMOVIMIENTO / Clasifica imp/exp/sm/ctm
invoice_type: Mapped[str] = mapped_column(ForeignKey("public.invoice_types.key")) # TIPOFACTURA / TIPODOC
document_type: Mapped[str] = mapped_column(ForeignKey("public.pedimento_regimens.code")) # CLAVEDOCUMENTO / Clave de documento
invoice_number: Mapped[str] = mapped_column(String(100)) # FACTURAIMPO/FACTURAEXPO/FACTURAREMISION/FACTURAENVIO/FACTURASALIDA
project_number: Mapped[Optional[str]] = mapped_column(String(14)) # NUMPROYECTO
purchase_order: Mapped[Optional[str]] = mapped_column(String(50)) # ORDENCOMPRA
related_doc_id: Mapped[Optional[int]] = mapped_column(Integer) # IDRELDOC / Para Rectificaciones
@@ -50,20 +68,20 @@ class InvoiceHeader(Base, TenantScopedMixin, TimestampMixin):
proforma_number: Mapped[Optional[str]] = mapped_column(String(20)) # NUMEROPROFORMA
# Dates
invoice_date: Mapped[Optional[datetime]] = mapped_column(Date) # FECHAFACTURA
invoice_date: Mapped[datetime] = mapped_column(Date) # FECHAFACTURA
capture_date: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=False), default=datetime.now) # FECHACAPTURA + HORAACTUAL
emission_date: Mapped[Optional[datetime]] = mapped_column(Date) # FECHAEMISION
# Status & Control
is_updated: Mapped[Optional[bool]] = mapped_column(Boolean) # ESTATUS
is_updated: Mapped[bool] = mapped_column(Boolean) # ESTATUS
is_updated_rec: Mapped[Optional[bool]] = mapped_column(Boolean) # ESTATUSREC / Estatus de recepción
is_updated_rep: Mapped[Optional[bool]] = mapped_column(Boolean) # ESTATUSREP / Estatus de reporte
updated_date: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP(timezone=False)) # FECHAACTUALIZACION / FECHAACTUAL
who_updated: Mapped[Optional[str]] = mapped_column(String(20)) # USUARIOACT / Quien actualizó
capture_user: Mapped[Optional[str]] = mapped_column(String(20)) # USUARIOCAP / Usuario que capturó
traffic_light_status: Mapped[Optional[str]] = mapped_column(String(50)) # SEMAFORO / SEMAFOROEXPO/IMPO
process_log: Mapped[Optional[str]] = mapped_column(String(300)) # COMOFUEPROCESADA
status_rec: Mapped[Optional[int]] = mapped_column(Integer) # ESTATUSREC / Estatus de recepción
status_rep: Mapped[Optional[str]] = mapped_column(String(2)) # ESTATUSREP / Estatus de reporte
process_log: Mapped[Optional[str]] = mapped_column(String(300)) # COMOFUEPROCESADA
# Comments
observation_es: Mapped[Optional[str]] = mapped_column(Text) # OBSERVACIONE / Observaciones en español
@@ -81,9 +99,9 @@ class InvoiceHeader(Base, TenantScopedMixin, TimestampMixin):
party_count: Mapped[Optional[int]] = mapped_column(Integer) # CANT_PARTIDAS / Cantidad de partidas
# Generation flags
generate_id: Mapped[Optional[str]] = mapped_column(String(1)) # GENERAID
generate_id: Mapped[Optional[bool]] = mapped_column(Boolean, default=False) # GENERAID
generate_desc_parties: Mapped[Optional[str]] = mapped_column(String(12)) # GENDESCPARTIDAS / Generar descripción de partidas
apply_manual_discount: Mapped[Optional[str]] = mapped_column(String(1)) # APLICADESCMANUAL
apply_manual_discount: Mapped[Optional[bool]] = mapped_column(Boolean, default=False) # APLICADESCMANUAL
# Bulk & Downloads
is_bulk: Mapped[Optional[bool]] = mapped_column(Boolean) # ESAGRANEL / Es a granel
@@ -118,9 +136,9 @@ class InvoiceComplianceMx(Base, TenantScopedMixin, TimestampMixin):
invoice_id: Mapped[int] = mapped_column(ForeignKey("a76.invoice_header.id"), primary_key=True)
# Core Customs Data
pedimento: Mapped[Optional[str]] = mapped_column(String(19)) # PEDIMENTO/PEDIMENTOIMPO/EXPO
pedimento_code: Mapped[Optional[str]] = mapped_column(String(5)) # PEDIMENTOR1
pedimento_k1: Mapped[Optional[str]] = mapped_column(String(15)) # PEDIMENTOK1
pedimento_id: Mapped[Optional[int]] = mapped_column(ForeignKey("a76.pedimentos.id")) # PEDIMENTO/PEDIMENTOIMPO/EXPO
pedimento_r1: Mapped[Optional[int]] = mapped_column(ForeignKey("a76.pedimentos.id")) # PEDIMENTOR1
pedimento_k1: Mapped[Optional[int]] = mapped_column(ForeignKey("a76.pedimentos.id")) # PEDIMENTOK1
remesa: Mapped[Optional[int]] = mapped_column(Integer) # REMESA
aduana: Mapped[Optional[str]] = mapped_column(ForeignKey("public.customs_sections.customs_code")) # ADUANA_CRUCE
port_of_entry: Mapped[Optional[str]] = mapped_column(String(6)) # PUERTOENTRADA / Puerto de entrada
@@ -129,15 +147,15 @@ class InvoiceComplianceMx(Base, TenantScopedMixin, TimestampMixin):
# Clients & Providers
provider_header: Mapped[Optional[str]] = mapped_column(String(20)) # PROVEEDOREXPORTADOR
provider_id: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.clients_and_providers.id")) # PROVEEDOR
provider_id: Mapped[Optional[int]] = mapped_column(ForeignKey("a76.clients_and_providers.id")) # PROVEEDOR
sold_to_header: Mapped[Optional[str]] = mapped_column(String(20)) # VENDIDOCONSIGNADO
sold_to_id: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.clients_and_providers.id")) # VENDIDOA
sold_to_id: Mapped[Optional[int]] = mapped_column(ForeignKey("a76.clients_and_providers.id")) # VENDIDOA
shipped_to_header: Mapped[Optional[str]] = mapped_column(String(20)) # ENVIADOTRANSFERIDO
shipped_to_id: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.clients_and_providers.id")) # ENVIADOA
shipped_to_id: Mapped[Optional[int]] = mapped_column(ForeignKey("a76.clients_and_providers.id")) # ENVIADOA
shipped_by_header: Mapped[Optional[str]] = mapped_column(String(20)) # ENVIADOPORVENDIDOPOR
shipped_by_id: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.clients_and_providers.id")) # VENDIDOPOR/ENVIADOPOR
customs_broker_id: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.customs_brokers.id")) # AADUANAL / Agente aduanal
customs_broker_us_id: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.customs_brokers.id")) # AADUANALAME / Agente aduanal americano
shipped_by_id: Mapped[Optional[int]] = mapped_column(ForeignKey("a76.clients_and_providers.id")) # VENDIDOPOR/ENVIADOPOR
customs_broker_id: Mapped[Optional[int]] = mapped_column(ForeignKey("a76.customs_brokers.id")) # AADUANAL / Agente aduanal
customs_broker_us_id: Mapped[Optional[int]] = mapped_column(ForeignKey("a76.customs_brokers.id")) # AADUANALAME / Agente aduanal americano
# Broker Invoice
broker_invoice_num: Mapped[Optional[str]] = mapped_column(String(20)) # NUMFACTURABROKER / Número factura broker
@@ -148,15 +166,15 @@ class InvoiceComplianceMx(Base, TenantScopedMixin, TimestampMixin):
waste_type: Mapped[Optional[str]] = mapped_column(String(1)) # TIPODESPERDICIO / Tipo de desperdicio
scrap_type: Mapped[Optional[str]] = mapped_column(String(1)) # TIPOSCRAP / Tipo de scrap
appendix_17: Mapped[Optional[int]] = mapped_column(Integer) # APENDICE17 / Apéndice 17
is_regime_change: Mapped[Optional[str]] = mapped_column(String(1)) # ESCAMBIOREGIMEN / Es cambio de régimen
is_regime_change: Mapped[Optional[bool]] = mapped_column(Boolean, default=False) # ESCAMBIOREGIMEN / Es cambio de régimen
which_exchange_rate: Mapped[Optional[str]] = mapped_column(String(5)) # CUALTIPOCAMBIO / Cuál tipo de cambio
value_method: Mapped[Optional[str]] = mapped_column(String(2)) # METVALOR / Método de valoración
act_value: Mapped[Optional[str]] = mapped_column(String(5)) # ACTVALOR / Actualizar valor
is_pedimento_pending: Mapped[Optional[bool]] = mapped_column(Boolean, default=False) # PED_PENDIENTE_ASIGNAR (Mapear 1 -> True, 0 -> False)
# Ownership & Balances
is_owner_of_goods: Mapped[Optional[str]] = mapped_column(String(2)) # ESDUENOMCIA / Es dueño de mercancía
generate_balances: Mapped[Optional[str]] = mapped_column(String(2)) # GENERARSALDOS / Generar saldos
is_owner_of_goods: Mapped[Optional[bool]] = mapped_column(Boolean, default=False) # ESDUENOMCIA / Es dueño de mercancía
generate_balances: Mapped[Optional[bool]] = mapped_column(Boolean, default=False) # GENERARSALDOS / Generar saldos
was_reviewed_by_company: Mapped[Optional[bool]] = mapped_column(Boolean) # FUEREVISADAMCIA / Fue revisada por la compañía
# VUCEM / Digital
@@ -166,7 +184,7 @@ class InvoiceComplianceMx(Base, TenantScopedMixin, TimestampMixin):
niu_number: Mapped[Optional[str]] = mapped_column(String(19)) # NUMERONIU / Número NIU
bill_of_lading_count: Mapped[Optional[str]] = mapped_column(String(12)) # CANTGUIASEMBARQUE / Cantidad guías embarque
addendum_vu: Mapped[Optional[str]] = mapped_column(String(204)) # ADENDAVU / Adenda VUCEM
origin_destination_cove: Mapped[Optional[str]] = mapped_column(String(19)) # DESTINOORIGENCOVE / Destino/Origen COVE
origin_destination_cove: Mapped[Optional[DestinationOriginCove]] = mapped_column(String(20)) # DESTINOORIGENCOVE / Destino/Origen COVE
vucem_operation_num: Mapped[Optional[str]] = mapped_column(String(19)) # NUMOPERACIONVU / Número operación VUCEM
customs_person_line: Mapped[Optional[int]] = mapped_column(Integer) # LINEAPERSONAAA / Línea persona agente aduanal
@@ -201,7 +219,7 @@ class InvoiceFinancials(Base, TenantScopedMixin, TimestampMixin):
invoice_id: Mapped[int] = mapped_column(ForeignKey("a76.invoice_header.id"))
# Currency
currency: Mapped[Optional[str]] = mapped_column(String(3)) # CLAVEMONEDA / Clave de moneda
currency: Mapped[Currency] = mapped_column(String(7)) # CLAVEMONEDA / Clave de moneda
currency_type: Mapped[Optional[str]] = mapped_column(ForeignKey("public.currency_types.code")) # TIPOMONEDA / TIPOCLAVEMONEDA
exchange_rate: Mapped[Optional[float]] = mapped_column(Numeric(13, 6)) # TIPOCAMBIO / Tipo de cambio
exchange_rate_mm: Mapped[Optional[float]] = mapped_column(Numeric(13, 6)) # TIPOCAMBIOMM / Tipo de cambio moneda a moneda
@@ -278,7 +296,7 @@ class InvoiceLogistics(Base, TenantScopedMixin, TimestampMixin):
transport_num: Mapped[Optional[str]] = mapped_column(String(20)) # NUMTRASPORTE / Número de transporte
transport_mode: Mapped[Optional[str]] = mapped_column(String(15)) # MODTRANS / Modo de transporte
driver_name: Mapped[Optional[str]] = mapped_column(String(80)) # CONDUCTOR / Nombre del conductor
is_rail: Mapped[Optional[str]] = mapped_column(String(2)) # ESFERROCARRIL / Es ferrocarril
is_rail: Mapped[Optional[bool]] = mapped_column(Boolean, default=False) # ESFERROCARRIL / Es ferrocarril
rail_id: Mapped[Optional[str]] = mapped_column(String(31)) # IDFERRORCARRIL / ID ferrocarril
# Vehicle & Tracking
@@ -302,7 +320,7 @@ class InvoiceLogistics(Base, TenantScopedMixin, TimestampMixin):
complement_2: Mapped[Optional[str]] = mapped_column(String(30)) # COMPLEMENTO2 / Complemento 2
# Weight & Container Info
weight_type: Mapped[Optional[str]] = mapped_column(String(6)) # TIPOPESO / Tipo de peso
weight_type: Mapped[WeightUnit] = mapped_column(String(3)) # TIPOPESO / Tipo de peso
container_types: Mapped[Optional[str]] = mapped_column(String(500)) # CONTENEDORESTIPO / Tipos de contenedores
vehicle_data: Mapped[Optional[str]] = mapped_column(String(500)) # DATOSVEHICULO / Datos del vehículo
@@ -317,7 +335,7 @@ class InvoiceLogistics(Base, TenantScopedMixin, TimestampMixin):
delivery_date: Mapped[Optional[datetime]] = mapped_column(Date) # FECHAENTREGA / Fecha de entrega
# Delivery Control
delivered_status: Mapped[Optional[str]] = mapped_column(String(2)) # ENTREGADO / Estado de entrega
delivered_status: Mapped[Optional[bool]] = mapped_column(Boolean, default=False) # ENTREGADO / Estado de entrega
received_by: Mapped[Optional[str]] = mapped_column(String(50)) # RECIBIDOPOR / Recibido por
# Payment Info
@@ -325,7 +343,7 @@ class InvoiceLogistics(Base, TenantScopedMixin, TimestampMixin):
payment_receipt_num: Mapped[Optional[str]] = mapped_column(String(20)) # NUMRECIBOPAGO / Número de recibo de pago
# CTM Process
is_ctm_process: Mapped[Optional[str]] = mapped_column(String(2)) # SETRATAPROCESOCTM / Se trata de proceso CTM
is_ctm_process: Mapped[Optional[bool]] = mapped_column(Boolean, default=False) # SETRATAPROCESOCTM / Se trata de proceso CTM
# Relationship
header: Mapped["InvoiceHeader"] = relationship(back_populates="logistics")

View File

@@ -1,8 +1,8 @@
from typing import Optional, List
from typing import Literal, Optional, List
from datetime import datetime, date
from decimal import Decimal
from pydantic import BaseModel, Field
from .models import OperationType
from .models import DestinationOriginCove, OperationType, Currency, TransportType, WeightUnit
# --- Base Schemas ---
@@ -11,11 +11,13 @@ class InvoiceHeaderBase(BaseModel):
system: Optional[str] = Field(
None, max_length=12, description="System of origin")
operation_type: Optional[OperationType] = Field(
None, max_length=10, description="Operation type: imp/exp/sm/ctm")
..., description="Operation type: imp/exp/sm/ctm")
invoice_type: Optional[str] = Field(
None, max_length=5, description="Invoice type key")
document_type: str = Field(
..., max_length=3, description="Document type (Regimen Aduanero)")
invoice_number: Optional[str] = Field(
None, max_length=20, description="Invoice number")
None, max_length=100, description="Invoice number")
project_number: Optional[str] = Field(
None, max_length=14, description="Project number")
purchase_order: Optional[str] = Field(
@@ -28,9 +30,9 @@ class InvoiceHeaderBase(BaseModel):
None, max_length=19, description="Invoice reference")
proforma_number: Optional[str] = Field(
None, max_length=20, description="Proforma number")
invoice_date: Optional[date] = Field(None, description="Invoice date")
invoice_date: date = Field(..., description="Invoice date")
emission_date: Optional[date] = Field(None, description="Emission date")
is_updated: Optional[bool] = Field(None, description="Status")
is_updated: bool = Field(False, description="Status")
updated_date: Optional[datetime] = Field(None, description="Update date")
who_updated: Optional[str] = Field(
None, max_length=20, description="Who updated")
@@ -40,8 +42,8 @@ class InvoiceHeaderBase(BaseModel):
None, max_length=50, description="Traffic light status")
process_log: Optional[str] = Field(
None, max_length=300, description="Processing log")
status_rec: Optional[int] = Field(None, description="Reception status")
status_rep: Optional[str] = Field(
is_updated_rec: Optional[int] = Field(None, description="Reception status")
is_updated_rep: Optional[str] = Field(
None, max_length=2, description="Report status")
observation_es: Optional[str] = Field(
None, description="Observations in Spanish")
@@ -60,12 +62,10 @@ class InvoiceHeaderBase(BaseModel):
subcompany: Optional[str] = Field(
None, max_length=5, description="Subcompany")
party_count: Optional[int] = Field(None, description="Quantity of parties")
generate_id: Optional[str] = Field(
None, max_length=1, description="Generate ID")
generate_id: Optional[bool] = Field(False, description="Generate ID")
generate_desc_parties: Optional[str] = Field(
None, max_length=12, description="Generate description of parties")
apply_manual_discount: Optional[str] = Field(
None, max_length=1, description="Apply manual discount")
apply_manual_discount: Optional[bool] = Field(False, description="Apply manual discount")
is_bulk: Optional[bool] = Field(None, description="Is bulk")
download_substance: Optional[bool] = Field(
None, description="Download substance")
@@ -84,66 +84,64 @@ class InvoiceHeaderBase(BaseModel):
class InvoiceComplianceMxBase(BaseModel):
"""Base fields for Compliance MX"""
pedimento: Optional[str] = Field(
None, max_length=19, description="Pedimento number")
pedimento_code: Optional[str] = Field(
None, max_length=5, description="Pedimento code (R1)")
pedimento_k1: Optional[str] = Field(
None, max_length=15, description="Pedimento K1")
pedimento_id: Optional[int] = Field(
None, description="Pedimento id")
pedimento_r1: Optional[int] = Field(
None, description="Pedimento id (R1)")
pedimento_k1: Optional[int] = Field(
None, description="Pedimento id (K1)")
remesa: Optional[int] = Field(None, description="Remesa")
aduana: Optional[str] = Field(
None, max_length=5, description="Customs office")
aduana: Optional[str] = Field(None, max_length=5, description="Customs office")
port_of_entry: Optional[str] = Field(
None, max_length=6, description="Port of entry")
destination: Optional[str] = Field(
None, max_length=3, description="Destination code")
manifest_number: Optional[str] = Field(
None, max_length=15, description="Manifest number")
provider_header: Optional[str] = Field(
provider_header: str = Field(
None, max_length=20, description="Provider header")
provider_id: Optional[str] = Field(
provider_id: int = Field(
None, description="Provider ID")
sold_to_header: Optional[str] = Field(
sold_to_header: str = Field(
None, max_length=20, description="Sold to header")
sold_to_id: Optional[str] = Field(
sold_to_id: int = Field(
None, description="Sold to ID")
shipped_to_header: Optional[str] = Field(
shipped_to_header: str = Field(
None, max_length=20, description="Shipped to header")
shipped_to_id: Optional[str] = Field(
shipped_to_id:int = Field(
None, description="Shipped to ID")
shipped_by_header: Optional[str] = Field(
shipped_by_header: Optional[int] = Field(
None, max_length=20, description="Shipped by header")
shipped_by_id: Optional[str] = Field(
shipped_by_id: Optional[int] = Field(
None, description="Shipped by ID")
customs_broker_id: Optional[str] = Field(
customs_broker_id: int = Field(
None, description="Customs broker ID")
customs_broker_us_id: Optional[str] = Field(
customs_broker_us_id: Optional[int] = Field(
None, description="US customs broker ID")
broker_invoice_num: Optional[str] = Field(
None, max_length=20, description="Broker invoice number")
broker_invoice_date: Optional[date] = Field(
None, description="Broker invoice date")
is_mixed: Optional[bool] = Field(
None, description="Is mixed operation")
False, description="Is mixed operation")
waste_type: Optional[str] = Field(
None, max_length=1, description="Waste type")
scrap_type: Optional[str] = Field(
None, max_length=1, description="Scrap type")
appendix_17: Optional[int] = Field(None, description="Appendix 17")
is_regime_change: Optional[str] = Field(
None, max_length=1, description="Is regime change")
is_regime_change: Optional[bool] = Field(
False, description="Is regime change")
which_exchange_rate: Optional[str] = Field(
None, max_length=5, description="Which exchange rate")
value_method: Optional[str] = Field(
None, max_length=2, description="Value method")
act_value: Optional[str] = Field(
None, max_length=5, description="Act value")
is_pedimento_pending: Optional[bool] = Field(
None, description="Is pedimento pending")
is_owner_of_goods: Optional[str] = Field(
None, max_length=2, description="Is owner of goods")
generate_balances: Optional[str] = Field(
None, max_length=2, description="Generate balances")
is_pedimento_pending: bool = Field(..., description="Is pedimento pending")
is_owner_of_goods: Optional[bool] = Field(
False, description="Is owner of goods")
generate_balances: Optional[bool] = Field(
False, description="Generate balances")
was_reviewed_by_company: Optional[bool] = Field(
None, description="Was reviewed by company")
edocument: Optional[str] = Field(
@@ -158,8 +156,7 @@ class InvoiceComplianceMxBase(BaseModel):
None, max_length=12, description="Bill of lading count")
addendum_vu: Optional[str] = Field(
None, max_length=204, description="VUCEM addendum")
origin_destination_cove: Optional[str] = Field(
None, max_length=19, description="Origin/Destination COVE")
origin_destination_cove: Optional[DestinationOriginCove] = Field('franja_front_norte', max_length=20, description="Origin/Destination COVE")
vucem_operation_num: Optional[str] = Field(
None, max_length=19, description="VUCEM operation number")
customs_person_line: Optional[int] = Field(
@@ -191,11 +188,11 @@ class InvoiceComplianceMxBase(BaseModel):
class InvoiceFinancialsBase(BaseModel):
"""Base fields for Financials"""
currency: Optional[str] = Field(
None, max_length=3, description="Currency code")
currency: Currency = Field(
None, max_length=7, description="Currency code")
currency_type: Optional[str] = Field(
None, description="Currency type")
exchange_rate: Optional[Decimal] = Field(None, description="Exchange rate")
"USD", description="Currency type")
exchange_rate: Decimal = Field(0.00, description="Exchange rate")
exchange_rate_mm: Optional[Decimal] = Field(
None, description="Exchange rate currency to currency")
value_mn: Optional[Decimal] = Field(None, description="Value in MXN")
@@ -245,8 +242,7 @@ class InvoiceFinancialsBase(BaseModel):
None, description="IVA in foreign currency")
iva_mc: Optional[Decimal] = Field(
None, description="IVA in third currency")
iva_factor: Optional[str] = Field(
None, max_length=10, description="IVA factor")
iva_factor: Optional[Decimal] = Field(None, description="IVA factor")
tax_value_me: Optional[Decimal] = Field(
None, description="Tax value in foreign currency")
seal_value_2500: Optional[bool] = Field(
@@ -267,16 +263,16 @@ class InvoiceLogisticsBase(BaseModel):
None, max_length=10, description="Transport ID")
transport_us_id: Optional[str] = Field(
None, max_length=10, description="US transport ID")
transport_type: Optional[str] = Field(
None, max_length=15, description="Transport type")
transport_type: TransportType = Field(
'none', max_length=15, description="Transport type")
transport_num: Optional[str] = Field(
None, max_length=20, description="Transport number")
transport_mode: Optional[str] = Field(
None, max_length=15, description="Transport mode")
30, max_length=15, description="Transport mode")
driver_name: Optional[str] = Field(
None, max_length=80, description="Driver name")
is_rail: Optional[str] = Field(
None, max_length=2, description="Is rail transport")
is_rail: Optional[bool] = Field(
False, description="Is rail transport")
rail_id: Optional[str] = Field(
None, max_length=31, description="Rail ID")
vehicle_num: Optional[str] = Field(
@@ -307,8 +303,8 @@ class InvoiceLogisticsBase(BaseModel):
None, max_length=2, description="Identifier 2")
complement_2: Optional[str] = Field(
None, max_length=30, description="Complement 2")
weight_type: Optional[str] = Field(
None, max_length=6, description="Weight type")
weight_type: WeightUnit = Field(
default="kgs", max_length=3, description="Weight type")
container_types: Optional[str] = Field(
None, max_length=500, description="Container types")
vehicle_data: Optional[str] = Field(
@@ -333,8 +329,8 @@ class InvoiceLogisticsBase(BaseModel):
None, description="Payment date")
payment_receipt_num: Optional[str] = Field(
None, max_length=20, description="Payment receipt number")
is_ctm_process: Optional[str] = Field(
None, max_length=2, description="Is CTM process")
is_ctm_process: Optional[bool] = Field(
False, description="Is CTM process")
class InvoiceSalesDetailsBase(BaseModel):

View File

@@ -1,15 +1,22 @@
import traceback
from typing import Optional, List, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import and_
from core.exceptions import ErrorCollector, DuplicateResourceException
from .common.mappers import clean_dict
from .imports.temporary.validators.create import validate_create
from .imports.temporary.validators.update import validate_update
from .common.common_validators import invoice_exists
from . import models, schemas
class InvoiceService:
"""Service for Invoice Header operations"""
@staticmethod
def get_by_id(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> Optional[models.InvoiceHeader]:
def get_by_id(
db: Session, invoice_id: int, tenant_id: int, company_id: int
) -> Optional[models.InvoiceHeader]:
"""Get an invoice by ID with tenant/company validation"""
return (
db.query(models.InvoiceHeader)
@@ -39,25 +46,32 @@ class InvoiceService:
# Apply filters if provided
if filters:
if filters.get("status"):
query = query.filter(
models.InvoiceHeader.status == filters["status"])
query = query.filter(models.InvoiceHeader.status == filters["status"])
if filters.get("operation_type"):
query = query.filter(
models.InvoiceHeader.operation_type == filters["operation_type"])
models.InvoiceHeader.operation_type == filters["operation_type"]
)
if filters.get("invoice_type"):
query = query.filter(
models.InvoiceHeader.invoice_type == filters["invoice_type"])
models.InvoiceHeader.invoice_type == filters["invoice_type"]
)
if filters.get("invoice_number"):
query = query.filter(models.InvoiceHeader.invoice_number.ilike(
f"%{filters['invoice_number']}%"))
query = query.filter(
models.InvoiceHeader.invoice_number.ilike(
f"%{filters['invoice_number']}%"
)
)
if filters.get("pedimento"):
query = query.join(models.InvoiceComplianceMx).filter(
models.InvoiceComplianceMx.pedimento.ilike(
f"%{filters['pedimento']}%")
f"%{filters['pedimento']}%"
)
)
if not filters.get("invoice_type") and filters.get("operation_type") == "exp":
query = query.filter(
models.InvoiceHeader.operation_type != "REPAR")
if (
not filters.get("invoice_type")
and filters.get("operation_type") == "exp"
):
query = query.filter(models.InvoiceHeader.operation_type != "REPAR")
total = query.count()
items = query.offset(skip).limit(limit).all()
@@ -68,30 +82,19 @@ class InvoiceService:
db: Session,
invoice_data: schemas.InvoiceHeaderCreate,
tenant_id: int,
company_id: int
company_id: int,
) -> models.InvoiceHeader:
"""Create a new invoice with all related data"""
def clean_dict(data_dict: dict) -> dict:
cleaned = {}
for key, value in data_dict.items():
if key == 'customs_agent':
key = 'customs_broker_id'
elif key == 'provider':
key = 'provider_id'
if isinstance(value, str) and not value.strip():
cleaned[key] = None
elif value == 0 and (key.endswith('_id') or key == 'remesa'):
cleaned[key] = None
else:
cleaned[key] = value
return cleaned
# Validaciones con ErrorCollector
errors = ErrorCollector()
# Validar si la factura ya existe
#invoice_exists(db, invoice_data.invoice_number, tenant_id, company_id, errors)
#validate_create(db, invoice_data, tenant_id, company_id, errors)
# Si hay errores, lanzar excepción
errors.raise_if_errors("Error al crear la factura")
try:
# Extract nested data
@@ -103,27 +106,32 @@ class InvoiceService:
# Create main invoice header
raw_invoice_dict = invoice_data.model_dump(
exclude={"compliance_mx", "financials",
"logistics", "details", "collections"}
exclude={
"compliance_mx",
"financials",
"logistics",
"details",
"collections",
}
)
invoice_dict = clean_dict(raw_invoice_dict)
invoice_dict["tenant_id"] = tenant_id
invoice_dict["company_id"] = company_id
new_invoice = models.InvoiceHeader(**invoice_dict)
db.add(new_invoice)
db.flush() # Flush to get the invoice ID
# Create compliance_mx if provided
if compliance_data:
raw_comp_dict = compliance_data.model_dump()
# Pasamos los datos por la lavadora para arreglar pedimento, aduana, etc.
compliance_dict = clean_dict(raw_comp_dict)
compliance_dict["invoice_id"] = new_invoice.id
compliance_dict["tenant_id"] = tenant_id
compliance_dict["company_id"] = company_id
new_compliance = models.InvoiceComplianceMx(**compliance_dict)
db.add(new_compliance)
@@ -131,11 +139,11 @@ class InvoiceService:
if financials_data:
raw_fin_dict = financials_data.model_dump()
financials_dict = clean_dict(raw_fin_dict)
financials_dict["invoice_id"] = new_invoice.id
financials_dict["tenant_id"] = tenant_id
financials_dict["company_id"] = company_id
new_financials = models.InvoiceFinancials(**financials_dict)
db.add(new_financials)
@@ -143,7 +151,7 @@ class InvoiceService:
for logistics_item in logistics_data:
raw_log_dict = logistics_item.model_dump()
logistics_dict = clean_dict(raw_log_dict)
logistics_dict["invoice_id"] = new_invoice.id
logistics_dict["tenant_id"] = tenant_id
logistics_dict["company_id"] = company_id
@@ -154,7 +162,7 @@ class InvoiceService:
for detail_item in details_data:
raw_det_dict = detail_item.model_dump()
detail_dict = clean_dict(raw_det_dict)
detail_dict["invoice_id"] = new_invoice.id
detail_dict["tenant_id"] = tenant_id
detail_dict["company_id"] = company_id
@@ -165,7 +173,7 @@ class InvoiceService:
for collection_item in collections_data:
raw_col_dict = collection_item.model_dump()
collection_dict = clean_dict(raw_col_dict)
collection_dict["invoice_id"] = new_invoice.id
collection_dict["tenant_id"] = tenant_id
collection_dict["company_id"] = company_id
@@ -180,7 +188,7 @@ class InvoiceService:
db.rollback()
print("\n\n🔥 ERROR AL GUARDAR FACTURA 🔥")
print(f"Error: {str(e)}")
traceback.print_exc() # Esto imprime el error real en la consola
traceback.print_exc() # Esto imprime el error real en la consola
print("--------------------------------\n")
raise e
@@ -190,20 +198,24 @@ class InvoiceService:
invoice_id: int,
tenant_id: int,
invoice_data: schemas.InvoiceHeaderUpdate,
company_id: int
company_id: int,
) -> Optional[models.InvoiceHeader]:
# ... (El resto de tu código update se queda igual) ...
# (Te recomiendo implementar clean_dict aquí también si tienes problemas al editar)
invoice = InvoiceService.get_by_id(
db, invoice_id, tenant_id, company_id)
invoice = InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id)
if not invoice:
return None
# Update main invoice header fields
update_dict = invoice_data.model_dump(
exclude={"compliance_mx", "financials",
"logistics", "details", "collections"},
exclude_unset=True
exclude={
"compliance_mx",
"financials",
"logistics",
"details",
"collections",
},
exclude_unset=True,
)
for key, value in update_dict.items():
setattr(invoice, key, value)
@@ -211,15 +223,21 @@ class InvoiceService:
# Update compliance_mx if provided
if invoice_data.compliance_mx is not None:
if invoice.compliance_mx:
for key, value in invoice_data.compliance_mx.model_dump(exclude_unset=True).items():
for key, value in invoice_data.compliance_mx.model_dump(
exclude_unset=True
).items():
# Parche rápido para update
if value == "": value = None
if value == "":
value = None
setattr(invoice.compliance_mx, key, value)
else:
compliance_dict = invoice_data.compliance_mx.model_dump()
# Aplicar limpieza manual si es necesario
if 'customs_agent' in compliance_dict: compliance_dict['customs_broker_id'] = compliance_dict.pop('customs_agent')
if "customs_agent" in compliance_dict:
compliance_dict["customs_broker_id"] = compliance_dict.pop(
"customs_agent"
)
compliance_dict["invoice_id"] = invoice.id
compliance_dict["tenant_id"] = tenant_id
compliance_dict["company_id"] = company_id
@@ -229,8 +247,11 @@ class InvoiceService:
# Update financials if provided
if invoice_data.financials is not None:
if invoice.financials:
for key, value in invoice_data.financials.model_dump(exclude_unset=True).items():
if value == "": value = None
for key, value in invoice_data.financials.model_dump(
exclude_unset=True
).items():
if value == "":
value = None
setattr(invoice.financials, key, value)
else:
financials_dict = invoice_data.financials.model_dump()
@@ -247,10 +268,9 @@ class InvoiceService:
@staticmethod
def delete(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> bool:
"""Delete an invoice and all related data (cascade delete)"""
invoice = InvoiceService.get_by_id(
db, invoice_id, tenant_id, company_id)
invoice = InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id)
if invoice:
db.delete(invoice)
db.commit()
return True
return False
return False

View File

@@ -1,159 +1,241 @@
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel, Field, ConfigDict
from pydantic import BaseModel, Field, ConfigDict, field_validator
# Import nested schemas
from ..line_customs.schemas import (
LineCustomCreate,
LineCustomUpdate,
LineCustomResponse
LineCustomResponse,
)
from ..line_descriptions.schemas import (
LineDescriptionCreate,
LineDescriptionUpdate,
LineDescriptionResponse
LineDescriptionResponse,
)
from ..line_quantities.schemas import (
LineQuantityCreate,
LineQuantityUpdate,
LineQuantityResponse
LineQuantityResponse,
)
from ..line_financials.schemas import (
LineFinancialCreate,
LineFinancialUpdate,
LineFinancialResponse
LineFinancialResponse,
)
from ..line_references.schemas import (
LineReferenceCreate,
LineReferenceUpdate,
LineReferenceResponse
LineReferenceResponse,
)
# ============================================================================
# LINE ITEM SCHEMAS
# ============================================================================
class LineItemBase(BaseModel):
"""Base schema for line items"""
line_number: int = Field(..., description="Line number")
# Part identification
part_number: Optional[str] = Field(None, max_length=50, description="Part number")
component_part_number: Optional[str] = Field(None, max_length=50, description="Component part number")
component_part_number: Optional[str] = Field(
None, max_length=50, description="Component part number"
)
class_code: Optional[str] = Field(None, max_length=20, description="Class code")
@field_validator(
"class_code",
"part_number",
"component_part_number",
"unit_of_measure",
"alternate_unit",
mode="before",
)
@classmethod
def convert_to_string(cls, v):
"""Convert integers to strings for FK fields"""
if v is not None and not isinstance(v, str):
return str(v)
return v
# Unit of measure
unit_of_measure: Optional[str] = Field(None, max_length=10, description="Unit of measure")
alternate_unit: Optional[str] = Field(None, max_length=10, description="Alternate unit")
unit_of_measure: Optional[str] = Field(
None, max_length=10, description="Unit of measure"
)
alternate_unit: Optional[str] = Field(
None, max_length=10, description="Alternate unit"
)
uma_key: Optional[str] = Field(None, max_length=2, description="UMA key")
auxiliary_unit: Optional[str] = Field(None, max_length=5, description="Auxiliary unit")
auxiliary_unit: Optional[str] = Field(
None, max_length=5, description="Auxiliary unit"
)
# Permits and certificates
permit_number: Optional[str] = Field(None, max_length=20, description="Permit number")
permit_number: Optional[str] = Field(
None, max_length=20, description="Permit number"
)
page_line: Optional[str] = Field(None, max_length=10, description="Page line")
has_certificate: Optional[bool] = Field(None, description="Has certificate")
certificate_number: Optional[str] = Field(None, max_length=10, description="Certificate number")
octave_permit: Optional[str] = Field(None, max_length=20, description="Octave permit")
certificate_number: Optional[str] = Field(
None, max_length=10, description="Certificate number"
)
octave_permit: Optional[str] = Field(
None, max_length=20, description="Octave permit"
)
permits_ped: Optional[str] = Field(None, max_length=500, description="PED permits")
# FDA
has_fda_code: Optional[bool] = Field(None, description="Has FDA code")
fda_key: Optional[str] = Field(None, max_length=10, description="FDA key")
# Subitem flags
is_subitem: Optional[bool] = Field(None, description="Is subitem")
contains_subitems: Optional[bool] = Field(None, description="Contains subitems")
includes_subitems: Optional[bool] = Field(None, description="Includes subitems")
subitem_number: Optional[bool] = Field(None, description="Subitem number")
# Special flags
is_military_mcia: Optional[bool] = Field(None, description="Is military merchandise")
is_military_mcia: Optional[bool] = Field(
None, description="Is military merchandise"
)
# IV32
iv32_type_key: Optional[str] = Field(None, max_length=5, description="IV32 type key")
iv32_type_key: Optional[str] = Field(
None, max_length=5, description="IV32 type key"
)
iv32_number: Optional[str] = Field(None, max_length=35, description="IV32 number")
# Export specific
scrap_invoice: Optional[str] = Field(None, max_length=15, description="Scrap invoice")
consecutive_destination: Optional[int] = Field(None, description="Consecutive destination")
scrap_invoice: Optional[str] = Field(
None, max_length=15, description="Scrap invoice"
)
consecutive_destination: Optional[int] = Field(
None, description="Consecutive destination"
)
ctm_section: Optional[str] = Field(None, max_length=3, description="CTM section")
# Tax payment
tax_payment: Optional[bool] = Field(None, description="Tax payment")
payment_method: Optional[str] = Field(None, max_length=9, description="Payment method")
payment_method: Optional[str] = Field(
None, max_length=9, description="Payment method"
)
igi_amount: Optional[Decimal] = Field(None, description="IGI amount")
igi_payment_method: Optional[str] = Field(None, max_length=9, description="IGI payment method")
igi_payment_method: Optional[str] = Field(
None, max_length=9, description="IGI payment method"
)
# FCC
fcc_key: Optional[str] = Field(None, max_length=30, description="FCC key")
# Valuation method
valuation_method: Optional[str] = Field(None, max_length=2, description="Valuation method")
valuation_determined_value: Optional[Decimal] = Field(None, description="Valuation determined value")
valuation_reason: Optional[str] = Field(None, max_length=500, description="Valuation reason")
valuation_method: Optional[str] = Field(
None, max_length=2, description="Valuation method"
)
valuation_determined_value: Optional[Decimal] = Field(
None, description="Valuation determined value"
)
valuation_reason: Optional[str] = Field(
None, max_length=500, description="Valuation reason"
)
# Container rules
container_rule: Optional[str] = Field(None, max_length=50, description="Container rule")
container_parts_ii: Optional[str] = Field(None, max_length=50, description="Container parts II")
container_rule: Optional[str] = Field(
None, max_length=50, description="Container rule"
)
container_parts_ii: Optional[str] = Field(
None, max_length=50, description="Container parts II"
)
# APHIS
consecutive_aphis: Optional[int] = Field(None, description="Consecutive APHIS")
# BOM/Commercial
bom_version: Optional[int] = Field(None, description="BOM version")
bill_version: Optional[int] = Field(None, description="Bill version")
# TLCAN value
tlcan_value: Optional[Decimal] = Field(None, description="TLCAN value")
# Identifier
identifier: Optional[str] = Field(None, max_length=2, description="Identifier")
# Validation fields
validation_zero: Optional[int] = Field(None, description="Validation zero")
validation_one: Optional[int] = Field(None, description="Validation one")
# Material type
material_type: Optional[str] = Field(None, max_length=50, description="Material type")
material_type: Optional[str] = Field(
None, max_length=50, description="Material type"
)
# Order concept
order_type: Optional[str] = Field(None, max_length=50, description="Order type")
line_concept: Optional[str] = Field(None, max_length=50, description="Line concept")
# Review dispatch
review_dispatch: Optional[str] = Field(None, max_length=10, description="Review dispatch")
review_dispatch: Optional[str] = Field(
None, max_length=10, description="Review dispatch"
)
# Take component from PT
take_component_pt: Optional[int] = Field(None, description="Take component from PT")
# Pallet
pallet2: Optional[int] = Field(None, description="Pallet 2")
# Wildcard field
wildcard_field: Optional[str] = Field(None, max_length=100, description="Wildcard field")
wildcard_field: Optional[str] = Field(
None, max_length=100, description="Wildcard field"
)
class LineItemCreate(LineItemBase):
"""Schema for creating line item with all nested data"""
financial: Optional[LineFinancialCreate] = Field(None, description="Financial data for this line")
quantity: Optional[LineQuantityCreate] = Field(None, description="Quantity data for this line")
customs: Optional[LineCustomCreate] = Field(None, description="Customs data for this line")
description: Optional[LineDescriptionCreate] = Field(None, description="Description data for this line")
reference: Optional[LineReferenceCreate] = Field(None, description="Reference data for this line")
financial: Optional[LineFinancialCreate] = Field(
None, description="Financial data for this line"
)
quantity: Optional[LineQuantityCreate] = Field(
None, description="Quantity data for this line"
)
customs: Optional[LineCustomCreate] = Field(
None, description="Customs data for this line"
)
description: Optional[LineDescriptionCreate] = Field(
None, description="Description data for this line"
)
reference: Optional[LineReferenceCreate] = Field(
None, description="Reference data for this line"
)
class LineItemUpdate(LineItemBase):
"""Schema for updating line item with all nested data"""
line_number: Optional[int] = Field(None, description="Line number")
financial: Optional[LineFinancialUpdate] = Field(None, description="Financial data for this line")
quantity: Optional[LineQuantityUpdate] = Field(None, description="Quantity data for this line")
customs: Optional[LineCustomUpdate] = Field(None, description="Customs data for this line")
description: Optional[LineDescriptionUpdate] = Field(None, description="Description data for this line")
reference: Optional[LineReferenceUpdate] = Field(None, description="Reference data for this line")
financial: Optional[LineFinancialUpdate] = Field(
None, description="Financial data for this line"
)
quantity: Optional[LineQuantityUpdate] = Field(
None, description="Quantity data for this line"
)
customs: Optional[LineCustomUpdate] = Field(
None, description="Customs data for this line"
)
description: Optional[LineDescriptionUpdate] = Field(
None, description="Description data for this line"
)
reference: Optional[LineReferenceUpdate] = Field(
None, description="Reference data for this line"
)
class LineItemResponse(LineItemBase):
"""Schema for line item response with all nested data"""
id: int
item_id: int
financial: Optional[LineFinancialResponse] = None

View File

@@ -106,7 +106,7 @@ class SubassemblyEntry(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
remission_line: Mapped[int] = mapped_column(Integer) # LINEAREMISION
exit_invoice: Mapped[Optional[str]] = mapped_column(
String(15)) # FACTURASALIDA
String(15)) # FACTURASALIDA
exit_line: Mapped[Optional[int]] = mapped_column(Integer) # LINEASALIDA
# ============================================================================

View File

@@ -36,10 +36,7 @@ class ItemService:
@staticmethod
def get_by_id(
db: Session,
item_id: int,
tenant_id: int,
company_id: int
db: Session, item_id: int, tenant_id: int, company_id: int
) -> Optional[Item]:
"""Get an item by ID with tenant/company validation"""
return (
@@ -91,8 +88,7 @@ class ItemService:
if filters.get("item_type"):
query = query.filter(Item.item_type == filters["item_type"])
if filters.get("system_origin"):
query = query.filter(Item.system_origin ==
filters["system_origin"])
query = query.filter(Item.system_origin == filters["system_origin"])
if filters.get("search"):
search_term = f"%{filters['search']}%"
query = query.filter(
@@ -151,6 +147,12 @@ class ItemService:
lines_data = item_data.lines or []
item_dict = item_data.model_dump(exclude={"lines"})
# DEBUG: Log incoming data
print(f"\n🔍 DEBUG CREATE ITEM:")
print(f" Item data: {item_dict}")
print(f" Lines count: {len(lines_data)}")
print(f" Tenant ID: {tenant_id}, Company ID: {company_id}")
# Add tenant and company
item_dict["tenant_id"] = tenant_id
item_dict["company_id"] = company_id
@@ -160,8 +162,11 @@ class ItemService:
db.add(db_item)
db.flush() # Get the item ID
print(f" ✅ Item created with ID: {db_item.id}")
# Create line items if provided
for line_data in lines_data:
for idx, line_data in enumerate(lines_data):
print(f"\n 📝 Processing line {idx + 1}/{len(lines_data)}")
# Extract nested data from line
financial_data = line_data.financial
quantity_data = line_data.quantity
@@ -169,16 +174,31 @@ class ItemService:
description_data = line_data.description
reference_data = line_data.reference
print(f" Line data: {line_data.model_dump()}")
print(f" Has financial: {financial_data is not None}")
print(f" Has quantity: {quantity_data is not None}")
print(f" Has customs: {customs_data is not None}")
print(f" Has description: {description_data is not None}")
print(f" Has reference: {reference_data is not None}")
line_dict = line_data.model_dump(
exclude={"financial", "quantity",
"customs", "description", "reference"}
exclude={
"financial",
"quantity",
"customs",
"description",
"reference",
}
)
line_dict["item_id"] = db_item.id
line_dict["tenant_id"] = tenant_id
line_dict["company_id"] = company_id
# Create line item
db_line = LineItem(**line_dict)
db.add(db_line)
db.flush() # Get the line ID
print(f" ✅ Line created with ID: {db_line.id}")
# Create financial data if provided
if financial_data:
@@ -186,6 +206,7 @@ class ItemService:
financial_dict["item_line_id"] = db_line.id
db_financial = LineFinancial(**financial_dict)
db.add(db_financial)
print(f" ✅ Financial data added")
# Create quantity data if provided
if quantity_data:
@@ -193,6 +214,7 @@ class ItemService:
quantity_dict["item_line_id"] = db_line.id
db_quantity = LineQuantity(**quantity_dict)
db.add(db_quantity)
print(f" ✅ Quantity data added")
# Create customs data if provided
if customs_data:
@@ -200,6 +222,7 @@ class ItemService:
customs_dict["item_line_id"] = db_line.id
db_customs = LineCustom(**customs_dict)
db.add(db_customs)
print(f" ✅ Customs data added")
# Create description data if provided
if description_data:
@@ -207,6 +230,7 @@ class ItemService:
description_dict["item_line_id"] = db_line.id
db_description = LineDescription(**description_dict)
db.add(db_description)
print(f" ✅ Description data added")
# Create reference data if provided
if reference_data:
@@ -214,9 +238,12 @@ class ItemService:
reference_dict["item_line_id"] = db_line.id
db_reference = LineReference(**reference_dict)
db.add(db_reference)
print(f" ✅ Reference data added")
print(f"\n 💾 Committing transaction...")
db.commit()
db.refresh(db_item)
print(f" ✅ Transaction committed successfully!")
return db_item
except IntegrityError as e:
@@ -248,8 +275,7 @@ class ItemService:
# Extract lines data
lines_data = item_data.lines
item_dict = item_data.model_dump(
exclude={"lines"}, exclude_unset=True)
item_dict = item_data.model_dump(exclude={"lines"}, exclude_unset=True)
# Update item fields
for key, value in item_dict.items():
@@ -272,43 +298,48 @@ class ItemService:
reference_data = line_data.reference
line_dict = line_data.model_dump(
exclude={"financial", "quantity",
"customs", "description", "reference"},
exclude_unset=True
exclude={
"financial",
"quantity",
"customs",
"description",
"reference",
},
exclude_unset=True,
)
line_dict["item_id"] = db_item.id
line_dict["tenant_id"] = tenant_id
line_dict["company_id"] = company_id
db_line = LineItem(**line_dict)
db.add(db_line)
db.flush()
# Create nested data if provided
if financial_data is not None:
financial_dict = financial_data.model_dump(
exclude_unset=True)
financial_dict = financial_data.model_dump(exclude_unset=True)
financial_dict["item_line_id"] = db_line.id
db.add(LineFinancial(**financial_dict))
if quantity_data is not None:
quantity_dict = quantity_data.model_dump(
exclude_unset=True)
quantity_dict = quantity_data.model_dump(exclude_unset=True)
quantity_dict["item_line_id"] = db_line.id
db.add(LineQuantity(**quantity_dict))
if customs_data is not None:
customs_dict = customs_data.model_dump(
exclude_unset=True)
customs_dict = customs_data.model_dump(exclude_unset=True)
customs_dict["item_line_id"] = db_line.id
db.add(LineCustom(**customs_dict))
if description_data is not None:
description_dict = description_data.model_dump(
exclude_unset=True)
exclude_unset=True
)
description_dict["item_line_id"] = db_line.id
db.add(LineDescription(**description_dict))
if reference_data is not None:
reference_dict = reference_data.model_dump(
exclude_unset=True)
reference_dict = reference_data.model_dump(exclude_unset=True)
reference_dict["item_line_id"] = db_line.id
db.add(LineReference(**reference_dict))

View File

@@ -1,232 +1,137 @@
"""
DTOs (Data Transfer Objects) para módulo de partes/componentes
Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
"""
from datetime import datetime
from decimal import Decimal
from typing import Optional
from typing import List, Optional
from pydantic import BaseModel, Field, ConfigDict
from pydantic import BaseModel, Field
# --- SUB-DTO: DATOS ADUANALES (FaData) ---
class FaDataDTO(BaseModel):
origin_country: Optional[str] = None
sector: Optional[str] = None
fraction_type: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
# --- SUB-DTO: DATOS DE INVENTARIO Y COSTEO (InvData) ---
class InvDataDTO(BaseModel):
part_type: Optional[str] = None
material_type: Optional[str] = None
reference_number: Optional[str] = None
flex_reference_number: Optional[str] = None
equivalent_uom: Optional[str] = None
conversion_factor: Optional[Decimal] = None
stock_uom: Optional[str] = None
alternate_uom: Optional[str] = None
conversion_uom: Optional[str] = None
added_value: Optional[Decimal] = None
added_value_type: Optional[str] = None
assigned_client: Optional[str] = None
supplier_code: Optional[str] = None
is_textile: Optional[str] = None
bom_version: Optional[int] = None
is_repair: Optional[str] = None
is_hazardous: Optional[str] = None
emergency_number: Optional[str] = None
danger_class: Optional[str] = None
packaging_group: Optional[str] = None
width: Optional[str] = None
thickness: Optional[str] = None
specification: Optional[str] = None
# Desglose de Costos (Anexo 24 - Valor Agregado)
total_value: Optional[Decimal] = None
direct_labor: Optional[Decimal] = None
general_expenses: Optional[Decimal] = None
total_expenses: Optional[Decimal] = None
depreciation: Optional[Decimal] = None
tooling: Optional[Decimal] = None
material_consumed: Optional[Decimal] = None
profit: Optional[Decimal] = None
# Fracciones adicionales y TLCAN/T-MEC
us_fraction_alt: Optional[str] = None
ca_fraction: Optional[str] = None
ad_valorem_us: Optional[Decimal] = None
nafta_result: Optional[str] = None
nafta_percentage: Optional[Decimal] = None
dta: Optional[str] = None
dtb: Optional[str] = None
dtg: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class PartCreateDTO(BaseModel):
"""DTO para crear una parte"""
client_id: int = Field(..., description="Client key")
part_number: str = Field(..., max_length=49, description="Part number")
fraction: Optional[str] = Field(None, max_length=10, description="Tariff fraction")
description_spanish: Optional[str] = Field(
None, max_length=500, description="Description in Spanish"
)
description_english: Optional[str] = Field(
None, max_length=500, description="Description in English"
)
part_class: Optional[str] = Field(None, max_length=8, description="Part class")
unit_of_measure: Optional[str] = Field(
None, max_length=5, description="Unit of measure"
)
commercial_part_number: Optional[str] = Field(
None, max_length=70, description="Commercial part number"
)
country_of_origin: Optional[str] = Field(
None, max_length=3, description="Country of origin code"
)
# Pricing and currency
unit_cost: Optional[Decimal] = Field(None, description="Unit cost")
currency_type: Optional[str] = Field(
None, max_length=2, description="Currency type"
)
currency_key: Optional[str] = Field(None, max_length=3, description="Currency key")
# Weight information
unit_weight: Optional[Decimal] = Field(None, description="Unit weight")
weight_type: Optional[str] = Field(None, max_length=6, description="Weight type")
# Classification and regulatory
us_fraction: Optional[str] = Field(
None, max_length=16, description="US tariff fraction"
)
fda_key: Optional[str] = Field(None, max_length=20, description="FDA key")
fcc_key: Optional[str] = Field(None, max_length=30, description="FCC key")
license_code: Optional[str] = Field(None, max_length=3, description="License code")
eccn: Optional[str] = Field(
None, max_length=20, description="Export Control Classification Number"
)
export_code: Optional[str] = Field(None, max_length=2, description="Export code")
exclusion_symbol: Optional[str] = Field(
None, max_length=19, description="Exclusion symbol"
)
# Additional information
supplier: Optional[str] = Field(None, max_length=14, description="Supplier")
alternate_unit_measure: Optional[str] = Field(
None, max_length=14, description="Alternate unit of measure"
)
added_value: Optional[Decimal] = Field(None, description="Added value")
# Status and media
is_active: Optional[bool] = Field(None, description="Enabled/Disabled status")
creation_date: Optional[int] = Field(None, description="Creation date")
part_photo: Optional[str] = Field(
None, max_length=255, description="Part photo URL"
)
class Config:
from_attributes = True
class PartUpdateDTO(BaseModel):
"""DTO para actualizar una parte"""
fraction: Optional[str] = Field(None, max_length=10, description="Tariff fraction")
description_spanish: Optional[str] = Field(
None, max_length=500, description="Description in Spanish"
)
description_english: Optional[str] = Field(
None, max_length=500, description="Description in English"
)
part_class: Optional[str] = Field(None, max_length=8, description="Part class")
unit_of_measure: Optional[str] = Field(
None, max_length=5, description="Unit of measure"
)
commercial_part_number: Optional[str] = Field(
None, max_length=70, description="Commercial part number"
)
country_of_origin: Optional[str] = Field(
None, max_length=3, description="Country of origin code"
)
# Pricing and currency
unit_cost: Optional[Decimal] = Field(None, description="Unit cost")
currency_type: Optional[str] = Field(
None, max_length=2, description="Currency type"
)
currency_key: Optional[str] = Field(None, max_length=3, description="Currency key")
# Weight information
unit_weight: Optional[Decimal] = Field(None, description="Unit weight")
weight_type: Optional[str] = Field(None, max_length=6, description="Weight type")
# Classification and regulatory
us_fraction: Optional[str] = Field(
None, max_length=16, description="US tariff fraction"
)
fda_key: Optional[str] = Field(None, max_length=20, description="FDA key")
fcc_key: Optional[str] = Field(None, max_length=30, description="FCC key")
license_code: Optional[str] = Field(None, max_length=3, description="License code")
eccn: Optional[str] = Field(
None, max_length=20, description="Export Control Classification Number"
)
export_code: Optional[str] = Field(None, max_length=2, description="Export code")
exclusion_symbol: Optional[str] = Field(
None, max_length=19, description="Exclusion symbol"
)
# Additional information
supplier: Optional[str] = Field(None, max_length=14, description="Supplier")
alternate_unit_measure: Optional[str] = Field(
None, max_length=14, description="Alternate unit of measure"
)
added_value: Optional[Decimal] = Field(None, description="Added value")
# Status and media
is_active: Optional[bool] = Field(None, description="Enabled/Disabled status")
part_photo: Optional[str] = Field(
None, max_length=255, description="Part photo URL"
)
class Config:
from_attributes = True
class PartResponseDTO(BaseModel):
"""DTO para respuesta de parte"""
class PartBase(BaseModel):
client_id: int
part_number: str
fraction: Optional[str] = None
part_number: str = Field(..., max_length=70)
commercial_part_number: Optional[str] = None
description_spanish: Optional[str] = None
description_english: Optional[str] = None
part_class: Optional[str] = None
unit_of_measure: Optional[str] = None
commercial_part_number: Optional[str] = None
country_of_origin: Optional[str] = None
# Pricing and currency
unit_of_measure: Optional[str] = "PZ"
unit_cost: Optional[Decimal] = None
currency_type: Optional[str] = None
currency_key: Optional[str] = None
# Weight information
currency_type: Optional[str] = None
unit_weight: Optional[Decimal] = None
weight_type: Optional[str] = None
# Classification and regulatory
fraction: Optional[str] = None
us_fraction: Optional[str] = None
# Regulatorios
fda_key: Optional[str] = None
fcc_key: Optional[str] = None
license_code: Optional[str] = None
eccn: Optional[str] = None
export_code: Optional[str] = None
exclusion_symbol: Optional[str] = None
# Additional information
supplier: Optional[str] = None
alternate_unit_measure: Optional[str] = None
added_value: Optional[Decimal] = None
# Status and dates
is_active: Optional[bool] = None
creation_date: Optional[int] = None
modification_date: Optional[int] = None
modification_date_iso: Optional[datetime] = None
# Media
is_active: bool = True
part_photo: Optional[str] = None
class Config:
from_attributes = True
# Anidados
fa_data: Optional[FaDataDTO] = None
inv_data: Optional[InvDataDTO] = None
# --- CREACIÓN ---
class PartCreateDTO(PartBase):
# Opcional para que lo tome de la URL si no viene en el body
company_id: Optional[int] = None
# --- ACTUALIZACIÓN ---
class PartUpdateDTO(PartBase):
client_id: Optional[int] = None
part_number: Optional[str] = None
# Todo opcional para PATCH
pass
# --- RESPUESTA ---
class PartResponseDTO(PartBase):
id: int
tenant_id: int
company_id: int
creation_date: Optional[int] = None
modification_date_iso: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
# --- BÁSICO (Para listados ligeros) ---
class PartBasicDTO(BaseModel):
"""DTO para información básica de parte"""
client_id: int
id: int
part_number: str
description_spanish: Optional[str] = None
description_english: Optional[str] = None
part_class: Optional[str] = None
unit_cost: Optional[Decimal] = None
currency_key: Optional[str] = None
is_active: Optional[bool] = None
fraction: Optional[str] = None
is_active: bool
model_config = ConfigDict(from_attributes=True)
class Config:
from_attributes = True
class PartListDTO(BaseModel):
"""DTO para lista de partes"""
parts: list[PartBasicDTO]
# --- LISTADO PAGINADO ---
class PartListResponseDTO(BaseModel):
items: List[PartResponseDTO]
total: int
page: int
size: int
page_size: int
pages: int
class Config:
from_attributes = True
class PartSearchDTO(BaseModel):
"""DTO para búsqueda de partes"""
client_id: Optional[int] = Field(None, description="Filter by client key")
part_number: Optional[str] = Field(None, description="Search by part number")
description: Optional[str] = Field(None, description="Search in descriptions")
fraction: Optional[str] = Field(None, description="Filter by tariff fraction")
supplier: Optional[str] = Field(None, description="Filter by supplier")
enabled_only: bool = Field(False, description="Show only enabled parts")
class Config:
from_attributes = True

View File

@@ -1,5 +1,5 @@
"""
Modelos ORM para gestión de partes/componentes
Modelos ORM para gestión de partes/componentes - Anexo 76 (Master Data)
"""
from datetime import datetime
@@ -16,34 +16,46 @@ from sqlalchemy import (
String,
UniqueConstraint,
Boolean,
DateTime,
)
# Importante usar relationship y Mapped
from sqlalchemy.orm import Mapped, mapped_column, relationship
if TYPE_CHECKING:
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.public.reference_data.countries.models import Country
from api.v1.modules.public.reference_data.currency_types.models import CurrencyType
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
from api.v1.modules.a76.general_catalogs.units_of_measure.models import (
UnitOfMeasure,
)
from api.v1.modules.a24.fa.fa_parts.models import FaPart
from api.v1.modules.a24.inv.inv_parts.models import InvPart
class Part(Base, TenantScopedMixin, TimestampMixin):
"""
Modelo para la tabla GPartes - Información de partes en los sistemas SCAII (N), SCAF (S) Y WINSAAI (W)
"""
__tablename__ = "parts"
__table_args__ = (
PrimaryKeyConstraint("id", name="parts_pkey"),
ForeignKeyConstraint(
["country_of_origin"], ["public.countries.m3_key"], name="fk_parts_country"
),
ForeignKeyConstraint(
["currency_key"], ["public.currency_types.code"], name="fk_parts_currency"
),
ForeignKeyConstraint(
["unit_of_measure", "tenant_id", "company_id"],
["a76.units_of_measure.code", "a76.units_of_measure.tenant_id",
"a76.units_of_measure.company_id"],
[
"a76.units_of_measure.code",
"a76.units_of_measure.tenant_id",
"a76.units_of_measure.company_id",
],
),
# Puente hacia la tabla de clases
ForeignKeyConstraint(
["part_class", "tenant_id", "company_id"],
[
"a76.classes.class_code",
"a76.classes.tenant_id",
"a76.classes.company_id",
],
name="fk_parts_class",
),
UniqueConstraint(
"tenant_id", "company_id", "part_number", name="client_part_ukey"
@@ -52,80 +64,60 @@ class Part(Base, TenantScopedMixin, TimestampMixin):
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# Unique constraint compuesta
client_id: Mapped[int] = mapped_column(Integer)
part_number: Mapped[str] = mapped_column(String(50))
part_number: Mapped[str] = mapped_column(String(70))
commercial_part_number: Mapped[Optional[str]] = mapped_column(String(70))
# Basic information
fraction: Mapped[Optional[str]] = mapped_column(String(10))
description_spanish: Mapped[Optional[str]] = mapped_column(String(500))
description_english: Mapped[Optional[str]] = mapped_column(String(500))
part_class: Mapped[Optional[str]] = mapped_column(String(8))
unit_of_measure: Mapped[Optional[str]] = mapped_column(
String(5)
)
commercial_part_number: Mapped[Optional[str]] = mapped_column(String(70))
country_of_origin: Mapped[Optional[str]] = mapped_column(String(3))
unit_of_measure: Mapped[Optional[str]] = mapped_column(String(5))
# Pricing and currency
unit_cost: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8))
currency_type: Mapped[Optional[str]] = mapped_column(String(2))
currency_key: Mapped[Optional[str]] = mapped_column(String(3))
# Weight information
unit_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8))
weight_type: Mapped[Optional[str]] = mapped_column(String(6))
# Classification and regulatory
us_fraction: Mapped[Optional[str]] = mapped_column(
String(16)) # FRACCIONAME
fraction: Mapped[Optional[str]] = mapped_column(String(10))
us_fraction: Mapped[Optional[str]] = mapped_column(String(16))
fda_key: Mapped[Optional[str]] = mapped_column(String(20))
fcc_key: Mapped[Optional[str]] = mapped_column(String(30))
license_code: Mapped[Optional[str]] = mapped_column(String(3))
eccn: Mapped[Optional[str]] = mapped_column(
String(20)
) # Export Control Classification Number
eccn: Mapped[Optional[str]] = mapped_column(String(20))
export_code: Mapped[Optional[str]] = mapped_column(String(2))
exclusion_symbol: Mapped[Optional[str]] = mapped_column(
String(19)) # SIMBOLOEXCLIC
exclusion_symbol: Mapped[Optional[str]] = mapped_column(String(19))
# Additional information
supplier: Mapped[Optional[str]] = mapped_column(String(14))
alternate_unit_measure: Mapped[Optional[str]] = mapped_column(String(14))
added_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8))
# Status and dates
is_active: Mapped[Optional[bool]] = mapped_column(Boolean)
creation_date: Mapped[Optional[int]
] = mapped_column() # FECHACREACIONPARTE
modification_date: Mapped[Optional[int]] = mapped_column() # FECHAMODIFICA
modification_date_iso: Mapped[Optional[datetime]] = (
mapped_column()
) # FECHAMODIFICA_ISO
# Media
is_active: Mapped[Optional[bool]] = mapped_column(Boolean, default=True)
part_photo: Mapped[Optional[str]] = mapped_column(String(255))
# Relationships
country: Mapped[Optional["Country"]] = relationship(
foreign_keys=[country_of_origin]
)
currency: Mapped[Optional["CurrencyType"]] = relationship(
foreign_keys=[currency_key]
)
creation_date: Mapped[Optional[int]] = mapped_column()
modification_date: Mapped[Optional[int]] = mapped_column()
modification_date_iso: Mapped[Optional[datetime]] = mapped_column(DateTime)
# --- RELACIONES ---
currency: Mapped[Optional["CurrencyType"]] = relationship("CurrencyType")
unit_of_measure_info: Mapped[Optional["UnitOfMeasure"]] = relationship(
foreign_keys=[unit_of_measure]
"UnitOfMeasure"
)
part_class_info: Mapped[Optional["Class"]] = relationship(
"Class", back_populates="parts", overlaps="unit_of_measure_info"
)
# Relationship with Class through composite foreign key
# Note: This requires both client_id and part_class to match client_id and class_code in Class
part_class_info: Mapped[Optional["Class"]] = relationship(
primaryjoin="and_(Part.client_id == Class.client_id, Part.part_class == Class.class_code)",
foreign_keys="[Part.client_id, Part.part_class]",
viewonly=True,
back_populates="parts",
# Extensiones Anexo 24
fa_data: Mapped[Optional["FaPart"]] = relationship(
"FaPart",
back_populates="master_info",
uselist=False,
cascade="all, delete-orphan",
)
inv_data: Mapped[Optional["InvPart"]] = relationship(
"InvPart",
back_populates="master_info",
uselist=False,
cascade="all, delete-orphan",
)
def __repr__(self) -> str:
return f"<Part(client_id={self.client_id}, part_number='{self.part_number}', description='{self.description_spanish}')>"
return f"<Part(id={self.id}, part_number='{self.part_number}')>"

View File

@@ -1,393 +1,22 @@
"""
Endpoints API para gestión de partes/componentes
Endpoints API para gestión de partes (SCAII)
"""
from typing import List, Optional
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from core.database import get_core_db
from core.security import get_current_user
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from .dto import (
PartBasicDTO,
PartCreateDTO,
PartListDTO,
PartResponseDTO,
PartSearchDTO,
PartUpdateDTO,
)
from .dto import PartCreateDTO, PartResponseDTO, PartUpdateDTO
from .service import PartService
router = APIRouter(prefix="/parts")
@router.post("/", response_model=PartResponseDTO, status_code=status.HTTP_201_CREATED)
async def create_part(
part_data: PartCreateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Create a new part in the system
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = PartService(db)
return service.create_part(part_data)
@router.get("/", response_model=PartListDTO)
async def list_parts(
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"
),
client_id: Optional[int] = Query(None, description="Filter by client key"),
part_number: Optional[str] = Query(None, description="Search by part number"),
description: Optional[str] = Query(None, description="Search in descriptions"),
fraction: Optional[str] = Query(None, description="Filter by tariff fraction"),
supplier: Optional[str] = Query(None, description="Filter by supplier"),
enabled_only: bool = Query(False, description="Show only enabled parts"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
List parts with optional filters and pagination
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = PartService(db)
search_params = PartSearchDTO(
client_id=client_id,
part_number=part_number,
description=description,
fraction=fraction,
supplier=supplier,
enabled_only=enabled_only,
)
return service.list_parts(skip, limit, search_params)
@router.get("/client/{client_id}", response_model=List[PartBasicDTO])
async def get_parts_by_client(
client_id: int,
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),
):
"""
Get all parts for a specific client
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = PartService(db)
return service.search_by_client(client_id, skip, limit)
@router.get("/search/fraction/{fraction}", response_model=List[PartBasicDTO])
async def search_by_fraction(
fraction: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Search parts by tariff fraction
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = PartService(db)
return service.search_by_fraction(fraction)
@router.get("/search/supplier/{supplier}", response_model=List[PartBasicDTO])
async def search_by_supplier(
supplier: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Search parts by supplier
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = PartService(db)
return service.search_by_supplier(supplier)
@router.get("/search/country/{country_code}", response_model=List[PartBasicDTO])
async def get_parts_by_country(
country_code: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Get parts by country of origin
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = PartService(db)
return service.get_parts_by_country(country_code)
@router.get("/statistics", response_model=dict)
async def get_parts_statistics(
db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user)
):
"""
Get basic parts statistics
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = PartService(db)
return service.get_parts_statistics()
@router.get("/{client_id}/{part_number}", response_model=PartResponseDTO)
async def get_part(
client_id: int,
part_number: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Get part by composite key (client_id + part_number)
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = PartService(db)
part = service.get_part(client_id, part_number)
if not part:
raise HTTPException(
status_code=404,
detail=f"Part with client_id '{client_id}' and part_number '{part_number}' not found",
)
return part
@router.put("/{client_id}/{part_number}", response_model=PartResponseDTO)
async def update_part(
client_id: int,
part_number: str,
part_data: PartUpdateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Update part information
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = PartService(db)
part = service.update_part(client_id, part_number, part_data)
if not part:
raise HTTPException(
status_code=404,
detail=f"Part with client_id '{client_id}' and part_number '{part_number}' not found",
)
return part
@router.delete("/{client_id}/{part_number}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_part(
client_id: int,
part_number: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Delete part from the system
Note: This will completely remove the part from the system.
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = PartService(db)
if not service.delete_part(client_id, part_number):
raise HTTPException(
status_code=404,
detail=f"Part with client_id '{client_id}' and part_number '{part_number}' not found",
)
@router.patch(
"/{client_id}/{part_number}/toggle-status", response_model=PartResponseDTO
)
async def toggle_part_status(
client_id: int,
part_number: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Toggle part enabled/disabled status
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = PartService(db)
part = service.toggle_status(client_id, part_number)
if not part:
raise HTTPException(
status_code=404,
detail=f"Part with client_id '{client_id}' and part_number '{part_number}' not found",
)
return part
# Endpoints específicos para información detallada
@router.get("/{client_id}/{part_number}/basic", response_model=PartBasicDTO)
async def get_part_basic_info(
client_id: int,
part_number: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Get basic information for a part
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = PartService(db)
part = service.get_part(client_id, part_number)
if not part:
raise HTTPException(
status_code=404,
detail=f"Part with client_id '{client_id}' and part_number '{part_number}' not found",
)
return PartBasicDTO(
client_id=part.client_id,
part_number=part.part_number,
description_spanish=part.description_spanish,
description_english=part.description_english,
part_class=part.part_class,
unit_cost=part.unit_cost,
currency_key=part.currency_key,
is_active=part.is_active,
)
@router.get("/{client_id}/{part_number}/regulatory", response_model=dict)
async def get_part_regulatory_info(
client_id: int,
part_number: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Get regulatory information for a part (FDA, FCC, ECCN, etc.)
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = PartService(db)
part = service.get_part(client_id, part_number)
if not part:
raise HTTPException(
status_code=404,
detail=f"Part with client_id '{client_id}' and part_number '{part_number}' not found",
)
return {
"client_id": part.client_id,
"part_number": part.part_number,
"fraction": part.fraction,
"us_fraction": part.us_fraction,
"fda_key": part.fda_key,
"fcc_key": part.fcc_key,
"license_code": part.license_code,
"eccn": part.eccn,
"export_code": part.export_code,
"exclusion_symbol": part.exclusion_symbol,
}
router = TenantCRUDRoutes(
service=PartService,
create_schema=PartCreateDTO,
update_schema=PartUpdateDTO,
response_schema=PartResponseDTO,
prefix="/parts",
tags=["a76 / parts"],
resource_name="Part",
id_name="part_id",
enable_list=True,
enable_filters=True,
).router

View File

@@ -1,309 +1,179 @@
"""
Capa de servicio para lógica de negocio de partes/componentes
"""
import logging
from typing import List, Optional
from typing import Any, Dict, List, Optional
from fastapi import HTTPException
from sqlalchemy import and_, func, or_
from sqlalchemy import or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .dto import PartCreateDTO, PartUpdateDTO
# Importamos el modelo PRINCIPAL
from .models import Part
# Importamos TODOS los DTOs necesarios
from .dto import (
PartCreateDTO,
PartUpdateDTO,
PartResponseDTO,
PartBasicDTO # ¡Importante tener este!
)
logger = logging.getLogger(__name__)
class PartService:
"""
Servicio para gestión de partes/componentes
"""
"""Servicio para gestión de Partes (Anexo 76 + Anexo 24)"""
@staticmethod
def create_part(db: Session, part_data: PartCreateDTO) -> Part:
"""
Crear una nueva parte
"""
def get_all(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
) -> tuple[List[Part], int]:
query = db.query(Part).filter(
Part.tenant_id == tenant_id,
Part.company_id == company_id
)
if filters:
if filters.get("q"):
search = f"%{filters['q']}%"
query = query.filter(
or_(
Part.part_number.ilike(search),
Part.description_spanish.ilike(search),
Part.commercial_part_number.ilike(search)
)
)
# Otros filtros...
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
@staticmethod
def get_by_id(db: Session, part_id: int, tenant_id: int, company_id: int) -> Optional[Part]:
return db.query(Part).filter(
Part.id == part_id,
Part.tenant_id == tenant_id,
Part.company_id == company_id
).first()
@staticmethod
def create(db: Session, part_data: PartCreateDTO, tenant_id: int, company_id: int) -> Part:
# 1. Preparar datos
data = part_data.model_dump()
# Separar datos anidados
fa_dict = data.pop('fa_data', None)
inv_dict = data.pop('inv_data', None)
# Inyectar IDs de contexto (Seguridad Multi-tenant)
data['company_id'] = company_id
data['tenant_id'] = tenant_id
# 2. Verificar duplicados (Usando la UniqueConstraint del modelo)
existing = db.query(Part).filter(
Part.tenant_id == tenant_id,
Part.company_id == company_id,
Part.part_number == data['part_number']
).first()
if existing:
raise HTTPException(
status_code=400,
detail=f"El número de parte '{data['part_number']}' ya existe."
)
# 3. Crear objeto Part
db_part = Part(**data)
# 4. Crear relaciones (Anexo 24)
# Importamos aquí para evitar ciclos, usando las rutas de tu modelo
if fa_dict:
from api.v1.modules.a24.fa.fa_parts.models import FaPart
# Importante: Pasar tenant/company también al hijo
db_part.fa_data = FaPart(**fa_dict, tenant_id=tenant_id, company_id=company_id)
if inv_dict:
from api.v1.modules.a24.inv.inv_parts.models import InvPart
db_part.inv_data = InvPart(**inv_dict, tenant_id=tenant_id, company_id=company_id)
try:
db_part = Part(**part_data.model_dump())
db.add(db_part)
db.commit()
db.refresh(db_part)
return db_part
except IntegrityError as e:
db.rollback()
err_msg = str(e.orig)
logger.error(f"Error DB creando parte: {err_msg}")
if "foreign key" in err_msg:
if "unit_of_measure" in err_msg:
raise HTTPException(400, "La Unidad de Medida no existe en el catálogo.")
if "currency_key" in err_msg:
raise HTTPException(400, "La Moneda no existe en el catálogo.")
if "part_class" in err_msg:
raise HTTPException(400, "La Clase no existe para este cliente.")
# El error genérico si falla Company/Client
raise HTTPException(400, "Error de referencia: Verifique Cliente, Compañía o Catálogos.")
raise HTTPException(400, "Error al guardar la parte.")
@staticmethod
def update(db: Session, part_id: int, tenant_id: int, part_data: PartUpdateDTO, company_id: int) -> Optional[Part]:
db_part = PartService.get_by_id(db, part_id, tenant_id, company_id)
if not db_part:
return None
data = part_data.model_dump(exclude_unset=True)
fa_dict = data.pop('fa_data', None)
inv_dict = data.pop('inv_data', None)
# Actualizar campos directos
for key, value in data.items():
setattr(db_part, key, value)
# Actualizar FA Data
if fa_dict is not None:
if db_part.fa_data:
for k, v in fa_dict.items():
setattr(db_part.fa_data, k, v)
else:
from api.v1.modules.a24.fa.fa_parts.models import FaPart
db_part.fa_data = FaPart(**fa_dict, tenant_id=tenant_id, company_id=company_id)
# Actualizar INV Data
if inv_dict is not None:
if db_part.inv_data:
for k, v in inv_dict.items():
setattr(db_part.inv_data, k, v)
else:
from api.v1.modules.a24.inv.inv_parts.models import InvPart
db_part.inv_data = InvPart(**inv_dict, tenant_id=tenant_id, company_id=company_id)
try:
db.commit()
db.refresh(db_part)
return db_part
except IntegrityError as e:
db.rollback()
logger.error(f"Error creating part: {e}")
raise HTTPException(
status_code=400,
detail="Part with this client_id and part_number already exists",
)
except Exception as e:
db.rollback()
logger.error(f"Unexpected error creating part: {e}")
raise HTTPException(status_code=500, detail="Error creating part")
raise HTTPException(400, f"Error actualizando: {str(e.orig)}")
@staticmethod
def get_part(db: Session, client_id: int, part_number: str) -> Optional[Part]:
"""
Obtener una parte por clave de cliente y número de parte
"""
def delete(db: Session, part_id: int, tenant_id: int, company_id: int) -> bool:
db_part = PartService.get_by_id(db, part_id, tenant_id, company_id)
if not db_part: return False
try:
return (
db.query(Part)
.filter(
and_(Part.client_id == client_id, Part.part_number == part_number)
)
.first()
)
except Exception as e:
logger.error(f"Error getting part: {e}")
raise HTTPException(status_code=500, detail="Error retrieving part")
@staticmethod
def get_parts_paginated(
db: Session,
skip: int = 0,
limit: int = 100,
search: Optional[str] = None,
client_id: Optional[int] = None,
fraction: Optional[str] = None,
country_of_origin: Optional[str] = None,
) -> tuple[List[Part], int]:
"""
Obtener partes con paginación y filtros
"""
try:
query = db.query(Part)
# Aplicar filtros
if search:
query = query.filter(
or_(
Part.description_spanish.ilike(f"%{search}%"),
Part.description_english.ilike(f"%{search}%"),
Part.part_number.ilike(f"%{search}%"),
)
)
if client_id is not None:
query = query.filter(Part.client_id == client_id)
if fraction:
query = query.filter(Part.fraction == fraction)
if country_of_origin:
query = query.filter(Part.country_of_origin == country_of_origin)
# Contar total
total = query.count()
# Aplicar paginación
parts = query.offset(skip).limit(limit).all()
return parts, total
except Exception as e:
logger.error(f"Error getting paginated parts: {e}")
raise HTTPException(status_code=500, detail="Error retrieving parts")
@staticmethod
def get_parts_by_client(db: Session, client_id: int) -> List[Part]:
"""
Obtener todas las partes de un cliente específico
"""
try:
return db.query(Part).filter(Part.client_id == client_id).all()
except Exception as e:
logger.error(f"Error getting parts by client: {e}")
raise HTTPException(status_code=500, detail="Error retrieving client parts")
@staticmethod
def search_parts_by_fraction(db: Session, fraction: str) -> List[Part]:
"""
Buscar partes por fracción arancelaria
"""
try:
return (
db.query(Part)
.filter(
or_(
Part.fraction.ilike(f"%{fraction}%"),
Part.us_fraction.ilike(f"%{fraction}%"),
)
)
.all()
)
except Exception as e:
logger.error(f"Error searching parts by fraction: {e}")
raise HTTPException(
status_code=500, detail="Error searching parts by fraction"
)
@staticmethod
def search_parts_by_supplier(db: Session, supplier: str) -> List[Part]:
"""
Buscar partes por proveedor
"""
try:
return db.query(Part).filter(Part.supplier.ilike(f"%{supplier}%")).all()
except Exception as e:
logger.error(f"Error searching parts by supplier: {e}")
raise HTTPException(
status_code=500, detail="Error searching parts by supplier"
)
@staticmethod
def search_parts_by_country(db: Session, country_code: str) -> List[Part]:
"""
Buscar partes por país de origen
"""
try:
return db.query(Part).filter(Part.country_of_origin == country_code).all()
except Exception as e:
logger.error(f"Error searching parts by country: {e}")
raise HTTPException(
status_code=500, detail="Error searching parts by country"
)
@staticmethod
def update_part(
db: Session, client_id: int, part_number: str, part_data: PartUpdateDTO
) -> Optional[Part]:
"""
Actualizar una parte existente
"""
try:
db_part = PartService.get_part(db, client_id, part_number)
if not db_part:
return None
# Actualizar campos
for field, value in part_data.model_dump(exclude_unset=True).items():
setattr(db_part, field, value)
db.commit()
db.refresh(db_part)
return db_part
except Exception as e:
db.rollback()
logger.error(f"Error updating part: {e}")
raise HTTPException(status_code=500, detail="Error updating part")
@staticmethod
def delete_part(db: Session, client_id: int, part_number: str) -> bool:
"""
Eliminar una parte
"""
try:
db_part = PartService.get_part(db, client_id, part_number)
if not db_part:
return False
db.delete(db_part)
db.commit()
return True
except Exception as e:
except Exception:
db.rollback()
logger.error(f"Error deleting part: {e}")
raise HTTPException(status_code=500, detail="Error deleting part")
@staticmethod
def toggle_part_status(
db: Session, client_id: int, part_number: str
) -> Optional[Part]:
"""
Cambiar el estado habilitado/deshabilitado de una parte
"""
try:
db_part = PartService.get_part(db, client_id, part_number)
if not db_part:
return None
# Toggle status (assuming 1 = enabled, 0 = disabled)
db_part.is_active = 1 if db_part.is_active == 0 else 0
db.commit()
db.refresh(db_part)
return db_part
except Exception as e:
db.rollback()
logger.error(f"Error toggling part status: {e}")
raise HTTPException(status_code=500, detail="Error toggling part status")
@staticmethod
def get_parts_statistics(db: Session) -> dict:
"""
Obtener estadísticas de partes
"""
try:
total_parts = db.query(Part).count()
# Partes por cliente
parts_by_client = (
db.query(Part.client_id, func.count(Part.part_number).label("count"))
.group_by(Part.client_id)
.all()
)
# Partes por país de origen
parts_by_country = (
db.query(
Part.country_of_origin, func.count(Part.part_number).label("count")
)
.filter(Part.country_of_origin.isnot(None))
.group_by(Part.country_of_origin)
.all()
)
# Partes habilitadas vs deshabilitadas
enabled_parts = db.query(Part).filter(Part.is_active == 1).count()
disabled_parts = db.query(Part).filter(Part.is_active == 0).count()
return {
"total_parts": total_parts,
"enabled_parts": enabled_parts,
"disabled_parts": disabled_parts,
"parts_by_client": [
{"client_id": item[0], "count": item[1]} for item in parts_by_client
],
"parts_by_country": [
{"country": item[0], "count": item[1]} for item in parts_by_country
],
}
except Exception as e:
logger.error(f"Error getting parts statistics: {e}")
raise HTTPException(
status_code=500, detail="Error retrieving parts statistics"
)
@staticmethod
def get_part_regulatory_info(
db: Session, client_id: int, part_number: str
) -> Optional[dict]:
"""
Obtener información regulatoria específica de una parte
"""
try:
db_part = PartService.get_part(db, client_id, part_number)
if not db_part:
return None
return {
"client_id": db_part.client_id,
"part_number": db_part.part_number,
"fraction": db_part.fraction,
"us_fraction": db_part.us_fraction,
"fda_key": db_part.fda_key,
"fcc_key": db_part.fcc_key,
"license_code": db_part.license_code,
"eccn": db_part.eccn,
"export_code": db_part.export_code,
"exclusion_symbol": db_part.exclusion_symbol,
"country_of_origin": db_part.country_of_origin,
}
except Exception as e:
logger.error(f"Error getting part regulatory info: {e}")
raise HTTPException(
status_code=500, detail="Error retrieving part regulatory information"
)
raise HTTPException(500, "Error eliminando parte")

View File

@@ -23,6 +23,7 @@ class PedimentoConfigAdditionalBase(BaseModel):
None, description="Send 502 validation file for consolidated"
)
add_remove_norms: Optional[bool] = Field(None, description="Add/remove norms")
choose_invoice_cove: Optional[bool] = Field(None, description="Choose invoice COVE in items")
class PedimentoConfigAdditionalCreate(PedimentoConfigAdditionalBase):
@@ -40,6 +41,7 @@ class PedimentoConfigAdditionalUpdate(BaseModel):
enable_import_invoice_recipient: Optional[bool] = None
send_502_validation_file_for_consolidated: Optional[bool] = None
add_remove_norms: Optional[bool] = None
choose_invoice_cove: Optional[bool] = None
class PedimentoConfigAdditionalResponse(PedimentoConfigAdditionalBase):

View File

@@ -0,0 +1,49 @@
from decimal import Decimal
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
class PedimentoContributionBase(BaseModel):
"""Base schema for Pedimento Contribution"""
pedimento_id: Optional[int] = None
contribucion: Optional[str] = None
tipo_tasa: Optional[str] = None
tasa: Optional[Decimal] = None
forma_pago: Optional[str] = None
importe: Optional[Decimal] = None
gravamen: Optional[str] = None
abreviacion: Optional[str] = None
forma_pago_2: Optional[str] = None
importe_2: Optional[Decimal] = None
class PedimentoContributionCreate(PedimentoContributionBase):
"""Schema for creating Pedimento Contribution"""
pass
class PedimentoContributionUpdate(BaseModel):
"""Schema for updating Pedimento Contribution"""
contribucion: Optional[str] = None
tipo_tasa: Optional[str] = None
tasa: Optional[Decimal] = None
forma_pago: Optional[str] = None
importe: Optional[Decimal] = None
gravamen: Optional[str] = None
abreviacion: Optional[str] = None
forma_pago_2: Optional[str] = None
importe_2: Optional[Decimal] = None
class PedimentoContributionResponse(PedimentoContributionBase):
"""Schema for Pedimento Contribution response"""
id: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)

View File

@@ -7,9 +7,9 @@ from pydantic import BaseModel, ConfigDict, Field
class PedimentoDatesBase(BaseModel):
"""Base schema for Pedimento Dates"""
entry_date: Optional[datetime] = Field(None, description="Entry date")
entry_date: datetime = Field(..., description="Entry date")
pedimento_date: Optional[datetime] = Field(None, description="Pedimento date")
payment_date: datetime = Field(..., description="Payment date")
payment_date: Optional[datetime] = Field(None, description="Payment date")
rectification_payment_date: Optional[datetime] = Field(
None, description="Rectification payment date"
)
@@ -18,7 +18,7 @@ class PedimentoDatesBase(BaseModel):
eucan_date: Optional[datetime] = Field(None, description="EUCAN date")
original_date: Optional[datetime] = Field(None, description="Original date")
start_date: Optional[datetime] = Field(None, description="Start date")
end_date: Optional[datetime] = Field(None, description="End date")
end_date: datetime = Field(..., description="End date")
class PedimentoDatesCreate(BaseModel):
@@ -26,14 +26,14 @@ class PedimentoDatesCreate(BaseModel):
entry_date: datetime = Field(..., description="Entry date")
pedimento_date: Optional[datetime] = Field(None, description="Pedimento date")
payment_date: datetime = Field(..., description="Payment date")
payment_date: Optional[datetime] = Field(None, description="Payment date")
rectification_payment_date: Optional[datetime] = Field(None, description="Rectification payment date")
extraction_date: Optional[datetime] = Field(None, description="Extraction date")
submission_date: Optional[datetime] = Field(None, description="Submission date")
eucan_date: Optional[datetime] = Field(None, description="EUCAN date")
original_date: Optional[datetime] = Field(None, description="Original date")
start_date: Optional[datetime] = Field(None, description="Start date")
end_date: Optional[datetime] = Field(None, description="End date")
end_date: datetime = Field(..., description="End date")
class PedimentoDatesUpdate(BaseModel):

View File

@@ -0,0 +1,178 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
class PedimentoPackagesBase(BaseModel):
"""Base schema for Pedimento Packages"""
pedimento_id: Optional[int] = None
quantity: Optional[int] = None
brand: Optional[str] = None
number: Optional[str] = None
vehicles: Optional[int] = None
class PedimentoPackagesCreate(PedimentoPackagesBase):
"""Schema for creating Pedimento Packages"""
pass
class PedimentoPackagesUpdate(BaseModel):
"""Schema for updating Pedimento Packages"""
quantity: Optional[int] = None
brand: Optional[str] = None
number: Optional[str] = None
vehicles: Optional[int] = None
class PedimentoPackagesResponse(PedimentoPackagesBase):
"""Schema for Pedimento Packages response"""
id: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class PedimentoTransportCarrierBase(BaseModel):
"""Base schema for Pedimento Transport Carrier"""
pedimento_id: Optional[int] = None
carrier: Optional[str] = None
rfc: Optional[str] = None
curp: Optional[str] = None
name: Optional[str] = None
address: Optional[str] = None
city: Optional[str] = None
state: Optional[str] = None
country: Optional[str] = None
tax_id: Optional[str] = None
total_packages: Optional[int] = None
identification: Optional[str] = None
class PedimentoTransportCarrierCreate(PedimentoTransportCarrierBase):
"""Schema for creating Pedimento Transport Carrier"""
pass
class PedimentoTransportCarrierUpdate(BaseModel):
"""Schema for updating Pedimento Transport Carrier"""
carrier: Optional[str] = None
rfc: Optional[str] = None
curp: Optional[str] = None
name: Optional[str] = None
address: Optional[str] = None
city: Optional[str] = None
state: Optional[str] = None
country: Optional[str] = None
tax_id: Optional[str] = None
total_packages: Optional[int] = None
identification: Optional[str] = None
class PedimentoTransportCarrierResponse(PedimentoTransportCarrierBase):
"""Schema for Pedimento Transport Carrier response"""
id: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class PedimentoSealBase(BaseModel):
"""Base schema for Pedimento Seal"""
pedimento_id: Optional[int] = None
number: Optional[str] = None
identification: Optional[str] = None
class PedimentoSealCreate(PedimentoSealBase):
"""Schema for creating Pedimento Seal"""
pass
class PedimentoSealUpdate(BaseModel):
"""Schema for updating Pedimento Seal"""
number: Optional[str] = None
identification: Optional[str] = None
class PedimentoSealResponse(PedimentoSealBase):
"""Schema for Pedimento Seal response"""
id: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class PedimentoContainerBase(BaseModel):
"""Base schema for Pedimento Container"""
pedimento_id: Optional[int] = None
number: Optional[str] = None
identification: Optional[str] = None
type: Optional[str] = None
class PedimentoContainerCreate(PedimentoContainerBase):
"""Schema for creating Pedimento Container"""
pass
class PedimentoContainerUpdate(BaseModel):
"""Schema for updating Pedimento Container"""
number: Optional[str] = None
identification: Optional[str] = None
type: Optional[str] = None
class PedimentoContainerResponse(PedimentoContainerBase):
"""Schema for Pedimento Container response"""
id: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class PedimentoGuideBase(BaseModel):
"""Base schema for Pedimento Guide"""
pedimento_id: Optional[int] = None
guide: Optional[str] = None
identifier: Optional[str] = None
class PedimentoGuideCreate(PedimentoGuideBase):
"""Schema for creating Pedimento Guide"""
pass
class PedimentoGuideUpdate(BaseModel):
"""Schema for updating Pedimento Guide"""
guide: Optional[str] = None
identifier: Optional[str] = None
class PedimentoGuideResponse(PedimentoGuideBase):
"""Schema for Pedimento Guide response"""
id: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,33 +1,80 @@
from datetime import datetime
from decimal import Decimal
from enum import IntEnum
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
from .pedimento_config_additional import PedimentoConfigAdditionalCreate, PedimentoConfigAdditionalResponse
from .pedimento_config_calculations import PedimentoConfigCalculationsCreate, PedimentoConfigCalculationsResponse
from .pedimento_config_parameters import PedimentoConfigParametersCreate, PedimentoConfigParametersResponse
from .pedimento_config_surcharges import PedimentoConfigSurchargesCreate, PedimentoConfigSurchargesResponse
from .pedimento_config_update_rectification import PedimentoConfigUpdateRectificationCreate, PedimentoConfigUpdateRectificationResponse
from .pedimento_config_updates import PedimentoConfigUpdatesCreate, PedimentoConfigUpdatesResponse
from .pedimento_customs_offices import PedimentoCustomsOfficesCreate, PedimentoCustomsOfficesResponse
from ..models.pedimentos import OperationType, PedimentoType
from .pedimento_config_additional import (
PedimentoConfigAdditionalCreate,
PedimentoConfigAdditionalResponse,
)
from .pedimento_config_calculations import (
PedimentoConfigCalculationsCreate,
PedimentoConfigCalculationsResponse,
)
from .pedimento_config_parameters import (
PedimentoConfigParametersCreate,
PedimentoConfigParametersResponse,
)
from .pedimento_config_surcharges import (
PedimentoConfigSurchargesCreate,
PedimentoConfigSurchargesResponse,
)
from .pedimento_config_update_rectification import (
PedimentoConfigUpdateRectificationCreate,
PedimentoConfigUpdateRectificationResponse,
)
from .pedimento_config_updates import (
PedimentoConfigUpdatesCreate,
PedimentoConfigUpdatesResponse,
)
from .pedimento_customs_offices import (
PedimentoCustomsOfficesCreate,
PedimentoCustomsOfficesResponse,
)
from .pedimento_dates import PedimentoDatesCreate, PedimentoDatesResponse
from .pedimento_decrementables import PedimentoDecrementablesCreate, PedimentoDecrementablesResponse
from .pedimento_incrementables import PedimentoIncrementablesCreate, PedimentoIncrementablesResponse
from .pedimento_decrementables import (
PedimentoDecrementablesCreate,
PedimentoDecrementablesResponse,
)
from .pedimento_incrementables import (
PedimentoIncrementablesCreate,
PedimentoIncrementablesResponse,
)
from .pedimento_indexes import PedimentoIndexesCreate, PedimentoIndexesResponse
from .pedimento_packages_transport import (
PedimentoContainerCreate,
PedimentoContainerResponse,
PedimentoPackagesCreate,
PedimentoPackagesResponse,
PedimentoSealCreate,
PedimentoSealResponse,
PedimentoTransportCarrierCreate,
PedimentoTransportCarrierResponse,
PedimentoGuideCreate,
PedimentoGuideResponse,
)
from .pedimento_contributions import (
PedimentoContributionCreate,
PedimentoContributionResponse,
)
from .pedimento_payments import PedimentoPaymentsCreate, PedimentoPaymentsResponse
from .pedimento_rectification_destination import PedimentoRectificationDestinationCreate, PedimentoRectificationDestinationResponse
from .pedimento_rectification_origin import PedimentoRectificationOriginCreate, PedimentoRectificationOriginResponse
from .pedimento_transport_means import PedimentoTransportMeansCreate, PedimentoTransportMeansResponse
from .pedimento_rectification_destination import (
PedimentoRectificationDestinationCreate,
PedimentoRectificationDestinationResponse,
)
from .pedimento_rectification_origin import (
PedimentoRectificationOriginCreate,
PedimentoRectificationOriginResponse,
)
from .pedimento_transport_means import (
PedimentoTransportMeansCreate,
PedimentoTransportMeansResponse,
)
from .pedimento_validation import PedimentoValidationCreate, PedimentoValidationResponse
class OperationType(IntEnum):
EXPORTACION = 1
IMPORTACION = 2
class PedimentosBase(BaseModel):
"""Base schema for Pedimentos"""
@@ -40,18 +87,17 @@ class PedimentosBase(BaseModel):
None, max_length=7, description="Pedimento number"
)
client_id: Optional[int] = Field(None, description="Client ID")
operation_type: Optional[int] = Field(None, description="Operation type")
pedimento_type: Optional[str] = Field(None, max_length=20, description="Pedimento type")
pedimento_code: str = Field(
..., max_length=2, description="Pedimento key"
)
operation_type: Optional[OperationType] = Field(None, description="Operation type")
pedimento_type: Optional[PedimentoType] = Field(None, description="Pedimento type")
pedimento_code: str = Field(..., max_length=2, description="Pedimento key")
regime: str = Field(..., max_length=3, description="Regime")
status: Optional[str] = Field(None, max_length=30, description="Status")
usd_value: Optional[Decimal] = Field(None, description="USD value")
paid_price: Optional[Decimal] = Field(None, description="Paid price")
gross_weight: Optional[Decimal] = Field(None, description="Gross weight")
exchange_rate: Optional[Decimal] = Field(None, description="Exchange rate")
observations: Optional[str] = Field(None, description="Observations")
observations: Optional[str] = Field(None, description="Observations")
class PedimentosCreate(PedimentosBase):
"""Schema for creating a new Pedimento"""
@@ -63,27 +109,36 @@ class PedimentosCreate(PedimentosBase):
pedimento_number: str = Field(..., max_length=7, description="Pedimento number")
client_id: int = Field(..., description="Client ID")
# operation_type, pedimento_type, status son opcionales - se pueden llenar después
pedimento_code: str = Field(
..., max_length=2, description="Pedimento key"
)
regime: str = Field(..., max_length=3, description="Regime")
pedimento_code: str = Field(..., max_length=2, description="Pedimento key")
regime: str = Field(..., max_length=3, description="Regime")
pedimento_dates: Optional[PedimentoDatesCreate] = None
pedimento_decrementables: Optional[PedimentoDecrementablesCreate] = None
pedimento_incrementables: Optional[PedimentoIncrementablesCreate] = None
pedimento_indexes: Optional[PedimentoIndexesCreate] = None
pedimento_validation: Optional[PedimentoValidationCreate] = None
pedimento_customs_offices: Optional[PedimentoCustomsOfficesCreate] = None
pedimento_validation: Optional[PedimentoValidationCreate] = None
pedimento_customs_offices: Optional[PedimentoCustomsOfficesCreate] = None
pedimento_payments: Optional[PedimentoPaymentsCreate] = None
pedimento_rectification_destination: Optional[PedimentoRectificationDestinationCreate] = None
pedimento_rectification_destination: Optional[
PedimentoRectificationDestinationCreate
] = None
pedimento_rectification_origin: Optional[PedimentoRectificationOriginCreate] = None
pedimento_transport_means: Optional[PedimentoTransportMeansCreate] = None
pedimento_transport_means: Optional[PedimentoTransportMeansCreate] = None
pedimento_config_additional: Optional[PedimentoConfigAdditionalCreate] = None
pedimento_config_calculations: Optional[PedimentoConfigCalculationsCreate] = None
pedimento_config_parameters: Optional[PedimentoConfigParametersCreate] = None
pedimento_config_surcharges: Optional[PedimentoConfigSurchargesCreate] = None
pedimento_config_update_rectification: Optional[PedimentoConfigUpdateRectificationCreate] = None
pedimento_config_update_rectification: Optional[
PedimentoConfigUpdateRectificationCreate
] = None
pedimento_config_updates: Optional[PedimentoConfigUpdatesCreate] = None
pedimento_packages: Optional[PedimentoPackagesCreate] = None
pedimento_transport_carriers: Optional[list[PedimentoTransportCarrierCreate]] = None
pedimento_guides: Optional[list[PedimentoGuideCreate]] = None
pedimento_contributions: Optional[list[PedimentoContributionCreate]] = None
pedimento_seals: Optional[list[PedimentoSealCreate]] = None
pedimento_containers: Optional[list[PedimentoContainerCreate]] = None
class PedimentosUpdate(BaseModel):
"""Schema for updating a Pedimento"""
@@ -93,7 +148,7 @@ class PedimentosUpdate(BaseModel):
license: Optional[str] = Field(None, max_length=4)
pedimento_number: Optional[str] = Field(None, max_length=7)
client_id: Optional[int] = None
operation_type: Optional[int] = None
operation_type: Optional[str] = Field(None, max_length=3)
pedimento_type: Optional[str] = Field(None, max_length=20)
pedimento_code: Optional[str] = Field(None, max_length=2)
regime: Optional[str] = Field(None, max_length=3)
@@ -103,24 +158,34 @@ class PedimentosUpdate(BaseModel):
gross_weight: Optional[Decimal] = None
exchange_rate: Optional[Decimal] = None
observations: Optional[str] = None
# Sub-resources
pedimento_dates: Optional[PedimentoDatesCreate] = None
pedimento_decrementables: Optional[PedimentoDecrementablesCreate] = None
pedimento_incrementables: Optional[PedimentoIncrementablesCreate] = None
pedimento_indexes: Optional[PedimentoIndexesCreate] = None
pedimento_validation: Optional[PedimentoValidationCreate] = None
pedimento_customs_offices: Optional[PedimentoCustomsOfficesCreate] = None
pedimento_validation: Optional[PedimentoValidationCreate] = None
pedimento_customs_offices: Optional[PedimentoCustomsOfficesCreate] = None
pedimento_payments: Optional[PedimentoPaymentsCreate] = None
pedimento_rectification_destination: Optional[PedimentoRectificationDestinationCreate] = None
pedimento_rectification_destination: Optional[
PedimentoRectificationDestinationCreate
] = None
pedimento_rectification_origin: Optional[PedimentoRectificationOriginCreate] = None
pedimento_transport_means: Optional[PedimentoTransportMeansCreate] = None
pedimento_transport_means: Optional[PedimentoTransportMeansCreate] = None
pedimento_config_additional: Optional[PedimentoConfigAdditionalCreate] = None
pedimento_config_calculations: Optional[PedimentoConfigCalculationsCreate] = None
pedimento_config_parameters: Optional[PedimentoConfigParametersCreate] = None
pedimento_config_surcharges: Optional[PedimentoConfigSurchargesCreate] = None
pedimento_config_update_rectification: Optional[PedimentoConfigUpdateRectificationCreate] = None
pedimento_config_update_rectification: Optional[
PedimentoConfigUpdateRectificationCreate
] = None
pedimento_config_updates: Optional[PedimentoConfigUpdatesCreate] = None
pedimento_packages: Optional[PedimentoPackagesCreate] = None
pedimento_transport_carriers: Optional[list[PedimentoTransportCarrierCreate]] = None
pedimento_guides: Optional[list[PedimentoGuideCreate]] = None
pedimento_contributions: Optional[list[PedimentoContributionCreate]] = None
pedimento_seals: Optional[list[PedimentoSealCreate]] = None
pedimento_containers: Optional[list[PedimentoContainerCreate]] = None
class PedimentosResponse(PedimentosBase):
@@ -129,22 +194,34 @@ class PedimentosResponse(PedimentosBase):
id: int
tenant_id: int
created_at: datetime
pedimento_dates: Optional[PedimentoDatesResponse] = None
pedimento_decrementables: Optional[PedimentoDecrementablesResponse] = None
pedimento_incrementables: Optional[PedimentoIncrementablesResponse] = None
pedimento_indexes: Optional[PedimentoIndexesResponse] = None
pedimento_validation: Optional[PedimentoValidationResponse] = None
pedimento_customs_offices: Optional[PedimentoCustomsOfficesResponse] = None
pedimento_validation: Optional[PedimentoValidationResponse] = None
pedimento_customs_offices: Optional[PedimentoCustomsOfficesResponse] = None
pedimento_payments: Optional[PedimentoPaymentsResponse] = None
pedimento_rectification_destination: Optional[PedimentoRectificationDestinationResponse] = None
pedimento_rectification_origin: Optional[PedimentoRectificationOriginResponse] = None
pedimento_transport_means: Optional[PedimentoTransportMeansResponse] = None
pedimento_rectification_destination: Optional[
PedimentoRectificationDestinationResponse
] = None
pedimento_rectification_origin: Optional[PedimentoRectificationOriginResponse] = (
None
)
pedimento_transport_means: Optional[PedimentoTransportMeansResponse] = None
pedimento_config_additional: Optional[PedimentoConfigAdditionalResponse] = None
pedimento_config_calculations: Optional[PedimentoConfigCalculationsResponse] = None
pedimento_config_parameters: Optional[PedimentoConfigParametersResponse] = None
pedimento_config_surcharges: Optional[PedimentoConfigSurchargesResponse] = None
pedimento_config_update_rectification: Optional[PedimentoConfigUpdateRectificationResponse] = None
pedimento_config_update_rectification: Optional[
PedimentoConfigUpdateRectificationResponse
] = None
pedimento_config_updates: Optional[PedimentoConfigUpdatesResponse] = None
pedimento_packages: Optional[PedimentoPackagesResponse] = None
pedimento_transport_carriers: Optional[list[PedimentoTransportCarrierResponse]] = None
pedimento_guides: Optional[list[PedimentoGuideResponse]] = None
pedimento_contributions: Optional[list[PedimentoContributionResponse]] = None
pedimento_seals: Optional[list[PedimentoSealResponse]] = None
pedimento_containers: Optional[list[PedimentoContainerResponse]] = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,84 @@
"""Models for pedimentos module"""
# Import all models to ensure they are registered with SQLAlchemy
from api.v1.modules.a76.pedmientos.models.pedimento_config_additional import (
PedimentoConfigAdditional,
)
from api.v1.modules.a76.pedmientos.models.pedimento_config_calculations import (
PedimentoConfigCalculations,
)
from api.v1.modules.a76.pedmientos.models.pedimento_config_parameters import (
PedimentoConfigParameters,
)
from api.v1.modules.a76.pedmientos.models.pedimento_config_surcharges import (
PedimentoConfigSurcharges,
)
from api.v1.modules.a76.pedmientos.models.pedimento_config_update_rectification import (
PedimentoConfigUpdateRectification,
)
from api.v1.modules.a76.pedmientos.models.pedimento_config_updates import (
PedimentoConfigUpdates,
)
from api.v1.modules.a76.pedmientos.models.pedimento_containers import (
PedimentoContainers,
)
from api.v1.modules.a76.pedmientos.models.pedimento_contributions import (
PedimentoContributions,
)
from api.v1.modules.a76.pedmientos.models.pedimento_customs_offices import (
PedimentoCustomsOffices,
)
from api.v1.modules.a76.pedmientos.models.pedimento_dates import PedimentoDates
from api.v1.modules.a76.pedmientos.models.pedimento_decrementables import (
PedimentoDecrementables,
)
from api.v1.modules.a76.pedmientos.models.pedimento_incrementables import (
PedimentoIncrementables,
)
from api.v1.modules.a76.pedmientos.models.pedimento_indexes import PedimentoIndexes
from api.v1.modules.a76.pedmientos.models.pedimento_packages import PedimentoPackages
from api.v1.modules.a76.pedmientos.models.pedimento_payments import PedimentoPayments
from api.v1.modules.a76.pedmientos.models.pedimento_guides import PedimentoGuides
from api.v1.modules.a76.pedmientos.models.pedimento_rectification_destination import (
PedimentoRectificationDestination,
)
from api.v1.modules.a76.pedmientos.models.pedimento_rectification_origin import (
PedimentoRectificationOrigin,
)
from api.v1.modules.a76.pedmientos.models.pedimento_seals import PedimentoSeals
from api.v1.modules.a76.pedmientos.models.pedimento_transport_carriers import (
PedimentoTransportCarriers,
)
from api.v1.modules.a76.pedmientos.models.pedimento_transport_means import (
PedimentoTransportMeans,
)
from api.v1.modules.a76.pedmientos.models.pedimento_validation import (
PedimentoValidation,
)
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
__all__ = [
"Pedimentos",
"PedimentoConfigAdditional",
"PedimentoConfigCalculations",
"PedimentoConfigParameters",
"PedimentoConfigSurcharges",
"PedimentoConfigUpdateRectification",
"PedimentoConfigUpdates",
"PedimentoContainers",
"PedimentoContributions",
"PedimentoCustomsOffices",
"PedimentoDates",
"PedimentoDecrementables",
"PedimentoIncrementables",
"PedimentoIndexes",
"PedimentoPackages",
"PedimentoGuides",
"PedimentoPayments",
"PedimentoRectificationDestination",
"PedimentoRectificationOrigin",
"PedimentoSeals",
"PedimentoTransportCarriers",
"PedimentoTransportMeans",
"PedimentoValidation",
]

View File

@@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import (
Boolean,
Boolean,
ForeignKeyConstraint,
Integer,

View File

@@ -0,0 +1,39 @@
from typing import TYPE_CHECKING
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import (
ForeignKeyConstraint,
Integer,
PrimaryKeyConstraint,
String,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
if TYPE_CHECKING:
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
class PedimentoContainers(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "pedimento_containers"
__table_args__ = (
PrimaryKeyConstraint("id", name="pedimento_containers_pkey"),
ForeignKeyConstraint(
["pedimento_id"],
["a76.pedimentos.id"],
ondelete="CASCADE",
name="fk_pedimento_containers",
),
{"schema": "a76"},
)
id: Mapped[int] = mapped_column(Integer)
pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False)
number: Mapped[str | None] = mapped_column(String(100), nullable=True)
identification: Mapped[str | None] = mapped_column(String(100), nullable=True)
type: Mapped[str | None] = mapped_column(String(50), nullable=True)
pedimento: Mapped["Pedimentos"] = relationship(
"Pedimentos", back_populates="pedimento_containers"
)

View File

@@ -0,0 +1,46 @@
from typing import TYPE_CHECKING
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import (
ForeignKeyConstraint,
Integer,
Numeric,
PrimaryKeyConstraint,
String,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
if TYPE_CHECKING:
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
class PedimentoContributions(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "pedimento_contributions"
__table_args__ = (
PrimaryKeyConstraint("id", name="pedimento_contributions_pkey"),
ForeignKeyConstraint(
["pedimento_id"],
["a76.pedimentos.id"],
ondelete="CASCADE",
name="fk_pedimento_contributions",
),
{"schema": "a76"},
)
id: Mapped[int] = mapped_column(Integer)
pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False)
contribucion: Mapped[str | None] = mapped_column(String(50), nullable=True)
tipo_tasa: Mapped[str | None] = mapped_column(String(100), nullable=True)
tasa: Mapped[Numeric | None] = mapped_column(Numeric(15, 8), nullable=True)
forma_pago: Mapped[str | None] = mapped_column(String(50), nullable=True)
importe: Mapped[Numeric | None] = mapped_column(Numeric(17, 2), nullable=True)
gravamen: Mapped[str | None] = mapped_column(String(100), nullable=True)
abreviacion: Mapped[str | None] = mapped_column(String(50), nullable=True)
forma_pago_2: Mapped[str | None] = mapped_column(String(50), nullable=True)
importe_2: Mapped[Numeric | None] = mapped_column(Numeric(17, 2), nullable=True)
pedimento: Mapped["Pedimentos"] = relationship(
"Pedimentos", back_populates="pedimento_contributions"
)

View File

@@ -42,16 +42,16 @@ class PedimentoDates(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(Integer)
pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False)
entry_date: Mapped[Optional[datetime]] = mapped_column(DateTime)
entry_date: Mapped[datetime] = mapped_column(DateTime)
pedimento_date: Mapped[Optional[datetime]] = mapped_column(DateTime)
payment_date: Mapped[datetime] = mapped_column(DateTime)
payment_date: Mapped[Optional[datetime]] = mapped_column(DateTime)
rectification_payment_date: Mapped[Optional[datetime]] = mapped_column(DateTime)
extraction_date: Mapped[Optional[datetime]] = mapped_column(DateTime)
submission_date: Mapped[Optional[datetime]] = mapped_column(DateTime)
eucan_date: Mapped[Optional[datetime]] = mapped_column(DateTime)
original_date: Mapped[Optional[datetime]] = mapped_column(DateTime)
start_date: Mapped[Optional[datetime]] = mapped_column(DateTime)
end_date: Mapped[Optional[datetime]] = mapped_column(DateTime)
end_date: Mapped[datetime] = mapped_column(DateTime)
capture_time: Mapped[datetime_time] = mapped_column(Time)

View File

@@ -1,5 +1,5 @@
from decimal import Decimal
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
@@ -40,15 +40,15 @@ class PedimentoDecrementables(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(Integer)
pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False)
freight: Mapped[Decimal] = mapped_column(Numeric(13, 2))
insurance: Mapped[Decimal] = mapped_column(Numeric(13, 2))
loading: Mapped[Decimal] = mapped_column(Numeric(13, 2))
unloading: Mapped[Decimal] = mapped_column(Numeric(13, 2))
others: Mapped[Decimal] = mapped_column(Numeric(13, 2))
currency: Mapped[str] = mapped_column(String(3))
currency_factor: Mapped[Decimal] = mapped_column(Numeric(15, 8))
not_affect_usd_value: Mapped[int] = mapped_column(SmallInteger)
not_affect_customs_value: Mapped[int] = mapped_column(SmallInteger)
freight: Mapped[Optional[Decimal]] = mapped_column(Numeric(13, 2))
insurance: Mapped[Optional[Decimal]] = mapped_column(Numeric(13, 2))
loading: Mapped[Optional[Decimal]] = mapped_column(Numeric(13, 2))
unloading: Mapped[Optional[Decimal]] = mapped_column(Numeric(13, 2))
others: Mapped[Optional[Decimal]] = mapped_column(Numeric(13, 2))
currency: Mapped[Optional[str]] = mapped_column(String(3))
currency_factor: Mapped[Optional[Decimal]] = mapped_column(Numeric(15, 8))
not_affect_usd_value: Mapped[Optional[int]] = mapped_column(SmallInteger)
not_affect_customs_value: Mapped[Optional[int]] = mapped_column(SmallInteger)
pedimento: Mapped["Pedimentos"] = relationship(
"Pedimentos", back_populates="pedimento_decrementables"

View File

@@ -0,0 +1,38 @@
from typing import TYPE_CHECKING
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import (
ForeignKeyConstraint,
Integer,
PrimaryKeyConstraint,
String,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
if TYPE_CHECKING:
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
class PedimentoGuides(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "pedimento_guides"
__table_args__ = (
PrimaryKeyConstraint("id", name="pedimento_guides_pkey"),
ForeignKeyConstraint(
["pedimento_id"],
["a76.pedimentos.id"],
ondelete="CASCADE",
name="fk_pedimento_guides",
),
{"schema": "a76"},
)
id: Mapped[int] = mapped_column(Integer)
pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False)
guide: Mapped[str | None] = mapped_column(String(100), nullable=True)
identifier: Mapped[str | None] = mapped_column(String(100), nullable=True)
pedimento: Mapped["Pedimentos"] = relationship(
"Pedimentos", back_populates="pedimento_guides"
)

View File

@@ -1,5 +1,5 @@
from decimal import Decimal
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
@@ -40,16 +40,16 @@ class PedimentoIncrementables(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(Integer)
pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False)
insured_value: Mapped[Decimal] = mapped_column(Numeric(13, 2))
freight: Mapped[Decimal] = mapped_column(Numeric(13, 2))
insurance: Mapped[Decimal] = mapped_column(Numeric(13, 2))
packaging: Mapped[Decimal] = mapped_column(Numeric(13, 2))
others: Mapped[Decimal] = mapped_column(Numeric(13, 3))
deductibles: Mapped[Decimal] = mapped_column(Numeric(13, 3))
currency: Mapped[str] = mapped_column(String(3))
currency_factor: Mapped[Decimal] = mapped_column(Numeric(15, 8))
not_affect_usd_value: Mapped[int] = mapped_column(SmallInteger)
not_affect_customs_value: Mapped[int] = mapped_column(SmallInteger)
insured_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(13, 2))
freight: Mapped[Optional[Decimal]] = mapped_column(Numeric(13, 2))
insurance: Mapped[Optional[Decimal]] = mapped_column(Numeric(13, 2))
packaging: Mapped[Optional[Decimal]] = mapped_column(Numeric(13, 2))
others: Mapped[Optional[Decimal]] = mapped_column(Numeric(13, 3))
deductibles: Mapped[Optional[Decimal]] = mapped_column(Numeric(13, 3))
currency: Mapped[Optional[str]] = mapped_column(String(3))
currency_factor: Mapped[Optional[Decimal]] = mapped_column(Numeric(15, 8))
not_affect_usd_value: Mapped[Optional[int]] = mapped_column(SmallInteger)
not_affect_customs_value: Mapped[Optional[int]] = mapped_column(SmallInteger)
pedimento: Mapped["Pedimentos"] = relationship(
"Pedimentos", back_populates="pedimento_incrementables"

View File

@@ -1,5 +1,5 @@
from decimal import Decimal
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
@@ -39,9 +39,9 @@ class PedimentoIndexes(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(Integer)
pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False)
update_factor_type: Mapped[int] = mapped_column(SmallInteger)
update_factor: Mapped[Decimal] = mapped_column(Numeric(7, 4))
manual_update_factor: Mapped[int] = mapped_column(SmallInteger)
update_factor_type: Mapped[Optional[int]] = mapped_column(SmallInteger)
update_factor: Mapped[Optional[Decimal]] = mapped_column(Numeric(7, 4))
manual_update_factor: Mapped[Optional[int]] = mapped_column(SmallInteger)
pedimento: Mapped["Pedimentos"] = relationship(
"Pedimentos", back_populates="pedimento_indexes"

View File

@@ -0,0 +1,48 @@
from typing import TYPE_CHECKING
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import (
ForeignKeyConstraint,
Integer,
Numeric,
PrimaryKeyConstraint,
String,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
if TYPE_CHECKING:
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
class PedimentoPackages(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "pedimento_packages"
__table_args__ = (
PrimaryKeyConstraint("id", name="pedimento_packages_pkey"),
ForeignKeyConstraint(
["pedimento_id"],
["a76.pedimentos.id"],
ondelete="CASCADE",
name="fk_pedimento_packages",
),
UniqueConstraint(
"tenant_id",
"company_id",
"pedimento_id",
name="pedimento_packages_pedimento_id_key",
),
{"schema": "a76"},
)
id: Mapped[int] = mapped_column(Integer)
pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False)
quantity: Mapped[int | None] = mapped_column(Integer, nullable=True)
brand: Mapped[str | None] = mapped_column(String(100), nullable=True)
number: Mapped[str | None] = mapped_column(String(100), nullable=True)
vehicles: Mapped[int | None] = mapped_column(Integer, nullable=True)
pedimento: Mapped["Pedimentos"] = relationship(
"Pedimentos", back_populates="pedimento_packages"
)

View File

@@ -0,0 +1,38 @@
from typing import TYPE_CHECKING
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import (
ForeignKeyConstraint,
Integer,
PrimaryKeyConstraint,
String,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
if TYPE_CHECKING:
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
class PedimentoSeals(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "pedimento_seals"
__table_args__ = (
PrimaryKeyConstraint("id", name="pedimento_seals_pkey"),
ForeignKeyConstraint(
["pedimento_id"],
["a76.pedimentos.id"],
ondelete="CASCADE",
name="fk_pedimento_seals",
),
{"schema": "a76"},
)
id: Mapped[int] = mapped_column(Integer)
pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False)
number: Mapped[str | None] = mapped_column(String(100), nullable=True)
identification: Mapped[str | None] = mapped_column(String(100), nullable=True)
pedimento: Mapped["Pedimentos"] = relationship(
"Pedimentos", back_populates="pedimento_seals"
)

View File

@@ -0,0 +1,47 @@
from typing import TYPE_CHECKING
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import (
ForeignKeyConstraint,
Integer,
PrimaryKeyConstraint,
String,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
if TYPE_CHECKING:
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
class PedimentoTransportCarriers(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "pedimento_transport_carriers"
__table_args__ = (
PrimaryKeyConstraint("id", name="pedimento_transport_carriers_pkey"),
ForeignKeyConstraint(
["pedimento_id"],
["a76.pedimentos.id"],
ondelete="CASCADE",
name="fk_pedimento_transport_carriers",
),
{"schema": "a76"},
)
id: Mapped[int] = mapped_column(Integer)
pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False)
carrier: Mapped[str | None] = mapped_column(String(200), nullable=True)
rfc: Mapped[str | None] = mapped_column(String(20), nullable=True)
curp: Mapped[str | None] = mapped_column(String(20), nullable=True)
name: Mapped[str | None] = mapped_column(String(200), nullable=True)
address: Mapped[str | None] = mapped_column(String(300), nullable=True)
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
state: Mapped[str | None] = mapped_column(String(100), nullable=True)
country: Mapped[str | None] = mapped_column(String(3), nullable=True)
tax_id: Mapped[str | None] = mapped_column(String(50), nullable=True)
total_packages: Mapped[int | None] = mapped_column(Integer, nullable=True)
identification: Mapped[str | None] = mapped_column(String(100), nullable=True)
pedimento: Mapped["Pedimentos"] = relationship(
"Pedimentos", back_populates="pedimento_transport_carriers"
)

View File

@@ -1,4 +1,5 @@
from decimal import Decimal
from enum import Enum
from typing import TYPE_CHECKING, Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
@@ -34,6 +35,9 @@ if TYPE_CHECKING:
from api.v1.modules.a76.pedmientos.models.pedimento_config_updates import (
PedimentoConfigUpdates,
)
from api.v1.modules.a76.pedmientos.models.pedimento_containers import (
PedimentoContainers,
)
from api.v1.modules.a76.pedmientos.models.pedimento_customs_offices import (
PedimentoCustomsOffices,
)
@@ -45,6 +49,15 @@ if TYPE_CHECKING:
PedimentoIncrementables,
)
from api.v1.modules.a76.pedmientos.models.pedimento_indexes import PedimentoIndexes
from api.v1.modules.a76.pedmientos.models.pedimento_packages import (
PedimentoPackages,
)
from api.v1.modules.a76.pedmientos.models.pedimento_guides import (
PedimentoGuides,
)
from api.v1.modules.a76.pedmientos.models.pedimento_contributions import (
PedimentoContributions,
)
from api.v1.modules.a76.pedmientos.models.pedimento_payments import (
PedimentoPayments,
)
@@ -54,6 +67,12 @@ if TYPE_CHECKING:
from api.v1.modules.a76.pedmientos.models.pedimento_rectification_origin import (
PedimentoRectificationOrigin,
)
from api.v1.modules.a76.pedmientos.models.pedimento_seals import (
PedimentoSeals,
)
from api.v1.modules.a76.pedmientos.models.pedimento_transport_carriers import (
PedimentoTransportCarriers,
)
from api.v1.modules.a76.pedmientos.models.pedimento_transport_means import (
PedimentoTransportMeans,
)
@@ -62,6 +81,17 @@ if TYPE_CHECKING:
)
class PedimentoType(str, Enum):
NORMAL = "normal"
CONSOLIDATED = "consolidated"
COMPLEMENTARY = "complementary"
AUTOMOBILE = "automobile"
class OperationType(str, Enum):
IMP = "imp" # Importación
EXP = "exp" # Exportación
class Pedimentos(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "pedimentos"
__table_args__ = (
@@ -99,8 +129,8 @@ class Pedimentos(Base, TenantScopedMixin, TimestampMixin):
license: Mapped[str] = mapped_column(String(4))
pedimento_number: Mapped[str] = mapped_column(String(7))
client_id: Mapped[int] = mapped_column(Integer)
operation_type: Mapped[int] = mapped_column(Integer)
pedimento_type: Mapped[str] = mapped_column(String(20))
operation_type: Mapped[OperationType] = mapped_column(String(3))
pedimento_type: Mapped[PedimentoType] = mapped_column(String(20))
pedimento_code: Mapped[str] = mapped_column(String(2))
regime: Mapped[str] = mapped_column(String(3))
status: Mapped[Optional[str]] = mapped_column(String(30))
@@ -111,59 +141,122 @@ class Pedimentos(Base, TenantScopedMixin, TimestampMixin):
observations: Mapped[Optional[str]] = mapped_column(Text)
pedimento_config_additional: Mapped["PedimentoConfigAdditional"] = relationship(
"PedimentoConfigAdditional", uselist=False, back_populates="pedimento", cascade="all, delete-orphan"
"PedimentoConfigAdditional",
uselist=False,
back_populates="pedimento",
cascade="all, delete-orphan",
)
pedimento_config_calculations: Mapped["PedimentoConfigCalculations"] = relationship(
"PedimentoConfigCalculations", uselist=False, back_populates="pedimento", cascade="all, delete-orphan"
"PedimentoConfigCalculations",
uselist=False,
back_populates="pedimento",
cascade="all, delete-orphan",
)
pedimento_config_parameters: Mapped["PedimentoConfigParameters"] = relationship(
"PedimentoConfigParameters", uselist=False, back_populates="pedimento", cascade="all, delete-orphan"
"PedimentoConfigParameters",
uselist=False,
back_populates="pedimento",
cascade="all, delete-orphan",
)
pedimento_config_surcharges: Mapped["PedimentoConfigSurcharges"] = relationship(
"PedimentoConfigSurcharges", uselist=False, back_populates="pedimento", cascade="all, delete-orphan"
"PedimentoConfigSurcharges",
uselist=False,
back_populates="pedimento",
cascade="all, delete-orphan",
)
pedimento_config_update_rectification: Mapped[
"PedimentoConfigUpdateRectification"
] = relationship(
"PedimentoConfigUpdateRectification", uselist=False, back_populates="pedimento", cascade="all, delete-orphan"
"PedimentoConfigUpdateRectification",
uselist=False,
back_populates="pedimento",
cascade="all, delete-orphan",
)
pedimento_config_updates: Mapped["PedimentoConfigUpdates"] = relationship(
"PedimentoConfigUpdates", uselist=False, back_populates="pedimento", cascade="all, delete-orphan"
"PedimentoConfigUpdates",
uselist=False,
back_populates="pedimento",
cascade="all, delete-orphan",
)
pedimento_customs_offices: Mapped["PedimentoCustomsOffices"] = relationship(
"PedimentoCustomsOffices", uselist=False, back_populates="pedimento", cascade="all, delete-orphan"
"PedimentoCustomsOffices",
uselist=False,
back_populates="pedimento",
cascade="all, delete-orphan",
)
pedimento_dates: Mapped["PedimentoDates"] = relationship(
"PedimentoDates", uselist=False, back_populates="pedimento", cascade="all, delete-orphan"
"PedimentoDates",
uselist=False,
back_populates="pedimento",
cascade="all, delete-orphan",
)
pedimento_decrementables: Mapped["PedimentoDecrementables"] = relationship(
"PedimentoDecrementables", uselist=False, back_populates="pedimento", cascade="all, delete-orphan"
"PedimentoDecrementables",
uselist=False,
back_populates="pedimento",
cascade="all, delete-orphan",
)
pedimento_incrementables: Mapped["PedimentoIncrementables"] = relationship(
"PedimentoIncrementables", uselist=False, back_populates="pedimento", cascade="all, delete-orphan"
"PedimentoIncrementables",
uselist=False,
back_populates="pedimento",
cascade="all, delete-orphan",
)
pedimento_indexes: Mapped["PedimentoIndexes"] = relationship(
"PedimentoIndexes", uselist=False, back_populates="pedimento", cascade="all, delete-orphan"
"PedimentoIndexes",
uselist=False,
back_populates="pedimento",
cascade="all, delete-orphan",
)
pedimento_payments: Mapped["PedimentoPayments"] = relationship(
"PedimentoPayments", uselist=False, back_populates="pedimento", cascade="all, delete-orphan"
"PedimentoPayments",
uselist=False,
back_populates="pedimento",
cascade="all, delete-orphan",
)
pedimento_rectification_destination: Mapped["PedimentoRectificationDestination"] = (
relationship(
"PedimentoRectificationDestination",
uselist=False,
back_populates="pedimento",
cascade="all, delete-orphan"
cascade="all, delete-orphan",
)
)
pedimento_rectification_origin: Mapped["PedimentoRectificationOrigin"] = (
relationship(
"PedimentoRectificationOrigin", uselist=False, back_populates="pedimento", cascade="all, delete-orphan"
"PedimentoRectificationOrigin",
uselist=False,
back_populates="pedimento",
cascade="all, delete-orphan",
)
)
pedimento_transport_means: Mapped["PedimentoTransportMeans"] = relationship(
"PedimentoTransportMeans", uselist=False, back_populates="pedimento", cascade="all, delete-orphan"
"PedimentoTransportMeans",
uselist=False,
back_populates="pedimento",
cascade="all, delete-orphan",
)
pedimento_validation: Mapped["PedimentoValidation"] = relationship(
"PedimentoValidation", uselist=False, back_populates="pedimento", cascade="all, delete-orphan"
"PedimentoValidation",
uselist=False,
back_populates="pedimento",
cascade="all, delete-orphan",
)
pedimento_packages: Mapped["PedimentoPackages"] = relationship(
"PedimentoPackages", uselist=False, back_populates="pedimento", cascade="all, delete-orphan"
)
pedimento_transport_carriers: Mapped[list["PedimentoTransportCarriers"]] = relationship(
"PedimentoTransportCarriers", back_populates="pedimento", cascade="all, delete-orphan"
)
pedimento_guides: Mapped[list["PedimentoGuides"]] = relationship(
"PedimentoGuides", back_populates="pedimento", cascade="all, delete-orphan"
)
pedimento_contributions: Mapped[list["PedimentoContributions"]] = relationship(
"PedimentoContributions", back_populates="pedimento", cascade="all, delete-orphan"
)
pedimento_seals: Mapped[list["PedimentoSeals"]] = relationship(
"PedimentoSeals", back_populates="pedimento", cascade="all, delete-orphan"
)
pedimento_containers: Mapped[list["PedimentoContainers"]] = relationship(
"PedimentoContainers", back_populates="pedimento", cascade="all, delete-orphan"
)

View File

@@ -48,6 +48,12 @@ from ..models.pedimento_config_parameters import PedimentoConfigParameters
from ..models.pedimento_config_surcharges import PedimentoConfigSurcharges
from ..models.pedimento_config_update_rectification import PedimentoConfigUpdateRectification
from ..models.pedimento_config_updates import PedimentoConfigUpdates
from ..models.pedimento_packages import PedimentoPackages
from ..models.pedimento_transport_carriers import PedimentoTransportCarriers
from ..models.pedimento_seals import PedimentoSeals
from ..models.pedimento_containers import PedimentoContainers
from ..models.pedimento_guides import PedimentoGuides
from ..models.pedimento_contributions import PedimentoContributions
logger = logging.getLogger(__name__)
@@ -110,6 +116,12 @@ class PedimentosService:
selectinload(Pedimentos.pedimento_config_surcharges),
selectinload(Pedimentos.pedimento_config_update_rectification),
selectinload(Pedimentos.pedimento_config_updates),
selectinload(Pedimentos.pedimento_packages),
selectinload(Pedimentos.pedimento_transport_carriers),
selectinload(Pedimentos.pedimento_guides),
selectinload(Pedimentos.pedimento_contributions),
selectinload(Pedimentos.pedimento_seals),
selectinload(Pedimentos.pedimento_containers),
)
.order_by(desc(Pedimentos.created_at))
.offset(skip)
@@ -160,6 +172,12 @@ class PedimentosService:
selectinload(Pedimentos.pedimento_config_surcharges),
selectinload(Pedimentos.pedimento_config_update_rectification),
selectinload(Pedimentos.pedimento_config_updates),
selectinload(Pedimentos.pedimento_packages),
selectinload(Pedimentos.pedimento_transport_carriers),
selectinload(Pedimentos.pedimento_guides),
selectinload(Pedimentos.pedimento_contributions),
selectinload(Pedimentos.pedimento_seals),
selectinload(Pedimentos.pedimento_containers),
)
return query.first()
@@ -199,6 +217,12 @@ class PedimentosService:
'pedimento_config_surcharges': pedimento_data.pedimento_config_surcharges,
'pedimento_config_update_rectification': pedimento_data.pedimento_config_update_rectification,
'pedimento_config_updates': pedimento_data.pedimento_config_updates,
'pedimento_packages': pedimento_data.pedimento_packages,
'pedimento_transport_carriers': pedimento_data.pedimento_transport_carriers,
'pedimento_guides': pedimento_data.pedimento_guides,
'pedimento_contributions': getattr(pedimento_data, 'pedimento_contributions', None),
'pedimento_seals': pedimento_data.pedimento_seals,
'pedimento_containers': pedimento_data.pedimento_containers,
}
# Crear pedimento principal (excluyendo relaciones)
@@ -209,7 +233,9 @@ class PedimentosService:
'pedimento_rectification_origin', 'pedimento_transport_means',
'pedimento_config_additional', 'pedimento_config_calculations',
'pedimento_config_parameters', 'pedimento_config_surcharges',
'pedimento_config_update_rectification', 'pedimento_config_updates'
'pedimento_config_update_rectification', 'pedimento_config_updates',
'pedimento_packages', 'pedimento_transport_carriers', 'pedimento_guides',
'pedimento_contributions', 'pedimento_seals', 'pedimento_containers'
})
pedimento = Pedimentos(**pedimento_dict)
@@ -219,7 +245,7 @@ class PedimentosService:
db.add(pedimento)
db.flush() # Flush para obtener el ID sin commit
# Helper function para crear objetos relacionados
# Helper function para crear objetos relacionados (uno a uno)
def create_related(model_class, data, extra_fields=None):
if data or extra_fields:
# Inicializar obj_dict desde data si existe, sino como dict vacío
@@ -274,6 +300,29 @@ class PedimentosService:
create_related(PedimentoConfigUpdates,
related_data['pedimento_config_updates'])
# Crear PedimentoPackages (uno a uno)
create_related(PedimentoPackages, related_data['pedimento_packages'])
# Crear relaciones uno a muchos
def create_many(model_class, items):
if not items:
return
for item in items:
obj_dict = item.model_dump(exclude_none=True)
obj = model_class(**obj_dict)
obj.pedimento_id = pedimento.id
obj.tenant_id = tenant_id
obj.company_id = company_id
db.add(obj)
create_many(PedimentoTransportCarriers,
related_data['pedimento_transport_carriers'])
create_many(PedimentoGuides, related_data['pedimento_guides'])
create_many(PedimentoContributions, related_data['pedimento_contributions'])
create_many(PedimentoSeals, related_data['pedimento_seals'])
create_many(PedimentoContainers,
related_data['pedimento_containers'])
db.commit()
db.refresh(pedimento)
return pedimento
@@ -323,7 +372,8 @@ class PedimentosService:
'pedimento_rectification_origin', 'pedimento_transport_means',
'pedimento_config_additional', 'pedimento_config_calculations',
'pedimento_config_parameters', 'pedimento_config_surcharges',
'pedimento_config_update_rectification', 'pedimento_config_updates'
'pedimento_config_update_rectification', 'pedimento_config_updates',
'pedimento_packages', 'pedimento_transport_carriers', 'pedimento_guides', 'pedimento_contributions', 'pedimento_seals', 'pedimento_containers'
})
for field, value in update_data.items():
@@ -343,13 +393,20 @@ class PedimentosService:
if not data:
return
existing = service_class.get_by_pedimento_id(
db, pedimento_id, tenant_id, company_id)
existing = None
if service_class:
existing = service_class.get_by_pedimento_id(
db, pedimento_id, tenant_id, company_id)
else:
existing = getattr(pedimento, data_attr, None)
if existing:
# Actualizar existente (excluir pedimento_id, tenant_id, company_id)
# Solo actualizar valores no-None para evitar sobrescribir con None
for field, value in data.items():
if hasattr(existing, field) and field not in ('pedimento_id', 'tenant_id', 'company_id'):
setattr(existing, field, value)
if value is not None:
setattr(existing, field, value)
else:
# Crear nuevo
obj = model_class(**data)
@@ -358,6 +415,28 @@ class PedimentosService:
obj.company_id = company_id
db.add(obj)
def upsert_one_to_many(model_class, data_attr):
full_data = pedimento_data.model_dump()
if data_attr not in full_data:
return
items = full_data[data_attr]
if items is None:
return
# Reemplazar completamente la colección por simplicidad
existing_items = getattr(pedimento, data_attr)
if existing_items:
for item in list(existing_items):
db.delete(item)
for item in items:
obj = model_class(**item)
obj.pedimento_id = pedimento_id
obj.tenant_id = tenant_id
obj.company_id = company_id
db.add(obj)
# Actualizar o crear tablas relacionadas
update_or_create_related(
PedimentoDatesService, PedimentoDates, 'pedimento_dates')
@@ -392,6 +471,15 @@ class PedimentosService:
update_or_create_related(
PedimentoConfigUpdatesService, PedimentoConfigUpdates, 'pedimento_config_updates')
# Manejar nuevas relaciones
update_or_create_related(None, PedimentoPackages, 'pedimento_packages')
upsert_one_to_many(PedimentoTransportCarriers,
'pedimento_transport_carriers')
upsert_one_to_many(PedimentoGuides, 'pedimento_guides')
upsert_one_to_many(PedimentoContributions, 'pedimento_contributions')
upsert_one_to_many(PedimentoSeals, 'pedimento_seals')
upsert_one_to_many(PedimentoContainers, 'pedimento_containers')
db.commit()
db.refresh(pedimento)
return pedimento

View File

@@ -15,11 +15,16 @@ from .clients_and_providers import router as client_and_provider_router
from .general_catalogs.company import router as company_router
from .country_rule_oct.routes import router as country_rule_oct_router
from .transportation.drivers.routes import router as drivers_router
from .doc_types_dig.routes import router as doc_types_dig_router
from .general_catalogs.exchange_rate.routes import router as exchange_rate_router
from .general_catalogs.identifiers.routes import router as identifiers_router
from .fraction_rule_octave.routes import router as fraction_rule_octave_router
from .general_catalogs.packages.routes import router as package_router
from .general_catalogs.ports.routes import router as ports_router
from .general_catalogs.tariff_fractions.routes import router as tariff_fractions_router
from .general_catalogs.us_tariff_fractions.routes import router as us_tariff_fractions_router
from .general_catalogs.depreciation_catalog.routes import router as depreciation_catalog_router
from .general_catalogs.fda_catalog.routes import router as fda_catalog_router
from .parts import router as parts_router
from .pedmientos.router import router as pedimentos_router
from .permission_rule_oct.routes import router as permission_rule_oct_router
@@ -41,6 +46,8 @@ from .general_catalogs.electronic_notices.routes import router as electronic_not
from .transportation.trailers.routes import router as trailers_router
from .transportation.transporters.routes import router as transporters_router
from .transportation.vehicles.routes import router as vehicles_router
from api.v1.modules.public.reference_data.material_types.routes import router as material_types_router
# Router principal
router = APIRouter()
@@ -60,6 +67,10 @@ router.include_router(
)
router.include_router(package_router, prefix="/a76")
router.include_router(ports_router, prefix="/a76")
router.include_router(tariff_fractions_router, prefix="/a76")
router.include_router(us_tariff_fractions_router, prefix="/a76")
router.include_router(depreciation_catalog_router, prefix="/a76")
router.include_router(fda_catalog_router, prefix="/a76")
router.include_router(seal_router, prefix="/a76", tags=["a76 / seal"])
router.include_router(units_of_measure_router, prefix="/a76")
router.include_router(
@@ -75,8 +86,9 @@ router.include_router(trailers_router, prefix="/a76/transportation", tags=["a76
router.include_router(
customs_broker_router, prefix="/a76", tags=["a76 / customs_broker"]
)
router.include_router(drivers_router, prefix="/a76/transportation", tags=["a76 / drivers"])
router.include_router(transporters_router, prefix="/a76/transportation",
router.include_router(doc_types_dig_router, prefix="/a76", tags=["a76 / document_types_digitization"])
router.include_router(drivers_router, prefix="/a76", tags=["a76 / drivers"])
router.include_router(transporters_router, prefix="/a76",
tags=["a76 / transporters"])
router.include_router(vehicles_router, prefix="/a76/transportation", tags=["a76 / vehicles"])
@@ -94,3 +106,11 @@ router.include_router(error_catalogs_router, prefix="/a76")
router.include_router(doda_router, prefix="/a76")
router.include_router(prevalidators_router, prefix="/a76")
router.include_router(electronic_notices_router, prefix="/a76")
# Registrar router de tipos de material públicos
router.include_router(
material_types_router,
prefix="/public/reference-data",
tags=["Reference Data"]
)