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

This commit is contained in:
2026-02-20 09:31:13 -06:00
294 changed files with 24472 additions and 5361 deletions

View File

@@ -16,7 +16,7 @@ from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
if TYPE_CHECKING:
from api.v1.modules.a76.items.line_items.models import LineItem
from api.v1.modules.a76.items.models import LineItem
class FaLineItem(Base, TenantScopedMixin, TimestampMixin):
@@ -67,9 +67,6 @@ class FaLineItem(Base, TenantScopedMixin, TimestampMixin):
# Subitems
is_subitem: Mapped[Optional[bool]] = mapped_column(Boolean) # ESSUBPARTIDA
contains_subitems: Mapped[Optional[bool]] = mapped_column(Boolean) # CONTIENESUBP
includes_subitems: Mapped[Optional[bool]] = mapped_column(
Boolean
) # INCUYESUBPARTIDAS
subitem_number: Mapped[Optional[int]] = mapped_column(Integer) # SUBPARTIDA
# Special flags

View File

@@ -96,7 +96,7 @@ class FaLineItemService:
"""Crear una nueva línea de activo fijo"""
try:
# Verificar que la línea base existe en a76.item_lines
from api.v1.modules.a76.items.line_items.models import LineItem
from api.v1.modules.a76.items.models import LineItem
base_line_item = (
db.query(LineItem)

View File

@@ -1,11 +1,14 @@
"""
Audit Log Events
"""
from sqlalchemy import event, inspect
from sqlalchemy.orm import Session
from .services.service import AuditService
from .utils.serialization import serialize_for_json
from core.context import get_user_context
def register_audit_listeners(models_to_audit):
"""
Register SQLAlchemy listeners for given models
@@ -15,16 +18,23 @@ def register_audit_listeners(models_to_audit):
event.listen(model, "after_update", after_update_listener)
event.listen(model, "after_delete", after_delete_listener)
def _get_current_username():
try:
context = get_user_context()
if context:
# Token usually has 'preferred_username' or 'name' or 'sub'
return context.get("preferred_username") or context.get("email") or context.get("sub") or "System"
return (
context.get("preferred_username")
or context.get("email")
or context.get("sub")
or "System"
)
except:
pass
return "System"
def after_insert_listener(mapper, connection, target):
"""
Listener for INSERT operations
@@ -32,8 +42,9 @@ def after_insert_listener(mapper, connection, target):
table_name = target.__tablename__
record_data = {c.name: getattr(target, c.name) for c in mapper.columns}
username = _get_current_username()
company_id = getattr(target, "company_id", None)
company_id = getattr(target, "company_id", None) or getattr(target, "id", None)
tenant_id = getattr(target, "tenant_id", None)
# Create a session bound to the connection
session = Session(bind=connection)
try:
@@ -44,24 +55,26 @@ def after_insert_listener(mapper, connection, target):
record_data=record_data,
username=username,
record_id=str(getattr(target, "id", "")),
company_id=company_id
company_id=company_id,
tenant_id=tenant_id,
)
except Exception as e:
print(f"Error logging insert: {e}")
finally:
session.close()
def after_update_listener(mapper, connection, target):
"""
Listener for UPDATE operations
"""
table_name = target.__tablename__
state = inspect(target)
changes = {}
old_values = {}
new_values = {}
for attr in state.attrs:
hist = attr.history
if hist.has_changes():
@@ -74,6 +87,8 @@ def after_update_listener(mapper, connection, target):
record_data = {c.name: getattr(target, c.name) for c in mapper.columns}
username = _get_current_username()
company_id = getattr(target, "company_id", None) or getattr(target, "id", None)
tenant_id = getattr(target, "tenant_id", None)
session = Session(bind=connection)
try:
@@ -84,13 +99,16 @@ def after_update_listener(mapper, connection, target):
record_data=record_data,
username=username,
record_id=str(getattr(target, "id", "")),
old_values=old_values,
new_values=new_values
old_values=serialize_for_json(old_values),
new_values=serialize_for_json(new_values),
company_id=company_id,
tenant_id=tenant_id,
)
except Exception as e:
print(f"Error logging update: {e}")
print(f"Error logging update: {e}")
finally:
session.close()
session.close()
def after_delete_listener(mapper, connection, target):
"""
@@ -99,7 +117,9 @@ def after_delete_listener(mapper, connection, target):
table_name = target.__tablename__
record_data = {c.name: getattr(target, c.name) for c in mapper.columns}
username = _get_current_username()
company_id = getattr(target, "company_id", None) or getattr(target, "id", None)
tenant_id = getattr(target, "tenant_id", None)
session = Session(bind=connection)
try:
AuditService.log_crud_operation(
@@ -108,9 +128,11 @@ def after_delete_listener(mapper, connection, target):
operation_type="DELETE",
record_data=record_data,
username=username,
record_id=str(getattr(target, "id", ""))
record_id=str(getattr(target, "id", "")),
company_id=company_id,
tenant_id=tenant_id,
)
except Exception as e:
print(f"Error logging delete: {e}")
print(f"Error logging delete: {e}")
finally:
session.close()
session.close()

View File

@@ -18,5 +18,10 @@ class UserContextMiddleware(BaseHTTPMiddleware):
# Log error or ignore
pass
response = await call_next(request)
try:
response = await call_next(request)
except Exception:
# Re-raise the exception to let other middleware and handlers deal with it
raise
return response

View File

@@ -4,10 +4,18 @@ Audit Log Models
from sqlalchemy import Column, Integer, String, Date, Time, DateTime, Text, Index, func
from sqlalchemy.dialects.postgresql import JSONB, ARRAY
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class AuditLog(Base):
class AuditLog(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "audit_logs"
__table_args__ = (
Index('idx_audit_username_date', 'username', 'date'),
Index('idx_audit_procedure_date', 'procedure', 'date'),
Index('idx_audit_system_timestamp', 'system', 'timestamp'),
Index('idx_audit_table_record', 'table_name', 'record_id'),
{"schema": "a76"} # Use the a76 schema for audit logs
)
# Primary Key
spec_id = Column(Integer, primary_key=True, autoincrement=True)
@@ -22,9 +30,7 @@ class AuditLog(Base):
# Technical Columns
timestamp = Column(DateTime(timezone=True), nullable=False, index=True) # Combined for queries
system = Column(String(20), nullable=False, index=True, default="SCAF")
company_id = Column(Integer, nullable=True, index=True)
tenant_id = Column(Integer, nullable=True, index=True)
system = Column(String(20), nullable=False, index=True, default="fixed_asset")
# Traceability
table_name = Column(String(100), nullable=True, index=True)
@@ -42,15 +48,4 @@ class AuditLog(Base):
endpoint = Column(String(500), nullable=True)
request_method = Column(String(10), nullable=True)
session_id = Column(String(50), nullable=True, index=True)
execution_time_ms = Column(Integer, nullable=True)
# Metadata
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# Composite Indexes for common filters
__table_args__ = (
Index('idx_audit_username_date', 'username', 'date'),
Index('idx_audit_procedure_date', 'procedure', 'date'),
Index('idx_audit_system_timestamp', 'system', 'timestamp'),
Index('idx_audit_table_record', 'table_name', 'record_id'),
)
execution_time_ms = Column(Integer, nullable=True)

View File

@@ -142,6 +142,10 @@ class AuditMapper:
("doda", "CREATE"): "ADD DODA",
("doda", "UPDATE"): "EDIT DODA",
("doda", "DELETE"): "DELETE DODA",
("company", "CREATE"): "ADD COMPANY",
("company", "UPDATE"): "EDIT COMPANY",
("company", "DELETE"): "DELETE COMPANY",
}
@staticmethod

View File

@@ -86,7 +86,8 @@ class AuditService:
# Context
ip_address: Optional[str] = None,
user_agent: Optional[str] = None,
company_id: Optional[int] = None
company_id: Optional[int] = None,
tenant_id: Optional[int] = None
):
"""
High-level wrapper to log CRUD operations automatically mapping to Legacy format
@@ -149,7 +150,8 @@ class AuditService:
changed_fields=changed_fields,
ip_address=ip_address,
user_agent=user_agent,
company_id=company_id
company_id=company_id,
tenant_id=tenant_id
)
@staticmethod

View File

@@ -0,0 +1,7 @@
"""
Audit Log Utilities
"""
from .serialization import serialize_for_json
__all__ = ["serialize_for_json"]

View File

@@ -0,0 +1,44 @@
"""
Serialization utilities for audit logs
"""
from datetime import date, datetime, time
from decimal import Decimal
from uuid import UUID
from typing import Any, Dict
def serialize_value(value: Any) -> Any:
"""
Convert a Python value to a JSON-serializable type
"""
if value is None:
return None
elif isinstance(value, (date, datetime)):
return value.isoformat()
elif isinstance(value, time):
return value.isoformat()
elif isinstance(value, Decimal):
return float(value)
elif isinstance(value, UUID):
return str(value)
elif isinstance(value, bytes):
return value.decode("utf-8", errors="replace")
elif isinstance(value, (list, tuple)):
return [serialize_value(item) for item in value]
elif isinstance(value, dict):
return {key: serialize_value(val) for key, val in value.items()}
else:
# For any other type, try to return as-is (str, int, float, bool, None)
# If it fails JSON serialization later, at least we tried
return value
def serialize_for_json(data: Dict[str, Any]) -> Dict[str, Any]:
"""
Recursively serialize a dictionary for JSON storage
"""
if not data:
return data
return {key: serialize_value(value) for key, value in data.items()}

View File

@@ -196,4 +196,33 @@ class ClassSearchDTO(BaseModel):
)
class Config:
from_attributes = True
from_attributes = True
class ClassWithFADataResponse(BaseModel):
"""DTO para respuesta de clase con datos FA embebidos (para fixed-asset-classes)"""
# Base class fields
id: int
tenant_id: int
company_id: int
class_code: str
description_es: Optional[str] = None
description_en: Optional[str] = None
material_key: Optional[str] = None
unit_of_measure: Optional[str] = None
fraction: Optional[str] = None
us_fraction: Optional[str] = None
sub_key: Optional[str] = None
physical_review: Optional[int] = None
iva_exempt_fraction: Optional[str] = None
created_at: datetime
updated_at: datetime
# FA-specific fields (embedded from a24.fa_classes)
fa_class_id: Optional[int] = None
depreciation_rate: Optional[Decimal] = None
fda_code: Optional[str] = None
class_enabled: Optional[bool] = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -2,34 +2,53 @@
Endpoints API para gestión de clases SCAII y SCAF
"""
from typing import Dict, Any
from fastapi import Depends, Query
from typing import Dict, Any, List
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 ClassCreateDTO, ClassCreateDTOFA, ClassResponseDTO, ClassResponseDTOFA, ClassUpdateDTO
from .dto import ClassCreateDTO, ClassCreateDTOFA, ClassResponseDTO, ClassResponseDTOFA, ClassUpdateDTO, ClassWithFADataResponse
from .service import ClassService
# Create router with generic CRUD routes
crud_routes = TenantCRUDRoutes(
service=ClassService,
create_schema=ClassCreateDTO,
update_schema=ClassUpdateDTO,
response_schema=ClassResponseDTO,
prefix="/classes",
tags=["a76 / classes"],
resource_name="Class",
id_name="id",
enable_list=True,
enable_filters=True,
default_page_size=50,
max_page_size=1000,
)
# Create a new router for custom endpoints
router = APIRouter()
router = crud_routes.router
# Add consolidated catalog endpoints FIRST (before generic CRUD routes)
# This ensures they have priority over the generic /{id} route
@router.get(
"/with-fa-data",
response_model=List[ClassWithFADataResponse],
summary="Get Classes with FA Data",
description="Get all classes with their FA data in a single query (eliminates N+1 problem)",
tags=["a76 / classes"],
)
async def get_classes_with_fa_data(
company_id: int = Query(..., description="Company ID"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(1000, ge=1, le=1000, description="Page size"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Get all classes with their FA data using a single LEFT JOIN query.
This endpoint is optimized for the fixed-asset-classes view.
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
skip = (page - 1) * page_size
classes_with_fa, total = ClassService.get_all_with_fa_data(
db=db,
tenant_id=tenant_id,
company_id=company_id,
skip=skip,
limit=page_size,
)
return classes_with_fa
@router.post(
"/fa",
@@ -37,6 +56,7 @@ router = crud_routes.router
status_code=201,
summary="Create Fixed Asset Class",
description="Create a class with FA extension in a single transaction",
tags=["a76 / classes"],
)
async def create_fa_class(
class_data: ClassCreateDTOFA,
@@ -50,4 +70,24 @@ async def create_fa_class(
result = ClassService.create_fa_class(db, class_data, tenant_id, company_id)
return result
return result
# Now include generic CRUD routes
# These will be registered AFTER the custom endpoints above
crud_router = TenantCRUDRoutes(
service=ClassService,
create_schema=ClassCreateDTO,
update_schema=ClassUpdateDTO,
response_schema=ClassResponseDTO,
prefix="", # No prefix here, will be added in main router
tags=["a76 / classes"],
resource_name="Class",
id_name="id",
enable_list=True,
enable_filters=True,
default_page_size=50,
max_page_size=1000,
).router
# Include the CRUD routes into our main router
router.include_router(crud_router)

View File

@@ -73,6 +73,91 @@ class ClassService:
return items, total
@staticmethod
def get_all_with_fa_data(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 1000,
filters: Optional[Dict[str, Any]] = None,
) -> tuple[List[Dict[str, Any]], int]:
"""
Get all classes with their FA data in a single query using LEFT JOIN.
This eliminates the N+1 query problem.
Returns a list of dicts with combined base class + FA data.
"""
from api.v1.modules.a24.fa.fa_classes.models import QClasses
# Build query with LEFT JOIN
query = (
db.query(Class, QClasses)
.outerjoin(QClasses, and_(
Class.id == QClasses.class_id,
QClasses.tenant_id == tenant_id
))
.filter(Class.tenant_id == tenant_id)
.filter(Class.company_id == company_id)
)
# Apply filters if provided
if filters:
if filters.get("class_code"):
query = query.filter(
Class.class_code.ilike(f"%{filters['class_code']}%")
)
if filters.get("description"):
description_pattern = f"%{filters['description']}%"
query = query.filter(
or_(
Class.description_es.ilike(description_pattern),
Class.description_en.ilike(description_pattern),
)
)
if filters.get("material_key"):
query = query.filter(
Class.material_key.ilike(f"%{filters['material_key']}%")
)
if filters.get("fraction"):
query = query.filter(Class.fraction.ilike(f"%{filters['fraction']}%"))
# Count total before pagination
total = query.count()
# Apply pagination
results = query.offset(skip).limit(limit).all()
# Combine base class + FA data into dicts
combined = []
for base_class, fa_class in results:
class_dict = {
# 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 (None if no FA record exists)
"fa_class_id": fa_class.id if fa_class else None,
"depreciation_rate": fa_class.depreciation_rate if fa_class else None,
"fda_code": fa_class.fda_code if fa_class else None,
"class_enabled": fa_class.class_enabled if fa_class else None,
}
combined.append(class_dict)
return combined, total
@staticmethod
def get_by_id(
db: Session, class_id: int, tenant_id: int, company_id: int

View File

@@ -85,88 +85,149 @@ class CompanyCreateDTO(BaseModel):
)
seventh_amendment: Optional[bool] = Field(None, description="Seventh amendment")
# Sectors
sector1: Optional[str] = Field(None, max_length=150)
sector2: Optional[str] = Field(None, max_length=150)
sector3: Optional[str] = Field(None, max_length=5)
# Certification (CompanyCertification flattened)
is_certified_company: Optional[str] = Field(None, max_length=1)
certified_company_registration: Optional[str] = Field(None, max_length=40)
certified_company_start_date: Optional[int] = None
certified_company_end_date: Optional[int] = None
annex31_certification_date: Optional[int] = None
annex31_certification_number: Optional[str] = Field(None, max_length=50)
annex31_modality: Optional[str] = Field(None, max_length=50)
annex31_company_type: Optional[str] = Field(None, max_length=50)
annex31_renewal_date: Optional[int] = None
annex31_final_certification_date: Optional[int] = None
is_oea_company: Optional[int] = None
neec_company: Optional[int] = None
# Addresses (Flattened)
# Main
main_street: Optional[str] = Field(None, max_length=255)
main_exterior_number: Optional[str] = Field(None, max_length=10)
main_interior_number: Optional[str] = Field(None, max_length=10)
main_postal_code: Optional[str] = Field(None, max_length=5)
main_neighborhood: Optional[str] = Field(None, max_length=255)
main_city: Optional[str] = Field(None, max_length=255)
main_municipality: Optional[str] = Field(None, max_length=255)
main_state: Optional[str] = Field(None, max_length=255)
main_country: Optional[str] = Field(None, max_length=255)
main_phone: Optional[str] = Field(None, max_length=20)
main_fax: Optional[str] = Field(None, max_length=20)
main_email: Optional[str] = Field(None, max_length=255)
# Industrial 1
ind1_street: Optional[str] = Field(None, max_length=255)
ind1_exterior_number: Optional[str] = Field(None, max_length=10)
ind1_interior_number: Optional[str] = Field(None, max_length=10)
ind1_postal_code: Optional[str] = Field(None, max_length=5)
ind1_neighborhood: Optional[str] = Field(None, max_length=255)
ind1_city: Optional[str] = Field(None, max_length=255)
ind1_municipality: Optional[str] = Field(None, max_length=255)
ind1_state: Optional[str] = Field(None, max_length=255)
ind1_country: Optional[str] = Field(None, max_length=255)
ind1_phone: Optional[str] = Field(None, max_length=20)
ind1_fax: Optional[str] = Field(None, max_length=20)
ind1_email: Optional[str] = Field(None, max_length=255)
# Industrial 2
ind2_street: Optional[str] = Field(None, max_length=255)
ind2_exterior_number: Optional[str] = Field(None, max_length=10)
ind2_interior_number: Optional[str] = Field(None, max_length=10)
ind2_postal_code: Optional[str] = Field(None, max_length=5)
ind2_neighborhood: Optional[str] = Field(None, max_length=255)
ind2_city: Optional[str] = Field(None, max_length=255)
ind2_municipality: Optional[str] = Field(None, max_length=255)
ind2_state: Optional[str] = Field(None, max_length=255)
ind2_country: Optional[str] = Field(None, max_length=255)
ind2_phone: Optional[str] = Field(None, max_length=20)
ind2_fax: Optional[str] = Field(None, max_length=20)
ind2_email: Optional[str] = Field(None, max_length=255)
# Technical flags
active_labels: Optional[int] = None
active_fractions: Optional[int] = None
activate_caat: Optional[int] = None
trans_interface: Optional[int] = None
american_costs: Optional[int] = None
scaf_readonly: Optional[int] = None
parts_replacement: Optional[int] = None
activate_facmexame: Optional[int] = None
part_reference: Optional[int] = None
international_firm: Optional[int] = None
# Advanced Config
ftp_key: Optional[str] = Field(None, max_length=10)
sifra_path: Optional[str] = Field(None, max_length=255)
version_type: Optional[str] = Field(None, max_length=20)
sql_language: Optional[str] = Field(None, max_length=19)
balance_operation_mode: Optional[str] = Field(None, max_length=50)
# Prevalidator (detailed)
prev_customs: Optional[str] = Field(None, max_length=20)
prev_key: Optional[str] = Field(None, max_length=20)
prev_patent: Optional[str] = Field(None, max_length=4)
prev_description: Optional[str] = Field(None, max_length=100)
# Ventanilla Única (VU)
vu_webservice_user: Optional[str] = Field(None, max_length=100)
vu_webservice_password: Optional[str] = Field(None, max_length=100)
vu_email: Optional[str] = Field(None, max_length=800)
vu_figure_type: Optional[str] = Field(None, max_length=29)
vu_central_path: Optional[str] = Field(None, max_length=1499)
vu_xml_files_path: Optional[str] = Field(None, max_length=1499)
vu_query_rfc: Optional[str] = Field(None, max_length=30)
vu_validation_rfc: Optional[str] = Field(None, max_length=30)
vu_configuration_source: Optional[str] = Field(None, max_length=30)
vu_measurement_units: Optional[str] = Field(None, max_length=3)
# Electronic Agent
ea_input_folder: Optional[str] = Field(None, max_length=1000)
ea_output_folder: Optional[str] = Field(None, max_length=1000)
ea_send_mask: Optional[str] = Field(None, max_length=20)
ea_response_mask: Optional[str] = Field(None, max_length=20)
ea_response_extension: Optional[str] = Field(None, max_length=20)
ea_counter_start: Optional[int] = None
ea_counter_end: Optional[int] = None
ea_counter_next: Optional[int] = None
# CFDI
cfdi_xml_save_path: Optional[str] = Field(None, max_length=5000)
cfdi_app_path: Optional[str] = Field(None, max_length=5000)
cfdi_pac_app_path: Optional[str] = Field(None, max_length=5000)
# Digital Certificates (CompanyDigitalCertificate flattened)
# FIEL
fiel_cer: Optional[str] = Field(None, max_length=5000)
fiel_key: Optional[str] = Field(None, max_length=5000)
fiel_pass: Optional[str] = Field(None, max_length=200)
fiel_access: Optional[str] = Field(None, max_length=50)
fiel_cer_exp: Optional[int] = None
fiel_key_exp: Optional[int] = None
# CFDI (Sello)
cfdi_cert_cer: Optional[str] = Field(None, max_length=5000)
cfdi_cert_key: Optional[str] = Field(None, max_length=5000)
cfdi_cert_pass: Optional[str] = Field(None, max_length=200)
cfdi_cert_access: Optional[str] = Field(None, max_length=50)
cfdi_cert_cer_exp: Optional[int] = None
cfdi_cert_key_exp: Optional[int] = None
# Cancellation
cancel_cer: Optional[str] = Field(None, max_length=5000)
cancel_key: Optional[str] = Field(None, max_length=5000)
cancel_pass: Optional[str] = Field(None, max_length=200)
cancel_access: Optional[str] = Field(None, max_length=50)
cancel_cer_exp: Optional[int] = None
cancel_key_exp: Optional[int] = None
class Config:
from_attributes = True
class CompanyUpdateDTO(BaseModel):
class CompanyUpdateDTO(CompanyCreateDTO):
"""DTO para actualizar una empresa"""
name: Optional[str] = Field(None, max_length=255, description="Company name")
rfc: Optional[str] = Field(None, max_length=30, description="Company RFC")
main_activity: Optional[str] = Field(
None, max_length=255, description="Main activity"
)
# Program information
program: Optional[str] = Field(None, max_length=10, description="Program")
program_number: Optional[str] = Field(
None, max_length=40, description="Program number"
)
prosec: Optional[int] = Field(None, description="PROSEC")
prosec_authorization: Optional[str] = Field(
None, max_length=20, description="PROSEC authorization"
)
# Identifiers
manufacturer_id: Optional[str] = Field(
None, max_length=25, description="Manufacturer ID"
)
broker_company: Optional[str] = Field(
None, max_length=10, description="Broker company"
)
# Responsible person
responsible: Optional[str] = Field(
None, max_length=80, description="Responsible person"
)
responsible_name: Optional[str] = Field(
None, max_length=20, description="Responsible first name"
)
responsible_last_name: Optional[str] = Field(
None, max_length=20, description="Responsible last name"
)
responsible_mother_last_name: Optional[str] = Field(
None, max_length=20, description="Responsible mother's last name"
)
responsible_rfc: Optional[str] = Field(
None, max_length=30, description="Responsible RFC"
)
position: Optional[str] = Field(
None, max_length=30, description="Responsible position"
)
# Configuration
logo: Optional[str] = Field(None, max_length=255, description="Company logo")
has_express_line: Optional[bool] = Field(None, description="Has express line")
order_format_type: Optional[str] = Field(
None, max_length=19, description="Order format type"
)
previous_code: Optional[int] = Field(None, description="Previous code")
is_service_company: Optional[bool] = Field(None, description="Is service company")
# Client and subassembly
client_name: Optional[str] = Field(None, max_length=300, description="Client name")
subassembly_mode: Optional[str] = Field(
None, max_length=7, description="Subassembly mode"
)
# Additional information
curp: Optional[str] = Field(None, max_length=19, description="CURP")
inter_db_name: Optional[str] = Field(
None, max_length=100, description="Inter DB name"
)
ctpat_svi: Optional[str] = Field(None, max_length=100, description="CTPAT SVI")
trusted_exporter_number: Optional[str] = Field(
None, max_length=50, description="Trusted exporter number"
)
prevalidator_key: Optional[str] = Field(
None, max_length=20, description="Prevalidator key"
)
seventh_amendment: Optional[bool] = Field(None, description="Seventh amendment")
class Config:
from_attributes = True
pass
class CompanyResponseDTO(BaseModel):
@@ -219,5 +280,142 @@ class CompanyResponseDTO(BaseModel):
created_at: datetime
updated_at: Optional[datetime] = None
# --- Flattened Fields for Response ---
# Sectores
sector1: Optional[str] = None
sector2: Optional[str] = None
sector3: Optional[str] = None
# Certification
is_certified_company: Optional[str] = None
certified_company_registration: Optional[str] = None
certified_company_start_date: Optional[int] = None
certified_company_end_date: Optional[int] = None
annex31_certification_date: Optional[int] = None
annex31_certification_number: Optional[str] = None
annex31_modality: Optional[str] = None
annex31_company_type: Optional[str] = None
annex31_renewal_date: Optional[int] = None
annex31_final_certification_date: Optional[int] = None
is_oea_company: Optional[int] = None
neec_company: Optional[int] = None
# Addresses
# ... (Main, Ind1, Ind2 can be added here if needed for flattened response)
main_street: Optional[str] = None
main_exterior_number: Optional[str] = None
main_interior_number: Optional[str] = None
main_postal_code: Optional[str] = None
main_neighborhood: Optional[str] = None
main_city: Optional[str] = None
main_municipality: Optional[str] = None
main_state: Optional[str] = None
main_country: Optional[str] = None
main_phone: Optional[str] = None
main_fax: Optional[str] = None
main_email: Optional[str] = None
ind1_street: Optional[str] = None
ind1_exterior_number: Optional[str] = None
ind1_interior_number: Optional[str] = None
ind1_postal_code: Optional[str] = None
ind1_neighborhood: Optional[str] = None
ind1_city: Optional[str] = None
ind1_municipality: Optional[str] = None
ind1_state: Optional[str] = None
ind1_country: Optional[str] = None
ind1_phone: Optional[str] = None
ind1_fax: Optional[str] = None
ind1_email: Optional[str] = None
ind2_street: Optional[str] = None
ind2_exterior_number: Optional[str] = None
ind2_interior_number: Optional[str] = None
ind2_postal_code: Optional[str] = None
ind2_neighborhood: Optional[str] = None
ind2_city: Optional[str] = None
ind2_municipality: Optional[str] = None
ind2_state: Optional[str] = None
ind2_country: Optional[str] = None
ind2_phone: Optional[str] = None
ind2_fax: Optional[str] = None
ind2_email: Optional[str] = None
# Technical flags
active_labels: Optional[int] = None
active_fractions: Optional[int] = None
activate_caat: Optional[int] = None
trans_interface: Optional[int] = None
american_costs: Optional[int] = None
scaf_readonly: Optional[int] = None
parts_replacement: Optional[int] = None
activate_facmexame: Optional[int] = None
part_reference: Optional[int] = None
international_firm: Optional[int] = None
# Advanced Config
ftp_key: Optional[str] = None
sifra_path: Optional[str] = None
version_type: Optional[str] = None
sql_language: Optional[str] = None
balance_operation_mode: Optional[str] = None
# Prevalidator
prev_customs: Optional[str] = None
prev_key: Optional[str] = None
prev_patent: Optional[str] = None
prev_description: Optional[str] = None
# VU
vu_webservice_user: Optional[str] = None
vu_webservice_password: Optional[str] = None
vu_email: Optional[str] = None
vu_figure_type: Optional[str] = None
vu_central_path: Optional[str] = None
vu_xml_files_path: Optional[str] = None
vu_query_rfc: Optional[str] = None
vu_validation_rfc: Optional[str] = None
vu_configuration_source: Optional[str] = None
vu_measurement_units: Optional[str] = None
# Electronic Agent
ea_input_folder: Optional[str] = None
ea_output_folder: Optional[str] = None
ea_send_mask: Optional[str] = None
ea_response_mask: Optional[str] = None
ea_response_extension: Optional[str] = None
ea_counter_start: Optional[int] = None
ea_counter_end: Optional[int] = None
ea_counter_next: Optional[int] = None
# CFDI
cfdi_xml_save_path: Optional[str] = None
cfdi_app_path: Optional[str] = None
cfdi_pac_app_path: Optional[str] = None
# Digital Certificates (Flattened)
fiel_cer: Optional[str] = None
fiel_key: Optional[str] = None
fiel_pass: Optional[str] = None
fiel_access: Optional[str] = None
fiel_cer_exp: Optional[int] = None
fiel_key_exp: Optional[int] = None
cfdi_cert_cer: Optional[str] = None
cfdi_cert_key: Optional[str] = None
cfdi_cert_pass: Optional[str] = None
cfdi_cert_access: Optional[str] = None
cfdi_cert_cer_exp: Optional[int] = None
cfdi_cert_key_exp: Optional[int] = None
cancel_cer: Optional[str] = None
cancel_key: Optional[str] = None
cancel_pass: Optional[str] = None
cancel_access: Optional[str] = None
cancel_cer_exp: Optional[int] = None
cancel_key_exp: Optional[int] = None
class Config:
from_attributes = True

View File

@@ -39,7 +39,7 @@ class Company(Base, TimestampMixin):
# Programa
program: Mapped[Optional[str]] = mapped_column(String(7))
program_number: Mapped[Optional[str]] = mapped_column(String(40))
prosec: Mapped[Optional[int]] = mapped_column(SmallInteger)
prosec: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
prosec_authorization: Mapped[Optional[str]] = mapped_column(String(20))
# Sectores
@@ -61,9 +61,9 @@ class Company(Base, TimestampMixin):
# Configuración básica
logo: Mapped[Optional[str]] = mapped_column(String(255))
has_express_line: Mapped[Optional[str]] = mapped_column(String(2), default="N")
has_express_line: Mapped[Optional[bool]] = mapped_column(Boolean, default=False, server_default="false")
order_format_type: Mapped[Optional[str]] = mapped_column(String(19))
is_service_company: Mapped[Optional[bool]] = mapped_column(Boolean, default=False)
is_service_company: Mapped[Optional[bool]] = mapped_column(Boolean, default=False, server_default="false")
client_name: Mapped[Optional[str]] = mapped_column(String(300))
subassembly_mode: Mapped[Optional[str]] = mapped_column(String(7))
@@ -77,9 +77,11 @@ class Company(Base, TimestampMixin):
scaf_readonly: Mapped[Optional[int]] = mapped_column(SmallInteger)
parts_replacement: Mapped[Optional[int]] = mapped_column(SmallInteger)
activate_facmexame: Mapped[Optional[int]] = mapped_column(SmallInteger)
part_reference: Mapped[Optional[int]] = mapped_column(SmallInteger)
part_reference: Mapped[Optional[int]] = mapped_column(SmallInteger)
part_reference: Mapped[Optional[int]] = mapped_column(SmallInteger)
international_firm: Mapped[Optional[int]] = mapped_column(SmallInteger)
seventh_amendment: Mapped[Optional[bool]] = mapped_column(
Boolean
) # Septima enimenda (FinalContadorAElectronico)
# Configuraciones simples
ftp_key: Mapped[Optional[str]] = mapped_column(String(10))

View File

@@ -341,3 +341,101 @@ async def upload_company_logo(
"logo_path": file_path,
"company_id": company_id,
}
@router.post(
"/{company_id}/upload-certificate",
response_model=dict,
summary="Upload company certificate",
)
async def upload_company_certificate(
company_id: int,
certificate_type: str,
file: UploadFile = File(...),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Upload a certificate for a company
certificate_type: fiel_cer, fiel_key, cfdi_cert_cer, cfdi_cert_key, cancel_cer, cancel_key
"""
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
# Validar que la empresa existe
service = CompanyService(db)
company = service.get_by_id(db, company_id, tenant_id, 0)
if not company:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Company not found",
)
# Validar tipo de certificado
valid_types = [
"fiel_cer", "fiel_key",
"cfdi_cert_cer", "cfdi_cert_key",
"cancel_cer", "cancel_key"
]
if certificate_type not in valid_types:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid certificate type. Allowed: {', '.join(valid_types)}",
)
# Validar extensión
file_ext = os.path.splitext(file.filename)[1].lower()
allowed_exts = {".cer", ".key"}
if file_ext not in allowed_exts:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File type not allowed. Allowed: {', '.join(allowed_exts)}",
)
# Validar correspondencia extensión vs tipo (simple check)
if "cer" in certificate_type and file_ext != ".cer":
raise HTTPException(status_code=400, detail="For this certificate type, file must be .cer")
if "key" in certificate_type and file_ext != ".key":
raise HTTPException(status_code=400, detail="For this certificate type, file must be .key")
# Validar tamaño
content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File too large. Maximum size: {MAX_FILE_SIZE / 1024 / 1024}MB",
)
# Crear directorio si no existe
certs_dir = os.path.join(UPLOAD_DIR, str(company_id), "certificates")
os.makedirs(certs_dir, exist_ok=True)
# Generar nombre único
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{certificate_type}_{timestamp}{file_ext}"
file_path = os.path.join(certs_dir, filename)
# Guardar archivo
try:
await file.seek(0)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error saving file: {str(e)}",
)
# Actualizar la base de datos
service.upload_certificate(company_id, certificate_type, file_path, tenant_id)
return {
"message": "Certificate uploaded successfully",
"file_path": file_path,
"certificate_type": certificate_type,
"company_id": company_id,
}

View File

@@ -11,6 +11,8 @@ from sqlalchemy.orm import Session
from .dto import CompanyCreateDTO, CompanyResponseDTO, CompanyUpdateDTO
from .models import Company
from ...audit_log.services.service import AuditService
from core.context import get_user_context
logger = logging.getLogger(__name__)
@@ -111,7 +113,8 @@ class CompanyService:
"active_labels", "active_fractions", "activate_caat", "trans_interface",
"american_costs", "scaf_readonly", "parts_replacement", "activate_facmexame",
"part_reference", "international_firm", "ftp_key", "sifra_path",
"version_type", "sql_language", "balance_operation_mode", "inter_db_name"
"version_type", "sql_language", "balance_operation_mode", "inter_db_name",
"seventh_amendment"
]
return {k: v for k, v in data.items() if k in company_fields}
@@ -127,15 +130,100 @@ class CompanyService:
]
return {k: v for k, v in data.items() if k in cert_fields}
def _extract_address_fields(self, data: Dict[str, Any], type_prefix: str) -> Dict[str, Any]:
"""Extrae campos de dirección con base en un prefijo (main_, ind1_, ind2_)"""
fields = ["street", "exterior_number", "interior_number", "postal_code",
"neighborhood", "city", "municipality", "state", "country",
"phone", "fax", "email"]
extracted = {}
for f in fields:
key = f"{type_prefix}_{f}"
if key in data:
extracted[f] = data[key]
return extracted
def _extract_prevalidator_fields(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Extrae campos que pertenecen a CompanyPrevalidator"""
# Note: 'prevalidator_key' in DTO maps to 'key' in model
fields = {}
if "prevalidator_key" in data:
fields["key"] = data["prevalidator_key"]
# Se mapean campos 'prev_*' a los nombres del modelo
mapping = {
"prev_customs": "customs",
"prev_key": "key",
"prev_patent": "patent",
"prev_description": "description"
}
extracted = {}
for dto_key, model_key in mapping.items():
if dto_key in data:
extracted[model_key] = data[dto_key]
# Add other fields if present in DTO in the future
return fields
# Retrocompatibilidad con el campo prevalidator_key que ya estaba en el DTO
if "prevalidator_key" in data and "key" not in extracted:
extracted["key"] = data["prevalidator_key"]
return extracted
def _extract_vu_fields(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Extrae campos que pertenecen a CompanyVU (prefijo vu_)"""
vu_fields = [
"webservice_user", "webservice_password", "email", "figure_type",
"central_path", "xml_files_path", "query_rfc", "validation_rfc",
"configuration_source", "measurement_units"
]
extracted = {}
for f in vu_fields:
key = f"vu_{f}"
if key in data:
extracted[f] = data[key]
return extracted
def _extract_electronic_agent_fields(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Extrae campos que pertenecen a CompanyElectronicAgent (prefijo ea_)"""
ea_fields = [
"input_folder", "output_folder", "send_mask", "response_mask",
"response_extension", "counter_start", "counter_end", "counter_next"
]
extracted = {}
for f in ea_fields:
key = f"ea_{f}"
if key in data:
extracted[f] = data[key]
return extracted
def _extract_cfdi_fields(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Extrae campos que pertenecen a CompanyCFDI (prefijo cfdi_)"""
cfdi_fields = ["xml_save_path", "cfdi_app_path", "pac_app_path"]
extracted = {}
for f in cfdi_fields:
key = f"cfdi_{f}"
if key in data:
extracted[f] = data[key]
return extracted
def _extract_digital_certificate_fields(self, data: Dict[str, Any], cert_prefix: str) -> Dict[str, Any]:
"""Extrae campos para un tipo específico de certificado (fiel, cfdi_cert, cancel)"""
# Mapeo de prefijos DTO a nombres de modelo
fields_map = {
f"{cert_prefix}_cer": "cer_file_path",
f"{cert_prefix}_key": "key_file_path",
f"{cert_prefix}_pass": "password",
f"{cert_prefix}_access": "access_key",
f"{cert_prefix}_cer_exp": "cer_expiration_date",
f"{cert_prefix}_key_exp": "key_expiration_date"
}
extracted = {}
for dto_key, model_key in fields_map.items():
if dto_key in data:
extracted[model_key] = data[dto_key]
if extracted:
# Mapear prefijo al tipo real en base de datos
model_type_map = {'fiel': 'fiel', 'cfdi_cert': 'cfdi', 'cancel': 'cancellation'}
extracted['certificate_type'] = model_type_map.get(cert_prefix, cert_prefix)
return extracted
def flatten_company_dto(self, company: Company) -> Dict[str, Any]:
"""Flattens Company and its submodels into a single dict for DTO validation"""
@@ -144,15 +232,7 @@ class CompanyService:
k: getattr(company, k)
for k in company.__mapper__.c.keys()
}
# Explicitly ensure logo is present (defensive programming)
if hasattr(company, 'logo'):
result['logo'] = company.logo
# Convert has_express_line from String "S"/"N" to Boolean
if hasattr(company, 'has_express_line'):
val = getattr(company, 'has_express_line', "N")
result['has_express_line'] = (val == "S")
# 2. Certification fields
if company.certification:
cert_fields = [
@@ -168,95 +248,214 @@ class CompanyService:
if val is not None:
result[field] = val
# 3. Prevalidator fields
# 3. Addresses
for addr in company.addresses:
prefix = ""
if addr.address_type == 'main': prefix = "main_"
elif addr.address_type == 'industrial': prefix = "ind1_"
elif addr.address_type == 'industrial2': prefix = "ind2_"
if prefix:
addr_fields = ["street", "exterior_number", "interior_number", "postal_code",
"neighborhood", "city", "municipality", "state", "country",
"phone", "fax", "email"]
for f in addr_fields:
val = getattr(addr, f, None)
if val is not None:
result[f"{prefix}{f}"] = val
# 4. Prevalidator fields
if company.prevalidator:
mapping = {"customs": "prev_customs", "key": "prev_key",
"patent": "prev_patent", "description": "prev_description"}
for model_f, dto_f in mapping.items():
val = getattr(company.prevalidator, model_f, None)
if val is not None:
result[dto_f] = val
# Retrocompatibilidad
if company.prevalidator.key:
result["prevalidator_key"] = company.prevalidator.key
# 5. VU fields
if company.ventanilla_unica:
f_list = ["webservice_user", "webservice_password", "email", "figure_type",
"central_path", "xml_files_path", "query_rfc", "validation_rfc",
"configuration_source", "measurement_units"]
for f in f_list:
val = getattr(company.ventanilla_unica, f, None)
if val is not None:
result[f"vu_{f}"] = val
# 6. Electronic Agent fields
if company.electronic_agent:
f_list = ["input_folder", "output_folder", "send_mask", "response_mask",
"response_extension", "counter_start", "counter_end", "counter_next"]
for f in f_list:
val = getattr(company.electronic_agent, f, None)
if val is not None:
result[f"ea_{f}"] = val
# 7. CFDI fields
if company.cfdi:
f_list = ["xml_save_path", "cfdi_app_path", "pac_app_path"]
for f in f_list:
val = getattr(company.cfdi, f, None)
if val is not None:
result[f"cfdi_{f}"] = val
# 8. Digital Certificates
cert_type_map = {'fiel': 'fiel', 'cfdi': 'cfdi_cert', 'cancellation': 'cancel'}
for dc in company.digital_certificates:
prefix = cert_type_map.get(dc.certificate_type)
if prefix:
result[f"{prefix}_cer"] = dc.cer_file_path
result[f"{prefix}_key"] = dc.key_file_path
result[f"{prefix}_pass"] = dc.password
result[f"{prefix}_access"] = dc.access_key
result[f"{prefix}_cer_exp"] = dc.cer_expiration_date
result[f"{prefix}_key_exp"] = dc.key_expiration_date
return result
# ==================== CRUD METHODS ====================
def create_company_manually(self, data: CompanyCreateDTO, tenant_id: int) -> Company:
def create_company_manually(self, data: CompanyCreateDTO, tenant_id: int, username: str = "System") -> Company:
from .submodels.certification import CompanyCertification
from .submodels.prevalidator import CompanyPrevalidator
from .submodels.address import CompanyAddress
from .submodels.vu import CompanyVU
from .submodels.electronic_agent import CompanyElectronicAgent
from .submodels.cfdi import CompanyCFDI
try:
# 1. Preparar datos
obj_data = data.model_dump(exclude_unset=True)
# Handle boolean flags for Company (Hybrid Approach)
# has_express_line is String(2), is_service_company is Boolean
if "has_express_line" in obj_data and isinstance(obj_data["has_express_line"], bool):
obj_data["has_express_line"] = "S" if obj_data["has_express_line"] else "N"
# 2. Extract fields for each model
# 2. Extract fields
company_data = self._extract_company_fields(obj_data)
cert_data = self._extract_certification_fields(obj_data)
preval_data = self._extract_prevalidator_fields(obj_data)
vu_data = self._extract_vu_fields(obj_data)
ea_data = self._extract_electronic_agent_fields(obj_data)
cfdi_data = self._extract_cfdi_fields(obj_data)
addr_main = self._extract_address_fields(obj_data, "main")
addr_ind1 = self._extract_address_fields(obj_data, "ind1")
addr_ind2 = self._extract_address_fields(obj_data, "ind2")
fiel_data = self._extract_digital_certificate_fields(obj_data, "fiel")
cfdi_cert_data = self._extract_digital_certificate_fields(obj_data, "cfdi_cert")
cancel_cert_data = self._extract_digital_certificate_fields(obj_data, "cancel")
# 3. Create Company
db_company = Company(**company_data, tenant_id=tenant_id)
self.db.add(db_company)
self.db.flush() # Generate ID
# 4. Create Certification if data exists
# 4. Create submodels
if cert_data:
cert = CompanyCertification(**cert_data, company_id=db_company.id)
self.db.add(cert)
# 5. Create Prevalidator if data exists
self.db.add(CompanyCertification(**cert_data, company_id=db_company.id))
if preval_data:
preval = CompanyPrevalidator(**preval_data, company_id=db_company.id)
self.db.add(preval)
self.db.add(CompanyPrevalidator(**preval_data, company_id=db_company.id))
if vu_data:
self.db.add(CompanyVU(**vu_data, company_id=db_company.id))
if ea_data:
self.db.add(CompanyElectronicAgent(**ea_data, company_id=db_company.id))
if cfdi_data:
self.db.add(CompanyCFDI(**cfdi_data, company_id=db_company.id))
# 5. Create Digital Certificates
from .submodels.digital_certificate import CompanyDigitalCertificate
for dc_data in [fiel_data, cfdi_cert_data, cancel_cert_data]:
if dc_data:
self.db.add(CompanyDigitalCertificate(**dc_data, company_id=db_company.id))
# 6. Create Addresses
# 6. Commit
if addr_main:
self.db.add(CompanyAddress(**addr_main, address_type='main', company_id=db_company.id))
if addr_ind1:
self.db.add(CompanyAddress(**addr_ind1, address_type='industrial', company_id=db_company.id))
if addr_ind2:
self.db.add(CompanyAddress(**addr_ind2, address_type='industrial2', company_id=db_company.id))
# 7. Commit
self.db.commit()
self.db.refresh(db_company)
# --- Audit Log ---
try:
# Si no se pasó un username explícito, intentar obtenerlo del contexto
if username == "System":
ctx = get_user_context()
if ctx:
username = ctx.get("preferred_username") or ctx.get("email") or "System"
# Preparamos la data para el log (aplanada)
log_data = self.flatten_company_dto(db_company)
AuditService.log_crud_operation(
db=self.db,
table_name="company",
operation_type="CREATE",
record_data=log_data,
username=username,
record_id=str(db_company.id),
company_id=db_company.id
)
except Exception as e:
logger.error(f"Error creating audit log for company creation: {e}")
# -----------------
return db_company
except IntegrityError as e:
self.db.rollback()
logger.error(f"IntegrityError creating company manually: {str(e)}")
raise HTTPException(
status_code=400,
detail="Error de integridad: Es posible que esta empresa ya exista.",
)
raise HTTPException(status_code=400, detail="Error de integridad: Es posible que esta empresa ya exista.")
except Exception as e:
self.db.rollback()
logger.error(f"Error creating company manually: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error creando empresa: {str(e)}")
def update(
self, # Changed to instance method to use self helper methods
self,
db: Session,
company_id: int,
tenant_id: int,
company_id_unused: int,
company_data: CompanyUpdateDTO,
username: str = "System",
) -> Optional[Company]:
"""Update a company"""
from .submodels.certification import CompanyCertification
from .submodels.prevalidator import CompanyPrevalidator
from .submodels.address import CompanyAddress
from .submodels.vu import CompanyVU
from .submodels.electronic_agent import CompanyElectronicAgent
from .submodels.cfdi import CompanyCFDI
# Use self.db if db is passed as None, or use passed db (legacy support)
session = db if db else self.db
company = self.get_by_id(session, company_id, tenant_id, company_id_unused)
if not company:
return None
if not company: return None
# --- Audit Log Prep ---
old_values = {}
try:
# Capturamos estado actual para comparar
# Usamos flatten_company_dto para tener una representación completa
old_values = self.flatten_company_dto(company)
except Exception as e:
logger.error(f"Error prepping audit log (old values): {e}")
# ----------------------
# Update only provided fields
update_data = company_data.model_dump(exclude_unset=True)
# 1. Update Company fields
company_fields = self._extract_company_fields(update_data)
# Hybrid Approach: has_express_line is String, is_service_company is Boolean
if "has_express_line" in company_fields and isinstance(company_fields["has_express_line"], bool):
company_fields["has_express_line"] = "S" if company_fields["has_express_line"] else "N"
for field, value in company_fields.items():
setattr(company, field, value)
@@ -264,26 +463,97 @@ class CompanyService:
cert_fields = self._extract_certification_fields(update_data)
if cert_fields:
if company.certification:
for field, value in cert_fields.items():
setattr(company.certification, field, value)
for field, value in cert_fields.items(): setattr(company.certification, field, value)
else:
new_cert = CompanyCertification(**cert_fields, company_id=company.id)
session.add(new_cert)
session.add(CompanyCertification(**cert_fields, company_id=company.id))
# 3. Update Prevalidator
preval_fields = self._extract_prevalidator_fields(update_data)
if preval_fields:
if company.prevalidator:
for field, value in preval_fields.items():
setattr(company.prevalidator, field, value)
for field, value in preval_fields.items(): setattr(company.prevalidator, field, value)
else:
new_preval = CompanyPrevalidator(**preval_fields, company_id=company.id)
session.add(new_preval)
session.add(CompanyPrevalidator(**preval_fields, company_id=company.id))
# 4. Update VU
vu_fields = self._extract_vu_fields(update_data)
if vu_fields:
if company.ventanilla_unica:
for field, value in vu_fields.items(): setattr(company.ventanilla_unica, field, value)
else:
session.add(CompanyVU(**vu_fields, company_id=company.id))
# 5. Update Electronic Agent
ea_fields = self._extract_electronic_agent_fields(update_data)
if ea_fields:
if company.electronic_agent:
for field, value in ea_fields.items(): setattr(company.electronic_agent, field, value)
else:
session.add(CompanyElectronicAgent(**ea_fields, company_id=company.id))
# 6. Update CFDI
cfdi_fields = self._extract_cfdi_fields(update_data)
if cfdi_fields:
if company.cfdi:
for field, value in cfdi_fields.items(): setattr(company.cfdi, field, value)
else:
session.add(CompanyCFDI(**cfdi_fields, company_id=company.id))
# 7. Update Digital Certificates
from .submodels.digital_certificate import CompanyDigitalCertificate
for p in ["fiel", "cfdi_cert", "cancel"]:
dc_data = self._extract_digital_certificate_fields(update_data, p)
if dc_data:
m_type = dc_data['certificate_type']
target = next((c for c in company.digital_certificates if c.certificate_type == m_type), None)
if target:
for field, value in dc_data.items(): setattr(target, field, value)
else:
session.add(CompanyDigitalCertificate(**dc_data, company_id=company.id))
# 8. Update Addresses
for prefix, addr_type in [("main", "main"), ("ind1", "industrial"), ("ind2", "industrial2")]:
addr_data = self._extract_address_fields(update_data, prefix)
if addr_data:
# Buscar dirección existente de ese tipo
target_addr = next((a for a in company.addresses if a.address_type == addr_type), None)
if target_addr:
for field, value in addr_data.items(): setattr(target_addr, field, value)
else:
session.add(CompanyAddress(**addr_data, address_type=addr_type, company_id=company.id))
try:
session.commit()
session.refresh(company)
# --- Audit Log ---
try:
# Si no se pasó un username explícito, intentar obtenerlo del contexto
if username == "System":
ctx = get_user_context()
if ctx:
username = ctx.get("preferred_username") or ctx.get("email") or "System"
new_values = self.flatten_company_dto(company)
AuditService.log_crud_operation(
db=session,
table_name="company",
operation_type="UPDATE",
record_data=new_values, # Data más reciente
username=username,
record_id=str(company.id),
old_values=old_values,
new_values=new_values,
company_id=company.id
)
except Exception as e:
logger.error(f"Error creating audit log for company update: {e}")
# -----------------
return company
except Exception as e:
session.rollback()
logger.error(f"Error updating company {company_id}: {str(e)}")
@@ -291,69 +561,139 @@ class CompanyService:
@staticmethod
def delete(
db: Session, company_id: int, tenant_id: int, company_id_unused: int
db: Session, company_id: int, tenant_id: int, company_id_unused: int, username: str = "System"
) -> bool:
"""Delete a company"""
company = CompanyService.get_by_id(db, company_id, tenant_id, company_id_unused)
if not company:
return False
# --- Audit Log Prep ---
record_data = {}
try:
# Manual cascade delete for submodels to ensure order and avoid FK issues
# (Even though cascade="all, delete-orphan" is set, manual deletion is safer for strict DBs)
# 1. Delete Certification
if company.certification:
db.delete(company.certification)
# 2. Delete Prevalidator
if company.prevalidator:
db.delete(company.prevalidator)
# 3. Delete Electronic Agent
if company.electronic_agent:
db.delete(company.electronic_agent)
# 4. Delete VU
if company.ventanilla_unica:
db.delete(company.ventanilla_unica)
# 5. Delete CFDI
if company.cfdi:
db.delete(company.cfdi)
# 6. Delete Digital Certificates
for cert in company.digital_certificates:
db.delete(cert)
# 7. Delete Addresses
for addr in company.addresses:
db.delete(addr)
service = CompanyService(db) # Instancia para usar métodos de instancia si fuera necesario, o usar estático si flatten lo fuera
# flatten_company_dto es método de instancia en la definición actual, pero se está llamando aquí
# Deberíamos instanciar el servicio o mover flatten a estático.
# Como flatten usa self solo para acceder a nada realmente del estado, podría ser estático,
# pero para no romper, instanciamos.
record_data = service.flatten_company_dto(company)
except Exception:
pass
# ----------------------
try:
# Cascading deletes are handled by relationship settings, but manual is safer here
if company.certification: db.delete(company.certification)
if company.prevalidator: db.delete(company.prevalidator)
if company.electronic_agent: db.delete(company.electronic_agent)
if company.ventanilla_unica: db.delete(company.ventanilla_unica)
if company.cfdi: db.delete(company.cfdi)
for cert in company.digital_certificates: db.delete(cert)
for addr in company.addresses: db.delete(addr)
# Flush to execute submodel deletions first
db.flush()
db.delete(company)
db.commit()
# --- Audit Log ---
try:
# Context check
if username == "System":
ctx = get_user_context()
if ctx:
username = ctx.get("preferred_username") or ctx.get("email") or "System"
AuditService.log_crud_operation(
db=db,
table_name="company",
operation_type="DELETE",
record_data=record_data,
username=username,
record_id=str(company_id),
company_id=company_id
)
except Exception as e:
logger.error(f"Error creating audit log for company delete: {e}")
# -----------------
return True
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError deleting company {company_id}: {str(e)}")
# Try to get detailed error from psycopg2
detail = "No se puede eliminar la empresa porque tiene registros relacionados."
if hasattr(e, 'orig') and hasattr(e.orig, 'diag'):
if e.orig.diag.message_detail:
detail += f" Detalles: {e.orig.diag.message_detail}"
raise HTTPException(
status_code=400,
detail=detail
)
raise HTTPException(status_code=400, detail="No se puede eliminar la empresa porque tiene registros relacionados.")
except Exception as e:
db.rollback()
logger.error(f"Error deleting company {company_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error al eliminar la empresa")
def upload_certificate(
self,
company_id: int,
certificate_type: str,
file_path: str,
tenant_id: int
) -> Company:
"""
Update a certificate path for a company
certificate_type: fiel_cer, fiel_key, cfdi_cert_cer, cfdi_cert_key, cancel_cer, cancel_key
"""
from .submodels.digital_certificate import CompanyDigitalCertificate
company = self.get_by_id(self.db, company_id, tenant_id, 0)
if not company:
return None
# Determinar el tipo de certificado (fiel, cfdi, cancellation) y el campo a actualizar (cer_file_path, key_file_path)
cert_model_type = ""
field_to_update = ""
if certificate_type == "fiel_cer":
cert_model_type = "fiel"
field_to_update = "cer_file_path"
elif certificate_type == "fiel_key":
cert_model_type = "fiel"
field_to_update = "key_file_path"
elif certificate_type == "cfdi_cert_cer":
cert_model_type = "cfdi"
field_to_update = "cer_file_path"
elif certificate_type == "cfdi_cert_key":
cert_model_type = "cfdi"
field_to_update = "key_file_path"
elif certificate_type == "cancel_cer":
cert_model_type = "cancellation"
field_to_update = "cer_file_path"
elif certificate_type == "cancel_key":
cert_model_type = "cancellation"
field_to_update = "key_file_path"
else:
raise ValueError(f"Invalid certificate type: {certificate_type}")
# Buscar el registro de certificado existente
target_cert = next((c for c in company.digital_certificates if c.certificate_type == cert_model_type), None)
try:
if target_cert:
# Si existe, actualizamos
setattr(target_cert, field_to_update, file_path)
else:
# Si no existe, creamos uno nuevo
new_cert_data = {
"certificate_type": cert_model_type,
"company_id": company.id,
field_to_update: file_path
}
new_cert = CompanyDigitalCertificate(**new_cert_data)
self.db.add(new_cert)
self.db.commit()
self.db.refresh(company)
return company
except Exception as e:
self.db.rollback()
logger.error(f"Error uploading certificate: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error al guardar la referencia del certificado: {str(e)}")
# Custom methods
def get_companies_by_tenant(self, tenant_id: int) -> List[Company]:
"""Get all companies for a tenant"""

View File

@@ -0,0 +1,29 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import String, Numeric, TIMESTAMP, func, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
class CanadianTariffFraction(Base, TenantScopedMixin, TimestampMixin):
"""Model for Canadian Tariff Fractions (GFracEUACan)"""
__tablename__ = "canadian_tariff_fractions"
__table_args__ = (
UniqueConstraint('fraction', 'country_code', 'company_id', name='uq_canadian_fraction_country_company'),
{"schema": "a76"}
)
id: Mapped[int] = mapped_column(primary_key=True, index=True)
# FRACCION
fraction: Mapped[str] = mapped_column(String(13), nullable=False, index=True)
# ADV
ad_valorem: Mapped[Optional[float]] = mapped_column(Numeric(5, 2))
# UNIDAD
unit_of_measure: Mapped[Optional[str]] = mapped_column(String(5))
# CLAVEM3 (Part of original PK)
country_code: Mapped[str] = mapped_column(String(3), nullable=False, index=True)
# DESCRIPCION
description: Mapped[Optional[str]] = mapped_column(String(1000))

View File

@@ -0,0 +1,98 @@
from typing import Optional
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
from .service import CanadianTariffFractionService
from .schemas import (
CanadianTariffFractionResponse,
CanadianTariffFractionCreate,
CanadianTariffFractionUpdate,
CanadianTariffFractionListResponse
)
from api.v1.common.tenant_crud_routes import validate_access_to_resource
router = APIRouter()
@router.get("/", response_model=CanadianTariffFractionListResponse)
def list_canadian_fractions(
company_id: int = Query(..., description="Company ID"),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=1000),
search: Optional[str] = None,
db: Session = Depends(get_core_db),
current_user = Depends(get_current_user)
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
skip = (page - 1) * page_size
service = CanadianTariffFractionService(db)
items, total = service.get_multi(
tenant_id=tenant_id,
company_id=company_id,
skip=skip,
limit=page_size,
search=search
)
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
"pages": (total + page_size - 1) // page_size if page_size > 0 else 1
}
@router.get("/{id}", response_model=CanadianTariffFractionResponse)
def get_canadian_fraction(
id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user = Depends(get_current_user)
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
service = CanadianTariffFractionService(db)
item = service.get(id, tenant_id, company_id)
if not item:
raise HTTPException(status_code=404, detail="Not found")
return item
@router.post("/", response_model=CanadianTariffFractionResponse)
def create_canadian_fraction(
item_in: CanadianTariffFractionCreate,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user = Depends(get_current_user)
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
service = CanadianTariffFractionService(db)
return service.create(item_in, tenant_id, company_id)
@router.put("/{id}", response_model=CanadianTariffFractionResponse)
def update_canadian_fraction(
id: int,
item_in: CanadianTariffFractionUpdate,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user = Depends(get_current_user)
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
service = CanadianTariffFractionService(db)
item = service.get(id, tenant_id, company_id)
if not item:
raise HTTPException(status_code=404, detail="Not found")
return service.update(item, item_in)
@router.delete("/{id}", response_model=CanadianTariffFractionResponse)
def delete_canadian_fraction(
id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user = Depends(get_current_user)
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
service = CanadianTariffFractionService(db)
item = service.get(id, tenant_id, company_id)
if not item:
raise HTTPException(status_code=404, detail="Not found")
return service.delete(id, tenant_id, company_id)

View File

@@ -0,0 +1,32 @@
from typing import Optional, List
from pydantic import BaseModel, Field, ConfigDict
from datetime import datetime
from decimal import Decimal
class CanadianTariffFractionBase(BaseModel):
fraction: str = Field(..., max_length=13)
ad_valorem: Optional[Decimal] = Field(None, max_digits=5, decimal_places=2)
unit_of_measure: Optional[str] = Field(None, max_length=5)
country_code: str = Field(..., max_length=3)
description: Optional[str] = Field(None, max_length=1000)
class CanadianTariffFractionCreate(CanadianTariffFractionBase):
pass
class CanadianTariffFractionUpdate(CanadianTariffFractionBase):
pass
class CanadianTariffFractionResponse(CanadianTariffFractionBase):
id: int
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class CanadianTariffFractionListResponse(BaseModel):
items: List[CanadianTariffFractionResponse]
total: int
page: int
page_size: int
pages: int

View File

@@ -0,0 +1,73 @@
from typing import Optional, List, Tuple
from sqlalchemy import select, func
from sqlalchemy.orm import Session
from .models import CanadianTariffFraction
from .schemas import CanadianTariffFractionCreate, CanadianTariffFractionUpdate
class CanadianTariffFractionService:
def __init__(self, db: Session):
self.db = db
def get(self, id: int, tenant_id: int, company_id: int) -> Optional[CanadianTariffFraction]:
return self.db.query(CanadianTariffFraction).filter(
CanadianTariffFraction.id == id,
CanadianTariffFraction.tenant_id == tenant_id,
CanadianTariffFraction.company_id == company_id
).first()
def get_multi(
self,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 100,
search: Optional[str] = None
) -> Tuple[List[CanadianTariffFraction], int]:
query = select(CanadianTariffFraction).where(
CanadianTariffFraction.tenant_id == tenant_id,
CanadianTariffFraction.company_id == company_id
)
if search:
query = query.where(
(CanadianTariffFraction.fraction.ilike(f"%{search}%")) |
(CanadianTariffFraction.description.ilike(f"%{search}%"))
)
total = self.db.execute(select(func.count()).select_from(query.subquery())).scalar_one()
# Add deterministic sort order
query = query.order_by(CanadianTariffFraction.fraction)
items = self.db.scalars(query.offset(skip).limit(limit)).all()
return items, total
def create(self, obj_in: CanadianTariffFractionCreate, tenant_id: int, company_id: int) -> CanadianTariffFraction:
db_obj = CanadianTariffFraction(
**obj_in.model_dump(),
tenant_id=tenant_id,
company_id=company_id
)
self.db.add(db_obj)
self.db.commit()
self.db.refresh(db_obj)
return db_obj
def update(
self,
db_obj: CanadianTariffFraction,
obj_in: CanadianTariffFractionUpdate
) -> CanadianTariffFraction:
update_data = obj_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_obj, field, value)
self.db.add(db_obj)
self.db.commit()
self.db.refresh(db_obj)
return db_obj
def delete(self, id: int, tenant_id: int, company_id: int) -> Optional[CanadianTariffFraction]:
obj = self.get(id, tenant_id, company_id)
if obj:
self.db.delete(obj)
self.db.commit()
return obj

View File

@@ -0,0 +1,29 @@
"""
DTOs for historical tariff fractions.
"""
from datetime import datetime
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel, ConfigDict
class HistoricalTariffFractionResponseDTO(BaseModel):
id: int
historical_fraction: Optional[str] = None
unit_of_measure_code: Optional[str] = None
country: Optional[str] = None
fraction_type: Optional[str] = None
sector: Optional[str] = None
import_tax_rate: Optional[Decimal] = None
export_tax_rate: Optional[Decimal] = None
publication_date: Optional[datetime] = None
is_immex: Optional[bool] = None
normal_temporality: Optional[bool] = None
services_temporality: Optional[bool] = None
certified_temporality: Optional[bool] = None
by_log: Optional[bool] = None
end_date: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,34 @@
from datetime import datetime
from typing import Optional
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, String, Integer, Numeric, Boolean
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
class HistoricalTariffFraction(Base, TenantScopedMixin, TimestampMixin):
"""
Historical tariff fractions catalog.
Maps to SQL Server table: GFraccionesHistorico
"""
__tablename__ = "historical_tariff_fractions"
__table_args__ = {"schema": "a76"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, nullable=False)
historical_fraction: Mapped[Optional[str]] = mapped_column(String(8), nullable=True)
nico: Mapped[Optional[str]] = mapped_column(String(2), nullable=True)
unit_of_measure_code: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.unit_of_measure_customs.code"), nullable=True)
country: Mapped[Optional[str]] = mapped_column(ForeignKey("public.countries.m3_key"), nullable=True)
fraction_type: Mapped[Optional[str]] = mapped_column(String(7), nullable=True)
sector: Mapped[Optional[str]] = mapped_column(String(5), nullable=True)
import_tax_rate: Mapped[Optional[Decimal]] = mapped_column(Numeric(7, 2), nullable=True)
export_tax_rate: Mapped[Optional[Decimal]] = mapped_column(Numeric(7, 2), nullable=True)
publication_date: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
is_immex: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
normal_temporality: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
services_temporality: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
certified_temporality: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
by_log: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
end_date: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)

View File

@@ -0,0 +1,101 @@
from typing import List, Optional
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
from .service import HistoricalTariffFractionService
from .schemas import HistoricalTariffFractionResponse, HistoricalTariffFractionCreate, HistoricalTariffFractionUpdate, HistoricalTariffFractionListResponse
from api.v1.common.tenant_crud_routes import validate_access_to_resource
router = APIRouter()
@router.get("/", response_model=HistoricalTariffFractionListResponse)
def get_historical_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=1000, description="Page size"),
historical_fraction: Optional[str] = Query(None, description="Search by historical fraction code"),
db: Session = Depends(get_core_db),
current_user = Depends(get_current_user)
):
"""
Get all historical tariff fractions (paginated).
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
skip = (page - 1) * page_size
service = HistoricalTariffFractionService(db)
items, total = service.get_multi(tenant_id, company_id, skip=skip, limit=page_size, historical_fraction=historical_fraction)
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
"pages": (total + page_size - 1) // page_size if page_size > 0 else 1
}
@router.get("/{id}", response_model=HistoricalTariffFractionResponse)
def get_historical_fraction(
id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user = Depends(get_current_user)
):
"""
Get a historical tariff fraction by ID.
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
service = HistoricalTariffFractionService(db)
fraction = service.get(id, tenant_id, company_id)
if not fraction:
raise HTTPException(status_code=404, detail="Historical tariff fraction not found")
return fraction
@router.post("/", response_model=HistoricalTariffFractionResponse)
def create_historical_fraction(
fraction_in: HistoricalTariffFractionCreate,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user = Depends(get_current_user)
):
"""
Create a new historical tariff fraction.
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
service = HistoricalTariffFractionService(db)
return service.create(fraction_in, tenant_id, company_id)
@router.put("/{id}", response_model=HistoricalTariffFractionResponse)
def update_historical_fraction(
id: int,
fraction_in: HistoricalTariffFractionUpdate,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user = Depends(get_current_user)
):
"""
Update a historical tariff fraction.
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
service = HistoricalTariffFractionService(db)
fraction = service.get(id, tenant_id, company_id)
if not fraction:
raise HTTPException(status_code=404, detail="Historical tariff fraction not found")
return service.update(fraction, fraction_in)
@router.delete("/{id}", response_model=HistoricalTariffFractionResponse)
def delete_historical_fraction(
id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user = Depends(get_current_user)
):
"""
Delete a historical tariff fraction.
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
service = HistoricalTariffFractionService(db)
fraction = service.get(id, tenant_id, company_id)
if not fraction:
raise HTTPException(status_code=404, detail="Historical tariff fraction not found")
return service.delete(id, tenant_id, company_id)

View File

@@ -0,0 +1,45 @@
from typing import Optional, List
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, Field
class HistoricalTariffFractionBase(BaseModel):
"""Base schema for Historical Tariff Fraction"""
historical_fraction: Optional[str] = Field(None, max_length=8)
unit_of_measure_code: Optional[str] = None
country: Optional[str] = None
fraction_type: Optional[str] = Field(None, max_length=7)
sector: Optional[str] = Field(None, max_length=5)
import_tax_rate: Optional[Decimal] = None
export_tax_rate: Optional[Decimal] = None
publication_date: Optional[datetime] = None
is_immex: Optional[bool] = None
normal_temporality: Optional[bool] = None
services_temporality: Optional[bool] = None
certified_temporality: Optional[bool] = None
by_log: Optional[bool] = None
end_date: Optional[datetime] = None
class HistoricalTariffFractionCreate(HistoricalTariffFractionBase):
"""Schema for creating a Historical Tariff Fraction"""
pass
class HistoricalTariffFractionUpdate(HistoricalTariffFractionBase):
"""Schema for updating a Historical Tariff Fraction"""
pass
class HistoricalTariffFractionResponse(HistoricalTariffFractionBase):
"""Schema for reading a Historical Tariff Fraction"""
id: int
class Config:
from_attributes = True
class HistoricalTariffFractionListResponse(BaseModel):
"""Schema for paginated list of Historical Tariff Fractions"""
items: List[HistoricalTariffFractionResponse]
total: int
page: int
page_size: int
pages: int

View File

@@ -0,0 +1,69 @@
from typing import Optional, List, Tuple
from sqlalchemy import select, or_, func
from sqlalchemy.orm import Session
from .models import HistoricalTariffFraction
from .schemas import HistoricalTariffFractionCreate, HistoricalTariffFractionUpdate
class HistoricalTariffFractionService:
def __init__(self, db: Session):
self.db = db
def get(self, id: int, tenant_id: int, company_id: int) -> Optional[HistoricalTariffFraction]:
return self.db.query(HistoricalTariffFraction).filter(
HistoricalTariffFraction.id == id,
HistoricalTariffFraction.tenant_id == tenant_id,
HistoricalTariffFraction.company_id == company_id
).first()
def get_multi(
self,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 100,
historical_fraction: Optional[str] = None
) -> Tuple[List[HistoricalTariffFraction], int]:
query = select(HistoricalTariffFraction).where(
HistoricalTariffFraction.tenant_id == tenant_id,
HistoricalTariffFraction.company_id == company_id
)
if historical_fraction:
query = query.where(HistoricalTariffFraction.historical_fraction.ilike(f"%{historical_fraction}%"))
total = self.db.execute(select(func.count()).select_from(query.subquery())).scalar_one()
# Add deterministic sort order
query = query.order_by(HistoricalTariffFraction.historical_fraction)
items = self.db.scalars(query.offset(skip).limit(limit)).all()
return items, total
def create(self, obj_in: HistoricalTariffFractionCreate, tenant_id: int, company_id: int) -> HistoricalTariffFraction:
db_obj = HistoricalTariffFraction(**obj_in.model_dump())
db_obj.tenant_id = tenant_id
db_obj.company_id = company_id
self.db.add(db_obj)
self.db.commit()
self.db.refresh(db_obj)
return db_obj
def update(
self,
db_obj: HistoricalTariffFraction,
obj_in: HistoricalTariffFractionUpdate
) -> HistoricalTariffFraction:
# db_obj already validated for tenant/company in get()
update_data = obj_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_obj, field, value)
self.db.add(db_obj)
self.db.commit()
self.db.refresh(db_obj)
return db_obj
def delete(self, id: int, tenant_id: int, company_id: int) -> Optional[HistoricalTariffFraction]:
obj = self.get(id, tenant_id, company_id)
if obj:
self.db.delete(obj)
self.db.commit()
return obj

View File

@@ -48,6 +48,9 @@ class TariffFractionResponseDTO(BaseModel):
umt: Optional[str] = None
adv_impo: Optional[str] = None
adv_expo: Optional[str] = None
dof: Optional[str] = None
aplica_ieps: Optional[str] = None
um_code: Optional[str] = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,212 @@
"""
Endpoints API para fracciones arancelarias
Catálogo de referencia global (no tenant-scoped)
"""
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 .dto import (
TariffFractionCreateDTO,
TariffFractionResponseDTO,
TariffFractionUpdateDTO,
)
from .service import TariffFractionService
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 (global catalog)",
)
async def list_tariff_fractions(
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"),
level: Optional[int] = Query(None, description="Filter by hierarchy level (e.g. 5)"),
catalog: Optional[str] = Query("mex", description="Catalog source: 'mex' (default) or 'usa'"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
skip = (page - 1) * page_size
filters = {}
if search:
filters["search"] = search
if level is not None:
filters["level"] = level
# Updated to async call with Sitar integration
# WARNING: Using async def with blocking DB dependency (Session) run in threadpool by FastAPI.
# Service.get_all calls Sitar (async) or DB (sync).
# This should be fine.
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id") # Assuming user is context-aware or we use a default?
# If using headers for selected company, it might be in current_user context if middleware sets it.
items, total = await TariffFractionService.get_all(
db, skip, page_size, filters, catalog, tenant_id, company_id
)
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,
}
@router.get(
"/{tariff_fraction_id}",
response_model=TariffFractionResponseDTO,
summary="Get Tariff Fraction by ID",
description="Get a specific tariff fraction by ID (Lookups in Local DB for legacy compatibility)",
)
async def get_tariff_fraction(
tariff_fraction_id: int,
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
item = TariffFractionService.get_by_id(db, tariff_fraction_id)
if not item:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Tariff fraction not found")
return TariffFractionResponseDTO.model_validate(item)
@router.post(
"/",
response_model=TariffFractionResponseDTO,
summary="Create Tariff Fraction",
description="Create a new tariff fraction (Only supported for 'american' catalog)",
)
async def create_tariff_fraction(
fraction_data: TariffFractionCreateDTO,
catalog: str = Query("mex", description="Catalog source: 'mex', 'usa', or 'american'"),
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Crea una nueva fracción.
- MEX/USA: No permitido (Read-Only)
- AMERICAN: Permitido (Local DB)
"""
from fastapi import HTTPException
tenant_id = current_user.get("tenant_id")
if catalog == "american":
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.service import USTariffFractionService
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.dto import USTariffFractionCreateDTO
import re
# Map generic DTO to US DTO
ad_valorem = None
if fraction_data.adv_impo:
try:
# remove non-numeric chars except dot
clean = re.sub(r'[^\d.]', '', fraction_data.adv_impo)
if clean:
ad_valorem = float(clean)
except:
pass
us_dto = USTariffFractionCreateDTO(
code=fraction_data.code,
description=fraction_data.description,
unit_of_measure=fraction_data.umt,
ad_valorem=ad_valorem,
# Defaults for others
prefix=None,
type_code=None,
fixed_cost=None
)
created = USTariffFractionService.create(db, tenant_id, company_id, us_dto)
return TariffFractionService.to_domain_usa_local(created)
else:
raise HTTPException(status_code=400, detail=f"Creation not allowed for '{catalog}' catalog (Read-Only)")
@router.put(
"/{tariff_fraction_id}",
response_model=TariffFractionResponseDTO,
summary="Update Tariff Fraction",
description="Update a tariff fraction (Only supported for 'american' catalog)",
)
async def update_tariff_fraction(
tariff_fraction_id: int,
fraction_data: TariffFractionUpdateDTO,
catalog: str = Query("mex", description="Catalog source: 'mex', 'usa', or 'american'"),
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
from fastapi import HTTPException
tenant_id = current_user.get("tenant_id")
if catalog == "american":
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.service import USTariffFractionService
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.dto import USTariffFractionUpdateDTO
import re
ad_valorem = None
if fraction_data.adv_impo:
try:
clean = re.sub(r'[^\d.]', '', fraction_data.adv_impo)
if clean:
ad_valorem = float(clean)
except:
pass
us_dto = USTariffFractionUpdateDTO(
description=fraction_data.description,
unit_of_measure=fraction_data.umt,
ad_valorem=ad_valorem
)
updated = USTariffFractionService.update(db, tenant_id, company_id, tariff_fraction_id, us_dto)
if not updated:
raise HTTPException(status_code=404, detail="US Tariff fraction not found")
return TariffFractionService.to_domain_usa_local(updated)
else:
raise HTTPException(status_code=400, detail=f"Update not allowed for '{catalog}' catalog (Read-Only)")
@router.delete(
"/{tariff_fraction_id}",
summary="Delete Tariff Fraction",
description="Delete a tariff fraction (Only supported for 'american' catalog)",
)
async def delete_tariff_fraction(
tariff_fraction_id: int,
catalog: str = Query("mex", description="Catalog source: 'mex', 'usa', or 'american'"),
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
from fastapi import HTTPException
tenant_id = current_user.get("tenant_id")
if catalog == "american":
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.service import USTariffFractionService
success = USTariffFractionService.delete(db, tenant_id, company_id, tariff_fraction_id)
if not success:
raise HTTPException(status_code=404, detail="US Tariff fraction not found")
return {"ok": True}
else:
raise HTTPException(status_code=400, detail=f"Delete not allowed for '{catalog}' catalog (Read-Only)")

View File

@@ -0,0 +1,401 @@
"""
Service para fracciones arancelarias
Catálogo de referencia global (no tenant-scoped)
"""
from typing import List, Optional, Tuple, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
import zlib
import logging
from .models import TariffFraction
from .dto import TariffFractionCreateDTO, TariffFractionUpdateDTO
from api.v1.modules.sitar.fracciones.service import FraccionesService
from api.v1.modules.sitar.fracciones.schemas import FraccionesResponse
from api.v1.modules.sitar.fracciones_usa.service import FraccionesUSAService
from api.v1.modules.sitar.fracciones_usa.schemas import FraccionesUSAResponse
logger = logging.getLogger(__name__)
class TariffFractionMapper:
"""Helper to map Sitar responses to Local domain objects"""
@staticmethod
def to_domain(fraccion: FraccionesResponse) -> TariffFraction:
# Generate ID: Use SYSID if available, else composite hash of code + nico
if fraccion.SYSID:
fake_id = fraccion.SYSID
else:
# Composite key for uniqueness if SYSID missing
unique_str = f"{fraccion.FRACCION}-{fraccion.NICO}"
fake_id = zlib.crc32(unique_str.encode('utf-8'))
# UX Enhauncement: Sitar API returns empty strings for some fields.
# We fill them with fallbacks so the frontend table isn't 90% empty.
code_val = fraccion.FRACCION
# Formatting Logic: if FRACCIONPUNTO is empty, try to format code_val
formatted_fraction = code_val
if fraccion.FRACCIONPUNTO:
formatted_fraction = fraccion.FRACCIONPUNTO
elif code_val and code_val.isdigit() and len(code_val) == 8:
# Standard 8 digit format: XX.XX.XX.XX
formatted_fraction = f"{code_val[:2]}.{code_val[2:4]}.{code_val[4:6]}.{code_val[6:]}"
elif code_val and code_val.isdigit() and len(code_val) == 6:
# 6 digit (subheading): XX.XX.XX
formatted_fraction = f"{code_val[:2]}.{code_val[2:4]}.{code_val[4:]}"
fraction_val = formatted_fraction
description_val = fraccion.DESCRIPCION if fraccion.DESCRIPCION else "(Sin descripción)"
tf = TariffFraction(
id=fake_id,
code=code_val,
fraction=fraction_val,
description=description_val,
nico=fraccion.NICO,
# MAP CHANGE: UMT now maps to Abbreviation (e.g., Pza, Kg)
umt=fraccion.UMABREVIACION,
adv_impo=fraccion.ADVIMPOTXT,
adv_expo=fraccion.ADVEXPOTXT
)
# Dynamically attach non-model attributes for DTO
tf.dof = fraccion.DOF
tf.aplica_ieps = fraccion.APLICAIEPS
# MAP CHANGE: New field for the numeric code (e.g., 01, 06)
tf.um_code = fraccion.UMCLAVE
return tf
@staticmethod
def to_domain_usa(item: FraccionesUSAResponse) -> TariffFraction:
"""Map US Fraction to Domain"""
return TariffFraction(
id=item.CONSECUTIVO,
code=item.FRACCION_SIN_PUNTO or "",
fraction=item.FRACCION_CON_PUNTO or "",
description=item.DESCRIPCION or "(Sin descripción)",
nico=None, # Not applicable
umt=item.UNIDADCANTIDAD,
adv_impo=item.TARIFA1,
adv_expo=item.TARIFA2
)
class TariffFractionService:
"""Service para gestionar fracciones arancelarias (catálogo global)"""
@staticmethod
def to_domain_usa_local(item: Any) -> TariffFraction:
"""Map Local US Fraction (ORM) to Domain"""
# Formatter helper (simple logic: add dots every 2/4 chars? or just return as is?)
# US format: 1234.56.78.90. For now return as is or use helper if available.
# item is USTariffFraction (imported inside method to avoid circular import if needed, or assumed available)
return TariffFraction(
id=item.id,
code=item.code,
fraction=item.code, # TODO: Format if needed
description=item.description or "(Sin descripción)",
nico=None,
umt=item.unit_of_measure,
adv_impo=str(item.ad_valorem) if item.ad_valorem is not None else None,
adv_expo=None
)
@staticmethod
async def get_all(
db: Session,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
catalog: str = "mex",
tenant_id: Optional[int] = None,
company_id: Optional[int] = None,
) -> Tuple[List[TariffFraction], int]:
"""
Obtiene fracciones arancelarias.
Estrategia:
- MEX: Sitar API -> Fallback Local DB
- USA: Local DB (Defined by user requirement)
"""
# AMERICAN CATALOG HANDLING (LOCAL - 'Fracciones Americanas')
if catalog == "american":
if tenant_id is None or company_id is None:
logger.warning("Solicitud de fracciones Americanas sin tenant/company ID")
return [], 0
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.service import USTariffFractionService
# Use local service directly
usa_items, total = USTariffFractionService._get_all_local(
db, tenant_id, company_id, skip, limit, filters
)
items = [TariffFractionMapper.to_domain_usa_local(item) for item in usa_items]
return items, total
# USA CATALOG HANDLING (API - 'Fracciones US')
if catalog == "usa":
try:
usa_service = FraccionesUSAService.get_instance()
search_term = None
search_description = None
if filters and filters.get("search"):
term = filters["search"]
# Simple heuristic: if it looks like a code, use code search, else description
# FIX: Short numeric codes (e.g. "01") often fail strict 'fraccion' search.
# Treat them as description search for partial matching.
clean_term = term.replace(".", "")
if clean_term.isdigit() and len(clean_term) >= 4:
search_term = term
else:
search_description = term
# USA Service search signature: fraccion, descripcion, skip, limit
usa_items = await usa_service.search(
fraccion=search_term,
descripcion=search_description,
skip=skip,
limit=limit
)
items = [TariffFractionMapper.to_domain_usa(item) for item in usa_items]
total = len(items) + skip
if len(items) == limit:
total += 1
return items, total
except Exception as e:
import traceback
logger.error(f"Error fetching USA fractions (API): {e}")
logger.error(traceback.format_exc())
# Return empty list on error as per requirement (since API is broken)
return [], 0
# MEX (SITAR) CATALOG HANDLING
try:
sitar_service = FraccionesService.get_instance()
# Map filters
sitar_fraccion = None
sitar_nico = None
# Default level logic
level_filter = 5 # Default legacy
if filters and filters.get("level") is not None:
level_filter = filters["level"]
# Allow disabling level filter explicitly
if level_filter == -1:
level_filter = None
if filters:
if filters.get("search"):
term = filters["search"]
# Heuristic: if search starts with digit (after removing dots), treat as code/fraccion/nico
# This covers "0101", "01.01", "020691A"
clean_term = term.replace(".", "")
if clean_term and clean_term[0].isdigit():
sitar_fraccion = clean_term
else:
# Attempt description search via API first
logger.info(f"Search term '{term}' identified as text. Attempting API description search.")
pass
if filters.get("code"):
sitar_fraccion = filters["code"]
if filters.get("fraction"):
sitar_fraccion = filters["fraction"]
if filters.get("nico"):
sitar_nico = filters["nico"]
# Determine description filter
sitar_description = None
# Only use description if we didn't use it as code above
if filters and filters.get("search"):
clean_term = filters["search"].replace(".", "")
if not (clean_term and clean_term[0].isdigit()):
sitar_description = filters["search"]
# Note: Sitar search might not return total count.
# We fetch page items. Pagination might be tricky if Sitar doesn't return total.
# Assuming Sitar returns a list.
sitar_items = await sitar_service.search(
fraccion=sitar_fraccion,
nico=sitar_nico,
description=sitar_description,
nivel=level_filter, # Dynamic level
skip=skip,
limit=limit
)
# STRICT API USAGE:
# We do NOT fallback to local DB on empty list, as user requested strict API consumption.
# We also do NOT attempt enrichment as codes mismatch (API uses '010191A' vs Local '01012101').
# Map items
items = [TariffFractionMapper.to_domain(item) for item in sitar_items]
# Estimate total (Sitar service doesn't return total currently)
# If we got full limit, assume there are more.
total = len(items) + skip
if len(items) == limit:
total += 1 # Indicate more pages
return items, total
except Exception as e:
logger.error(f"Error fetching from Sitar API: {e}")
# STRICT API USAGE: Propagate error, do NOT fallback to local DB.
raise e
@staticmethod
async def _get_all_local_async(
db: Session,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[TariffFraction], int]:
"""Lógica original de consulta local"""
query = db.query(TariffFraction)
# 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()
# Add deterministic sort order
query = query.order_by(TariffFraction.fraction)
items = query.offset(skip).limit(limit).all()
return items, total
@staticmethod
def get_by_id(
db: Session,
tariff_fraction_id: int,
) -> Optional[TariffFraction]:
"""
Obtiene por ID.
Como Sitar no usa estos IDs, consultamos Local DB directamente para compatibilidad legacy.
Si se necesitara obtener detalle de Sitar, se requeriría otro identificador (Code).
"""
return (
db.query(TariffFraction)
.filter(TariffFraction.id == tariff_fraction_id)
.first()
)
# WRITE OPERATIONS - DEPRECATED / LOCAL ONLY (Optional: Remove or Keep for Fallback Maintenance)
@staticmethod
def get_by_code(
db: Session,
code: str,
) -> Optional[TariffFraction]:
return (
db.query(TariffFraction)
.filter(TariffFraction.code == code)
.first()
)
@staticmethod
def create(
db: Session,
tariff_fraction_data: TariffFractionCreateDTO,
) -> TariffFraction:
try:
tariff_fraction = TariffFraction(
**tariff_fraction_data.model_dump(),
)
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,
) -> Optional[TariffFraction]:
tariff_fraction = TariffFractionService.get_by_id(
db, tariff_fraction_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,
) -> bool:
tariff_fraction = TariffFractionService.get_by_id(
db, tariff_fraction_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

@@ -9,9 +9,10 @@ from sqlalchemy import String, Numeric, TIMESTAMP, func
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
class USTariffFraction(Base):
class USTariffFraction(Base, TenantScopedMixin, TimestampMixin):
"""Modelo para fracciones arancelarias americanas (US HTS codes)"""
__tablename__ = "us_tariff_fractions"
@@ -20,10 +21,6 @@ class USTariffFraction(Base):
# 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"
@@ -47,16 +44,5 @@ class USTariffFraction(Base):
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

@@ -17,21 +17,8 @@ from .dto import (
)
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,
)
# Create base router with generic CRUD routes - REMOVED strictly read-only from Sitar
# Writes are disabled at API level, but Service still supports fallback writes if needed internally
router = APIRouter(prefix="/us-tariff-fractions", tags=["a76 / general catalogs / us tariff fractions"])
@@ -57,7 +44,8 @@ async def list_us_tariff_fractions(
if search:
filters["search"] = search
items, total = USTariffFractionService.get_all(
# Updated to async call with Sitar integration
items, total = await USTariffFractionService.get_all(
db, tenant_id, company_id, skip, page_size, filters
)
@@ -69,5 +57,23 @@ async def list_us_tariff_fractions(
"pages": (total + page_size - 1) // page_size,
}
# Include other CRUD routes from base router
router.include_router(base_router.router)
@router.get(
"/{us_tariff_fraction_id}",
response_model=USTariffFractionResponseDTO,
summary="Get US Tariff Fraction by ID",
description="Get a specific US tariff fraction by ID (Lookups in Local DB for legacy compatibility)",
)
async def get_us_tariff_fraction(
us_tariff_fraction_id: int,
company_id: int = Query(..., description="Company ID"),
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)
item = USTariffFractionService.get_by_id(db, tenant_id, company_id, us_tariff_fraction_id)
if not item:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="US Tariff fraction not found")
return USTariffFractionResponseDTO.model_validate(item)

View File

@@ -0,0 +1,232 @@
"""
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 zlib
import logging
import re
from decimal import Decimal
from .models import USTariffFraction
from .dto import USTariffFractionCreateDTO, USTariffFractionUpdateDTO
from api.v1.modules.sitar.fracciones_usa.service import FraccionesUSAService
from api.v1.modules.sitar.fracciones_usa.schemas import FraccionesUSAResponse
logger = logging.getLogger(__name__)
class USTariffFractionMapper:
"""Helper to map Sitar USA responses to Local domain objects"""
@staticmethod
def to_domain(fraccion: FraccionesUSAResponse, tenant_id: int, company_id: int) -> USTariffFraction:
# Generate a deterministic numeric ID based on the unique code
# We use CRC32 to get a consistent integer implementation-independent
fake_id = zlib.crc32((fraccion.FRACCION_SIN_PUNTO or "").encode('utf-8'))
# Parse numeric values safely
ad_valorem = None
if fraccion.TARIFA1:
try:
# Extract numbers from string like "5.2%" or similar if present
# Assuming TARIFA1 might be clean number or percentage string
clean_val = re.sub(r'[^\d.]', '', str(fraccion.TARIFA1))
if clean_val:
ad_valorem = Decimal(clean_val)
except:
pass
fixed_cost = None
if fraccion.ESPECIFICO:
try:
clean_val = re.sub(r'[^\d.]', '', str(fraccion.ESPECIFICO))
if clean_val:
fixed_cost = Decimal(clean_val)
except:
pass
return USTariffFraction(
id=fake_id, # Updated to use fake_id instead of sitar consecutive if needed, or consistent hash
tenant_id=tenant_id,
company_id=company_id,
code=fraccion.FRACCION_SIN_PUNTO or "",
prefix=None, # Not mapped from Sitar response currently
type_code=None,
ad_valorem=ad_valorem,
fixed_cost=fixed_cost,
unit_of_measure=fraccion.UNIDADCANTIDAD,
description=fraccion.DESCRIPCION
)
class USTariffFractionService:
"""Service para gestionar fracciones arancelarias americanas"""
@staticmethod
async 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.
Estrategia: Sitar API -> Fallback Local DB
"""
# 1. Try Sitar API
try:
sitar_service = FraccionesUSAService.get_instance()
sitar_fraccion = None
has_filters = False
if filters and filters.get("search"):
term = filters["search"]
# Sitar only filters by fraction code
if term.replace(".", "").isdigit():
sitar_fraccion = term
has_filters = True
sitar_items = await sitar_service.search(
fraccion=sitar_fraccion,
skip=skip,
limit=limit
)
# If Sitar returns empty list AND we didn't have specific filters, attempt fallback
if not sitar_items and not has_filters:
logger.warning("Sitar return empty list for USA broad query. Attempting fallback to local DB.")
return USTariffFractionService._get_all_local(db, tenant_id, company_id, skip, limit, filters)
# Map items
items = [USTariffFractionMapper.to_domain(item, tenant_id, company_id) for item in sitar_items]
# Estimate total
total = len(items) + skip
if len(items) == limit:
total += 1
return items, total
except Exception as e:
logger.error(f"Error fetching USA Fractions from Sitar API, falling back to local DB: {e}")
return USTariffFractionService._get_all_local(db, tenant_id, company_id, skip, limit, filters)
@staticmethod
def _get_all_local(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[USTariffFraction], int]:
"""Lógica original de consulta local"""
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 por ID.
Legacy: Consulta Local DB.
"""
return (
db.query(USTariffFraction)
.filter(
USTariffFraction.id == fraction_id,
USTariffFraction.tenant_id == tenant_id,
USTariffFraction.company_id == company_id,
)
.first()
)
# WRITE OPERATIONS - DEPRECATED / LOCAL ONLY
@staticmethod
def create(
db: Session,
tenant_id: int,
company_id: int,
fraction_data: USTariffFractionCreateDTO,
) -> USTariffFraction:
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]:
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:
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,56 @@
from fastapi import APIRouter
from .company import router as company_router
from .exchange_rate.routes import router as exchange_rate_router
from .identifiers.routes import router as identifiers_router
from .packages.routes import router as package_router
from .ports.routes import router as ports_router
from .fractions.tariff_fractions.routes import router as tariff_fractions_router
from .fractions.us_tariff_fractions.routes import router as us_tariff_fractions_router
from .fractions.historical_tariff_fractions.routes import router as historical_tariff_fractions_router
from .fractions.canadian_tariff_fractions.routes import router as canadian_tariff_fractions_router
from .depreciation_catalog.routes import router as depreciation_catalog_router
from .fda_catalog.routes import router as fda_catalog_router
from .seal.routes import router as seal_router
from .units_of_measure.routes import router as units_of_measure_router
from .concepts.routes import router as concepts_router
from .customs_broker_concepts.routes import router as customs_broker_concepts_router
from .classification_concepts.routes import router as classification_concepts_router
from .unit_conversions.routes import router as unit_conversions_router
from .equivalencies.routes import router as equivalencies_router
from .multi_currency_types.routes import router as multi_currency_types_router
from .inpc.routes import router as inpc_router
from .legends.routes import router as legends_router
from .signatures.routes import router as signatures_router
from .error_catalogs.routes import router as error_catalogs_router
from .doda.routes import router as doda_router
from .prevalidators.routes import router as prevalidators_router
from .electronic_notices.routes import router as electronic_notices_router
router = APIRouter()
router.include_router(company_router, tags=["a76 / company"])
router.include_router(package_router)
router.include_router(ports_router)
router.include_router(tariff_fractions_router)
router.include_router(us_tariff_fractions_router)
router.include_router(historical_tariff_fractions_router, prefix="/fractions/historical-tariff-fractions", tags=["a76 / historical_tariff_fractions"])
router.include_router(canadian_tariff_fractions_router, prefix="/fractions/canadian-tariff-fractions", tags=["a76 / canadian_tariff_fractions"])
router.include_router(depreciation_catalog_router)
router.include_router(fda_catalog_router)
router.include_router(seal_router, tags=["a76 / seal"])
router.include_router(units_of_measure_router)
router.include_router(identifiers_router)
router.include_router(exchange_rate_router, tags=["a76 / exchange_rate"])
router.include_router(concepts_router)
router.include_router(customs_broker_concepts_router)
router.include_router(classification_concepts_router)
router.include_router(unit_conversions_router)
router.include_router(equivalencies_router)
router.include_router(multi_currency_types_router)
router.include_router(inpc_router)
router.include_router(legends_router)
router.include_router(signatures_router)
router.include_router(error_catalogs_router)
router.include_router(doda_router)
router.include_router(prevalidators_router)
router.include_router(electronic_notices_router)

View File

@@ -1,122 +0,0 @@
"""
Endpoints API para fracciones arancelarias
Catálogo de referencia global (no tenant-scoped)
"""
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 .dto import (
TariffFractionCreateDTO,
TariffFractionResponseDTO,
TariffFractionUpdateDTO,
)
from .service import TariffFractionService
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 (global catalog)",
)
async def list_tariff_fractions(
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),
):
skip = (page - 1) * page_size
filters = {}
if search:
filters["search"] = search
items, total = TariffFractionService.get_all(
db, 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,
}
@router.get(
"/{tariff_fraction_id}",
response_model=TariffFractionResponseDTO,
summary="Get Tariff Fraction by ID",
description="Get a specific tariff fraction by ID",
)
async def get_tariff_fraction(
tariff_fraction_id: int,
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
item = TariffFractionService.get_by_id(db, tariff_fraction_id)
if not item:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Tariff fraction not found")
return TariffFractionResponseDTO.model_validate(item)
@router.post(
"/",
response_model=TariffFractionResponseDTO,
summary="Create Tariff Fraction",
description="Create a new tariff fraction (admin only)",
status_code=201,
)
async def create_tariff_fraction(
data: TariffFractionCreateDTO,
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
item = TariffFractionService.create(db, data)
return TariffFractionResponseDTO.model_validate(item)
@router.put(
"/{tariff_fraction_id}",
response_model=TariffFractionResponseDTO,
summary="Update Tariff Fraction",
description="Update an existing tariff fraction (admin only)",
)
async def update_tariff_fraction(
tariff_fraction_id: int,
data: TariffFractionUpdateDTO,
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
item = TariffFractionService.update(db, tariff_fraction_id, data)
if not item:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Tariff fraction not found")
return TariffFractionResponseDTO.model_validate(item)
@router.delete(
"/{tariff_fraction_id}",
summary="Delete Tariff Fraction",
description="Delete a tariff fraction (admin only)",
status_code=204,
)
async def delete_tariff_fraction(
tariff_fraction_id: int,
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
success = TariffFractionService.delete(db, tariff_fraction_id)
if not success:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Tariff fraction not found")

View File

@@ -1,166 +0,0 @@
"""
Service para fracciones arancelarias
Catálogo de referencia global (no tenant-scoped)
"""
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 (catálogo global)"""
@staticmethod
def get_all(
db: Session,
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)
# 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,
) -> Optional[TariffFraction]:
"""Obtiene una fracción arancelaria por ID"""
return (
db.query(TariffFraction)
.filter(TariffFraction.id == tariff_fraction_id)
.first()
)
@staticmethod
def get_by_code(
db: Session,
code: str,
) -> Optional[TariffFraction]:
"""Obtiene una fracción arancelaria por código"""
return (
db.query(TariffFraction)
.filter(TariffFraction.code == code)
.first()
)
@staticmethod
def create(
db: Session,
tariff_fraction_data: TariffFractionCreateDTO,
) -> TariffFraction:
"""Crea una nueva fracción arancelaria"""
try:
tariff_fraction = TariffFraction(
**tariff_fraction_data.model_dump(),
)
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,
) -> Optional[TariffFraction]:
"""Actualiza una fracción arancelaria existente"""
tariff_fraction = TariffFractionService.get_by_id(
db, tariff_fraction_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,
) -> bool:
"""Elimina una fracción arancelaria"""
tariff_fraction = TariffFractionService.get_by_id(
db, tariff_fraction_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

@@ -1,129 +0,0 @@
"""
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,118 @@
from datetime import datetime
from uuid import uuid4
import os
import json
import logging
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends, Query
from sqlalchemy.orm import Session
from typing import Optional, Literal, Dict, Any
from core.celery_app import celery_app
from core.config import settings
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from .tasks import scan_file, insert_valid_rows
from .schemas import ImportJobResponse, ImportJobStatus, CommitRequest
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/upload/{model_target}", response_model=ImportJobResponse)
async def upload_import_file(
model_target: Literal["invoice_header", "invoice_details"],
file: UploadFile = File(...),
footer_config: Optional[str] = Form(None), # JSON string with settings
company_id: int = Query(..., description="Company ID"), # Required for context
operation_type: Optional[str] = Query("imp"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Step 1: Upload CSV, save to temp, trigger scan task.
"""
# 1. Validate Access & Get Tenant
try:
tenant_id = validate_access_to_resource(db, company_id, current_user)
except Exception as e:
logger.error(f"Access validation failed: {e}")
raise HTTPException(status_code=403, detail="Invalid company access")
if not file.filename.endswith(".csv"):
raise HTTPException(status_code=400, detail="Only .csv files allowed")
job_id = str(uuid4())
# Ensure directory exists (Safety check)
upload_dir = os.path.join(os.getcwd(), "uploads", "temp")
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, f"{job_id}.csv")
meta_path = os.path.join(upload_dir, f"{job_id}.meta.json")
try:
# Save CSV
contents = await file.read()
with open(file_path, "wb") as f:
f.write(contents)
# Save Metadata (Context)
meta_data = {
"tenant_id": tenant_id,
"company_id": company_id,
"user_id": current_user.get("id"),
"footer_config": footer_config,
"operation_type": operation_type,
}
with open(meta_path, "w") as f:
json.dump(meta_data, f)
except Exception as e:
logger.error(f"File save error: {e}")
raise HTTPException(status_code=500, detail=f"Failed to save file: {str(e)}")
# Trigger Celery Task (Async)
# Use our job_id as the Celery task_id for easier tracking
scan_file.apply_async(args=[job_id, file_path, model_target, footer_config], task_id=job_id)
return ImportJobResponse(
job_id=job_id,
status="queued",
message="File uploaded. Scanning started."
)
@router.get("/{job_id}/status")
async def get_import_status(job_id: str):
"""
Poll this endpoint to get % progress or final report.
"""
# In a real app, query Redis or DB.
# For MVP, we might mock or use Celery AsyncResult if backend shares Redis.
task_result = celery_app.AsyncResult(job_id)
if task_result.state == 'PENDING':
return {"status": "processing", "progress": 0}
elif task_result.state == 'PROGRESS':
return {
"status": "processing",
"progress": task_result.info.get('current', 0),
"total": task_result.info.get('total', 0)
}
elif task_result.state == 'SUCCESS':
return task_result.result # Should return the report
else:
return {"status": task_result.state, "error": str(task_result.info)}
@router.post("/{job_id}/commit")
async def commit_import_job(job_id: str, body: CommitRequest):
"""
Step 2: User confirms import. Trigger bulk insert.
"""
task = insert_valid_rows.delay(job_id, body.model_target)
return {
"status": "committing",
"message": "Bulk insert started.",
"commit_job_id": task.id
}

View File

@@ -0,0 +1,20 @@
from pydantic import BaseModel
from typing import Optional, Literal
class ImportJobResponse(BaseModel):
job_id: str
status: str
message: str
class CommitRequest(BaseModel):
model_target: Literal["invoice_header", "invoice_details"]
class ImportJobStatus(BaseModel):
status: str
job_id: str
total_rows: Optional[int] = 0
error_count: Optional[int] = 0
valid_rows: Optional[int] = 0
error: Optional[str] = None
inserted: Optional[int] = 0
error_file: Optional[str] = None

View File

@@ -0,0 +1,875 @@
import os
from datetime import datetime
from decimal import Decimal
import csv
import json
import logging
import re
import unicodedata
from celery import shared_task
from typing import Dict, Any, Optional
from core.database import CoreSessionLocal
# Models are imported inside tasks to avoid circular dependencies and mapper initialization issues in the API process
# We'll need schemas for validation
# from api.v1.modules.a76.invoices.schemas import InvoiceHeaderCreate
# But for Phase 1 we use a lighter check
logger = logging.getLogger(__name__)
class ForeignKeyValidator:
def __init__(self, session, tenant_id, company_id):
self.session = session
self.tenant_id = tenant_id
self.company_id = company_id
self.cache = {} # {(model_name, value): bool}
def check_exists(self, model, value, field_name="id", is_public=False):
if value is None:
return True # Assume optional if None, or let DB handle not-null
key = (model.__name__, value)
if key in self.cache:
return self.cache[key]
query = self.session.query(getattr(model, field_name)).filter(getattr(model, field_name) == value)
if not is_public:
query = query.filter(model.tenant_id == self.tenant_id, model.company_id == self.company_id)
exists = query.first() is not None
self.cache[key] = exists
return exists
@shared_task(bind=True)
def scan_file(self, job_id: str, file_path: str, model_target: str, config: str = None):
"""
Pass 1: Read CSV, Validate types, Write Errors to JSONL.
"""
logger.info(f"Starting scan for job {job_id} target {model_target}")
# 1. Setup Error Log
error_path = file_path.replace("temp", "errors").replace(".csv", ".jsonl")
os.makedirs(os.path.dirname(error_path), exist_ok=True)
total_rows = 0
error_count = 0
processed_rows = 0
# 2. Count Total (Quick Pass) or just estimate
# For better progress, we can get file line count first
try:
with open(file_path, 'r', encoding='utf-8-sig') as f:
total_rows = sum(1 for _ in f) - 1 # Minus header
except Exception as e:
return {"status": "failed", "error": f"Cannot read file: {e}"}
footer_config = parse_footer_config(config)
date_format = footer_config.get("dateFormat")
# Validate and set default date_format if not provided
if not date_format:
date_format = "yyyy-mm-dd" # Default to ISO format
logger.info(f"No date_format specified in config, using default: {date_format}")
try:
with open(file_path, 'r', encoding='utf-8-sig') as f_in, \
open(error_path, 'w', encoding='utf-8') as f_err:
# Detect Delimiter
sample = f_in.read(2048)
f_in.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
except:
dialect = 'excel'
reader = csv.DictReader(f_in, dialect=dialect)
for i, row in enumerate(reader, start=1):
# Check for Progress Update
if i % 1000 == 0:
self.update_state(state='PROGRESS', meta={
'current': i,
'total': total_rows,
'errors': error_count
})
# Validation (Phase 1: Minimal)
row_norm = normalize_row(row)
errors = validate_row_phase_1(row_norm, model_target, i, date_format)
if errors:
error_count += 1
# Write simple JSON error
f_err.write(json.dumps(errors) + "\n")
processed_rows += 1
except Exception as e:
logger.error(f"Scan failed: {e}")
return {"status": "failed", "error": str(e)}
# 4. Result
return {
"status": "waiting_confirmation",
"job_id": job_id,
"total_rows": processed_rows,
"error_count": error_count,
"valid_rows": processed_rows - error_count,
"error_file": error_path
}
def validate_row_phase_1(
row: Dict[str, Any],
target: str,
line_num: int,
date_format: Optional[str],
) -> Dict[str, Any]:
"""
Minimal validation: Unique IDs and Dates.
Target: 'invoice_header' or 'invoice_details'
"""
errors = {}
# A. Invoice Header
if target == 'invoice_header':
# 1. Unique ID
if not row.get('NUMERO FACTURA') and not row.get('NUM FACTURA') and not row.get('ID'):
return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"}
# 2. Date Format
date_str = row.get('FECHA FACTURA')
if date_str:
if not is_valid_date(date_str, date_format):
expected = display_date_format(date_format)
return {
"line": line_num,
"col": "FECHA FACTURA",
"msg": f"Formato inválido ({expected})",
}
else:
return {"line": line_num, "col": "FECHA FACTURA", "msg": "Requerido"}
# B. Invoice Details (Parts)
elif target == 'invoice_details':
# 1. Line Number
if not row.get('LINEA'):
return {"line": line_num, "col": "LINEA", "msg": "Requerido"}
# 2. Parent Link (Invoice Number)
if not (row.get('NUMERO FACTURA') or row.get('NUM FACTURA') or row.get('FACTURA')):
return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"}
# 2. Parent Link (Simplified for now, we assume parent exists or is in same batch)
# In a real scenario, we'd check if the invoice exists.
pass
return errors if errors else None
def parse_footer_config(config: Optional[str]) -> Dict[str, Any]:
if not config:
return {}
try:
if isinstance(config, str):
return json.loads(config)
if isinstance(config, dict):
return config
except Exception:
return {}
return {}
def display_date_format(date_format: Optional[str]) -> str:
if not date_format:
return "YYYY-MM-DD"
return date_format.upper()
def parse_date(date_text: Optional[str], date_format: Optional[str]) -> Optional[datetime.date]:
if not date_text:
return None
candidates = []
fmt_map = {
"dd/mm/yyyy": "%d/%m/%Y",
"mm/dd/yyyy": "%m/%d/%Y",
"yyyy-mm-dd": "%Y-%m-%d",
}
if date_format and date_format in fmt_map:
candidates.append(fmt_map[date_format])
candidates.extend(["%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y"])
for fmt in candidates:
try:
return datetime.strptime(str(date_text).strip(), fmt).date()
except ValueError:
continue
return None
def is_valid_date(date_text: Optional[str], date_format: Optional[str]) -> bool:
return parse_date(date_text, date_format) is not None
def normalize_header(name: Optional[str]) -> str:
if not name:
return ""
name = unicodedata.normalize("NFKD", str(name)).upper()
name = "".join(ch for ch in name if not unicodedata.combining(ch))
name = re.sub(r"[^A-Z0-9]+", " ", name)
return re.sub(r"\s+", " ", name).strip()
def normalize_row(row: Dict[str, Any]) -> Dict[str, Any]:
return {normalize_header(k): v for k, v in row.items()}
def parse_int(value: Any) -> Optional[int]:
if value is None:
return None
text = str(value).strip()
if not text:
return None
try:
return int(text)
except ValueError:
return None
def parse_decimal(value: Any) -> Optional[Decimal]:
if value is None:
return None
text = str(value).strip()
if not text:
return None
text = text.replace(",", "")
try:
return Decimal(text)
except Exception:
return None
def parse_currency(value: Optional[str], currency_type: Optional[str]):
from api.v1.modules.a76.invoices.models import Currency
if value:
normalized = normalize_header(value)
if normalized in {"MN", "M N", "NACIONAL", "LOCAL", "PESOS", "PESO"}:
return Currency.LOCAL
if normalized in {"ME", "M E", "EXTRANJERA", "EXTRANJERO", "FOREIGN", "USD", "DOLAR", "DOLARES"}:
return Currency.FOREIGN
if "MANUAL" in normalized:
return Currency.MANUAL
if currency_type and str(currency_type).strip().upper() == "MXN":
return Currency.LOCAL
if currency_type:
return Currency.FOREIGN
return Currency.MANUAL
def parse_weight_unit(value: Optional[str]):
from api.v1.modules.a76.invoices.models import WeightUnit
if not value:
return None
normalized = normalize_header(value)
if normalized in {"KG", "KGS", "KILOS", "KILOGRAMOS"}:
return WeightUnit.KGS
if normalized in {"LB", "LBS", "LIBRAS"}:
return WeightUnit.LBS
return None
def resolve_tenant_fk_id(
session: CoreSessionLocal,
model,
value: Optional[int],
tenant_id: int,
company_id: int,
cache: Dict[int, Optional[int]],
) -> Optional[int]:
if value is None:
return None
if value in cache:
return cache[value]
exists = (
session.query(model.id)
.filter(
model.id == value,
model.tenant_id == tenant_id,
model.company_id == company_id,
)
.scalar()
)
cache[value] = value if exists is not None else None
return cache[value]
def resolve_public_code(
session: CoreSessionLocal,
model,
column,
value: Optional[str],
cache: Dict[str, Optional[str]],
) -> Optional[str]:
if not value:
return None
normalized = str(value).strip().upper()
if not normalized:
return None
if normalized in cache:
return cache[normalized]
exists = session.query(column).filter(column == normalized).scalar()
cache[normalized] = normalized if exists is not None else None
return cache[normalized]
@shared_task(bind=True)
def insert_valid_rows(self, job_id: str, model_target: str):
"""
Pass 2: Re-read CSV, Skip Errors, Bulk Insert.
"""
logger.info(f"Starting Commit for {job_id} target {model_target}")
try:
from api.v1.modules.a76.invoices.models import (
InvoiceHeader,
InvoiceComplianceMx,
InvoiceFinancials,
InvoiceLogistics,
InvoiceSalesDetails,
OperationType,
WeightUnit,
)
from api.v1.modules.a76.clients_and_providers.models import ClientProvider
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
from api.v1.modules.public.reference_data.currency_types.models import CurrencyType
from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento
from api.v1.modules.public.reference_data.code_pedimento_regimens.models import CodePedimentoRegimen
from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode
from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType
from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.items.line_financials.models import LineFinancial
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
from api.v1.modules.a76.items.line_customs.models import LineCustom
from api.v1.modules.a76.items.line_descriptions.models import LineDescription
from api.v1.modules.a76.parts.models import Part
upload_dir = os.path.join(os.getcwd(), "uploads", "temp")
file_path = os.path.join(upload_dir, f"{job_id}.csv")
error_path = file_path.replace("temp", "errors").replace(".csv", ".jsonl")
# 1. Load Error Line Numbers
error_lines = set()
if os.path.exists(error_path):
with open(error_path, 'r', encoding='utf-8') as f:
for line in f:
try:
err = json.loads(line)
error_lines.add(err['line'])
except: pass
# Load Metadata (Context)
meta_path = file_path.replace("temp", "temp").replace(".csv", ".meta.json")
tenant_id = None
company_id = None
footer_config = {}
if os.path.exists(meta_path):
try:
with open(meta_path, 'r') as f:
meta = json.load(f)
tenant_id = meta.get('tenant_id')
company_id = meta.get('company_id')
operation_type_raw = meta.get('operation_type', 'imp')
footer_config = parse_footer_config(meta.get('footer_config'))
except: pass
if not tenant_id or not company_id:
return {"status": "failed", "error": "Missing context (tenant/company)"}
# 2. Re-read and Map
# Initialize counters outside the session block so they're accessible later
headers_to_insert = []
details_to_insert = []
skipped_invalid = 0
skipped_missing_invoice = 0
skipped_missing_fk = 0
skipped_fk_details = []
inserted_count = 0
response = None # Will be set inside the session block
date_format = footer_config.get("dateFormat")
# Validate and set default date_format if not provided
if not date_format:
date_format = "yyyy-mm-dd" # Default to ISO format
logger.info(f"No date_format specified in config, using default: {date_format}")
else:
logger.info(f"Using date_format from config: {date_format}")
# Default types from config or fallback
op_type_value = OperationType(meta.get('operation_type', 'imp').lower())
inv_type_value = footer_config.get('invoice_type', 'TEM')
logger.info(f"Processing CSV with operation_type={op_type_value}, invoice_type={inv_type_value}, date_format={date_format}")
with CoreSessionLocal() as session:
invoice_id_cache = {}
cleared_invoices = set() # Track invoices where we've already cleared items in this job
provider_cache: Dict[int, Optional[int]] = {}
sold_to_cache: Dict[int, Optional[int]] = {}
shipped_to_cache: Dict[int, Optional[int]] = {}
broker_cache: Dict[int, Optional[int]] = {}
regimen_cache: Dict[str, Optional[str]] = {}
currency_type_cache: Dict[str, Optional[str]] = {}
customs_section_cache: Dict[str, Optional[str]] = {}
part_cache: Dict[str, Optional[int]] = {}
validator = ForeignKeyValidator(session, tenant_id, company_id)
with open(file_path, 'r', encoding='utf-8-sig') as f:
# Detect Delimiter
sample = f.read(2048)
f.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
except:
dialect = 'excel'
reader = csv.DictReader(f, dialect=dialect)
for i, row in enumerate(reader, start=1):
if i in error_lines:
continue
row_norm = normalize_row(row)
# Mapping Logic
if model_target == 'invoice_header':
invoice_number = (row_norm.get('NUMERO FACTURA') or row_norm.get('NUM FACTURA') or row_norm.get('FACTURA') or '').strip()
invoice_date = parse_date(row_norm.get('FECHA FACTURA') or row_norm.get('FECHA'), date_format)
if not invoice_number or not invoice_date:
skipped_invalid += 1
logger.debug(f"Row {i}: Skipped - missing invoice_number or invalid invoice_date. "
f"Invoice: {invoice_number}, Date: {row_norm.get('FECHA FACTURA') or row_norm.get('FECHA')}")
continue
# --- NEW: Foreign Key Validations ---
# 1. Invoice Type (Public)
if not validator.check_exists(InvoiceType, inv_type_value, field_name="key", is_public=True):
skipped_missing_fk += 1
reason = f"Tipo de factura '{inv_type_value}' no existe"
skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason})
logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}")
continue
# 2. Client/Provider (Tenant)
provider_id = parse_int(row_norm.get('CLAVE PROVEEDOR'))
if provider_id and not validator.check_exists(ClientProvider, provider_id):
skipped_missing_fk += 1
reason = f"Proveedor ID '{provider_id}' no existe"
skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason})
logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}")
continue
# 3. Customs Broker (Tenant)
broker_id = parse_int(row_norm.get('AGENTE ADUANAL'))
if broker_id and not validator.check_exists(CustomsBroker, broker_id):
skipped_missing_fk += 1
reason = f"Agente Aduanal ID '{broker_id}' no existe"
skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason})
logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}")
continue
# --- 4. Check for Existing Invoice (Upsert Logic) ---
existing_header = None
if invoice_number:
existing_header = (
session.query(InvoiceHeader)
.filter(
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id,
InvoiceHeader.invoice_number == invoice_number,
InvoiceHeader.invoice_type == inv_type_value
)
.first()
)
if existing_header:
# UPDATE existing header
header = existing_header
header.invoice_date = invoice_date
header.operation_type = op_type_value
header.is_updated = True # Mark as updated
header.updated_date = datetime.utcnow()
header.document_type = resolve_public_code(
session,
RegimenPedimento,
RegimenPedimento.code,
(row_norm.get('REGIMEN') or row_norm.get('CLAVEDOCUMENTO')),
regimen_cache,
)
header.project_number = (row_norm.get('NUM PROYECTO') or row_norm.get('NUMPROYECTO') or None)
header.purchase_order = (row_norm.get('ORDEN COMPRA') or row_norm.get('ORDENCOMPRA') or None)
header.alternate_invoice = (row_norm.get('FACTURA ALTERNA') or None)
header.invoice_ref = (row_norm.get('FACTURA EXPO REF') or row_norm.get('FACTURAEXPOREF') or None)
header.emission_date = parse_date(row_norm.get('FECHA EMISION'), date_format)
header.observation_es = (row_norm.get('OBSERVACIONES E') or None)
header.observation_en = (row_norm.get('OBSERVACIONES I') or None)
logger.info(f"Row {i}: Updating existing invoice {invoice_number}")
# Clean up related data that will be re-inserted/updated
# Note: compliance, financials, logistics are 1-to-1 relationships and will be updated by assignment below
# but we might want to be explicit if ORM doesn't handle replace well.
# SQLAlchemy relationship assignment usually handles 1-to-1 updates correctly.
else:
# CREATE new header
header = InvoiceHeader(
invoice_number=invoice_number,
invoice_date=invoice_date,
operation_type=op_type_value,
is_updated=False,
system="CSV",
capture_date=datetime.utcnow(),
invoice_type=inv_type_value,
document_type=resolve_public_code(
session,
RegimenPedimento,
RegimenPedimento.code,
(row_norm.get('REGIMEN') or row_norm.get('CLAVEDOCUMENTO')),
regimen_cache,
),
project_number=(row_norm.get('NUM PROYECTO') or row_norm.get('NUMPROYECTO') or None),
purchase_order=(row_norm.get('ORDEN COMPRA') or row_norm.get('ORDENCOMPRA') or None),
alternate_invoice=(row_norm.get('FACTURA ALTERNA') or None),
invoice_ref=(row_norm.get('FACTURA EXPO REF') or row_norm.get('FACTURAEXPOREF') or None),
emission_date=parse_date(row_norm.get('FECHA EMISION'), date_format),
observation_es=(row_norm.get('OBSERVACIONES E') or None),
observation_en=(row_norm.get('OBSERVACIONES I') or None),
tenant_id=tenant_id,
company_id=company_id,
)
compliance = InvoiceComplianceMx(
remesa=parse_int(row_norm.get('REMESA')),
aduana=resolve_public_code(
session,
CustomsSection,
CustomsSection.customs_code,
row_norm.get('ADUANA DE CRUCE'),
customs_section_cache,
),
provider_id=resolve_tenant_fk_id(
session,
ClientProvider,
parse_int(row_norm.get('CLAVE PROVEEDOR')),
tenant_id,
company_id,
provider_cache,
),
sold_to_id=resolve_tenant_fk_id(
session,
ClientProvider,
parse_int(row_norm.get('CLAVE VENDIDO A')),
tenant_id,
company_id,
sold_to_cache,
),
shipped_to_id=resolve_tenant_fk_id(
session,
ClientProvider,
parse_int(row_norm.get('CLAVE ENVIADO A')),
tenant_id,
company_id,
shipped_to_cache,
),
customs_broker_id=resolve_tenant_fk_id(
session,
CustomsBroker,
parse_int(row_norm.get('AGENTE ADUANAL')),
tenant_id,
company_id,
broker_cache,
),
edocument=(row_norm.get('E DOCUMENT') or None),
vucem_operation_num=(row_norm.get('NUM OPERACION') or None),
tenant_id=tenant_id,
company_id=company_id,
)
financials_currency_type = resolve_public_code(
session,
CurrencyType,
CurrencyType.code,
row_norm.get('CLAVE MONEDA'),
currency_type_cache,
)
financials = InvoiceFinancials(
currency=parse_currency(row_norm.get('TIPO MONEDA'), financials_currency_type),
currency_type=financials_currency_type,
exchange_rate=parse_decimal(row_norm.get('TIPO DE CAMBIO')),
freight=parse_decimal(row_norm.get('FLETES')),
insurance_value=parse_decimal(row_norm.get('VALOR SEGUROS')),
insurance=parse_decimal(row_norm.get('SEGUROS')),
packaging=parse_decimal(row_norm.get('EMBALAJES')),
other_increments=parse_decimal(row_norm.get('OTROS INCREMENTABLES')),
tenant_id=tenant_id,
company_id=company_id,
)
weight_type = parse_weight_unit(row_norm.get('TIPO PESO'))
logistics = None
if weight_type or row_norm.get('TIPO TRANSPORTE') or row_norm.get('NUMERO TRANSPORTE'):
logistics = InvoiceLogistics(
carrier_id=(row_norm.get('CLAVE TRANSPORTISTA') or None),
driver_name=(row_norm.get('NOMBRE CONDUCTOR') or None),
transport_type=str(row_norm.get('TIPO TRANSPORTE') or "none").lower(),
transport_num=(row_norm.get('NUMERO TRANSPORTE') or None),
weight_type=weight_type or WeightUnit.KGS,
seal_number=(row_norm.get('PRECINTO') or None),
incoterm=(row_norm.get('CLAVE INCOTERM') or None),
entry_exit_date=parse_date(row_norm.get('FECHA EMISION'), date_format),
tenant_id=tenant_id,
company_id=company_id,
)
header.compliance_mx = compliance
header.financials = financials
if logistics:
header.logistics = logistics
headers_to_insert.append(header)
elif model_target == 'invoice_details':
invoice_number = (row_norm.get('NUMERO FACTURA') or row_norm.get('NUM FACTURA') or '').strip()
if not invoice_number:
skipped_invalid += 1
continue
if invoice_number in invoice_id_cache:
invoice_id = invoice_id_cache[invoice_number]
else:
invoice_id = (
session.query(InvoiceHeader.id)
.filter(
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id,
InvoiceHeader.invoice_number == invoice_number,
)
.scalar()
)
invoice_id_cache[invoice_number] = invoice_id
if not invoice_id:
logger.warning(
"Invoice not found for details row %s (invoice_number=%s)",
i,
invoice_number,
)
skipped_missing_invoice += 1
continue
# --- Prevent Duplicates: Clear existing items for this invoice (Once per job) ---
if invoice_id not in cleared_invoices:
logger.info(f"Clearing existing details for Invoice {invoice_number} (ID: {invoice_id}) to prevent duplicates")
# 1. Delete Items (Cascades to LineItem, LineFinancial, etc. if DB configured, check models)
# Checking Item model, we usually need to be careful.
# Assuming Cascade delete is set up on FKs or we rely on ORM cascade if using relationships.
# Here we use bulk delete.
session.query(Item).filter(Item.invoice_id == invoice_id).delete(synchronize_session=False)
# 2. Delete InvoiceSalesDetails
session.query(InvoiceSalesDetails).filter(InvoiceSalesDetails.invoice_id == invoice_id).delete(synchronize_session=False)
cleared_invoices.add(invoice_id)
# --- NEW LOGIC: Expanded Anexo 76 Structure ---
# A. Find/Cache Part
part_num = (row_norm.get('NUMPARTE') or row_norm.get('NUMERO PARTE') or '').strip()
part_id = None
if part_num:
part_id = part_cache.get(part_num)
if part_id is None:
p = session.query(Part.id).filter(
Part.part_number == part_num,
Part.tenant_id == tenant_id,
Part.company_id == company_id
).first()
if p:
part_id = p.id
part_cache[part_num] = part_id
line_num_val = (row_norm.get('LINEA') or row_norm.get('RENGLON') or row_norm.get('PARTIDA'))
line_num = parse_int(line_num_val) or (len(details_to_insert) + 1)
# 1. Parent Item
item = Item(
invoice_id=invoice_id,
tenant_id=tenant_id,
company_id=company_id,
item_type="N", # Default to Normal
system_origin="CSV"
)
session.add(item)
session.flush() # Need item.id
# 2. Main Line
line = LineItem(
item_id=item.id,
line_number=line_num,
part_number=part_id,
tenant_id=tenant_id,
company_id=company_id
)
session.add(line)
session.flush() # Need line.id
# 3. Financial Data
price = parse_decimal(row_norm.get('PRECIO UNITARIO') or row_norm.get('PRECIOUNITARIO'))
val_com = parse_decimal(row_norm.get('VALOR COMERCIAL') or row_norm.get('VALORCOMERCIAL'))
qty = parse_decimal(row_norm.get('CANTIDAD'))
session.add(LineFinancial(
item_line_id=line.id,
unit_price=price,
commercial_value=val_com or (price * qty if price and qty else None),
))
# 4. Quantities
if qty:
session.add(LineQuantity(
item_line_id=line.id,
quantity=qty,
))
# 5. Customs/Fraction
origin = row_norm.get('PAIS ORIGEN') or row_norm.get('PAISORIGEN')
fraction = row_norm.get('FRACCION')
if origin or fraction:
session.add(LineCustom(
item_line_id=line.id,
fraction=fraction,
origin_country=origin,
))
# 6. Description
desc = row_norm.get('DESCRIPCION')
if desc:
session.add(LineDescription(
item_line_id=line.id,
description_spanish=desc,
))
# 7. Legacy Sales Details (For specific audit/UI fields)
detail = InvoiceSalesDetails(
invoice_id=invoice_id,
line_number=line_num,
sales_order=(row_norm.get('ORDEN DE COMPRA') or row_norm.get('ORDENCOMPRA') or None),
line_bundles=parse_int(row_norm.get('CANTIDAD BULTOS') or row_norm.get('CANTIDADBULTOS')),
tenant_id=tenant_id,
company_id=company_id,
)
session.add(detail)
details_to_insert.append(item) # Use as counter/ref
# 3. Bulk Insert (ORM Transaction)
try:
if model_target == 'invoice_header':
if headers_to_insert:
logger.info(f"Attempting to commit {len(headers_to_insert)} headers")
session.add_all(headers_to_insert)
session.commit()
inserted_count = len(headers_to_insert)
logger.info(f"Headers commit successful. Inserted: {inserted_count}")
else:
logger.warning(f"No headers to insert for job {job_id}")
else:
if details_to_insert:
logger.info(f"Attempting to commit {len(details_to_insert)} items and related data")
session.commit() # Everything was already added with session.add()
inserted_count = len(details_to_insert)
logger.info(f"Details commit successful. Inserted: {inserted_count}")
else:
logger.warning(f"No details to insert for job {job_id}")
except Exception as db_err:
session.rollback()
logger.error(f"DB Error during {model_target} commit: {db_err}")
import traceback
logger.error(traceback.format_exc())
return {"status": "failed", "error": str(db_err)}
# 4. Determine final status and prepare response (inside session block to access variables)
total_skipped = skipped_invalid + skipped_missing_fk + skipped_missing_invoice
# Log summary
logger.info(f"Job {job_id} completed. Inserted: {inserted_count}, Skipped: {total_skipped} "
f"(invalid: {skipped_invalid}, missing_fk: {skipped_missing_fk}, missing_invoice: {skipped_missing_invoice})")
# Prepare response based on results
if inserted_count == 0:
if total_skipped > 0:
logger.warning(f"No valid records to insert for job {job_id}. All {total_skipped} records were rejected.")
response = {
"status": "warning",
"inserted": 0,
"skipped_invalid": skipped_invalid,
"skipped_missing_invoice": skipped_missing_invoice,
"skipped_missing_fk": skipped_missing_fk,
"skipped_details": skipped_fk_details,
"message": f"No se insertaron registros. {total_skipped} fueron rechazados."
}
else:
logger.error(f"No valid records found in CSV for job {job_id}")
response = {
"status": "failed",
"error": "No hay registros válidos en el archivo CSV",
"inserted": 0,
"skipped_invalid": skipped_invalid,
"skipped_missing_invoice": skipped_missing_invoice,
"skipped_missing_fk": skipped_missing_fk,
"skipped_details": skipped_fk_details
}
else:
# Success case - at least some records were inserted
response = {
"status": "finished",
"inserted": inserted_count,
"skipped_invalid": skipped_invalid,
"skipped_missing_invoice": skipped_missing_invoice,
"skipped_missing_fk": skipped_missing_fk,
"skipped_details": skipped_fk_details
}
except Exception as e:
logger.error(f"Task failed: {e}")
import traceback
logger.error(traceback.format_exc())
return {"status": "failed", "error": str(e)}
# 5. Cleanup
try:
if os.path.exists(file_path):
os.remove(file_path)
if os.path.exists(error_path):
os.remove(error_path)
except:
logger.warning("Failed to cleanup temp files")
# Ensure response is defined (fallback in case of unexpected errors)
if response is None:
logger.error(f"Unexpected error: response not set for job {job_id}")
response = {
"status": "failed",
"error": "Error inesperado durante el procesamiento",
"inserted": 0,
"skipped_invalid": skipped_invalid,
"skipped_missing_invoice": skipped_missing_invoice,
"skipped_missing_fk": skipped_missing_fk,
"skipped_details": skipped_fk_details
}
return response

View File

@@ -2,13 +2,7 @@ from typing import Any, Dict, Optional
from pydantic import BaseModel, ConfigDict
from enum import Enum
class OperationType(str, Enum):
IMP = "imp" # Importación
EXP = "exp" # Exportación
SM_IN = "sm_in" # Entrada SM
SM_OUT = "sm_out" # Salida SM
CTM_SEND = "ctm_send" # Envío CTM
CTM_RECEIVE = "ctm_receive" # Recibo CTM
from .models import OperationType
class InvoiceSettingsBase(BaseModel):
invoice_type: str

View File

@@ -40,8 +40,7 @@ def get_invoice_settings(
tenant_id=tenant_id,
company_id=company_id
)
return settings
return InvoiceSettingsResponse.model_validate(settings)
@router.get("/", response_model=List[InvoiceSettingsResponse])
def list_invoice_settings(

View File

@@ -17,7 +17,7 @@ def get_settings(
InvoiceSettings.tenant_id == tenant_id,
InvoiceSettings.company_id == company_id,
InvoiceSettings.invoice_type == invoice_type,
InvoiceSettings.operation_type == operation_type
InvoiceSettings.operation_type == operation_type.value
)
return db.execute(stmt).scalar_one_or_none()
@@ -60,7 +60,7 @@ def upsert_settings(
tenant_id=tenant_id,
company_id=company_id,
invoice_type=settings_data.invoice_type,
operation_type=settings_data.operation_type,
operation_type=settings_data.operation_type.value,
settings=settings_data.settings
)

View File

@@ -0,0 +1,217 @@
from typing import Dict, Any, List, Optional
from sqlalchemy.orm import Session
# Import Reference Data Models
from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType
from api.v1.modules.public.reference_data.currency_types.models import CurrencyType
from api.v1.modules.public.reference_data.transport_types.models import TransportType
from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection
from api.v1.modules.public.reference_data.code_pedimento_regimens.models import CodePedimentoRegimen
from api.v1.modules.public.reference_data.incoterms.models import Incoterm
from api.v1.modules.public.reference_data.transport_modes.models import TransportMode
# Import A76 Services
from api.v1.modules.a76.customs_brokers.services import CustomsBrokerService
from api.v1.modules.a76.clients_and_providers.service import ClientProviderService
from api.v1.modules.a76.transportation.transporters.services import TransporterService
from api.v1.modules.a76.transportation.vehicles.services import VehicleService
from api.v1.modules.a76.transportation.drivers.services import DriverService
from api.v1.modules.a76.transportation.trailers.services import TrailerService
from api.v1.modules.a76.general_catalogs.seal.services import SealService
from api.v1.modules.a76.pedmientos.services.pedimentos import PedimentosService
# Import DTOs for mapping
from api.v1.modules.public.reference_data.invoice_types.dto import InvoiceTypeDTO
from api.v1.modules.a76.customs_brokers.dto import CustomsBrokerResponseDTO
from api.v1.modules.a76.clients_and_providers.dto import ClientProviderResponseDTO
from api.v1.modules.public.reference_data.currency_types.dto import CurrencyTypeDTO
from api.v1.modules.public.reference_data.transport_types.dto import TransportTypeDTO
from api.v1.modules.public.reference_data.customs_sections.dto import CustomsSectionDTO
from api.v1.modules.public.reference_data.code_pedimento_regimens.dto import CodePedimentoRegimenDTO
from api.v1.modules.public.reference_data.incoterms.dto import IncotermDTO
from api.v1.modules.public.reference_data.transport_modes.dto import TransportModeDTO
# Additional DTOs
from api.v1.modules.a76.transportation.transporters.dto import TransporterResponseDTO
from api.v1.modules.a76.transportation.vehicles.dto import VehicleResponseDTO
from api.v1.modules.a76.transportation.drivers.dto import DriverResponseDTO
from api.v1.modules.a76.transportation.trailers.dto import TrailerResponseDTO
from api.v1.modules.a76.general_catalogs.seal.dto import SealResponseDTO
from api.v1.modules.a76.pedmientos.dtos.pedimentos import PedimentosResponse
from .schemas import InvoiceCatalogsResponse, InvoiceCreationResponse, InvoiceEditionResponse
class InvoiceCatalogService:
"""Service to fetch consolidated catalogs for Invoice views"""
@staticmethod
def get_catalogs(db: Session, tenant_id: int, company_id: int) -> InvoiceCatalogsResponse:
"""Fetch all catalogs"""
response = InvoiceCatalogsResponse()
# Helper to fetch reference data (no company_id needed)
def fetch_ref_data():
response.invoice_types = [
InvoiceTypeDTO.model_validate(obj) for obj in db.query(InvoiceType).all()
]
response.currency_types = [
CurrencyTypeDTO.model_validate(obj) for obj in db.query(CurrencyType).all()
]
response.transport_types = [
TransportTypeDTO.model_validate(obj) for obj in db.query(TransportType).all()
]
response.customs_sections = [
CustomsSectionDTO.model_validate(obj) for obj in db.query(CustomsSection).all()
]
response.code_pedimento_regimens = [
CodePedimentoRegimenDTO.model_validate(obj) for obj in db.query(CodePedimentoRegimen).all()
]
response.incoterms = [
IncotermDTO.model_validate(obj) for obj in db.query(Incoterm).all()
]
response.transport_modes = [
TransportModeDTO.model_validate(obj) for obj in db.query(TransportMode).all()
]
# Helper to fetch tenant/company specific data
def fetch_tenant_data():
# Customs Brokers
try:
brokers, _ = CustomsBrokerService.get_all(db, tenant_id, company_id, limit=1000)
response.customs_brokers = [
CustomsBrokerResponseDTO.model_validate(obj) for obj in brokers
]
except Exception as e:
print(f"Error fetching customs brokers: {e}")
# Clients and Providers
try:
# Fetch all clients/providers
# Note: get_all returns list[ClientProvider]
all_cps, _ = ClientProviderService.get_all(
db, tenant_id, company_id, limit=2000
)
# Helper to safely check client type (handles string or Enum)
def is_type(obj, types):
val = obj.client_or_provider
# If it's an enum, get its value, otherwise use as string
val_str = val.value if hasattr(val, 'value') else str(val)
return val_str in types
response.clients = [
ClientProviderResponseDTO.model_validate(obj) for obj in all_cps
if is_type(obj, ['client', 'both'])
]
response.providers = [
ClientProviderResponseDTO.model_validate(obj) for obj in all_cps
if is_type(obj, ['provider', 'both'])
]
except Exception as e:
print(f"Error fetching clients/providers: {e}")
# Transporters
try:
transporters, _ = TransporterService.get_all(db, tenant_id, company_id, limit=1000)
response.transporters = [
TransporterResponseDTO.model_validate(t) for t in transporters
]
except Exception as e:
print(f"Error fetching transporters: {e}")
# Vehicles
try:
vehicles, _ = VehicleService.get_all(db, tenant_id, company_id, limit=1000)
response.vehicles = [
VehicleResponseDTO.model_validate(v) for v in vehicles
]
except Exception as e:
print(f"Error fetching vehicles: {e}")
# Drivers
try:
drivers, _ = DriverService.get_all(db, tenant_id, company_id, limit=1000)
response.drivers = [
DriverResponseDTO.model_validate(d) for d in drivers
]
except Exception as e:
print(f"Error fetching drivers: {e}")
# Trailers
try:
trailers, _ = TrailerService.get_all(db, tenant_id, company_id, limit=1000)
response.trailers = [
TrailerResponseDTO.model_validate(t) for t in trailers
]
except Exception as e:
print(f"Error fetching trailers: {e}")
# Seals
try:
seals, _ = SealService.get_all(db, tenant_id, company_id, limit=1000)
response.seals = [
SealResponseDTO.model_validate(s) for s in seals
]
except Exception as e:
print(f"Error fetching seals: {e}")
# Pedimentos
try:
# Fetch recent pedimentos (e.g. last 100) or filtered if necessary
pedimentos, _ = PedimentosService.get_all(db, tenant_id, company_id, limit=100)
response.pedimentos = [
# Use dict for now if PedimentosResponse fails due to complexity or just map fields manually if needed
# But PedimentosResponse has from_attributes=True
# Note: PedimentosResponse structure is complex with nested relations.
# If Pedimentos model is fully loaded (eager load in service), this should work.
# However, to be safe against recursion or huge payload, we might want a lighter DTO.
# Re-using PedimentosResponse for now but be cautious of payload size.
PedimentosResponse.model_validate(p) for p in pedimentos
]
except Exception as e:
print(f"Error fetching pedimentos: {e}")
try:
fetch_ref_data()
fetch_tenant_data()
except Exception as e:
print(f"Error fetching catalogs: {e}")
# In production, we might want to log this properly and potentially return partial data
# For now, re-raising might be safer to debug, but for resiliency we could suppress.
# Let's log and re-raise to ensure frontend knows something went wrong during dev.
import traceback
traceback.print_exc()
raise e
return response
@staticmethod
def get_creation_data(db: Session, tenant_id: int, company_id: int) -> InvoiceCreationResponse:
catalogs = InvoiceCatalogService.get_catalogs(db, tenant_id, company_id)
return InvoiceCreationResponse(
**catalogs.model_dump(),
is_create=True,
filters={}
)
@staticmethod
def get_edition_data(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> Optional[InvoiceEditionResponse]:
catalogs = InvoiceCatalogService.get_catalogs(db, tenant_id, company_id)
from .services import InvoiceService
invoice = InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id)
if not invoice:
return None
return InvoiceEditionResponse(
**catalogs.model_dump(),
is_create=False,
invoice=invoice,
invoice_id=invoice_id,
filters={}
)

View File

@@ -1,3 +1,4 @@
from typing import Optional
from core.exceptions import ErrorCollector
from .. import models
from sqlalchemy.orm import Session
@@ -8,21 +9,80 @@ def invoice_exists(
invoice_number: str,
tenant_id: int,
company_id: int,
errors: ErrorCollector
) -> bool:
invoice_exists = (
db.query(models.InvoiceHeader.id)
errors: Optional[ErrorCollector],
):
invoice = (
db.query(models.InvoiceHeader)
.filter(
models.InvoiceHeader.invoice_number == invoice_number,
models.InvoiceHeader.tenant_id == tenant_id,
models.InvoiceHeader.company_id == company_id,
)
.first()
.first()
)
if invoice_exists:
errors.add_duplicate_error(
if invoice:
if errors:
errors.add_duplicate_error(
"invoice_number",
invoice_number,
f"Ya existe una factura con el número '{invoice_number}'",
)
)
return invoice
return None
def invoice_exists_by_id(
db: Session,
invoice_id: str,
tenant_id: int,
company_id: int,
errors: Optional[ErrorCollector],
):
invoice = (
db.query(models.InvoiceHeader)
.filter(
models.InvoiceHeader.id == invoice_id,
models.InvoiceHeader.tenant_id == tenant_id,
models.InvoiceHeader.company_id == company_id,
)
.first()
)
if invoice:
if errors:
errors.add_duplicate_error(
"invoice_id",
invoice_id,
f"Ya existe una factura con el número '{invoice_id}'",
)
return invoice
return None
def invoice_updated(
db: Session,
invoice_id: str,
tenant_id: int,
company_id: int,
errors: ErrorCollector,
) -> bool:
is_updated = (
db.query(models.InvoiceHeader.is_updated)
.filter(
models.InvoiceHeader.id == invoice_id,
models.InvoiceHeader.tenant_id == tenant_id,
models.InvoiceHeader.company_id == company_id,
)
.scalar()
)
if is_updated:
errors.add_error(
field="invoice_number",
message=f"La factura con el número '{invoice_id}' ya ha sido actualizada y no se puede modificar.",
solution="Capturar otro número de Factura de Importación Temporal o Desactualizar la factura.",
code="INVOICE_UPDATED",
value=invoice_id,
)
return True
return False

View File

@@ -7,7 +7,7 @@ from api.v1.modules.a76.clients_and_providers.models import ClientProvider
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
from api.v1.modules.public.reference_data.incoterms.models import Incoterm
from ....models import InvoiceComplianceMx
from api.v1.modules.a76.items.models import Item
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.public.reference_data.currency_types.models import CurrencyType
from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection
from ....models import TransportType, Currency, WeightUnit
@@ -435,11 +435,11 @@ def validate_common(
# Only check for existing items during update operations (when invoice has an id)
if hasattr(invoice, "id"):
has_items = (
db.query(Item)
db.query(LineItem)
.filter(
Item.invoice_id == invoice.id,
Item.tenant_id == tenant_id,
Item.company_id == company_id,
LineItem.invoice_id == invoice.id,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id,
)
.first()
)

View File

@@ -58,13 +58,13 @@ def validate_update(
# Columna A: Pedimento (si no viene en CSV, usar el existente)
if invoice_data.compliance_mx.pedimento_id:
invoice_data.compliance_mx.pedimento_id = clean_str(invoice_data.compliance_mx.pedimento_id)
invoice_data.compliance_mx.pedimento_id = invoice_data.compliance_mx.pedimento_id
else:
invoice_data.compliance_mx.pedimento_id = existing_invoice.compliance_mx.pedimento_id if existing_invoice.compliance_mx else None
# Columna B: Remesa
if invoice_data.compliance_mx.remesa:
invoice_data.compliance_mx.remesa = clean_str(invoice_data.compliance_mx.remesa)
invoice_data.compliance_mx.remesa = invoice_data.compliance_mx.remesa
else:
invoice_data.compliance_mx.remesa = existing_invoice.compliance_mx.remesa if existing_invoice.compliance_mx else None

View File

@@ -6,10 +6,36 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Path
from sqlalchemy.orm import Session
from . import schemas, services
from .catalog_service import InvoiceCatalogService
# Create main router
router = APIRouter()
@router.get("/invoices/creation-data", response_model=schemas.InvoiceCreationResponse)
def get_creation_data(
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Get consolidated data for creating a new invoice"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
return InvoiceCatalogService.get_creation_data(db, tenant_id, company_id)
@router.get("/invoices/{invoice_id}/edition-data", response_model=schemas.InvoiceEditionResponse)
def get_edition_data(
invoice_id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Get consolidated data for editing an existing invoice"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
data = InvoiceCatalogService.get_edition_data(db, invoice_id, tenant_id, company_id)
if not data:
raise HTTPException(status_code=404, detail="Invoice not found")
return data
# Create CRUD routes for Invoice Header using TenantCRUDRoutes
invoice_crud = TenantCRUDRoutes(
service=services.InvoiceService,

View File

@@ -568,4 +568,61 @@ class InvoiceHeaderListResponse(BaseModel):
items: List[InvoiceHeaderResponse]
total: int
page: int
page_size: int
# --- Consolidated Response Schemas ---
# Import necessary DTOs from other modules
from api.v1.modules.public.reference_data.invoice_types.dto import InvoiceTypeDTO
from api.v1.modules.a76.customs_brokers.dto import CustomsBrokerResponseDTO
from api.v1.modules.a76.clients_and_providers.dto import ClientProviderResponseDTO
from api.v1.modules.public.reference_data.currency_types.dto import CurrencyTypeDTO
from api.v1.modules.public.reference_data.transport_types.dto import TransportTypeDTO
from api.v1.modules.public.reference_data.customs_sections.dto import CustomsSectionDTO
from api.v1.modules.public.reference_data.incoterms.dto import IncotermDTO
from api.v1.modules.public.reference_data.transport_modes.dto import TransportModeDTO
from api.v1.modules.public.reference_data.code_pedimento_regimens.dto import CodePedimentoRegimenDTO
# TODO: Check if these paths are correct or need adjustment based on actual file locations
# Using Any for now for potentially complex or unverified paths to avoid immediate ImportErrors
# Detailed verification is needed for:
# - TransporterDTO
# - VehicleDTO
# - DriverDTO
# - TrailerDTO
# - SealResponseDTO
# - PedimentoDTO
class InvoiceCatalogsResponse(BaseModel):
"""Consolidated response for all catalogs needed in Invoice Create/Edit views"""
invoice_types: List[InvoiceTypeDTO] = []
customs_brokers: List[CustomsBrokerResponseDTO] = []
clients: List[ClientProviderResponseDTO] = []
providers: List[ClientProviderResponseDTO] = []
currency_types: List[CurrencyTypeDTO] = []
transport_types: List[TransportTypeDTO] = []
transporters: List[dict] = [] # Placeholder, refine with actual DTO
vehicles: List[dict] = [] # Placeholder, refine with actual DTO
drivers: List[dict] = [] # Placeholder, refine with actual DTO
trailers: List[dict] = [] # Placeholder, refine with actual DTO
customs_sections: List[CustomsSectionDTO] = []
code_pedimento_regimens: List[CodePedimentoRegimenDTO] = []
seals: List[dict] = [] # Placeholder, refine with actual DTO
incoterms: List[IncotermDTO] = []
pedimentos: List[dict] = [] # Placeholder, refine with actual DTO
transport_modes: List[TransportModeDTO] = []
default_settings: Optional[dict] = None
class InvoiceCreationResponse(InvoiceCatalogsResponse):
"""Response for Invoice Creation View"""
is_create: bool = True
invoice: Optional[dict] = None # Should be null for creation
invoice_id: Optional[int] = None
filters: Optional[dict] = None # Pre-filled filters if any
class InvoiceEditionResponse(InvoiceCatalogsResponse):
"""Response for Invoice Edition View"""
is_create: bool = False
invoice: InvoiceHeaderResponse
invoice_id: int
filters: Optional[dict] = None

View File

@@ -4,16 +4,14 @@ Items module - Annex 76 Compliance
# Import models in correct order to avoid circular dependencies
# LineItem must be imported before models that reference it
from .line_items.models import LineItem
from .line_financials.models import LineFinancial
from .line_quantities.models import LineQuantity
from .line_customs.models import LineCustom
from .line_descriptions.models import LineDescription
from .line_references.models import LineReference
from .models import Item, CTMReceipt, SubassemblyEntry
from .models import LineItem, CTMReceipt, SubassemblyEntry
__all__ = [
"Item",
"LineItem",
"LineFinancial",
"LineQuantity",

View File

@@ -0,0 +1,33 @@
from sqlalchemy import func
from core.exceptions import ErrorCollector
from .. import models
from sqlalchemy.orm import Session
def item_exists(db: Session, item_line: int, tenant_id: int, company_id: int):
item_exists = (
db.query(models.LineItem)
.filter(
models.LineItem.line_number == item_line,
models.LineItem.tenant_id == tenant_id,
models.LineItem.company_id == company_id,
)
.first()
)
return item_exists
def count_items(db: Session, invoice_id: int, tenant_id: int, company_id: int):
count = (
db.query(func.count())
.select_from(models.LineItem)
.filter(
models.LineItem.invoice_id == invoice_id,
models.LineItem.tenant_id == tenant_id,
models.LineItem.company_id == company_id,
)
.scalar()
)
return count

View File

@@ -0,0 +1,155 @@
import asyncio
from datetime import datetime
from typing import Optional, Tuple
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.general_catalogs.fractions.historical_tariff_fractions.models import HistoricalTariffFraction
from api.v1.modules.sitar.tlcs import TLCSService
from api.v1.modules.sitar.prosec import ProsecService
from api.v1.modules.sitar.fracciones import FraccionesService
from sqlalchemy.orm import Session
from sqlalchemy import exists
from core.exceptions import ErrorCollector
def search_historical_fraction(db: Session, fraction: str, country: str, fraction_type: str, sector: str, invoice_date: Optional[datetime], errors: Optional[ErrorCollector]) -> Tuple[Optional[str], float]:
"""Search for historical fraction data
This corresponds to BUSCA_FRACCION_HISTORICA in original Clarion code
Returns:
Tuple of (rate_im, adv_impo)
"""
# Placeholder for historical search
historical_exists = db.query(exists().where(HistoricalTariffFraction.historical_fraction == fraction)).scalar()
if not historical_exists:
errors.add_error(
field=f"fraction, country, fraction_type, sector",
message=f"La fraccion {fraction}, con pais {country}, con preferencia {fraction_type} y sector {sector} no existe.",
solution=["Revisar que si exista la preferencia para esta fracción en caso de ser historico, registrarlo en el catálogo de fracciones historicas."],
code="HISTORICAL_FRACTION_NOT_FOUND",
)
else:
historical_exists = db.query(HistoricalTariffFraction).filter(
HistoricalTariffFraction.historical_fraction == fraction,
HistoricalTariffFraction.country == country,
HistoricalTariffFraction.fraction_type == fraction_type,
HistoricalTariffFraction.sector == sector,
HistoricalTariffFraction.publication_date <= invoice_date,
).first()
return historical_exists.import_tax_rate, historical_exists.import_tax_rate
def search_fraction_preference(
db: Session,
country: str,
fraccion: str,
fraction_type: str,
sector: Optional[str] = None,
invoice_date: Optional[datetime] = None,
errors: Optional[ErrorCollector] = None
) -> Tuple[Optional[str], float]:
"""Search fraction preference and return rate_im and adv_impo
Args:
country: Country code
fraction_type: Type of fraction (e.g., 'TLCS', 'PROSEC')
company: Company object with configuration
fraccion: Tariff fraction code to search
sector: Sector code (required for PROSEC searches)
Returns:
Tuple of (rate_im, adv_impo) where:
- rate_im: Tax rate as string (e.g., "EXE", "5.0%")
- adv_impo: Numeric ad valorem rate
"""
rate_im = None
adv_impo = 0.0
fraccion_8 = fraccion[:8]
country_group = "USA" if country == "MEX" else country
"""" TLCS Search """
if fraction_type.upper() == "TLCS":
try:
# Get TLCS service instance
tlcs_service = TLCSService.get_instance()
# Fetch TLCS data using the service
tlcs_data = asyncio.run(
tlcs_service.search(fraccion=fraccion_8, pais=country_group, limit=100)
)
if not tlcs_data:
rate_im, adv_impo = search_historical_fraction(db=db, fraction=fraccion_8, country=country, fraction_type=fraction_type, sector=sector, invoice_date=invoice_date, errors=errors)
else:
first_record = tlcs_data[0]
rate_im = first_record.TASATXT
adv_impo = float(first_record.TASA1NUM or 0.0)
except Exception as e:
print(f"Error fetching Fracciones data from SITAR API: {e}")
""" ALADI Search """
if fraction_type.upper() == "ALADI":
rate_im, adv_impo = search_historical_fraction(db=db, fraction=fraccion_8, country=country, fraction_type=fraction_type, sector=sector, invoice_date=invoice_date, errors=errors)
""" PROSEC Search """
if fraction_type.upper() == "PROSEC":
try:
# Búsqueda en API de PROSEC (TARIFA_AS..sProsec)
prosec_service = ProsecService.get_instance()
# Buscar primero con ARTICULO = '4to'
prosec_data = asyncio.run(
prosec_service.search(
fraccion=fraccion_8, sector=sector, articulo="4to", limit=100
)
)
# Si no se encuentra con '4to', buscar con '5to'
if not prosec_data:
prosec_data = asyncio.run(
prosec_service.search(
fraccion=fraccion_8, sector=sector, articulo="5to", limit=100
)
)
if not prosec_data:
rate_im, adv_impo = search_historical_fraction(db=db, fraction=fraccion_8, country=country, fraction_type=fraction_type, sector=sector, invoice_date=invoice_date, errors=errors)
else:
first_record = prosec_data[0]
rate_im = first_record.TASATXT
adv_impo = float(first_record.TASANUM or 0.0)
except Exception as e:
print(f"Error fetching Fracciones data from SITAR API: {e}")
""" General search fallback """
if fraction_type.upper() == "GENERAL":
# Búsqueda en API de Fracciones (sFracciones)
try:
fracciones_service = FraccionesService.get_instance()
# Extraer fracción (primeros 8 caracteres) e nico (caracteres 9-10)
nico = fraccion[8:10] if len(fraccion) >= 10 else None
# Buscar por fracción e histórico
fracciones_data = asyncio.run(
fracciones_service.search(
fraccion=fraccion_8, nico=nico, limit=100
)
)
if not fracciones_data:
rate_im, adv_impo = search_historical_fraction(db=db, fraction=fraccion_8, country=country, fraction_type=fraction_type, sector=sector, invoice_date=invoice_date, errors=errors)
else:
# Se encontraron registros, tomar el primero
first_record = fracciones_data[0]
rate_im = first_record.ADVIMPOTXT # AdvImpoTxt
adv_impo = float(first_record.ADVIMPONUM or 0.0)
except Exception as e:
print(f"Error fetching Fracciones data from SITAR API: {e}")
return rate_im, adv_impo

View File

@@ -1,158 +1,335 @@
"""
Funciones helper compartidas para validaciones de items.
"""
from sqlalchemy import exists
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.common.common_validators import invoice_exists_by_id
from core.exceptions import ErrorCollector
from typing import Optional
from sqlalchemy import func
from ....common.fractions import search_fraction_preference
from ....common.common_validators import item_exists
from ....models import LineItem
from ....line_customs.models import FractionType, LineCustom
from api.v1.modules.a76.items.schemas import LineItemCreate
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
from api.v1.modules.a76.general_catalogs.packages.models import Package
from api.v1.modules.public.reference_data.countries.models import Country
from api.v1.modules.public.reference_data.sectors.models import Sector
from api.v1.modules.public.reference_data.valuation_methods.models import (
ValuationMethod,
)
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.general_catalogs.company.models import Company
def validate_catalog_reference(
def validate_common(
db: Session,
model_class,
id_value: Optional[int],
field_name: str,
line: LineItemCreate,
tenant_id: int,
company_id: Optional[int],
company_id: int,
errors: ErrorCollector,
error_message: str = None
) -> bool:
"""
Valida que una referencia a catálogo exista en la base de datos.
Args:
db: Sesión de base de datos
model_class: Clase del modelo SQLAlchemy a consultar
id_value: ID a validar
field_name: Nombre del campo para el error
tenant_id: ID del tenant
company_id: ID de la compañía (opcional)
errors: Colector de errores
error_message: Mensaje personalizado de error
Returns:
True si existe, False si no
"""
if not id_value:
return False
query = db.query(model_class).filter(
model_class.id == id_value,
model_class.tenant_id == tenant_id
line_number: int,
):
invoice: InvoiceHeader = invoice_exists_by_id(
db, line.invoice_id, tenant_id, company_id, errors
)
# Agregar filtro de company_id si el modelo lo tiene y se proporciona
if company_id and hasattr(model_class, 'company_id'):
query = query.filter(model_class.company_id == company_id)
exists = query.first() is not None
if not exists:
msg = error_message or f"El valor {id_value} no existe en el catálogo"
line_item: LineItem = item_exists(db, line.line_number, tenant_id, company_id)
fecha_factura = invoice.invoice_date if invoice else None
fraction = None
class_ = db.query(Class).filter(Class.id == line.class_id).first()
if not class_:
errors.add_error(
field=field_name,
message=msg,
solution="Selecciona un valor válido del catálogo",
code="NOT_FOUND"
field=f"line[{line_number}].class_id",
message="La clase especificada no existe.",
solution=["Darla de alta en el catalogo de clases."],
code="CLASS_NOT_FOUND",
)
return exists
def validate_positive_value(
value: Optional[float],
field_name: str,
errors: ErrorCollector,
required: bool = True,
allow_zero: bool = False
) -> bool:
"""
Valida que un valor numérico sea positivo.
Args:
value: Valor a validar
field_name: Nombre del campo para el error
errors: Colector de errores
required: Si el campo es obligatorio
allow_zero: Si se permite el valor cero
Returns:
True si es válido, False si no
"""
if value is None:
if required:
else:
if not line.unit_of_measure and not class_.unit_of_measure:
errors.add_error(
field=field_name,
message=f"El campo {field_name} es obligatorio",
solution="Proporciona un valor válido",
code="REQUIRED"
field=f"line[{line_number}].unit_of_measure",
message="La unidad de medida es obligatoria para la clase especificada.",
solution=["Proporciona una unidad de medida valida."],
code="UNIT_OF_MEASURE_REQUIRED",
)
return False
return True
if allow_zero and value == 0:
return True
if value <= 0:
if not line.customs.fraction:
if not line_item:
if not class_.fraction:
errors.add_error(
field=f"line[{line_number}].customs.fraction",
message="La fracción arancelaria es obligatoria para la clase especificada.",
solution=["Proporciona una fracción arancelaria valida."],
code="FRACTION_REQUIRED",
)
else:
fraction = class_.fraction
else:
if not line.customs.fraction:
if not class_.fraction:
errors.add_error(
field=f"line[{line_number}].customs.fraction",
message="La fracción arancelaria es obligatoria para la clase especificada.",
solution=["Proporciona una fracción arancelaria valida."],
code="FRACTION_REQUIRED",
)
else:
fraction = class_.fraction
else:
if line_item:
fraction = line.customs.fraction
if not line.description.description_spanish and not class_.description_es:
errors.add_error(
field=f"line[{line_number}].description.description_spanish",
message="La descripción en español es obligatoria para la clase especificada.",
solution=["Proporciona una descripción en español valida."],
code="DESCRIPTION_SPANISH_REQUIRED",
)
if not line.description.description_english and not class_.description_en:
errors.add_error(
field=f"line[{line_number}].description.description_english",
message="La descripción en inglés es obligatoria para la clase especificada.",
solution=["Proporciona una descripción en inglés valida."],
code="DESCRIPTION_ENGLISH_REQUIRED",
)
if line.quantity.quantity and line.quantity.quantity <= 0:
errors.add_error(
field=field_name,
message=f"El campo {field_name} debe ser mayor a cero",
solution="Proporciona un valor positivo",
code="INVALID_VALUE"
field=f"line[{line_number}].quantity.quantity",
message="La cantidad debe ser mayor a cero.",
solution=["Proporciona una cantidad valida."],
code="QUANTITY_MUST_BE_GREATER_THAN_ZERO",
)
return False
return True
def normalize_yes_no_value(value: Optional[str]) -> Optional[str]:
"""
Normaliza valores SI/NO a formato estándar.
Args:
value: Valor a normalizar (SI, NO, S, N)
Returns:
'SI' o 'NO', o None si el valor es None
"""
if not value:
return None
val = value.upper().strip()
if val in ['SI', 'S']:
return 'SI'
elif val in ['NO', 'N']:
return 'NO'
return value # Retornar original si no coincide
def validate_string_not_empty(
value: Optional[str],
field_name: str,
errors: ErrorCollector,
required: bool = True
) -> bool:
"""
Valida que un string no esté vacío.
Args:
value: Valor a validar
field_name: Nombre del campo para el error
errors: Colector de errores
required: Si el campo es obligatorio
Returns:
True si es válido, False si no
"""
if value is None or (isinstance(value, str) and not value.strip()):
if required:
errors.add_error(
field=field_name,
message=f"El campo {field_name} no puede estar vacío",
solution="Proporciona un valor válido",
code="REQUIRED"
if line.unit_of_measure:
um = (
db.query(func.count(UnitOfMeasure.id))
.filter(
UnitOfMeasure.id == line.unit_of_measure,
UnitOfMeasure.tenant_id == tenant_id,
UnitOfMeasure.company_id == company_id,
)
.scalar()
)
if um == 0:
errors.add_error(
field=f"line[{line_number}].unit_of_measure",
message="La unidad de medida especificada no existe.",
solution=["Proporciona una unidad de medida valida."],
code="UNIT_OF_MEASURE_NOT_FOUND",
)
if line.quantity.package_id:
package = (
db.query(func.count(Package.id))
.filter(
Package.id == line.quantity.package_id,
Package.tenant_id == tenant_id,
Package.company_id == company_id,
)
.scalar()
)
if package == 0:
errors.add_error(
field=f"line[{line_number}].quantity.package_id",
message="El paquete especificado no existe.",
solution=["Proporciona un paquete valido."],
code="PACKAGE_NOT_FOUND",
)
if not line.quantity.package_quantity:
errors.add_error(
field=f"line[{line_number}].quantity.package_quantity",
message="La cantidad de paquetes es obligatoria cuando se proporciona el paquete.",
solution=["Proporciona una cantidad de paquetes valida."],
code="PACKAGE_QUANTITY_REQUIRED",
)
if line.quantity.package_quantity <= 0:
errors.add_error(
field=f"line[{line_number}].quantity.package_quantity",
message="La cantidad de paquetes debe ser mayor a cero.",
solution=["Proporciona una cantidad de paquetes valida."],
code="PACKAGE_QUANTITY_MUST_BE_GREATER_THAN_ZERO",
)
else:
if line.quantity.package_quantity and (line.quantity.package_quantity > 0 and not line.quantity.package_id):
errors.add_error(
field=f"line[{line_number}].quantity.package_id",
message="El paquete es obligatorio cuando se proporciona la cantidad de paquetes.",
solution=["Proporciona un paquete valido."],
code="PACKAGE_ID_REQUIRED",
)
country = None
fraction_type = None
sector = None
if fraction:
fraction = line.customs.fraction if line.customs.fraction else fraction
country = line.customs.origin_country
if line_item and line_item.customs:
country = (
line_item.customs.origin_country
if line_item.customs.origin_country
else country
)
fraction_type = line.customs.fraction_type.upper()
if line_item and line_item.customs:
fraction_type = (
line_item.customs.fraction_type
if line_item.customs.fraction_type
else fraction_type
)
sector = line.customs.sector
if line_item and line_item.customs:
sector = line_item.customs.sector if line_item.customs.sector else sector
country_m3 = db.query(Country.m3_key).filter(Country.m3_key == country).scalar()
if not country_m3:
country_m3 = (
db.query(Country.m3_key).filter(Country.ame_key == country).scalar()
)
country = country_m3
if not country:
errors.add_error(
field=f"line[{line_number}].customs.origin_country",
message="El país de origen especificado no existe.",
solution=["Proporciona un país de origen valido."],
code="ORIGIN_COUNTRY_NOT_FOUND",
)
else:
if fraction_type.strip().upper() not in vars(FractionType).values():
valid_types = [
v
for k, v in vars(FractionType).items()
if not k.startswith("_") and isinstance(v, str)
]
errors.add_error(
field=f"line[{line_number}].customs.fraction_type",
message="El tipo de fracción especificado no es válido.",
solution=[
f"Proporciona un tipo de fracción válido. Valores permitidos: {', '.join(valid_types)}"
],
code="FRACTION_TYPE_INVALID",
value=fraction_type,
)
else:
if fraction_type.strip().upper() == FractionType.PROSEC and not sector:
errors.add_error(
field=f"line[{line_number}].customs.sector",
message="El sector es obligatorio cuando el tipo de fracción es 'PROSEC'.",
solution=["Proporciona un sector valido."],
code="SECTOR_REQUIRED_FOR_PROSEC",
)
elif fraction_type.strip().upper() != FractionType.PROSEC and sector:
errors.add_error(
field=f"line[{line_number}].customs.sector",
message="El sector solo es aplicable cuando el tipo de fracción es 'PROSEC'.",
solution=[
"Elimina el sector o cambia el tipo de fracción a 'PROSEC'."
],
code="SECTOR_ONLY_FOR_PROSEC",
)
elif fraction_type.strip().upper() == FractionType.PROSEC and sector:
sector_db: Sector = (
db.query(Sector).filter(Sector.key == sector).scalar()
)
if sector_db:
errors.add_error(
field=f"line[{line_number}].customs.sector",
message="El sector especificado no existe.",
solution=["Proporciona un sector valido."],
code="SECTOR_NOT_FOUND",
)
else:
if not sector_db.authorized:
errors.add_error(
field=f"line[{line_number}].customs.sector",
message="El sector especificado no está autorizado.",
solution=["Proporciona un sector autorizado."],
code="SECTOR_NOT_AUTHORIZED",
)
company_db = db.query(Company).filter(Company.id == company_id).first()
if not company_db.prosec:
errors.add_error(
field=f"line[{line_number}].customs.sector",
message=" La empresa no cuenta con autorización PROSEC.",
solution=[
"Accese a los datos de la empresa y selecione la opción Pertenece al Programa de Promoción Sectorial y capture el número de permiso PROSEC."
],
code="COMPANY_NOT_AUTHORIZED_FOR_PROSEC",
)
if fraction:
search_fraction_preference(
db=db,
country=country,
fraccion=fraction,
fraction_type=fraction_type,
sector=sector,
invoice_date=fecha_factura,
errors=errors,
)
if line.customs.american_fraction:
american_fraction_exists = db.query(
exists().where(
LineCustom.american_fraction == line.customs.american_fraction
)
).scalar()
if not american_fraction_exists:
errors.add_error(
field=f"line[{line_number}].customs.american_fraction",
message="La fracción americana especificada no existe.",
solution=["Proporciona una fracción americana valida."],
code="AMERICAN_FRACTION_NOT_FOUND",
)
if line.order:
if len(line.order) > 20:
errors.add_error(
field=f"item.order",
message="El campo orden no debe exceder los 20 caracteres.",
solution=["Proporciona un valor valido para el campo orden."],
code="ORDER_EXCEEDS_MAX_LENGTH",
)
unit_of_measure = line.unit_of_measure or (
class_.unit_of_measure if class_ else None
)
if unit_of_measure == "PZA" and line.quantity.quantity % 1 != 0:
errors.add_error(
field=f"line[{line_number}].quantity.quantity",
message="La cantidad debe ser un número entero cuando la unidad de medida es PZA.",
solution=["Proporciona una cantidad entera."],
code="QUANTITY_MUST_BE_INTEGER_FOR_PIECES",
)
if line.valuation_method:
valuation_method_exists = db.query(
exists().where(ValuationMethod.key == line.valuation_method)
).scalar()
if not valuation_method_exists:
errors.add_error(
field=f"line[{line_number}].valuation_method",
message="El método de valoración especificado no existe.",
solution=["Proporciona un método de valoración valido."],
code="VALUATION_METHOD_NOT_FOUND",
)
if line.part_number_id:
part_exists = db.query(exists().where(Part.id == line.part_number_id)).scalar()
if not part_exists:
errors.add_error(
field=f"line[{line_number}].part_number_id",
message="El número de parte especificado no existe.",
solution=["Proporciona un número de parte valido."],
code="PART_NUMBER_NOT_FOUND",
)
return False
return True
return True

View File

@@ -1,135 +1,344 @@
"""
Validaciones para creación de items vía API.
"""
from decimal import Decimal
from sqlalchemy import func, exists
from sqlalchemy.orm import Session
from ....common.common_validators import count_items
from core.exceptions import ErrorCollector
from api.v1.modules.a76.items.line_items.schemas import LineItemCreate
from .common import validate_string_not_empty, validate_positive_value
from ....models import LineItem
from ....line_financials.models import LineFinancial
from ....line_financials.schemas import LineFinancialCreate
from ....line_quantities.models import LineQuantity
from ....line_quantities.schemas import LineQuantityCreate
from ....line_customs.models import LineCustom
from ....line_customs.schemas import LineCustomCreate
from ....line_descriptions.models import LineDescription
from ....line_descriptions.schemas import LineDescriptionCreate
from ....line_references.models import LineReference
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
from ....models import LineItem
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.packages.models import Package
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
USTariffFraction,
)
from .common import validate_common
def validate_create(
db: Session,
line: LineItemCreate,
line: LineItem, # LineItemCreate schema (Pydantic)
tenant_id: int,
company_id: int,
errors: ErrorCollector,
) -> None:
line_number: int,
):
"""
Validaciones para crear LineItems vía API (actualmente en uso).
Validates and calculates fields for a new line item before DB creation.
Works with Pydantic schemas, modifying them in-place.
Args:
db: Sesión de base de datos
line: Datos del line item
tenant_id: ID del tenant
company_id: ID de la compañía
errors: Colector de errores
"""
# 1. Validar line_number
if not line.line_number:
errors.add_error(
field="line_number",
message="El número de línea es obligatorio",
solution="Proporciona un número de línea válido",
code="REQUIRED",
)
line: LineItemCreate schema with nested data (financial, quantity, customs, etc.)
invoice_id: ID of the invoice this line belongs to
fa_data: FaLineItemCreateDTO or None (None for INV system)
"""
# 2. Validar class_id
# Access fa_data safely
fa_data = getattr(line, "fa_data", None)
# Required field validations
if not line.class_id:
errors.add_error(
field="class_id",
message="Clase (ID) es obligatorio",
solution="Selecciona una clasificación válida del catálogo",
code="REQUIRED",
)
errors.add_required_error(field=f"line[{line_number}].class_id")
# 4. Validar unit_of_measure
if not line.unit_of_measure:
errors.add_error(
field="unit_of_measure",
message="U.M. es obligatorio",
solution="Proporciona una unidad de medida válida",
code="REQUIRED",
)
if not line.quantity.quantity or line.quantity.quantity <= 0:
errors.add_required_error(field=f"line[{line_number}].quantity.quantity")
# 5. Validar quantity.quantity
if not line.quantity:
errors.add_error(
field="quantity",
message="Quantity es obligatorio",
solution="Proporciona una cantidad válida",
code="REQUIRED",
)
else:
# Validar con nombre amigable
if line.quantity.quantity is None or line.quantity.quantity <= 0:
errors.add_error(
field="quantity.quantity",
message="Quantity debe ser mayor a cero",
solution="Proporciona una cantidad válida",
code=(
"INVALID_VALUE"
if line.quantity.quantity is not None
else "REQUIRED"
),
)
# 6. Validar financial.unit_cost
if not line.financial:
errors.add_error(
field="financial",
message="Unit Cost es obligatorio",
solution="Proporciona el costo unitario del item",
code="REQUIRED",
)
else:
has_cost = (
line.financial.unit_cost_usd
or line.financial.unit_cost_mxn
or line.financial.unit_cost_capture
)
if not has_cost:
errors.add_error(
field="financial.unit_cost",
message="Unit Cost es obligatorio",
solution="Proporciona al menos un costo unitario (USD, MXN o captura)",
code="REQUIRED",
)
# 7. Validar description.description_spanish
if line.description:
# TODO: Añadir validacion SSisGen:CalcularCostoUnitarioEnBaseAValorTotalScaf <-- de la tabla de preferencias de el sistema
# if SSisGen:CalcularCostoUnitarioEnBaseAValorTotalScaf == False:
if fa_data and not fa_data.is_subitem:
if (
not line.description.description_spanish
or not line.description.description_spanish.strip()
not line.financial.unit_cost_capture
or line.financial.unit_cost_capture <= 0
):
errors.add_required_error(
field=f"line[{line_number}].financial.unit_cost_capture"
)
if not line.quantity.net_weight or line.quantity.net_weight <= 0:
errors.add_required_error(field=f"line[{line_number}].quantity.net_weight")
if not line.customs.origin_country:
errors.add_required_error(field=f"line[{line_number}].customs.origin_country")
if not line.customs.fraction_type:
errors.add_required_error(field=f"line[{line_number}].customs.fraction_type")
# FA-specific validations
if fa_data:
if (
fa_data.is_subitem and fa_data.contains_subitems
) and not fa_data.subitem_number:
errors.add_required_error(
field=f"line[{line_number}].fa_data.subitem_number"
)
# Validar que si es un subitem, existe un item principal correspondiente
if (
fa_data.is_subitem
and fa_data.subitem_number
and fa_data.subitem_number != 0
):
principal_item_exists = db.query(
exists().where(
(LineItem.id == FaLineItem.id)
& (LineItem.id == LineItem.id)
& (LineItem.invoice_id == line.invoice_id)
& (LineItem.line_number == line_number)
& (FaLineItem.is_subitem == False)
& (FaLineItem.contains_subitems == True)
& (LineItem.tenant_id == tenant_id)
& (LineItem.company_id == company_id)
)
).scalar()
if not principal_item_exists:
errors.add_error(
field=f"line[{line_number}]",
message=f"No existe un item principal registrado para esta linea {line_number} con subitem {fa_data.subitem_number}",
solution=[
"Registrar el item principal correspondiente a esta linea antes de registrar subitems."
],
code="SUBITEM_WITHOUT_PRINCIPAL_ITEM",
)
if fa_data.is_subitem and (
fa_data.subitem_number == 0 or not fa_data.subitem_number
):
errors.add_error(
field="description.description_spanish",
message="Description in Spanish es obligatorio",
solution="Proporciona una descripción del item en español",
code="REQUIRED",
field=f"line[{line_number}]",
message=f"El número de subitem no puede ser 0 si la línea es un subitem.",
solution=["Asignar un número de subitem mayor a 0 para esta línea."],
code="SUBITEM_NUMBER_INVALID",
)
else:
errors.add_error(
field="description.description_spanish",
message="Description in Spanish es obligatorio",
solution="Proporciona una descripción del item en español",
code="REQUIRED",
validate_common(db, line, tenant_id, company_id, errors, line_number)
if not errors.has_errors():
# Obtener la factura para acceder a tipo de cambio, moneda y peso
invoice: InvoiceHeader = (
db.query(InvoiceHeader)
.filter(
InvoiceHeader.id == line.invoice_id,
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id,
)
.first()
)
# 8. Validar customs.origin_country
if not line.customs or not line.customs.origin_country:
errors.add_error(
field="customs.origin_country",
message="País de Origen es obligatorio",
solution="Selecciona el país de origen del item",
code="REQUIRED"
if not invoice or not invoice.financials or not invoice.logistics:
errors.add_error(
field=f"line[{line_number}]",
message="No se pudo obtener información de la factura",
solution=[
"Verificar que la factura existe y tiene datos financieros y logísticos"
],
code="INVOICE_DATA_MISSING",
)
return
# Obtener la clase para valores por defecto
class_info: Class = (
db.query(Class)
.filter(
Class.id == line.class_id,
Class.tenant_id == tenant_id,
Class.company_id == company_id,
)
.first()
)
# 9. Validar customs.fraction_type
if not line.customs or not line.customs.fraction_type:
errors.add_error(
field="customs.fraction_type",
message="Tipo de Tarifa es obligatorio",
solution="Selecciona el tipo de tarifa (GENERAL, PROSEC, ALADI, TLCS)",
code="REQUIRED"
)
# ==========================================
# ASIGNAR TIPO DE CAMBIO
# ==========================================
exchange_rate = invoice.financials.exchange_rate or Decimal("1.0")
# ==========================================
# ASIGNAR UNIDAD DE MEDIDA
# ==========================================
# Si no se proporcionó unidad de medida, usar la de la clase
if not line.unit_of_measure and class_info:
line.unit_of_measure = class_info.unit_of_measure
# ==========================================
# ASIGNAR TIPOS DE MONEDA Y CALCULAR COSTOS
# ==========================================
currency_type = invoice.financials.currency_type
unit_cost_capture = line.financial.unit_cost_capture or Decimal("0")
# Calcular costos según tipo de moneda
if currency_type == "USD" or currency_type == "ME": # Moneda Extranjera (ME)
line.financial.unit_cost_capture = unit_cost_capture
line.financial.unit_cost_usd = unit_cost_capture
line.financial.unit_cost_mxn = unit_cost_capture * exchange_rate
elif currency_type == "MXN" or currency_type == "MN": # Moneda Nacional (MN)
line.financial.unit_cost_capture = unit_cost_capture
line.financial.unit_cost_usd = (
unit_cost_capture / exchange_rate if exchange_rate else Decimal("0")
)
line.financial.unit_cost_mxn = unit_cost_capture
# Si es otro tipo de moneda, dejamos el costo como está
# ==========================================
# VALIDAR Y CONVERTIR PESOS NETOS
# ==========================================
invoice_weight_type = invoice.logistics.weight_type # 'kgs' o 'lbs'
quantity = line.quantity.quantity or Decimal("0")
net_weight_input = line.quantity.net_weight or Decimal("0")
# Determinar si la unidad de medida es de peso
unit_is_kgs = line.unit_of_measure and line.unit_of_measure == "24" #KGS
unit_is_lbs = line.unit_of_measure and line.unit_of_measure == "25" #LBS
# Calcular peso neto en kilogramos (estándar interno)
if unit_is_kgs:
if invoice_weight_type == "KGS":
line.quantity.net_weight = quantity
else: # invoice en libras
line.quantity.net_weight = quantity * Decimal("2.204624")
elif unit_is_lbs:
if invoice_weight_type == "KGS":
line.quantity.net_weight = quantity / Decimal("2.204624")
else: # invoice en libras
line.quantity.net_weight = quantity
else:
# Otra unidad de medida - usar peso capturado y convertir si es necesario
if invoice_weight_type == "KGS":
# El peso capturado está en kilos
line.quantity.net_weight = net_weight_input
else:
# El peso capturado está en libras, convertir a kilos
line.quantity.net_weight = net_weight_input / Decimal("2.204624")
# ==========================================
# CALCULAR PESO BRUTO
# ==========================================
gross_weight_input = line.quantity.gross_weight
package_quantity = line.quantity.package_quantity or 0
package_weight_unit = Decimal("0")
# Obtener peso unitario del bulto si existe
if line.quantity.package_id:
package: Package = (
db.query(Package)
.filter(
Package.id == line.quantity.package_id,
Package.tenant_id == tenant_id,
Package.company_id == company_id,
)
.first()
)
if package and package.weight_unit:
package_weight_unit = package.weight_unit
# Si no se proporcionó peso bruto, calcularlo
if not gross_weight_input or gross_weight_input == 0:
if invoice_weight_type == "KGS":
line.quantity.gross_weight = line.quantity.net_weight + (
package_weight_unit * package_quantity
)
else: # libras
line.quantity.gross_weight = line.quantity.net_weight + (
(package_weight_unit * Decimal("2.204624")) * package_quantity
)
else:
# Convertir peso bruto capturado según tipo de factura
if invoice_weight_type == "KGS":
line.quantity.gross_weight = gross_weight_input
else: # libras
line.quantity.gross_weight = gross_weight_input / Decimal("2.204624")
# ==========================================
# VALIDAR PESO BRUTO < PESO NETO
# ==========================================
if line.quantity.gross_weight < line.quantity.net_weight:
line.quantity.gross_weight = line.quantity.net_weight + (
package_weight_unit * package_quantity
)
# ==========================================
# ASIGNAR DESCRIPCIÓN DE BULTOS
# ==========================================
if package_quantity and package_quantity > 0 and line.quantity.package_id:
package: Package = (
db.query(Package)
.filter(
Package.id == line.quantity.package_id,
Package.tenant_id == tenant_id,
Package.company_id == company_id,
)
.first()
)
if package:
line.description.package_description = package.description_es
else:
line.quantity.package_quantity = 0
line.quantity.package_id = None
line.description.package_description = None
# ==========================================
# ASIGNAR FRACCIÓN AMERICANA POR DEFECTO
# ==========================================
if not line.customs.american_fraction and class_info and class_info.us_fraction:
line.customs.american_fraction = class_info.us_fraction
# Buscar el advalorem de la fracción americana
if line.customs.american_fraction:
us_fraction: USTariffFraction = (
db.query(USTariffFraction)
.filter(
USTariffFraction.code == line.customs.american_fraction,
USTariffFraction.tenant_id == tenant_id,
USTariffFraction.company_id == company_id,
)
.first()
)
if us_fraction:
# Si el tipo es 'ME' (Moneda Extranjera), usar costo fijo
# De lo contrario, usar ad valorem
if us_fraction.type_code == "foreign":
line.customs.advalorem_american = us_fraction.fixed_cost
else:
line.customs.advalorem_american = us_fraction.ad_valorem
# ==========================================
# ASIGNAR DESCRIPCIONES POR DEFECTO
# ==========================================
if not line.description.description_spanish and class_info:
line.description.description_spanish = class_info.description_es
if not line.description.description_english and class_info:
line.description.description_english = class_info.description_en
# ==========================================
# NORMALIZAR CAMPOS DE TEXTO
# ==========================================
# Convertir a mayúsculas campos que lo requieran
if line.description.brand:
line.description.brand = line.description.brand.upper().strip()
if line.description.model:
line.description.model = line.description.model.upper().strip()
# ==========================================
# ASIGNAR VALORES POR DEFECTO DE IMPUESTOS
# ==========================================
# Si no se especificó pago de impuesto, tomar de preferencias del sistema (SisImp)
# TODO: Implementar lectura de preferencias del sistema
# Por ahora dejamos None si no se proporcionó
# Si no se especificó forma de pago, tomar de preferencias del sistema
# TODO: Implementar lectura de preferencias del sistema
# Si no se especificó método de valoración, tomar de preferencias del sistema
# TODO: Implementar lectura de preferencias del sistema

View File

@@ -1,246 +1,202 @@
"""
Validaciones para actualización de items vía API.
"""
from decimal import Decimal
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.common.common_validators import invoice_exists
from core.exceptions import ErrorCollector
from api.v1.modules.a76.items.line_items.schemas import LineItemUpdate
from .common import validate_string_not_empty, validate_positive_value
from ....models import LineItem
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
USTariffFraction,
)
from .common import validate_common
def validate_update(
db: Session,
line: LineItemUpdate,
line: LineItem,
existing_line: LineItem,
tenant_id: int,
company_id: int,
errors: ErrorCollector,
invoice_id: int = None,
) -> None:
line_number: int,
):
"""
Validaciones para actualizar LineItems vía API.
Incluye todas las validaciones de negocio de Clarion.
Args:
db: Sesión de base de datos
line: Datos del line item a actualizar
tenant_id: ID del tenant
company_id: ID de la compañía
errors: Colector de errores
invoice_id: ID de la factura asociada (opcional, para validar subpartidas)
Validar y procesar actualización parcial de línea de importación temporal.
Si un campo no se proporciona, se mantiene el valor existente.
"""
# 1. Validar line_number si se proporciona
if line.line_number is not None and not line.line_number:
errors.add_error(
field="line_number",
message="El número de línea no puede estar vacío",
solution="Proporciona un número de línea válido",
code="REQUIRED",
validate_common(db, line, tenant_id, company_id, errors, line_number)
if not errors.has_errors():
# Obtener la factura para acceder a tipo de cambio, moneda y peso
invoice: InvoiceHeader = (
db.query(InvoiceHeader)
.filter(
InvoiceHeader.id == line.invoice_id,
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id
)
.first()
)
# 2. Validar class_id si se proporciona
if line.class_id is not None and not line.class_id:
errors.add_error(
field="class_id",
message="Clase (ID) no puede estar vacío",
solution="Selecciona una clasificación válida del catálogo",
code="REQUIRED",
)
if not invoice or not invoice.financials or not invoice.logistics:
errors.add_error(
field=f"line[{line_number}]",
message="No se pudo obtener información de la factura",
solution=[
"Verificar que la factura existe y tiene datos financieros y logísticos"
],
code="INVOICE_DATA_MISSING",
)
return
# 4. Validar unit_of_measure si se proporciona
if line.unit_of_measure is not None and not line.unit_of_measure:
errors.add_error(
field="unit_of_measure",
message="U.M. no puede estar vacío",
solution="Proporciona una unidad de medida válida",
code="REQUIRED",
)
# ==========================================
# ACTUALIZACIÓN PARCIAL DE CAMPOS
# Si no se proporciona, mantener valor existente
# ==========================================
# 5. Validar cantidad si se proporciona
if line.quantity:
# Si se proporciona el objeto quantity, validar que quantity.quantity sea válido
if line.quantity.quantity is not None:
if line.quantity.quantity <= 0:
errors.add_error(
field="quantity.quantity",
message="Quantity debe ser mayor a cero",
solution="Proporciona una cantidad válida",
code="INVALID_VALUE",
)
# Tipo de cambio de la factura
exchange_rate = invoice.financials.exchange_rate or Decimal("1.0")
# Unidad de medida
if not line.unit_of_measure:
line.unit_of_measure = existing_line.unit_of_measure
# Costo unitario
if line.financial.unit_cost_capture is None:
line.financial.unit_cost_capture = existing_line.financial.unit_cost_capture
# Convertir peso neto si se proporcionó
invoice_weight_type = invoice.logistics.weight_type
if line.quantity.net_weight is not None:
# Se proporcionó nuevo peso neto, convertir según tipo
net_weight_input = line.quantity.net_weight
if invoice_weight_type == "KGS":
line.quantity.net_weight = net_weight_input
else: # libras, convertir a kilos
line.quantity.net_weight = net_weight_input / Decimal("2.204624")
else:
# Si se proporciona quantity pero quantity.quantity es None, es requerido
errors.add_error(
field="quantity.quantity",
message="Quantity es obligatorio",
solution="Proporciona una cantidad mayor a 0",
code="REQUIRED",
)
# Mantener peso existente
line.quantity.net_weight = existing_line.quantity.net_weight
# 6. Validar peso neto si se proporciona
if line.quantity and line.quantity.net_weight is not None:
if line.quantity.net_weight <= 0:
errors.add_error(
field="quantity.net_weight",
message="Net Weight debe ser mayor a cero",
solution="Proporciona un peso neto válido",
code="INVALID_VALUE",
)
print(f"After weight conversion: net_weight={line.quantity.net_weight}, gross_weight={line.quantity.gross_weight}, weight_type={invoice_weight_type}")
# 7. Validar costo unitario si se proporciona financial (excepto subpartidas)
if line.financial:
is_subitem = line.fa_data and line.fa_data.is_subitem if line.fa_data else False
# Convertir peso bruto si se proporcionó
if line.quantity.gross_weight is not None:
gross_weight_input = line.quantity.gross_weight
if not is_subitem:
has_cost = (
line.financial.unit_cost_usd
or line.financial.unit_cost_mxn
or line.financial.unit_cost_capture
)
if not has_cost:
errors.add_error(
field="financial.unit_cost",
message="Unit Cost es obligatorio",
solution="Proporciona al menos un costo unitario (USD, MXN o captura)",
code="REQUIRED",
)
# Validar que sean positivos
if line.financial.unit_cost_usd is not None:
if line.financial.unit_cost_usd <= 0:
errors.add_error(
field="financial.unit_cost_usd",
message="Unit Cost (USD) debe ser mayor a cero",
solution="Proporciona un costo unitario válido",
code="INVALID_VALUE",
)
if line.financial.unit_cost_mxn is not None:
if line.financial.unit_cost_mxn <= 0:
errors.add_error(
field="financial.unit_cost_mxn",
message="Unit Cost (MXN) debe ser mayor a cero",
solution="Proporciona un costo unitario válido",
code="INVALID_VALUE",
)
if line.financial.unit_cost_capture is not None:
if line.financial.unit_cost_capture <= 0:
errors.add_error(
field="financial.unit_cost_capture",
message="Unit Cost (Captura) debe ser mayor a cero",
solution="Proporciona un costo unitario válido",
code="INVALID_VALUE",
)
if invoice_weight_type == "KGS":
line.quantity.gross_weight = gross_weight_input
else: # libras, convertir a kilos
line.quantity.gross_weight = gross_weight_input / Decimal("2.204624")
else:
# Mantener peso existente
line.quantity.gross_weight = existing_line.quantity.gross_weight
# 8. Validar datos aduanales si se proporcionan
if line.customs:
# Validar país de origen (OBLIGATORIO)
if line.customs.origin_country is not None:
if not line.customs.origin_country:
errors.add_error(
field="customs.origin_country",
message="País de Origen es obligatorio",
solution="Selecciona el país de origen del item",
code="REQUIRED"
)
# Validar tipo de tarifa (OBLIGATORIO)
if line.customs.fraction_type is not None:
if not line.customs.fraction_type:
errors.add_error(
field="customs.fraction_type",
message="Tipo de Tarifa es obligatorio",
solution="Selecciona el tipo de tarifa (GENERAL, PROSEC, ALADI, TLCS)",
code="REQUIRED"
)
# Validar preferencia arancelaria
if line.customs.preference is not None and not line.customs.preference:
errors.add_error(
field="customs.preference",
message="La preferencia arancelaria no puede estar vacía",
solution="Selecciona la preferencia arancelaria",
code="REQUIRED",
)
# Cantidad de bultos
if line.quantity.package_quantity is None:
line.quantity.package_quantity = existing_line.quantity.package_quantity
# Validar formato de pago de impuestos
if line.customs.tax_paid:
val_tax = line.customs.tax_paid.upper()
if val_tax not in ["SI", "NO", "S", "N"]:
errors.add_error(
field="customs.tax_paid",
message="El valor de pago de impuesto debe ser SI/NO o S/N",
solution="Proporciona un valor válido: SI, NO, S o N",
code="INVALID_VALUE",
)
# Clave de bultos
if not line.quantity.package_id:
line.quantity.package_id = existing_line.quantity.package_id
# Validar forma de pago si existe
if line.customs.payment_form:
from api.v1.modules.a76.general_catalogs.forms_of_payment.models import (
PaymentForm,
)
# País de origen
if not line.customs.origin_country:
line.customs.origin_country = existing_line.customs.origin_country
payment = (
db.query(PaymentForm)
# Fracción arancelaria
if not line.customs.fraction:
line.customs.fraction = existing_line.customs.fraction
# Tipo de fracción
if not line.customs.fraction_type:
line.customs.fraction_type = existing_line.customs.fraction_type
# Sector
if not line.customs.sector:
line.customs.sector = existing_line.customs.sector
# Fracción americana y su advalorem
if line.customs.american_fraction:
# Se proporcionó nueva fracción americana, buscar su advalorem
us_fraction: USTariffFraction = (
db.query(USTariffFraction)
.filter(
PaymentForm.code == line.customs.payment_form,
PaymentForm.tenant_id == tenant_id,
USTariffFraction.code == line.customs.american_fraction,
USTariffFraction.tenant_id == tenant_id,
USTariffFraction.company_id == company_id,
)
.first()
)
if not payment:
errors.add_error(
field="customs.payment_form",
message=f"La forma de pago '{line.customs.payment_form}' no es válida",
solution="Selecciona una forma de pago válida del catálogo",
code="INVALID_VALUE",
)
# 9. Validar descripción en español (OBLIGATORIA)
if line.description and hasattr(line.description, 'description_spanish'):
if line.description.description_spanish is not None:
if not line.description.description_spanish.strip():
errors.add_error(
field="description.description_spanish",
message="Descripción en Español es obligatoria",
solution="Proporciona una descripción del item en español",
code="REQUIRED"
)
# 10. Validar subpartidas si se actualizan
if line.fa_data and line.fa_data.is_subitem:
# Es subpartida, debe tener partida principal
if not line.fa_data.main_line_id:
errors.add_error(
field="fa_data.main_line_id",
message="La subpartida debe tener asignada una partida principal",
solution="Selecciona la partida principal de esta subpartida",
code="REQUIRED",
)
elif invoice_id:
# Validar que la partida principal exista en la misma factura
from api.v1.modules.a76.items.line_items.models import LineItem
from api.v1.modules.a76.items.models import Item
if us_fraction:
if us_fraction.type_code == "ME":
line.customs.advalorem_american = us_fraction.fixed_cost
else:
line.customs.advalorem_american = us_fraction.ad_valorem
else:
# Mantener fracción americana existente
line.customs.american_fraction = existing_line.customs.american_fraction
line.customs.advalorem_american = existing_line.customs.advalorem_american
parent = (
db.query(LineItem)
.join(LineItem.item)
.filter(
LineItem.line_number == line.fa_data.main_line_id,
Item.invoice_id == invoice_id,
LineItem.company_id == company_id,
)
.first()
# Orden de compra
if not line.order:
line.order = existing_line.order
# Descripciones
if not line.description.description_spanish:
line.description.description_spanish = (
existing_line.description.description_spanish
)
if not parent:
errors.add_error(
field="fa_data.main_line_id",
message=f"La partida principal {line.fa_data.main_line_id} no existe en esta factura",
solution="Verifica el número de la partida principal",
code="NOT_FOUND",
)
elif parent.fa_data and parent.fa_data.is_subitem:
errors.add_error(
field="fa_data.main_line_id",
message="La partida principal no puede ser otra subpartida",
solution="Selecciona una partida normal como principal",
code="INVALID_VALUE",
)
if not line.description.description_english:
line.description.description_english = (
existing_line.description.description_english
)
if not line.description.extra_description:
line.description.extra_description = (
existing_line.description.extra_description
)
# Marca y modelo
if line.description.brand:
line.description.brand = line.description.brand.upper().strip()
else:
line.description.brand = existing_line.description.brand
if line.description.model:
line.description.model = line.description.model.upper().strip()
else:
line.description.model = existing_line.description.model
# Subpartidas (si aplica)
# TODO: Implementar lógica de subpartidas si Loc:LevantarSubpartidas = 'S'
# Número de parte
if not line.part_number_id:
line.part_number_id = existing_line.part_number_id
# Pago de impuesto
if line.tax_payment is None:
line.tax_payment = existing_line.tax_payment
# Forma de pago
if not line.payment_method:
line.payment_method = existing_line.payment_method
# Método de valoración
if not line.valuation_method:
if existing_line.valuation_method:
line.valuation_method = existing_line.valuation_method
# else: TODO: Tomar de SisImp:MetValor (preferencias del sistema)
# Número de entrada
if not line.description.entry_number:
line.description.entry_number = existing_line.description.entry_number
# Lote
if not line.description.lot:
line.description.lot = existing_line.description.lot

View File

@@ -5,7 +5,14 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.database import Base
if TYPE_CHECKING:
from ..line_items.models import LineItem
from ..models import LineItem
class FractionType:
"""Enumeration for fraction types"""
GENERAL = "GENERAL"
PROSEC = "PROSEC"
ALADI = "ALADI"
TLCS = "TLCS"
class LineCustom(Base):
"""
@@ -22,7 +29,7 @@ class LineCustom(Base):
# Tariff/Customs Classifications
fraction: Mapped[Optional[str]] = mapped_column(String(10)) # FRACCION / FRACCIONIMPO / FRACCIONEXPO
fraction_type: Mapped[Optional[str]] = mapped_column(String(7)) # TIPOFRACCION / TIPOFRACCIONIMPO / TIPOFRACCIONEXPO
fraction_type: Mapped[Optional[FractionType]] = mapped_column(String(7)) # TIPOFRACCION / TIPOFRACCIONIMPO / TIPOFRACCIONEXPO
american_fraction: Mapped[Optional[str]] = mapped_column(String(16)) # FRACCIONAMERICANA
alternate_fraction: Mapped[Optional[str]] = mapped_column(String(10)) # FRACCIONALTERNA
reference_fraction: Mapped[Optional[str]] = mapped_column(String(10)) # FRACCIONREFERENCIA

View File

@@ -4,7 +4,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.database import Base
if TYPE_CHECKING:
from ..line_items.models import LineItem
from ..models import LineItem
class LineDescription(Base):
"""
@@ -24,7 +24,8 @@ class LineDescription(Base):
description_english: Mapped[Optional[str]] = mapped_column(String(4999)) # DESCRIPCIONI
extra_description: Mapped[Optional[str]] = mapped_column(Text) # DESCRIPCIONEEXTRA
part_description: Mapped[Optional[str]] = mapped_column(String(500)) # DESCRIPCIONPARTE
class_description: Mapped[Optional[str]] = mapped_column(String(500)) # DESCRIPCIONCLASE
class_description: Mapped[Optional[str]] = mapped_column(String(500)) # DESCRIPCIONCLASE
package_description: Mapped[Optional[str]] = mapped_column(String(500)) # DESCRIPCIONBULTO
# Product attributes
brand: Mapped[Optional[str]] = mapped_column(String(50)) # MARCA

View File

@@ -13,6 +13,7 @@ class LineDescriptionBase(BaseModel):
extra_description: Optional[str] = Field(None, description="Extra description (DESCRIPCIONEEXTRA)")
part_description: Optional[str] = Field(None, max_length=500, description="Part description (DESCRIPCIONPARTE)")
class_description: Optional[str] = Field(None, max_length=500, description="Class description (DESCRIPCIONCLASE)")
package_description: Optional[str] = Field(None, max_length=500, description="Package description (DESCRIPCIONBULTO)")
# Product attributes
brand: Optional[str] = Field(None, max_length=50, description="Brand (MARCA)")

View File

@@ -5,7 +5,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.database import Base
if TYPE_CHECKING:
from ..line_items.models import LineItem
from ..models import LineItem
class LineFinancial(Base):
"""

View File

@@ -1,16 +0,0 @@
"""Line items module"""
from .models import LineItem
from .schemas import (
LineItemBase,
LineItemCreate,
LineItemUpdate,
LineItemResponse,
)
__all__ = [
"LineItem",
"LineItemBase",
"LineItemCreate",
"LineItemUpdate",
"LineItemResponse",
]

View File

@@ -1,200 +0,0 @@
from decimal import Decimal
from typing import Optional, TYPE_CHECKING
from sqlalchemy import (
Boolean,
String,
Integer,
Numeric,
SmallInteger,
ForeignKey,
ForeignKeyConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
if TYPE_CHECKING:
from ..models import Item
from ..line_financials.models import LineFinancial
from ..line_quantities.models import LineQuantity
from ..line_customs.models import LineCustom
from ..line_descriptions.models import LineDescription
from ..line_references.models import LineReference
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
class LineItem(Base, TenantScopedMixin, TimestampMixin):
"""
Unified line items for all items
Consolidates all line-level data from Q and S tables
"""
__tablename__ = "item_lines"
__table_args__ = ({"schema": "a76"},)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
item_id: Mapped[int] = mapped_column(ForeignKey("a76.items.id"))
line_number: Mapped[int] = mapped_column(Integer) # LINEAIMPO/LINEAEXPO/LINEA
# Part identification
part_number: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("a76.parts.id")
) # NUMPARTE
component_part_number: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("a76.parts.id")
) # NUMPARTECOM
class_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("a76.classes.id")
) # CLASE
# Unit of measure
unit_of_measure: Mapped[Optional[int]] = mapped_column(
ForeignKey("a76.units_of_measure.id")
) # UNIDADMEDIDA/UNIMED
alternate_unit: Mapped[Optional[int]] = mapped_column(
ForeignKey("a76.units_of_measure.id")
) # UNIMEDALTERNA
uma_key: Mapped[Optional[str]] = mapped_column(String(2)) # CLAVEUMA
auxiliary_unit: Mapped[Optional[str]] = mapped_column(String(5)) # UNIMEDAUXILIAR
# Permits and certificates
permit_number: Mapped[Optional[str]] = mapped_column(String(20)) # NUMPERMISO
page_line: Mapped[Optional[str]] = mapped_column(String(10)) # PAGRENGLON
has_certificate: Mapped[Optional[bool]] = mapped_column(
Boolean
) # TIENECO/CERTORIGEN
certificate_number: Mapped[Optional[str]] = mapped_column(
String(10)
) # NOCERTIFICADO
octave_permit: Mapped[Optional[str]] = mapped_column(String(20)) # PERMISOROCTAVA
permits_ped: Mapped[Optional[str]] = mapped_column(String(500)) # PERMISOSPED
# FDA
has_fda_code: Mapped[Optional[bool]] = mapped_column(Boolean) # LLEVACODFDA
fda_key: Mapped[Optional[str]] = mapped_column(String(10)) # CLAVEFDA
# Special flags
is_military_mcia: Mapped[Optional[bool]] = mapped_column(Boolean) # ESMCIAMILITAR
# IV32 (Tax identification)
iv32_type_key: Mapped[Optional[str]] = mapped_column(String(5)) # CLAVETIPOIV32
iv32_number: Mapped[Optional[str]] = mapped_column(String(35)) # NUMEROIV32
# IN CASE OF EXPO
scrap_invoice: Mapped[Optional[str]] = mapped_column(String(15)) # FACTURASCRAP
consecutive_destination: Mapped[Optional[int]] = mapped_column(
Integer
) # CONSECUTIVODES
ctm_section: Mapped[Optional[str]] = mapped_column(String(3)) # APARTADOCTM
# Tax payment
tax_payment: Mapped[Optional[bool]] = mapped_column(Boolean) # PAGOIMPUESTO
payment_method: Mapped[Optional[str]] = mapped_column(
String(9)
) # FORMAPAGO/FORMAPAGOTIGI
igi_amount: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # MONTOIGI
igi_payment_method: Mapped[Optional[str]] = mapped_column(
String(9)
) # FORMAPAGOTIGI
# FCC
fcc_key: Mapped[Optional[str]] = mapped_column(String(30)) # CLAVEFCC
# Valuation method
valuation_method: Mapped[Optional[str]] = mapped_column(String(2)) # METVALOR
valuation_determined_value: Mapped[Optional[Decimal]] = mapped_column(
Numeric(29, 8)
) # METVALORVALORDETERMINADO/METVALORACIONVALORDETERMINADO
valuation_reason: Mapped[Optional[str]] = mapped_column(
String(500)
) # METVALORMOTIVODEUSO/METVALORACIONMOTIVODEUSO
# Container rules
container_rule: Mapped[Optional[str]] = mapped_column(String(50)) # CONTENEDORREGLA
container_parts_ii: Mapped[Optional[str]] = mapped_column(
String(50)
) # CONTENEDORPARTESII
# APHIS
consecutive_aphis: Mapped[Optional[int]] = mapped_column(
Integer
) # CONSECUTIVOAPHIS
# BOM/Commercial
bom_version: Mapped[Optional[int]] = mapped_column(Integer) # VERSIONBOM
bill_version: Mapped[Optional[int]] = mapped_column(Integer) # VERSIONBILL
# TLCAN value
tlcan_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORTLCAN
# Identifier
identifier: Mapped[Optional[str]] = mapped_column(String(2)) # IDENTIFICADOR
# Validation fields
validation_zero: Mapped[Optional[int]] = mapped_column(Integer) # VALIDACIONZERO
validation_one: Mapped[Optional[int]] = mapped_column(Integer) # VALIDACIONUNO
# Material type
material_type: Mapped[Optional[str]] = mapped_column(
String(50)
) # TIPOMAT/TIPODENUMPARTE
# Order concept
order_type: Mapped[Optional[str]] = mapped_column(String(50)) # TIPODEORDEN
line_concept: Mapped[Optional[str]] = mapped_column(
String(50)
) # CONCEPTODELAPARTIDA
# Review dispatch
review_dispatch: Mapped[Optional[str]] = mapped_column(String(10)) # REVISARDESP
# Take component from PT
take_component_pt: Mapped[Optional[int]] = mapped_column(Integer) # TOMARCOMOPT
# Pallet
pallet2: Mapped[Optional[int]] = mapped_column(SmallInteger) # PALLET2
# Wildcard field
wildcard_field: Mapped[Optional[str]] = mapped_column(String(100)) # CAMPOCOMODIN
# Relationships
item: Mapped["Item"] = relationship(back_populates="lines")
financial: Mapped[Optional["LineFinancial"]] = relationship(
back_populates="line", cascade="all, delete-orphan", uselist=False
)
quantity: Mapped[Optional["LineQuantity"]] = relationship(
back_populates="line", cascade="all, delete-orphan", uselist=False
)
customs: Mapped[Optional["LineCustom"]] = relationship(
back_populates="line", cascade="all, delete-orphan", uselist=False
)
description: Mapped[Optional["LineDescription"]] = relationship(
back_populates="line", cascade="all, delete-orphan", uselist=False
)
reference: Mapped[Optional["LineReference"]] = relationship(
back_populates="line", cascade="all, delete-orphan", uselist=False
)
class_info: Mapped[Optional["Class"]] = relationship(
"api.v1.modules.a76.classes.models.Class",
foreign_keys=[class_id],
viewonly=True,
)
unit_of_measure_info: Mapped[Optional["UnitOfMeasure"]] = relationship(
"api.v1.modules.a76.general_catalogs.units_of_measure.models.UnitOfMeasure",
foreign_keys=[unit_of_measure],
viewonly=True,
)
fa_data: Mapped[Optional["FaLineItem"]] = relationship(
"FaLineItem",
back_populates="master_info",
cascade="all, delete-orphan",
uselist=False,
)
part_info: Mapped[Optional["api.v1.modules.a76.parts.models.Part"]] = relationship(
"api.v1.modules.a76.parts.models.Part",
foreign_keys=[part_number],
viewonly=True,
)

View File

@@ -1,278 +0,0 @@
from decimal import Decimal
from typing import Optional, Any
from pydantic import BaseModel, Field, ConfigDict, field_validator, model_validator
# Import nested schemas
from ..line_customs.schemas import (
LineCustomCreate,
LineCustomUpdate,
LineCustomResponse,
)
from ..line_descriptions.schemas import (
LineDescriptionCreate,
LineDescriptionUpdate,
LineDescriptionResponse,
)
from ..line_quantities.schemas import (
LineQuantityCreate,
LineQuantityUpdate,
LineQuantityResponse,
)
from ..line_financials.schemas import (
LineFinancialCreate,
LineFinancialUpdate,
LineFinancialResponse,
)
from ..line_references.schemas import (
LineReferenceCreate,
LineReferenceUpdate,
LineReferenceResponse,
)
from api.v1.modules.a24.fa.fa_item_lines.dto import (
FaLineItemCreateDTO,
FaLineItemUpdateDTO,
FaLineItemResponseDTO,
)
# ============================================================================
# LINE ITEM SCHEMAS
# ============================================================================
class LineItemBase(BaseModel):
"""Base schema for line items"""
model_config = ConfigDict(populate_by_name=True)
line_number: int = Field(..., description="Line number")
# Part identification
part_number_id: Optional[int] = Field(
None, description="Part number", alias="part_number", serialization_alias="part_number_id"
)
component_part_number_id: Optional[int] = Field(
None, description="Component part number", alias="component_part_number", serialization_alias="component_part_number_id"
)
class_id: Optional[int] = Field(None, description="Class code")
# Unit of measure
unit_of_measure: Optional[int] = Field(None, description="Unit of measure")
alternate_unit: Optional[int] = Field(None, 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"
)
# Permits and certificates
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"
)
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")
# Special flags
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_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"
)
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"
)
igi_amount: Optional[Decimal] = Field(None, description="IGI amount")
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"
)
# 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"
)
# 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"
)
# 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"
)
# 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"
)
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"
)
fa_data: Optional[FaLineItemCreateDTO] = Field(
None, description="Fixed Asset 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"
)
fa_data: Optional[FaLineItemUpdateDTO] = Field(
None, description="Fixed Asset 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
quantity: Optional[LineQuantityResponse] = None
customs: Optional[LineCustomResponse] = None
description: Optional[LineDescriptionResponse] = None
reference: Optional[LineReferenceResponse] = None
fa_data: Optional[FaLineItemResponseDTO] = None
# Fields populated from relationships
class_code: Optional[str] = None
class_description: Optional[str] = None
unit_of_measure_code: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
@model_validator(mode="before")
@classmethod
def extract_relationship_info(cls, data: Any) -> Any:
"""Extract class_code, class_description and unit_of_measure_code from relationships"""
if isinstance(data, dict):
return data
# It's an ORM object
result = {}
for key in cls.model_fields.keys():
if hasattr(data, key):
result[key] = getattr(data, key)
# Map model field names to schema field names for aliased fields
if hasattr(data, "part_number"):
result["part_number_id"] = data.part_number
if hasattr(data, "component_part_number"):
result["component_part_number_id"] = data.component_part_number
# Extract class info
if hasattr(data, "class_info") and data.class_info is not None:
result["class_code"] = data.class_info.class_code
result["class_description"] = data.class_info.description_es
# Extract unit of measure code
if (
hasattr(data, "unit_of_measure_info")
and data.unit_of_measure_info is not None
):
result["unit_of_measure_code"] = data.unit_of_measure_info.code
return result

View File

@@ -7,7 +7,7 @@ from core.database import Base
from api.v1.modules.a76.general_catalogs.packages.models import Package
if TYPE_CHECKING:
from ..line_items.models import LineItem
from ..models import LineItem
class LineQuantity(Base):
"""

View File

@@ -4,7 +4,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.database import Base
if TYPE_CHECKING:
from ..line_items.models import LineItem
from ..models import LineItem
class LineReference(Base):
"""

View File

@@ -3,58 +3,229 @@ Normalized Database Schema for SCAF (Fixed Assets) and SCAII (Parts Inventory)
SQLAlchemy v2 - Annex 24 Compliance
"""
from typing import Optional, TYPE_CHECKING, List
from sqlalchemy import Boolean, String, Integer, ForeignKey
from typing import Optional, TYPE_CHECKING
from core.database import Base
from decimal import Decimal
from sqlalchemy import Boolean, String, Integer, Numeric, SmallInteger, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
if TYPE_CHECKING:
from .line_items.models import LineItem
from .line_financials.models import LineFinancial
from .line_quantities.models import LineQuantity
from .line_customs.models import LineCustom
from .line_descriptions.models import LineDescription
from .line_references.models import LineReference
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.invoices.models import InvoiceHeader
# ============================================================================
# CORE ENTITIES
# ============================================================================
class Item(Base, TenantScopedMixin, TimestampMixin):
class LineItem(Base, TenantScopedMixin, TimestampMixin):
"""
Unified item header table for all import/export operations
Consolidates headers from both SCAF and SCAII systems
"""
__tablename__ = "items"
__tablename__ = "item_lines"
__table_args__ = {
"schema": "a76",
}
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
invoice_id: Mapped[int] = mapped_column(ForeignKey("a76.invoice_header.id")) # CONSECUTIVO
invoice_id: Mapped[int] = mapped_column(
ForeignKey("a76.invoice_header.id")
) # CONSECUTIVO
line_number: Mapped[int] = mapped_column(Integer) # LINEAIMPO/LINEAEXPO/LINEA
# Part identification
part_number_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("a76.parts.id")
) # NUMPARTE
component_part_number_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("a76.parts.id")
) # NUMPARTECOM
class_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("a76.classes.id")
) # CLASE
# Unit of measure
unit_of_measure: Mapped[Optional[int]] = mapped_column(
ForeignKey("a76.units_of_measure.id")
) # UNIDADMEDIDA/UNIMED
alternate_unit: Mapped[Optional[int]] = mapped_column(
ForeignKey("a76.units_of_measure.id")
) # UNIMEDALTERNA
uma_key: Mapped[Optional[str]] = mapped_column(String(2)) # CLAVEUMA
auxiliary_unit: Mapped[Optional[str]] = mapped_column(String(5)) # UNIMEDAUXILIAR
# Permits and certificates
permit_number: Mapped[Optional[str]] = mapped_column(String(20)) # NUMPERMISO
page_line: Mapped[Optional[str]] = mapped_column(String(10)) # PAGRENGLON
has_certificate: Mapped[Optional[bool]] = mapped_column(
Boolean
) # TIENECO/CERTORIGEN
certificate_number: Mapped[Optional[str]] = mapped_column(
String(10)
) # NOCERTIFICADO
octave_permit: Mapped[Optional[str]] = mapped_column(String(20)) # PERMISOROCTAVA
permits_ped: Mapped[Optional[str]] = mapped_column(String(500)) # PERMISOSPED
# FDA
has_fda_code: Mapped[Optional[bool]] = mapped_column(Boolean) # LLEVACODFDA
fda_key: Mapped[Optional[str]] = mapped_column(String(10)) # CLAVEFDA
# Special flags
is_military_mcia: Mapped[Optional[bool]] = mapped_column(Boolean) # ESMCIAMILITAR
# IV32 (Tax identification)
iv32_type_key: Mapped[Optional[str]] = mapped_column(String(5)) # CLAVETIPOIV32
iv32_number: Mapped[Optional[str]] = mapped_column(String(35)) # NUMEROIV32
# IN CASE OF EXPO
scrap_invoice: Mapped[Optional[str]] = mapped_column(String(15)) # FACTURASCRAP
consecutive_destination: Mapped[Optional[int]] = mapped_column(
Integer
) # CONSECUTIVODES
ctm_section: Mapped[Optional[str]] = mapped_column(String(3)) # APARTADOCTM
# Tax payment
tax_payment: Mapped[Optional[bool]] = mapped_column(Boolean) # PAGOIMPUESTO
payment_method: Mapped[Optional[str]] = mapped_column(
String(9)
) # FORMAPAGO/FORMAPAGOTIGI
igi_amount: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # MONTOIGI
igi_payment_method: Mapped[Optional[str]] = mapped_column(
String(9)
) # FORMAPAGOTIGI
# FCC
fcc_key: Mapped[Optional[str]] = mapped_column(String(30)) # CLAVEFCC
# Valuation method
valuation_method: Mapped[Optional[str]] = mapped_column(String(2)) # METVALOR
valuation_determined_value: Mapped[Optional[Decimal]] = mapped_column(
Numeric(29, 8)
) # METVALORVALORDETERMINADO/METVALORACIONVALORDETERMINADO
valuation_reason: Mapped[Optional[str]] = mapped_column(
String(500)
) # METVALORMOTIVODEUSO/METVALORACIONMOTIVODEUSO
# Container rules
container_rule: Mapped[Optional[str]] = mapped_column(String(50)) # CONTENEDORREGLA
container_parts_ii: Mapped[Optional[str]] = mapped_column(
String(50)
) # CONTENEDORPARTESII
# APHIS
consecutive_aphis: Mapped[Optional[int]] = mapped_column(
Integer
) # CONSECUTIVOAPHIS
# BOM/Commercial
bom_version: Mapped[Optional[int]] = mapped_column(Integer) # VERSIONBOM
bill_version: Mapped[Optional[int]] = mapped_column(Integer) # VERSIONBILL
# TLCAN value
tlcan_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORTLCAN
# Identifier
identifier: Mapped[Optional[str]] = mapped_column(String(2)) # IDENTIFICADOR
# Validation fields
validation_zero: Mapped[Optional[int]] = mapped_column(Integer) # VALIDACIONZERO
validation_one: Mapped[Optional[int]] = mapped_column(Integer) # VALIDACIONUNO
# Material type
material_type: Mapped[Optional[str]] = mapped_column(
String(50)
) # TIPOMAT/TIPODENUMPARTE
# Order concept
order_type: Mapped[Optional[str]] = mapped_column(String(50)) # TIPODEORDEN
line_concept: Mapped[Optional[str]] = mapped_column(
String(50)
) # CONCEPTODELAPARTIDA
# Review dispatch
review_dispatch: Mapped[Optional[str]] = mapped_column(String(10)) # REVISARDESP
# Take component from PT
take_component_pt: Mapped[Optional[int]] = mapped_column(Integer) # TOMARCOMOPT
# Pallet
pallet2: Mapped[Optional[int]] = mapped_column(SmallInteger) # PALLET2
# Wildcard field
wildcard_field: Mapped[Optional[str]] = mapped_column(String(100)) # CAMPOCOMODIN
# Item references
reference_number: Mapped[Optional[str]] = mapped_column(
String(20)) # NUMREFERENCIA
order: Mapped[Optional[str]] = mapped_column(
String(50)) # ORDENCOMPRA / ORDENVENTA
reference_number: Mapped[Optional[str]] = mapped_column(String(20)) # NUMREFERENCIA
order: Mapped[Optional[str]] = mapped_column(String(50)) # ORDENCOMPRA / ORDENVENTA
guide_number: Mapped[Optional[str]] = mapped_column(
String(50)) # NUMEROGUIA/NUMERODEGUIA
String(50)
) # NUMEROGUIA/NUMERODEGUIA
# Dates
depreciation_date: Mapped[Optional[int]] = mapped_column(
Integer) # FECHADEPRECIACION
Integer
) # FECHADEPRECIACION
# Administrative fields
rectification: Mapped[Optional[bool]] = mapped_column(
Boolean) # RECTIFICACION
rectification: Mapped[Optional[bool]] = mapped_column(Boolean) # RECTIFICACION
warehouse: Mapped[Optional[str]] = mapped_column(String(30)) # BODEGA
location: Mapped[Optional[str]] = mapped_column(
String(200)) # LOCALIZACION
location: Mapped[Optional[str]] = mapped_column(String(200)) # LOCALIZACION
# Relationships (one-to-many)
lines: Mapped[List["LineItem"]] = relationship(
"LineItem", back_populates="item", cascade="all, delete-orphan")
invoice: Mapped["InvoiceHeader"] = relationship("InvoiceHeader")
# Relationships
financial: Mapped[Optional["LineFinancial"]] = relationship(
back_populates="line", cascade="all, delete-orphan", uselist=False
)
quantity: Mapped[Optional["LineQuantity"]] = relationship(
back_populates="line", cascade="all, delete-orphan", uselist=False
)
customs: Mapped[Optional["LineCustom"]] = relationship(
back_populates="line", cascade="all, delete-orphan", uselist=False
)
description: Mapped[Optional["LineDescription"]] = relationship(
back_populates="line", cascade="all, delete-orphan", uselist=False
)
reference: Mapped[Optional["LineReference"]] = relationship(
back_populates="line", cascade="all, delete-orphan", uselist=False
)
class_info: Mapped[Optional["Class"]] = relationship(
"api.v1.modules.a76.classes.models.Class",
foreign_keys=[class_id],
viewonly=True,
)
unit_of_measure_info: Mapped[Optional["UnitOfMeasure"]] = relationship(
"api.v1.modules.a76.general_catalogs.units_of_measure.models.UnitOfMeasure",
foreign_keys=[unit_of_measure],
viewonly=True,
)
fa_data: Mapped[Optional["FaLineItem"]] = relationship(
"FaLineItem",
back_populates="master_info",
cascade="all, delete-orphan",
uselist=False,
)
part_info: Mapped[Optional["Part"]] = relationship(
"Part",
foreign_keys=[part_number_id],
viewonly=True,
)
# ============================================================================
# SUPPORTING TABLES
# ============================================================================
@@ -65,6 +236,7 @@ class PackingList(Base, TenantScopedMixin, TimestampMixin):
Packing list items
From: SPartidasPackingList
"""
__tablename__ = "packing_lists"
__table_args__ = {
"schema": "a76",
@@ -73,7 +245,8 @@ class PackingList(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
item_line_id: Mapped[int] = mapped_column(Integer) # LINEA
packing_list_number: Mapped[Optional[str]] = mapped_column(
String(100)) # NUMPACKINGLIST
String(100)
) # NUMPACKINGLIST
class CTMReceipt(Base, TenantScopedMixin, TimestampMixin):
@@ -81,6 +254,7 @@ class CTMReceipt(Base, TenantScopedMixin, TimestampMixin):
CTM Receipt lines (temporary manufacturing)
From: SPartidasReciboCTM
"""
__tablename__ = "ctm_receipts"
__table_args__ = {
"schema": "a76",
@@ -88,11 +262,11 @@ class CTMReceipt(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
receipt_line: Mapped[int] = mapped_column(
ForeignKey("a76.item_lines.id")) # LINEARECIBO
ForeignKey("a76.item_lines.id")
) # LINEARECIBO
option: Mapped[Optional[str]] = mapped_column(String(3)) # OPCION
exit_invoice: Mapped[Optional[str]] = mapped_column(
String(19)) # FACTURASALIDA
exit_invoice: Mapped[Optional[str]] = mapped_column(String(19)) # FACTURASALIDA
class SubassemblyEntry(Base, TenantScopedMixin, TimestampMixin):
@@ -100,6 +274,7 @@ class SubassemblyEntry(Base, TenantScopedMixin, TimestampMixin):
Subassembly/Submanufacturing Entry lines
From: SPartidasEntradaSM
"""
__tablename__ = "subassembly_entries"
__table_args__ = {
"schema": "a76",
@@ -107,10 +282,10 @@ 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
exit_invoice: Mapped[Optional[str]] = mapped_column(String(15)) # FACTURASALIDA
exit_line: Mapped[Optional[int]] = mapped_column(Integer) # LINEASALIDA
# ============================================================================
# INDEXES AND CONSTRAINTS
# ============================================================================
@@ -189,8 +364,8 @@ MIGRATION STRATEGY FROM ORIGINAL TABLES TO NORMALIZED SCHEMA:
7. QUERYING EXAMPLES:
```python
# Get all imports (both systems)
session.query(Item).filter(
Item.item_type.in_(['IMPORT', 'EQUIPMENT_IMPORT_TEMP', 'EQUIPMENT_IMPORT_DEF'])
session.query(LineItem).filter(
LineItem.item_type.in_(['IMPORT', 'EQUIPMENT_IMPORT_TEMP', 'EQUIPMENT_IMPORT_DEF'])
)
# Get all lines for a specific part across all items
@@ -199,8 +374,8 @@ MIGRATION STRATEGY FROM ORIGINAL TABLES TO NORMALIZED SCHEMA:
)
# Get SCAF equipment with depreciation
session.query(Item).join(LineItem).filter(
Item.system_origin == 'SCAF',
session.query(LineItem).join(LineItem).filter(
LineItem.system_origin == 'SCAF',
LineItem.value_depreciated_usd.isnot(None)
)
```

View File

@@ -11,10 +11,10 @@ from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from .schemas import (
ItemCreate,
ItemUpdate,
ItemResponse,
ItemListResponse,
LineItemCreate,
LineItemUpdate,
LineItemResponse,
LineItemListResponse,
)
from .service import ItemService
@@ -25,9 +25,9 @@ router = APIRouter(prefix="/items", tags=["Items"])
# ITEM CRUD ENDPOINTS
# ============================================================================
@router.post("/", response_model=ItemResponse, status_code=status.HTTP_201_CREATED)
@router.post("/", response_model=LineItemResponse, status_code=status.HTTP_201_CREATED)
async def create_item(
item_data: ItemCreate,
item_data: LineItemCreate,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
@@ -36,7 +36,6 @@ async def create_item(
Create a new item with multiple line items and their nested data
The item follows a one-to-many relationship structure:
- Item has many LineItems
- Each LineItem has one LineFinancial
- Each LineItem has one LineQuantity
- Each LineItem has one LineCustoms
@@ -49,7 +48,7 @@ async def create_item(
return service.create(db, item_data, tenant_id, company_id)
@router.get("/{item_id}", response_model=ItemResponse)
@router.get("/{item_id}", response_model=LineItemResponse)
async def get_item(
item_id: int = Path(..., description="Item ID"),
company_id: int = Query(..., description="Company ID"),
@@ -70,7 +69,7 @@ async def get_item(
return item
@router.get("/", response_model=ItemListResponse)
@router.get("/", response_model=LineItemListResponse)
async def list_items(
company_id: int = Query(..., description="Company ID"),
skip: int = Query(0, ge=0, description="Number of records to skip"),
@@ -109,7 +108,7 @@ async def list_items(
items, total = service.get_all(
db, tenant_id, company_id, skip, limit, filters)
return ItemListResponse(
return LineItemListResponse(
total=total,
items=items,
skip=skip,
@@ -117,10 +116,10 @@ async def list_items(
)
@router.put("/{item_id}", response_model=ItemResponse)
@router.put("/{item_id}", response_model=LineItemResponse)
async def update_item(
item_id: int = Path(..., description="Item ID"),
item_data: ItemUpdate = ...,
item_data: LineItemUpdate = ...,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
@@ -168,7 +167,7 @@ async def delete_item(
# ADDITIONAL ENDPOINTS FOR INVOICE
# ============================================================================
@router.get("/invoice/{invoice_id}/items", response_model=ItemListResponse)
@router.get("/invoice/{invoice_id}/items", response_model=LineItemListResponse)
async def get_items_by_invoice(
invoice_id: int = Path(..., description="Invoice ID"),
company_id: int = Query(..., description="Company ID"),
@@ -186,7 +185,7 @@ async def get_items_by_invoice(
items, total = service.get_by_invoice(
db, invoice_id, tenant_id, company_id, skip, limit)
return ItemListResponse(
return LineItemListResponse(
total=total,
items=items,
skip=skip,

View File

@@ -1,19 +1,46 @@
"""
Schemas for Items and related entities
Complete nested one-to-one structure:
Item -> LineItem -> LineFinancial -> LineQuantity -> LineCustoms -> LineDescription -> LineReference
LineItem -> LineFinancial -> LineQuantity -> LineCustoms -> LineDescription -> LineReference
"""
from typing import Optional
from typing import Any, Optional
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, Field, ConfigDict
from pydantic import BaseModel, Field, ConfigDict, model_validator
# Import schemas from individual modules
from .line_items.schemas import (
LineItemCreate,
LineItemUpdate,
LineItemResponse
# Import nested schemas
from .line_customs.schemas import (
LineCustomCreate,
LineCustomUpdate,
LineCustomResponse,
)
from .line_descriptions.schemas import (
LineDescriptionCreate,
LineDescriptionUpdate,
LineDescriptionResponse,
)
from .line_quantities.schemas import (
LineQuantityCreate,
LineQuantityUpdate,
LineQuantityResponse,
)
from .line_financials.schemas import (
LineFinancialCreate,
LineFinancialUpdate,
LineFinancialResponse,
)
from .line_references.schemas import (
LineReferenceCreate,
LineReferenceUpdate,
LineReferenceResponse,
)
from api.v1.modules.a24.fa.fa_item_lines.dto import (
FaLineItemCreateDTO,
FaLineItemUpdateDTO,
FaLineItemResponseDTO,
)
@@ -21,53 +48,294 @@ from .line_items.schemas import (
# ITEM SCHEMAS
# ============================================================================
class ItemBase(BaseModel):
class LineItemBase(BaseModel):
"""Base schema for items"""
model_config = ConfigDict(populate_by_name=True)
invoice_id: int = Field(..., description="Invoice ID")
line_number: int = Field(..., description="Line number")
# Part identification
part_number_id: Optional[int] = Field(
None,
description="Part number",
alias="part_number",
serialization_alias="part_number_id",
)
component_part_number_id: Optional[int] = Field(
None,
description="Component part number",
alias="component_part_number",
serialization_alias="component_part_number_id",
)
class_id: Optional[int] = Field(None, description="Class code")
# Unit of measure
unit_of_measure: Optional[int] = Field(None, description="Unit of measure")
alternate_unit: Optional[int] = Field(None, 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"
)
# Permits and certificates
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"
)
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")
# Special flags
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_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"
)
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"
)
igi_amount: Optional[Decimal] = Field(None, description="IGI amount")
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"
)
# 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"
)
# 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"
)
# 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"
)
# 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"
)
reference_number: Optional[str] = Field(
None, max_length=20, description="Reference number")
None, max_length=20, description="Reference number"
)
order: Optional[str] = Field(None, max_length=50, description="Order")
guide_number: Optional[str] = Field(
None, max_length=50, description="Guide number")
guide_number: Optional[str] = Field(None, max_length=50, description="Guide number")
# Dates
depreciation_date: Optional[int] = Field(
None, description="Depreciation date")
depreciation_date: Optional[int] = Field(None, description="Depreciation date")
# Administrative fields
rectification: Optional[int] = Field(None, description="Rectification")
warehouse: Optional[str] = Field(
None, max_length=30, description="Warehouse")
location: Optional[str] = Field(
None, max_length=200, description="Location")
warehouse: Optional[str] = Field(None, max_length=30, description="Warehouse")
location: Optional[str] = Field(None, max_length=200, description="Location")
class ItemCreate(ItemBase):
class LineItemCreate(LineItemBase):
"""Schema for creating item with nested lines (one-to-many)"""
lines: Optional[list[LineItemCreate]] = Field(
default=[], description="List of line items")
# Override base fields - estos se asignan automáticamente en el service
line_number: Optional[int] = Field(None, description="Line number (auto-assigned)")
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"
)
fa_data: Optional[FaLineItemCreateDTO] = Field(
None, description="Fixed Asset data for this line"
)
class ItemUpdate(ItemBase):
class LineItemUpdate(LineItemBase):
"""Schema for updating item"""
invoice_id: Optional[int] = Field(None, description="Invoice ID")
lines: Optional[list[LineItemUpdate]] = Field(
None, description="List of line items to update")
# Override base fields - todos opcionales en updates
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"
)
fa_data: Optional[FaLineItemUpdateDTO] = Field(
None, description="Fixed Asset data for this line"
)
class ItemResponse(ItemBase):
"""Schema for item response with nested data (one-to-many)"""
class LineItemResponse(LineItemBase):
"""Schema for single item response"""
id: int
lines: list[LineItemResponse] = Field(
default=[], description="List of line items")
invoice_id: int
line_number: int
model_config = ConfigDict(from_attributes=True)
# Part identification
part_number_id: Optional[int] = Field(
None, alias="part_number", serialization_alias="part_number_id"
)
component_part_number_id: Optional[int] = Field(
None,
alias="component_part_number",
serialization_alias="component_part_number_id",
)
class_id: Optional[int] = None
# Nested data
financial: Optional[LineFinancialResponse] = None
quantity: Optional[LineQuantityResponse] = None
customs: Optional[LineCustomResponse] = None
description: Optional[LineDescriptionResponse] = None
reference: Optional[LineReferenceResponse] = None
fa_data: Optional[FaLineItemResponseDTO] = None
# Fields populated from relationships
class_code: Optional[str] = None
class_description: Optional[str] = None
unit_of_measure_code: Optional[str] = None
# Additional fields that might be present
reference_number: Optional[str] = None
order: Optional[str] = None
warehouse: Optional[str] = None
location: Optional[str] = None
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
@model_validator(mode="before")
@classmethod
def extract_relationship_info(cls, data: Any) -> Any:
"""Extract class_code, class_description and unit_of_measure_code from relationships"""
if isinstance(data, dict):
return data
# It's an ORM object
result = {}
for key in cls.model_fields.keys():
if hasattr(data, key):
result[key] = getattr(data, key)
# Map model field names to schema field names for aliased fields
if hasattr(data, "part_number"):
result["part_number_id"] = data.part_number
if hasattr(data, "component_part_number"):
result["component_part_number_id"] = data.component_part_number
# Extract class info
if hasattr(data, "class_info") and data.class_info is not None:
result["class_code"] = data.class_info.class_code
result["class_description"] = data.class_info.description_es
# Extract unit of measure code
if (
hasattr(data, "unit_of_measure_info")
and data.unit_of_measure_info is not None
):
result["unit_of_measure_code"] = data.unit_of_measure_info.code
return result
class ItemListResponse(BaseModel):
class LineItemListResponse(BaseModel):
"""Schema for paginated item list"""
total: int = Field(..., description="Total number of items")
items: list[ItemResponse] = Field(..., description="List of items")
items: list[LineItemResponse] = Field(..., description="List of items")
skip: int = Field(..., description="Number of skipped items")
limit: int = Field(..., description="Maximum items per page")

View File

@@ -1,12 +1,15 @@
"""
Service layer for Items business logic
Handles CRUD operations for Item with complete one-to-one relationships:
Item -> LineItem -> LineFinancial
-> LineQuantity
-> LineCustoms
-> LineDescription
-> LineReference
-> FaLineItem (Fixed Assets - a24)
Handles CRUD operations for LineItem with complete one-to-one relationships:
LineItem -> LineFinancial
-> LineQuantity
-> LineCustoms
-> LineDescription
-> LineReference
-> FaLineItem (Fixed Assets - a24)
After refactoring: LineItem is the main entity, representing a single line item in an invoice.
There is no intermediate Item entity anymore. Each LineItem belongs directly to an InvoiceHeader.
"""
import logging
@@ -16,24 +19,23 @@ from sqlalchemy import and_, or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload
from api.v1.modules.a76.invoices.common.common_validators import (
invoice_exists_by_id,
invoice_updated,
)
from core.exceptions import ErrorCollector
from .imports.temporary.validators.create import validate_create
from .imports.temporary.validators.update import validate_update
from api.v1.modules.a76.items.line_items.schemas import LineItemCreate, LineItemUpdate
from .schemas import ItemCreate, ItemUpdate
from .line_items.models import LineItem
from .schemas import LineItemCreate, LineItemUpdate
from .line_financials.models import LineFinancial
from .line_quantities.models import LineQuantity
from .line_customs.models import LineCustom
from .line_descriptions.models import LineDescription
from .line_references.models import LineReference
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
from .models import Item
from .models import LineItem
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.general_catalogs.company.models import Company
logger = logging.getLogger(__name__)
@@ -43,27 +45,124 @@ class ItemService:
Service for managing Items and related entities with tenant/company isolation
"""
@staticmethod
def _get_next_line_number(db: Session, invoice_id: int) -> int:
"""Calculate the next line_number for a given invoice based on database."""
from sqlalchemy import func
max_line = (
db.query(func.max(LineItem.line_number))
.filter(LineItem.invoice_id == invoice_id)
.scalar()
)
return 1 if max_line is None else max_line + 1
@staticmethod
def _renumber_all_invoice_lines(db: Session, invoice_id: int) -> None:
"""Renumber all line_items for a given invoice to be consecutive (1, 2, 3, ...)."""
items = (
db.query(LineItem)
.filter(LineItem.invoice_id == invoice_id)
.order_by(LineItem.line_number)
.all()
)
for idx, item in enumerate(items, start=1):
item.line_number = idx
@staticmethod
def _lock_invoice(
db: Session,
invoice_id: int,
tenant_id: int,
company_id: int,
errors: ErrorCollector,
) -> Optional[InvoiceHeader]:
"""Lock invoice to prevent concurrent modifications. Returns locked invoice or adds error."""
try:
invoice = (
db.query(InvoiceHeader)
.filter(
InvoiceHeader.id == invoice_id,
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id,
)
.with_for_update()
.first()
)
if not invoice:
errors.add_error(
field="invoice_id",
message="La factura no existe o no se pudo bloquear",
code="LOCK_FAILED",
value=str(invoice_id),
)
return invoice
except Exception as e:
logger.error(f"Error locking invoice {invoice_id}: {e}")
errors.add_error(
field="invoice_id",
message="Error al intentar bloquear la factura",
code="LOCK_ERROR",
)
return None
@staticmethod
def _create_line_nested_data(
db: Session, line: LineItem, line_data, tenant_id: int, company_id: int
) -> None:
"""Create all nested data for a line item."""
nested_models = [
(line_data.financial, LineFinancial),
(line_data.quantity, LineQuantity),
(line_data.customs, LineCustom),
(line_data.description, LineDescription),
(line_data.reference, LineReference),
]
for data, model_class in nested_models:
if data:
nested_dict = (
data.model_dump(exclude_unset=True)
if hasattr(data, "model_dump")
else data.model_dump()
)
nested_dict["item_line_id"] = line.id
db.add(model_class(**nested_dict))
# FA data uses line.id as primary key
if line_data.fa_data:
fa_dict = line_data.fa_data.model_dump(
exclude_unset=True, exclude={"line_item_id"}
)
fa_dict.update(
{"id": line.id, "tenant_id": tenant_id, "company_id": company_id}
)
db.add(FaLineItem(**fa_dict))
@staticmethod
def get_by_id(
db: Session, item_id: int, tenant_id: int, company_id: int
) -> Optional[Item]:
) -> Optional[LineItem]:
"""Get an item by ID with tenant/company validation"""
return (
db.query(Item)
db.query(LineItem)
.options(
joinedload(Item.lines).joinedload(LineItem.financial),
joinedload(Item.lines).joinedload(LineItem.quantity),
joinedload(Item.lines).joinedload(LineItem.customs),
joinedload(Item.lines).joinedload(LineItem.description),
joinedload(Item.lines).joinedload(LineItem.reference),
joinedload(Item.lines).joinedload(LineItem.class_info),
joinedload(Item.lines).joinedload(LineItem.unit_of_measure_info),
joinedload(Item.lines).joinedload(LineItem.fa_data),
joinedload(LineItem.financial),
joinedload(LineItem.quantity),
joinedload(LineItem.customs),
joinedload(LineItem.description),
joinedload(LineItem.reference),
joinedload(LineItem.class_info),
joinedload(LineItem.unit_of_measure_info),
joinedload(LineItem.fa_data),
)
.filter(
Item.id == item_id,
Item.tenant_id == tenant_id,
Item.company_id == company_id,
LineItem.id == item_id,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id,
)
.first()
)
@@ -76,42 +175,42 @@ class ItemService:
skip: int = 0,
limit: int = 100,
filters: Optional[dict] = None,
) -> Tuple[List[Item], int]:
) -> Tuple[List[LineItem], int]:
"""Get all items for a tenant/company with pagination and optional filters"""
query = (
db.query(Item)
db.query(LineItem)
.options(
joinedload(Item.lines).joinedload(LineItem.financial),
joinedload(Item.lines).joinedload(LineItem.quantity),
joinedload(Item.lines).joinedload(LineItem.customs),
joinedload(Item.lines).joinedload(LineItem.description),
joinedload(Item.lines).joinedload(LineItem.reference),
joinedload(Item.lines).joinedload(LineItem.class_info),
joinedload(Item.lines).joinedload(LineItem.unit_of_measure_info),
joinedload(Item.lines).joinedload(LineItem.fa_data),
joinedload(LineItem.financial),
joinedload(LineItem.quantity),
joinedload(LineItem.customs),
joinedload(LineItem.description),
joinedload(LineItem.reference),
joinedload(LineItem.class_info),
joinedload(LineItem.unit_of_measure_info),
joinedload(LineItem.fa_data),
)
.filter(
Item.tenant_id == tenant_id,
Item.company_id == company_id,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id,
)
)
# Apply filters if provided
if filters:
if filters.get("invoice_id"):
query = query.filter(Item.invoice_id == filters["invoice_id"])
query = query.filter(LineItem.invoice_id == filters["invoice_id"])
if filters.get("item_type"):
query = query.filter(Item.item_type == filters["item_type"])
query = query.filter(LineItem.item_type == filters["item_type"])
if filters.get("system_origin"):
query = query.filter(Item.system_origin == filters["system_origin"])
query = query.filter(LineItem.system_origin == filters["system_origin"])
if filters.get("search"):
search_term = f"%{filters['search']}%"
query = query.filter(
or_(
Item.invoice_number.ilike(search_term),
Item.reference_number.ilike(search_term),
Item.order.ilike(search_term),
Item.guide_number.ilike(search_term),
LineItem.invoice_id.ilike(search_term),
LineItem.reference_number.ilike(search_term),
LineItem.order.ilike(search_term),
LineItem.guide_number.ilike(search_term),
)
)
@@ -127,22 +226,22 @@ class ItemService:
company_id: int,
skip: int = 0,
limit: int = 100,
) -> Tuple[List[Item], int]:
) -> Tuple[List[LineItem], int]:
"""Get all items for a specific invoice"""
query = (
db.query(Item)
db.query(LineItem)
.options(
joinedload(Item.lines).joinedload(LineItem.financial),
joinedload(Item.lines).joinedload(LineItem.quantity),
joinedload(Item.lines).joinedload(LineItem.customs),
joinedload(Item.lines).joinedload(LineItem.description),
joinedload(Item.lines).joinedload(LineItem.reference),
joinedload(Item.lines).joinedload(LineItem.fa_data),
joinedload(LineItem.financial),
joinedload(LineItem.quantity),
joinedload(LineItem.customs),
joinedload(LineItem.description),
joinedload(LineItem.reference),
joinedload(LineItem.fa_data),
)
.filter(
Item.invoice_id == invoice_id,
Item.tenant_id == tenant_id,
Item.company_id == company_id,
LineItem.invoice_id == invoice_id,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id,
)
)
@@ -153,185 +252,88 @@ class ItemService:
@staticmethod
def create(
db: Session,
item_data: ItemCreate,
item_data: LineItemCreate,
tenant_id: int,
company_id: int,
) -> Item:
) -> LineItem:
"""Create a new item with all related nested data (multiple lines)"""
# Validaciones con ErrorCollector
errors = ErrorCollector()
# Validar que la factura exista y no esté actualizada (si viene invoice_id)
invoice = None
if item_data.invoice_id:
invoice = (
db.query(InvoiceHeader)
.filter(
InvoiceHeader.id == item_data.invoice_id,
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id,
)
.first()
)
if not item_data.invoice_id:
errors.add_required_error(field="invoice_id")
errors.raise_if_errors("Error al crear el item")
if not invoice:
errors.add_error(
field="invoice_id",
message="La factura especificada no existe",
code="NOT_FOUND",
value=str(item_data.invoice_id),
)
if not invoice_exists_by_id(
db, item_data.invoice_id, tenant_id, company_id, errors
):
errors.raise_if_errors("Error al crear el item")
if not invoice_updated(db, item_data.invoice_id, tenant_id, company_id, errors):
errors.raise_if_errors("Error al crear el item")
# Validar cada line item que se va a crear
if item_data.lines:
for idx, line_data in enumerate(item_data.lines):
# Convertir a LineItemCreate para validar
line_create = LineItemCreate(**line_data.model_dump())
# Lock invoice and calculate line number
if not ItemService._lock_invoice(
db, item_data.invoice_id, tenant_id, company_id, errors
):
errors.raise_if_errors("Error al crear el item")
validate_create(db, line_create, tenant_id, company_id, errors)
# Calculate the next line number for this single item
line_number = ItemService._get_next_line_number(db, item_data.invoice_id)
# Validaciones adicionales específicas del negocio
# Validar el item
validate_create(
db,
item_data, # Schema Pydantic completo
tenant_id,
company_id,
errors,
line_number,
)
# Validar apóstrofes en número de parte
if line_data.part_number_id and "'" in str(line_data.part_number_id):
errors.add_error(
field=f"lines[{idx}].part_number",
message=f"Advertencia: El Número de Parte contiene apóstrofes y serán omitidos",
code="WARNING_APOSTROPHE",
)
# Validaciones adicionales específicas del negocio
if item_data.fa_data and item_data.fa_data.is_subitem is None:
errors.add_required_error(field=f"fa_data.is_subitem")
# Validar tipo de partida
if hasattr(line_data, "item_type"):
tipo_partida = line_data.item_type
if tipo_partida and tipo_partida not in ["N", "S"]:
errors.add_error(
field=f"lines[{idx}].item_type",
message=f"Tipo de partida debe ser 'N' (Normal) o 'S' (Subpartida), recibido: '{tipo_partida}'",
code="INVALID_ITEM_TYPE",
value=str(tipo_partida),
)
if item_data.fa_data and item_data.fa_data.subitem_number is None:
errors.add_required_error(field=f"fa_data.subitem_number")
# Si es subpartida (S), debe tener partida principal
if tipo_partida == "S":
if (
not hasattr(line_data, "main_line_id")
or not line_data.main_line_id
):
errors.add_error(
field=f"lines[{idx}].main_line_id",
message="Las subpartidas (tipo 'S') deben tener una partida principal",
code="MISSING_MAIN_LINE",
)
# Validar que el line_number sea consecutivo (si se especifica)
if hasattr(line_data, "line_number") and line_data.line_number:
expected_line = idx + 1
if line_data.line_number != expected_line:
errors.add_error(
field=f"lines[{idx}].line_number",
message=f"Número de línea esperado: {expected_line}, recibido: {line_data.line_number}",
code="INVALID_LINE_SEQUENCE",
value=str(line_data.line_number),
)
# Si hay errores, lanzar excepción ANTES de intentar crear
errors.raise_if_errors("Error al crear el item")
try:
# Extract lines data
lines_data = item_data.lines or []
item_dict = item_data.model_dump(exclude={"lines"})
# Prepare item data
item_dict = item_data.model_dump(
exclude={
"financial",
"quantity",
"customs",
"description",
"reference",
"fa_data",
}
)
# Add tenant and company
item_dict["tenant_id"] = tenant_id
item_dict["company_id"] = company_id
# Add tenant, company and line number
item_dict.update(
{
"tenant_id": tenant_id,
"company_id": company_id,
"line_number": line_number,
}
)
# Create the item
db_item = Item(**item_dict)
db_item = LineItem(**item_dict)
db.add(db_item)
db.flush() # Get the item ID
# Create line items if provided
for idx, line_data in enumerate(lines_data):
# Extract nested data from line
financial_data = line_data.financial
quantity_data = line_data.quantity
customs_data = line_data.customs
description_data = line_data.description
reference_data = line_data.reference
fa_data = line_data.fa_data
line_dict = line_data.model_dump(
exclude={
"financial",
"quantity",
"customs",
"description",
"reference",
"fa_data",
}
)
line_dict["item_id"] = db_item.id
line_dict["tenant_id"] = tenant_id
line_dict["company_id"] = company_id
# Map schema field names to model field names
if "part_number_id" in line_dict:
line_dict["part_number"] = line_dict.pop("part_number_id")
if "component_part_number_id" in line_dict:
line_dict["component_part_number"] = line_dict.pop("component_part_number_id")
# Create line item
db_line = LineItem(**line_dict)
db.add(db_line)
db.flush() # Get the line ID
# Create financial data if provided
if financial_data:
financial_dict = financial_data.model_dump()
financial_dict["item_line_id"] = db_line.id
db_financial = LineFinancial(**financial_dict)
db.add(db_financial)
# Create quantity data if provided
if quantity_data:
quantity_dict = quantity_data.model_dump()
quantity_dict["item_line_id"] = db_line.id
db_quantity = LineQuantity(**quantity_dict)
db.add(db_quantity)
# Create customs data if provided
if customs_data:
customs_dict = customs_data.model_dump()
customs_dict["item_line_id"] = db_line.id
db_customs = LineCustom(**customs_dict)
db.add(db_customs)
# Create description data if provided
if description_data:
description_dict = description_data.model_dump()
description_dict["item_line_id"] = db_line.id
db_description = LineDescription(**description_dict)
db.add(db_description)
# Create reference data if provided
if reference_data:
reference_dict = reference_data.model_dump()
reference_dict["item_line_id"] = db_line.id
db_reference = LineReference(**reference_dict)
db.add(db_reference)
# Create FA data if provided
if fa_data:
fa_dict = fa_data.model_dump(
exclude={"line_item_id"}
) # Exclude line_item_id from DTO
fa_dict["id"] = db_line.id # FA table uses same ID as line item
fa_dict["tenant_id"] = tenant_id
fa_dict["company_id"] = company_id
db_fa = FaLineItem(**fa_dict)
db.add(db_fa)
# Create all nested data
ItemService._create_line_nested_data(
db, db_item, item_data, tenant_id, company_id
)
db.commit()
db.refresh(db_item)
@@ -342,221 +344,119 @@ class ItemService:
logger.error(f"Error creating item: {e}")
raise HTTPException(
status_code=400,
detail="Item creation failed - integrity constraint violated",
detail="LineItem creation failed - integrity constraint violated",
)
except Exception as e:
db.rollback()
logger.error(f"Unexpected error creating item: {e}")
raise HTTPException(status_code=500, detail="Error creating item")
logger.error(f"Unexpected error creating LineItem: {e}")
raise HTTPException(status_code=500, detail="Error creating LineItem")
@staticmethod
def update(
db: Session,
item_id: int,
item_data: ItemUpdate,
item_data: LineItemUpdate,
tenant_id: int,
company_id: int,
) -> Item:
) -> LineItem:
"""Update an item and optionally its nested data (multiple lines)"""
# Get existing item
db_item = ItemService.get_by_id(db, item_id, tenant_id, company_id)
if not db_item:
raise HTTPException(status_code=404, detail="Item not found")
raise HTTPException(status_code=404, detail="LineItem not found")
# Validaciones con ErrorCollector
errors = ErrorCollector()
# Si se está actualizando el invoice_id, validar la factura
invoice = None
if item_data.invoice_id:
invoice = (
db.query(InvoiceHeader)
.filter(
InvoiceHeader.id == item_data.invoice_id,
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id,
)
.first()
)
# Lock invoice
invoice_id_to_lock = (
item_data.invoice_id if item_data.invoice_id else db_item.invoice_id
)
if not ItemService._lock_invoice(
db, invoice_id_to_lock, tenant_id, company_id, errors
):
errors.raise_if_errors("Error al actualizar el item")
if not invoice:
# Validar el item que se va a actualizar
validate_update(
db,
item_data, # Schema de update
db_item, # LineItem existente en DB
tenant_id,
company_id,
errors,
db_item.line_number,
)
# Validar tipo de partida
if hasattr(item_data, "item_type") and item_data.item_type:
tipo_partida = item_data.item_type
if tipo_partida and tipo_partida not in ["N", "S"]:
errors.add_error(
field="invoice_id",
message="La factura especificada no existe",
code="NOT_FOUND",
value=str(item_data.invoice_id),
field=f"item_type",
message=f"Tipo de partida debe ser 'N' (Normal) o 'S' (Subpartida), recibido: '{tipo_partida}'",
solution=None,
code="INVALID_ITEM_TYPE",
value=str(tipo_partida),
)
else:
# Si no se está actualizando invoice_id, obtener la factura actual por invoice_id
from api.v1.modules.a76.invoices.models import InvoiceHeader
invoice = (
db.query(InvoiceHeader)
.filter(InvoiceHeader.id == db_item.invoice_id)
.first()
)
# Validar cada line item que se va a actualizar
if item_data.lines:
for idx, line_data in enumerate(item_data.lines):
# Si el line tiene ID, es actualización; si no, es creación
if hasattr(line_data, "id") and line_data.id:
# Buscar el line item existente
existing_line = next(
(line for line in db_item.lines if line.id == line_data.id),
None,
)
if existing_line:
# Convertir a LineItemUpdate para validar
line_update = LineItemUpdate(**line_data.model_dump())
validate_update(db, line_update, tenant_id, company_id, errors)
else:
# Es un nuevo line item, validar como creación
line_create = LineItemCreate(**line_data.model_dump())
validate_create(db, line_create, tenant_id, company_id, errors)
# Validaciones adicionales específicas del negocio
# (Aplican tanto para crear como actualizar)
# Validar apóstrofes en número de parte
if line_data.part_number_id and "'" in str(line_data.part_number_id):
# Si es subpartida (S), debe tener partida principal
if tipo_partida == "S":
if not hasattr(item_data, "main_line_id") or not item_data.main_line_id:
errors.add_error(
field=f"lines[{idx}].part_number",
message=f"Advertencia: El Número de Parte contiene apóstrofes y serán omitidos",
field=f"main_line_id",
message="Las subpartidas (tipo 'S') deben tener una partida principal",
solution=None,
code="WARNING_APOSTROPHE",
code="MISSING_MAIN_LINE",
)
# Validar tipo de partida
if hasattr(line_data, "item_type"):
tipo_partida = line_data.item_type
if tipo_partida and tipo_partida not in ["N", "S"]:
errors.add_error(
field=f"lines[{idx}].item_type",
message=f"Tipo de partida debe ser 'N' (Normal) o 'S' (Subpartida), recibido: '{tipo_partida}'",
solution=None,
code="INVALID_ITEM_TYPE",
value=str(tipo_partida),
)
# Si es subpartida (S), debe tener partida principal
if tipo_partida == "S":
if (
not hasattr(line_data, "main_line_id")
or not line_data.main_line_id
):
errors.add_error(
field=f"lines[{idx}].main_line_id",
message="Las subpartidas (tipo 'S') deben tener una partida principal",
solution=None,
code="MISSING_MAIN_LINE",
)
# Validar que el line_number sea consecutivo (si se especifica)
if hasattr(line_data, "line_number") and line_data.line_number:
expected_line = idx + 1
if line_data.line_number != expected_line:
errors.add_error(
field=f"lines[{idx}].line_number",
message=f"Número de línea esperado: {expected_line}, recibido: {line_data.line_number}",
solution=None,
code="INVALID_LINE_SEQUENCE",
value=str(line_data.line_number),
)
# Si hay errores, lanzar excepción ANTES de actualizar
errors.raise_if_errors("Error al actualizar el item")
try:
# Extract lines data
lines_data = item_data.lines
item_dict = item_data.model_dump(exclude={"lines"}, exclude_unset=True)
# Get item data excluding nested objects
item_dict = item_data.model_dump(
exclude={
"financial",
"quantity",
"customs",
"description",
"reference",
"fa_data",
},
exclude_unset=True,
)
# Update item fields
for key, value in item_dict.items():
setattr(db_item, key, value)
# Update lines if provided (replace all lines)
if lines_data is not None:
# Delete existing lines (cascade will handle nested data)
for existing_line in db_item.lines:
db.delete(existing_line)
db.flush()
# Delete existing nested data
db.query(LineFinancial).filter(
LineFinancial.item_line_id == db_item.id
).delete()
db.query(LineQuantity).filter(
LineQuantity.item_line_id == db_item.id
).delete()
db.query(LineCustom).filter(LineCustom.item_line_id == db_item.id).delete()
db.query(LineDescription).filter(
LineDescription.item_line_id == db_item.id
).delete()
db.query(LineReference).filter(
LineReference.item_line_id == db_item.id
).delete()
db.query(FaLineItem).filter(FaLineItem.id == db_item.id).delete()
db.flush()
# Create new lines
for line_data in lines_data:
# Extract nested data from line
financial_data = line_data.financial
quantity_data = line_data.quantity
customs_data = line_data.customs
description_data = line_data.description
reference_data = line_data.reference
fa_data = line_data.fa_data
# Create new nested data
ItemService._create_line_nested_data(
db, db_item, item_data, tenant_id, company_id
)
line_dict = line_data.model_dump(
exclude={
"financial",
"quantity",
"customs",
"description",
"reference",
"fa_data",
},
exclude_unset=True,
)
line_dict["item_id"] = db_item.id
line_dict["tenant_id"] = tenant_id
line_dict["company_id"] = company_id
# Map schema field names to model field names
if "part_number_id" in line_dict:
line_dict["part_number"] = line_dict.pop("part_number_id")
if "component_part_number_id" in line_dict:
line_dict["component_part_number"] = line_dict.pop("component_part_number_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["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["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["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
)
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["item_line_id"] = db_line.id
db.add(LineReference(**reference_dict))
# Create FA data if provided
if fa_data is not None:
fa_dict = fa_data.model_dump(
exclude_unset=True, exclude={"line_item_id"}
)
fa_dict["id"] = db_line.id # FA table uses same ID as line item
fa_dict["tenant_id"] = tenant_id
fa_dict["company_id"] = company_id
db.add(FaLineItem(**fa_dict))
# Renumber all lines for this invoice to ensure consecutive numbering
ItemService._renumber_all_invoice_lines(db, db_item.invoice_id)
db.commit()
db.refresh(db_item)
@@ -582,7 +482,20 @@ class ItemService:
if not db_item:
return False
invoice_id = db_item.invoice_id
errors = ErrorCollector()
# Lock the invoice
if not ItemService._lock_invoice(
db, invoice_id, tenant_id, company_id, errors
):
raise HTTPException(
status_code=404, detail="Invoice not found or could not be locked"
)
db.delete(db_item)
db.flush()
ItemService._renumber_all_invoice_lines(db, invoice_id)
db.commit()
return True

View File

@@ -0,0 +1,124 @@
from typing import List
from sqlalchemy.orm import Session
# Import Reference Data Models
from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode
from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection
from api.v1.modules.public.reference_data.code_pedimento_regimens.models import CodePedimentoRegimen
# Import A76 Services
from api.v1.modules.a76.customs_brokers.services import CustomsBrokerService
from api.v1.modules.a76.clients_and_providers.service import ClientProviderService
# Import DTOs for mapping
from api.v1.modules.public.reference_data.pedimento_codes.dto import PedimentoCodeDTO
from api.v1.modules.public.reference_data.customs_sections.dto import CustomsSectionDTO
from api.v1.modules.public.reference_data.code_pedimento_regimens.dto import CodePedimentoRegimenDTO
from api.v1.modules.a76.customs_brokers.dto import CustomsBrokerResponseDTO
from api.v1.modules.a76.clients_and_providers.dto import ClientProviderResponseDTO
from .dtos.pedimentos import PedimentosResponse
from .schemas import PedimentoCatalogsResponse, PedimentoCreationResponse, PedimentoEditionResponse
class PedimentoCatalogService:
"""Service to fetch consolidated catalogs for Pedimento views"""
@staticmethod
def get_catalogs(db: Session, tenant_id: int, company_id: int) -> PedimentoCatalogsResponse:
"""Fetch all catalogs with graceful degradation"""
response = PedimentoCatalogsResponse()
# Helper to fetch reference data (no company_id needed)
def fetch_ref_data():
try:
response.pedimento_codes = [
PedimentoCodeDTO.model_validate(obj) for obj in db.query(PedimentoCode).limit(100).all()
]
except Exception as e:
print(f"Error fetching pedimento_codes: {e}")
try:
response.customs_sections = [
CustomsSectionDTO.model_validate(obj) for obj in db.query(CustomsSection).limit(100).all()
]
except Exception as e:
print(f"Error fetching customs_sections: {e}")
try:
response.code_pedimento_regimens = [
CodePedimentoRegimenDTO.model_validate(obj) for obj in db.query(CodePedimentoRegimen).limit(100).all()
]
except Exception as e:
print(f"Error fetching code_pedimento_regimens: {e}")
# Helper to fetch tenant/company specific data
def fetch_tenant_data():
# Customs Brokers
try:
brokers, _ = CustomsBrokerService.get_all(db, tenant_id, company_id, limit=1000)
response.customs_brokers = [
CustomsBrokerResponseDTO.model_validate(obj) for obj in brokers
]
except Exception as e:
print(f"Error fetching customs brokers: {e}")
# Clients (only clients, not providers)
try:
all_cps, _ = ClientProviderService.get_all(
db, tenant_id, company_id, limit=1000
)
# Helper to safely check client type (handles string or Enum)
def is_type(obj, types):
val = obj.client_or_provider
# If it's an enum, get its value, otherwise use as string
val_str = val.value if hasattr(val, 'value') else str(val)
return val_str in types
response.clients = [
ClientProviderResponseDTO.model_validate(obj) for obj in all_cps
if is_type(obj, ['client', 'both'])
]
except Exception as e:
print(f"Error fetching clients: {e}")
try:
fetch_ref_data()
fetch_tenant_data()
except Exception as e:
print(f"Error fetching catalogs: {e}")
import traceback
traceback.print_exc()
raise e
return response
@staticmethod
def get_creation_data(db: Session, tenant_id: int, company_id: int) -> PedimentoCreationResponse:
catalogs = PedimentoCatalogService.get_catalogs(db, tenant_id, company_id)
return PedimentoCreationResponse(
**catalogs.model_dump(),
is_create=True
)
@staticmethod
def get_edition_data(db: Session, pedimento_id: int, tenant_id: int, company_id: int) -> PedimentoEditionResponse:
catalogs = PedimentoCatalogService.get_catalogs(db, tenant_id, company_id)
from .services.pedimentos import PedimentosService
pedimento = PedimentosService.get_by_id(db, pedimento_id, tenant_id, company_id)
if not pedimento:
return None
# Convert SQLAlchemy object to Pydantic DTO
pedimento_dto = PedimentosResponse.model_validate(pedimento)
return PedimentoEditionResponse(
**catalogs.model_dump(),
is_create=False,
pedimento=pedimento_dto,
pedimento_id=pedimento_id
)

View File

@@ -2,13 +2,76 @@
Routes for Pedimentos CRUD operations
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from core.database import get_core_db
from core.security import get_current_user
from ..dtos.pedimentos import PedimentosCreate, PedimentosResponse, PedimentosUpdate
from ..services.pedimentos import PedimentosService
from ..catalog_service import PedimentoCatalogService
from ..schemas import PedimentoCreationResponse, PedimentoEditionResponse
# Create router with generic CRUD routes
router = TenantCRUDRoutes(
# Create a new router for custom endpoints
router = APIRouter()
# Add consolidated catalog endpoints FIRST (before generic CRUD routes)
# This ensures they have priority over the generic /{id} route
@router.get("/creation-data", response_model=PedimentoCreationResponse, tags=["a76 / pedimentos"])
async def get_creation_data(
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Get all catalogs needed for creating a new pedimento.
Consolidates multiple catalog calls into a single endpoint.
"""
tenant_id = current_user["tenant_id"]
try:
return PedimentoCatalogService.get_creation_data(db, tenant_id, company_id)
except Exception as e:
print(f"Error fetching creation data: {e}")
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail="Error fetching creation data")
@router.get("/{pedimento_id}/edition-data", response_model=PedimentoEditionResponse, tags=["a76 / pedimentos"])
async def get_edition_data(
pedimento_id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Get all catalogs and pedimento data needed for editing an existing pedimento.
Consolidates multiple catalog calls + pedimento fetch into a single endpoint.
"""
tenant_id = current_user["tenant_id"]
try:
result = PedimentoCatalogService.get_edition_data(db, pedimento_id, tenant_id, company_id)
if result is None:
raise HTTPException(status_code=404, detail="Pedimento not found")
return result
except HTTPException:
raise
except Exception as e:
print(f"Error fetching edition data: {e}")
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail="Error fetching edition data")
# Now include generic CRUD routes
# These will be registered AFTER the custom endpoints above
crud_router = TenantCRUDRoutes(
service=PedimentosService,
create_schema=PedimentosCreate,
update_schema=PedimentosUpdate,
@@ -22,3 +85,6 @@ router = TenantCRUDRoutes(
default_page_size=50,
max_page_size=100,
).router
# Include the CRUD routes into our main router
router.include_router(crud_router)

View File

@@ -0,0 +1,38 @@
"""
Consolidated schemas for Pedimento catalog responses
"""
from typing import List, Optional
from pydantic import BaseModel
# Import DTOs for catalog items
from api.v1.modules.public.reference_data.pedimento_codes.dto import PedimentoCodeDTO
from api.v1.modules.public.reference_data.customs_sections.dto import CustomsSectionDTO
from api.v1.modules.public.reference_data.code_pedimento_regimens.dto import CodePedimentoRegimenDTO
from api.v1.modules.a76.customs_brokers.dto import CustomsBrokerResponseDTO
from api.v1.modules.a76.clients_and_providers.dto import ClientProviderResponseDTO
from .dtos.pedimentos import PedimentosResponse
class PedimentoCatalogsResponse(BaseModel):
"""Base response containing all catalogs needed for pedimento views"""
pedimento_codes: List[PedimentoCodeDTO] = []
customs_sections: List[CustomsSectionDTO] = []
code_pedimento_regimens: List[CodePedimentoRegimenDTO] = []
customs_brokers: List[CustomsBrokerResponseDTO] = []
clients: List[ClientProviderResponseDTO] = []
class PedimentoCreationResponse(PedimentoCatalogsResponse):
"""Response for creating a new pedimento (catalogs only)"""
is_create: bool = True
class PedimentoEditionResponse(PedimentoCatalogsResponse):
"""Response for editing an existing pedimento (catalogs + pedimento data)"""
is_create: bool = False
pedimento: Optional[PedimentosResponse] = None
pedimento_id: Optional[int] = None

View File

@@ -17,7 +17,7 @@ from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
from api.v1.modules.a76.pedmientos.models.pedimento_validation import PedimentoValidation
from api.v1.modules.a76.customs_brokers.models import CustomsBroker, CustomsBrokerPersonnel
from api.v1.modules.a76.items.models import Item
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.clients_and_providers.models import ClientProvider, ClientProviderAddress, ClientProviderPrograms
# --- SCHEMAS FOR TEMPLATE CONTEXT ---
@@ -186,7 +186,7 @@ class AvisoConsolidadoExportacionService:
logistics = header.logistics
# Fetch Items associated with this invoice (MOVED UP FOR WEIGHT CALCULATION)
items = db.query(Item).filter(Item.invoice_id == invoice_id).all()
items = db.query(LineItem).filter(LineItem.invoice_id == invoice_id).all()
# Peso Bruto
peso_bruto_val = "0.0"
@@ -194,14 +194,12 @@ class AvisoConsolidadoExportacionService:
# Calculate sum from items first
if items:
for item in items:
if item.lines:
for line in item.lines:
if line.quantity and line.quantity.gross_weight:
try:
calculated_gross_weight += float(line.quantity.gross_weight)
except (ValueError, TypeError):
pass
for item in items:
if item.quantity and item.quantity.gross_weight:
try:
calculated_gross_weight += float(item.quantity.gross_weight)
except (ValueError, TypeError):
pass
if financials and financials.gross_weight and float(financials.gross_weight) > 0:
peso_bruto_val = f"{financials.gross_weight:,.2f}"
@@ -379,20 +377,19 @@ class AvisoConsolidadoExportacionService:
cant_total = 0.0
if items:
for item in items:
if item.lines:
for line in item.lines:
# Priority: Quantity (UMA or Standard)
q = 0.0
if line.quantity:
try:
if line.quantity.quantity_uma is not None:
q = float(line.quantity.quantity_uma)
elif line.quantity.quantity is not None:
q = float(line.quantity.quantity)
except (ValueError, TypeError):
q = 0.0
cant_total += q
for item in items:
for line in item:
# Priority: Quantity (UMA or Standard)
q = 0.0
if line.quantity:
try:
if line.quantity.quantity_uma is not None:
q = float(line.quantity.quantity_uma)
elif line.quantity.quantity is not None:
q = float(line.quantity.quantity)
except (ValueError, TypeError):
q = 0.0
cant_total += q
# Format: 15 chars, 3 decimals? Actually Clarion LINEPRINT usually just prints the text.
# Clarion 'CLIP(FORMAT(Loc:CantTotal,@n015.3))' removes spaces.

View File

@@ -15,10 +15,9 @@ from datetime import datetime
# --- MODELOS (Imported from system for Header info) ---
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceComplianceMx
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.items.line_items.models import LineItem
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.items.models import Item
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
from api.v1.modules.a76.pedmientos.models.pedimento_dates import PedimentoDates
from api.v1.modules.core.tenants.models import Tenant
@@ -40,8 +39,8 @@ class FIFOAssignmentService:
Returns a list of calculated discharges.
"""
# 1. Get Export Lines
export_lines = db.query(LineItem).join(Item).filter(
Item.invoice_id == invoice_id
export_lines = db.query(LineItem).join(LineItem).filter(
LineItem.invoice_id == invoice_id
).options(
joinedload(LineItem.quantity),
joinedload(LineItem.description),
@@ -58,7 +57,7 @@ class FIFOAssignmentService:
if qty_needed <= 0:
continue
part_number = exp_line.part_number
part_number = exp_line.part_number_id
if not part_number:
self._log(f"Skipping line {exp_line.id}, no part number")
continue
@@ -79,10 +78,10 @@ class FIFOAssignmentService:
# 2. Find Import Candidates (FIFO order by payment date)
# Use outerjoin for pedimento dates to avoid filtering out candidates with missing dates
candidates = db.query(LineItem).join(Item).join(InvoiceHeader)\
candidates = db.query(LineItem).join(InvoiceHeader)\
.join(InvoiceComplianceMx).join(InvoiceComplianceMx.pedimento).outerjoin(Pedimentos.pedimento_dates)\
.filter(
LineItem.part_number == part_number,
LineItem.part_number_id == part_number,
InvoiceHeader.operation_type == 'imp', # Assuming 'imp' is the value for Import based on Enum
).order_by(
PedimentoDates.payment_date.asc()
@@ -90,7 +89,7 @@ class FIFOAssignmentService:
joinedload(LineItem.quantity),
joinedload(LineItem.customs),
joinedload(LineItem.financial),
joinedload(LineItem.item).joinedload(Item.invoice).joinedload(InvoiceHeader.compliance_mx).joinedload(InvoiceComplianceMx.pedimento).joinedload(Pedimentos.pedimento_dates)
joinedload(LineItem.item).joinedload(LineItem.invoice).joinedload(InvoiceHeader.compliance_mx).joinedload(InvoiceComplianceMx.pedimento).joinedload(Pedimentos.pedimento_dates)
).all()
self._log(f"Found {len(candidates)} candidates for {part_number}")
@@ -259,16 +258,15 @@ class DescargaReportService:
# --- 2. Obtener Líneas de Exportación (Lo que necesitamos cubrir) ---
if progress_callback: progress_callback(20, "Obteniendo items a exportar...")
export_lines = db.query(LineItem).filter(
LineItem.item_id == Item.id,
Item.invoice_id == invoice_id
export_lines = db.query(LineItem).filter(
LineItem.invoice_id == invoice_id
).options(
joinedload(LineItem.quantity),
joinedload(LineItem.customs),
joinedload(LineItem.description),
joinedload(LineItem.unit_of_measure_info),
joinedload(LineItem.part_info)
).join(Item).all()
).join(LineItem).all()
items_reporte = []

View File

@@ -17,7 +17,7 @@ from api.v1.modules.a76.invoices.models import (
)
from api.v1.modules.a76.items.line_financials.models import LineFinancial
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
from api.v1.modules.a76.items.line_items.models import LineItem
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.clients_and_providers.models import (
ClientProvider,
ClientProviderAddress,
@@ -27,7 +27,7 @@ from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.pedmientos.models import Pedimentos
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
from api.v1.modules.a76.items.models import Item
from api.v1.modules.a76.items.models import LineItem
# --- TRANSPORTATION MODELS ---
from api.v1.modules.a76.transportation.transporters.models import Transporter
@@ -36,7 +36,7 @@ from api.v1.modules.a76.transportation.trailers.models import Trailer
from api.v1.modules.a76.transportation.drivers.models import Driver
# --- MODELO DE FRACCIONES ---
from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction
# --- MODELO DE UNIDADES DE MEDIDA ---
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
@@ -50,6 +50,10 @@ from .schemas import (
FacturaImportacionCompleta,
)
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
USTariffFraction,
)
class ConsolidadoImportacionMexService:
def __init__(self):
@@ -478,8 +482,7 @@ class ConsolidadoImportacionMexService:
lines = (
db.query(LineItem)
.join(Item, LineItem.item_id == Item.id)
.filter(Item.invoice_id.in_(target_invoice_ids))
.filter(LineItem.invoice_id.in_(target_invoice_ids))
.all()
)
@@ -515,11 +518,7 @@ class ConsolidadoImportacionMexService:
.filter(InvoiceHeader.id.in_(target_invoice_ids))
.all()
)
invoice_map = {inv.id: inv for inv in invoices_list}
from api.v1.modules.a76.general_catalogs.us_tariff_fractions.models import (
USTariffFraction,
)
invoice_map = {inv.id: inv for inv in invoices_list}
for line in lines:
qty = (
@@ -532,7 +531,7 @@ class ConsolidadoImportacionMexService:
.filter(LineFinancial.item_line_id == line.id)
.first()
)
part_master = db.query(Part).filter(Part.id == line.part_number).first()
part_master = db.query(Part).filter(Part.id == line.part_number_id).first()
# --- Resolver Identificadores (MOVED INSIDE MAIN LOOP) ---
us_fraction_raw = ""
@@ -559,7 +558,7 @@ class ConsolidadoImportacionMexService:
# --- Multi-Currency Normalization Logic ---
# Determine Line Currency context
# Use manual lookup instead of specific attribute
invoice_id = line.item.invoice_id if line.item else None
invoice_id = line.invoice_id
line_invoice = invoice_map.get(invoice_id) if invoice_id else None
line_currency_is_mxn = False

View File

@@ -13,7 +13,6 @@ from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics, InvoiceComplianceMx
from api.v1.modules.a76.items.line_financials.models import LineFinancial
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
from api.v1.modules.a76.items.line_items.models import LineItem
from api.v1.modules.a76.clients_and_providers.models import (
ClientProvider, ClientProviderAddress, ClientProviderPrograms
)
@@ -21,7 +20,7 @@ from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.pedmientos.models import Pedimentos
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
from api.v1.modules.a76.items.models import Item
from api.v1.modules.a76.items.models import LineItem
# --- TRANSPORTATION MODELS ---
from api.v1.modules.a76.transportation.transporters.models import Transporter
@@ -30,7 +29,7 @@ from api.v1.modules.a76.transportation.trailers.models import Trailer
from api.v1.modules.a76.transportation.drivers.models import Driver
# --- MODELO DE FRACCIONES ---
from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction
# --- SCHEMAS ---
from ...mex.schemas import (
@@ -314,8 +313,8 @@ class ConsolidadoImportacionMexService:
# NOT consolidating all invoices from the same Pedimento.
target_invoice_ids = [header.id]
lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter(
Item.invoice_id.in_(target_invoice_ids)
lines = db.query(LineItem).filter(
LineItem.invoice_id.in_(target_invoice_ids)
).all()
partidas_list = []
@@ -345,12 +344,12 @@ class ConsolidadoImportacionMexService:
invoices_list = db.query(InvoiceHeader).filter(InvoiceHeader.id.in_(target_invoice_ids)).all()
invoice_map = {inv.id: inv for inv in invoices_list}
from api.v1.modules.a76.general_catalogs.us_tariff_fractions.models import USTariffFraction
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import USTariffFraction
for line in lines:
qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first()
fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == line.id).first()
part_master = db.query(Part).filter(Part.id == line.part_number).first()
part_master = db.query(Part).filter(Part.id == line.part_number_id).first()
# --- Resolver Identificadores (MOVED INSIDE MAIN LOOP) ---
us_fraction_raw = ""
@@ -371,7 +370,7 @@ class ConsolidadoImportacionMexService:
# --- Multi-Currency Normalization Logic ---
# Determine Line Currency context
# Use manual lookup instead of specific attribute
invoice_id = line.item.invoice_id if line.item else None
invoice_id = line.invoice_id
line_invoice = invoice_map.get(invoice_id) if invoice_id else None
line_currency_is_mxn = False

View File

@@ -13,7 +13,6 @@ from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics
from api.v1.modules.a76.items.line_financials.models import LineFinancial
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
from api.v1.modules.a76.items.line_items.models import LineItem
from api.v1.modules.a76.clients_and_providers.models import (
ClientProvider,
ClientProviderAddress,
@@ -23,7 +22,7 @@ from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.pedmientos.models import Pedimentos
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
from api.v1.modules.a76.items.models import Item
from api.v1.modules.a76.items.models import LineItem
# --- TRANSPORTATION MODELS ---
from api.v1.modules.a76.transportation.transporters.models import Transporter
@@ -32,10 +31,11 @@ from api.v1.modules.a76.transportation.trailers.models import Trailer
from api.v1.modules.a76.transportation.drivers.models import Driver
# --- MODELO DE FRACCIONES ---
from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction
# --- MODELO DE UNIDADES DE MEDIDA ---
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
from api.v1.modules.a76.general_catalogs.packages.models import Package
# --- SCHEMAS ---
from .schemas import (
@@ -422,9 +422,8 @@ class FacturaImportacionMexService:
if progress_callback:
progress_callback(50, "Procesando partidas...")
lines = (
db.query(LineItem)
.join(Item, LineItem.item_id == Item.id)
.filter(Item.invoice_id == header.id)
db.query(LineItem)
.filter(LineItem.invoice_id == header.id)
.all()
)
partidas_list = []
@@ -440,10 +439,10 @@ class FacturaImportacionMexService:
.filter(LineFinancial.item_line_id == line.id)
.first()
)
part_master = db.query(Part).filter(Part.id == line.part_number).first()
part_master = db.query(Part).filter(Part.id == line.part_number_id).first()
desc_final = "S/D"
num_parte_final = str(line.part_number or "S/N")
num_parte_final = str(line.part_number_id or "S/N")
fraccion_raw = ""
origen_final = "MEX"
@@ -533,7 +532,7 @@ class FacturaImportacionMexService:
if uom:
unidad_desc = uom.description or uom.code
else:
unidad_desc = ""
unidad_desc = ""
partidas_list.append(
PartidaSchema(
@@ -552,7 +551,7 @@ class FacturaImportacionMexService:
if qty and qty.package_quantity
else 0
),
clave_bultos=(qty.package_key or "") if qty else "",
clave_bultos=(qty.package_info.key if (qty and qty.package_info) else ""),
peso_neto=self.formatear_numero(qty.net_weight if qty else 0),
peso_bruto=self.formatear_numero(
qty.gross_weight if qty else 0

View File

@@ -13,7 +13,6 @@ from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics
from api.v1.modules.a76.items.line_financials.models import LineFinancial
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
from api.v1.modules.a76.items.line_items.models import LineItem
from api.v1.modules.a76.clients_and_providers.models import (
ClientProvider, ClientProviderAddress, ClientProviderPrograms
)
@@ -21,7 +20,7 @@ from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.pedmientos.models import Pedimentos
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
from api.v1.modules.a76.items.models import Item
from api.v1.modules.a76.items.models import LineItem
# --- TRANSPORTATION MODELS ---
from api.v1.modules.a76.transportation.transporters.models import Transporter
@@ -30,7 +29,7 @@ from api.v1.modules.a76.transportation.trailers.models import Trailer
from api.v1.modules.a76.transportation.drivers.models import Driver
# --- MODELO DE FRACCIONES ---
from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction
# --- SCHEMAS ---
from ...mex.schemas import (
@@ -227,16 +226,16 @@ class FacturaImportacionMexService:
)
if progress_callback: progress_callback(50, "Procesando partidas...")
lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter(Item.invoice_id == header.id).all()
lines = db.query(LineItem).filter(LineItem.invoice_id == header.id).all()
partidas_list = []
for line in lines:
qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first()
fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == line.id).first()
part_master = db.query(Part).filter(Part.id == line.part_number).first()
part_master = db.query(Part).filter(Part.id == line.part_number_id).first()
desc_final = "S/D"
num_parte_final = str(line.part_number or "S/N")
num_parte_final = str(line.part_number_id or "S/N")
fraccion_raw = ""
origen_final = "MEX"
@@ -316,7 +315,7 @@ class FacturaImportacionMexService:
cantidad_importacion=self.formatear_numero(qty.quantity if qty else 0),
unidad_medida=qty.weight_unit if qty else "PZA",
cantidad_bultos=int(qty.package_quantity) if qty and qty.package_quantity else 0,
clave_bultos=(qty.package_key or "") if qty else "",
clave_bultos=(qty.package_info.key if (qty and qty.package_info) else ""),
peso_neto=self.formatear_numero(qty.net_weight if qty else 0),
peso_bruto=self.formatear_numero(qty.gross_weight if qty else 0),
valor_costo_unitario=self.formatear_numero(v_unitario),

View File

@@ -10,18 +10,19 @@ from fastapi import HTTPException
from sqlalchemy.orm import Session
# --- MODELOS ---
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics
from api.v1.modules.a76.items.line_financials.models import LineFinancial
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
from api.v1.modules.a76.items.line_items.models import LineItem
from api.v1.modules.a76.clients_and_providers.models import (
ClientProvider, ClientProviderAddress, ClientProviderPrograms
ClientProvider,
ClientProviderAddress,
ClientProviderPrograms,
)
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.pedmientos.models import Pedimentos
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
from api.v1.modules.a76.items.models import Item
from api.v1.modules.a76.items.models import LineItem
# --- TRANSPORTATION MODELS ---
from api.v1.modules.a76.transportation.transporters.models import Transporter
@@ -30,32 +31,38 @@ from api.v1.modules.a76.transportation.trailers.models import Trailer
from api.v1.modules.a76.transportation.drivers.models import Driver
# --- MODELO DE FRACCIONES ---
from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import (
TariffFraction,
)
# --- SCHEMAS ---
# Reuse schemas from neighbor package as they fit the same data structure
from ..mex.schemas import (
ClienteSchema, PartidaSchema, TotalesSchema,
FacturaSchema, FacturaImportacionCompleta
ClienteSchema,
PartidaSchema,
TotalesSchema,
FacturaSchema,
FacturaImportacionCompleta,
)
class FacturaImportacionUsaService:
def __init__(self):
self.template_dir = Path(__file__).parent.parent / "templates"
self.jinja_env = Environment(
loader=FileSystemLoader(self.template_dir),
autoescape=select_autoescape(['html', 'xml'])
autoescape=select_autoescape(["html", "xml"]),
)
self.template = self.jinja_env.get_template('factura_usa_ver.html')
self.template = self.jinja_env.get_template("factura_usa_ver.html")
def _get_document_title(self, invoice_type: str, is_american: bool = True) -> str:
"""
Determina el título del documento basado en el tipo de factura.
Args:
invoice_type: Tipo de factura (TEM, DEF, MEX, CR)
is_american: Si es factura americana (True) o mexicana (False)
Returns:
Título formateado para la factura
"""
@@ -66,7 +73,7 @@ class FacturaImportacionUsaService:
"TEM": "Importación Temporal",
"CR": "Importación de Cambio de Régimen",
}
# Mapeo para facturas americanas
american_titles = {
"MEX": "Mexican Purchases Import Invoice",
@@ -74,18 +81,18 @@ class FacturaImportacionUsaService:
"TEM": "Temporary Importation",
"CR": "Regime Change Importation",
}
# Seleccionar el mapa correcto
titles = american_titles if is_american else mexican_titles
# Obtener el título (normalizar a mayúsculas)
invoice_type_upper = invoice_type.upper() if invoice_type else ""
title = titles.get(invoice_type_upper, "")
# Fallback a genéricos si no se encuentra
if not title:
return "Commercial Invoice" if is_american else "Factura de Importación"
return title
def _get_wkhtmltopdf_config(self):
@@ -95,28 +102,49 @@ class FacturaImportacionUsaService:
return pdfkit.configuration(wkhtmltopdf=path)
def formatear_numero(self, valor, decimales: int = 2):
if valor is None: return 0.0
if valor is None:
return 0.0
try:
return round(float(valor), decimales)
except: return 0.0
except:
return 0.0
def _format_fraccion_fallback(self, fraccion_raw: str) -> str:
if not fraccion_raw or len(fraccion_raw) < 8:
return fraccion_raw
return f"{fraccion_raw[:4]}.{fraccion_raw[4:6]}.{fraccion_raw[6:]}"
def _obtener_datos_cliente(self, db: Session, client_id: int, rol: str) -> ClienteSchema:
def _obtener_datos_cliente(
self, db: Session, client_id: int, rol: str
) -> ClienteSchema:
main = db.query(ClientProvider).filter(ClientProvider.id == client_id).first()
if not main:
return ClienteSchema(header=rol, nombre="Unknown", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="USA")
addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == client_id).first()
prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == client_id).first()
return ClienteSchema(
header=rol,
nombre="Unknown",
direccion="",
tax_id="",
codigo_postal="",
ciudad="",
estado="",
pais="USA",
)
addr = (
db.query(ClientProviderAddress)
.filter(ClientProviderAddress.client_id == client_id)
.first()
)
prog = (
db.query(ClientProviderPrograms)
.filter(ClientProviderPrograms.client_id == client_id)
.first()
)
return ClienteSchema(
header=rol,
nombre=(main.name or main.short_name) or "N/A",
direccion=(addr.streets or "") if addr else "",
direccion=(addr.streets or "") if addr else "",
num_exterior=(addr.exterior_number or "") if addr else "",
num_interior=(addr.interior_number or "") if addr else "",
colonia=(addr.neighborhood or "") if addr else "",
@@ -124,58 +152,128 @@ class FacturaImportacionUsaService:
ciudad=(addr.city or "") if addr else "",
estado=(addr.state or "") if addr else "",
pais=(addr.country or "USA") if addr else "USA",
tax_id=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""),
programa="IMMEX" if (prog and prog.program) else "",
autorizacion=prog.program_number if prog else "",
prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "",
reg_emp=prog.val_certified_company_registry if (prog and hasattr(prog, 'val_certified_company_registry')) else (
prog.certified_company_registry if (prog and prog.certified_company_registry) else ""
tax_id=(
prog.tax_id
if (prog and prog.tax_id)
else (getattr(main, "rfc", "") or "")
),
programa="IMMEX" if (prog and prog.program) else "",
autorizacion=prog.program_number if prog else "",
prosec=(
prog.prosec_authorization
if (prog and prog.prosec and prog.prosec_authorization)
else ""
),
reg_emp=(
prog.val_certified_company_registry
if (prog and hasattr(prog, "val_certified_company_registry"))
else (
prog.certified_company_registry
if (prog and prog.certified_company_registry)
else ""
)
),
cert=(
prog.is_certified_company
if (prog and prog.is_certified_company)
else ""
),
cert=prog.is_certified_company if (prog and prog.is_certified_company) else ""
)
def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None, currency_code: str = 'ORIGINAL') -> FacturaImportacionCompleta:
def obtener_datos(
self,
db: Session,
invoice_id: int,
company_id: int,
progress_callback: Optional[Callable] = None,
currency_code: str = "ORIGINAL",
) -> FacturaImportacionCompleta:
try:
if progress_callback: progress_callback(10, "Searching invoice...")
header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first()
if not header: raise HTTPException(status_code=404, detail="Invoice not found")
if progress_callback:
progress_callback(10, "Searching invoice...")
header = (
db.query(InvoiceHeader)
.filter(
InvoiceHeader.id == invoice_id,
InvoiceHeader.company_id == company_id,
)
.first()
)
if not header:
raise HTTPException(status_code=404, detail="Invoice not found")
compliance = header.compliance_mx
compliance = header.compliance_mx
logistics = header.logistics if header.logistics else None
financials = header.financials if header.financials else None
if progress_callback: progress_callback(20, "Fetching entry data...")
pedimento_id = compliance.pedimento_id if (compliance and compliance.pedimento_id) else header.related_doc_id
pedimento = db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first() if pedimento_id else None
if progress_callback: progress_callback(30, "Fetching client and supplier...")
if progress_callback:
progress_callback(20, "Fetching entry data...")
pedimento_id = (
compliance.pedimento_id
if (compliance and compliance.pedimento_id)
else header.related_doc_id
)
pedimento = (
db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first()
if pedimento_id
else None
)
if progress_callback:
progress_callback(30, "Fetching client and supplier...")
proveedor_id = compliance.provider_id if compliance else None
cliente_proveedor = self._obtener_datos_cliente(db, proveedor_id, "Supplier:") if proveedor_id else ClienteSchema(header="Supplier", nombre="Unassigned", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="")
cliente_proveedor = (
self._obtener_datos_cliente(db, proveedor_id, "Supplier:")
if proveedor_id
else ClienteSchema(
header="Supplier",
nombre="Unassigned",
direccion="",
tax_id="",
codigo_postal="",
ciudad="",
estado="",
pais="",
)
)
nombre_agente = ""
if compliance and compliance.customs_broker_id:
broker = db.query(CustomsBroker).filter(CustomsBroker.id == compliance.customs_broker_id).first()
if broker: nombre_agente = broker.name
broker = (
db.query(CustomsBroker)
.filter(CustomsBroker.id == compliance.customs_broker_id)
.first()
)
if broker:
nombre_agente = broker.name
company = db.query(Company).filter(Company.id == header.company_id).first()
# Datos Default (Company/Importer)
cliente_default = ClienteSchema(
header="Importer / Consignee:",
nombre=getattr(company, 'name', "Local Company"),
nombre=getattr(company, "name", "Local Company"),
direccion="FISCAL ADDRESS",
num_exterior="", colonia="", codigo_postal="", ciudad="", estado="", pais="MEX",
tax_id=getattr(company, 'rfc', ""),
programa=getattr(company, 'program', "IMMEX"), autorizacion=getattr(company, 'program_number', "")
num_exterior="",
colonia="",
codigo_postal="",
ciudad="",
estado="",
pais="MEX",
tax_id=getattr(company, "rfc", ""),
programa=getattr(company, "program", "IMMEX"),
autorizacion=getattr(company, "program_number", ""),
)
# Left Side Logic (Sold To)
cliente_vendido = cliente_default
if compliance and compliance.sold_to_id:
# Force English header for American Invoice
clean_header = "Sold To:"
clean_header = "Sold To:"
# raw_header = compliance.sold_to_header or "SOLD_TO"
# clean_header = raw_header.replace("_", " ").title() + ":"
cliente_vendido = self._obtener_datos_cliente(db, compliance.sold_to_id, clean_header)
cliente_vendido = self._obtener_datos_cliente(
db, compliance.sold_to_id, clean_header
)
# Right Side Logic (Shipped To)
cliente_enviado = cliente_default
if compliance and compliance.shipped_to_id:
@@ -183,26 +281,37 @@ class FacturaImportacionUsaService:
clean_header_shipped = "Shipped To:"
# raw_header_shipped = compliance.shipped_to_header or "SHIPPED_TO"
# clean_header_shipped = raw_header_shipped.replace("_", " ").title() + ":"
# Fetch client data
cliente_enviado = self._obtener_datos_cliente(db, compliance.shipped_to_id, clean_header_shipped)
remesa_valor = str(compliance.remesa) if (compliance and compliance.remesa) else ""
acuse_valor = str(compliance.edocument) if (compliance and compliance.edocument) else "N/A"
# Fetch client data
cliente_enviado = self._obtener_datos_cliente(
db, compliance.shipped_to_id, clean_header_shipped
)
remesa_valor = (
str(compliance.remesa) if (compliance and compliance.remesa) else ""
)
acuse_valor = (
str(compliance.edocument)
if (compliance and compliance.edocument)
else "N/A"
)
patente_val = ""
if pedimento and pedimento.license:
patente_val = pedimento.license
elif 'broker' in locals() and broker and broker.license:
elif "broker" in locals() and broker and broker.license:
patente_val = broker.license
# --- Transport Data Fetching ---
transporte_txt = str(logistics.transport_type) if (logistics and logistics.transport_type) else ""
transporte_txt = (
str(logistics.transport_type)
if (logistics and logistics.transport_type)
else ""
)
num_transporte_val = (logistics.trailer_num or "") if logistics else ""
# Init values
placas_val = (logistics.license_plate or "") if logistics else "" # Plates
placas_val = (logistics.license_plate or "") if logistics else "" # Plates
placas_remolque_val = ""
transportista_val = (logistics.carrier_id or "") if logistics else ""
caat_val = ""
@@ -212,53 +321,89 @@ class FacturaImportacionUsaService:
if logistics:
# 1. Transporter (CAAT / SCAC)
if logistics.carrier_id:
transporter_obj = db.query(Transporter).filter(Transporter.transporter_key == logistics.carrier_id).first()
transporter_obj = (
db.query(Transporter)
.filter(Transporter.transporter_key == logistics.carrier_id)
.first()
)
if transporter_obj:
caat_val = transporter_obj.caat_code or ""
scac_val = transporter_obj.transport_code or "" # Mapping transport_code to SCAC
scac_val = (
transporter_obj.transport_code or ""
) # Mapping transport_code to SCAC
transportista_val = transporter_obj.name or logistics.carrier_id
# 2. Vehicle (Plates)
if logistics.transport_id:
veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.transport_id).first()
veh_obj = (
db.query(Vehicle)
.filter(Vehicle.vehicle_key == logistics.transport_id)
.first()
)
if veh_obj:
placas_val = veh_obj.plate_number or placas_val
elif logistics.vehicle_num:
veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.vehicle_num).first()
if veh_obj:
placas_val = veh_obj.plate_number or placas_val
placas_val = veh_obj.plate_number or placas_val
elif logistics.vehicle_num:
veh_obj = (
db.query(Vehicle)
.filter(Vehicle.vehicle_key == logistics.vehicle_num)
.first()
)
if veh_obj:
placas_val = veh_obj.plate_number or placas_val
# 3. Trailer
if logistics.trailer_num:
trl_obj = db.query(Trailer).filter(Trailer.trailer_number == logistics.trailer_num).first()
trl_obj = (
db.query(Trailer)
.filter(Trailer.trailer_number == logistics.trailer_num)
.first()
)
if trl_obj:
placas_remolque_val = trl_obj.plate_number or ""
# 4. Driver (License)
if logistics.carrier_id and logistics.driver_name:
drv_obj = db.query(Driver).filter(
Driver.transporter_key == logistics.carrier_id,
Driver.driver_name == logistics.driver_name
).first()
drv_obj = (
db.query(Driver)
.filter(
Driver.transporter_key == logistics.carrier_id,
Driver.driver_name == logistics.driver_name,
)
.first()
)
if drv_obj:
licencia_cond_val = drv_obj.license_number or ""
licencia_cond_val = drv_obj.license_number or ""
# Determine Currency
moneda_final = getattr(header, 'currency', "USD") or "USD"
if currency_code == 'MXN':
moneda_final = 'MXN'
elif currency_code == 'USD':
moneda_final = 'USD'
moneda_final = getattr(header, "currency", "USD") or "USD"
if currency_code == "MXN":
moneda_final = "MXN"
elif currency_code == "USD":
moneda_final = "USD"
factura_schema = FacturaSchema(
numero=header.invoice_number or "N/A",
titulo_documento=self._get_document_title(header.invoice_type or "", is_american=True),
titulo_documento=self._get_document_title(
header.invoice_type or "", is_american=True
),
fecha=str(header.invoice_date) if header.invoice_date else "",
tipo_cambio=float(financials.exchange_rate) if (financials and financials.exchange_rate) else (float(pedimento.exchange_rate) if pedimento and pedimento.exchange_rate else 1.0),
tipo_cambio=(
float(financials.exchange_rate)
if (financials and financials.exchange_rate)
else (
float(pedimento.exchange_rate)
if pedimento and pedimento.exchange_rate
else 1.0
)
),
moneda=moneda_final,
incoterm=(logistics.incoterm or "") if logistics else "",
observaciones=header.observation_es or header.observation_en or "",
pedimento=f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}" if pedimento else "",
pedimento=(
f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}"
if pedimento
else ""
),
clave_pedimento=pedimento.pedimento_code if pedimento else "",
regimen=header.document_type or "",
patente=patente_val,
@@ -271,62 +416,87 @@ class FacturaImportacionUsaService:
caat=caat_val,
scac=scac_val,
licencia_conductor=licencia_cond_val,
aduana=compliance.aduana if (compliance and compliance.aduana) else (pedimento.customs_office[:2] if (pedimento and pedimento.customs_office) else ""),
aduana=(
compliance.aduana
if (compliance and compliance.aduana)
else (
pedimento.customs_office[:2]
if (pedimento and pedimento.customs_office)
else ""
)
),
precinto=(logistics.seal_number or "") if logistics else "",
destino=(logistics.destination_goods or "") if logistics else "",
remesa=remesa_valor, acuse_electronico=acuse_valor
remesa=remesa_valor,
acuse_electronico=acuse_valor,
)
if progress_callback: progress_callback(50, "Processing items...")
lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter(Item.invoice_id == header.id).all()
if progress_callback:
progress_callback(50, "Processing items...")
lines = db.query(LineItem).filter(LineItem.invoice_id == header.id).all()
partidas_list = []
for line in lines:
qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first()
fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == line.id).first()
part_master = db.query(Part).filter(Part.id == line.part_number).first()
qty = (
db.query(LineQuantity)
.filter(LineQuantity.item_line_id == line.id)
.first()
)
fin = (
db.query(LineFinancial)
.filter(LineFinancial.item_line_id == line.id)
.first()
)
part_master = (
db.query(Part).filter(Part.id == line.part_number_id).first()
)
desc_final = "N/D"
num_parte_final = str(line.part_number or "N/A")
fraccion_raw = ""
num_parte_final = str(line.part_number_id or "N/A")
fraccion_raw = ""
origen_final = "MEX"
if part_master:
# Prefer English description if available, else Spanish
desc_final = part_master.description_english or part_master.description_spanish or "No Desc."
desc_final = (
part_master.description_english
or part_master.description_spanish
or "No Desc."
)
num_parte_final = part_master.part_number
# Prefer US Fraction (HTS) if available
fraccion_raw = part_master.us_fraction if part_master.us_fraction else ""
fraccion_raw = (
part_master.us_fraction if part_master.us_fraction else ""
)
if part_master.fa_data and part_master.fa_data.origin_country:
origen_final = part_master.fa_data.origin_country
# FRACTION LOGIC: Use US Fraction (us_fraction) if available, otherwise blank
fraccion_imprimir = ""
# Check part master US fraction
if part_master and part_master.us_fraction:
fraccion_imprimir = part_master.us_fraction.strip()
# Optional: Format if needed, but raw is usually fine for US HTS
# If valid US fraction logic requires looking up in DB, we could add that here.
# For now, per requirement: "Si no tiene, pues de queda en blanco"
# Default "General" and "0%" if no specific logic for US duties yet
preferencia_txt = "General"
preferencia_txt = "General"
advalorem_txt = "0%"
# Prioritize USD for American Invoice logic if available?
# Sticking to same logic as Mex for now but could prioritize USD columns.
# Actually, duplicate logic from mex service for now to ensure consistency.
v_unitario = 0.0
v_total = 0.0
if fin:
is_mxn = (factura_schema.moneda == 'MXN')
is_mxn = factura_schema.moneda == "MXN"
if is_mxn:
v_unitario = float(fin.unit_cost_commercial_mxn or 0.0)
v_total = float(fin.value_commercial_mxn or 0.0)
@@ -335,13 +505,13 @@ class FacturaImportacionUsaService:
v_total = float(fin.value_commercial_usd or 0.0)
if not v_unitario:
v_unitario = float(fin.commercial_unit_cost or 0.0)
v_unitario = float(fin.commercial_unit_cost or 0.0)
if not v_total:
v_total = float(fin.total_commercial_value or 0.0)
v_total = float(fin.total_commercial_value or 0.0)
cantidad = float(qty.quantity) if (qty and qty.quantity) else 0.0
if cantidad > 0:
if v_unitario > 0 and v_total == 0:
v_total = v_unitario * cantidad
@@ -349,39 +519,59 @@ class FacturaImportacionUsaService:
v_unitario = v_total / cantidad
# UOM Mapping for English context
uom_raw = qty.weight_unit if qty else "PCS"
if uom_raw == "PZA": uom_raw = "PCS"
uom_raw = line.unit_of_measure_info.code if line.unit_of_measure_info else "PCS"
if uom_raw == "PZA":
uom_raw = "PCS"
partidas_list.append(PartidaSchema(
numero_parte=num_parte_final,
descripcion=desc_final,
fraccion=fraccion_imprimir,
origen=origen_final,
advalorem=advalorem_txt,
preferencia=preferencia_txt,
cantidad_importacion=self.formatear_numero(qty.quantity if qty else 0),
unidad_medida=uom_raw,
cantidad_bultos=int(qty.package_quantity) if qty and qty.package_quantity else 0,
clave_bultos=(qty.package_key or "") if qty else "",
peso_neto=self.formatear_numero(qty.net_weight if qty else 0),
peso_bruto=self.formatear_numero(qty.gross_weight if qty else 0),
valor_costo_unitario=self.formatear_numero(v_unitario),
valor_total=self.formatear_numero(v_total)
))
partidas_list.append(
PartidaSchema(
numero_parte=num_parte_final,
descripcion=desc_final,
fraccion=fraccion_imprimir,
origen=origen_final,
advalorem=advalorem_txt,
preferencia=preferencia_txt,
cantidad_importacion=self.formatear_numero(
qty.quantity if qty else 0
),
unidad_medida=uom_raw,
cantidad_bultos=(
int(qty.package_quantity)
if qty and qty.package_quantity
else 0
),
clave_bultos=(
qty.package_info.key if (qty and qty.package_info) else ""
),
peso_neto=self.formatear_numero(qty.net_weight if qty else 0),
peso_bruto=self.formatear_numero(
qty.gross_weight if qty else 0
),
valor_costo_unitario=self.formatear_numero(v_unitario),
valor_total=self.formatear_numero(v_total),
)
)
totales = self.calcular_totales(partidas_list, Decimal(factura_schema.tipo_cambio))
totales = self.calcular_totales(
partidas_list, Decimal(factura_schema.tipo_cambio)
)
return FacturaImportacionCompleta(
cliente_proveedor=cliente_proveedor, cliente_vendido=cliente_vendido,
cliente_enviado=cliente_enviado, factura=factura_schema,
partidas=partidas_list, totales=totales
cliente_proveedor=cliente_proveedor,
cliente_vendido=cliente_vendido,
cliente_enviado=cliente_enviado,
factura=factura_schema,
partidas=partidas_list,
totales=totales,
)
except Exception as e:
print(f"Error Service A76 USA: {e}")
raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
def calcular_totales(self, partidas: List[PartidaSchema], tipo_cambio: Decimal) -> TotalesSchema:
def calcular_totales(
self, partidas: List[PartidaSchema], tipo_cambio: Decimal
) -> TotalesSchema:
cant = sum(p.cantidad_importacion for p in partidas)
valor = sum(p.valor_total for p in partidas)
peso_n = sum(p.peso_neto for p in partidas)
@@ -389,22 +579,38 @@ class FacturaImportacionUsaService:
bultos = sum(p.cantidad_bultos for p in partidas)
claves = [p.clave_bultos for p in partidas if p.clave_bultos]
clave_comun = max(set(claves), key=claves.count) if claves else ""
# if bultos > 1 and clave_comun and not clave_comun.endswith("S"): clave_comun += "S"
# if bultos > 1 and clave_comun and not clave_comun.endswith("S"): clave_comun += "S"
# Don't pluralize strictly in English without logic, kept simple.
tc = float(tipo_cambio) if tipo_cambio else 1.0
return TotalesSchema(
cantidad_total=self.formatear_numero(cant), bultos_total=bultos, clave_bultos=clave_comun,
peso_neto_total=self.formatear_numero(peso_n), peso_bruto_total=self.formatear_numero(peso_b),
valor_total_total=self.formatear_numero(valor), valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0)
return TotalesSchema(
cantidad_total=self.formatear_numero(cant),
bultos_total=bultos,
clave_bultos=clave_comun,
peso_neto_total=self.formatear_numero(peso_n),
peso_bruto_total=self.formatear_numero(peso_b),
valor_total_total=self.formatear_numero(valor),
valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0),
)
def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", progress_callback: Optional[Callable] = None, currency_code: str = 'ORIGINAL') -> Tuple[bytes, str, str]:
if progress_callback: progress_callback(5, "Starting report service...")
datos = self.obtener_datos(db, invoice_id, company_id, progress_callback, currency_code)
if progress_callback: progress_callback(80, "Rendering template...")
def generar_factura_completa(
self,
db: Session,
invoice_id: int,
company_id: int,
formato: str = "pdf",
progress_callback: Optional[Callable] = None,
currency_code: str = "ORIGINAL",
) -> Tuple[bytes, str, str]:
if progress_callback:
progress_callback(5, "Starting report service...")
datos = self.obtener_datos(
db, invoice_id, company_id, progress_callback, currency_code
)
if progress_callback:
progress_callback(80, "Rendering template...")
# LOGO LOGIC
logo_b64 = None
try:
@@ -419,26 +625,48 @@ class FacturaImportacionUsaService:
if target_path.exists():
with open(target_path, "rb") as image_file:
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
encoded_string = base64.b64encode(image_file.read()).decode(
"utf-8"
)
mime = "image/png"
if target_path.suffix.lower() in ['.jpg', '.jpeg']: mime = "image/jpeg"
if target_path.suffix.lower() in [".jpg", ".jpeg"]:
mime = "image/jpeg"
logo_b64 = f"data:{mime};base64,{encoded_string}"
except Exception as e:
print(f"Error loading logo: {e}")
context = {
'cliente_proveedor': datos.cliente_proveedor.model_dump(), 'cliente_vendido': datos.cliente_vendido.model_dump(),
'cliente_enviado': datos.cliente_enviado.model_dump(), 'factura': datos.factura.model_dump(),
'partidas': [p.model_dump() for p in datos.partidas], 'totales': datos.totales.model_dump(),
'logo_b64': logo_b64
"cliente_proveedor": datos.cliente_proveedor.model_dump(),
"cliente_vendido": datos.cliente_vendido.model_dump(),
"cliente_enviado": datos.cliente_enviado.model_dump(),
"factura": datos.factura.model_dump(),
"partidas": [p.model_dump() for p in datos.partidas],
"totales": datos.totales.model_dump(),
"logo_b64": logo_b64,
}
html_content = self.template.render(**context)
nombre = f"Commercial_Invoice_{datos.factura.numero}.{formato}"
if formato == "html": return html_content.encode('utf-8'), nombre, "text/html"
if progress_callback: progress_callback(90, "Generating PDF...")
options = {'page-size': 'Letter', 'margin-top': '0.5in', 'margin-right': '0.5in', 'margin-bottom': '0.5in', 'margin-left': '0.5in', 'encoding': "UTF-8", 'enable-local-file-access': None}
pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config())
if progress_callback: progress_callback(100, "Completed")
if formato == "html":
return html_content.encode("utf-8"), nombre, "text/html"
if progress_callback:
progress_callback(90, "Generating PDF...")
options = {
"page-size": "Letter",
"margin-top": "0.5in",
"margin-right": "0.5in",
"margin-bottom": "0.5in",
"margin-left": "0.5in",
"encoding": "UTF-8",
"enable-local-file-access": None,
}
pdf = pdfkit.from_string(
html_content,
False,
options=options,
configuration=self._get_wkhtmltopdf_config(),
)
if progress_callback:
progress_callback(100, "Completed")
return pdf, nombre, "application/pdf"

View File

@@ -13,7 +13,6 @@ from sqlalchemy.orm import Session
# --- MODELOS ---
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
from api.v1.modules.a76.items.line_items.models import LineItem
from api.v1.modules.a76.items.line_customs.models import LineCustom
from api.v1.modules.a76.clients_and_providers.models import (
ClientProvider, ClientProviderAddress, ClientProviderPrograms
@@ -22,7 +21,7 @@ from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.pedmientos.models import Pedimentos
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
from api.v1.modules.a76.items.models import Item
from api.v1.modules.a76.items.models import LineItem
# --- TRANSPORTATION MODELS ---
from api.v1.modules.a76.transportation.transporters.models import Transporter
@@ -31,7 +30,7 @@ from api.v1.modules.a76.transportation.trailers.models import Trailer
from api.v1.modules.a76.transportation.drivers.models import Driver
# --- MODELO DE FRACCIONES ---
from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction
# --- SCHEMAS ---
from .schemas import (
@@ -238,40 +237,40 @@ class PackingListService:
)
if progress_callback: progress_callback(50, "Procesando partidas...")
lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter(Item.invoice_id == header.id).all()
lines = db.query(LineItem).filter(LineItem.invoice_id == header.id).all()
partidas_list = []
for line in lines:
qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first()
weight_type = db.query(InvoiceLogistics.weight_type).filter(InvoiceLogistics.invoice_id == line.invoice_id).scalar()
qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first()
# --- WEIGHT CALCULATION LOGIC ---
peso_neto_kg = 0.0
peso_bruto_kg = 0.0
peso_neto_lb = 0.0
peso_bruto_lb = 0.0
peso_bruto_lb = 0.0
if qty:
raw_net = float(qty.net_weight or 0)
raw_gross = float(qty.gross_weight or 0)
unit = (qty.weight_unit or "KG").upper()
if unit == "LB" or unit == "LBS":
peso_neto_lb = raw_net
peso_bruto_lb = raw_gross
peso_neto_kg = raw_net / 2.20462
peso_bruto_kg = raw_gross / 2.20462
else: # Default KG
peso_neto_kg = raw_net
peso_bruto_kg = raw_gross
peso_neto_lb = raw_net * 2.20462
peso_bruto_lb = raw_gross * 2.20462
raw_net = float(qty.net_weight or 0)
raw_gross = float(qty.gross_weight or 0)
unit = (weight_type or "KGS").upper()
if unit == "LBS":
peso_neto_lb = raw_net
peso_bruto_lb = raw_gross
peso_neto_kg = raw_net / 2.20462
peso_bruto_kg = raw_gross / 2.20462
else: # Default KG
peso_neto_kg = raw_net
peso_bruto_kg = raw_gross
peso_neto_lb = raw_net * 2.20462
peso_bruto_lb = raw_gross * 2.20462
# --------------------------------
custom_obj = db.query(LineCustom).filter(LineCustom.item_line_id == line.id).first()
part_master = db.query(Part).filter(Part.id == line.part_number).first()
part_master = db.query(Part).filter(Part.id == line.part_number_id).first()
desc_final = "S/D"
num_parte_final = str(line.part_number or "S/N")
num_parte_final = str(line.part_number_id or "S/N")
fraccion_raw = ""
origen_final = "MEX"
uom_comercial = "PZA" # Default UOM

View File

@@ -8,43 +8,23 @@ from fastapi import APIRouter
from .customs_brokers.routes import router as customs_broker_router
# Importar routers de módulos
from .general_catalogs.router import router as general_catalogs_router
from .invoices.routes import router as invoices_router
from .items.routes import router as items_router
from .classes import router as classes_router
from .classes import router as classes_router
from .clients_and_providers import router as client_and_provider_router
from .imports.routes import router as imports_router
from .invoice_settings.routes import router as invoice_settings_router
from .item_presets.routes import router as item_presets_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
from .general_catalogs.seal.routes import router as seal_router
from .general_catalogs.units_of_measure.routes import router as units_of_measure_router
from .general_catalogs.concepts.routes import router as concepts_router
from .general_catalogs.customs_broker_concepts.routes import router as customs_broker_concepts_router
from .general_catalogs.classification_concepts.routes import router as classification_concepts_router
from .general_catalogs.unit_conversions.routes import router as unit_conversions_router
from .general_catalogs.equivalencies.routes import router as equivalencies_router
from .general_catalogs.multi_currency_types.routes import router as multi_currency_types_router
from .general_catalogs.inpc.routes import router as inpc_router
from .general_catalogs.legends.routes import router as legends_router
from .general_catalogs.signatures.routes import router as signatures_router
from .general_catalogs.error_catalogs.routes import router as error_catalogs_router
from .general_catalogs.doda.routes import router as doda_router
from .general_catalogs.prevalidators.routes import router as prevalidators_router
from .general_catalogs.electronic_notices.routes import router as electronic_notices_router
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
@@ -69,63 +49,26 @@ from .reports.importacion.transmission.definitive.MAINX30.routes import router a
router = APIRouter()
# Registrar módulos
router.include_router(general_catalogs_router, prefix="/a76", tags=["a76 / general_catalogs"])
router.include_router(invoices_router, prefix="/a76", tags=["a76 / invoices"])
router.include_router(items_router, prefix="/a76", tags=["a76 / items"])
router.include_router(imports_router, prefix="/a76/imports", tags=["a76 / imports"])
router.include_router(invoice_settings_router)
router.include_router(item_presets_router, prefix="/a76/item-presets", tags=["a76 / item_presets"])
router.include_router(pedimentos_router, prefix="/a76")
router.include_router(
client_and_provider_router, prefix="/a76", tags=["a76 / clients_and_providers"]
)
router.include_router(company_router, prefix="/a76", tags=["a76 / company"])
router.include_router(classes_router, prefix="/a76", tags=["a76 / classes"])
router.include_router(client_and_provider_router, prefix="/a76", tags=["a76 / clients_and_providers"])
router.include_router(classes_router, prefix="/a76/classes", tags=["a76 / classes"])
router.include_router(parts_router, prefix="/a76", tags=["a76 / parts"])
router.include_router(
permission_rule_oct_router, prefix="/a76", tags=["a76 / permission_rule_oct"]
)
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(
fraction_rule_octave_router, prefix="/a76", tags=["a76 / fraction_rule_octave"]
)
router.include_router(identifiers_router, prefix="/a76")
router.include_router(
country_rule_oct_router, prefix="/a76", tags=["a76 / country_rule_oct"]
)
router.include_router(exchange_rate_router, prefix="/a76",
tags=["a76 / exchange_rate"])
router.include_router(permission_rule_oct_router, prefix="/a76", tags=["a76 / permission_rule_oct"])
router.include_router(fraction_rule_octave_router, prefix="/a76", tags=["a76 / fraction_rule_octave"])
router.include_router(country_rule_oct_router, prefix="/a76", tags=["a76 / country_rule_oct"])
router.include_router(trailers_router, prefix="/a76/transportation", tags=["a76 / trailers"])
router.include_router(
customs_broker_router, prefix="/a76", tags=["a76 / customs_broker"]
)
router.include_router(customs_broker_router, prefix="/a76", tags=["a76 / customs_broker"])
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(transporters_router, prefix="/a76", tags=["a76 / transporters"])
router.include_router(vehicles_router, prefix="/a76/transportation", tags=["a76 / vehicles"])
# Registrar catálogos generales adicionales
router.include_router(concepts_router, prefix="/a76")
router.include_router(customs_broker_concepts_router, prefix="/a76")
router.include_router(classification_concepts_router, prefix="/a76")
router.include_router(unit_conversions_router, prefix="/a76")
router.include_router(equivalencies_router, prefix="/a76")
router.include_router(multi_currency_types_router, prefix="/a76")
router.include_router(inpc_router, prefix="/a76")
router.include_router(legends_router, prefix="/a76")
router.include_router(signatures_router, prefix="/a76")
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,

View File

@@ -12,7 +12,7 @@ from api.v1.modules.a76.invoices.models import (
InvoiceComplianceMx,
OperationType,
)
from api.v1.modules.a76.items.models import Item
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.clients_and_providers.models import (
ClientProvider,
ClientOrProviderEnum,
@@ -244,9 +244,9 @@ class DashboardService:
def get_items_metrics(self) -> KPIMetric:
"""Obtiene métricas de items/productos"""
total_items = (
self.db.query(func.count(Item.id))
self.db.query(func.count(LineItem.id))
.filter(
Item.tenant_id == self.tenant_id, Item.company_id == self.company_id
LineItem.tenant_id == self.tenant_id, LineItem.company_id == self.company_id
)
.scalar()
or 0

View File

@@ -4,6 +4,6 @@ from pydantic import BaseModel, ConfigDict, Field
class SectorDTO(BaseModel):
key: str = Field(..., min_length=1, max_length=8)
description: str
authorized: int
authorized: bool
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,5 +1,5 @@
from core.database import Base
from sqlalchemy import PrimaryKeyConstraint, SmallInteger, String
from sqlalchemy import Boolean, PrimaryKeyConstraint, SmallInteger, String
from sqlalchemy.orm import Mapped, mapped_column
@@ -15,9 +15,9 @@ class Sector(Base):
description: Mapped[str] = mapped_column(
String(150), nullable=False
) # descripción oficial (en español)
authorized: Mapped[SmallInteger] = mapped_column(
SmallInteger
) # 1 = autorizado, 0 = no autorizado
authorized: Mapped[bool] = mapped_column(
Boolean
) # True = autorizado, False = no autorizado
def __repr__(self):
return f"<Sector(key={self.key}, description={self.description}, authorized={self.authorized})>"

View File

@@ -1,8 +1,10 @@
from typing import Any, Dict
from typing import Any, Dict, Optional
from core.database import get_core_db
from core.security import get_current_user, has_role
from core.security import get_current_user
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import or_
from sqlalchemy.orm import Session
from .dto import SectorDTO
@@ -15,13 +17,25 @@ router = APIRouter(prefix="/sectors")
def list_sectors(
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"),
search: Optional[str] = Query(None, description="Término de búsqueda"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
skip = (page - 1) * page_size
query = db.query(Sector)
items = query.offset(skip).limit(page_size).all()
if search:
search_filter = or_(
Sector.key.ilike(f"%{search}%"),
Sector.description.ilike(f"%{search}%")
)
query = query.filter(search_filter)
total = query.count()
# Add deterministic sort order
query = query.order_by(Sector.key)
items = query.offset(skip).limit(page_size).all()
return {
"items": [SectorDTO.model_validate(obj) for obj in items],
"total": total,
@@ -40,47 +54,3 @@ def get_sector(
if not obj:
raise HTTPException(status_code=404, detail="Not found")
return obj
@router.post("/", response_model=SectorDTO, status_code=201)
def create_sector(
data: SectorDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(has_role("admin")),
):
obj = Sector(**data.dict())
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@router.put("/{key}", response_model=SectorDTO)
def update_sector(
key: str,
data: SectorDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(has_role("admin")),
):
obj = db.query(Sector).filter(Sector.key == key).first()
if not obj:
raise HTTPException(status_code=404, detail="Not found")
for field, value in data.dict().items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
@router.delete("/{key}", status_code=204)
def delete_sector(
key: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(has_role("admin")),
):
obj = db.query(Sector).filter(Sector.key == key).first()
if not obj:
raise HTTPException(status_code=404, detail="Not found")
db.delete(obj)
db.commit()
return None

View File

@@ -0,0 +1,320 @@
# SITAR API Module
Módulo reorganizado para integración con la API externa de SITAR (Sistema de Información de Aranceles).
## Estructura
Cada recurso ahora tiene su propia carpeta con separación de responsabilidades:
```
backend/api/v1/modules/sitar/
├── common/ # Servicio base compartido
│ ├── __init__.py
│ └── base_service.py # SitarAPIBaseService con autenticación
├── tlcs/ # TLCS (Tratados de Libre Comercio)
│ ├── __init__.py
│ ├── schemas.py # TLCSResponse
│ ├── service.py # TLCSService
│ └── router.py # Endpoints FastAPI
├── fracciones/ # Fracciones arancelarias mexicanas
├── fracciones_usa/ # Fracciones USA
├── regulaciones/ # Regulaciones y restricciones
├── prosec/ # PROSEC
├── precios_estimados/ # Precios estimados
├── fundamentos_tlc/ # Fundamentos TLC
├── aladi2/ # ALADI2
├── cuotas2/ # Cuotas compensatorias
├── cupos/ # Cupos de importación
├── fracciones_anteriores/ # Historial de fracciones
├── informacion_general/ # Información general
├── ieps/ # IEPS
├── noms/ # Normas Oficiales Mexicanas
├── rcg2/ # Reglas de Carácter General
├── reit/ # REIT
├── requisito_previo/ # Requisitos previos
├── vehiculos_marcas/ # Marcas de vehículos
├── vehiculos_modelos/ # Modelos de vehículos
├── __init__.py # Exporta todos los servicios
└── main_router.py # Router principal con todos los endpoints
```
## Uso del Servicio
### Desde cualquier parte del código
Cada recurso tiene su propio servicio **singleton** que maneja automáticamente la autenticación:
```python
from api.v1.modules.sitar.tlcs import TLCSService
from api.v1.modules.sitar.fracciones import FraccionesService
# Obtener instancia singleton
tlcs_service = TLCSService.get_instance()
# Buscar TLCS (async)
tlcs_data = await tlcs_service.search(
fraccion="84716001",
pais="USA",
limit=100
)
# Procesar resultados
for tlcs in tlcs_data:
print(f"Tasa: {tlcs.TASATXT}, País: {tlcs.PAIS}")
```
### Desde código síncrono
Si estás en contexto síncrono, usa `asyncio.run()`:
```python
import asyncio
from api.v1.modules.sitar.tlcs import TLCSService
tlcs_service = TLCSService.get_instance()
tlcs_data = asyncio.run(tlcs_service.search(fraccion="84716001", pais="USA"))
```
## Métodos Disponibles
### TLCS (Tratados de Libre Comercio)
- `search_tlcs(fraccion, pais, nico, skip, limit)` - Buscar TLCS
- `get_tlcs_by_id(sysid, fraccion)` - Obtener TLCS por ID
### Fracciones Arancelarias
- `search_fracciones(fraccion, nico, skip, limit)` - Buscar fracciones mexicanas
- `get_fraccion_by_id(sysid)` - Obtener fracción por ID
- `search_fracciones_usa(fraccion, skip, limit)` - Buscar fracciones USA
- `get_fraccion_usa_by_id(consecutivo)` - Obtener fracción USA por ID
- `search_fracciones_anteriores(fraccion_actual, fraccion_anterior, skip, limit)` - Buscar historial de fracciones
- `get_fracciones_anteriores_by_id(sysid)` - Obtener historial por ID
### Regulaciones y Restricciones
- `search_regulaciones(fraccion, nico, skip, limit)` - Buscar regulaciones
- `get_regulacion_by_id(sysid)` - Obtener regulación por ID
- `search_noms(fraccion, pais, nico, skip, limit)` - Buscar Normas Oficiales Mexicanas
- `get_noms_by_id(sysid)` - Obtener NOM por ID
- `search_requisito_previo(fraccion, nico, skip, limit)` - Buscar requisitos previos
- `get_requisito_previo_by_id(sysid)` - Obtener requisito previo por ID
### PROSEC y Programas de Promoción
- `search_prosec(fraccion, nico, skip, limit)` - Buscar PROSEC
- `get_prosec_by_id(sysid)` - Obtener PROSEC por ID
- `search_reit(fraccion, nico, skip, limit)` - Buscar REIT
- `get_reit_by_id(sysid)` - Obtener REIT por ID
### Precios e Impuestos
- `search_precios_estimados(fraccion, nico, skip, limit)` - Buscar precios estimados
- `get_precio_estimado_by_id(sysid)` - Obtener precio estimado por ID
- `search_ieps(fraccion, nico, skip, limit)` - Buscar IEPS
- `get_ieps_by_id(consecutivo)` - Obtener IEPS por ID
### Fundamentos y Acuerdos
- `search_fundamentos_tlc(fraccion, nico, tipat_only, skip, limit)` - Buscar fundamentos TLC
- `get_fundamento_tlc_by_id(sysid)` - Obtener fundamento por ID
- `search_aladi2(fraccion, pais, nico, skip, limit)` - Buscar ALADI2
- `get_aladi2_by_id(sysid)` - Obtener ALADI2 por ID
### Cuotas y Cupos
- `search_cuotas2(fraccion, pais, nico, skip, limit)` - Buscar cuotas compensatorias
- `get_cuotas2_by_id(sysid)` - Obtener cuota por ID
- `search_cupos(fraccion, nico, skip, limit)` - Buscar cupos de importación
- `get_cupos_by_id(sysid)` - Obtener cupo por ID
### Reglas de Carácter General
- `search_rcg2(fraccion, nico, skip, limit)` - Buscar RCG2
- `get_rcg2_by_id(sysid)` - Obtener RCG2 por ID
### Información General
- `search_informacion_general(fraccion, nico, skip, limit)` - Buscar información general
- `get_informacion_general_by_id(sysid)` - Obtener información general por ID
### Vehículos
- `search_vehiculos_marcas(fraccion, marca, skip, limit)` - Buscar marcas de vehículos
- `get_vehiculos_marcas_by_id(sysid)` - Obtener marca por ID
- `search_vehiculos_modelos(fraccion, marca, modelo, skip, limit)` - Buscar modelos de vehículos
- `get_vehiculos_modelos_by_id(sysid)` - Obtener modelo por ID
## Configuración
Requiere las siguientes variables de entorno:
```bash
SITAR_API_URL=https://api.sitar.example.com
SITAR_API_USER=your_username
SITAR_API_PASSWORD=your_password
```
## Servicios Disponibles
Cada módulo sigue el mismo patrón con métodos `search()` y `get_by_id()`:
### TLCS - `TLCSService`
```python
from api.v1.modules.sitar.tlcs import TLCSService
service = TLCSService.get_instance()
await service.search(fraccion="84716001", pais="USA", nico=None, skip=0, limit=100)
await service.get_by_id(sysid=123, fraccion="84716001")
```
### Fracciones - `FraccionesService`
```python
from api.v1.modules.sitar.fracciones import FraccionesService
service = FraccionesService.get_instance()
await service.search(fraccion="84716001", nico=None, skip=0, limit=100)
await service.get_by_id(sysid=123)
```
### Fracciones USA - `FraccionesUSAService`
```python
from api.v1.modules.sitar.fracciones_usa import FraccionesUSAService
service = FraccionesUSAService.get_instance()
await service.search(fraccion="84716001", skip=0, limit=100)
await service.get_by_id(consecutivo=123)
```
### Otros servicios disponibles:
- **RegulacionesService** - Regulaciones y restricciones
- **ProsecService** - PROSEC (Programa de Promoción Sectorial)
- **PreciosEstimadosService** - Precios estimados
- **FundamentosTLCService** - Fundamentos de tratados de libre comercio
- **Aladi2Service** - ALADI2 (Asociación Latinoamericana de Integración)
- **Cuotas2Service** - Cuotas compensatorias
- **CuposService** - Cupos de importación
- **FraccionesAnterioresService** - Historial de fracciones
- **InformacionGeneralService** - Información general de fracciones
- **IepsService** - IEPS (Impuesto Especial sobre Producción y Servicios)
- **NomsService** - Normas Oficiales Mexicanas
- **Rcg2Service** - Reglas de Carácter General
- **ReitService** - Registro de Empresas de Industria Terminal
- **RequisitoPrevioService** - Requisitos previos
- **VehiculosMarcasService** - Marcas de vehículos
- **VehiculosModelosService** - Modelos de vehículos
```### Desde endpoints asíncronos
```python
from fastapi import APIRouter, HTTPException
from api.v1.modules.sitar.tlcs import TLCSService
router = APIRouter()
@router.get("/tariff/{fraccion}")
async def get_tariff_info(fraccion: str, country: str = "USA"):
"""Endpoint que consulta información arancelaria"""
try:
tlcs_service = TLCSService.get_instance()
data = await tlcs_service.search(fraccion=fraccion, pais=country)
return {"success": True, "data": data}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
```
## Arquitectura
### Servicio Base Compartido
Todos los servicios heredan de `SitarAPIBaseService` que maneja:
- Autenticación con token caching
- Renovación automática de tokens
- Manejo de errores HTTP
- Timeout configurable
### Singleton Pattern
Cada servicio usa el patrón singleton para:
- Compartir la conexión HTTP
- Reutilizar tokens de autenticación
- Evitar múltiples instancias
### Separación de Responsabilidades
- **schemas.py**: Modelos Pydantic para validación de datos
- **service.py**: Lógica de negocio y llamadas a API
- **router.py**: Endpoints FastAPI (opcional)
- **__init__.py**: Exportaciones públicas
## Notas Importantes
1. **Autenticación**: El token se cachea y renueva automáticamente
2. **Timeouts**: Configurado a 10 segundos por defecto
3. **Límites**: Máximo 1000 registros por consulta
4. **Errores**: Todos los servicios lanzan excepciones httpx en caso de error
5. **Async/Sync**: Los servicios son async, usa `asyncio.run()` en código síncrono
```
Esto expondrá endpoints como:
- `GET /api/v1/sitar/tlcs/` - Buscar TLCS
- `GET /api/v1/sitar/fracciones/` - Buscar fracciones
- etc.
## Ejemplo Completo
```python
from api.v1.modules.sitar import SitarAPIService
async def get_tariff_info(fraccion: str, country: str):
"""Obtener información arancelaria completa"""
sitar = SitarAPIService.get_instance()
try:
# Buscar TLCS
tlcs = await sitar.search_tlcs(fraccion=fraccion, pais=country)
if tlcs:
first_tlcs = tlcs[0]
return {
"rate": first_tlcs.TASATXT,
"adv_impo": float(first_tlcs.TASA1NUM or 0.0),
"country": first_tlcs.PAIS
}
# Si no hay TLCS, buscar fracción general
fracciones = await sitar.search_fracciones(fraccion=fraccion)
if fracciones:
first_frac = fracciones[0]
return {
"rate": first_frac.ADVIMPOTXT,
"adv_impo": float(first_frac.ADVIMPONUM or 0.0),
"description": first_frac.DESCRIPCION
}
return None
except Exception as e:
print(f"Error: {e}")
return None
```
## Manejo de Errores
El servicio lanza excepciones `httpx.HTTPError` en caso de error:
```python
import httpx
try:
tlcs = await sitar.search_tlcs(fraccion="invalid")
except httpx.HTTPStatusError as e:
print(f"HTTP Error: {e.response.status_code}")
except httpx.RequestError as e:
print(f"Request Error: {e}")
except Exception as e:
print(f"General Error: {e}")
```

View File

@@ -0,0 +1,90 @@
"""
SITAR API Module
Módulo reorganizado para integración con la API externa de SITAR.
Cada recurso tiene su propia carpeta con schemas, service y router.
Estructura:
backend/api/v1/modules/sitar/
├── common/ # Base service compartido
├── tlcs/ # TLCS (Tratados de Libre Comercio)
├── fracciones/ # Fracciones arancelarias mexicanas
├── fracciones_usa/ # Fracciones arancelarias USA
├── regulaciones/ # Regulaciones y restricciones
├── prosec/ # PROSEC
├── precios_estimados/ # Precios estimados
├── fundamentos_tlc/ # Fundamentos TLC
├── aladi2/ # ALADI2
├── cuotas2/ # Cuotas compensatorias
├── cupos/ # Cupos de importación
├── fracciones_anteriores/ # Historial de fracciones
├── informacion_general/ # Información general
├── ieps/ # IEPS
├── noms/ # Normas Oficiales Mexicanas
├── rcg2/ # Reglas de Carácter General
├── reit/ # REIT
├── requisito_previo/ # Requisitos previos
├── vehiculos_marcas/ # Marcas de vehículos
└── vehiculos_modelos/ # Modelos de vehículos
Uso:
# Importar servicios específicos
from api.v1.modules.sitar.tlcs import TLCSService
from api.v1.modules.sitar.fracciones import FraccionesService
# Usar servicios
tlcs_service = TLCSService.get_instance()
data = await tlcs_service.search(fraccion="84716001")
# Importar routers para FastAPI
from api.v1.modules.sitar.tlcs import router as tlcs_router
from api.v1.modules.sitar.fracciones import router as fracciones_router
app.include_router(tlcs_router, prefix="/api/v1/sitar/tlcs", tags=["sitar-tlcs"])
"""
from .common import SitarAPIBaseService
# Import all services for convenient access
from .tlcs import TLCSService
from .fracciones import FraccionesService
from .fracciones_usa import FraccionesUSAService
from .regulaciones import RegulacionesService
from .prosec import ProsecService
from .precios_estimados import PreciosEstimadosService
from .fundamentos_tlc import FundamentosTLCService
from .aladi2 import Aladi2Service
from .cuotas2 import Cuotas2Service
from .cupos import CuposService
from .fracciones_anteriores import FraccionesAnterioresService
from .informacion_general import InformacionGeneralService
from .ieps import IepsService
from .noms import NomsService
from .rcg2 import Rcg2Service
from .reit import ReitService
from .requisito_previo import RequisitoPrevioService
from .vehiculos_marcas import VehiculosMarcasService
from .vehiculos_modelos import VehiculosModelosService
__all__ = [
"SitarAPIBaseService",
"TLCSService",
"FraccionesService",
"FraccionesUSAService",
"RegulacionesService",
"ProsecService",
"PreciosEstimadosService",
"FundamentosTLCService",
"Aladi2Service",
"Cuotas2Service",
"CuposService",
"FraccionesAnterioresService",
"InformacionGeneralService",
"IepsService",
"NomsService",
"Rcg2Service",
"ReitService",
"RequisitoPrevioService",
"VehiculosMarcasService",
"VehiculosModelosService",
]

View File

@@ -0,0 +1,7 @@
"""Aladi2 Module"""
from .schemas import Aladi2Response
from .service import Aladi2Service
from .router import router
__all__ = ["Aladi2Response", "Aladi2Service", "router"]

View File

@@ -0,0 +1,37 @@
"""Aladi2 Router"""
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Query
from fastapi import Depends
from core.security import get_current_user
from .service import Aladi2Service
from .schemas import Aladi2Response
router = APIRouter()
@router.get("/", response_model=List[Aladi2Response])
async def search(
fraccion: Optional[str] = Query(None),
pais: Optional[str] = Query(None),
nico: Optional[str] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
current_user: dict = Depends(get_current_user),
):
try:
service = Aladi2Service.get_instance()
return await service.search(
fraccion=fraccion, pais=pais, nico=nico, skip=skip, limit=limit
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{sysid}", response_model=Aladi2Response)
async def get_by_id(sysid: int, current_user: dict = Depends(get_current_user)):
try:
return await Aladi2Service.get_instance().get_by_id(sysid)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))

View File

@@ -0,0 +1,24 @@
"""ALADI2 Schemas"""
from typing import Optional
from pydantic import BaseModel, Field
class Aladi2Response(BaseModel):
"""ALADI2 (Asociación Latinoamericana de Integración)"""
FRACCION: Optional[str] = Field(None, max_length=10)
ACUERDO: Optional[str] = Field(None, max_length=49)
PAIS: Optional[str] = Field(None, max_length=3)
TASATXT: Optional[str] = Field(None, max_length=19)
TASANUM: Optional[str] = None
TASACALCULADA: Optional[str] = Field(None, max_length=19)
TIPOTASA: Optional[int] = None
DOF: Optional[str] = Field(None, max_length=8)
NOTAS: Optional[str] = Field(None, max_length=499)
OBSERVACIONES: Optional[str] = None
NICO: Optional[str] = Field(None, max_length=2)
SYSID: int
class Config:
from_attributes = True

View File

@@ -0,0 +1,37 @@
"""Aladi2 Service"""
from typing import Optional, List
from ..common import SitarAPIBaseService
from .schemas import Aladi2Response
class Aladi2Service(SitarAPIBaseService):
_instance: Optional["Aladi2Service"] = None
@classmethod
def get_instance(cls) -> "Aladi2Service":
if cls._instance is None:
cls._instance = cls()
return cls._instance
async def search(
self,
fraccion: Optional[str] = None,
pais: Optional[str] = None,
nico: Optional[str] = None,
skip: int = 0,
limit: int = 100,
) -> List[Aladi2Response]:
params = {"skip": skip, "limit": min(limit, 1000)}
if fraccion:
params["fraccion"] = fraccion
if pais:
params["pais"] = pais
if nico:
params["nico"] = nico
data = await self._make_request("GET", "/api/v1/aladi2/", params=params)
return [Aladi2Response(**item) for item in data]
async def get_by_id(self, sysid: int) -> Aladi2Response:
data = await self._make_request("GET", f"/api/v1/aladi2/{sysid}")
return Aladi2Response(**data)

View File

@@ -0,0 +1,5 @@
"""SITAR API Common Module"""
from .base_service import SitarAPIBaseService
__all__ = ["SitarAPIBaseService"]

View File

@@ -0,0 +1,122 @@
"""
SITAR API Base Service
Base service class for SITAR API authentication and HTTP requests.
All specific resource services inherit from this.
"""
import os
from typing import Optional, Dict, Any
from datetime import datetime, timedelta
import httpx
class SitarAPIBaseService:
"""Base service for SITAR API integration with authentication"""
_token: Optional[str] = None
_token_expires: Optional[datetime] = None
def __init__(self):
"""Initialize base service with API credentials"""
self.base_url = os.getenv("SITAR_API_URL")
self.username = os.getenv("SITAR_API_USER")
self.password = os.getenv("SITAR_API_PASSWORD")
self.timeout = 30.0
if not all([self.base_url, self.username, self.password]):
raise ValueError(
"Missing SITAR API configuration. "
"Set SITAR_API_URL, SITAR_API_USER, and SITAR_API_PASSWORD environment variables."
)
async def _get_token(self) -> str:
"""
Get authentication token, refreshing if necessary
Returns:
str: Bearer token for API authentication
Raises:
httpx.HTTPError: If authentication fails
"""
# Return cached token if still valid
if self._token and self._token_expires and datetime.now() < self._token_expires:
return self._token
# Authenticate and get new token
login_url = f"{self.base_url}/fractions/api/v1/auth/login"
payload = {"username": self.username, "password": self.password}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(login_url, json=payload)
response.raise_for_status()
data = response.json()
self._token = data.get("access_token") or data.get("token")
if not self._token:
raise ValueError("No token received from SITAR API")
# Set token expiration (assume 1 hour if not specified)
self._token_expires = datetime.now() + timedelta(hours=1)
return self._token
async def _make_request(
self,
method: str,
endpoint: str,
params: Optional[Dict[str, Any]] = None,
json_data: Optional[Dict[str, Any]] = None,
) -> Any:
"""
Make authenticated request to SITAR API
Args:
method: HTTP method (GET, POST, etc.)
endpoint: API endpoint path
params: Query parameters
json_data: JSON body data
Returns:
JSON response data
Raises:
httpx.HTTPError: If request fails
"""
token = await self._get_token()
# Ensure no double slash between fractures and endpoint
endpoint = endpoint.lstrip("/")
url = f"{self.base_url}/fractions/{endpoint}"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.request(
method=method,
url=url,
params=params,
json=json_data,
headers=headers,
)
response.raise_for_status()
# DEBUG LOGGING for SITAR inspection
if "fracciones" in url:
import logging
logger = logging.getLogger(__name__)
logger.info(f"SITAR API Response Headers for {url}: {dict(response.headers)}")
try:
data = response.json()
if isinstance(data, dict):
logger.info(f"SITAR API Response Body Keys: {list(data.keys())}")
elif isinstance(data, list) and len(data) > 0:
logger.info(f"SITAR API Response List Item Keys: {list(data[0].keys())}")
return data
except Exception:
pass
return response.json()

View File

@@ -0,0 +1,7 @@
"""Cuotas2 Module"""
from .schemas import Cuotas2Response
from .service import Cuotas2Service
from .router import router
__all__ = ["Cuotas2Response", "Cuotas2Service", "router"]

Some files were not shown because too many files have changed in this diff Show More