Merge remote-tracking branch 'origin/development' into feature/Invoice-movements

# Conflicts:
#	backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py
#	backend/core/celery_app.py
#	frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts
This commit is contained in:
Galindo97
2026-02-20 10:09:19 -06:00
127 changed files with 17010 additions and 9437 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,8 @@ from sqlalchemy.orm import Session
from .dto import CompanyCreateDTO, CompanyResponseDTO, CompanyUpdateDTO
from .models import Company
from ...audit_log.services.service import AuditService
from core.context import get_user_context
logger = logging.getLogger(__name__)
@@ -111,7 +113,8 @@ class CompanyService:
"active_labels", "active_fractions", "activate_caat", "trans_interface",
"american_costs", "scaf_readonly", "parts_replacement", "activate_facmexame",
"part_reference", "international_firm", "ftp_key", "sifra_path",
"version_type", "sql_language", "balance_operation_mode", "inter_db_name"
"version_type", "sql_language", "balance_operation_mode", "inter_db_name",
"seventh_amendment"
]
return {k: v for k, v in data.items() if k in company_fields}
@@ -127,15 +130,100 @@ class CompanyService:
]
return {k: v for k, v in data.items() if k in cert_fields}
def _extract_address_fields(self, data: Dict[str, Any], type_prefix: str) -> Dict[str, Any]:
"""Extrae campos de dirección con base en un prefijo (main_, ind1_, ind2_)"""
fields = ["street", "exterior_number", "interior_number", "postal_code",
"neighborhood", "city", "municipality", "state", "country",
"phone", "fax", "email"]
extracted = {}
for f in fields:
key = f"{type_prefix}_{f}"
if key in data:
extracted[f] = data[key]
return extracted
def _extract_prevalidator_fields(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Extrae campos que pertenecen a CompanyPrevalidator"""
# Note: 'prevalidator_key' in DTO maps to 'key' in model
fields = {}
if "prevalidator_key" in data:
fields["key"] = data["prevalidator_key"]
# Se mapean campos 'prev_*' a los nombres del modelo
mapping = {
"prev_customs": "customs",
"prev_key": "key",
"prev_patent": "patent",
"prev_description": "description"
}
extracted = {}
for dto_key, model_key in mapping.items():
if dto_key in data:
extracted[model_key] = data[dto_key]
# Add other fields if present in DTO in the future
return fields
# Retrocompatibilidad con el campo prevalidator_key que ya estaba en el DTO
if "prevalidator_key" in data and "key" not in extracted:
extracted["key"] = data["prevalidator_key"]
return extracted
def _extract_vu_fields(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Extrae campos que pertenecen a CompanyVU (prefijo vu_)"""
vu_fields = [
"webservice_user", "webservice_password", "email", "figure_type",
"central_path", "xml_files_path", "query_rfc", "validation_rfc",
"configuration_source", "measurement_units"
]
extracted = {}
for f in vu_fields:
key = f"vu_{f}"
if key in data:
extracted[f] = data[key]
return extracted
def _extract_electronic_agent_fields(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Extrae campos que pertenecen a CompanyElectronicAgent (prefijo ea_)"""
ea_fields = [
"input_folder", "output_folder", "send_mask", "response_mask",
"response_extension", "counter_start", "counter_end", "counter_next"
]
extracted = {}
for f in ea_fields:
key = f"ea_{f}"
if key in data:
extracted[f] = data[key]
return extracted
def _extract_cfdi_fields(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Extrae campos que pertenecen a CompanyCFDI (prefijo cfdi_)"""
cfdi_fields = ["xml_save_path", "cfdi_app_path", "pac_app_path"]
extracted = {}
for f in cfdi_fields:
key = f"cfdi_{f}"
if key in data:
extracted[f] = data[key]
return extracted
def _extract_digital_certificate_fields(self, data: Dict[str, Any], cert_prefix: str) -> Dict[str, Any]:
"""Extrae campos para un tipo específico de certificado (fiel, cfdi_cert, cancel)"""
# Mapeo de prefijos DTO a nombres de modelo
fields_map = {
f"{cert_prefix}_cer": "cer_file_path",
f"{cert_prefix}_key": "key_file_path",
f"{cert_prefix}_pass": "password",
f"{cert_prefix}_access": "access_key",
f"{cert_prefix}_cer_exp": "cer_expiration_date",
f"{cert_prefix}_key_exp": "key_expiration_date"
}
extracted = {}
for dto_key, model_key in fields_map.items():
if dto_key in data:
extracted[model_key] = data[dto_key]
if extracted:
# Mapear prefijo al tipo real en base de datos
model_type_map = {'fiel': 'fiel', 'cfdi_cert': 'cfdi', 'cancel': 'cancellation'}
extracted['certificate_type'] = model_type_map.get(cert_prefix, cert_prefix)
return extracted
def flatten_company_dto(self, company: Company) -> Dict[str, Any]:
"""Flattens Company and its submodels into a single dict for DTO validation"""
@@ -144,10 +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
# 2. Certification fields
if company.certification:
cert_fields = [
@@ -163,86 +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)
# 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)
for field, value in company_fields.items():
setattr(company, field, value)
@@ -250,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)}")
@@ -277,69 +561,139 @@ class CompanyService:
@staticmethod
def delete(
db: Session, company_id: int, tenant_id: int, company_id_unused: int
db: Session, company_id: int, tenant_id: int, company_id_unused: int, username: str = "System"
) -> bool:
"""Delete a company"""
company = CompanyService.get_by_id(db, company_id, tenant_id, company_id_unused)
if not company:
return False
# --- Audit Log Prep ---
record_data = {}
try:
# Manual cascade delete for submodels to ensure order and avoid FK issues
# (Even though cascade="all, delete-orphan" is set, manual deletion is safer for strict DBs)
# 1. Delete Certification
if company.certification:
db.delete(company.certification)
# 2. Delete Prevalidator
if company.prevalidator:
db.delete(company.prevalidator)
# 3. Delete Electronic Agent
if company.electronic_agent:
db.delete(company.electronic_agent)
# 4. Delete VU
if company.ventanilla_unica:
db.delete(company.ventanilla_unica)
# 5. Delete CFDI
if company.cfdi:
db.delete(company.cfdi)
# 6. Delete Digital Certificates
for cert in company.digital_certificates:
db.delete(cert)
# 7. Delete Addresses
for addr in company.addresses:
db.delete(addr)
service = CompanyService(db) # Instancia para usar métodos de instancia si fuera necesario, o usar estático si flatten lo fuera
# flatten_company_dto es método de instancia en la definición actual, pero se está llamando aquí
# Deberíamos instanciar el servicio o mover flatten a estático.
# Como flatten usa self solo para acceder a nada realmente del estado, podría ser estático,
# pero para no romper, instanciamos.
record_data = service.flatten_company_dto(company)
except Exception:
pass
# ----------------------
try:
# Cascading deletes are handled by relationship settings, but manual is safer here
if company.certification: db.delete(company.certification)
if company.prevalidator: db.delete(company.prevalidator)
if company.electronic_agent: db.delete(company.electronic_agent)
if company.ventanilla_unica: db.delete(company.ventanilla_unica)
if company.cfdi: db.delete(company.cfdi)
for cert in company.digital_certificates: db.delete(cert)
for addr in company.addresses: db.delete(addr)
# Flush to execute submodel deletions first
db.flush()
db.delete(company)
db.commit()
# --- Audit Log ---
try:
# Context check
if username == "System":
ctx = get_user_context()
if ctx:
username = ctx.get("preferred_username") or ctx.get("email") or "System"
AuditService.log_crud_operation(
db=db,
table_name="company",
operation_type="DELETE",
record_data=record_data,
username=username,
record_id=str(company_id),
company_id=company_id
)
except Exception as e:
logger.error(f"Error creating audit log for company delete: {e}")
# -----------------
return True
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError deleting company {company_id}: {str(e)}")
# Try to get detailed error from psycopg2
detail = "No se puede eliminar la empresa porque tiene registros relacionados."
if hasattr(e, 'orig') and hasattr(e.orig, 'diag'):
if e.orig.diag.message_detail:
detail += f" Detalles: {e.orig.diag.message_detail}"
raise HTTPException(
status_code=400,
detail=detail
)
raise HTTPException(status_code=400, detail="No se puede eliminar la empresa porque tiene registros relacionados.")
except Exception as e:
db.rollback()
logger.error(f"Error deleting company {company_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error al eliminar la empresa")
def upload_certificate(
self,
company_id: int,
certificate_type: str,
file_path: str,
tenant_id: int
) -> Company:
"""
Update a certificate path for a company
certificate_type: fiel_cer, fiel_key, cfdi_cert_cer, cfdi_cert_key, cancel_cer, cancel_key
"""
from .submodels.digital_certificate import CompanyDigitalCertificate
company = self.get_by_id(self.db, company_id, tenant_id, 0)
if not company:
return None
# Determinar el tipo de certificado (fiel, cfdi, cancellation) y el campo a actualizar (cer_file_path, key_file_path)
cert_model_type = ""
field_to_update = ""
if certificate_type == "fiel_cer":
cert_model_type = "fiel"
field_to_update = "cer_file_path"
elif certificate_type == "fiel_key":
cert_model_type = "fiel"
field_to_update = "key_file_path"
elif certificate_type == "cfdi_cert_cer":
cert_model_type = "cfdi"
field_to_update = "cer_file_path"
elif certificate_type == "cfdi_cert_key":
cert_model_type = "cfdi"
field_to_update = "key_file_path"
elif certificate_type == "cancel_cer":
cert_model_type = "cancellation"
field_to_update = "cer_file_path"
elif certificate_type == "cancel_key":
cert_model_type = "cancellation"
field_to_update = "key_file_path"
else:
raise ValueError(f"Invalid certificate type: {certificate_type}")
# Buscar el registro de certificado existente
target_cert = next((c for c in company.digital_certificates if c.certificate_type == cert_model_type), None)
try:
if target_cert:
# Si existe, actualizamos
setattr(target_cert, field_to_update, file_path)
else:
# Si no existe, creamos uno nuevo
new_cert_data = {
"certificate_type": cert_model_type,
"company_id": company.id,
field_to_update: file_path
}
new_cert = CompanyDigitalCertificate(**new_cert_data)
self.db.add(new_cert)
self.db.commit()
self.db.refresh(company)
return company
except Exception as e:
self.db.rollback()
logger.error(f"Error uploading certificate: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error al guardar la referencia del certificado: {str(e)}")
# Custom methods
def get_companies_by_tenant(self, tenant_id: int) -> List[Company]:
"""Get all companies for a tenant"""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,9 +4,10 @@ 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):
class HistoricalTariffFraction(Base, TenantScopedMixin, TimestampMixin):
"""
Historical tariff fractions catalog.
Maps to SQL Server table: GFraccionesHistorico
@@ -17,6 +18,7 @@ class HistoricalTariffFraction(Base):
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)

View File

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

View File

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

View File

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

View File

@@ -30,6 +30,8 @@ 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),
):
@@ -37,13 +39,20 @@ async def list_tariff_fractions(
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
db, skip, page_size, filters, catalog, tenant_id, company_id
)
return {
@@ -72,3 +81,132 @@ async def get_tariff_fraction(
raise HTTPException(status_code=404, detail="Tariff fraction not found")
return TariffFractionResponseDTO.model_validate(item)
@router.post(
"/",
response_model=TariffFractionResponseDTO,
summary="Create Tariff Fraction",
description="Create a new tariff fraction (Only supported for 'american' catalog)",
)
async def create_tariff_fraction(
fraction_data: TariffFractionCreateDTO,
catalog: str = Query("mex", description="Catalog source: 'mex', 'usa', or 'american'"),
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Crea una nueva fracción.
- MEX/USA: No permitido (Read-Only)
- AMERICAN: Permitido (Local DB)
"""
from fastapi import HTTPException
tenant_id = current_user.get("tenant_id")
if catalog == "american":
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.service import USTariffFractionService
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.dto import USTariffFractionCreateDTO
import re
# Map generic DTO to US DTO
ad_valorem = None
if fraction_data.adv_impo:
try:
# remove non-numeric chars except dot
clean = re.sub(r'[^\d.]', '', fraction_data.adv_impo)
if clean:
ad_valorem = float(clean)
except:
pass
us_dto = USTariffFractionCreateDTO(
code=fraction_data.code,
description=fraction_data.description,
unit_of_measure=fraction_data.umt,
ad_valorem=ad_valorem,
# Defaults for others
prefix=None,
type_code=None,
fixed_cost=None
)
created = USTariffFractionService.create(db, tenant_id, company_id, us_dto)
return TariffFractionService.to_domain_usa_local(created)
else:
raise HTTPException(status_code=400, detail=f"Creation not allowed for '{catalog}' catalog (Read-Only)")
@router.put(
"/{tariff_fraction_id}",
response_model=TariffFractionResponseDTO,
summary="Update Tariff Fraction",
description="Update a tariff fraction (Only supported for 'american' catalog)",
)
async def update_tariff_fraction(
tariff_fraction_id: int,
fraction_data: TariffFractionUpdateDTO,
catalog: str = Query("mex", description="Catalog source: 'mex', 'usa', or 'american'"),
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
from fastapi import HTTPException
tenant_id = current_user.get("tenant_id")
if catalog == "american":
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.service import USTariffFractionService
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.dto import USTariffFractionUpdateDTO
import re
ad_valorem = None
if fraction_data.adv_impo:
try:
clean = re.sub(r'[^\d.]', '', fraction_data.adv_impo)
if clean:
ad_valorem = float(clean)
except:
pass
us_dto = USTariffFractionUpdateDTO(
description=fraction_data.description,
unit_of_measure=fraction_data.umt,
ad_valorem=ad_valorem
)
updated = USTariffFractionService.update(db, tenant_id, company_id, tariff_fraction_id, us_dto)
if not updated:
raise HTTPException(status_code=404, detail="US Tariff fraction not found")
return TariffFractionService.to_domain_usa_local(updated)
else:
raise HTTPException(status_code=400, detail=f"Update not allowed for '{catalog}' catalog (Read-Only)")
@router.delete(
"/{tariff_fraction_id}",
summary="Delete Tariff Fraction",
description="Delete a tariff fraction (Only supported for 'american' catalog)",
)
async def delete_tariff_fraction(
tariff_fraction_id: int,
catalog: str = Query("mex", description="Catalog source: 'mex', 'usa', or 'american'"),
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
from fastapi import HTTPException
tenant_id = current_user.get("tenant_id")
if catalog == "american":
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.service import USTariffFractionService
success = USTariffFractionService.delete(db, tenant_id, company_id, tariff_fraction_id)
if not success:
raise HTTPException(status_code=404, detail="US Tariff fraction not found")
return {"ok": True}
else:
raise HTTPException(status_code=400, detail=f"Delete not allowed for '{catalog}' catalog (Read-Only)")

View File

@@ -14,6 +14,8 @@ 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__)
@@ -68,30 +70,130 @@ class TariffFractionMapper:
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: Sitar API -> Fallback Local DB
Estrategia:
- MEX: Sitar API -> Fallback Local DB
- USA: Local DB (Defined by user requirement)
"""
# 1. Try Sitar API
# 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
has_filters = False
# 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"):
@@ -101,7 +203,6 @@ class TariffFractionService:
clean_term = term.replace(".", "")
if clean_term and clean_term[0].isdigit():
sitar_fraccion = clean_term
has_filters = True
else:
# Attempt description search via API first
logger.info(f"Search term '{term}' identified as text. Attempting API description search.")
@@ -109,13 +210,10 @@ class TariffFractionService:
if filters.get("code"):
sitar_fraccion = filters["code"]
has_filters = True
if filters.get("fraction"):
sitar_fraccion = filters["fraction"]
has_filters = True
if filters.get("nico"):
sitar_nico = filters["nico"]
has_filters = True
# Determine description filter
sitar_description = None
@@ -124,7 +222,6 @@ class TariffFractionService:
clean_term = filters["search"].replace(".", "")
if not (clean_term and clean_term[0].isdigit()):
sitar_description = filters["search"]
has_filters = True
# Note: Sitar search might not return total count.
# We fetch page items. Pagination might be tricky if Sitar doesn't return total.
@@ -133,7 +230,7 @@ class TariffFractionService:
fraccion=sitar_fraccion,
nico=sitar_nico,
description=sitar_description,
nivel=5, # User requested filtering by level 5
nivel=level_filter, # Dynamic level
skip=skip,
limit=limit
)
@@ -194,6 +291,8 @@ class TariffFractionService:
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

View File

@@ -9,9 +9,10 @@ from sqlalchemy import String, Numeric, TIMESTAMP, func
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
class USTariffFraction(Base):
class USTariffFraction(Base, TenantScopedMixin, TimestampMixin):
"""Modelo para fracciones arancelarias americanas (US HTS codes)"""
__tablename__ = "us_tariff_fractions"
@@ -20,10 +21,6 @@ class USTariffFraction(Base):
# Primary Key
id: Mapped[int] = mapped_column(primary_key=True, index=True)
# Tenant/Company
tenant_id: Mapped[int] = mapped_column(index=True, nullable=False)
company_id: Mapped[int] = mapped_column(index=True, nullable=False)
# Datos principales
code: Mapped[str] = mapped_column(
String(16), nullable=False, comment="Código de fracción americana"
@@ -47,16 +44,5 @@ class USTariffFraction(Base):
String, comment="Descripción de la fracción"
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
def __repr__(self):
return f"<USTariffFraction {self.code}>"

View File

@@ -6,6 +6,8 @@ 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
@@ -26,14 +28,16 @@ from .electronic_notices.routes import router as electronic_notices_router
router = APIRouter()
router.include_router(company_router, tags=["a76 / company"])
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(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"])
@@ -49,4 +53,4 @@ 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)
router.include_router(electronic_notices_router)

View File

@@ -345,8 +345,7 @@ def insert_valid_rows(self, job_id: str, model_target: str):
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 Item
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.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

View File

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

View File

@@ -304,6 +304,7 @@ class InvoiceService:
# Update compliance_mx if provided
if invoice_data.compliance_mx is not None:
print(f"DEBUG: 更新 compliance_mx para factura {invoice.id}: {invoice_data.compliance_mx}")
if invoice.compliance_mx:
for key, value in invoice_data.compliance_mx.model_dump(
exclude_unset=True

View File

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

View File

@@ -1,6 +1,6 @@
from sqlalchemy import func
from core.exceptions import ErrorCollector
from ..line_items import models
from .. import models
from sqlalchemy.orm import Session
@@ -21,11 +21,11 @@ def item_exists(db: Session, item_line: int, tenant_id: int, company_id: int):
def count_items(db: Session, invoice_id: int, tenant_id: int, company_id: int):
count = (
db.query(func.count())
.select_from(models.Item)
.select_from(models.LineItem)
.filter(
models.Item.invoice_id == invoice_id,
models.Item.tenant_id == tenant_id,
models.Item.company_id == company_id,
models.LineItem.invoice_id == invoice_id,
models.LineItem.tenant_id == tenant_id,
models.LineItem.company_id == company_id,
)
.scalar()
)

View File

@@ -6,8 +6,7 @@ from sqlalchemy import func
from ....common.fractions import search_fraction_preference
from ....common.common_validators import item_exists
from ....models import Item
from ....line_items.models import LineItem
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
@@ -26,21 +25,13 @@ from api.v1.modules.a76.general_catalogs.company.models import Company
def validate_common(
db: Session,
line: LineItemCreate,
invoice_id: int, # Para creación, se pasa directamente; para update, se consulta del item
tenant_id: int,
company_id: int,
errors: ErrorCollector,
line_number: int,
):
# Para updates, line.item_id existe; para creates, es None
item_header = None
if line.item_id:
item_header = db.query(Item).filter(Item.id == line.item_id).first()
if item_header:
invoice_id = item_header.invoice_id
invoice: InvoiceHeader = invoice_exists_by_id(
db, invoice_id, tenant_id, company_id, errors
db, line.invoice_id, tenant_id, company_id, errors
)
line_item: LineItem = item_exists(db, line.line_number, tenant_id, company_id)
@@ -164,7 +155,7 @@ def validate_common(
code="PACKAGE_QUANTITY_MUST_BE_GREATER_THAN_ZERO",
)
else:
if (line.quantity.package_quantity or line.quantity.package_quantity > 0) and not line.quantity.package_id:
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.",
@@ -301,8 +292,8 @@ def validate_common(
code="AMERICAN_FRACTION_NOT_FOUND",
)
if item_header and item_header.order:
if len(item_header.order) > 20:
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.",

View File

@@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
from ....common.common_validators import count_items
from core.exceptions import ErrorCollector
from ....line_items.models import LineItem
from ....models import LineItem
from ....line_financials.models import LineFinancial
from ....line_financials.schemas import LineFinancialCreate
from ....line_quantities.models import LineQuantity
@@ -15,7 +15,7 @@ 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 Item
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
@@ -27,8 +27,7 @@ from .common import validate_common
def validate_create(
db: Session,
line, # LineItemCreate schema (Pydantic)
invoice_id: int, # Passed from service
line: LineItem, # LineItemCreate schema (Pydantic)
tenant_id: int,
company_id: int,
errors: ErrorCollector,
@@ -42,16 +41,7 @@ def validate_create(
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)
"""
# Inicializar nested schemas si no existen (para poder validar y modificar)
if not line.financial:
line.financial = LineFinancialCreate()
if not line.quantity:
line.quantity = LineQuantityCreate()
if not line.customs:
line.customs = LineCustomCreate()
if not line.description:
line.description = LineDescriptionCreate()
"""
# Access fa_data safely
fa_data = getattr(line, "fa_data", None)
@@ -101,8 +91,8 @@ def validate_create(
principal_item_exists = db.query(
exists().where(
(LineItem.id == FaLineItem.id)
& (LineItem.item_id == Item.id)
& (Item.invoice_id == invoice_id)
& (LineItem.id == LineItem.id)
& (LineItem.invoice_id == line.invoice_id)
& (LineItem.line_number == line_number)
& (FaLineItem.is_subitem == False)
& (FaLineItem.contains_subitems == True)
@@ -131,14 +121,14 @@ def validate_create(
code="SUBITEM_NUMBER_INVALID",
)
validate_common(db, line, invoice_id, tenant_id, company_id, errors, line_number)
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 == invoice_id,
InvoiceHeader.id == line.invoice_id,
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id,
)

View File

@@ -3,8 +3,7 @@ from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.common.common_validators import invoice_exists
from core.exceptions import ErrorCollector
from ....line_items.models import LineItem
from ....models import Item
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,
@@ -15,8 +14,7 @@ from .common import validate_common
def validate_update(
db: Session,
line: LineItem,
existing_line: LineItem,
invoice_id: int, # Passed from service
existing_line: LineItem,
tenant_id: int,
company_id: int,
errors: ErrorCollector,
@@ -26,14 +24,14 @@ def validate_update(
Validar y procesar actualización parcial de línea de importación temporal.
Si un campo no se proporciona, se mantiene el valor existente.
"""
validate_common(db, line, invoice_id, tenant_id, company_id, errors, line_number)
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 == invoice_id,
InvoiceHeader.id == line.invoice_id,
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id
)
@@ -81,6 +79,8 @@ def validate_update(
# Mantener peso existente
line.quantity.net_weight = existing_line.quantity.net_weight
print(f"After weight conversion: net_weight={line.quantity.net_weight}, gross_weight={line.quantity.gross_weight}, weight_type={invoice_weight_type}")
# Convertir peso bruto si se proporcionó
if line.quantity.gross_weight is not None:
gross_weight_input = line.quantity.gross_weight
@@ -141,8 +141,8 @@ def validate_update(
line.customs.advalorem_american = existing_line.customs.advalorem_american
# Orden de compra
if not line.reference.purchase_order:
line.reference.purchase_order = existing_line.reference.purchase_order
if not line.order:
line.order = existing_line.order
# Descripciones
if not line.description.description_spanish:
@@ -176,8 +176,8 @@ def validate_update(
# Número de parte
if not line.part_number:
line.part_number = existing_line.part_number
if not line.part_number_id:
line.part_number_id = existing_line.part_number_id
# Pago de impuesto
if line.tax_payment is None:

View File

@@ -5,7 +5,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.database import Base
if TYPE_CHECKING:
from ..line_items.models import LineItem
from ..models import LineItem
class FractionType:
"""Enumeration for fraction types"""

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,293 +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)
item_id: int = Field(..., description="ID of the parent item")
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"""
# Override base fields - estos se asignan automáticamente en el service
item_id: Optional[int] = Field(
None, description="ID of the parent item (auto-assigned)"
)
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 LineItemUpdate(LineItemBase):
"""Schema for updating line item with all nested data"""
# Override base fields - todos opcionales en updates
item_id: Optional[int] = Field(None, description="ID of the parent item")
line_number: Optional[int] = Field(None, description="Line number")
financial: Optional[LineFinancialUpdate] = Field(
None, description="Financial data for this line"
)
quantity: Optional[LineQuantityUpdate] = Field(
None, description="Quantity data for this line"
)
customs: Optional[LineCustomUpdate] = Field(
None, description="Customs data for this line"
)
description: Optional[LineDescriptionUpdate] = Field(
None, description="Description data for this line"
)
reference: Optional[LineReferenceUpdate] = Field(
None, description="Reference data for this line"
)
fa_data: Optional[FaLineItemUpdateDTO] = Field(
None, description="Fixed Asset data for this line"
)
class LineItemResponse(LineItemBase):
"""Schema for line item response with all nested data"""
id: int
item_id: int
financial: Optional[LineFinancialResponse] = None
quantity: Optional[LineQuantityResponse] = None
customs: Optional[LineCustomResponse] = None
description: Optional[LineDescriptionResponse] = None
reference: Optional[LineReferenceResponse] = None
fa_data: Optional[FaLineItemResponseDTO] = None
# Fields populated from relationships
class_code: Optional[str] = None
class_description: Optional[str] = None
unit_of_measure_code: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
@model_validator(mode="before")
@classmethod
def extract_relationship_info(cls, data: Any) -> Any:
"""Extract class_code, class_description and unit_of_measure_code from relationships"""
if isinstance(data, dict):
return data
# It's an ORM object
result = {}
for key in cls.model_fields.keys():
if hasattr(data, key):
result[key] = getattr(data, key)
# Map model field names to schema field names for aliased fields
if hasattr(data, "part_number"):
result["part_number_id"] = data.part_number
if hasattr(data, "component_part_number"):
result["component_part_number_id"] = data.component_part_number
# Extract class info
if hasattr(data, "class_info") and data.class_info is not None:
result["class_code"] = data.class_info.class_code
result["class_description"] = data.class_info.description_es
# Extract unit of measure code
if (
hasattr(data, "unit_of_measure_info")
and data.unit_of_measure_info is not None
):
result["unit_of_measure_code"] = data.unit_of_measure_info.code
return result

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,46 @@
"""
Schemas for Items and related entities
Complete nested one-to-one structure:
Item -> LineItem -> LineFinancial -> LineQuantity -> LineCustoms -> LineDescription -> LineReference
LineItem -> LineFinancial -> LineQuantity -> LineCustoms -> LineDescription -> LineReference
"""
from typing import Optional
from typing import Any, Optional
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, Field, ConfigDict
from pydantic import BaseModel, Field, ConfigDict, model_validator
# Import schemas from individual modules
from .line_items.schemas import (
LineItemCreate,
LineItemUpdate,
LineItemResponse
# Import nested schemas
from .line_customs.schemas import (
LineCustomCreate,
LineCustomUpdate,
LineCustomResponse,
)
from .line_descriptions.schemas import (
LineDescriptionCreate,
LineDescriptionUpdate,
LineDescriptionResponse,
)
from .line_quantities.schemas import (
LineQuantityCreate,
LineQuantityUpdate,
LineQuantityResponse,
)
from .line_financials.schemas import (
LineFinancialCreate,
LineFinancialUpdate,
LineFinancialResponse,
)
from .line_references.schemas import (
LineReferenceCreate,
LineReferenceUpdate,
LineReferenceResponse,
)
from api.v1.modules.a24.fa.fa_item_lines.dto import (
FaLineItemCreateDTO,
FaLineItemUpdateDTO,
FaLineItemResponseDTO,
)
@@ -21,53 +48,294 @@ from .line_items.schemas import (
# ITEM SCHEMAS
# ============================================================================
class ItemBase(BaseModel):
class LineItemBase(BaseModel):
"""Base schema for items"""
model_config = ConfigDict(populate_by_name=True)
invoice_id: int = Field(..., description="Invoice ID")
line_number: int = Field(..., description="Line number")
# Part identification
part_number_id: Optional[int] = Field(
None,
description="Part number",
alias="part_number",
serialization_alias="part_number_id",
)
component_part_number_id: Optional[int] = Field(
None,
description="Component part number",
alias="component_part_number",
serialization_alias="component_part_number_id",
)
class_id: Optional[int] = Field(None, description="Class code")
# Unit of measure
unit_of_measure: Optional[int] = Field(None, description="Unit of measure")
alternate_unit: Optional[int] = Field(None, description="Alternate unit")
uma_key: Optional[str] = Field(None, max_length=2, description="UMA key")
auxiliary_unit: Optional[str] = Field(
None, max_length=5, description="Auxiliary unit"
)
# Permits and certificates
permit_number: Optional[str] = Field(
None, max_length=20, description="Permit number"
)
page_line: Optional[str] = Field(None, max_length=10, description="Page line")
has_certificate: Optional[bool] = Field(None, description="Has certificate")
certificate_number: Optional[str] = Field(
None, max_length=10, description="Certificate number"
)
octave_permit: Optional[str] = Field(
None, max_length=20, description="Octave permit"
)
permits_ped: Optional[str] = Field(None, max_length=500, description="PED permits")
# FDA
has_fda_code: Optional[bool] = Field(None, description="Has FDA code")
fda_key: Optional[str] = Field(None, max_length=10, description="FDA key")
# Special flags
is_military_mcia: Optional[bool] = Field(
None, description="Is military merchandise"
)
# IV32
iv32_type_key: Optional[str] = Field(
None, max_length=5, description="IV32 type key"
)
iv32_number: Optional[str] = Field(None, max_length=35, description="IV32 number")
# Export specific
scrap_invoice: Optional[str] = Field(
None, max_length=15, description="Scrap invoice"
)
consecutive_destination: Optional[int] = Field(
None, description="Consecutive destination"
)
ctm_section: Optional[str] = Field(None, max_length=3, description="CTM section")
# Tax payment
tax_payment: Optional[bool] = Field(None, description="Tax payment")
payment_method: Optional[str] = Field(
None, max_length=9, description="Payment method"
)
igi_amount: Optional[Decimal] = Field(None, description="IGI amount")
igi_payment_method: Optional[str] = Field(
None, max_length=9, description="IGI payment method"
)
# FCC
fcc_key: Optional[str] = Field(None, max_length=30, description="FCC key")
# Valuation method
valuation_method: Optional[str] = Field(
None, max_length=2, description="Valuation method"
)
valuation_determined_value: Optional[Decimal] = Field(
None, description="Valuation determined value"
)
valuation_reason: Optional[str] = Field(
None, max_length=500, description="Valuation reason"
)
# Container rules
container_rule: Optional[str] = Field(
None, max_length=50, description="Container rule"
)
container_parts_ii: Optional[str] = Field(
None, max_length=50, description="Container parts II"
)
# APHIS
consecutive_aphis: Optional[int] = Field(None, description="Consecutive APHIS")
# BOM/Commercial
bom_version: Optional[int] = Field(None, description="BOM version")
bill_version: Optional[int] = Field(None, description="Bill version")
# TLCAN value
tlcan_value: Optional[Decimal] = Field(None, description="TLCAN value")
# Identifier
identifier: Optional[str] = Field(None, max_length=2, description="Identifier")
# Validation fields
validation_zero: Optional[int] = Field(None, description="Validation zero")
validation_one: Optional[int] = Field(None, description="Validation one")
# Material type
material_type: Optional[str] = Field(
None, max_length=50, description="Material type"
)
# Order concept
order_type: Optional[str] = Field(None, max_length=50, description="Order type")
line_concept: Optional[str] = Field(None, max_length=50, description="Line concept")
# Review dispatch
review_dispatch: Optional[str] = Field(
None, max_length=10, description="Review dispatch"
)
# Take component from PT
take_component_pt: Optional[int] = Field(None, description="Take component from PT")
# Pallet
pallet2: Optional[int] = Field(None, description="Pallet 2")
# Wildcard field
wildcard_field: Optional[str] = Field(
None, max_length=100, description="Wildcard field"
)
reference_number: Optional[str] = Field(
None, max_length=20, description="Reference number")
None, max_length=20, description="Reference number"
)
order: Optional[str] = Field(None, max_length=50, description="Order")
guide_number: Optional[str] = Field(
None, max_length=50, description="Guide number")
guide_number: Optional[str] = Field(None, max_length=50, description="Guide number")
# Dates
depreciation_date: Optional[int] = Field(
None, description="Depreciation date")
depreciation_date: Optional[int] = Field(None, description="Depreciation date")
# Administrative fields
rectification: Optional[int] = Field(None, description="Rectification")
warehouse: Optional[str] = Field(
None, max_length=30, description="Warehouse")
location: Optional[str] = Field(
None, max_length=200, description="Location")
warehouse: Optional[str] = Field(None, max_length=30, description="Warehouse")
location: Optional[str] = Field(None, max_length=200, description="Location")
class ItemCreate(ItemBase):
class LineItemCreate(LineItemBase):
"""Schema for creating item with nested lines (one-to-many)"""
lines: Optional[list[LineItemCreate]] = Field(
default=[], description="List of line items")
# Override base fields - estos se asignan automáticamente en el service
line_number: Optional[int] = Field(None, description="Line number (auto-assigned)")
financial: Optional[LineFinancialCreate] = Field(
None, description="Financial data for this line"
)
quantity: Optional[LineQuantityCreate] = Field(
None, description="Quantity data for this line"
)
customs: Optional[LineCustomCreate] = Field(
None, description="Customs data for this line"
)
description: Optional[LineDescriptionCreate] = Field(
None, description="Description data for this line"
)
reference: Optional[LineReferenceCreate] = Field(
None, description="Reference data for this line"
)
fa_data: Optional[FaLineItemCreateDTO] = Field(
None, description="Fixed Asset data for this line"
)
class ItemUpdate(ItemBase):
class LineItemUpdate(LineItemBase):
"""Schema for updating item"""
invoice_id: Optional[int] = Field(None, description="Invoice ID")
lines: Optional[list[LineItemUpdate]] = Field(
None, description="List of line items to update")
# Override base fields - todos opcionales en updates
line_number: Optional[int] = Field(None, description="Line number")
financial: Optional[LineFinancialUpdate] = Field(
None, description="Financial data for this line"
)
quantity: Optional[LineQuantityUpdate] = Field(
None, description="Quantity data for this line"
)
customs: Optional[LineCustomUpdate] = Field(
None, description="Customs data for this line"
)
description: Optional[LineDescriptionUpdate] = Field(
None, description="Description data for this line"
)
reference: Optional[LineReferenceUpdate] = Field(
None, description="Reference data for this line"
)
fa_data: Optional[FaLineItemUpdateDTO] = Field(
None, description="Fixed Asset data for this line"
)
class ItemResponse(ItemBase):
"""Schema for item response with nested data (one-to-many)"""
class LineItemResponse(LineItemBase):
"""Schema for single item response"""
id: int
lines: list[LineItemResponse] = Field(
default=[], description="List of line items")
invoice_id: int
line_number: int
model_config = ConfigDict(from_attributes=True)
# Part identification
part_number_id: Optional[int] = Field(
None, alias="part_number", serialization_alias="part_number_id"
)
component_part_number_id: Optional[int] = Field(
None,
alias="component_part_number",
serialization_alias="component_part_number_id",
)
class_id: Optional[int] = None
# Nested data
financial: Optional[LineFinancialResponse] = None
quantity: Optional[LineQuantityResponse] = None
customs: Optional[LineCustomResponse] = None
description: Optional[LineDescriptionResponse] = None
reference: Optional[LineReferenceResponse] = None
fa_data: Optional[FaLineItemResponseDTO] = None
# Fields populated from relationships
class_code: Optional[str] = None
class_description: Optional[str] = None
unit_of_measure_code: Optional[str] = None
# Additional fields that might be present
reference_number: Optional[str] = None
order: Optional[str] = None
warehouse: Optional[str] = None
location: Optional[str] = None
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
@model_validator(mode="before")
@classmethod
def extract_relationship_info(cls, data: Any) -> Any:
"""Extract class_code, class_description and unit_of_measure_code from relationships"""
if isinstance(data, dict):
return data
# It's an ORM object
result = {}
for key in cls.model_fields.keys():
if hasattr(data, key):
result[key] = getattr(data, key)
# Map model field names to schema field names for aliased fields
if hasattr(data, "part_number"):
result["part_number_id"] = data.part_number
if hasattr(data, "component_part_number"):
result["component_part_number_id"] = data.component_part_number
# Extract class info
if hasattr(data, "class_info") and data.class_info is not None:
result["class_code"] = data.class_info.class_code
result["class_description"] = data.class_info.description_es
# Extract unit of measure code
if (
hasattr(data, "unit_of_measure_info")
and data.unit_of_measure_info is not None
):
result["unit_of_measure_code"] = data.unit_of_measure_info.code
return result
class ItemListResponse(BaseModel):
class LineItemListResponse(BaseModel):
"""Schema for paginated item list"""
total: int = Field(..., description="Total number of items")
items: list[ItemResponse] = Field(..., description="List of items")
items: list[LineItemResponse] = Field(..., description="List of items")
skip: int = Field(..., description="Number of skipped items")
limit: int = Field(..., description="Maximum items per page")

View File

@@ -1,12 +1,15 @@
"""
Service layer for Items business logic
Handles CRUD operations for Item with complete one-to-one relationships:
Item -> LineItem -> LineFinancial
-> LineQuantity
-> LineCustoms
-> LineDescription
-> LineReference
-> FaLineItem (Fixed Assets - a24)
Handles CRUD operations for LineItem with complete one-to-one relationships:
LineItem -> LineFinancial
-> LineQuantity
-> LineCustoms
-> LineDescription
-> LineReference
-> FaLineItem (Fixed Assets - a24)
After refactoring: LineItem is the main entity, representing a single line item in an invoice.
There is no intermediate Item entity anymore. Each LineItem belongs directly to an InvoiceHeader.
"""
import logging
@@ -24,17 +27,14 @@ 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
logger = logging.getLogger(__name__)
@@ -52,8 +52,7 @@ class ItemService:
max_line = (
db.query(func.max(LineItem.line_number))
.join(Item, LineItem.item_id == Item.id)
.filter(Item.invoice_id == invoice_id)
.filter(LineItem.invoice_id == invoice_id)
.scalar()
)
@@ -62,12 +61,15 @@ class ItemService:
@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(Item).filter(Item.invoice_id == invoice_id).all()
all_lines = [line for item in items for line in item.lines]
all_lines.sort(key=lambda x: x.line_number if x.line_number else 0)
items = (
db.query(LineItem)
.filter(LineItem.invoice_id == invoice_id)
.order_by(LineItem.line_number)
.all()
)
for idx, line in enumerate(all_lines, start=1):
line.line_number = idx
for idx, item in enumerate(items, start=1):
item.line_number = idx
@staticmethod
def _lock_invoice(
@@ -143,24 +145,24 @@ class ItemService:
@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()
)
@@ -173,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_id.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),
)
)
@@ -224,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,
)
)
@@ -250,10 +252,10 @@ 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
@@ -271,109 +273,67 @@ class ItemService:
if not invoice_updated(db, item_data.invoice_id, tenant_id, company_id, errors):
errors.raise_if_errors("Error al crear el item")
# Lock invoice and pre-calculate line_numbers
# 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")
line_numbers = []
if item_data.lines:
starting_line = ItemService._get_next_line_number(db, item_data.invoice_id)
line_numbers = [starting_line + i for i in range(len(item_data.lines))]
# Calculate the next line number for this single item
line_number = ItemService._get_next_line_number(db, item_data.invoice_id)
# Validar cada line item que se va a crear
if item_data.lines:
for idx, line_data in enumerate(item_data.lines):
line_number = line_numbers[idx] # Usar el line_number calculado
# Validar el item
validate_create(
db,
item_data, # Schema Pydantic completo
tenant_id,
company_id,
errors,
line_number,
)
validate_create(
db,
line_data, # Schema Pydantic completo
item_data.invoice_id, # invoice_id
tenant_id,
company_id,
errors,
line_number,
)
# 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")
# Validaciones adicionales específicas del negocio
if line_data.fa_data and line_data.fa_data.is_subitem is None:
errors.add_required_error(
field=f"lines[{line_number}].fa_data.is_subitem"
)
if item_data.fa_data and item_data.fa_data.subitem_number is None:
errors.add_required_error(field=f"fa_data.subitem_number")
if line_data.fa_data and line_data.fa_data.subitem_number is None:
errors.add_required_error(
field=f"lines[{line_number}].fa_data.subitem_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[{line_number}].part_number",
message=f"Advertencia: El Número de Parte contiene apóstrofes y serán omitidos",
code="WARNING_APOSTROPHE",
)
# 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):
line_dict = line_data.model_dump(
exclude={
"financial",
"quantity",
"customs",
"description",
"reference",
"fa_data",
}
)
line_dict.update(
{
"item_id": db_item.id,
"tenant_id": tenant_id,
"company_id": company_id,
"line_number": (
line_numbers[idx]
if line_numbers
else ItemService._get_next_line_number(
db, item_data.invoice_id
)
),
}
)
# Map schema field names to model field names
line_dict["part_number"] = line_dict.pop("part_number_id", None)
line_dict["component_part_number"] = line_dict.pop(
"component_part_number_id", None
)
db_line = LineItem(**line_dict)
db.add(db_line)
db.flush()
# Create all nested data
ItemService._create_line_nested_data(
db, db_line, line_data, tenant_id, company_id
)
# Create all nested data
ItemService._create_line_nested_data(
db, db_item, item_data, tenant_id, company_id
)
db.commit()
db.refresh(db_item)
@@ -384,27 +344,27 @@ 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()
@@ -418,143 +378,85 @@ class ItemService:
):
errors.raise_if_errors("Error al actualizar el item")
# Pre-calcular line_numbers para cada línea (en update, las líneas se renumeran desde 1)
line_numbers = []
if item_data.lines:
line_numbers = [i + 1 for i in range(len(item_data.lines))]
# 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 cada line item que se va a actualizar
if item_data.lines:
for idx, line_data in enumerate(item_data.lines):
line_number = line_numbers[idx] # Usar el line_number calculado
# 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:
# Validar update con línea existente
validate_update(
db,
line_data, # Schema de update
existing_line, # LineItem existente en DB
invoice_id_to_lock, # invoice_id
tenant_id,
company_id,
errors,
line_number,
)
else:
# Es un nuevo line item, validar como creación
validate_create(
db,
line_data, # Schema Pydantic completo
invoice_id_to_lock, # invoice_id
tenant_id,
company_id,
errors,
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=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),
)
# 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[{line_number}].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[{line_number}].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[{line_number}].main_line_id",
message="Las subpartidas (tipo 'S') deben tener una partida principal",
solution=None,
code="MISSING_MAIN_LINE",
)
# 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 idx, line_data in enumerate(lines_data):
line_dict = line_data.model_dump(
exclude={
"financial",
"quantity",
"customs",
"description",
"reference",
"fa_data",
},
exclude_unset=True,
)
line_dict.update(
{
"item_id": db_item.id,
"tenant_id": tenant_id,
"company_id": company_id,
"line_number": idx + 1,
}
)
# Create new nested data
ItemService._create_line_nested_data(
db, db_item, item_data, tenant_id, company_id
)
# Map schema field names to model field names
line_dict["part_number"] = line_dict.pop("part_number_id", None)
line_dict["component_part_number"] = line_dict.pop(
"component_part_number_id", None
)
db_line = LineItem(**line_dict)
db.add(db_line)
db.flush()
# Create all nested data
ItemService._create_line_nested_data(
db, db_line, line_data, tenant_id, company_id
)
# Renumber all lines for this invoice to ensure consecutive numbering
ItemService._renumber_all_invoice_lines(db, db_item.invoice_id)
# 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)

View File

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

View File

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

View File

@@ -0,0 +1,356 @@
from typing import List, Dict, Any, Tuple
from sqlalchemy.orm import Session, joinedload
from .schemas import Mainx30GenerationRequest, ErrorValidacion
# --- MODELOS A76 ---
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceComplianceMx
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.clients_and_providers.models import ClientProvider
class ScaiiProcessor:
def __init__(self):
self.cuenta_partidas = 0
self.cuenta_facturas = 0
self.valor_total_factura = 0.0
self.peso_bruto_factura = 0.0
self.peso_neto_factura = 0.0
self.errores: List[ErrorValidacion] = []
def _obtener_datos_cliente(self, cliente: ClientProvider) -> dict:
"""Extrae de manera segura los datos del cliente/dirección"""
# Determine Tax ID: RFC for MX, Tax ID for others
address = cliente.address
pais_raw = (address.country or "MX").upper()
pais = "MX"
if pais_raw in ["MEXICO", "MEX", "MX"]:
pais = "MX"
elif pais_raw in ["USA", "US", "UNITED STATES"]:
pais = "US"
else:
pais = pais_raw[:2]
tax_id = ""
if pais == "MX":
tax_id = cliente.rfc or ""
else:
# Try generic tax_id field if exists, else generic field or RFC as fallback
# Providing a fallback to extra_information or web_key if needed, but per model inspection:
# We don't see a specific 'tax_id' field in ClientProvider model snippet.
# We see 'rfc'. Let's use RFC as generic holder or look for 'tax_id' if I missed it.
# Re-reading model: rfc is the only obvious one.
# Let's use RFC field for foreign tax id too unless instructed otherwise.
tax_id = cliente.rfc or ""
data = {
"nombre": (cliente.name or "")[:39],
"tax_id": tax_id[:15],
"broker": "", "calle": "", "cp": "", "ciudad": "", "estado": "", "pais": pais, "tel": ""
}
if cliente.programs:
data["broker"] = (cliente.programs.broker or "")[:6]
if address:
calle_comp = f"{address.streets or ''} {address.exterior_number or ''}".strip()
data["calle"] = calle_comp[:35]
data["cp"] = (address.postal_code or "")[:9]
data["ciudad"] = (address.city or "")[:20]
data["estado"] = (address.state or "")[:2].upper()
data["tel"] = (address.phone or "")[:15] # Remove default "000000"
return data
def procesar_facturas(
self, db: Session, manifiesto: str, empresa_dict: Dict[str, Any], request: Mainx30GenerationRequest
) -> Tuple[List[str], List[ErrorValidacion]]:
lineas = []
self.errores = []
# 1. Traer Facturas del Manifiesto
facturas = db.query(InvoiceHeader).join(
InvoiceComplianceMx, InvoiceHeader.id == InvoiceComplianceMx.invoice_id
).options(
joinedload(InvoiceHeader.financials),
joinedload(InvoiceHeader.compliance_mx)
).filter(
InvoiceComplianceMx.manifest_number == manifiesto
).all()
for factura in facturas:
self.cuenta_facturas += 1
f_val_total = 0.0
f_pb = 0.0
f_pn = 0.0
f_consec_partidas = 0
# --- MF20 / MF22: Per-Invoice Header at Manifest Level ---
# Sample: MF20AAK22-001 I10900 1234 1234
# Invoice(15) + Type(1?) + Port(5?) + ...
entry_port = manifiesto.replace("-", "")[:4] # or from manifest object if available here?
# Manifiesto passed to this method is just a string 'manifest_number'.
# We need to query manifest or pass it.
# Actually, `manifiesto` arg is just the number string.
# But we can pass the entry_port from service.py in empresa_dict or request?
# Let's check service.py.
# Assuming it is in empresa_dict for now (I will add it next step)
# --- MF20 / MF22: Per-Invoice Header at Manifest Level ---
# Sample: MF20AAK22-001 I10900 1234 1234
port_code = empresa_dict.get('entry_port', '')[:4]
manufacturer_id = empresa_dict.get('manufacturer_id', '')[:10]
# Constructing line to match sample length/spacing
lineas.append(
f"MF20"
f"{factura.invoice_number[:15]:<15}"
f"I{manufacturer_id:<15}"
f"{port_code:<20}"
f"{port_code:<4}"
)
lineas.append(f"MF22")
self.cuenta_partidas += 2
# --- IV01: Header de Factura ---
flete = float(factura.financials.freight) if factura.financials and factura.financials.freight else 0.0
fecha_str = factura.invoice_date.strftime("%y%m%d") if factura.invoice_date else "000000"
s_rfc = ""
c_rfc = ""
if factura.compliance_mx:
if factura.compliance_mx.provider_id:
s_obj = db.query(ClientProvider).filter(ClientProvider.id == factura.compliance_mx.provider_id).first()
if s_obj: s_rfc = s_obj.rfc or ""
if factura.compliance_mx.sold_to_id:
c_obj = db.query(ClientProvider).filter(ClientProvider.id == factura.compliance_mx.sold_to_id).first()
if c_obj: c_rfc = c_obj.rfc or ""
lineas.append(
f"IV01{factura.invoice_number[:15]:<15}"
f"{fecha_str}01 " # 6 + 3 = 9
f"{port_code:<11}" # Port (Use same as MF20)
f"{empresa_dict.get('broker', '')[:6]:<15}" # Broker
f"{s_rfc[:12]:<12}{c_rfc[:12]:<12}"
)
self.cuenta_partidas += 1
# --- IV02: Company Name ---
nombre_empresa = empresa_dict.get('nombre_empresa', '')[:40]
lineas.append(f"IV02 {nombre_empresa:<40}")
self.cuenta_partidas += 1
# --- IV10: Goods Description & Contact ---
# Dynamic Description from Invoice (observation_en or observation_es)
desc_global = (factura.observation_en or factura.observation_es or "")[:30]
contacto = empresa_dict.get('responsable', '')[:30]
lineas.append(f"IV10 {desc_global:<30}{contacto:<30}")
self.cuenta_partidas += 1
# --- IV11: Headers ---
lineas.append(f"IV11H")
lineas.append(f"IV11F")
self.cuenta_partidas += 2
# --- DATOS DE DIRECCIONES (S, C, T, I) ---
# Shipper (S) -> Proveedor de la factura
if factura.compliance_mx and factura.compliance_mx.provider_id:
s_cliente = db.query(ClientProvider).filter(ClientProvider.id == factura.compliance_mx.provider_id).first()
if s_cliente:
s_data = self._obtener_datos_cliente(s_cliente)
calle_cp = f"{s_data['calle']} {s_data['cp']}".strip()
lineas.append(f"IV12S {s_data['nombre'][:39]:<39}")
lineas.append(f"IV13S {calle_cp[:35]:<35}")
lineas.append(f"IV14S{s_data['ciudad'][:20]:<20}{s_data['estado'][:2]}{s_data['pais'][:2]}{s_data['tel'][:15]:<15}{s_data['tax_id']:<15}00000")
self.cuenta_partidas += 3
# Consignee / Vendido A (C)
c_data = None
if factura.compliance_mx and factura.compliance_mx.sold_to_id:
c_cliente = db.query(ClientProvider).filter(ClientProvider.id == factura.compliance_mx.sold_to_id).first()
if c_cliente:
c_data = self._obtener_datos_cliente(c_cliente)
calle_cp = f"{c_data['calle']} {c_data['cp']}".strip()
lineas.append(f"IV12C {c_data['nombre'][:39]:<39}")
lineas.append(f"IV13C {calle_cp[:35]:<35}")
lineas.append(f"IV14C{c_data['ciudad'][:20]:<20}{c_data['estado'][:2]}{c_data['pais'][:2]}{c_data['tel'][:15]:<15}{c_data['tax_id']:<15}00000")
self.cuenta_partidas += 3
# Ship To / Enviado A (T)
if factura.compliance_mx and factura.compliance_mx.shipped_to_id:
t_cliente = db.query(ClientProvider).filter(ClientProvider.id == factura.compliance_mx.shipped_to_id).first()
if t_cliente:
t_data = self._obtener_datos_cliente(t_cliente)
calle_cp = f"{t_data['calle']} {t_data['cp']}".strip()
lineas.append(f"IV12T {t_data['nombre'][:39]:<39}")
lineas.append(f"IV13T {calle_cp[:35]:<35}")
lineas.append(f"IV14T{t_data['ciudad'][:20]:<20}{t_data['estado'][:2]}{t_data['pais'][:2]}{t_data['tel'][:15]:<15}{t_data['tax_id']:<15}00000")
self.cuenta_partidas += 3
# Importer (I) - Sample shows it same as Consignee or Importer
if c_data:
# Reuse c_data calculation or re-fetch if needed. Reusing c_data structure.
calle_cp = f"{c_data['calle']} {c_data['cp']}".strip()
lineas.append(f"IV12I {c_data['nombre'][:39]:<39}")
lineas.append(f"IV13I {calle_cp[:35]:<35}")
lineas.append(f"IV14I{c_data['ciudad'][:20]:<20}{c_data['estado'][:2]}{c_data['pais'][:2]}{c_data['tel'][:15]:<15}{c_data['tax_id']:<15}00000")
self.cuenta_partidas += 3
# --- PARTIDAS (DETALLE IV20-IV27) ---
items_query = db.query(LineItem).filter(
LineItem.invoice_id == factura.id
).options(
joinedload(LineItem.part_info),
joinedload(LineItem.description),
joinedload(LineItem.financial),
joinedload(LineItem.quantity), # Added quantity relation
joinedload(LineItem.customs),
joinedload(LineItem.unit_of_measure_info)
).all()
for line in items_query:
f_consec_partidas += 1
part_num = line.part_info.part_number if line.part_info else "S/N"
po_num = factura.purchase_order or ""
desc = ""
if line.description:
desc = line.description.description_english or line.description.description_spanish or ""
# --- OBTENCIÓN DE DATOS DE LINEFINANCIAL / LINEQUANTITY ---
qty = 0.0; pb = 0.0; pn = 0.0; val_usd = 0.0
val_no_duty = 0.0; val_packing = 0.0
if line.quantity:
qty = float(line.quantity.quantity or 0.0)
pb = float(line.quantity.gross_weight or 0.0)
pn = float(line.quantity.net_weight or 0.0)
if pb == 0 and pn > 0: pb = pn
if line.financial:
val_usd = float(line.financial.value_usd or 0.0)
val_no_duty = float(line.financial.exempt_amount_usd or 0.0) # IV24
val_packing = float(line.financial.value_us_packing_usd or 0.0) # IV26
f_pb += pb
f_pn += pn
f_val_total += val_usd # Assuming Total Invoice Value is sum of line.value_usd
# Aduanas
hts_ame = ""
pais_orig = "MX"
if line.customs:
raw_hts = line.customs.american_fraction or line.customs.fraction or ""
hts_ame = raw_hts.replace(".", "").strip()
pais_orig = (line.customs.origin_country or "MX")[:2]
# UM
um_ame = "PC"
if line.unit_of_measure_info:
um_ame = line.unit_of_measure_info.american_code or "PC"
# Escritura (Igual que el Clarion)
lineas.append(f"IV20{f_consec_partidas:03d} {part_num[:25]:<25}A {po_num[:20]:<20}")
lineas.append(f"IV21{' ':21}{desc[:50]:<50}")
# IV22: Fix alignment based on sample
# Sample: N0000002235PCS000050000CN0000010000000000000000 000000549000000408
# HTS(10?) + Val(10) + UM(3) + Cant(9) + Pais(2) + ...
v_int = int(round(val_usd * 100))
q_int = int(round(qty * 1000)) # Sample 000050000 for 50? 50 * 1000 = 50000.
pb_int = int(round(pb * 100))
pn_int = int(round(pn * 100))
lineas.append(
f"IV22 N" # 10 spaces + N
f"{v_int:010d}" # Value (integer 10)
f"{um_ame[:3]:<3}" # UM (3)
f"{q_int:09d}" # Qty (integer 9)
f"{pais_orig[:2]:<2}" # Pais (2)
f"0000010000000000000000 " # Fixed (23 with space)
f"{pb_int:010d}" # Peso Bruto (10 chars)
f"{pn_int:010d}" # Peso Neto (10 chars)
)
# IV24 (No Duty / Exempt)
# Dynamic Logic: Use exempt_amount_usd if > 0
v_nd_int = int(round(val_no_duty * 100))
# IV24 uses same UM and Qty layout as IV22 but for NoDuty portion?
# Sample shows just value and then mostly zeros?
# Sample: IV24 0000000000 000000000 0000000000000000000000
# We will use v_nd_int. If 0, it renders as 0000000000.
if v_nd_int > 0:
# If there IS a No Duty value, we should probably output it.
# Format seems to start at same pos as IV22 Value?
# IV22 starts value at col 20 (approx).
# IV24 starts value at col 20 (approx).
# IV24 {Val} {Qty?} ...
# Given sample: `IV24 0000000000 000000000 ...`
# It looks like: Prefix(15) + Val(10) + Space(3) + Qty??(9) + ...
# Let's mimic structure
lineas.append(f"IV24 {v_nd_int:010d} {0:09d} 0000000000000000000000")
else:
lineas.append(f"IV24 {0:010d} {0:09d} 0000000000000000000000")
# IV26 (Packing)
# Dynamic Logic: Use value_us_packing_usd
v_p_int = int(round(val_packing * 100))
if v_p_int > 0:
lineas.append(f"IV26 {v_p_int:010d} {0:09d} 0000000000000000000000")
else:
lineas.append(f"IV26 {0:010d} {0:09d} 0000000000000000000000")
# IV27 (Unit Costs)
# Sample: IV27 000000000000000000000000000000000000000000000000000000000000000000
# If we have distinct values, maybe we should calculate unit costs?
# But legacy sample shows all zeros.
# Calculating separate unit costs for Duty/NoDuty/Packing:
c_u_d = val_usd / qty if qty > 0 else 0
c_u_nd = val_no_duty / qty if qty > 0 else 0
c_u_p = val_packing / qty if qty > 0 else 0
# If user wants NO HARDCODING, maybe we should populate this?
# But sample had 0s. Let's populate specific costs if values exist, else 0.
# Format: IV27 + 10 spaces + CostDuty(11) + CostNoDuty(11) + CostPacking(11) + ...
# Based on legacy Clarion: `FORMAT(Left(Loc:CostoUDuty),@n011v5)`
cud_int = int(round(c_u_d * 100000))
cund_int = int(round(c_u_nd * 100000))
cup_int = int(round(c_u_p * 100000))
lineas.append(f"IV27 {cud_int:011d}{cund_int:011d}{cup_int:011d}000000000000000000000000000000000")
self.cuenta_partidas += 6
# --- TOTALES FACTURA ---
# Sample: IV900000700000000000000000063320000005348
# IV90 + CantPartidas(5) + ValTotal(12) + PesoBruto(10) + PesoNeto(10)
f_val_int = int(round(f_val_total * 100))
f_pb_int = int(round(f_pb * 100))
f_pn_int = int(round(f_pn * 100))
lineas.append(f"IV90{f_consec_partidas:05d}{f_val_int:012d}{f_pb_int:010d}{f_pn_int:010d}")
self.cuenta_partidas += 1
self.valor_total_factura += f_val_total
self.peso_bruto_factura += f_pb
self.peso_neto_factura += f_pn
return lineas, self.errores
def _agregar_error(self, partida, id_err, desc, sol, tipo):
self.errores.append(ErrorValidacion(partida=partida, linea=0, descripcion=f"[ {id_err} ] {desc}", soluciones=sol, identificador=tipo))
# (Dummy Processors para que no truene el Service)
class ScafDefProcessor:
def __init__(self): self.cuenta_partidas=0; self.cuenta_facturas=0; self.valor_total_factura=0; self.peso_bruto_factura=0; self.peso_neto_factura=0
def procesar_facturas(self, db, manifiesto, empresa_dict, request): return [], []
class ScafTempProcessor:
def __init__(self): self.cuenta_partidas=0; self.cuenta_facturas=0; self.valor_total_factura=0; self.peso_bruto_factura=0; self.peso_neto_factura=0
def procesar_facturas(self, db, manifiesto, empresa_dict, request): return [], []

View File

@@ -0,0 +1,43 @@
from typing import Dict, Any
from fastapi import APIRouter, Depends, Body
from celery.result import AsyncResult
from core.celery_app import celery_app
from core.security import get_current_user
from .task import generar_transmission_file_async
from .schemas import Mainx30GenerationRequest
router = APIRouter()
@router.get("/tasks/{task_id}")
async def get_task_status(
task_id: str,
current_user: Dict[str, Any] = Depends(get_current_user)
):
task_result = AsyncResult(task_id, app=celery_app)
response = {
"task_id": task_id,
"state": task_result.state,
"result": None,
"info": None
}
if task_result.state == 'FAILURE':
response["result"] = str(task_result.result)
elif task_result.state == 'SUCCESS':
response["result"] = task_result.result
elif task_result.state == 'PROCESSING':
# Ensure info is serializable
response["info"] = task_result.info
return response
@router.post("/generate")
async def trigger_generation(
request: Mainx30GenerationRequest,
current_user: Dict[str, Any] = Depends(get_current_user)
):
tenant_id = current_user.get("tenant_id")
# Pass request as dict to Celery task
task = generar_transmission_file_async.delay(request.model_dump(), tenant_id)
return {"task_id": task.id, "message": "Generación iniciada"}

View File

@@ -0,0 +1,71 @@
from typing import List, Optional, Any
from pydantic import BaseModel, Field
class Mainx30GenerationRequest(BaseModel):
"""
Schema for the Mainx30 file generation request
"""
manifiestos: List[str] = Field(..., description="Lista de números de manifiesto a procesar")
nomenclatura_factura: bool = Field(False, description="Usar nomenclatura basada en factura")
consolidar_rbs: bool = Field(False, description="Consolidar por fracción RB System")
emanifest_fast_blanco: bool = Field(False, description="E-Manifest y FAST en blanco")
no_enviar_emanifest: bool = Field(False, description="No enviar E-Manifest")
consolidar_partidas: bool = Field(False, description="Consolidar partidas (XML OPTIMA Y RBS2)")
main_x40_emanifest: bool = Field(False, description="Main X40 E-Manifest")
main_x30_fedex: bool = Field(False, description="Main X30 (FEDEX)")
iv11: bool = Field(False, description="IV11")
iv42: bool = Field(False, description="IV42")
class ErrorValidacion(BaseModel):
"""
Schema for validation errors during file generation
"""
partida: int
linea: int
descripcion: str
soluciones: str
identificador: str
campos: str = ""
campos2: str = ""
class Mainx30Response(BaseModel):
"""
Schema for the generation response
"""
success: bool
message: str
task_id: Optional[str] = None
archivo_generado: Optional[str] = None
ruta_archivo: Optional[str] = None
content: Optional[str] = None
# Statistics
cuenta_partidas: int = 0
valor_total: float = 0.0
flete_total: float = 0.0
peso_bruto_total: float = 0.0
peso_neto_total: float = 0.0
cuenta_facturas: int = 0
# Validation
errores: List[ErrorValidacion] = []
tiene_inconsistencias: bool = False
class BrokerValidationResult(BaseModel):
es_valido: bool
mensaje_error: Optional[str] = None
broker_cliente: Optional[str] = None
class EmpresaDatos(BaseModel):
broker: str
responsable: str
rfc: str
tiene_linea_express: str
nombre_empresa: str = "AAKRON RULE CORPORATION"
manufacturer_id: str = "I10900"
ftp_key: str = "00SCSI"
class ConfiguracionSistema(BaseModel):
path_arch_transmision: str
utilizar_nombre_generico_mainx30: bool
utilizar_codigo_broker_cliente: bool

View File

@@ -0,0 +1,300 @@
import os
import tempfile
from datetime import date, datetime
from typing import List, Tuple, Optional
from pathlib import Path
from sqlalchemy.orm import Session
from fastapi import HTTPException
from .schemas import (
Mainx30GenerationRequest, Mainx30Response, ErrorValidacion,
EmpresaDatos, ConfiguracionSistema
)
# --- HELPERS ---
def fecha_clarion_a_iso(clarion_date):
"""Convierte fecha Clarion (días desde 1800-12-28) a ISO YYYY-MM-DD"""
if not clarion_date: return "1900-01-01"
try:
from datetime import date, timedelta
base_date = date(1800, 12, 28)
delta = timedelta(days=int(clarion_date))
return (base_date + delta).isoformat()
except:
return "1900-01-01"
# --- MODELOS A76 ---
from api.v1.modules.a76.manifests.manifest.models import Manifest
from api.v1.modules.a76.general_catalogs.company.models import Company as GEmpresa
# --- PROCESADORES ---
from .processors import ScaiiProcessor, ScafDefProcessor, ScafTempProcessor
class Mainx30Service:
def __init__(self):
self.errores_validacion: List[ErrorValidacion] = []
self.cuenta_partidas = 0
self.cuenta_facturas = 0
self.valor_total = 0.0
self.flete_total = 0.0
self.peso_bruto_total = 0.0
self.peso_neto_total = 0.0
def generar_mainx30_expo(
self,
db: Session,
request: Mainx30GenerationRequest,
task_instance=None
) -> Mainx30Response:
try:
self._inicializar_variables()
fecha_transmision = date.today().strftime("%y%m%d")
config_sistema = self._obtener_configuracion_sistema(db)
datos_empresa = self._obtener_datos_empresa(db)
self._validar_datos_empresa(datos_empresa)
if not request.manifiestos:
raise HTTPException(status_code=400, detail="No se seleccionaron manifiestos")
nombre_archivo = self._generar_nombre_archivo(config_sistema, request, request.manifiestos[0])
lineas_archivo = []
# Línea A
lineas_archivo.append(self._generar_linea_a(fecha_transmision, datos_empresa))
for manifiesto_num in request.manifiestos:
if task_instance:
task_instance.update_state(state='PROCESSING', meta={'status': f'Procesando {manifiesto_num}'})
lineas_manifiesto = self._procesar_manifiesto(
db, manifiesto_num, datos_empresa, fecha_transmision, request
)
lineas_archivo.extend(lineas_manifiesto)
# Línea Z
lineas_archivo.append(f"Z {self.cuenta_partidas:05d}")
ruta_completa = os.path.join("api/v1/modules/reports/generated", nombre_archivo)
self._escribir_archivo(ruta_completa, lineas_archivo)
return Mainx30Response(
success=len(self.errores_validacion) == 0,
message=self._generar_mensaje_resultado(ruta_completa),
archivo_generado=nombre_archivo,
ruta_archivo=ruta_completa,
cuenta_partidas=self.cuenta_partidas,
valor_total=self.valor_total,
flete_total=self.flete_total,
peso_bruto_total=self.peso_bruto_total,
peso_neto_total=self.peso_neto_total,
cuenta_facturas=self.cuenta_facturas,
errores=self.errores_validacion,
tiene_inconsistencias=len(self.errores_validacion) > 0,
content="\r\n".join(lineas_archivo)
)
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"Error generando Mainx30: {str(e)}")
def _inicializar_variables(self):
self.errores_validacion = []
self.cuenta_partidas = 1 # Empieza en 1 por la línea A
self.valor_total = 0.0
self.flete_total = 0.0
self.peso_bruto_total = 0.0
self.peso_neto_total = 0.0
self.cuenta_facturas = 0
def _procesar_manifiesto(
self,
db: Session,
manifiesto_num: str,
datos_empresa: EmpresaDatos,
fecha_transmision: str,
request: Mainx30GenerationRequest
) -> List[str]:
lineas = []
# --- TABLA A76: MANIFEST ---
manifiesto = db.query(Manifest).filter(
Manifest.manifest_number == manifiesto_num
).first()
if not manifiesto:
self._agregar_error_validacion(0, "MF", f"Manifiesto {manifiesto_num} no encontrado.", "Verificar BD", "MANIFIESTO")
return lineas
persona_cargo = manifiesto.person_in_charge or ""
if not persona_cargo:
self._agregar_error_validacion(0, "MF03", "Falta Persona a Cargo", "Capturar en Manifiesto", "MANIFIESTO")
num_manifiesto_clean = manifiesto_num.replace("-", "")
# Fecha en formato yyMMdd. Asumimos entry_date almacena Clarion Date o Timestamp.
fecha_entrada_str = "000000"
if manifiesto.entry_date:
try:
# Si es Clarion Date
fecha_iso = fecha_clarion_a_iso(manifiesto.entry_date)
fecha_entrada_str = datetime.strptime(fecha_iso, "%Y-%m-%d").strftime("%y%m%d")
except: pass
firms_code = manifiesto.entry_port_loc or ""
entry_port = manifiesto.entry_port or "000"
# MF01
# Sample Clarion: MF01AKR 1234 1234 2602061233026021345
# Layout:
# MF01 (4)
# Broker (6) -> "AKR "
# Port Ent (5) -> "1234 "
# Port Sal (5) -> "1234 "
# FecEnt (6) -> "260206"
# 12 (2) -> Prefix?
# 3 (1) -> Digit 3?
# 30 (2) -> Constant?
# FecTrans (6) -> "260213"
# Manifiesto (15?) -> "45 " (Sample has '45' at end, maybe manifest is '45'?)
# Let's align with sample string length and fields.
# "MF01"
# Broker: Left aligned 6 chars
# Port1: Left aligned 5 chars
# Port2: Left aligned 5 chars
# Date1: 6 chars
# "12330" (Hardcoded sequence based on sample analysis vs previous logic)
# Date2: 6 chars
# Manifest: Left aligned 15 chars? Sample "45" is at end.
# Re-analyzing sample: "MF01AKR 1234 1234 2602061233026021345"
# Length: 4+6+5+5+6+2+1+2+6+2 = 39? No.
# AKR : 6
# 1234 : 5
# 1234 : 5
# 260206: 6
# 12: 2
# 3: 1
# 30: 2
# 260213: 6
# 45: 2?
# Total: 4+6+5+5+6+5+6+2 = 39 chars displayed.
# My generated was: MF01123 000 000 0001011230260213123456879
# It was way off.
man_clean = num_manifiesto_clean[:15]
lineas.append(
f"MF01{datos_empresa.broker:<6}"
f"{entry_port:<5}"
f"{entry_port:<5}"
f"{fecha_entrada_str}"
f"12330{fecha_transmision}" # Fixed sequence "12330" inferred from sample
f"{man_clean:<15}"
)
self.cuenta_partidas += 1
# MF03
# Sample: MF03FRANCISCO 1234
# MF03 (4)
# Person (Top Left?)
# Sample: "MF03FRANCISCO 1234"
# It seems "FRANCISCO" is right after MF03. That's the PERSON.
# "1234" is the Gafete/License.
# My previous code put Carrier first: "MF03TRUCK Lopez Doriga..."
# Correct mapping: MF03 + Person(Included Name) + License
# Let's follow sample:
# MF03 + Person(15?) + License(15?)
# MF03
transportista = manifiesto.carrier_code or ""
persona = persona_cargo or ""
# 'driver_license' attribute does not exist in Manifest model.
# Using 'transport_code' or similar as fallback for license/gafete.
licencia = manifiesto.transport_code or ""
lineas.append(
f"MF03{persona[:15]:<15} {licencia[:15]:<15}"
)
self.cuenta_partidas += 1
# --- PROCESAR FACTURAS ---
empresa_dict = {
'broker': datos_empresa.broker,
'responsable': datos_empresa.responsable,
'rfc': datos_empresa.rfc,
'nombre_empresa': datos_empresa.nombre_empresa,
'entry_port': entry_port,
'manufacturer_id': datos_empresa.manufacturer_id
}
processor = ScaiiProcessor()
l_facturas, e_facturas = processor.procesar_facturas(db, manifiesto_num, empresa_dict, request)
lineas.extend(l_facturas)
self.errores_validacion.extend(e_facturas)
# Actualizar acumuladores Globales
self.cuenta_partidas += processor.cuenta_partidas
self.cuenta_facturas += processor.cuenta_facturas
self.valor_total += processor.valor_total_factura
self.peso_bruto_total += processor.peso_bruto_factura
self.peso_neto_total += processor.peso_neto_factura
# MF80 (Totales Manifiesto)
# Sample: MF80000000000000000200000001099200000002000000009736
# MF80 (4) + Val(12) + CantFact(4) + PB(12) + Flete(8) + PN(12)
# Importante: El sample muestra que los totales NO tienen puntos y son enteros (centavos).
val_int = int(round(processor.valor_total_factura * 100))
pb_int = int(round(processor.peso_bruto_factura * 100))
pn_int = int(round(processor.peso_neto_factura * 100))
flete_int = 0 # Flete total
lineas.append(
f"MF80{val_int:012d}"
f"{processor.cuenta_facturas:04d}"
f"{pb_int:012d}"
f"{flete_int:08d}"
f"{pn_int:012d}"
)
self.cuenta_partidas += 1
return lineas
# (Mantenemos los métodos auxiliares: _obtener_configuracion_sistema, _obtener_datos_empresa, _escribir_archivo, etc.)
def _obtener_configuracion_sistema(self, db): return ConfiguracionSistema(path_arch_transmision="/tmp", utilizar_nombre_generico_mainx30=True, utilizar_codigo_broker_cliente=False)
def _obtener_datos_empresa(self, db):
empresa = db.query(GEmpresa).first()
if not empresa:
# Fallback safe defaults if no company config found
return EmpresaDatos(broker="", responsable="", rfc="", tiene_linea_express="N", nombre_empresa="", manufacturer_id="", ftp_key="")
return EmpresaDatos(
broker=(empresa.broker_company or "")[:5],
responsable=(empresa.responsible or "")[:30],
rfc=(empresa.rfc or "")[:13],
tiene_linea_express=empresa.has_express_line or "N",
nombre_empresa=(empresa.name or "")[:40],
manufacturer_id=(empresa.manufacturer_id or "")[:10],
ftp_key=(empresa.ftp_key or "")[:10]
)
def _validar_datos_empresa(self, datos): pass
def _generar_nombre_archivo(self, c, r, m): return f"{m}_Mainx30.dat"
def _generar_linea_a(self, f, d):
# Sample: A 26021203AKR AKR 00SCSI
broker = d.broker.strip()[:6]
# Use ftp_key (password?)
password = (d.ftp_key or "00SCSI")[:6]
return f"A {f}03{broker:<6}{broker:<10}{password}"
def _escribir_archivo(self, ruta, lineas):
Path(ruta).parent.mkdir(parents=True, exist_ok=True)
with open(ruta, 'w', encoding='latin-1') as f: f.write('\r\n'.join(lineas))
def _generar_mensaje_resultado(self, nombre): return f"Generado: {nombre}"
def _agregar_error_validacion(self, partida, id_err, desc, sol, tipo):
self.errores_validacion.append(ErrorValidacion(partida=partida, linea=0, descripcion=desc, soluciones=sol, identificador=tipo))

View File

@@ -0,0 +1,46 @@
from celery import Task
from core.celery_app import celery_app
from core.celery_app import celery_app
from core.database import get_core_db as get_db
from .service import Mainx30Service
from .schemas import Mainx30GenerationRequest, Mainx30Response
@celery_app.task(name="generar_transmission_file_async", bind=True)
def generar_transmission_file_async(self, request_data: dict, tenant_id: int):
"""
Generates the transmission .dat file asynchronously using Mainx30Service
"""
try:
# Re-create db session for task
# Using next(get_db()) is a common pattern for obtaining a session in tasks
# but ensure context management
db = next(get_db())
# Deserialize request
request = Mainx30GenerationRequest(**request_data)
service = Mainx30Service()
response = service.generar_mainx30_expo(db, request, task_instance=self)
# Return result as dict for Celery serialization
# Ensure we return valid JSON serializable dict
result = response.model_dump()
# If we returned content directly, encode it if it's bytes (it's str here)
if response.content:
import base64
# Mainx30Service returns content as string with \r\n
encoded_content = base64.b64encode(response.content.encode('utf-8')).decode('utf-8')
# Add to result to match expected format by frontend dialog
result['content'] = encoded_content
result['file_name'] = response.archivo_generado
result['media_type'] = "text/plain"
return result
except Exception as e:
import traceback
traceback.print_exc()
self.update_state(state='FAILURE', meta={'exc_type': type(e).__name__, 'exc_message': str(e)})
# Re-raise to mark task as failed in Celery
raise e

View File

@@ -17,7 +17,7 @@ from api.v1.modules.a76.invoices.models import (
)
from api.v1.modules.a76.items.line_financials.models import LineFinancial
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
from api.v1.modules.a76.items.line_items.models import LineItem
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.clients_and_providers.models import (
ClientProvider,
ClientProviderAddress,
@@ -27,7 +27,7 @@ from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.pedmientos.models import Pedimentos
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
from api.v1.modules.a76.items.models import Item
from api.v1.modules.a76.items.models import LineItem
# --- TRANSPORTATION MODELS ---
from api.v1.modules.a76.transportation.transporters.models import Transporter
@@ -488,8 +488,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()
)
@@ -538,7 +537,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 = ""
@@ -565,7 +564,7 @@ class ConsolidadoImportacionMexService:
# --- Multi-Currency Normalization Logic ---
# Determine Line Currency context
# Use manual lookup instead of specific attribute
invoice_id = line.item.invoice_id if line.item else None
invoice_id = line.invoice_id
line_invoice = invoice_map.get(invoice_id) if invoice_id else None
line_currency_is_mxn = False

View File

@@ -13,7 +13,6 @@ from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics, InvoiceComplianceMx
from api.v1.modules.a76.items.line_financials.models import LineFinancial
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
from api.v1.modules.a76.items.line_items.models import LineItem
from api.v1.modules.a76.clients_and_providers.models import (
ClientProvider, ClientProviderAddress, ClientProviderPrograms
)
@@ -21,7 +20,7 @@ from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.pedmientos.models import Pedimentos
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
from api.v1.modules.a76.items.models import Item
from api.v1.modules.a76.items.models import LineItem
# --- TRANSPORTATION MODELS ---
from api.v1.modules.a76.transportation.transporters.models import Transporter
@@ -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 = []
@@ -350,7 +349,7 @@ class ConsolidadoImportacionMexService:
for line in lines:
qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first()
fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == line.id).first()
part_master = db.query(Part).filter(Part.id == line.part_number).first()
part_master = db.query(Part).filter(Part.id == line.part_number_id).first()
# --- Resolver Identificadores (MOVED INSIDE MAIN LOOP) ---
us_fraction_raw = ""
@@ -371,7 +370,7 @@ class ConsolidadoImportacionMexService:
# --- Multi-Currency Normalization Logic ---
# Determine Line Currency context
# Use manual lookup instead of specific attribute
invoice_id = line.item.invoice_id if line.item else None
invoice_id = line.invoice_id
line_invoice = invoice_map.get(invoice_id) if invoice_id else None
line_currency_is_mxn = False

View File

@@ -13,7 +13,6 @@ from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics
from api.v1.modules.a76.items.line_financials.models import LineFinancial
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
from api.v1.modules.a76.items.line_items.models import LineItem
from api.v1.modules.a76.clients_and_providers.models import (
ClientProvider,
ClientProviderAddress,
@@ -23,7 +22,7 @@ from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.pedmientos.models import Pedimentos
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
from api.v1.modules.a76.items.models import Item
from api.v1.modules.a76.items.models import LineItem
# --- TRANSPORTATION MODELS ---
from api.v1.modules.a76.transportation.transporters.models import Transporter
@@ -36,6 +35,7 @@ from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models impor
# --- 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 (
@@ -428,9 +428,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 = []
@@ -446,10 +445,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"
@@ -539,7 +538,7 @@ class FacturaImportacionMexService:
if uom:
unidad_desc = uom.description or uom.code
else:
unidad_desc = ""
unidad_desc = ""
partidas_list.append(
PartidaSchema(
@@ -558,7 +557,7 @@ class FacturaImportacionMexService:
if qty and qty.package_quantity
else 0
),
clave_bultos=(qty.package_key or "") if qty else "",
clave_bultos=(qty.package_info.key if (qty and qty.package_info) else ""),
peso_neto=self.formatear_numero(qty.net_weight if qty else 0),
peso_bruto=self.formatear_numero(
qty.gross_weight if qty else 0

View File

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

View File

@@ -10,18 +10,19 @@ from fastapi import HTTPException
from sqlalchemy.orm import Session
# --- MODELOS ---
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics
from api.v1.modules.a76.items.line_financials.models import LineFinancial
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
from api.v1.modules.a76.items.line_items.models import LineItem
from api.v1.modules.a76.clients_and_providers.models import (
ClientProvider, ClientProviderAddress, ClientProviderPrograms
ClientProvider,
ClientProviderAddress,
ClientProviderPrograms,
)
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.pedmientos.models import Pedimentos
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
from api.v1.modules.a76.items.models import Item
from api.v1.modules.a76.items.models import LineItem
# --- TRANSPORTATION MODELS ---
from api.v1.modules.a76.transportation.transporters.models import Transporter
@@ -30,32 +31,38 @@ from api.v1.modules.a76.transportation.trailers.models import Trailer
from api.v1.modules.a76.transportation.drivers.models import Driver
# --- MODELO DE FRACCIONES ---
from api.v1.modules.a76.general_catalogs.fractions.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):
@@ -108,18 +115,37 @@ class FacturaImportacionUsaService:
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 "",
@@ -127,58 +153,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:
@@ -186,26 +282,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 = ""
@@ -215,53 +322,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,
@@ -274,62 +417,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)
@@ -338,13 +506,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
@@ -352,39 +520,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)
@@ -392,22 +580,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:
@@ -422,26 +626,48 @@ class FacturaImportacionUsaService:
if target_path.exists():
with open(target_path, "rb") as image_file:
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
encoded_string = base64.b64encode(image_file.read()).decode(
"utf-8"
)
mime = "image/png"
if target_path.suffix.lower() in ['.jpg', '.jpeg']: mime = "image/jpeg"
if target_path.suffix.lower() in [".jpg", ".jpeg"]:
mime = "image/jpeg"
logo_b64 = f"data:{mime};base64,{encoded_string}"
except Exception as e:
print(f"Error loading logo: {e}")
context = {
'cliente_proveedor': datos.cliente_proveedor.model_dump(), 'cliente_vendido': datos.cliente_vendido.model_dump(),
'cliente_enviado': datos.cliente_enviado.model_dump(), 'factura': datos.factura.model_dump(),
'partidas': [p.model_dump() for p in datos.partidas], 'totales': datos.totales.model_dump(),
'logo_b64': logo_b64
"cliente_proveedor": datos.cliente_proveedor.model_dump(),
"cliente_vendido": datos.cliente_vendido.model_dump(),
"cliente_enviado": datos.cliente_enviado.model_dump(),
"factura": datos.factura.model_dump(),
"partidas": [p.model_dump() for p in datos.partidas],
"totales": datos.totales.model_dump(),
"logo_b64": logo_b64,
}
html_content = self.template.render(**context)
nombre = f"Commercial_Invoice_{datos.factura.numero}.{formato}"
if formato == "html": return html_content.encode('utf-8'), nombre, "text/html"
if progress_callback: progress_callback(90, "Generating PDF...")
options = {'page-size': 'Letter', 'margin-top': '0.5in', 'margin-right': '0.5in', 'margin-bottom': '0.5in', 'margin-left': '0.5in', 'encoding': "UTF-8", 'enable-local-file-access': None}
pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config())
if progress_callback: progress_callback(100, "Completed")
if formato == "html":
return html_content.encode("utf-8"), nombre, "text/html"
if progress_callback:
progress_callback(90, "Generating PDF...")
options = {
"page-size": "Letter",
"margin-top": "0.5in",
"margin-right": "0.5in",
"margin-bottom": "0.5in",
"margin-left": "0.5in",
"encoding": "UTF-8",
"enable-local-file-access": None,
}
pdf = pdfkit.from_string(
html_content,
False,
options=options,
configuration=self._get_wkhtmltopdf_config(),
)
if progress_callback:
progress_callback(100, "Completed")
return pdf, nombre, "application/pdf"

View File

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

View File

@@ -0,0 +1,369 @@
from typing import List, Dict, Any, Tuple
from sqlalchemy.orm import Session, joinedload
from datetime import datetime
from .schemas import Mainx30GenerationRequest, ErrorValidacion
# --- MODELOS A76 ---
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceComplianceMx
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.clients_and_providers.models import ClientProvider
class ScaiiProcessor:
def __init__(self):
self.cuenta_partidas = 0
self.cuenta_facturas = 0
self.valor_total_factura = 0.0
self.flete_total = 0.0
self.peso_bruto_factura = 0.0
self.peso_neto_factura = 0.0
self.errores: List[ErrorValidacion] = []
def _obtener_datos_cliente(self, cliente: ClientProvider) -> dict:
"""Extrae de manera segura los datos del cliente/dirección"""
address = cliente.address
pais_raw = (address.country or "MX").upper() if address else "MX"
pais = "MX"
if pais_raw in ["MEXICO", "MEX", "MX"]:
pais = "MX"
elif pais_raw in ["USA", "US", "UNITED STATES"]:
pais = "US"
else:
pais = pais_raw[:2]
tax_id = cliente.rfc or ""
data = {
"nombre": (cliente.name or "")[:39],
"tax_id": tax_id[:15],
"broker": "", "calle": "", "cp": "", "ciudad": "", "estado": "", "pais": pais, "tel": "",
"manufacturer_id": "", "tipo_ext_nac": "E"
}
# TipoExtNac logic from Clarion: N (National/ManufacturerID) or E (External/TaxID)
# We'll use TaxID as default for definitive if not specified
data["manufacturer_id"] = (cliente.programs.manufacturer_id or "")[:15] if cliente.programs else ""
if cliente.programs:
data["broker"] = (cliente.programs.broker or "")[:6]
if address:
calle_base = address.streets or ""
num_base = address.exterior_number or ""
# Prevent 'None' string
calle_comp = f"{calle_base} {num_base}".strip()
# Clarion expects 20 chars for city_state: 5 CP + 11 City + 4 State
cp_formatted = (address.postal_code or "")[:5]
city_formatted = (address.city or "")[:11]
state_formatted = (address.state or "")[:4]
data["city_state"] = f"{cp_formatted:<5}{city_formatted:<11}{state_formatted:<4}"
data["calle"] = calle_comp[:35]
data["cp"] = (address.postal_code or "")[:9]
data["ciudad"] = (address.city or "").strip()[:20]
data["estado"] = (address.state or "").strip()[:2].upper()
data["tel"] = (address.phone or "")[:15]
return data
def _agregar_error(self, partida, id_err, desc, sol, tipo):
self.errores.append(ErrorValidacion(partida=partida, linea=0, descripcion=f"[ {id_err} ] {desc}", soluciones=sol, identificador=tipo))
class ScafDefProcessor(ScaiiProcessor):
"""Procesador para Importación Definitiva basado en lógica Clarion (SComprasMexID)"""
def __init__(self):
super().__init__()
def procesar_facturas(
self, db: Session, facturas_nums: List[str], empresa_dict: Dict[str, Any], request: Mainx30GenerationRequest
) -> Tuple[List[str], List[ErrorValidacion]]:
lineas = []
self.errores = []
# 1. Traer Facturas por número
facturas = db.query(InvoiceHeader).join(
InvoiceComplianceMx, InvoiceHeader.id == InvoiceComplianceMx.invoice_id
).options(
joinedload(InvoiceHeader.financials),
joinedload(InvoiceHeader.compliance_mx),
joinedload(InvoiceHeader.logistics)
).filter(
InvoiceHeader.invoice_number.in_(facturas_nums)
).all()
entry_port = request.entry_port or ""
exit_port = request.exit_port or ""
fecha_trans = datetime.now().strftime("%y%m%d")
entry_port_desc = empresa_dict.get('entry_port_desc', 'PUERTO ENTRADA')[:15]
exit_port_desc = empresa_dict.get('exit_port_desc', 'PUERTO SALIDA')[:15]
main_activity = empresa_dict.get('main_activity', 'RAW MATERIAL')[:30]
city_state = empresa_dict.get('city_state', '')[:30]
for factura in facturas:
self.cuenta_facturas += 1
f_val_total = 0.0
f_pb = 0.0
f_pn = 0.0
f_consec_partidas = 0
# MF01: Header
mod_trans = factura.logistics.transport_mode if factura.logistics else "30"
f_fecha = factura.invoice_date.strftime("%y%m%d") if factura.invoice_date else fecha_trans
lineas.append(
f"MF01{empresa_dict['broker'][:6]:<6}"
f"{exit_port[:5]:<5}"
f"{entry_port[:5]:<5}"
f"{fecha_trans}"
f" {mod_trans[:2]:<2}"
f"{f_fecha}"
f"{factura.invoice_number[:15]:<15}"
)
self.cuenta_partidas += 1
# MF03
conductor = (factura.logistics.driver_name or "")[:23] if factura.logistics else ""
carrier = (factura.logistics.carrier_id or "")[:10]
lineas.append(
f"MF03{carrier:<10}{conductor:<23}{entry_port_desc:<15}{exit_port_desc:<15}"
)
self.cuenta_partidas += 1
# MF20
num_transporte = (factura.logistics.transport_num or "")[:15] if factura.logistics else ""
# Use dynamic city/state from entry_port description if available, otherwise blank
# Clarion format: 5 digit Zip (if relevant) + City State
# We will use the entry_port_desc (e.g. CD. JUAREZ CHIH) and assume a dummy zip '00000' if not parsed
# Or better, just use the entry_port_desc fully aligned
# Using entry_port_desc directly instead of hardcoded default
cruce_desc = entry_port_desc[:20] if entry_port_desc else " "
lineas.append(
f"MF20{factura.invoice_number[:15]:<15}I{num_transporte:<15}00000{cruce_desc:<20}"
f"{exit_port[:5]:<5}{entry_port_desc:<15} "
)
self.cuenta_partidas += 1
# MF22
lineas.append(f"MF22{main_activity:<60}")
self.cuenta_partidas += 1
# IV01
flete = int(round(float(factura.financials.freight or 0))) if factura.financials else 0
self.flete_total += float(factura.financials.freight or 0) if factura.financials else 0.0
s_tax = ""; c_tax = ""
if factura.compliance_mx:
if factura.compliance_mx.sold_to_id:
c_obj = db.query(ClientProvider).filter(ClientProvider.id == factura.compliance_mx.sold_to_id).first()
if c_obj: c_tax = (c_obj.rfc or "")[:12]
if factura.compliance_mx.provider_id:
s_obj = db.query(ClientProvider).filter(ClientProvider.id == factura.compliance_mx.provider_id).first()
if s_obj: s_tax = (s_obj.rfc or "")[:12]
# IV01 uses 11 spaces then 'C' per Clarion logic
# Adjusted validation for RFCs to avoid crashes or None
s_tax_safe = s_tax if s_tax else " "
c_tax_safe = c_tax if c_tax else " "
lineas.append(
f"IV01{factura.invoice_number[:15]:<15}{f_fecha}78{' ':<12}C"
f"{empresa_dict['broker'][:6]:<6}{flete:08d}{s_tax_safe:<13}{c_tax_safe:<13}"
)
self.cuenta_partidas += 1
# IV02: Vendor/Manufacturer Info
# Clarion: CliVen:ManufacterID, CliVen:Nombre
manufacturer_id = ""
vendor_name = ""
if factura.compliance_mx and factura.compliance_mx.provider_id:
vendor = db.query(ClientProvider).options(
joinedload(ClientProvider.programs)
).filter(ClientProvider.id == factura.compliance_mx.provider_id).first()
if vendor:
manufacturer_id = (vendor.programs.manufacturer_id or "")[:16] if vendor.programs else ""
vendor_name = (vendor.name or "")[:39]
lineas.append(f"IV02{manufacturer_id:<16}{vendor_name:<39}")
self.cuenta_partidas += 1
# IV10: Responsible
lineas.append(f"IV10 {main_activity[:30]:<30}{empresa_dict['responsable'][:30]:<30}")
self.cuenta_partidas += 1
# IV11: Obs
lineas.append(f"IV11H")
self.cuenta_partidas += 1
obs_line = f"IV11F{(factura.notes or '')[:70]:<70}" if request.iv11 else "IV11F"
lineas.append(obs_line)
self.cuenta_partidas += 1
# Participants IV12-14 (S, C, T, I)
# Shipper (S)
if factura.compliance_mx and factura.compliance_mx.provider_id:
s_cliente = db.query(ClientProvider).options(
joinedload(ClientProvider.address),
joinedload(ClientProvider.programs)
).filter(ClientProvider.id == factura.compliance_mx.provider_id).first()
if s_cliente:
s_data = self._obtener_datos_cliente(s_cliente)
broker_impo = s_data['broker'] or empresa_dict['broker']
lineas.append(f"IV12S{broker_impo[:6]:<6}{s_data['nombre'][:39]:<39}")
# Ensure spacing aligns with Clarion example (30 spaces + 35 address + space + CP)
lineas.append(f"IV13S{'':<30}{s_data['calle']:<35} {s_data['cp']:<9}")
# IV14: City(20)+State(2)+Country(2)+Phone(30?? No, Clarion example shows Phone then TaxID)
# Clarion example: IV14S... CITY... STMX... TAXID... TEL...
# Re-aligning based on provided example:
# IV14SAKRON NEUS 16-0919851 00000
# City (20) State (2) Country (2) Space(30) TaxID(15) Tel(5?)
lineas.append(f"IV14S{s_data['ciudad']:<20}{s_data['estado']:<2}{s_data['pais']:<2}{'':<30}{s_data['tax_id']:<15}{s_data['tel'][:5]:<5}")
self.cuenta_partidas += 3
# Consignee (C), Ship To (T), and Importer (I)
if factura.compliance_mx and factura.compliance_mx.sold_to_id:
c_cliente = db.query(ClientProvider).options(
joinedload(ClientProvider.address),
joinedload(ClientProvider.programs)
).filter(ClientProvider.id == factura.compliance_mx.sold_to_id).first()
if c_cliente:
c_data = self._obtener_datos_cliente(c_cliente)
broker_impo = c_data['broker'] or empresa_dict['broker']
# Consignee (C)
lineas.append(f"IV12C{broker_impo[:6]:<6}{c_data['nombre'][:39]:<39}")
lineas.append(f"IV13C{'':<30}{c_data['calle']:<35} {c_data['cp']:<9}")
lineas.append(f"IV14C{c_data['ciudad']:<20}{c_data['estado']:<2}{c_data['pais']:<2}{'':<30}{c_data['tax_id']:<15}{c_data['tel'][:5]:<5}")
self.cuenta_partidas += 3
# Ship To (T) - check if different
t_id = factura.compliance_mx.shipped_to_id
if t_id and t_id != factura.compliance_mx.sold_to_id:
t_cl = db.query(ClientProvider).options(
joinedload(ClientProvider.address),
joinedload(ClientProvider.programs)
).filter(ClientProvider.id == t_id).first()
if t_cl:
t_data = self._obtener_datos_cliente(t_cl)
b_t = t_data['broker'] or empresa_dict['broker']
lineas.append(f"IV12T{b_t[:6]:<6}{t_data['nombre'][:39]:<39}")
lineas.append(f"IV13T{'':<30}{t_data['calle']:<35} {t_data['cp']:<9}")
lineas.append(f"IV14T{t_data['ciudad']:<20}{t_data['estado']:<2}{t_data['pais']:<2}{'':<30}{t_data['tax_id']:<15}{t_data['tel'][:5]:<5}")
self.cuenta_partidas += 3
# Importer (I) - Usually same as Consignee in Definitive unless ShippedBy is set
i_id = factura.compliance_mx.shipped_by_id
if i_id and i_id != factura.compliance_mx.sold_to_id and i_id != t_id:
i_cl = db.query(ClientProvider).options(
joinedload(ClientProvider.address),
joinedload(ClientProvider.programs)
).filter(ClientProvider.id == i_id).first()
if i_cl:
i_data = self._obtener_datos_cliente(i_cl)
b_i = i_data['broker'] or empresa_dict['broker']
lineas.append(f"IV12I{b_i[:6]:<6}{i_data['nombre'][:39]:<39}")
lineas.append(f"IV13I{'':<30}{i_data['calle']:<35} {i_data['cp']:<9}")
lineas.append(f"IV14I{i_data['ciudad']:<20}{i_data['estado']:<2}{i_data['pais']:<2}{'':<30}{i_data['tax_id']:<15}{i_data['tel'][:5]:<5}")
self.cuenta_partidas += 3
else:
# Fallback: repeat C as I if not specified (following Clarion pattern)
lineas.append(f"IV12I{broker_impo[:6]:<6}{c_data['nombre'][:39]:<39}")
lineas.append(f"IV13I{'':<30}{c_data['calle']:<35} {c_data['cp']:<9}")
lineas.append(f"IV14I{c_data['ciudad']:<20}{c_data['estado']:<2}{c_data['pais']:<2}{'':<30}{c_data['tax_id']:<15}{c_data['tel'][:5]:<5}")
self.cuenta_partidas += 3
# Items IV20-27
items_headers = db.query(LineItem).filter(LineItem.invoice_id == factura.id).all()
item_ids = [ih.id for ih in items_headers]
if item_ids:
items_query = db.query(LineItem).filter(
LineItem.id.in_(item_ids)
).options(
joinedload(LineItem.part_info),
joinedload(LineItem.description),
joinedload(LineItem.financial),
joinedload(LineItem.quantity),
joinedload(LineItem.customs),
joinedload(LineItem.unit_of_measure_info)
).all()
for line in items_query:
f_consec_partidas += 1
part_num = (line.part_info.part_number if line.part_info else "S/N")[:25]
po = (line.order or "")[:20] if line.financial else ""
# IV20
lineas.append(f"IV20{f_consec_partidas:03d} {part_num:<25}C {po:<20}")
self.cuenta_partidas += 1
# IV21
desc_ingles = (line.description.description_english or "")[:50] if line.description else ""
lineas.append(f"IV21 {desc_ingles:<50}")
self.cuenta_partidas += 1
# Data for IV22
val_me = float(line.financial.value_usd or 0) if line.financial else 0.0
qty = float(line.quantity.quantity or 0) if line.quantity else 0.0
pb = float(line.quantity.gross_weight or 0) if line.quantity else 0.0
pn = float(line.quantity.net_weight or 0) if line.quantity else 0.0
costo_u = float(line.financial.unit_cost_capture or 0) if line.financial else 0.0
um = (line.unit_of_measure_info.american_code or "PCS")[:3] if line.unit_of_measure_info else "PCS"
pais = ((line.customs.origin_country or "MX")[:2]).upper() if line.customs else "MX"
hts = (line.customs.american_fraction or "").replace(".", "")[:10] if line.customs else ""
# Formato Clarion @n...v...
val_int = int(round(val_me * 100)) # @n010v2
qty_int = int(round(qty * 100)) # @n09v2
pb_int = int(round(pb * 100)) # @n09v2
pn_int = int(round(pn * 100)) # @n09v2
costo_int = int(round(costo_u * 100000)) # @n011v5
prog_ind = "S" if line.customs and line.customs.has_origin_certificate else "N"
# IV22
lineas.append(
f"IV22{hts:<10}{prog_ind}"
f"{val_int:010d}{um:<3}{qty_int:09d}{pais:<2}0000010000000000100000 "
f"{pb_int:09d}{pn_int:09d}"
)
self.cuenta_partidas += 1
# IV24, IV26 (Zeros per logic)
lineas.append(f"IV24 0000000000 000000000 0000000000000000000000")
lineas.append(f"IV26 0000000000 000000000 0000000000000000000000")
self.cuenta_partidas += 2
# IV27
lineas.append(f"IV27{hts:<10}{costo_int:011d}0000000000000000000000000000000000000 ")
self.cuenta_partidas += 1
# IV42 (Optional)
if request.iv42:
um4 = um[:4]
lineas.append(f"IV42 0{qty_int:09d}{um4:<4}0000000000 0000000000 0000000000 0000000000 ")
self.cuenta_partidas += 1
f_val_total += val_me
f_pb += pb
f_pn += pn
# IV90: Footer per Invoice
fv_int = int(round(f_val_total * 100))
fpb_int = int(round(f_pb * 100))
fpn_int = int(round(f_pn * 100))
lineas.append(f"IV90{f_consec_partidas:05d}{fv_int:012d}{fpb_int:010d}{fpn_int:010d}")
self.cuenta_partidas += 1
self.valor_total_factura += f_val_total
self.peso_bruto_factura += f_pb
self.peso_neto_factura += f_pn
return lineas, self.errores

View File

@@ -0,0 +1,41 @@
from typing import Dict, Any
from fastapi import APIRouter, Depends, Body
from celery.result import AsyncResult
from core.celery_app import celery_app
from core.security import get_current_user
from .task import generar_transmission_definitiva_async
from .schemas import Mainx30GenerationRequest
router = APIRouter()
@router.get("/tasks/{task_id}")
async def get_task_status(
task_id: str,
current_user: Dict[str, Any] = Depends(get_current_user)
):
task_result = AsyncResult(task_id, app=celery_app)
response = {
"task_id": task_id,
"state": task_result.state,
"result": None,
"info": None
}
if task_result.state == 'FAILURE':
response["result"] = str(task_result.result)
elif task_result.state == 'SUCCESS':
response["result"] = task_result.result
elif task_result.state == 'PROCESSING':
response["info"] = task_result.info
return response
@router.post("/generate")
async def trigger_generation(
request: Mainx30GenerationRequest,
current_user: Dict[str, Any] = Depends(get_current_user)
):
tenant_id = current_user.get("tenant_id")
task = generar_transmission_definitiva_async.delay(request.model_dump(), tenant_id)
return {"task_id": task.id, "message": "Generación Definitiva iniciada"}

View File

@@ -0,0 +1,72 @@
from typing import List, Optional, Any
from pydantic import BaseModel, Field
class Mainx30GenerationRequest(BaseModel):
"""
Schema for the Mainx30 file generation request for Definitive Import
"""
manifiestos: Optional[List[str]] = Field(None, description="Lista de números de manifiesto a procesar")
facturas: Optional[List[str]] = Field(None, description="Lista de números de factura a procesar")
entry_port: Optional[str] = Field(None, description="Puerto de entrada")
exit_port: Optional[str] = Field(None, description="Puerto de salida")
regimen: Optional[str] = Field("Definitiva", description="Regimen de importación (Temporal/Definitiva)")
nomenclatura_factura: bool = Field(False, description="Usar nomenclatura basada en factura")
consolidar_rbs: bool = Field(False, description="Consolidar por fracción RB System")
emanifest_fast_blanco: bool = Field(False, description="E-Manifest y FAST en blanco")
no_enviar_emanifest: bool = Field(False, description="No enviar E-Manifest")
consolidar_partidas: bool = Field(False, description="Consolidar partidas (XML OPTIMA Y RBS2)")
main_x40_emanifest: bool = Field(False, description="Main X40 E-Manifest")
main_x30_fedex: bool = Field(False, description="Main X30 (FEDEX)")
iv11: bool = Field(False, description="IV11")
iv42: bool = Field(False, description="IV42")
class ErrorValidacion(BaseModel):
"""
Schema for validation errors during file generation
"""
partida: int
linea: int
descripcion: str
soluciones: str
identificador: str
campos: str = ""
campos2: str = ""
class Mainx30Response(BaseModel):
"""
Schema for the generation response
"""
success: bool
message: str
task_id: Optional[str] = None
archivo_generado: Optional[str] = None
ruta_archivo: Optional[str] = None
content: Optional[str] = None
# Statistics
cuenta_partidas: int = 0
valor_total: float = 0.0
flete_total: float = 0.0
peso_bruto_total: float = 0.0
peso_neto_total: float = 0.0
cuenta_facturas: int = 0
# Validation
errores: List[ErrorValidacion] = []
tiene_inconsistencias: bool = False
class EmpresaDatos(BaseModel):
broker: str
responsable: str
rfc: str
tiene_linea_express: str
nombre_empresa: str = ""
manufacturer_id: str = ""
ftp_key: str = "00SCSI"
main_activity: str = "RAW MATERIAL"
city_state: str = ""
class ConfiguracionSistema(BaseModel):
path_arch_transmision: str
utilizar_nombre_generico_mainx30: bool
utilizar_codigo_broker_cliente: bool

View File

@@ -0,0 +1,175 @@
import os
import tempfile
from datetime import date, datetime
from typing import List, Tuple, Optional, Dict, Any
from pathlib import Path
from sqlalchemy.orm import Session, joinedload
from fastapi import HTTPException
from .schemas import (
Mainx30GenerationRequest, Mainx30Response, ErrorValidacion,
EmpresaDatos, ConfiguracionSistema
)
# --- MODELOS A76 ---
from api.v1.modules.a76.general_catalogs.company.models import Company as GEmpresa
from api.v1.modules.a76.general_catalogs.ports.models import Port
# --- PROCESADORES ---
from .processors import ScafDefProcessor
class Mainx30DefinitiveService:
def __init__(self):
self.errores_validacion: List[ErrorValidacion] = []
self.cuenta_partidas = 0
self.cuenta_facturas = 0
self.valor_total = 0.0
self.flete_total = 0.0
self.peso_bruto_total = 0.0
self.peso_neto_total = 0.0
def generar_mainx30(
self,
db: Session,
request: Mainx30GenerationRequest,
task_instance=None
) -> Mainx30Response:
try:
self._inicializar_variables()
# 1. Obtener Datos de Empresa
datos_empresa = self._obtener_datos_company(db)
emp_dict = datos_empresa.model_dump()
# 2. Obtener Descripciones de Puertos
if request.entry_port:
p_ent = db.query(Port).filter(Port.port_code == request.entry_port).first()
if p_ent: emp_dict['entry_port_desc'] = p_ent.description or p_ent.location_description or ""
if request.exit_port:
p_sal = db.query(Port).filter(Port.port_code == request.exit_port).first()
if p_sal: emp_dict['exit_port_desc'] = p_sal.description or p_sal.location_description or ""
# 3. Fecha de Transmisión (YYMMDD)
fecha_transmision = datetime.now().strftime("%y%m%d")
# 4. Determinar Procesador (Always ScafDefProcessor for this service)
processor = ScafDefProcessor()
# Both 'Definitiva' and 'DEFINITIVO SCAF' use the same heavy processor
# 5. Procesar Facturas
if not request.facturas:
raise HTTPException(status_code=400, detail="No se proporcionaron facturas para procesar.")
l_facturas, e_facturas = processor.procesar_facturas(db, request.facturas, emp_dict, request)
self.errores_validacion.extend(e_facturas)
# 6. Construir Líneas del Archivo
lineas = []
# Línea A
broker = (datos_empresa.broker or "")[:6]
# Clarion uses Broker twice in 'A' record for Definitive
ftp_key = (datos_empresa.ftp_key or "00SCSI")[:6]
lineas.append(f"A {fecha_transmision}03{broker:<6}{broker:<10}{ftp_key:<6}")
self.cuenta_partidas += 1
# Agregar Líneas de Facturas
lineas.extend(l_facturas)
self.cuenta_partidas += processor.cuenta_partidas
self.cuenta_facturas = processor.cuenta_facturas
self.valor_total = processor.valor_total_factura
self.peso_bruto_total = processor.peso_bruto_factura
self.peso_neto_total = processor.peso_neto_factura
self.flete_total = processor.flete_total
# MF80 (Totales Globales)
val_int = int(round(self.valor_total * 100))
pb_int = int(round(self.peso_bruto_total * 10000))
pn_int = int(round(self.peso_neto_total * 10000))
flete_int = int(round(self.flete_total * 100))
lineas.append(
f"MF80{val_int:012d}"
f"{self.cuenta_facturas:04d}"
f"{pb_int:012d}"
f"{flete_int:08d}"
f"{pn_int:012d}"
)
self.cuenta_partidas += 1
# Línea Z (Total de líneas)
lineas.append(f"Z {self.cuenta_partidas:05d}")
self.cuenta_partidas += 1
# 7. Generar Nombre y Guardar
nombre_archivo = f"{request.facturas[0][:15]}_Mainx30.dat"
if len(request.facturas) > 1:
nombre_archivo = f"MULTIPLE_Mainx30.dat"
if request.nomenclatura_factura and len(request.facturas) == 1:
nombre_archivo = f"{request.facturas[0][:15]}_Mainx30.dat"
content = '\r\n'.join(lineas)
return Mainx30Response(
success=len(self.errores_validacion) == 0,
message="Archivo generado" if len(self.errores_validacion) == 0 else "Archivo generado con errores de validación",
archivo_generado=nombre_archivo,
ruta_archivo="",
content=content,
errores_validacion=self.errores_validacion,
cuenta_partidas=self.cuenta_partidas,
valor_total=self.valor_total,
flete_total=self.flete_total,
peso_bruto_total=self.peso_bruto_total,
peso_neto_total=self.peso_neto_total,
cuenta_facturas=self.cuenta_facturas,
tiene_inconsistencias=len(self.errores_validacion) > 0
)
except HTTPException:
raise
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"Error generando Mainx30 Definitivo: {str(e)}")
def _inicializar_variables(self):
self.errores_validacion = []
self.cuenta_partidas = 0
self.valor_total = 0.0
self.flete_total = 0.0
self.peso_bruto_total = 0.0
self.peso_neto_total = 0.0
self.cuenta_facturas = 0
def _obtener_datos_company(self, db: Session) -> EmpresaDatos:
empresa = db.query(GEmpresa).options(joinedload(GEmpresa.addresses)).first()
if not empresa:
return EmpresaDatos(broker="", responsable="", rfc="", tiene_linea_express="N", nombre_empresa="", manufacturer_id="", ftp_key="", main_activity="", city_state="")
city_state = ""
main_addr = next((a for a in (empresa.addresses or []) if a.address_type == 'main'), None)
if not main_addr and empresa.addresses:
main_addr = empresa.addresses[0]
if main_addr:
cp = (main_addr.postal_code or "")[:5]
city = (main_addr.city or "")[:11]
state = (main_addr.state or "")[:4]
city_state = f"{cp:<5}{city:<11}{state:<4}"
return EmpresaDatos(
broker=(empresa.broker_company or "")[:5],
responsable=(empresa.responsible or "")[:30],
rfc=(empresa.rfc or "")[:13],
tiene_linea_express=empresa.has_express_line or "N",
nombre_empresa=(empresa.name or "")[:40],
manufacturer_id=(empresa.manufacturer_id or "")[:10],
ftp_key=(empresa.ftp_key or "")[:10],
main_activity=(empresa.main_activity or "")[:30],
city_state=city_state[:30]
)

View File

@@ -0,0 +1,45 @@
from core.celery_app import celery_app
from core.database import get_core_db as get_db
from .service import Mainx30DefinitiveService
from .schemas import Mainx30GenerationRequest
@celery_app.task(name="generar_transmission_definitiva_async", bind=True)
def generar_transmission_definitiva_async(self, request_data: dict, tenant_id: int):
"""
Celery task to generate Mainx30 file for Definitive Import
"""
try:
# Re-create db session for task
db = next(get_db())
# Deserialize request
request = Mainx30GenerationRequest(**request_data)
service = Mainx30DefinitiveService()
response = service.generar_mainx30(db, request, task_instance=self)
# Return result as dict for Celery serialization
result = response.model_dump()
# Add base64 encoding for content to match frontend expectations
if response.content:
import base64
encoded_content = base64.b64encode(response.content.encode('utf-8')).decode('utf-8')
result['content'] = encoded_content
result['file_name'] = response.archivo_generado
result['media_type'] = "text/plain"
return result
except Exception as e:
import traceback
traceback_print = traceback.format_exc()
self.update_state(
state='FAILURE',
meta={
'exc_type': type(e).__name__,
'exc_message': str(e),
'traceback': traceback_print
}
)
raise e

View File

@@ -0,0 +1,427 @@
from typing import List, Dict, Any, Tuple
from sqlalchemy.orm import Session, joinedload
from datetime import datetime
from .schemas import Mainx30GenerationRequest, ErrorValidacion
# --- MODELOS A76 ---
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceComplianceMx
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.clients_and_providers.models import ClientProvider
class ScaiiProcessor:
def __init__(self):
self.cuenta_partidas = 0
self.cuenta_facturas = 0
self.valor_total_factura = 0.0
self.flete_total = 0.0
self.peso_bruto_factura = 0.0
self.peso_neto_factura = 0.0
self.errores: List[ErrorValidacion] = []
def _obtener_datos_cliente(self, cliente: ClientProvider) -> dict:
"""Extrae de manera segura los datos del cliente/dirección"""
address = cliente.address
pais_raw = (address.country or "MX").upper() if address else "MX"
pais = "MX"
if pais_raw in ["MEXICO", "MEX", "MX"]:
pais = "MX"
elif pais_raw in ["USA", "US", "UNITED STATES"]:
pais = "US"
else:
pais = pais_raw[:2]
tax_id = cliente.rfc or ""
data = {
"nombre": (cliente.name or "")[:39],
"tax_id": tax_id[:15],
"broker": "", "calle": "", "cp": "", "ciudad": "", "estado": "", "pais": pais, "tel": ""
}
if cliente.programs:
data["broker"] = (cliente.programs.broker or "")[:6]
if address:
calle_comp = f"{address.streets or ''} {address.exterior_number or ''}".strip()
# Clarion expects 20 chars for city_state: 5 CP + 11 City + 4 State
cp_formatted = (address.postal_code or "")[:5]
city_formatted = (address.city or "")[:11]
state_formatted = (address.state or "")[:4]
data["city_state"] = f"{cp_formatted:<5}{city_formatted:<11}{state_formatted:<4}"
data["calle"] = calle_comp[:35]
data["cp"] = (address.postal_code or "")[:9]
data["ciudad"] = (address.city or "")[:20]
data["estado"] = (address.state or "")[:2].upper()
data["tel"] = (address.phone or "")[:15]
return data
def procesar_facturas(
self, db: Session, manifiesto: str, empresa_dict: Dict[str, Any], request: Mainx30GenerationRequest
) -> Tuple[List[str], List[ErrorValidacion]]:
# This base method is used for Manifest-based processing (Exportacion/Legacy)
lineas = []
self.errores = []
return lineas, self.errores
def _agregar_error(self, partida, id_err, desc, sol, tipo):
self.errores.append(ErrorValidacion(partida=partida, linea=0, descripcion=f"[ {id_err} ] {desc}", soluciones=sol, identificador=tipo))
# --- PROCESADORES ESPECIFICOS ---
class ScafDefProcessor(ScaiiProcessor):
"""Procesador para Importación Definitiva"""
def __init__(self):
super().__init__()
def procesar_facturas(
self, db: Session, facturas_nums: List[str], empresa_dict: Dict[str, Any], request: Mainx30GenerationRequest
) -> Tuple[List[str], List[ErrorValidacion]]:
# Placeholder for Definitiva logic
lineas = []
self.errores = []
return lineas, self.errores
class ScafTempProcessor(ScaiiProcessor):
"""Procesador para Importación Temporal basado en lógica Clarion"""
def __init__(self):
super().__init__()
def procesar_facturas(
self, db: Session, facturas_nums: List[str], empresa_dict: Dict[str, Any], request: Mainx30GenerationRequest
) -> Tuple[List[str], List[ErrorValidacion]]:
lineas = []
self.errores = []
# 1. Traer Facturas por número (Importación Temporal trabaja por factura)
facturas = db.query(InvoiceHeader).join(
InvoiceComplianceMx, InvoiceHeader.id == InvoiceComplianceMx.invoice_id
).options(
joinedload(InvoiceHeader.financials),
joinedload(InvoiceHeader.compliance_mx),
joinedload(InvoiceHeader.logistics)
).filter(
InvoiceHeader.invoice_number.in_(facturas_nums)
).all()
# Helper for ports from request
entry_port = request.entry_port or ""
exit_port = request.exit_port or ""
# Date for transmission records (YYMMDD)
fecha_trans = datetime.now().strftime("%y%m%d")
# Port descriptions from empresa_dict (populated in service.py)
entry_port_desc = empresa_dict.get('entry_port_desc', 'PUERTO ENTRADA')[:15]
exit_port_desc = empresa_dict.get('exit_port_desc', 'PUERTO SALIDA')[:15]
main_activity = empresa_dict.get('main_activity', 'RAW MATERIAL')[:30]
city_state = empresa_dict.get('city_state', '')[:30]
for factura in facturas:
self.cuenta_facturas += 1
f_val_total = 0.0
f_pb = 0.0
f_pn = 0.0
f_consec_partidas = 0
# MF01: Header per Invoice in Importacion Temporal
mod_trans = factura.logistics.transport_mode if factura.logistics else "30"
f_fecha = factura.invoice_date.strftime("%y%m%d") if factura.invoice_date else fecha_trans
lineas.append(
f"MF01{empresa_dict['broker'][:6]:<6}"
f"{exit_port[:5]:<5}"
f"{entry_port[:5]:<5}"
f"{fecha_trans}"
f" {mod_trans[:2]:<2}"
f"{f_fecha}"
f"{factura.invoice_number[:15]:<15}"
)
self.cuenta_partidas += 1
# MF03: Dynamic Driver/Carrier info
conductor = (factura.logistics.driver_name or "") if factura.logistics else ""
# Carrier Logic from Clarion:
# IF ERRORCODE() = 35 THEN Loc:NumTransporte = '00000TRUCK'
# ELSE IF GenTra:NombreCorto = '' THEN Loc:NumTransporte = GenTra:Nombre
# ELSE Loc:NumTransporte = GenTra:NombreCorto
carrier = "00000TRUCK"
if factura.logistics:
# Logic simplified: assume carrier_id holds the correct code/name or fallback
carrier = (factura.logistics.carrier_id or "00000TRUCK")
lineas.append(
f"MF03{carrier[:10]:<10}{conductor[:23]:<23}{entry_port_desc:<15}{exit_port_desc:<15}"
)
self.cuenta_partidas += 1
# MF20
num_transporte = (factura.logistics.transport_num or "") if factura.logistics else ""
lineas.append(
f"MF20{factura.invoice_number[:15]:<15}I{num_transporte[:15]:<15}{city_state[:20]:<20}"
f"{exit_port[:5]:<5}{entry_port_desc:<15} "
)
self.cuenta_partidas += 1
# MF22
lineas.append(f"MF22{main_activity:<60}")
self.cuenta_partidas += 1
# IV01: Header
f_fecha = factura.invoice_date.strftime("%y%m%d") if factura.invoice_date else "000000"
flete = int(round(float(factura.financials.freight or 0))) if factura.financials else 0
self.flete_total += float(factura.financials.freight or 0) if factura.financials else 0.0
s_tax = ""; c_tax = ""
if factura.compliance_mx:
if factura.compliance_mx.sold_to_id:
c_obj = db.query(ClientProvider).filter(ClientProvider.id == factura.compliance_mx.sold_to_id).first()
if c_obj: c_tax = c_obj.rfc[:12] if c_obj.rfc else ""
if factura.compliance_mx.provider_id:
s_obj = db.query(ClientProvider).filter(ClientProvider.id == factura.compliance_mx.provider_id).first()
if s_obj: s_tax = s_obj.rfc[:12] if s_obj.rfc else ""
lineas.append(
f"IV01{factura.invoice_number[:15]:<15}{f_fecha}{entry_port:<5}{' ':<11}C"
f"{empresa_dict['broker'][:6]:<6}{flete:08d}{c_tax:<13}"
)
self.cuenta_partidas += 1
# IV02: Shipper Name
lineas.append(f"IV02 {empresa_dict['nombre_empresa'][:40]:<40}")
self.cuenta_partidas += 1
# IV10: Responsible
lineas.append(f"IV10 {main_activity[:30]:<30}{empresa_dict['responsable'][:30]:<30}")
self.cuenta_partidas += 1
# IV11: Obs
lineas.append(f"IV11H")
lineas.append(f"IV11F")
self.cuenta_partidas += 2
# IV12-14 (S, C, T, I)
# Shipper (S)
if factura.compliance_mx and factura.compliance_mx.provider_id:
s_cliente = db.query(ClientProvider).filter(ClientProvider.id == factura.compliance_mx.provider_id).first()
if s_cliente:
s_data = self._obtener_datos_cliente(s_cliente)
lineas.append(f"IV12S {s_data['nombre'][:39]:<39}")
lineas.append(f"IV13S {s_data['calle'][:35]:<35}{s_data['cp']:<9}")
lineas.append(f"IV14S{s_data['ciudad'][:20]:<20}{s_data['state_full'][:2] if 'state_full' in s_data else s_data['estado'][:2]}{s_data['pais'][:2]}{s_data['tel'][:30]:<30}{s_data['tax_id']:<15}00000")
self.cuenta_partidas += 3
# Consignee (C), Ship To (T), and Intermediate (I)
if factura.compliance_mx and factura.compliance_mx.sold_to_id:
c_cliente = db.query(ClientProvider).filter(ClientProvider.id == factura.compliance_mx.sold_to_id).first()
if c_cliente:
c_data = self._obtener_datos_cliente(c_cliente)
l12c = f"IV12C {c_data['nombre'][:39]:<39}"
l13c = f"IV13C {c_data['calle'][:35]:<35}{c_data['cp']:<9}"
l14c = f"IV14C{c_data['ciudad'][:20]:<20}{c_data['estado'][:2]}{c_data['pais'][:2]}{c_data['tel'][:30]:<30}{c_data['tax_id']:<15}00000"
# Output C
lineas.extend([l12c, l13c, l14c])
self.cuenta_partidas += 3
# T (Ship To) - Only if different from C
t_id = factura.compliance_mx.shipped_to_id
if t_id and t_id != factura.compliance_mx.sold_to_id:
t_cl = db.query(ClientProvider).filter(ClientProvider.id == t_id).first()
if t_cl:
t_data = self._obtener_datos_cliente(t_cl)
lineas.append(f"IV12T {t_data['nombre'][:39]:<39}")
lineas.append(f"IV13T {t_data['calle'][:35]:<35}{t_data['cp']:<9}")
lineas.append(f"IV14T{t_data['ciudad'][:20]:<20}{t_data['estado'][:2]}{t_data['pais'][:2]}{t_data['tel'][:30]:<30}{t_data['tax_id']:<15}00000")
self.cuenta_partidas += 3
# I (Intermediate) - Only if different from C and T
i_id = factura.compliance_mx.shipped_by_id
if i_id and i_id != factura.compliance_mx.sold_to_id and i_id != t_id:
i_cl = db.query(ClientProvider).filter(ClientProvider.id == i_id).first()
if i_cl:
i_data = self._obtener_datos_cliente(i_cl)
lineas.append(f"IV12I {i_data['nombre'][:39]:<39}")
lineas.append(f"IV13I {i_data['calle'][:35]:<35}{i_data['cp']:<9}")
lineas.append(f"IV14I{i_data['ciudad'][:20]:<20}{i_data['estado'][:2]}{i_data['pais'][:2]}{i_data['tel'][:30]:<30}{i_data['tax_id']:<15}00000")
self.cuenta_partidas += 3
# Partidas IV20, IV21, IV22, IV24, IV26, IV27
# Let's get Item IDs first to ensure we find them
items_headers = db.query(LineItem).filter(LineItem.invoice_id == factura.id).all()
item_ids = [ih.id for ih in items_headers]
if item_ids:
items_query = db.query(LineItem).filter(
LineItem.id.in_(item_ids)
).options(
joinedload(LineItem.part_info),
joinedload(LineItem.description),
joinedload(LineItem.financial),
joinedload(LineItem.quantity),
joinedload(LineItem.customs),
joinedload(LineItem.unit_of_measure_info)
).all()
for line in items_query:
f_consec_partidas += 1
part_num = line.part_info.part_number if line.part_info else "S/N"
# IV20
lineas.append(f"IV20{f_consec_partidas:03d} {part_num[:25]:<25}C")
self.cuenta_partidas += 1
# IV21
lineas.append(f"IV21")
self.cuenta_partidas += 1
# Data
val_me = float(line.financial.value_usd or 0) if line.financial else 0.0
qty = float(line.quantity.quantity or 0) if line.quantity else 0.0
pb = float(line.quantity.gross_weight or 0) if line.quantity else 0.0
pn = float(line.quantity.net_weight or 0) if line.quantity else 0.0
costo_u = float(line.financial.unit_cost_capture or 0) if line.financial else 0.0
um = line.unit_of_measure_info.american_code if line.unit_of_measure_info else "PCS"
pais = (line.customs.origin_country or "MX")[:2] if line.customs else "MX"
hts = (line.customs.american_fraction or "").replace(".", "")[:10] if line.customs else ""
val_int = int(round(val_me * 10000))
qty_int = int(round(qty * 10000))
pb_int = int(round(pb * 10000))
pn_int = int(round(pn * 10000))
costo_int = int(round(costo_u * 100000))
# IV22
lineas.append(
f"IV22{hts:<10} "
f"{val_int:010d}{um[:3]:<3}{qty_int:09d}{pais:<2}0000010000000000100000 "
f"{pb_int:010d}{pn_int:010d}"
)
self.cuenta_partidas += 1
# IV24, IV26 (Zeros)
lineas.append(f"IV24 0000000000 000000000 0000000000000000000000")
lineas.append(f"IV26 0000000000 000000000 0000000000000000000000")
self.cuenta_partidas += 2
# IV27
lineas.append(f"IV27{hts:<10}{costo_int:011d}0000000000000000000000000000000000000")
self.cuenta_partidas += 1
f_val_total += val_me
f_pb += pb
f_pn += pn
# IV90: Footer per Invoice
# IV90
f_val_int = int(round(f_val_total * 100))
f_pb_int = int(round(f_pb * 10000))
f_pn_int = int(round(f_pn * 10000))
lineas.append(f"IV90{f_consec_partidas:05d}{f_val_int:012d}{f_pb_int:010d}{f_pn_int:010d}")
self.cuenta_partidas += 1
self.valor_total_factura += f_val_total
self.peso_bruto_factura += f_pb
self.peso_neto_factura += f_pn
return lineas, self.errores
class ScaiiTempProcessor(ScafTempProcessor):
"""
Procesador para Importación Temporal (Versión Ligera/SCAII)
Se salta los registros MF20, MF22, IV10-14 para mayor velocidad y menor detalle.
"""
def procesar_facturas(
self, db: Session, facturas_nums: List[str], empresa_dict: Dict[str, Any], request: Mainx30GenerationRequest
) -> Tuple[List[str], List[ErrorValidacion]]:
lineas = []
self.errores = []
# 1. Traer Facturas por número
facturas = db.query(InvoiceHeader).join(
InvoiceComplianceMx, InvoiceHeader.id == InvoiceComplianceMx.invoice_id
).options(
joinedload(InvoiceHeader.financials),
joinedload(InvoiceHeader.compliance_mx),
joinedload(InvoiceHeader.logistics)
).filter(
InvoiceHeader.invoice_number.in_(facturas_nums)
).all()
entry_port = request.entry_port or ""
exit_port = request.exit_port or ""
fecha_trans = datetime.now().strftime("%y%m%d")
entry_port_desc = empresa_dict.get('entry_port_desc', 'PUERTO ENTRADA')[:15]
exit_port_desc = empresa_dict.get('exit_port_desc', 'PUERTO SALIDA')[:15]
for factura in facturas:
self.cuenta_facturas += 1
f_val_total = 0.0
f_consec_partidas = 0
# MF01
mod_trans = factura.logistics.transport_mode if factura.logistics else "30"
f_fecha = factura.invoice_date.strftime("%y%m%d") if factura.invoice_date else fecha_trans
lineas.append(
f"MF01{empresa_dict['broker'][:6]:<6}{exit_port[:5]:<5}{entry_port[:5]:<5}"
f"{fecha_trans} {mod_trans[:2]:<2}{f_fecha}{factura.invoice_number[:15]:<15}"
)
self.cuenta_partidas += 1
# MF03
conductor = (factura.logistics.driver_name or "") if factura.logistics else ""
carrier = (factura.logistics.carrier_id or "00000TRUCK")
lineas.append(f"MF03{carrier[:10]:<10}{conductor[:23]:<23}{entry_port_desc:<15}{exit_port_desc:<15}")
self.cuenta_partidas += 1
# IV01
f_fecha_iv = factura.invoice_date.strftime("%y%m%d") if factura.invoice_date else "000000"
flete = int(round(float(factura.financials.freight or 0))) if factura.financials else 0
self.flete_total += float(factura.financials.freight or 0) if factura.financials else 0.0
lineas.append(
f"IV01{factura.invoice_number[:15]:<15}{f_fecha_iv}{entry_port:<5}{' ':<11}C"
f"{empresa_dict['broker'][:6]:<6}{flete:08d}{'':<13}"
)
self.cuenta_partidas += 1
# Partidas IV20, IV21, IV22
items_headers = db.query(LineItem).filter(LineItem.invoice_id == factura.id).all()
item_ids = [ih.id for ih in items_headers]
if item_ids:
items_query = db.query(LineItem).filter(LineItem.id.in_(item_ids)).all()
for line in items_query:
f_consec_partidas += 1
part_num = line.part_info.part_number if line.part_info else "S/N"
# IV20
lineas.append(f"IV20{f_consec_partidas:03d} {part_num[:25]:<25}C")
self.cuenta_partidas += 1
# IV22
qty = float(line.quantity.quantity or 0) if line.quantity else 0.0
val_me = float(line.financial.value_usd or 0) if line.financial else 0.0
hts = (line.customs.american_fraction or "").replace(".", "")[:10] if line.customs else ""
val_int = int(round(val_me * 10000))
qty_int = int(round(qty * 10000))
lineas.append(f"IV22{hts:<10} {val_int:010d}PCS{qty_int:09d}MX0000010000000000100000 00000000000000000000")
self.cuenta_partidas += 1
f_val_total += val_me
# IV90
fv_int = int(round(f_val_total * 100))
lineas.append(f"IV90{f_consec_partidas:05d}{fv_int:012d}00000000000000000000")
self.cuenta_partidas += 1
self.valor_total_factura += f_val_total
return lineas, self.errores

View File

@@ -0,0 +1,43 @@
from typing import Dict, Any
from fastapi import APIRouter, Depends, Body
from celery.result import AsyncResult
from core.celery_app import celery_app
from core.security import get_current_user
from .task import generar_transmission_temporal_async
from .schemas import Mainx30GenerationRequest
router = APIRouter()
@router.get("/tasks/{task_id}")
async def get_task_status(
task_id: str,
current_user: Dict[str, Any] = Depends(get_current_user)
):
task_result = AsyncResult(task_id, app=celery_app)
response = {
"task_id": task_id,
"state": task_result.state,
"result": None,
"info": None
}
if task_result.state == 'FAILURE':
response["result"] = str(task_result.result)
elif task_result.state == 'SUCCESS':
response["result"] = task_result.result
elif task_result.state == 'PROCESSING':
# Ensure info is serializable
response["info"] = task_result.info
return response
@router.post("/generate")
async def trigger_generation(
request: Mainx30GenerationRequest,
current_user: Dict[str, Any] = Depends(get_current_user)
):
tenant_id = current_user.get("tenant_id")
# Pass request as dict to Celery task
task = generar_transmission_temporal_async.delay(request.model_dump(), tenant_id)
return {"task_id": task.id, "message": "Generación Temporal iniciada"}

View File

@@ -0,0 +1,77 @@
from typing import List, Optional, Any
from pydantic import BaseModel, Field
class Mainx30GenerationRequest(BaseModel):
"""
Schema for the Mainx30 file generation request
"""
manifiestos: Optional[List[str]] = Field(None, description="Lista de números de manifiesto a procesar")
facturas: Optional[List[str]] = Field(None, description="Lista de números de factura a procesar")
entry_port: Optional[str] = Field(None, description="Puerto de entrada")
exit_port: Optional[str] = Field(None, description="Puerto de salida")
regimen: Optional[str] = Field("Temporal", description="Regimen de importación (Temporal/Definitiva)")
nomenclatura_factura: bool = Field(False, description="Usar nomenclatura basada en factura")
consolidar_rbs: bool = Field(False, description="Consolidar por fracción RB System")
emanifest_fast_blanco: bool = Field(False, description="E-Manifest y FAST en blanco")
no_enviar_emanifest: bool = Field(False, description="No enviar E-Manifest")
consolidar_partidas: bool = Field(False, description="Consolidar partidas (XML OPTIMA Y RBS2)")
main_x40_emanifest: bool = Field(False, description="Main X40 E-Manifest")
main_x30_fedex: bool = Field(False, description="Main X30 (FEDEX)")
iv11: bool = Field(False, description="IV11")
iv42: bool = Field(False, description="IV42")
class ErrorValidacion(BaseModel):
"""
Schema for validation errors during file generation
"""
partida: int
linea: int
descripcion: str
soluciones: str
identificador: str
campos: str = ""
campos2: str = ""
class Mainx30Response(BaseModel):
"""
Schema for the generation response
"""
success: bool
message: str
task_id: Optional[str] = None
archivo_generado: Optional[str] = None
ruta_archivo: Optional[str] = None
content: Optional[str] = None
# Statistics
cuenta_partidas: int = 0
valor_total: float = 0.0
flete_total: float = 0.0
peso_bruto_total: float = 0.0
peso_neto_total: float = 0.0
cuenta_facturas: int = 0
# Validation
errores: List[ErrorValidacion] = []
tiene_inconsistencias: bool = False
class BrokerValidationResult(BaseModel):
es_valido: bool
mensaje_error: Optional[str] = None
broker_cliente: Optional[str] = None
class EmpresaDatos(BaseModel):
broker: str
responsable: str
rfc: str
tiene_linea_express: str
nombre_empresa: str = "AAKRON RULE CORPORATION"
manufacturer_id: str = "I10900"
ftp_key: str = "00SCSI"
main_activity: str = "RAW MATERIAL"
city_state: str = ""
class ConfiguracionSistema(BaseModel):
path_arch_transmision: str
utilizar_nombre_generico_mainx30: bool
utilizar_codigo_broker_cliente: bool

View File

@@ -0,0 +1,177 @@
import os
import tempfile
from datetime import date, datetime
from typing import List, Tuple, Optional, Dict, Any
from pathlib import Path
from sqlalchemy.orm import Session, joinedload
from fastapi import HTTPException
from .schemas import (
Mainx30GenerationRequest, Mainx30Response, ErrorValidacion,
EmpresaDatos, ConfiguracionSistema
)
# --- MODELOS A76 ---
from api.v1.modules.a76.manifests.manifest.models import Manifest
from api.v1.modules.a76.general_catalogs.company.models import Company as GEmpresa
from api.v1.modules.a76.general_catalogs.ports.models import Port
# --- PROCESADORES ---
from .processors import ScaiiProcessor, ScafDefProcessor, ScafTempProcessor, ScaiiTempProcessor
class Mainx30Service:
def __init__(self):
self.errores_validacion: List[ErrorValidacion] = []
self.cuenta_partidas = 0
self.cuenta_facturas = 0
self.valor_total = 0.0
self.flete_total = 0.0
self.peso_bruto_total = 0.0
self.peso_neto_total = 0.0
def generar_mainx30(
self,
db: Session,
request: Mainx30GenerationRequest,
task_instance=None
) -> Mainx30Response:
try:
self._inicializar_variables()
# 1. Obtener Datos de Empresa
datos_empresa = self._obtener_datos_company(db)
emp_dict = datos_empresa.model_dump()
# 2. Obtener Descripciones de Puertos
if request.entry_port:
p_ent = db.query(Port).filter(Port.port_code == request.entry_port).first()
if p_ent: emp_dict['entry_port_desc'] = p_ent.description or p_ent.location_description or ""
if request.exit_port:
p_sal = db.query(Port).filter(Port.port_code == request.exit_port).first()
if p_sal: emp_dict['exit_port_desc'] = p_sal.description or p_sal.location_description or ""
# 3. Fecha de Transmisión (Clarion @D11 = mm/dd/yy, but example uses YYMMDD)
fecha_transmision = datetime.now().strftime("%y%m%d")
# 4. Determinar Procesador
processor = ScafTempProcessor() # Now 'Temporal' defaults to Heavy (SCAF) logic per user request
if request.regimen == "TEMPORAL SCAF":
processor = ScaiiTempProcessor() # 'TEMPORAL SCAF' uses light (SCAII) logic
elif request.regimen == "Definitiva" or request.regimen == "DEFINITIVO SCAF":
processor = ScafDefProcessor()
# 5. Procesar Facturas
if not request.facturas:
raise HTTPException(status_code=400, detail="No se proporcionaron facturas para procesar.")
l_facturas, e_facturas = processor.procesar_facturas(db, request.facturas, emp_dict, request)
self.errores_validacion.extend(e_facturas)
# 6. Construir Líneas del Archivo
lineas = []
# Línea A
broker = (datos_empresa.broker or "")[:6]
ftp_key = (datos_empresa.ftp_key or "00SCSI")[:6]
lineas.append(f"A {fecha_transmision}03{broker:<6}{broker:<10}{ftp_key:<6}")
self.cuenta_partidas += 1
# Agregar Líneas de Facturas
lineas.extend(l_facturas)
self.cuenta_partidas += processor.cuenta_partidas
self.cuenta_facturas = processor.cuenta_facturas
self.valor_total = processor.valor_total_factura
self.peso_bruto_total = processor.peso_bruto_factura
self.peso_neto_total = processor.peso_neto_factura
# MF80 (Totales Globales)
val_int = int(round(self.valor_total * 100))
pb_int = int(round(self.peso_bruto_total * 10000))
pn_int = int(round(self.peso_neto_total * 10000))
flete_int = 0
lineas.append(
f"MF80{val_int:012d}"
f"{self.cuenta_facturas:04d}"
f"{pb_int:012d}"
f"{flete_int:08d}"
f"{pn_int:012d}"
)
self.cuenta_partidas += 1
# Línea Z (Total de líneas)
lineas.append(f"Z {self.cuenta_partidas:05d}")
self.cuenta_partidas += 1
# 7. Generar Nombre y Guardar
nombre_archivo = f"{request.facturas[0][:15]}_Mainx30.dat"
if len(request.facturas) > 1:
nombre_archivo = f"MULTIPLE_Mainx30.dat"
if request.nomenclatura_factura and len(request.facturas) == 1:
nombre_archivo = f"{request.facturas[0][:15]}_Mainx30.dat"
content = '\r\n'.join(lineas)
return Mainx30Response(
success=len(self.errores_validacion) == 0,
message="Archivo generado" if len(self.errores_validacion) == 0 else "Archivo generado con errores de validación",
archivo_generado=nombre_archivo,
ruta_archivo="",
content=content,
errores_validacion=self.errores_validacion,
cuenta_partidas=self.cuenta_partidas,
valor_total=self.valor_total,
peso_bruto_total=self.peso_bruto_total,
peso_neto_total=self.peso_neto_total,
cuenta_facturas=self.cuenta_facturas
)
except HTTPException:
raise
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"Error generando Mainx30: {str(e)}")
def _inicializar_variables(self):
self.errores_validacion = []
self.cuenta_partidas = 0
self.valor_total = 0.0
self.flete_total = 0.0
self.peso_bruto_total = 0.0
self.peso_neto_total = 0.0
self.cuenta_facturas = 0
def _obtener_datos_company(self, db: Session) -> EmpresaDatos:
empresa = db.query(GEmpresa).options(joinedload(GEmpresa.addresses)).first()
if not empresa:
return EmpresaDatos(broker="", responsable="", rfc="", tiene_linea_express="N", nombre_empresa="", manufacturer_id="", ftp_key="", main_activity="", city_state="")
# Get city/state from main address, or first found
city_state = ""
main_addr = next((a for a in (empresa.addresses or []) if a.address_type == 'main'), None)
if not main_addr and empresa.addresses:
main_addr = empresa.addresses[0]
if main_addr:
# Clarion expects 20 chars for city_state: 5 CP + 11 City + 4 State
cp = (main_addr.postal_code or "")[:5]
city = (main_addr.city or "")[:11]
state = (main_addr.state or "")[:4]
city_state = f"{cp:<5}{city:<11}{state:<4}"
return EmpresaDatos(
broker=(empresa.broker_company or "")[:5],
responsable=(empresa.responsible or "")[:30],
rfc=(empresa.rfc or "")[:13],
tiene_linea_express=empresa.has_express_line or "N",
nombre_empresa=(empresa.name or "")[:40],
manufacturer_id=(empresa.manufacturer_id or "")[:10],
ftp_key=(empresa.ftp_key or "")[:10],
main_activity=(empresa.main_activity or "")[:30],
city_state=city_state[:30]
)

View File

@@ -0,0 +1,46 @@
from celery import Task
from core.celery_app import celery_app
from core.celery_app import celery_app
from core.database import get_core_db as get_db
from .service import Mainx30Service
from .schemas import Mainx30GenerationRequest, Mainx30Response
@celery_app.task(name="generar_transmission_temporal_async", bind=True)
def generar_transmission_temporal_async(self, request_data: dict, tenant_id: int):
"""
Generates the transmission .dat file asynchronously using Mainx30Service
"""
try:
# Re-create db session for task
# Using next(get_db()) is a common pattern for obtaining a session in tasks
# but ensure context management
db = next(get_db())
# Deserialize request
request = Mainx30GenerationRequest(**request_data)
service = Mainx30Service()
response = service.generar_mainx30(db, request, task_instance=self)
# Return result as dict for Celery serialization
# Ensure we return valid JSON serializable dict
result = response.model_dump()
# If we returned content directly, encode it if it's bytes (it's str here)
if response.content:
import base64
# Mainx30Service returns content as string with \r\n
encoded_content = base64.b64encode(response.content.encode('utf-8')).decode('utf-8')
# Add to result to match expected format by frontend dialog
result['content'] = encoded_content
result['file_name'] = response.archivo_generado
result['media_type'] = "text/plain"
return result
except Exception as e:
import traceback
traceback.print_exc()
self.update_state(state='FAILURE', meta={'exc_type': type(e).__name__, 'exc_message': str(e)})
# Re-raise to mark task as failed in Celery
raise e

View File

@@ -40,6 +40,9 @@ from .reports.exportacion.descargo.routes import router as discharge_reports_rou
from .manifests.manifest.routes import router as manifests_router
from .manifests.driver.routes import router as manifest_drivers_router
from .manifests.manifiesto_anexo.routes import router as manifest_anexos_router
from .reports.exportacion.transmission.MAINX30.routes import router as transmission_router
from .reports.importacion.transmission.temporal.MAINX30.routes import router as transmission_temporal_router
from .reports.importacion.transmission.definitive.MAINX30.routes import router as transmission_definitive_router
# Router principal
@@ -129,6 +132,24 @@ router.include_router(
tags=["a76 / manifests"]
)
router.include_router(
transmission_router,
prefix="/a76/reports/exportacion/transmission",
tags=["a76 / reports"]
)
router.include_router(
transmission_temporal_router,
prefix="/a76/reports/importacion/transmission/temporal",
tags=["a76 / reports"]
)
router.include_router(
transmission_definitive_router,
prefix="/a76/reports/importacion/transmission/definitive",
tags=["a76 / reports"]
)
# Registrar router de bitácora
from .audit_log.router import router as audit_log_router
router.include_router(audit_log_router, prefix="/a76/audit-log", tags=["Audit Log"])