Files
plantillas-proyectos/backend/api/v1/modules/a76/invoices/services.py

769 lines
29 KiB
Python

import traceback
from typing import Optional, List, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import and_, func, or_
from core.exceptions import ErrorCollector, DuplicateResourceException
from core.context import get_user_context
from api.v1.modules.a76.audit_log.models import AuditLog
from api.v1.modules.a76.audit_log.services.service import AuditService
from .common.mappers import clean_dict
from .imports.validators.create import validate_create as validate_create_import
from .imports.validators.update import validate_update as validate_update_import
from .exports.validators.create import validate_create as validate_create_export
from .exports.validators.update import validate_update as validate_update_export
from .common.common_validators import invoice_exists
from api.v1.modules.a76.items.models import LineItem
from . import models, schemas
def _autofill_transport_int_ids(
db: Session,
logistics_target,
tenant_id: int,
company_id: int,
) -> None:
"""
Ensures invoice logistics internal int IDs are populated when catalog rows exist.
String keys (carrier_id, transport_id, trailer_num) are matched to existing catalog
rows for the tenant/company; no placeholder rows are created. Invalid references must
be rejected by validate_common before persist.
"""
# Local imports to avoid circular dependencies.
from api.v1.modules.a76.transportation.transporters.models import Transporter
from api.v1.modules.a76.transportation.vehicles.models import Vehicle
from api.v1.modules.a76.transportation.trailers.models import Trailer
def _get(field: str):
if isinstance(logistics_target, dict):
return logistics_target.get(field)
return getattr(logistics_target, field, None)
def _set(field: str, value):
if isinstance(logistics_target, dict):
logistics_target[field] = value
else:
setattr(logistics_target, field, value)
carrier_code = _get("carrier_id")
carrier_int_id = _get("carrier_int_id")
if carrier_code and carrier_int_id is None:
transporter_obj = (
db.query(Transporter)
.filter(
Transporter.transporter_key == carrier_code,
Transporter.tenant_id == tenant_id,
Transporter.company_id == company_id,
)
.first()
)
if transporter_obj:
_set("carrier_int_id", transporter_obj.transporter_id)
transport_code = _get("transport_id")
transport_int_id = _get("transport_int_id")
if transport_code and transport_int_id is None:
vehicle_obj = (
db.query(Vehicle)
.filter(
func.upper(Vehicle.vehicle_key) == str(transport_code).strip().upper(),
Vehicle.tenant_id == tenant_id,
Vehicle.company_id == company_id,
)
.first()
)
if vehicle_obj:
_set("transport_int_id", vehicle_obj.vehicle_id)
trailer_code = _get("trailer_num")
trailer_int_id = _get("trailer_int_id")
if trailer_code and trailer_int_id is None:
trailer_obj = (
db.query(Trailer)
.filter(
func.upper(Trailer.trailer_number)
== str(trailer_code).strip().upper(),
Trailer.tenant_id == tenant_id,
Trailer.company_id == company_id,
)
.first()
)
if trailer_obj:
_set("trailer_int_id", trailer_obj.trailer_id)
# Placa: si no vino valor, tomar del vehículo y si no del remolque (catálogo)
lp = _get("license_plate")
if lp is None or str(lp).strip() == "":
veh = None
tv_id = _get("transport_int_id")
tv_code = _get("transport_id")
if tv_id is not None:
veh = (
db.query(Vehicle)
.filter(
Vehicle.vehicle_id == tv_id,
Vehicle.tenant_id == tenant_id,
Vehicle.company_id == company_id,
)
.first()
)
elif tv_code:
veh = (
db.query(Vehicle)
.filter(
func.upper(Vehicle.vehicle_key) == str(tv_code).strip().upper(),
Vehicle.tenant_id == tenant_id,
Vehicle.company_id == company_id,
)
.first()
)
if veh and getattr(veh, "plate_number", None):
_set("license_plate", veh.plate_number)
else:
tr = None
tr_id = _get("trailer_int_id")
tr_code = _get("trailer_num")
if tr_id is not None:
tr = (
db.query(Trailer)
.filter(
Trailer.trailer_id == tr_id,
Trailer.tenant_id == tenant_id,
Trailer.company_id == company_id,
)
.first()
)
elif tr_code:
tr = (
db.query(Trailer)
.filter(
func.upper(Trailer.trailer_number)
== str(tr_code).strip().upper(),
Trailer.tenant_id == tenant_id,
Trailer.company_id == company_id,
)
.first()
)
if tr and getattr(tr, "plate_number", None):
_set("license_plate", tr.plate_number)
def _get_current_username() -> str:
"""Helper to get current username from context or fallback to System"""
try:
context = get_user_context()
if context:
# Token usually has 'preferred_username' or 'name' or 'sub'
username = (
context.get("preferred_username")
or context.get("email")
or context.get("sub")
or "System"
)
print(f"DEBUG: _get_current_username found context: {username}")
return username
except Exception:
pass
print("DEBUG: _get_current_username NO context found, using System")
return "System"
def _autofill_remesa_if_needed(db: Session, invoice_data, tenant_id: int, company_id: int) -> None:
"""
Autocalcula remesa cuando hay pedimento consolidado y remesa viene vacía.
Se hace ANTES de validar para que cumpla reglas de required en validators.
"""
try:
compliance = getattr(invoice_data, "compliance_mx", None)
if not compliance:
return
pedimento_id = getattr(compliance, "pedimento_id", None)
remesa = getattr(compliance, "remesa", None)
if not pedimento_id or remesa:
return
# No aplicar a MEX (por consistencia con CSV import donde remesa es None para MEX)
invoice_type = getattr(invoice_data, "invoice_type", None)
if invoice_type == "MEX":
return
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos, PedimentoType
ped = (
db.query(Pedimentos)
.filter(
Pedimentos.id == pedimento_id,
Pedimentos.tenant_id == tenant_id,
Pedimentos.company_id == company_id,
)
.first()
)
if not ped:
return
if getattr(ped, "pedimento_type", None) != PedimentoType.CONSOLIDATED:
return
max_rem = (
db.query(func.max(models.InvoiceComplianceMx.remesa))
.filter(
models.InvoiceComplianceMx.pedimento_id == pedimento_id,
models.InvoiceComplianceMx.tenant_id == tenant_id,
models.InvoiceComplianceMx.company_id == company_id,
)
.scalar()
)
next_rem = (max_rem or 0) + 1
compliance.remesa = next_rem
except Exception:
# No bloquear guardado por fallo de autocalculo; validación normal aplicará.
return
def _ensure_create_audit_log(
db: Session,
*,
table_name: str,
record_id: str,
record_data: dict,
username: str,
tenant_id: int,
company_id: int,
) -> bool:
"""
Backup audit writer: writes CREATE only when missing.
Returns True when a backup log row was added.
"""
exists = (
db.query(AuditLog.spec_id)
.filter(
AuditLog.table_name == table_name,
AuditLog.operation_type == "CREATE",
AuditLog.record_id == record_id,
AuditLog.tenant_id == tenant_id,
AuditLog.company_id == company_id,
)
.first()
)
if exists:
return False
AuditService.log_crud_operation(
db=db,
table_name=table_name,
operation_type="CREATE",
record_data=record_data,
username=username,
record_id=record_id,
company_id=company_id,
tenant_id=tenant_id,
)
return True
class InvoiceService:
"""Service for Invoice Header operations"""
@staticmethod
def get_by_id(
db: Session, invoice_id: int, tenant_id: int, company_id: int
) -> Optional[models.InvoiceHeader]:
"""Get an invoice by ID with tenant/company validation"""
return (
db.query(models.InvoiceHeader)
.filter(
models.InvoiceHeader.id == invoice_id,
models.InvoiceHeader.tenant_id == tenant_id,
models.InvoiceHeader.company_id == company_id,
)
.first()
)
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 100,
filters: Optional[dict] = None,
sort_by: Optional[str] = None,
sort_order: Optional[str] = "asc",
) -> Tuple[List[models.InvoiceHeader], int]:
"""Get all invoices for a tenant/company with pagination and optional filters"""
query = db.query(models.InvoiceHeader).filter(
models.InvoiceHeader.tenant_id == tenant_id,
models.InvoiceHeader.company_id == company_id,
)
# Apply filters if provided
print(f"DEBUG: Invoice Query - Company: {company_id}, Filters: {filters}, Skip: {skip}, Limit: {limit}")
if filters:
# Join compliance_mx if needed for filters
needs_compliance_join = any(k in filters for k in ["pedimento", "manifest_number"])
if needs_compliance_join:
query = query.join(models.InvoiceComplianceMx)
if filters.get("status") is not None:
status_val = filters["status"]
if status_val in [True, "processed", models.InvoiceStatus.PROCESSED]:
target_status = models.InvoiceStatus.PROCESSED
elif status_val in [False, "pending", models.InvoiceStatus.PENDING]:
target_status = models.InvoiceStatus.PENDING
else:
target_status = status_val
query = query.filter(models.InvoiceHeader.status == target_status)
if filters.get("operation_type"):
ot = filters["operation_type"]
ot_val = ot.value if hasattr(ot, "value") else ot
query = query.filter(models.InvoiceHeader.operation_type == ot_val)
if filters.get("invoice_type"):
query = query.filter(
models.InvoiceHeader.invoice_type == filters["invoice_type"]
)
if filters.get("invoice_number"):
query = query.filter(
models.InvoiceHeader.invoice_number.ilike(
f"%{filters['invoice_number']}%"
)
)
if filters.get("project_number"):
query = query.filter(
models.InvoiceHeader.project_number.ilike(
f"%{filters['project_number']}%"
)
)
if filters.get("year"):
try:
year_val = int(filters["year"])
query = query.filter(
func.extract("year", models.InvoiceHeader.invoice_date) == year_val
)
except (ValueError, TypeError):
pass
if filters.get("pedimento"):
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
query = query.join(models.InvoiceComplianceMx.pedimento).filter(
Pedimentos.pedimento_number.ilike(f"%{filters['pedimento']}%")
)
if filters.get("manifest_number"):
query = query.filter(
models.InvoiceComplianceMx.manifest_number.ilike(
f"%{filters['manifest_number']}%"
)
)
# Special case for exports: exclude REPAR if no invoice_type specified
ot_exp = filters.get("operation_type")
ot_exp_val = ot_exp.value if hasattr(ot_exp, "value") else ot_exp
if not filters.get("invoice_type") and ot_exp_val == "exp":
query = query.filter(models.InvoiceHeader.invoice_type != "REPAR")
# Filtro por permisos granulares (allowed_types)
if "allowed_types" in filters:
allowed = filters["allowed_types"]
if allowed is None:
# Acceso global (admin o view_all) - no filtramos por tipos
pass
elif not allowed:
# Seguridad: Si el usuario NO tiene permisos para ningún tipo específico
query = query.filter(models.InvoiceHeader.id == -1)
else:
conditions = []
for op, inv in allowed:
# Aseguramos comparación insensible a mayúsculas para mayor robustez con la DB
op_str = str(op).lower()
inv_str = str(inv).lower()
conditions.append(
and_(
func.lower(models.InvoiceHeader.operation_type) == op_str,
func.lower(models.InvoiceHeader.invoice_type) == inv_str
)
)
if conditions:
query = query.filter(or_(*conditions))
else:
# Seguridad: Si tiene allowed_types pero no generamos condiciones, no debe ver nada
query = query.filter(models.InvoiceHeader.id == -1)
# Apply sorting
if sort_by:
# Simple column mapping
# This can be improved to handle joins if needed
column = getattr(models.InvoiceHeader, sort_by, None)
if column:
if sort_order == "desc":
query = query.order_by(column.desc())
else:
query = query.order_by(column.asc())
else:
# Default sorting if none provided
query = query.order_by(models.InvoiceHeader.id.desc())
total = query.count()
items = query.offset(skip).limit(limit).all()
# Keep party_count aligned with the real number of line items.
# This avoids stale values stored in invoice_header.party_count.
if items:
invoice_ids = [inv.id for inv in items]
counts = (
db.query(LineItem.invoice_id, func.count(LineItem.id))
.filter(
LineItem.invoice_id.in_(invoice_ids),
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id,
)
.group_by(LineItem.invoice_id)
.all()
)
count_map = {invoice_id: int(count) for invoice_id, count in counts}
for inv in items:
inv.party_count = count_map.get(inv.id, 0)
return items, total
@staticmethod
def create(
db: Session,
invoice_data: schemas.InvoiceHeaderCreate,
tenant_id: int,
company_id: int,
) -> models.InvoiceHeader:
"""Create a new invoice with all related data"""
# Validaciones con ErrorCollector
errors = ErrorCollector()
# Autocalculo remesa (si aplica) ANTES de validar
_autofill_remesa_if_needed(db, invoice_data, tenant_id, company_id)
# DEBUG: Log payload for analysis
print(f"DEBUG: Creating invoice {invoice_data.invoice_number} of type {invoice_data.invoice_type}")
print(f"DEBUG: Payload: {invoice_data.model_dump()}")
# Validar si la factura ya existe
invoice_exists(db, invoice_data.invoice_number, tenant_id, company_id, errors)
if invoice_data.operation_type == "exp":
validate_create_export(db, invoice_data, tenant_id, company_id, errors)
else:
validate_create_import(db, invoice_data, tenant_id, company_id, errors)
# Si hay errores, lanzar excepción ANTES de intentar crear
errors.raise_if_errors("Error al crear la factura")
# Extract nested data
compliance_data = invoice_data.compliance_mx
financials_data = invoice_data.financials
logistics_data = invoice_data.logistics
details_data = invoice_data.details or []
collections_data = invoice_data.collections or []
try:
# Create main invoice header
raw_invoice_dict = invoice_data.model_dump(
exclude={
"compliance_mx",
"financials",
"logistics",
"details",
"collections",
}
)
invoice_dict = clean_dict(raw_invoice_dict)
invoice_dict["tenant_id"] = tenant_id
invoice_dict["company_id"] = company_id
# Automatic status and audit fields
username = _get_current_username()
invoice_dict["capture_user"] = username
invoice_dict["who_processed"] = username
# Ensure document_type respects DB constraints for MEX/AME invoices (bypass clean_dict)
if invoice_dict.get("invoice_type") in ["MEX", "AME"] and not invoice_dict.get("document_type"):
invoice_dict["document_type"] = None
new_invoice = models.InvoiceHeader(**invoice_dict)
db.add(new_invoice)
db.flush() # Flush to get the invoice ID
# Create compliance_mx if provided
if compliance_data:
raw_comp_dict = compliance_data.model_dump()
compliance_dict = clean_dict(raw_comp_dict)
compliance_dict["invoice_id"] = new_invoice.id
compliance_dict["tenant_id"] = tenant_id
compliance_dict["company_id"] = company_id
new_compliance = models.InvoiceComplianceMx(**compliance_dict)
db.add(new_compliance)
# Create financials if provided
if financials_data:
raw_fin_dict = financials_data.model_dump()
financials_dict = clean_dict(raw_fin_dict)
financials_dict["invoice_id"] = new_invoice.id
financials_dict["tenant_id"] = tenant_id
financials_dict["company_id"] = company_id
new_financials = models.InvoiceFinancials(**financials_dict)
db.add(new_financials)
# Create logistics entries
if logistics_data:
raw_log_dict = logistics_data.model_dump()
logistics_dict = clean_dict(raw_log_dict)
logistics_dict["invoice_id"] = new_invoice.id
logistics_dict["tenant_id"] = tenant_id
logistics_dict["company_id"] = company_id
_autofill_transport_int_ids(
db,
logistics_dict,
tenant_id=tenant_id,
company_id=company_id,
)
new_logistics = models.InvoiceLogistics(**logistics_dict)
db.add(new_logistics)
# Create sales details
for detail_item in details_data:
raw_det_dict = detail_item.model_dump()
detail_dict = clean_dict(raw_det_dict)
detail_dict["invoice_id"] = new_invoice.id
detail_dict["tenant_id"] = tenant_id
detail_dict["company_id"] = company_id
new_detail = models.InvoiceSalesDetails(**detail_dict)
db.add(new_detail)
# Create collections
for collection_item in collections_data:
raw_col_dict = collection_item.model_dump()
collection_dict = clean_dict(raw_col_dict)
collection_dict["invoice_id"] = new_invoice.id
collection_dict["tenant_id"] = tenant_id
collection_dict["company_id"] = company_id
new_collection = models.InvoiceCollections(**collection_dict)
db.add(new_collection)
db.commit()
db.refresh(new_invoice)
backup_written = _ensure_create_audit_log(
db,
table_name=new_invoice.__tablename__,
record_id=str(new_invoice.id),
record_data={
c.name: getattr(new_invoice, c.name)
for c in models.InvoiceHeader.__table__.columns
},
username=username,
tenant_id=tenant_id,
company_id=company_id,
)
for detail in new_invoice.details:
backup_written = (
_ensure_create_audit_log(
db,
table_name=detail.__tablename__,
record_id=str(detail.id),
record_data={
c.name: getattr(detail, c.name)
for c in models.InvoiceSalesDetails.__table__.columns
},
username=username,
tenant_id=tenant_id,
company_id=company_id,
)
or backup_written
)
if backup_written:
db.commit()
return new_invoice
except Exception as e:
db.rollback()
print("\n\n🔥 ERROR AL GUARDAR FACTURA 🔥")
print(f"Error: {str(e)}")
traceback.print_exc() # Esto imprime el error real en la consola
print("--------------------------------\n")
raise e
@staticmethod
def update(
db: Session,
invoice_id: int,
tenant_id: int,
invoice_data: schemas.InvoiceHeaderUpdate,
company_id: int,
) -> Optional[models.InvoiceHeader]:
"""Update an existing invoice with validation"""
# DEBUG: Log payload for analysis
print(f"DEBUG: Updating invoice ID {invoice_id} of type {invoice_data.invoice_type}")
print(f"DEBUG: Payload: {invoice_data.model_dump(exclude_unset=True)}")
# Validaciones con ErrorCollector
errors = ErrorCollector()
# Obtener la factura existente
invoice = InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id)
if not invoice:
return None
# Si se cambió el número de factura, validar que no exista otra con ese número
if invoice_data.invoice_number and invoice_data.invoice_number != invoice.invoice_number:
# Verificar que no exista otra factura con el nuevo número
existing_invoice = (
db.query(models.InvoiceHeader.id)
.filter(
models.InvoiceHeader.invoice_number == invoice_data.invoice_number,
models.InvoiceHeader.tenant_id == tenant_id,
models.InvoiceHeader.company_id == company_id,
models.InvoiceHeader.id != invoice_id, # Excluir la factura actual
)
.first()
)
if existing_invoice:
errors.add_duplicate_error(
"invoice_number",
invoice_data.invoice_number,
f"Ya existe otra factura con el número '{invoice_data.invoice_number}'",
)
if invoice_data.operation_type == "exp":
validate_update_export(db, invoice_data, invoice, tenant_id, company_id, errors)
else:
# Autocalculo remesa (si aplica) ANTES de validar
_autofill_remesa_if_needed(db, invoice_data, tenant_id, company_id)
validate_update_import(db, invoice_data, invoice, tenant_id, company_id, errors)
# Si hay errores, lanzar excepción ANTES de actualizar
errors.raise_if_errors("Error al actualizar la factura")
# Update main invoice header fields
update_dict = invoice_data.model_dump(
exclude={
"id",
"compliance_mx",
"financials",
"logistics",
"details",
"collections",
},
exclude_unset=True,
)
for key, value in update_dict.items():
setattr(invoice, key, value)
# Audit update fields
username = _get_current_username()
invoice.who_processed = username
invoice.updated_date = func.now()
# Backfill capture_user if missing or previous generic 'System'
if not invoice.capture_user or invoice.capture_user == "System":
if username != "System":
invoice.capture_user = username
# 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
).items():
# Parche rápido para update
if value == "":
value = None
setattr(invoice.compliance_mx, key, value)
else:
compliance_dict = invoice_data.compliance_mx.model_dump()
# Aplicar limpieza manual si es necesario
if "customs_agent" in compliance_dict:
compliance_dict["customs_broker_id"] = compliance_dict.pop(
"customs_agent"
)
compliance_dict["invoice_id"] = invoice.id
compliance_dict["tenant_id"] = tenant_id
compliance_dict["company_id"] = company_id
new_compliance = models.InvoiceComplianceMx(**compliance_dict)
db.add(new_compliance)
# Update financials if provided
if invoice_data.financials is not None:
if invoice.financials:
for key, value in invoice_data.financials.model_dump(
exclude_unset=True
).items():
if value == "":
value = None
setattr(invoice.financials, key, value)
else:
financials_dict = invoice_data.financials.model_dump()
financials_dict["invoice_id"] = invoice.id
financials_dict["tenant_id"] = tenant_id
financials_dict["company_id"] = company_id
new_financials = models.InvoiceFinancials(**financials_dict)
db.add(new_financials)
# Update logistics if provided
if invoice_data.logistics is not None:
if invoice.logistics:
for key, value in invoice_data.logistics.model_dump(
exclude_unset=True
).items():
if value == "":
value = None
setattr(invoice.logistics, key, value)
_autofill_transport_int_ids(
db,
invoice.logistics,
tenant_id=tenant_id,
company_id=company_id,
)
else:
logistics_dict = invoice_data.logistics.model_dump()
logistics_dict["invoice_id"] = invoice.id
logistics_dict["tenant_id"] = tenant_id
logistics_dict["company_id"] = company_id
_autofill_transport_int_ids(
db,
logistics_dict,
tenant_id=tenant_id,
company_id=company_id,
)
new_logistics = models.InvoiceLogistics(**logistics_dict)
db.add(new_logistics)
db.commit()
db.refresh(invoice)
return invoice
@staticmethod
def delete(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> bool:
"""Delete an invoice and all related data (cascade delete)"""
invoice = InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id)
if invoice:
db.delete(invoice)
db.commit()
return True
return False