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:
@@ -1,11 +1,14 @@
|
||||
"""
|
||||
Audit Log Events
|
||||
"""
|
||||
|
||||
from sqlalchemy import event, inspect
|
||||
from sqlalchemy.orm import Session
|
||||
from .services.service import AuditService
|
||||
from .utils.serialization import serialize_for_json
|
||||
from core.context import get_user_context
|
||||
|
||||
|
||||
def register_audit_listeners(models_to_audit):
|
||||
"""
|
||||
Register SQLAlchemy listeners for given models
|
||||
@@ -15,16 +18,23 @@ def register_audit_listeners(models_to_audit):
|
||||
event.listen(model, "after_update", after_update_listener)
|
||||
event.listen(model, "after_delete", after_delete_listener)
|
||||
|
||||
|
||||
def _get_current_username():
|
||||
try:
|
||||
context = get_user_context()
|
||||
if context:
|
||||
# Token usually has 'preferred_username' or 'name' or 'sub'
|
||||
return context.get("preferred_username") or context.get("email") or context.get("sub") or "System"
|
||||
return (
|
||||
context.get("preferred_username")
|
||||
or context.get("email")
|
||||
or context.get("sub")
|
||||
or "System"
|
||||
)
|
||||
except:
|
||||
pass
|
||||
return "System"
|
||||
|
||||
|
||||
def after_insert_listener(mapper, connection, target):
|
||||
"""
|
||||
Listener for INSERT operations
|
||||
@@ -32,8 +42,9 @@ def after_insert_listener(mapper, connection, target):
|
||||
table_name = target.__tablename__
|
||||
record_data = {c.name: getattr(target, c.name) for c in mapper.columns}
|
||||
username = _get_current_username()
|
||||
company_id = getattr(target, "company_id", None)
|
||||
|
||||
company_id = getattr(target, "company_id", None) or getattr(target, "id", None)
|
||||
tenant_id = getattr(target, "tenant_id", None)
|
||||
|
||||
# Create a session bound to the connection
|
||||
session = Session(bind=connection)
|
||||
try:
|
||||
@@ -44,24 +55,26 @@ def after_insert_listener(mapper, connection, target):
|
||||
record_data=record_data,
|
||||
username=username,
|
||||
record_id=str(getattr(target, "id", "")),
|
||||
company_id=company_id
|
||||
company_id=company_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error logging insert: {e}")
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def after_update_listener(mapper, connection, target):
|
||||
"""
|
||||
Listener for UPDATE operations
|
||||
"""
|
||||
table_name = target.__tablename__
|
||||
|
||||
|
||||
state = inspect(target)
|
||||
changes = {}
|
||||
old_values = {}
|
||||
new_values = {}
|
||||
|
||||
|
||||
for attr in state.attrs:
|
||||
hist = attr.history
|
||||
if hist.has_changes():
|
||||
@@ -74,6 +87,8 @@ def after_update_listener(mapper, connection, target):
|
||||
|
||||
record_data = {c.name: getattr(target, c.name) for c in mapper.columns}
|
||||
username = _get_current_username()
|
||||
company_id = getattr(target, "company_id", None) or getattr(target, "id", None)
|
||||
tenant_id = getattr(target, "tenant_id", None)
|
||||
|
||||
session = Session(bind=connection)
|
||||
try:
|
||||
@@ -84,13 +99,16 @@ def after_update_listener(mapper, connection, target):
|
||||
record_data=record_data,
|
||||
username=username,
|
||||
record_id=str(getattr(target, "id", "")),
|
||||
old_values=old_values,
|
||||
new_values=new_values
|
||||
old_values=serialize_for_json(old_values),
|
||||
new_values=serialize_for_json(new_values),
|
||||
company_id=company_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error logging update: {e}")
|
||||
print(f"Error logging update: {e}")
|
||||
finally:
|
||||
session.close()
|
||||
session.close()
|
||||
|
||||
|
||||
def after_delete_listener(mapper, connection, target):
|
||||
"""
|
||||
@@ -99,7 +117,9 @@ def after_delete_listener(mapper, connection, target):
|
||||
table_name = target.__tablename__
|
||||
record_data = {c.name: getattr(target, c.name) for c in mapper.columns}
|
||||
username = _get_current_username()
|
||||
|
||||
company_id = getattr(target, "company_id", None) or getattr(target, "id", None)
|
||||
tenant_id = getattr(target, "tenant_id", None)
|
||||
|
||||
session = Session(bind=connection)
|
||||
try:
|
||||
AuditService.log_crud_operation(
|
||||
@@ -108,9 +128,11 @@ def after_delete_listener(mapper, connection, target):
|
||||
operation_type="DELETE",
|
||||
record_data=record_data,
|
||||
username=username,
|
||||
record_id=str(getattr(target, "id", ""))
|
||||
record_id=str(getattr(target, "id", "")),
|
||||
company_id=company_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error logging delete: {e}")
|
||||
print(f"Error logging delete: {e}")
|
||||
finally:
|
||||
session.close()
|
||||
session.close()
|
||||
|
||||
@@ -18,5 +18,10 @@ class UserContextMiddleware(BaseHTTPMiddleware):
|
||||
# Log error or ignore
|
||||
pass
|
||||
|
||||
response = await call_next(request)
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
# Re-raise the exception to let other middleware and handlers deal with it
|
||||
raise
|
||||
|
||||
return response
|
||||
|
||||
@@ -4,10 +4,18 @@ Audit Log Models
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Date, Time, DateTime, Text, Index, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB, ARRAY
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
class AuditLog(Base):
|
||||
class AuditLog(Base, TenantScopedMixin, TimestampMixin):
|
||||
__tablename__ = "audit_logs"
|
||||
__table_args__ = (
|
||||
Index('idx_audit_username_date', 'username', 'date'),
|
||||
Index('idx_audit_procedure_date', 'procedure', 'date'),
|
||||
Index('idx_audit_system_timestamp', 'system', 'timestamp'),
|
||||
Index('idx_audit_table_record', 'table_name', 'record_id'),
|
||||
{"schema": "a76"} # Use the a76 schema for audit logs
|
||||
)
|
||||
|
||||
# Primary Key
|
||||
spec_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
@@ -22,9 +30,7 @@ class AuditLog(Base):
|
||||
|
||||
# Technical Columns
|
||||
timestamp = Column(DateTime(timezone=True), nullable=False, index=True) # Combined for queries
|
||||
system = Column(String(20), nullable=False, index=True, default="SCAF")
|
||||
company_id = Column(Integer, nullable=True, index=True)
|
||||
tenant_id = Column(Integer, nullable=True, index=True)
|
||||
system = Column(String(20), nullable=False, index=True, default="fixed_asset")
|
||||
|
||||
# Traceability
|
||||
table_name = Column(String(100), nullable=True, index=True)
|
||||
@@ -42,15 +48,4 @@ class AuditLog(Base):
|
||||
endpoint = Column(String(500), nullable=True)
|
||||
request_method = Column(String(10), nullable=True)
|
||||
session_id = Column(String(50), nullable=True, index=True)
|
||||
execution_time_ms = Column(Integer, nullable=True)
|
||||
|
||||
# Metadata
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
# Composite Indexes for common filters
|
||||
__table_args__ = (
|
||||
Index('idx_audit_username_date', 'username', 'date'),
|
||||
Index('idx_audit_procedure_date', 'procedure', 'date'),
|
||||
Index('idx_audit_system_timestamp', 'system', 'timestamp'),
|
||||
Index('idx_audit_table_record', 'table_name', 'record_id'),
|
||||
)
|
||||
execution_time_ms = Column(Integer, nullable=True)
|
||||
|
||||
@@ -142,6 +142,10 @@ class AuditMapper:
|
||||
("doda", "CREATE"): "ADD DODA",
|
||||
("doda", "UPDATE"): "EDIT DODA",
|
||||
("doda", "DELETE"): "DELETE DODA",
|
||||
|
||||
("company", "CREATE"): "ADD COMPANY",
|
||||
("company", "UPDATE"): "EDIT COMPANY",
|
||||
("company", "DELETE"): "DELETE COMPANY",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -86,7 +86,8 @@ class AuditService:
|
||||
# Context
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
company_id: Optional[int] = None
|
||||
company_id: Optional[int] = None,
|
||||
tenant_id: Optional[int] = None
|
||||
):
|
||||
"""
|
||||
High-level wrapper to log CRUD operations automatically mapping to Legacy format
|
||||
@@ -149,7 +150,8 @@ class AuditService:
|
||||
changed_fields=changed_fields,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
company_id=company_id
|
||||
company_id=company_id,
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
7
backend/api/v1/modules/a76/audit_log/utils/__init__.py
Normal file
7
backend/api/v1/modules/a76/audit_log/utils/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Audit Log Utilities
|
||||
"""
|
||||
|
||||
from .serialization import serialize_for_json
|
||||
|
||||
__all__ = ["serialize_for_json"]
|
||||
44
backend/api/v1/modules/a76/audit_log/utils/serialization.py
Normal file
44
backend/api/v1/modules/a76/audit_log/utils/serialization.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Serialization utilities for audit logs
|
||||
"""
|
||||
|
||||
from datetime import date, datetime, time
|
||||
from decimal import Decimal
|
||||
from uuid import UUID
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
def serialize_value(value: Any) -> Any:
|
||||
"""
|
||||
Convert a Python value to a JSON-serializable type
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
elif isinstance(value, (date, datetime)):
|
||||
return value.isoformat()
|
||||
elif isinstance(value, time):
|
||||
return value.isoformat()
|
||||
elif isinstance(value, Decimal):
|
||||
return float(value)
|
||||
elif isinstance(value, UUID):
|
||||
return str(value)
|
||||
elif isinstance(value, bytes):
|
||||
return value.decode("utf-8", errors="replace")
|
||||
elif isinstance(value, (list, tuple)):
|
||||
return [serialize_value(item) for item in value]
|
||||
elif isinstance(value, dict):
|
||||
return {key: serialize_value(val) for key, val in value.items()}
|
||||
else:
|
||||
# For any other type, try to return as-is (str, int, float, bool, None)
|
||||
# If it fails JSON serialization later, at least we tried
|
||||
return value
|
||||
|
||||
|
||||
def serialize_for_json(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Recursively serialize a dictionary for JSON storage
|
||||
"""
|
||||
if not data:
|
||||
return data
|
||||
|
||||
return {key: serialize_value(value) for key, value in data.items()}
|
||||
@@ -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
|
||||
|
||||
@@ -341,3 +341,101 @@ async def upload_company_logo(
|
||||
"logo_path": file_path,
|
||||
"company_id": company_id,
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{company_id}/upload-certificate",
|
||||
response_model=dict,
|
||||
summary="Upload company certificate",
|
||||
)
|
||||
async def upload_company_certificate(
|
||||
company_id: int,
|
||||
certificate_type: str,
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Upload a certificate for a company
|
||||
certificate_type: fiel_cer, fiel_key, cfdi_cert_cer, cfdi_cert_key, cancel_cer, cancel_key
|
||||
"""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
if not tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Tenant ID not found in user data",
|
||||
)
|
||||
|
||||
# Validar que la empresa existe
|
||||
service = CompanyService(db)
|
||||
company = service.get_by_id(db, company_id, tenant_id, 0)
|
||||
if not company:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Company not found",
|
||||
)
|
||||
|
||||
# Validar tipo de certificado
|
||||
valid_types = [
|
||||
"fiel_cer", "fiel_key",
|
||||
"cfdi_cert_cer", "cfdi_cert_key",
|
||||
"cancel_cer", "cancel_key"
|
||||
]
|
||||
if certificate_type not in valid_types:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid certificate type. Allowed: {', '.join(valid_types)}",
|
||||
)
|
||||
|
||||
# Validar extensión
|
||||
file_ext = os.path.splitext(file.filename)[1].lower()
|
||||
allowed_exts = {".cer", ".key"}
|
||||
if file_ext not in allowed_exts:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File type not allowed. Allowed: {', '.join(allowed_exts)}",
|
||||
)
|
||||
|
||||
# Validar correspondencia extensión vs tipo (simple check)
|
||||
if "cer" in certificate_type and file_ext != ".cer":
|
||||
raise HTTPException(status_code=400, detail="For this certificate type, file must be .cer")
|
||||
if "key" in certificate_type and file_ext != ".key":
|
||||
raise HTTPException(status_code=400, detail="For this certificate type, file must be .key")
|
||||
|
||||
# Validar tamaño
|
||||
content = await file.read()
|
||||
if len(content) > MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File too large. Maximum size: {MAX_FILE_SIZE / 1024 / 1024}MB",
|
||||
)
|
||||
|
||||
# Crear directorio si no existe
|
||||
certs_dir = os.path.join(UPLOAD_DIR, str(company_id), "certificates")
|
||||
os.makedirs(certs_dir, exist_ok=True)
|
||||
|
||||
# Generar nombre único
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{certificate_type}_{timestamp}{file_ext}"
|
||||
file_path = os.path.join(certs_dir, filename)
|
||||
|
||||
# Guardar archivo
|
||||
try:
|
||||
await file.seek(0)
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error saving file: {str(e)}",
|
||||
)
|
||||
|
||||
# Actualizar la base de datos
|
||||
service.upload_certificate(company_id, certificate_type, file_path, tenant_id)
|
||||
|
||||
return {
|
||||
"message": "Certificate uploaded successfully",
|
||||
"file_path": file_path,
|
||||
"certificate_type": certificate_type,
|
||||
"company_id": company_id,
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from .dto import CompanyCreateDTO, CompanyResponseDTO, CompanyUpdateDTO
|
||||
from .models import Company
|
||||
from ...audit_log.services.service import AuditService
|
||||
from core.context import get_user_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -111,7 +113,8 @@ class CompanyService:
|
||||
"active_labels", "active_fractions", "activate_caat", "trans_interface",
|
||||
"american_costs", "scaf_readonly", "parts_replacement", "activate_facmexame",
|
||||
"part_reference", "international_firm", "ftp_key", "sifra_path",
|
||||
"version_type", "sql_language", "balance_operation_mode", "inter_db_name"
|
||||
"version_type", "sql_language", "balance_operation_mode", "inter_db_name",
|
||||
"seventh_amendment"
|
||||
]
|
||||
return {k: v for k, v in data.items() if k in company_fields}
|
||||
|
||||
@@ -127,15 +130,100 @@ class CompanyService:
|
||||
]
|
||||
return {k: v for k, v in data.items() if k in cert_fields}
|
||||
|
||||
def _extract_address_fields(self, data: Dict[str, Any], type_prefix: str) -> Dict[str, Any]:
|
||||
"""Extrae campos de dirección con base en un prefijo (main_, ind1_, ind2_)"""
|
||||
fields = ["street", "exterior_number", "interior_number", "postal_code",
|
||||
"neighborhood", "city", "municipality", "state", "country",
|
||||
"phone", "fax", "email"]
|
||||
|
||||
extracted = {}
|
||||
for f in fields:
|
||||
key = f"{type_prefix}_{f}"
|
||||
if key in data:
|
||||
extracted[f] = data[key]
|
||||
return extracted
|
||||
|
||||
def _extract_prevalidator_fields(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extrae campos que pertenecen a CompanyPrevalidator"""
|
||||
# Note: 'prevalidator_key' in DTO maps to 'key' in model
|
||||
fields = {}
|
||||
if "prevalidator_key" in data:
|
||||
fields["key"] = data["prevalidator_key"]
|
||||
# Se mapean campos 'prev_*' a los nombres del modelo
|
||||
mapping = {
|
||||
"prev_customs": "customs",
|
||||
"prev_key": "key",
|
||||
"prev_patent": "patent",
|
||||
"prev_description": "description"
|
||||
}
|
||||
extracted = {}
|
||||
for dto_key, model_key in mapping.items():
|
||||
if dto_key in data:
|
||||
extracted[model_key] = data[dto_key]
|
||||
|
||||
# Add other fields if present in DTO in the future
|
||||
return fields
|
||||
# Retrocompatibilidad con el campo prevalidator_key que ya estaba en el DTO
|
||||
if "prevalidator_key" in data and "key" not in extracted:
|
||||
extracted["key"] = data["prevalidator_key"]
|
||||
|
||||
return extracted
|
||||
|
||||
def _extract_vu_fields(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extrae campos que pertenecen a CompanyVU (prefijo vu_)"""
|
||||
vu_fields = [
|
||||
"webservice_user", "webservice_password", "email", "figure_type",
|
||||
"central_path", "xml_files_path", "query_rfc", "validation_rfc",
|
||||
"configuration_source", "measurement_units"
|
||||
]
|
||||
extracted = {}
|
||||
for f in vu_fields:
|
||||
key = f"vu_{f}"
|
||||
if key in data:
|
||||
extracted[f] = data[key]
|
||||
return extracted
|
||||
|
||||
def _extract_electronic_agent_fields(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extrae campos que pertenecen a CompanyElectronicAgent (prefijo ea_)"""
|
||||
ea_fields = [
|
||||
"input_folder", "output_folder", "send_mask", "response_mask",
|
||||
"response_extension", "counter_start", "counter_end", "counter_next"
|
||||
]
|
||||
extracted = {}
|
||||
for f in ea_fields:
|
||||
key = f"ea_{f}"
|
||||
if key in data:
|
||||
extracted[f] = data[key]
|
||||
return extracted
|
||||
|
||||
def _extract_cfdi_fields(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extrae campos que pertenecen a CompanyCFDI (prefijo cfdi_)"""
|
||||
cfdi_fields = ["xml_save_path", "cfdi_app_path", "pac_app_path"]
|
||||
extracted = {}
|
||||
for f in cfdi_fields:
|
||||
key = f"cfdi_{f}"
|
||||
if key in data:
|
||||
extracted[f] = data[key]
|
||||
return extracted
|
||||
|
||||
def _extract_digital_certificate_fields(self, data: Dict[str, Any], cert_prefix: str) -> Dict[str, Any]:
|
||||
"""Extrae campos para un tipo específico de certificado (fiel, cfdi_cert, cancel)"""
|
||||
# Mapeo de prefijos DTO a nombres de modelo
|
||||
fields_map = {
|
||||
f"{cert_prefix}_cer": "cer_file_path",
|
||||
f"{cert_prefix}_key": "key_file_path",
|
||||
f"{cert_prefix}_pass": "password",
|
||||
f"{cert_prefix}_access": "access_key",
|
||||
f"{cert_prefix}_cer_exp": "cer_expiration_date",
|
||||
f"{cert_prefix}_key_exp": "key_expiration_date"
|
||||
}
|
||||
|
||||
extracted = {}
|
||||
for dto_key, model_key in fields_map.items():
|
||||
if dto_key in data:
|
||||
extracted[model_key] = data[dto_key]
|
||||
|
||||
if extracted:
|
||||
# Mapear prefijo al tipo real en base de datos
|
||||
model_type_map = {'fiel': 'fiel', 'cfdi_cert': 'cfdi', 'cancel': 'cancellation'}
|
||||
extracted['certificate_type'] = model_type_map.get(cert_prefix, cert_prefix)
|
||||
|
||||
return extracted
|
||||
|
||||
|
||||
def flatten_company_dto(self, company: Company) -> Dict[str, Any]:
|
||||
"""Flattens Company and its submodels into a single dict for DTO validation"""
|
||||
@@ -144,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"""
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import String, Numeric, TIMESTAMP, func, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from core.database import Base
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
|
||||
class CanadianTariffFraction(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Model for Canadian Tariff Fractions (GFracEUACan)"""
|
||||
__tablename__ = "canadian_tariff_fractions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('fraction', 'country_code', 'company_id', name='uq_canadian_fraction_country_company'),
|
||||
{"schema": "a76"}
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
|
||||
# FRACCION
|
||||
fraction: Mapped[str] = mapped_column(String(13), nullable=False, index=True)
|
||||
# ADV
|
||||
ad_valorem: Mapped[Optional[float]] = mapped_column(Numeric(5, 2))
|
||||
# UNIDAD
|
||||
unit_of_measure: Mapped[Optional[str]] = mapped_column(String(5))
|
||||
# CLAVEM3 (Part of original PK)
|
||||
country_code: Mapped[str] = mapped_column(String(3), nullable=False, index=True)
|
||||
# DESCRIPCION
|
||||
description: Mapped[Optional[str]] = mapped_column(String(1000))
|
||||
@@ -0,0 +1,98 @@
|
||||
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
from .service import CanadianTariffFractionService
|
||||
from .schemas import (
|
||||
CanadianTariffFractionResponse,
|
||||
CanadianTariffFractionCreate,
|
||||
CanadianTariffFractionUpdate,
|
||||
CanadianTariffFractionListResponse
|
||||
)
|
||||
|
||||
from api.v1.common.tenant_crud_routes import validate_access_to_resource
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/", response_model=CanadianTariffFractionListResponse)
|
||||
def list_canadian_fractions(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=1000),
|
||||
search: Optional[str] = None,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user = Depends(get_current_user)
|
||||
):
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
skip = (page - 1) * page_size
|
||||
service = CanadianTariffFractionService(db)
|
||||
items, total = service.get_multi(
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
skip=skip,
|
||||
limit=page_size,
|
||||
search=search
|
||||
)
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"pages": (total + page_size - 1) // page_size if page_size > 0 else 1
|
||||
}
|
||||
|
||||
@router.get("/{id}", response_model=CanadianTariffFractionResponse)
|
||||
def get_canadian_fraction(
|
||||
id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user = Depends(get_current_user)
|
||||
):
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
service = CanadianTariffFractionService(db)
|
||||
item = service.get(id, tenant_id, company_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return item
|
||||
|
||||
@router.post("/", response_model=CanadianTariffFractionResponse)
|
||||
def create_canadian_fraction(
|
||||
item_in: CanadianTariffFractionCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user = Depends(get_current_user)
|
||||
):
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
service = CanadianTariffFractionService(db)
|
||||
return service.create(item_in, tenant_id, company_id)
|
||||
|
||||
@router.put("/{id}", response_model=CanadianTariffFractionResponse)
|
||||
def update_canadian_fraction(
|
||||
id: int,
|
||||
item_in: CanadianTariffFractionUpdate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user = Depends(get_current_user)
|
||||
):
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
service = CanadianTariffFractionService(db)
|
||||
item = service.get(id, tenant_id, company_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return service.update(item, item_in)
|
||||
|
||||
@router.delete("/{id}", response_model=CanadianTariffFractionResponse)
|
||||
def delete_canadian_fraction(
|
||||
id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user = Depends(get_current_user)
|
||||
):
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
service = CanadianTariffFractionService(db)
|
||||
item = service.get(id, tenant_id, company_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return service.delete(id, tenant_id, company_id)
|
||||
@@ -0,0 +1,32 @@
|
||||
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
class CanadianTariffFractionBase(BaseModel):
|
||||
fraction: str = Field(..., max_length=13)
|
||||
ad_valorem: Optional[Decimal] = Field(None, max_digits=5, decimal_places=2)
|
||||
unit_of_measure: Optional[str] = Field(None, max_length=5)
|
||||
country_code: str = Field(..., max_length=3)
|
||||
description: Optional[str] = Field(None, max_length=1000)
|
||||
|
||||
class CanadianTariffFractionCreate(CanadianTariffFractionBase):
|
||||
pass
|
||||
|
||||
class CanadianTariffFractionUpdate(CanadianTariffFractionBase):
|
||||
pass
|
||||
|
||||
class CanadianTariffFractionResponse(CanadianTariffFractionBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class CanadianTariffFractionListResponse(BaseModel):
|
||||
items: List[CanadianTariffFractionResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
pages: int
|
||||
@@ -0,0 +1,73 @@
|
||||
|
||||
from typing import Optional, List, Tuple
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import Session
|
||||
from .models import CanadianTariffFraction
|
||||
from .schemas import CanadianTariffFractionCreate, CanadianTariffFractionUpdate
|
||||
|
||||
class CanadianTariffFractionService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get(self, id: int, tenant_id: int, company_id: int) -> Optional[CanadianTariffFraction]:
|
||||
return self.db.query(CanadianTariffFraction).filter(
|
||||
CanadianTariffFraction.id == id,
|
||||
CanadianTariffFraction.tenant_id == tenant_id,
|
||||
CanadianTariffFraction.company_id == company_id
|
||||
).first()
|
||||
|
||||
def get_multi(
|
||||
self,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
search: Optional[str] = None
|
||||
) -> Tuple[List[CanadianTariffFraction], int]:
|
||||
query = select(CanadianTariffFraction).where(
|
||||
CanadianTariffFraction.tenant_id == tenant_id,
|
||||
CanadianTariffFraction.company_id == company_id
|
||||
)
|
||||
|
||||
if search:
|
||||
query = query.where(
|
||||
(CanadianTariffFraction.fraction.ilike(f"%{search}%")) |
|
||||
(CanadianTariffFraction.description.ilike(f"%{search}%"))
|
||||
)
|
||||
|
||||
total = self.db.execute(select(func.count()).select_from(query.subquery())).scalar_one()
|
||||
# Add deterministic sort order
|
||||
query = query.order_by(CanadianTariffFraction.fraction)
|
||||
items = self.db.scalars(query.offset(skip).limit(limit)).all()
|
||||
return items, total
|
||||
|
||||
def create(self, obj_in: CanadianTariffFractionCreate, tenant_id: int, company_id: int) -> CanadianTariffFraction:
|
||||
db_obj = CanadianTariffFraction(
|
||||
**obj_in.model_dump(),
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id
|
||||
)
|
||||
self.db.add(db_obj)
|
||||
self.db.commit()
|
||||
self.db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
def update(
|
||||
self,
|
||||
db_obj: CanadianTariffFraction,
|
||||
obj_in: CanadianTariffFractionUpdate
|
||||
) -> CanadianTariffFraction:
|
||||
update_data = obj_in.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(db_obj, field, value)
|
||||
self.db.add(db_obj)
|
||||
self.db.commit()
|
||||
self.db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
def delete(self, id: int, tenant_id: int, company_id: int) -> Optional[CanadianTariffFraction]:
|
||||
obj = self.get(id, tenant_id, company_id)
|
||||
if obj:
|
||||
self.db.delete(obj)
|
||||
self.db.commit()
|
||||
return obj
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
DTOs for historical tariff fractions.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class HistoricalTariffFractionResponseDTO(BaseModel):
|
||||
id: int
|
||||
historical_fraction: Optional[str] = None
|
||||
unit_of_measure_code: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
fraction_type: Optional[str] = None
|
||||
sector: Optional[str] = None
|
||||
import_tax_rate: Optional[Decimal] = None
|
||||
export_tax_rate: Optional[Decimal] = None
|
||||
publication_date: Optional[datetime] = None
|
||||
is_immex: Optional[bool] = None
|
||||
normal_temporality: Optional[bool] = None
|
||||
services_temporality: Optional[bool] = None
|
||||
certified_temporality: Optional[bool] = None
|
||||
by_log: Optional[bool] = None
|
||||
end_date: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
from .service import HistoricalTariffFractionService
|
||||
from .schemas import HistoricalTariffFractionResponse, HistoricalTariffFractionCreate, HistoricalTariffFractionUpdate, HistoricalTariffFractionListResponse
|
||||
|
||||
from api.v1.common.tenant_crud_routes import validate_access_to_resource
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/", response_model=HistoricalTariffFractionListResponse)
|
||||
def get_historical_fractions(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(50, ge=1, le=1000, description="Page size"),
|
||||
historical_fraction: Optional[str] = Query(None, description="Search by historical fraction code"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get all historical tariff fractions (paginated).
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
skip = (page - 1) * page_size
|
||||
service = HistoricalTariffFractionService(db)
|
||||
items, total = service.get_multi(tenant_id, company_id, skip=skip, limit=page_size, historical_fraction=historical_fraction)
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"pages": (total + page_size - 1) // page_size if page_size > 0 else 1
|
||||
}
|
||||
|
||||
@router.get("/{id}", response_model=HistoricalTariffFractionResponse)
|
||||
def get_historical_fraction(
|
||||
id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get a historical tariff fraction by ID.
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
service = HistoricalTariffFractionService(db)
|
||||
fraction = service.get(id, tenant_id, company_id)
|
||||
if not fraction:
|
||||
raise HTTPException(status_code=404, detail="Historical tariff fraction not found")
|
||||
return fraction
|
||||
|
||||
@router.post("/", response_model=HistoricalTariffFractionResponse)
|
||||
def create_historical_fraction(
|
||||
fraction_in: HistoricalTariffFractionCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Create a new historical tariff fraction.
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
service = HistoricalTariffFractionService(db)
|
||||
return service.create(fraction_in, tenant_id, company_id)
|
||||
|
||||
@router.put("/{id}", response_model=HistoricalTariffFractionResponse)
|
||||
def update_historical_fraction(
|
||||
id: int,
|
||||
fraction_in: HistoricalTariffFractionUpdate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Update a historical tariff fraction.
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
service = HistoricalTariffFractionService(db)
|
||||
fraction = service.get(id, tenant_id, company_id)
|
||||
if not fraction:
|
||||
raise HTTPException(status_code=404, detail="Historical tariff fraction not found")
|
||||
return service.update(fraction, fraction_in)
|
||||
|
||||
@router.delete("/{id}", response_model=HistoricalTariffFractionResponse)
|
||||
def delete_historical_fraction(
|
||||
id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Delete a historical tariff fraction.
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
service = HistoricalTariffFractionService(db)
|
||||
fraction = service.get(id, tenant_id, company_id)
|
||||
if not fraction:
|
||||
raise HTTPException(status_code=404, detail="Historical tariff fraction not found")
|
||||
return service.delete(id, tenant_id, company_id)
|
||||
@@ -0,0 +1,45 @@
|
||||
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class HistoricalTariffFractionBase(BaseModel):
|
||||
"""Base schema for Historical Tariff Fraction"""
|
||||
historical_fraction: Optional[str] = Field(None, max_length=8)
|
||||
unit_of_measure_code: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
fraction_type: Optional[str] = Field(None, max_length=7)
|
||||
sector: Optional[str] = Field(None, max_length=5)
|
||||
import_tax_rate: Optional[Decimal] = None
|
||||
export_tax_rate: Optional[Decimal] = None
|
||||
publication_date: Optional[datetime] = None
|
||||
is_immex: Optional[bool] = None
|
||||
normal_temporality: Optional[bool] = None
|
||||
services_temporality: Optional[bool] = None
|
||||
certified_temporality: Optional[bool] = None
|
||||
by_log: Optional[bool] = None
|
||||
end_date: Optional[datetime] = None
|
||||
|
||||
class HistoricalTariffFractionCreate(HistoricalTariffFractionBase):
|
||||
"""Schema for creating a Historical Tariff Fraction"""
|
||||
pass
|
||||
|
||||
class HistoricalTariffFractionUpdate(HistoricalTariffFractionBase):
|
||||
"""Schema for updating a Historical Tariff Fraction"""
|
||||
pass
|
||||
|
||||
class HistoricalTariffFractionResponse(HistoricalTariffFractionBase):
|
||||
"""Schema for reading a Historical Tariff Fraction"""
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class HistoricalTariffFractionListResponse(BaseModel):
|
||||
"""Schema for paginated list of Historical Tariff Fractions"""
|
||||
items: List[HistoricalTariffFractionResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
pages: int
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
|
||||
from typing import Optional, List, Tuple
|
||||
from sqlalchemy import select, or_, func
|
||||
from sqlalchemy.orm import Session
|
||||
from .models import HistoricalTariffFraction
|
||||
from .schemas import HistoricalTariffFractionCreate, HistoricalTariffFractionUpdate
|
||||
|
||||
class HistoricalTariffFractionService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get(self, id: int, tenant_id: int, company_id: int) -> Optional[HistoricalTariffFraction]:
|
||||
return self.db.query(HistoricalTariffFraction).filter(
|
||||
HistoricalTariffFraction.id == id,
|
||||
HistoricalTariffFraction.tenant_id == tenant_id,
|
||||
HistoricalTariffFraction.company_id == company_id
|
||||
).first()
|
||||
|
||||
def get_multi(
|
||||
self,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
historical_fraction: Optional[str] = None
|
||||
) -> Tuple[List[HistoricalTariffFraction], int]:
|
||||
query = select(HistoricalTariffFraction).where(
|
||||
HistoricalTariffFraction.tenant_id == tenant_id,
|
||||
HistoricalTariffFraction.company_id == company_id
|
||||
)
|
||||
|
||||
if historical_fraction:
|
||||
query = query.where(HistoricalTariffFraction.historical_fraction.ilike(f"%{historical_fraction}%"))
|
||||
|
||||
total = self.db.execute(select(func.count()).select_from(query.subquery())).scalar_one()
|
||||
# Add deterministic sort order
|
||||
query = query.order_by(HistoricalTariffFraction.historical_fraction)
|
||||
items = self.db.scalars(query.offset(skip).limit(limit)).all()
|
||||
return items, total
|
||||
|
||||
def create(self, obj_in: HistoricalTariffFractionCreate, tenant_id: int, company_id: int) -> HistoricalTariffFraction:
|
||||
db_obj = HistoricalTariffFraction(**obj_in.model_dump())
|
||||
db_obj.tenant_id = tenant_id
|
||||
db_obj.company_id = company_id
|
||||
self.db.add(db_obj)
|
||||
self.db.commit()
|
||||
self.db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
def update(
|
||||
self,
|
||||
db_obj: HistoricalTariffFraction,
|
||||
obj_in: HistoricalTariffFractionUpdate
|
||||
) -> HistoricalTariffFraction:
|
||||
# db_obj already validated for tenant/company in get()
|
||||
update_data = obj_in.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(db_obj, field, value)
|
||||
self.db.add(db_obj)
|
||||
self.db.commit()
|
||||
self.db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
def delete(self, id: int, tenant_id: int, company_id: int) -> Optional[HistoricalTariffFraction]:
|
||||
obj = self.get(id, tenant_id, company_id)
|
||||
if obj:
|
||||
self.db.delete(obj)
|
||||
self.db.commit()
|
||||
return obj
|
||||
@@ -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)")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -5,7 +5,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..line_items.models import LineItem
|
||||
from ..models import LineItem
|
||||
|
||||
class LineFinancial(Base):
|
||||
"""
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
"""Line items module"""
|
||||
from .models import LineItem
|
||||
from .schemas import (
|
||||
LineItemBase,
|
||||
LineItemCreate,
|
||||
LineItemUpdate,
|
||||
LineItemResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"LineItem",
|
||||
"LineItemBase",
|
||||
"LineItemCreate",
|
||||
"LineItemUpdate",
|
||||
"LineItemResponse",
|
||||
]
|
||||
@@ -1,200 +0,0 @@
|
||||
from decimal import Decimal
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
String,
|
||||
Integer,
|
||||
Numeric,
|
||||
SmallInteger,
|
||||
ForeignKey,
|
||||
ForeignKeyConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
from api.v1.modules.a76.classes.models import Class
|
||||
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..models import Item
|
||||
from ..line_financials.models import LineFinancial
|
||||
from ..line_quantities.models import LineQuantity
|
||||
from ..line_customs.models import LineCustom
|
||||
from ..line_descriptions.models import LineDescription
|
||||
from ..line_references.models import LineReference
|
||||
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
|
||||
|
||||
|
||||
class LineItem(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""
|
||||
Unified line items for all items
|
||||
Consolidates all line-level data from Q and S tables
|
||||
"""
|
||||
|
||||
__tablename__ = "item_lines"
|
||||
__table_args__ = ({"schema": "a76"},)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
item_id: Mapped[int] = mapped_column(ForeignKey("a76.items.id"))
|
||||
line_number: Mapped[int] = mapped_column(Integer) # LINEAIMPO/LINEAEXPO/LINEA
|
||||
|
||||
# Part identification
|
||||
part_number: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("a76.parts.id")
|
||||
) # NUMPARTE
|
||||
component_part_number: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("a76.parts.id")
|
||||
) # NUMPARTECOM
|
||||
class_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("a76.classes.id")
|
||||
) # CLASE
|
||||
|
||||
# Unit of measure
|
||||
unit_of_measure: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("a76.units_of_measure.id")
|
||||
) # UNIDADMEDIDA/UNIMED
|
||||
alternate_unit: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("a76.units_of_measure.id")
|
||||
) # UNIMEDALTERNA
|
||||
uma_key: Mapped[Optional[str]] = mapped_column(String(2)) # CLAVEUMA
|
||||
auxiliary_unit: Mapped[Optional[str]] = mapped_column(String(5)) # UNIMEDAUXILIAR
|
||||
|
||||
# Permits and certificates
|
||||
permit_number: Mapped[Optional[str]] = mapped_column(String(20)) # NUMPERMISO
|
||||
page_line: Mapped[Optional[str]] = mapped_column(String(10)) # PAGRENGLON
|
||||
has_certificate: Mapped[Optional[bool]] = mapped_column(
|
||||
Boolean
|
||||
) # TIENECO/CERTORIGEN
|
||||
certificate_number: Mapped[Optional[str]] = mapped_column(
|
||||
String(10)
|
||||
) # NOCERTIFICADO
|
||||
octave_permit: Mapped[Optional[str]] = mapped_column(String(20)) # PERMISOROCTAVA
|
||||
permits_ped: Mapped[Optional[str]] = mapped_column(String(500)) # PERMISOSPED
|
||||
|
||||
# FDA
|
||||
has_fda_code: Mapped[Optional[bool]] = mapped_column(Boolean) # LLEVACODFDA
|
||||
fda_key: Mapped[Optional[str]] = mapped_column(String(10)) # CLAVEFDA
|
||||
|
||||
# Special flags
|
||||
is_military_mcia: Mapped[Optional[bool]] = mapped_column(Boolean) # ESMCIAMILITAR
|
||||
|
||||
# IV32 (Tax identification)
|
||||
iv32_type_key: Mapped[Optional[str]] = mapped_column(String(5)) # CLAVETIPOIV32
|
||||
iv32_number: Mapped[Optional[str]] = mapped_column(String(35)) # NUMEROIV32
|
||||
|
||||
# IN CASE OF EXPO
|
||||
scrap_invoice: Mapped[Optional[str]] = mapped_column(String(15)) # FACTURASCRAP
|
||||
consecutive_destination: Mapped[Optional[int]] = mapped_column(
|
||||
Integer
|
||||
) # CONSECUTIVODES
|
||||
ctm_section: Mapped[Optional[str]] = mapped_column(String(3)) # APARTADOCTM
|
||||
|
||||
# Tax payment
|
||||
tax_payment: Mapped[Optional[bool]] = mapped_column(Boolean) # PAGOIMPUESTO
|
||||
payment_method: Mapped[Optional[str]] = mapped_column(
|
||||
String(9)
|
||||
) # FORMAPAGO/FORMAPAGOTIGI
|
||||
igi_amount: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # MONTOIGI
|
||||
igi_payment_method: Mapped[Optional[str]] = mapped_column(
|
||||
String(9)
|
||||
) # FORMAPAGOTIGI
|
||||
|
||||
# FCC
|
||||
fcc_key: Mapped[Optional[str]] = mapped_column(String(30)) # CLAVEFCC
|
||||
|
||||
# Valuation method
|
||||
valuation_method: Mapped[Optional[str]] = mapped_column(String(2)) # METVALOR
|
||||
valuation_determined_value: Mapped[Optional[Decimal]] = mapped_column(
|
||||
Numeric(29, 8)
|
||||
) # METVALORVALORDETERMINADO/METVALORACIONVALORDETERMINADO
|
||||
valuation_reason: Mapped[Optional[str]] = mapped_column(
|
||||
String(500)
|
||||
) # METVALORMOTIVODEUSO/METVALORACIONMOTIVODEUSO
|
||||
|
||||
# Container rules
|
||||
container_rule: Mapped[Optional[str]] = mapped_column(String(50)) # CONTENEDORREGLA
|
||||
container_parts_ii: Mapped[Optional[str]] = mapped_column(
|
||||
String(50)
|
||||
) # CONTENEDORPARTESII
|
||||
|
||||
# APHIS
|
||||
consecutive_aphis: Mapped[Optional[int]] = mapped_column(
|
||||
Integer
|
||||
) # CONSECUTIVOAPHIS
|
||||
|
||||
# BOM/Commercial
|
||||
bom_version: Mapped[Optional[int]] = mapped_column(Integer) # VERSIONBOM
|
||||
bill_version: Mapped[Optional[int]] = mapped_column(Integer) # VERSIONBILL
|
||||
|
||||
# TLCAN value
|
||||
tlcan_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORTLCAN
|
||||
|
||||
# Identifier
|
||||
identifier: Mapped[Optional[str]] = mapped_column(String(2)) # IDENTIFICADOR
|
||||
|
||||
# Validation fields
|
||||
validation_zero: Mapped[Optional[int]] = mapped_column(Integer) # VALIDACIONZERO
|
||||
validation_one: Mapped[Optional[int]] = mapped_column(Integer) # VALIDACIONUNO
|
||||
|
||||
# Material type
|
||||
material_type: Mapped[Optional[str]] = mapped_column(
|
||||
String(50)
|
||||
) # TIPOMAT/TIPODENUMPARTE
|
||||
|
||||
# Order concept
|
||||
order_type: Mapped[Optional[str]] = mapped_column(String(50)) # TIPODEORDEN
|
||||
line_concept: Mapped[Optional[str]] = mapped_column(
|
||||
String(50)
|
||||
) # CONCEPTODELAPARTIDA
|
||||
|
||||
# Review dispatch
|
||||
review_dispatch: Mapped[Optional[str]] = mapped_column(String(10)) # REVISARDESP
|
||||
|
||||
# Take component from PT
|
||||
take_component_pt: Mapped[Optional[int]] = mapped_column(Integer) # TOMARCOMOPT
|
||||
|
||||
# Pallet
|
||||
pallet2: Mapped[Optional[int]] = mapped_column(SmallInteger) # PALLET2
|
||||
|
||||
# Wildcard field
|
||||
wildcard_field: Mapped[Optional[str]] = mapped_column(String(100)) # CAMPOCOMODIN
|
||||
|
||||
# Relationships
|
||||
item: Mapped["Item"] = relationship(back_populates="lines")
|
||||
financial: Mapped[Optional["LineFinancial"]] = relationship(
|
||||
back_populates="line", cascade="all, delete-orphan", uselist=False
|
||||
)
|
||||
quantity: Mapped[Optional["LineQuantity"]] = relationship(
|
||||
back_populates="line", cascade="all, delete-orphan", uselist=False
|
||||
)
|
||||
customs: Mapped[Optional["LineCustom"]] = relationship(
|
||||
back_populates="line", cascade="all, delete-orphan", uselist=False
|
||||
)
|
||||
description: Mapped[Optional["LineDescription"]] = relationship(
|
||||
back_populates="line", cascade="all, delete-orphan", uselist=False
|
||||
)
|
||||
reference: Mapped[Optional["LineReference"]] = relationship(
|
||||
back_populates="line", cascade="all, delete-orphan", uselist=False
|
||||
)
|
||||
class_info: Mapped[Optional["Class"]] = relationship(
|
||||
"api.v1.modules.a76.classes.models.Class",
|
||||
foreign_keys=[class_id],
|
||||
viewonly=True,
|
||||
)
|
||||
unit_of_measure_info: Mapped[Optional["UnitOfMeasure"]] = relationship(
|
||||
"api.v1.modules.a76.general_catalogs.units_of_measure.models.UnitOfMeasure",
|
||||
foreign_keys=[unit_of_measure],
|
||||
viewonly=True,
|
||||
)
|
||||
fa_data: Mapped[Optional["FaLineItem"]] = relationship(
|
||||
"FaLineItem",
|
||||
back_populates="master_info",
|
||||
cascade="all, delete-orphan",
|
||||
uselist=False,
|
||||
)
|
||||
part_info: Mapped[Optional["api.v1.modules.a76.parts.models.Part"]] = relationship(
|
||||
"api.v1.modules.a76.parts.models.Part",
|
||||
foreign_keys=[part_number],
|
||||
viewonly=True,
|
||||
)
|
||||
@@ -1,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
|
||||
@@ -7,7 +7,7 @@ from core.database import Base
|
||||
from api.v1.modules.a76.general_catalogs.packages.models import Package
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..line_items.models import LineItem
|
||||
from ..models import LineItem
|
||||
|
||||
class LineQuantity(Base):
|
||||
"""
|
||||
|
||||
@@ -4,7 +4,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..line_items.models import LineItem
|
||||
from ..models import LineItem
|
||||
|
||||
class LineReference(Base):
|
||||
"""
|
||||
|
||||
@@ -3,58 +3,229 @@ Normalized Database Schema for SCAF (Fixed Assets) and SCAII (Parts Inventory)
|
||||
SQLAlchemy v2 - Annex 24 Compliance
|
||||
"""
|
||||
|
||||
from typing import Optional, TYPE_CHECKING, List
|
||||
from sqlalchemy import Boolean, String, Integer, ForeignKey
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from core.database import Base
|
||||
from decimal import Decimal
|
||||
from sqlalchemy import Boolean, String, Integer, Numeric, SmallInteger, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
from api.v1.modules.a76.classes.models import Class
|
||||
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .line_items.models import LineItem
|
||||
from .line_financials.models import LineFinancial
|
||||
from .line_quantities.models import LineQuantity
|
||||
from .line_customs.models import LineCustom
|
||||
from .line_descriptions.models import LineDescription
|
||||
from .line_references.models import LineReference
|
||||
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
|
||||
from api.v1.modules.a76.parts.models import Part
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader
|
||||
|
||||
# ============================================================================
|
||||
# CORE ENTITIES
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class Item(Base, TenantScopedMixin, TimestampMixin):
|
||||
class LineItem(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""
|
||||
Unified item header table for all import/export operations
|
||||
Consolidates headers from both SCAF and SCAII systems
|
||||
"""
|
||||
__tablename__ = "items"
|
||||
|
||||
__tablename__ = "item_lines"
|
||||
__table_args__ = {
|
||||
"schema": "a76",
|
||||
}
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
invoice_id: Mapped[int] = mapped_column(ForeignKey("a76.invoice_header.id")) # CONSECUTIVO
|
||||
invoice_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("a76.invoice_header.id")
|
||||
) # CONSECUTIVO
|
||||
|
||||
line_number: Mapped[int] = mapped_column(Integer) # LINEAIMPO/LINEAEXPO/LINEA
|
||||
|
||||
# Part identification
|
||||
part_number_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("a76.parts.id")
|
||||
) # NUMPARTE
|
||||
component_part_number_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("a76.parts.id")
|
||||
) # NUMPARTECOM
|
||||
class_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("a76.classes.id")
|
||||
) # CLASE
|
||||
|
||||
# Unit of measure
|
||||
unit_of_measure: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("a76.units_of_measure.id")
|
||||
) # UNIDADMEDIDA/UNIMED
|
||||
alternate_unit: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("a76.units_of_measure.id")
|
||||
) # UNIMEDALTERNA
|
||||
uma_key: Mapped[Optional[str]] = mapped_column(String(2)) # CLAVEUMA
|
||||
auxiliary_unit: Mapped[Optional[str]] = mapped_column(String(5)) # UNIMEDAUXILIAR
|
||||
|
||||
# Permits and certificates
|
||||
permit_number: Mapped[Optional[str]] = mapped_column(String(20)) # NUMPERMISO
|
||||
page_line: Mapped[Optional[str]] = mapped_column(String(10)) # PAGRENGLON
|
||||
has_certificate: Mapped[Optional[bool]] = mapped_column(
|
||||
Boolean
|
||||
) # TIENECO/CERTORIGEN
|
||||
certificate_number: Mapped[Optional[str]] = mapped_column(
|
||||
String(10)
|
||||
) # NOCERTIFICADO
|
||||
octave_permit: Mapped[Optional[str]] = mapped_column(String(20)) # PERMISOROCTAVA
|
||||
permits_ped: Mapped[Optional[str]] = mapped_column(String(500)) # PERMISOSPED
|
||||
|
||||
# FDA
|
||||
has_fda_code: Mapped[Optional[bool]] = mapped_column(Boolean) # LLEVACODFDA
|
||||
fda_key: Mapped[Optional[str]] = mapped_column(String(10)) # CLAVEFDA
|
||||
|
||||
# Special flags
|
||||
is_military_mcia: Mapped[Optional[bool]] = mapped_column(Boolean) # ESMCIAMILITAR
|
||||
|
||||
# IV32 (Tax identification)
|
||||
iv32_type_key: Mapped[Optional[str]] = mapped_column(String(5)) # CLAVETIPOIV32
|
||||
iv32_number: Mapped[Optional[str]] = mapped_column(String(35)) # NUMEROIV32
|
||||
|
||||
# IN CASE OF EXPO
|
||||
scrap_invoice: Mapped[Optional[str]] = mapped_column(String(15)) # FACTURASCRAP
|
||||
consecutive_destination: Mapped[Optional[int]] = mapped_column(
|
||||
Integer
|
||||
) # CONSECUTIVODES
|
||||
ctm_section: Mapped[Optional[str]] = mapped_column(String(3)) # APARTADOCTM
|
||||
|
||||
# Tax payment
|
||||
tax_payment: Mapped[Optional[bool]] = mapped_column(Boolean) # PAGOIMPUESTO
|
||||
payment_method: Mapped[Optional[str]] = mapped_column(
|
||||
String(9)
|
||||
) # FORMAPAGO/FORMAPAGOTIGI
|
||||
igi_amount: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # MONTOIGI
|
||||
igi_payment_method: Mapped[Optional[str]] = mapped_column(
|
||||
String(9)
|
||||
) # FORMAPAGOTIGI
|
||||
|
||||
# FCC
|
||||
fcc_key: Mapped[Optional[str]] = mapped_column(String(30)) # CLAVEFCC
|
||||
|
||||
# Valuation method
|
||||
valuation_method: Mapped[Optional[str]] = mapped_column(String(2)) # METVALOR
|
||||
valuation_determined_value: Mapped[Optional[Decimal]] = mapped_column(
|
||||
Numeric(29, 8)
|
||||
) # METVALORVALORDETERMINADO/METVALORACIONVALORDETERMINADO
|
||||
valuation_reason: Mapped[Optional[str]] = mapped_column(
|
||||
String(500)
|
||||
) # METVALORMOTIVODEUSO/METVALORACIONMOTIVODEUSO
|
||||
|
||||
# Container rules
|
||||
container_rule: Mapped[Optional[str]] = mapped_column(String(50)) # CONTENEDORREGLA
|
||||
container_parts_ii: Mapped[Optional[str]] = mapped_column(
|
||||
String(50)
|
||||
) # CONTENEDORPARTESII
|
||||
|
||||
# APHIS
|
||||
consecutive_aphis: Mapped[Optional[int]] = mapped_column(
|
||||
Integer
|
||||
) # CONSECUTIVOAPHIS
|
||||
|
||||
# BOM/Commercial
|
||||
bom_version: Mapped[Optional[int]] = mapped_column(Integer) # VERSIONBOM
|
||||
bill_version: Mapped[Optional[int]] = mapped_column(Integer) # VERSIONBILL
|
||||
|
||||
# TLCAN value
|
||||
tlcan_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORTLCAN
|
||||
|
||||
# Identifier
|
||||
identifier: Mapped[Optional[str]] = mapped_column(String(2)) # IDENTIFICADOR
|
||||
|
||||
# Validation fields
|
||||
validation_zero: Mapped[Optional[int]] = mapped_column(Integer) # VALIDACIONZERO
|
||||
validation_one: Mapped[Optional[int]] = mapped_column(Integer) # VALIDACIONUNO
|
||||
|
||||
# Material type
|
||||
material_type: Mapped[Optional[str]] = mapped_column(
|
||||
String(50)
|
||||
) # TIPOMAT/TIPODENUMPARTE
|
||||
|
||||
# Order concept
|
||||
order_type: Mapped[Optional[str]] = mapped_column(String(50)) # TIPODEORDEN
|
||||
line_concept: Mapped[Optional[str]] = mapped_column(
|
||||
String(50)
|
||||
) # CONCEPTODELAPARTIDA
|
||||
|
||||
# Review dispatch
|
||||
review_dispatch: Mapped[Optional[str]] = mapped_column(String(10)) # REVISARDESP
|
||||
|
||||
# Take component from PT
|
||||
take_component_pt: Mapped[Optional[int]] = mapped_column(Integer) # TOMARCOMOPT
|
||||
|
||||
# Pallet
|
||||
pallet2: Mapped[Optional[int]] = mapped_column(SmallInteger) # PALLET2
|
||||
|
||||
# Wildcard field
|
||||
wildcard_field: Mapped[Optional[str]] = mapped_column(String(100)) # CAMPOCOMODIN
|
||||
|
||||
# Item references
|
||||
reference_number: Mapped[Optional[str]] = mapped_column(
|
||||
String(20)) # NUMREFERENCIA
|
||||
order: Mapped[Optional[str]] = mapped_column(
|
||||
String(50)) # ORDENCOMPRA / ORDENVENTA
|
||||
reference_number: Mapped[Optional[str]] = mapped_column(String(20)) # NUMREFERENCIA
|
||||
order: Mapped[Optional[str]] = mapped_column(String(50)) # ORDENCOMPRA / ORDENVENTA
|
||||
guide_number: Mapped[Optional[str]] = mapped_column(
|
||||
String(50)) # NUMEROGUIA/NUMERODEGUIA
|
||||
String(50)
|
||||
) # NUMEROGUIA/NUMERODEGUIA
|
||||
|
||||
# Dates
|
||||
depreciation_date: Mapped[Optional[int]] = mapped_column(
|
||||
Integer) # FECHADEPRECIACION
|
||||
Integer
|
||||
) # FECHADEPRECIACION
|
||||
|
||||
# Administrative fields
|
||||
rectification: Mapped[Optional[bool]] = mapped_column(
|
||||
Boolean) # RECTIFICACION
|
||||
rectification: Mapped[Optional[bool]] = mapped_column(Boolean) # RECTIFICACION
|
||||
warehouse: Mapped[Optional[str]] = mapped_column(String(30)) # BODEGA
|
||||
location: Mapped[Optional[str]] = mapped_column(
|
||||
String(200)) # LOCALIZACION
|
||||
location: Mapped[Optional[str]] = mapped_column(String(200)) # LOCALIZACION
|
||||
|
||||
# Relationships (one-to-many)
|
||||
lines: Mapped[List["LineItem"]] = relationship(
|
||||
"LineItem", back_populates="item", cascade="all, delete-orphan")
|
||||
|
||||
invoice: Mapped["InvoiceHeader"] = relationship("InvoiceHeader")
|
||||
|
||||
# Relationships
|
||||
financial: Mapped[Optional["LineFinancial"]] = relationship(
|
||||
back_populates="line", cascade="all, delete-orphan", uselist=False
|
||||
)
|
||||
quantity: Mapped[Optional["LineQuantity"]] = relationship(
|
||||
back_populates="line", cascade="all, delete-orphan", uselist=False
|
||||
)
|
||||
customs: Mapped[Optional["LineCustom"]] = relationship(
|
||||
back_populates="line", cascade="all, delete-orphan", uselist=False
|
||||
)
|
||||
description: Mapped[Optional["LineDescription"]] = relationship(
|
||||
back_populates="line", cascade="all, delete-orphan", uselist=False
|
||||
)
|
||||
reference: Mapped[Optional["LineReference"]] = relationship(
|
||||
back_populates="line", cascade="all, delete-orphan", uselist=False
|
||||
)
|
||||
class_info: Mapped[Optional["Class"]] = relationship(
|
||||
"api.v1.modules.a76.classes.models.Class",
|
||||
foreign_keys=[class_id],
|
||||
viewonly=True,
|
||||
)
|
||||
unit_of_measure_info: Mapped[Optional["UnitOfMeasure"]] = relationship(
|
||||
"api.v1.modules.a76.general_catalogs.units_of_measure.models.UnitOfMeasure",
|
||||
foreign_keys=[unit_of_measure],
|
||||
viewonly=True,
|
||||
)
|
||||
fa_data: Mapped[Optional["FaLineItem"]] = relationship(
|
||||
"FaLineItem",
|
||||
back_populates="master_info",
|
||||
cascade="all, delete-orphan",
|
||||
uselist=False,
|
||||
)
|
||||
part_info: Mapped[Optional["Part"]] = relationship(
|
||||
"Part",
|
||||
foreign_keys=[part_number_id],
|
||||
viewonly=True,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SUPPORTING TABLES
|
||||
# ============================================================================
|
||||
@@ -65,6 +236,7 @@ class PackingList(Base, TenantScopedMixin, TimestampMixin):
|
||||
Packing list items
|
||||
From: SPartidasPackingList
|
||||
"""
|
||||
|
||||
__tablename__ = "packing_lists"
|
||||
__table_args__ = {
|
||||
"schema": "a76",
|
||||
@@ -73,7 +245,8 @@ class PackingList(Base, TenantScopedMixin, TimestampMixin):
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
item_line_id: Mapped[int] = mapped_column(Integer) # LINEA
|
||||
packing_list_number: Mapped[Optional[str]] = mapped_column(
|
||||
String(100)) # NUMPACKINGLIST
|
||||
String(100)
|
||||
) # NUMPACKINGLIST
|
||||
|
||||
|
||||
class CTMReceipt(Base, TenantScopedMixin, TimestampMixin):
|
||||
@@ -81,6 +254,7 @@ class CTMReceipt(Base, TenantScopedMixin, TimestampMixin):
|
||||
CTM Receipt lines (temporary manufacturing)
|
||||
From: SPartidasReciboCTM
|
||||
"""
|
||||
|
||||
__tablename__ = "ctm_receipts"
|
||||
__table_args__ = {
|
||||
"schema": "a76",
|
||||
@@ -88,11 +262,11 @@ class CTMReceipt(Base, TenantScopedMixin, TimestampMixin):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
receipt_line: Mapped[int] = mapped_column(
|
||||
ForeignKey("a76.item_lines.id")) # LINEARECIBO
|
||||
ForeignKey("a76.item_lines.id")
|
||||
) # LINEARECIBO
|
||||
|
||||
option: Mapped[Optional[str]] = mapped_column(String(3)) # OPCION
|
||||
exit_invoice: Mapped[Optional[str]] = mapped_column(
|
||||
String(19)) # FACTURASALIDA
|
||||
exit_invoice: Mapped[Optional[str]] = mapped_column(String(19)) # FACTURASALIDA
|
||||
|
||||
|
||||
class SubassemblyEntry(Base, TenantScopedMixin, TimestampMixin):
|
||||
@@ -100,6 +274,7 @@ class SubassemblyEntry(Base, TenantScopedMixin, TimestampMixin):
|
||||
Subassembly/Submanufacturing Entry lines
|
||||
From: SPartidasEntradaSM
|
||||
"""
|
||||
|
||||
__tablename__ = "subassembly_entries"
|
||||
__table_args__ = {
|
||||
"schema": "a76",
|
||||
@@ -107,10 +282,10 @@ class SubassemblyEntry(Base, TenantScopedMixin, TimestampMixin):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
remission_line: Mapped[int] = mapped_column(Integer) # LINEAREMISION
|
||||
exit_invoice: Mapped[Optional[str]] = mapped_column(
|
||||
String(15)) # FACTURASALIDA
|
||||
exit_invoice: Mapped[Optional[str]] = mapped_column(String(15)) # FACTURASALIDA
|
||||
exit_line: Mapped[Optional[int]] = mapped_column(Integer) # LINEASALIDA
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# INDEXES AND CONSTRAINTS
|
||||
# ============================================================================
|
||||
@@ -189,8 +364,8 @@ MIGRATION STRATEGY FROM ORIGINAL TABLES TO NORMALIZED SCHEMA:
|
||||
7. QUERYING EXAMPLES:
|
||||
```python
|
||||
# Get all imports (both systems)
|
||||
session.query(Item).filter(
|
||||
Item.item_type.in_(['IMPORT', 'EQUIPMENT_IMPORT_TEMP', 'EQUIPMENT_IMPORT_DEF'])
|
||||
session.query(LineItem).filter(
|
||||
LineItem.item_type.in_(['IMPORT', 'EQUIPMENT_IMPORT_TEMP', 'EQUIPMENT_IMPORT_DEF'])
|
||||
)
|
||||
|
||||
# Get all lines for a specific part across all items
|
||||
@@ -199,8 +374,8 @@ MIGRATION STRATEGY FROM ORIGINAL TABLES TO NORMALIZED SCHEMA:
|
||||
)
|
||||
|
||||
# Get SCAF equipment with depreciation
|
||||
session.query(Item).join(LineItem).filter(
|
||||
Item.system_origin == 'SCAF',
|
||||
session.query(LineItem).join(LineItem).filter(
|
||||
LineItem.system_origin == 'SCAF',
|
||||
LineItem.value_depreciated_usd.isnot(None)
|
||||
)
|
||||
```
|
||||
|
||||
@@ -11,10 +11,10 @@ from core.database import get_core_db
|
||||
from core.security import get_current_user, validate_access_to_resource
|
||||
|
||||
from .schemas import (
|
||||
ItemCreate,
|
||||
ItemUpdate,
|
||||
ItemResponse,
|
||||
ItemListResponse,
|
||||
LineItemCreate,
|
||||
LineItemUpdate,
|
||||
LineItemResponse,
|
||||
LineItemListResponse,
|
||||
)
|
||||
from .service import ItemService
|
||||
|
||||
@@ -25,9 +25,9 @@ router = APIRouter(prefix="/items", tags=["Items"])
|
||||
# ITEM CRUD ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/", response_model=ItemResponse, status_code=status.HTTP_201_CREATED)
|
||||
@router.post("/", response_model=LineItemResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_item(
|
||||
item_data: ItemCreate,
|
||||
item_data: LineItemCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
@@ -36,7 +36,6 @@ async def create_item(
|
||||
Create a new item with multiple line items and their nested data
|
||||
|
||||
The item follows a one-to-many relationship structure:
|
||||
- Item has many LineItems
|
||||
- Each LineItem has one LineFinancial
|
||||
- Each LineItem has one LineQuantity
|
||||
- Each LineItem has one LineCustoms
|
||||
@@ -49,7 +48,7 @@ async def create_item(
|
||||
return service.create(db, item_data, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.get("/{item_id}", response_model=ItemResponse)
|
||||
@router.get("/{item_id}", response_model=LineItemResponse)
|
||||
async def get_item(
|
||||
item_id: int = Path(..., description="Item ID"),
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
@@ -70,7 +69,7 @@ async def get_item(
|
||||
return item
|
||||
|
||||
|
||||
@router.get("/", response_model=ItemListResponse)
|
||||
@router.get("/", response_model=LineItemListResponse)
|
||||
async def list_items(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
@@ -109,7 +108,7 @@ async def list_items(
|
||||
items, total = service.get_all(
|
||||
db, tenant_id, company_id, skip, limit, filters)
|
||||
|
||||
return ItemListResponse(
|
||||
return LineItemListResponse(
|
||||
total=total,
|
||||
items=items,
|
||||
skip=skip,
|
||||
@@ -117,10 +116,10 @@ async def list_items(
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{item_id}", response_model=ItemResponse)
|
||||
@router.put("/{item_id}", response_model=LineItemResponse)
|
||||
async def update_item(
|
||||
item_id: int = Path(..., description="Item ID"),
|
||||
item_data: ItemUpdate = ...,
|
||||
item_data: LineItemUpdate = ...,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
@@ -168,7 +167,7 @@ async def delete_item(
|
||||
# ADDITIONAL ENDPOINTS FOR INVOICE
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/invoice/{invoice_id}/items", response_model=ItemListResponse)
|
||||
@router.get("/invoice/{invoice_id}/items", response_model=LineItemListResponse)
|
||||
async def get_items_by_invoice(
|
||||
invoice_id: int = Path(..., description="Invoice ID"),
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
@@ -186,7 +185,7 @@ async def get_items_by_invoice(
|
||||
items, total = service.get_by_invoice(
|
||||
db, invoice_id, tenant_id, company_id, skip, limit)
|
||||
|
||||
return ItemListResponse(
|
||||
return LineItemListResponse(
|
||||
total=total,
|
||||
items=items,
|
||||
skip=skip,
|
||||
|
||||
@@ -1,19 +1,46 @@
|
||||
"""
|
||||
Schemas for Items and related entities
|
||||
Complete nested one-to-one structure:
|
||||
Item -> LineItem -> LineFinancial -> LineQuantity -> LineCustoms -> LineDescription -> LineReference
|
||||
LineItem -> LineFinancial -> LineQuantity -> LineCustoms -> LineDescription -> LineReference
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from pydantic import BaseModel, Field, ConfigDict, model_validator
|
||||
|
||||
# Import schemas from individual modules
|
||||
from .line_items.schemas import (
|
||||
LineItemCreate,
|
||||
LineItemUpdate,
|
||||
LineItemResponse
|
||||
# Import nested schemas
|
||||
from .line_customs.schemas import (
|
||||
LineCustomCreate,
|
||||
LineCustomUpdate,
|
||||
LineCustomResponse,
|
||||
)
|
||||
from .line_descriptions.schemas import (
|
||||
LineDescriptionCreate,
|
||||
LineDescriptionUpdate,
|
||||
LineDescriptionResponse,
|
||||
)
|
||||
from .line_quantities.schemas import (
|
||||
LineQuantityCreate,
|
||||
LineQuantityUpdate,
|
||||
LineQuantityResponse,
|
||||
)
|
||||
from .line_financials.schemas import (
|
||||
LineFinancialCreate,
|
||||
LineFinancialUpdate,
|
||||
LineFinancialResponse,
|
||||
)
|
||||
from .line_references.schemas import (
|
||||
LineReferenceCreate,
|
||||
LineReferenceUpdate,
|
||||
LineReferenceResponse,
|
||||
)
|
||||
|
||||
from api.v1.modules.a24.fa.fa_item_lines.dto import (
|
||||
FaLineItemCreateDTO,
|
||||
FaLineItemUpdateDTO,
|
||||
FaLineItemResponseDTO,
|
||||
)
|
||||
|
||||
|
||||
@@ -21,53 +48,294 @@ from .line_items.schemas import (
|
||||
# ITEM SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
class ItemBase(BaseModel):
|
||||
|
||||
class LineItemBase(BaseModel):
|
||||
"""Base schema for items"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
invoice_id: int = Field(..., description="Invoice ID")
|
||||
line_number: int = Field(..., description="Line number")
|
||||
|
||||
# Part identification
|
||||
part_number_id: Optional[int] = Field(
|
||||
None,
|
||||
description="Part number",
|
||||
alias="part_number",
|
||||
serialization_alias="part_number_id",
|
||||
)
|
||||
component_part_number_id: Optional[int] = Field(
|
||||
None,
|
||||
description="Component part number",
|
||||
alias="component_part_number",
|
||||
serialization_alias="component_part_number_id",
|
||||
)
|
||||
class_id: Optional[int] = Field(None, description="Class code")
|
||||
|
||||
# Unit of measure
|
||||
unit_of_measure: Optional[int] = Field(None, description="Unit of measure")
|
||||
alternate_unit: Optional[int] = Field(None, description="Alternate unit")
|
||||
uma_key: Optional[str] = Field(None, max_length=2, description="UMA key")
|
||||
auxiliary_unit: Optional[str] = Field(
|
||||
None, max_length=5, description="Auxiliary unit"
|
||||
)
|
||||
|
||||
# Permits and certificates
|
||||
permit_number: Optional[str] = Field(
|
||||
None, max_length=20, description="Permit number"
|
||||
)
|
||||
page_line: Optional[str] = Field(None, max_length=10, description="Page line")
|
||||
has_certificate: Optional[bool] = Field(None, description="Has certificate")
|
||||
certificate_number: Optional[str] = Field(
|
||||
None, max_length=10, description="Certificate number"
|
||||
)
|
||||
octave_permit: Optional[str] = Field(
|
||||
None, max_length=20, description="Octave permit"
|
||||
)
|
||||
permits_ped: Optional[str] = Field(None, max_length=500, description="PED permits")
|
||||
|
||||
# FDA
|
||||
has_fda_code: Optional[bool] = Field(None, description="Has FDA code")
|
||||
fda_key: Optional[str] = Field(None, max_length=10, description="FDA key")
|
||||
|
||||
# Special flags
|
||||
is_military_mcia: Optional[bool] = Field(
|
||||
None, description="Is military merchandise"
|
||||
)
|
||||
|
||||
# IV32
|
||||
iv32_type_key: Optional[str] = Field(
|
||||
None, max_length=5, description="IV32 type key"
|
||||
)
|
||||
iv32_number: Optional[str] = Field(None, max_length=35, description="IV32 number")
|
||||
|
||||
# Export specific
|
||||
scrap_invoice: Optional[str] = Field(
|
||||
None, max_length=15, description="Scrap invoice"
|
||||
)
|
||||
consecutive_destination: Optional[int] = Field(
|
||||
None, description="Consecutive destination"
|
||||
)
|
||||
ctm_section: Optional[str] = Field(None, max_length=3, description="CTM section")
|
||||
|
||||
# Tax payment
|
||||
tax_payment: Optional[bool] = Field(None, description="Tax payment")
|
||||
payment_method: Optional[str] = Field(
|
||||
None, max_length=9, description="Payment method"
|
||||
)
|
||||
igi_amount: Optional[Decimal] = Field(None, description="IGI amount")
|
||||
igi_payment_method: Optional[str] = Field(
|
||||
None, max_length=9, description="IGI payment method"
|
||||
)
|
||||
|
||||
# FCC
|
||||
fcc_key: Optional[str] = Field(None, max_length=30, description="FCC key")
|
||||
|
||||
# Valuation method
|
||||
valuation_method: Optional[str] = Field(
|
||||
None, max_length=2, description="Valuation method"
|
||||
)
|
||||
valuation_determined_value: Optional[Decimal] = Field(
|
||||
None, description="Valuation determined value"
|
||||
)
|
||||
valuation_reason: Optional[str] = Field(
|
||||
None, max_length=500, description="Valuation reason"
|
||||
)
|
||||
|
||||
# Container rules
|
||||
container_rule: Optional[str] = Field(
|
||||
None, max_length=50, description="Container rule"
|
||||
)
|
||||
container_parts_ii: Optional[str] = Field(
|
||||
None, max_length=50, description="Container parts II"
|
||||
)
|
||||
|
||||
# APHIS
|
||||
consecutive_aphis: Optional[int] = Field(None, description="Consecutive APHIS")
|
||||
|
||||
# BOM/Commercial
|
||||
bom_version: Optional[int] = Field(None, description="BOM version")
|
||||
bill_version: Optional[int] = Field(None, description="Bill version")
|
||||
|
||||
# TLCAN value
|
||||
tlcan_value: Optional[Decimal] = Field(None, description="TLCAN value")
|
||||
|
||||
# Identifier
|
||||
identifier: Optional[str] = Field(None, max_length=2, description="Identifier")
|
||||
|
||||
# Validation fields
|
||||
validation_zero: Optional[int] = Field(None, description="Validation zero")
|
||||
validation_one: Optional[int] = Field(None, description="Validation one")
|
||||
|
||||
# Material type
|
||||
material_type: Optional[str] = Field(
|
||||
None, max_length=50, description="Material type"
|
||||
)
|
||||
|
||||
# Order concept
|
||||
order_type: Optional[str] = Field(None, max_length=50, description="Order type")
|
||||
line_concept: Optional[str] = Field(None, max_length=50, description="Line concept")
|
||||
|
||||
# Review dispatch
|
||||
review_dispatch: Optional[str] = Field(
|
||||
None, max_length=10, description="Review dispatch"
|
||||
)
|
||||
|
||||
# Take component from PT
|
||||
take_component_pt: Optional[int] = Field(None, description="Take component from PT")
|
||||
|
||||
# Pallet
|
||||
pallet2: Optional[int] = Field(None, description="Pallet 2")
|
||||
|
||||
# Wildcard field
|
||||
wildcard_field: Optional[str] = Field(
|
||||
None, max_length=100, description="Wildcard field"
|
||||
)
|
||||
reference_number: Optional[str] = Field(
|
||||
None, max_length=20, description="Reference number")
|
||||
None, max_length=20, description="Reference number"
|
||||
)
|
||||
order: Optional[str] = Field(None, max_length=50, description="Order")
|
||||
guide_number: Optional[str] = Field(
|
||||
None, max_length=50, description="Guide number")
|
||||
guide_number: Optional[str] = Field(None, max_length=50, description="Guide number")
|
||||
|
||||
# Dates
|
||||
depreciation_date: Optional[int] = Field(
|
||||
None, description="Depreciation date")
|
||||
depreciation_date: Optional[int] = Field(None, description="Depreciation date")
|
||||
|
||||
# Administrative fields
|
||||
rectification: Optional[int] = Field(None, description="Rectification")
|
||||
warehouse: Optional[str] = Field(
|
||||
None, max_length=30, description="Warehouse")
|
||||
location: Optional[str] = Field(
|
||||
None, max_length=200, description="Location")
|
||||
warehouse: Optional[str] = Field(None, max_length=30, description="Warehouse")
|
||||
location: Optional[str] = Field(None, max_length=200, description="Location")
|
||||
|
||||
|
||||
class ItemCreate(ItemBase):
|
||||
class LineItemCreate(LineItemBase):
|
||||
"""Schema for creating item with nested lines (one-to-many)"""
|
||||
lines: Optional[list[LineItemCreate]] = Field(
|
||||
default=[], description="List of line items")
|
||||
|
||||
# Override base fields - estos se asignan automáticamente en el service
|
||||
line_number: Optional[int] = Field(None, description="Line number (auto-assigned)")
|
||||
|
||||
financial: Optional[LineFinancialCreate] = Field(
|
||||
None, description="Financial data for this line"
|
||||
)
|
||||
quantity: Optional[LineQuantityCreate] = Field(
|
||||
None, description="Quantity data for this line"
|
||||
)
|
||||
customs: Optional[LineCustomCreate] = Field(
|
||||
None, description="Customs data for this line"
|
||||
)
|
||||
description: Optional[LineDescriptionCreate] = Field(
|
||||
None, description="Description data for this line"
|
||||
)
|
||||
reference: Optional[LineReferenceCreate] = Field(
|
||||
None, description="Reference data for this line"
|
||||
)
|
||||
fa_data: Optional[FaLineItemCreateDTO] = Field(
|
||||
None, description="Fixed Asset data for this line"
|
||||
)
|
||||
|
||||
|
||||
class ItemUpdate(ItemBase):
|
||||
class LineItemUpdate(LineItemBase):
|
||||
"""Schema for updating item"""
|
||||
|
||||
invoice_id: Optional[int] = Field(None, description="Invoice ID")
|
||||
lines: Optional[list[LineItemUpdate]] = Field(
|
||||
None, description="List of line items to update")
|
||||
# Override base fields - todos opcionales en updates
|
||||
line_number: Optional[int] = Field(None, description="Line number")
|
||||
financial: Optional[LineFinancialUpdate] = Field(
|
||||
None, description="Financial data for this line"
|
||||
)
|
||||
quantity: Optional[LineQuantityUpdate] = Field(
|
||||
None, description="Quantity data for this line"
|
||||
)
|
||||
customs: Optional[LineCustomUpdate] = Field(
|
||||
None, description="Customs data for this line"
|
||||
)
|
||||
description: Optional[LineDescriptionUpdate] = Field(
|
||||
None, description="Description data for this line"
|
||||
)
|
||||
reference: Optional[LineReferenceUpdate] = Field(
|
||||
None, description="Reference data for this line"
|
||||
)
|
||||
fa_data: Optional[FaLineItemUpdateDTO] = Field(
|
||||
None, description="Fixed Asset data for this line"
|
||||
)
|
||||
|
||||
|
||||
class ItemResponse(ItemBase):
|
||||
"""Schema for item response with nested data (one-to-many)"""
|
||||
class LineItemResponse(LineItemBase):
|
||||
"""Schema for single item response"""
|
||||
|
||||
id: int
|
||||
lines: list[LineItemResponse] = Field(
|
||||
default=[], description="List of line items")
|
||||
invoice_id: int
|
||||
line_number: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
# Part identification
|
||||
part_number_id: Optional[int] = Field(
|
||||
None, alias="part_number", serialization_alias="part_number_id"
|
||||
)
|
||||
component_part_number_id: Optional[int] = Field(
|
||||
None,
|
||||
alias="component_part_number",
|
||||
serialization_alias="component_part_number_id",
|
||||
)
|
||||
class_id: Optional[int] = None
|
||||
|
||||
# Nested data
|
||||
financial: Optional[LineFinancialResponse] = None
|
||||
quantity: Optional[LineQuantityResponse] = None
|
||||
customs: Optional[LineCustomResponse] = None
|
||||
description: Optional[LineDescriptionResponse] = None
|
||||
reference: Optional[LineReferenceResponse] = None
|
||||
fa_data: Optional[FaLineItemResponseDTO] = None
|
||||
|
||||
# Fields populated from relationships
|
||||
class_code: Optional[str] = None
|
||||
class_description: Optional[str] = None
|
||||
unit_of_measure_code: Optional[str] = None
|
||||
|
||||
# Additional fields that might be present
|
||||
reference_number: Optional[str] = None
|
||||
order: Optional[str] = None
|
||||
warehouse: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def extract_relationship_info(cls, data: Any) -> Any:
|
||||
"""Extract class_code, class_description and unit_of_measure_code from relationships"""
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
|
||||
# It's an ORM object
|
||||
result = {}
|
||||
for key in cls.model_fields.keys():
|
||||
if hasattr(data, key):
|
||||
result[key] = getattr(data, key)
|
||||
|
||||
# Map model field names to schema field names for aliased fields
|
||||
if hasattr(data, "part_number"):
|
||||
result["part_number_id"] = data.part_number
|
||||
if hasattr(data, "component_part_number"):
|
||||
result["component_part_number_id"] = data.component_part_number
|
||||
|
||||
# Extract class info
|
||||
if hasattr(data, "class_info") and data.class_info is not None:
|
||||
result["class_code"] = data.class_info.class_code
|
||||
result["class_description"] = data.class_info.description_es
|
||||
|
||||
# Extract unit of measure code
|
||||
if (
|
||||
hasattr(data, "unit_of_measure_info")
|
||||
and data.unit_of_measure_info is not None
|
||||
):
|
||||
result["unit_of_measure_code"] = data.unit_of_measure_info.code
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class ItemListResponse(BaseModel):
|
||||
class LineItemListResponse(BaseModel):
|
||||
"""Schema for paginated item list"""
|
||||
|
||||
total: int = Field(..., description="Total number of items")
|
||||
items: list[ItemResponse] = Field(..., description="List of items")
|
||||
items: list[LineItemResponse] = Field(..., description="List of items")
|
||||
skip: int = Field(..., description="Number of skipped items")
|
||||
limit: int = Field(..., description="Maximum items per page")
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"""
|
||||
Service layer for Items business logic
|
||||
Handles CRUD operations for Item with complete one-to-one relationships:
|
||||
Item -> LineItem -> LineFinancial
|
||||
-> LineQuantity
|
||||
-> LineCustoms
|
||||
-> LineDescription
|
||||
-> LineReference
|
||||
-> FaLineItem (Fixed Assets - a24)
|
||||
Handles CRUD operations for LineItem with complete one-to-one relationships:
|
||||
LineItem -> LineFinancial
|
||||
-> LineQuantity
|
||||
-> LineCustoms
|
||||
-> LineDescription
|
||||
-> LineReference
|
||||
-> FaLineItem (Fixed Assets - a24)
|
||||
|
||||
After refactoring: LineItem is the main entity, representing a single line item in an invoice.
|
||||
There is no intermediate Item entity anymore. Each LineItem belongs directly to an InvoiceHeader.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -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)
|
||||
|
||||
@@ -17,7 +17,7 @@ from api.v1.modules.a76.general_catalogs.company.models import Company
|
||||
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
|
||||
from api.v1.modules.a76.pedmientos.models.pedimento_validation import PedimentoValidation
|
||||
from api.v1.modules.a76.customs_brokers.models import CustomsBroker, CustomsBrokerPersonnel
|
||||
from api.v1.modules.a76.items.models import Item
|
||||
from api.v1.modules.a76.items.models import LineItem
|
||||
from api.v1.modules.a76.clients_and_providers.models import ClientProvider, ClientProviderAddress, ClientProviderPrograms
|
||||
|
||||
# --- SCHEMAS FOR TEMPLATE CONTEXT ---
|
||||
@@ -186,7 +186,7 @@ class AvisoConsolidadoExportacionService:
|
||||
logistics = header.logistics
|
||||
|
||||
# Fetch Items associated with this invoice (MOVED UP FOR WEIGHT CALCULATION)
|
||||
items = db.query(Item).filter(Item.invoice_id == invoice_id).all()
|
||||
items = db.query(LineItem).filter(LineItem.invoice_id == invoice_id).all()
|
||||
|
||||
# Peso Bruto
|
||||
peso_bruto_val = "0.0"
|
||||
@@ -194,14 +194,12 @@ class AvisoConsolidadoExportacionService:
|
||||
|
||||
# Calculate sum from items first
|
||||
if items:
|
||||
for item in items:
|
||||
if item.lines:
|
||||
for line in item.lines:
|
||||
if line.quantity and line.quantity.gross_weight:
|
||||
try:
|
||||
calculated_gross_weight += float(line.quantity.gross_weight)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
for item in items:
|
||||
if item.quantity and item.quantity.gross_weight:
|
||||
try:
|
||||
calculated_gross_weight += float(item.quantity.gross_weight)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if financials and financials.gross_weight and float(financials.gross_weight) > 0:
|
||||
peso_bruto_val = f"{financials.gross_weight:,.2f}"
|
||||
@@ -379,20 +377,19 @@ class AvisoConsolidadoExportacionService:
|
||||
cant_total = 0.0
|
||||
|
||||
if items:
|
||||
for item in items:
|
||||
if item.lines:
|
||||
for line in item.lines:
|
||||
# Priority: Quantity (UMA or Standard)
|
||||
q = 0.0
|
||||
if line.quantity:
|
||||
try:
|
||||
if line.quantity.quantity_uma is not None:
|
||||
q = float(line.quantity.quantity_uma)
|
||||
elif line.quantity.quantity is not None:
|
||||
q = float(line.quantity.quantity)
|
||||
except (ValueError, TypeError):
|
||||
q = 0.0
|
||||
cant_total += q
|
||||
for item in items:
|
||||
for line in item:
|
||||
# Priority: Quantity (UMA or Standard)
|
||||
q = 0.0
|
||||
if line.quantity:
|
||||
try:
|
||||
if line.quantity.quantity_uma is not None:
|
||||
q = float(line.quantity.quantity_uma)
|
||||
elif line.quantity.quantity is not None:
|
||||
q = float(line.quantity.quantity)
|
||||
except (ValueError, TypeError):
|
||||
q = 0.0
|
||||
cant_total += q
|
||||
|
||||
# Format: 15 chars, 3 decimals? Actually Clarion LINEPRINT usually just prints the text.
|
||||
# Clarion 'CLIP(FORMAT(Loc:CantTotal,@n015.3))' removes spaces.
|
||||
|
||||
@@ -15,10 +15,9 @@ from datetime import datetime
|
||||
# --- MODELOS (Imported from system for Header info) ---
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceComplianceMx
|
||||
from api.v1.modules.a76.general_catalogs.company.models import Company
|
||||
from api.v1.modules.a76.items.line_items.models import LineItem
|
||||
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
|
||||
from api.v1.modules.a76.parts.models import Part
|
||||
from api.v1.modules.a76.items.models import Item
|
||||
from api.v1.modules.a76.items.models import LineItem
|
||||
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
|
||||
from api.v1.modules.a76.pedmientos.models.pedimento_dates import PedimentoDates
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
@@ -40,8 +39,8 @@ class FIFOAssignmentService:
|
||||
Returns a list of calculated discharges.
|
||||
"""
|
||||
# 1. Get Export Lines
|
||||
export_lines = db.query(LineItem).join(Item).filter(
|
||||
Item.invoice_id == invoice_id
|
||||
export_lines = db.query(LineItem).join(LineItem).filter(
|
||||
LineItem.invoice_id == invoice_id
|
||||
).options(
|
||||
joinedload(LineItem.quantity),
|
||||
joinedload(LineItem.description),
|
||||
@@ -58,7 +57,7 @@ class FIFOAssignmentService:
|
||||
if qty_needed <= 0:
|
||||
continue
|
||||
|
||||
part_number = exp_line.part_number
|
||||
part_number = exp_line.part_number_id
|
||||
if not part_number:
|
||||
self._log(f"Skipping line {exp_line.id}, no part number")
|
||||
continue
|
||||
@@ -79,10 +78,10 @@ class FIFOAssignmentService:
|
||||
|
||||
# 2. Find Import Candidates (FIFO order by payment date)
|
||||
# Use outerjoin for pedimento dates to avoid filtering out candidates with missing dates
|
||||
candidates = db.query(LineItem).join(Item).join(InvoiceHeader)\
|
||||
candidates = db.query(LineItem).join(InvoiceHeader)\
|
||||
.join(InvoiceComplianceMx).join(InvoiceComplianceMx.pedimento).outerjoin(Pedimentos.pedimento_dates)\
|
||||
.filter(
|
||||
LineItem.part_number == part_number,
|
||||
LineItem.part_number_id == part_number,
|
||||
InvoiceHeader.operation_type == 'imp', # Assuming 'imp' is the value for Import based on Enum
|
||||
).order_by(
|
||||
PedimentoDates.payment_date.asc()
|
||||
@@ -90,7 +89,7 @@ class FIFOAssignmentService:
|
||||
joinedload(LineItem.quantity),
|
||||
joinedload(LineItem.customs),
|
||||
joinedload(LineItem.financial),
|
||||
joinedload(LineItem.item).joinedload(Item.invoice).joinedload(InvoiceHeader.compliance_mx).joinedload(InvoiceComplianceMx.pedimento).joinedload(Pedimentos.pedimento_dates)
|
||||
joinedload(LineItem.item).joinedload(LineItem.invoice).joinedload(InvoiceHeader.compliance_mx).joinedload(InvoiceComplianceMx.pedimento).joinedload(Pedimentos.pedimento_dates)
|
||||
).all()
|
||||
|
||||
self._log(f"Found {len(candidates)} candidates for {part_number}")
|
||||
@@ -259,16 +258,15 @@ class DescargaReportService:
|
||||
# --- 2. Obtener Líneas de Exportación (Lo que necesitamos cubrir) ---
|
||||
if progress_callback: progress_callback(20, "Obteniendo items a exportar...")
|
||||
|
||||
export_lines = db.query(LineItem).filter(
|
||||
LineItem.item_id == Item.id,
|
||||
Item.invoice_id == invoice_id
|
||||
export_lines = db.query(LineItem).filter(
|
||||
LineItem.invoice_id == invoice_id
|
||||
).options(
|
||||
joinedload(LineItem.quantity),
|
||||
joinedload(LineItem.customs),
|
||||
joinedload(LineItem.description),
|
||||
joinedload(LineItem.unit_of_measure_info),
|
||||
joinedload(LineItem.part_info)
|
||||
).join(Item).all()
|
||||
).join(LineItem).all()
|
||||
|
||||
items_reporte = []
|
||||
|
||||
|
||||
@@ -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 [], []
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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]
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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]
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user