Merge remote-tracking branch 'origin/development' into feature/trasnferencia_mainx30
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
7
backend/api/v1/modules/a76/audit_log/utils/__init__.py
Normal file
7
backend/api/v1/modules/a76/audit_log/utils/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Audit Log Utilities
|
||||
"""
|
||||
|
||||
from .serialization import serialize_for_json
|
||||
|
||||
__all__ = ["serialize_for_json"]
|
||||
44
backend/api/v1/modules/a76/audit_log/utils/serialization.py
Normal file
44
backend/api/v1/modules/a76/audit_log/utils/serialization.py
Normal 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()}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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}>"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
56
backend/api/v1/modules/a76/general_catalogs/router.py
Normal file
56
backend/api/v1/modules/a76/general_catalogs/router.py
Normal 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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
0
backend/api/v1/modules/a76/imports/__init__.py
Normal file
0
backend/api/v1/modules/a76/imports/__init__.py
Normal file
118
backend/api/v1/modules/a76/imports/routes.py
Normal file
118
backend/api/v1/modules/a76/imports/routes.py
Normal 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
|
||||
}
|
||||
20
backend/api/v1/modules/a76/imports/schemas.py
Normal file
20
backend/api/v1/modules/a76/imports/schemas.py
Normal 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
|
||||
875
backend/api/v1/modules/a76/imports/tasks.py
Normal file
875
backend/api/v1/modules/a76/imports/tasks.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
217
backend/api/v1/modules/a76/invoices/catalog_service.py
Normal file
217
backend/api/v1/modules/a76/invoices/catalog_service.py
Normal 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={}
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
33
backend/api/v1/modules/a76/items/common/common_validators.py
Normal file
33
backend/api/v1/modules/a76/items/common/common_validators.py
Normal 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
|
||||
155
backend/api/v1/modules/a76/items/common/fractions.py
Normal file
155
backend/api/v1/modules/a76/items/common/fractions.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
"""Line items module"""
|
||||
from .models import LineItem
|
||||
from .schemas import (
|
||||
LineItemBase,
|
||||
LineItemCreate,
|
||||
LineItemUpdate,
|
||||
LineItemResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"LineItem",
|
||||
"LineItemBase",
|
||||
"LineItemCreate",
|
||||
"LineItemUpdate",
|
||||
"LineItemResponse",
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
```
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
124
backend/api/v1/modules/a76/pedmientos/catalog_service.py
Normal file
124
backend/api/v1/modules/a76/pedmientos/catalog_service.py
Normal 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
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
38
backend/api/v1/modules/a76/pedmientos/schemas.py
Normal file
38
backend/api/v1/modules/a76/pedmientos/schemas.py
Normal 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
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})>"
|
||||
|
||||
@@ -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
|
||||
|
||||
320
backend/api/v1/modules/sitar/README.md
Normal file
320
backend/api/v1/modules/sitar/README.md
Normal 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}")
|
||||
```
|
||||
90
backend/api/v1/modules/sitar/__init__.py
Normal file
90
backend/api/v1/modules/sitar/__init__.py
Normal 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",
|
||||
]
|
||||
7
backend/api/v1/modules/sitar/aladi2/__init__.py
Normal file
7
backend/api/v1/modules/sitar/aladi2/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Aladi2 Module"""
|
||||
|
||||
from .schemas import Aladi2Response
|
||||
from .service import Aladi2Service
|
||||
from .router import router
|
||||
|
||||
__all__ = ["Aladi2Response", "Aladi2Service", "router"]
|
||||
37
backend/api/v1/modules/sitar/aladi2/router.py
Normal file
37
backend/api/v1/modules/sitar/aladi2/router.py
Normal 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))
|
||||
24
backend/api/v1/modules/sitar/aladi2/schemas.py
Normal file
24
backend/api/v1/modules/sitar/aladi2/schemas.py
Normal 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
|
||||
37
backend/api/v1/modules/sitar/aladi2/service.py
Normal file
37
backend/api/v1/modules/sitar/aladi2/service.py
Normal 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)
|
||||
5
backend/api/v1/modules/sitar/common/__init__.py
Normal file
5
backend/api/v1/modules/sitar/common/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""SITAR API Common Module"""
|
||||
|
||||
from .base_service import SitarAPIBaseService
|
||||
|
||||
__all__ = ["SitarAPIBaseService"]
|
||||
122
backend/api/v1/modules/sitar/common/base_service.py
Normal file
122
backend/api/v1/modules/sitar/common/base_service.py
Normal 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()
|
||||
7
backend/api/v1/modules/sitar/cuotas2/__init__.py
Normal file
7
backend/api/v1/modules/sitar/cuotas2/__init__.py
Normal 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
Reference in New Issue
Block a user