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

2548 lines
101 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 api.v1.modules.a76.items.line_financials.models import LineFinancial
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
from api.v1.modules.a76.items.line_customs.models import LineCustom
from api.v1.modules.a76.items.line_descriptions.models import LineDescription
from api.v1.modules.a76.items.line_references.models import LineReference
from api.v1.modules.a76.items.series.models import Serie
from api.v1.modules.a76.general_catalogs.identifiers.models import IdentifierDetail
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 sistema activo (SCAF / SCAII)
if filters.get("system"):
query = query.filter(models.InvoiceHeader.system == filters["system"])
# 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
@staticmethod
def copy(
db: Session,
invoice_id: int,
tenant_id: int,
company_id: int,
new_invoice_number: Optional[str] = None,
) -> models.InvoiceHeader:
"""Duplica una factura con todas sus tablas relacionadas, incluyendo partidas.
Si se provee new_invoice_number, se usa ese número (falla con 409 si ya existe).
Si no, genera sufijo '-COPIA' / '-COPIA-N' automáticamente.
Estado reseteado a 'pending', sin pedimento ni datos de procesamiento.
"""
from fastapi import HTTPException
original = (
db.query(models.InvoiceHeader)
.filter(
models.InvoiceHeader.id == invoice_id,
models.InvoiceHeader.tenant_id == tenant_id,
models.InvoiceHeader.company_id == company_id,
)
.first()
)
if not original:
raise HTTPException(status_code=404, detail="Factura no encontrada")
if new_invoice_number:
exists = db.query(models.InvoiceHeader).filter(
models.InvoiceHeader.invoice_number == new_invoice_number,
models.InvoiceHeader.tenant_id == tenant_id,
models.InvoiceHeader.company_id == company_id,
).first()
if exists:
raise HTTPException(
status_code=409,
detail=f"Ya existe una factura con el número '{new_invoice_number}'"
)
else:
base_number = original.invoice_number or ""
candidate = f"{base_number}-COPIA"
counter = 1
while db.query(models.InvoiceHeader).filter(
models.InvoiceHeader.invoice_number == candidate,
models.InvoiceHeader.tenant_id == tenant_id,
models.InvoiceHeader.company_id == company_id,
).first():
counter += 1
candidate = f"{base_number}-COPIA-{counter}"
new_invoice_number = candidate
username = _get_current_username()
# Columnas a excluir en la copia del encabezado
EXCLUDE_COLS = {
"id", "invoice_number", "status", "capture_date", "capture_user",
"who_processed", "processed_date", "process_log", "status_rec",
"status_rep", "comments_status", "vu_observations", "cfdi_uuid",
"path_pdf", "path_xml", "created_at", "updated_at",
}
header_data = {
c.name: getattr(original, c.name)
for c in models.InvoiceHeader.__table__.columns
if c.name not in EXCLUDE_COLS
}
header_data["invoice_number"] = new_invoice_number
header_data["status"] = models.InvoiceStatus.PENDING
header_data["capture_user"] = username
header_data["who_processed"] = username
new_invoice = models.InvoiceHeader(**header_data)
db.add(new_invoice)
db.flush()
# Copiar compliance_mx (sin pedimento ni datos de procesamiento aduanal)
comp = db.query(models.InvoiceComplianceMx).filter(
models.InvoiceComplianceMx.invoice_id == invoice_id
).first()
if comp:
EXCLUDE_COMP = {
"invoice_id", "vucem_operation_num",
"electronic_signature", "certificate_number", "niu_number",
"code_signature", "edocument", "created_at", "updated_at",
}
comp_data = {
c.name: getattr(comp, c.name)
for c in models.InvoiceComplianceMx.__table__.columns
if c.name not in EXCLUDE_COMP
}
comp_data["invoice_id"] = new_invoice.id
db.add(models.InvoiceComplianceMx(**comp_data))
# Copiar financials
fin = db.query(models.InvoiceFinancials).filter(
models.InvoiceFinancials.invoice_id == invoice_id
).first()
if fin:
EXCLUDE_FIN = {"id", "invoice_id", "created_at", "updated_at"}
fin_data = {
c.name: getattr(fin, c.name)
for c in models.InvoiceFinancials.__table__.columns
if c.name not in EXCLUDE_FIN
}
fin_data["invoice_id"] = new_invoice.id
fin_data["tenant_id"] = tenant_id
fin_data["company_id"] = company_id
db.add(models.InvoiceFinancials(**fin_data))
# Copiar logistics
log_entries = db.query(models.InvoiceLogistics).filter(
models.InvoiceLogistics.invoice_id == invoice_id
).all()
for log in log_entries:
EXCLUDE_LOG = {"id", "invoice_id", "created_at", "updated_at"}
log_data = {
c.name: getattr(log, c.name)
for c in models.InvoiceLogistics.__table__.columns
if c.name not in EXCLUDE_LOG
}
log_data["invoice_id"] = new_invoice.id
log_data["tenant_id"] = tenant_id
log_data["company_id"] = company_id
db.add(models.InvoiceLogistics(**log_data))
# Copiar sales details
details = db.query(models.InvoiceSalesDetails).filter(
models.InvoiceSalesDetails.invoice_id == invoice_id
).all()
for det in details:
EXCLUDE_DET = {"id", "invoice_id", "created_at", "updated_at"}
det_data = {
c.name: getattr(det, c.name)
for c in models.InvoiceSalesDetails.__table__.columns
if c.name not in EXCLUDE_DET
}
det_data["invoice_id"] = new_invoice.id
det_data["tenant_id"] = tenant_id
det_data["company_id"] = company_id
db.add(models.InvoiceSalesDetails(**det_data))
# Copiar collections
collections = db.query(models.InvoiceCollections).filter(
models.InvoiceCollections.invoice_id == invoice_id
).all()
for col in collections:
EXCLUDE_COL = {"id", "invoice_id", "created_at", "updated_at"}
col_data = {
c.name: getattr(col, c.name)
for c in models.InvoiceCollections.__table__.columns
if c.name not in EXCLUDE_COL
}
col_data["invoice_id"] = new_invoice.id
col_data["tenant_id"] = tenant_id
col_data["company_id"] = company_id
db.add(models.InvoiceCollections(**col_data))
# Copiar partidas (LineItem) con todas sus sub-tablas
EXCLUDE_LINE = {"id", "invoice_id", "created_at", "updated_at"}
EXCLUDE_SUB = {"id", "item_line_id", "created_at", "updated_at"}
EXCLUDE_SERIE = {"id", "line_item_id", "created_at", "updated_at"}
EXCLUDE_IDENT = {"id", "item_line_id", "created_at", "updated_at"}
line_items = db.query(LineItem).filter(
LineItem.invoice_id == invoice_id,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id,
).order_by(LineItem.line_number).all()
for item in line_items:
line_data = {
c.name: getattr(item, c.name)
for c in LineItem.__table__.columns
if c.name not in EXCLUDE_LINE
}
line_data["invoice_id"] = new_invoice.id
new_item = LineItem(**line_data)
db.add(new_item)
db.flush()
# Copiar series primero para reusar sus IDs en LineReference
serie_id_map: dict[int, int] = {}
for serie in db.query(Serie).filter(Serie.line_item_id == item.id).all():
s_data = {
c.name: getattr(serie, c.name)
for c in Serie.__table__.columns
if c.name not in EXCLUDE_SERIE
}
s_data["line_item_id"] = new_item.id
new_serie = Serie(**s_data)
db.add(new_serie)
db.flush()
serie_id_map[serie.id] = new_serie.id
fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == item.id).first()
if fin:
f_data = {c.name: getattr(fin, c.name) for c in LineFinancial.__table__.columns if c.name not in EXCLUDE_SUB}
f_data["item_line_id"] = new_item.id
db.add(LineFinancial(**f_data))
qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == item.id).first()
if qty:
q_data = {c.name: getattr(qty, c.name) for c in LineQuantity.__table__.columns if c.name not in EXCLUDE_SUB}
q_data["item_line_id"] = new_item.id
db.add(LineQuantity(**q_data))
cus = db.query(LineCustom).filter(LineCustom.item_line_id == item.id).first()
if cus:
cu_data = {c.name: getattr(cus, c.name) for c in LineCustom.__table__.columns if c.name not in EXCLUDE_SUB}
cu_data["item_line_id"] = new_item.id
db.add(LineCustom(**cu_data))
desc = db.query(LineDescription).filter(LineDescription.item_line_id == item.id).first()
if desc:
d_data = {c.name: getattr(desc, c.name) for c in LineDescription.__table__.columns if c.name not in EXCLUDE_SUB}
d_data["item_line_id"] = new_item.id
db.add(LineDescription(**d_data))
ref = db.query(LineReference).filter(LineReference.item_line_id == item.id).first()
if ref:
r_data = {
c.name: getattr(ref, c.name)
for c in LineReference.__table__.columns
if c.name not in {"id", "item_line_id", "serie_id", "created_at", "updated_at"}
}
r_data["item_line_id"] = new_item.id
r_data["serie_id"] = serie_id_map.get(ref.serie_id) if ref.serie_id else None
db.add(LineReference(**r_data))
for ident in db.query(IdentifierDetail).filter(IdentifierDetail.item_line_id == item.id).all():
i_data = {c.name: getattr(ident, c.name) for c in IdentifierDetail.__table__.columns if c.name not in EXCLUDE_IDENT}
i_data["item_line_id"] = new_item.id
db.add(IdentifierDetail(**i_data))
db.commit()
db.refresh(new_invoice)
return new_invoice
@staticmethod
def export_items(
db: Session,
invoice: "models.InvoiceHeader",
fmt: str,
):
"""Genera StreamingResponse con partidas de la factura.
Replica el OF 2 del POPUP legacy (QEqiDef → CSV/TXT/XLSX).
Formatos: csv (,) | txt (|) | xlsx (openpyxl).
Incluye sección de series al final si existen.
"""
import io
import csv
import re
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import joinedload
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction
def _strip_newlines(text):
if not text:
return ''
return re.sub(r'[\r\n]+', ' ', str(text)).strip()
# --- Query principal: patrón joinedload igual que ItemService.get_all ---
items = (
db.query(LineItem)
.options(
joinedload(LineItem.quantity),
joinedload(LineItem.financial),
joinedload(LineItem.customs),
joinedload(LineItem.description),
joinedload(LineItem.class_info),
joinedload(LineItem.part_info),
joinedload(LineItem.unit_of_measure_info),
joinedload(LineItem.series),
)
.filter(
LineItem.invoice_id == invoice.id,
LineItem.tenant_id == invoice.tenant_id,
LineItem.company_id == invoice.company_id,
)
.order_by(LineItem.line_number)
.all()
)
# --- Lookup bulk de UMCLAVE (TariffFraction.umt por código de fracción) ---
fractions = {
item.customs.fraction
for item in items
if item.customs and item.customs.fraction
}
umt_map: dict = {}
if fractions:
umt_map = {
r.code: r.umt
for r in db.query(TariffFraction.code, TariffFraction.umt)
.filter(TariffFraction.code.in_(fractions))
.all()
}
# --- Delimitadores y media type ---
fmt = fmt.lower()
if fmt == 'xlsx':
return InvoiceService._export_items_xlsx(invoice, items, umt_map)
delim = ',' if fmt == 'csv' else '|'
media_type = 'text/csv; charset=utf-8' if fmt == 'csv' else 'text/plain; charset=utf-8'
ext = fmt
HEADERS = [
'FACTURA', 'LINEA', 'PROCEDENCIA', 'FACTURA', 'LINEA', 'SI',
'CANTIDAD', 'UNIMED', 'COSTO UNITARIO',
'PESO NETO', 'PESO BRUTO', 'CANT BULTOS', 'CLAVE BULTOS',
'PAIS', 'FRACCION', 'PREFERENCIA', 'SECTOR', 'FRAC AMERICANA',
'ORDEN COMPRA', 'NUM PARTE',
'DESCRIP ESP', 'DESCRIP ING', 'MARCA', 'MODELO', 'CLASE', 'UMCLAVE',
'VALORIMPOMN', 'VALORIMPOME', 'LOTE',
'DESCRIPCIÓN EXTRA EN ESPAÑOL', 'USUARIO QUE CAPTURO LA FACTURA',
]
SER_HEADERS = ['FACTURA', 'LINEA', 'RENGLON', 'SERIE', 'MODELO', 'NUMID']
def _item_to_list(item: LineItem):
q = item.quantity
f = item.financial
c = item.customs
d = item.description
cls = item.class_info
part = item.part_info
uom = item.unit_of_measure_info
pais = c.origin_country if c else ''
fraccion = (c.octave_fraction or c.fraction) if c else ''
umclave = umt_map.get(c.fraction, '') if c and c.fraction else ''
return [
invoice.invoice_number or '',
item.line_number or '',
'TEM',
invoice.invoice_number or '',
item.line_number or '',
'SI',
q.quantity if q else '',
uom.code if uom else '',
f.unit_cost_capture if f else '',
q.net_weight if q else '',
q.gross_weight if q else '',
q.package_quantity if q else '',
q.package_id if q else '',
pais or '',
fraccion,
c.fraction_type if c else '',
c.sector if c else '',
c.american_fraction if c else '',
invoice.purchase_order or '',
part.part_number if part else '',
_strip_newlines(d.description_spanish if d else None),
_strip_newlines(d.description_english if d else None),
d.brand if d else '',
d.model if d else '',
cls.class_code if cls else '',
umclave,
f.value_mxn if f else '',
f.value_usd if f else '',
d.lot if d else '',
_strip_newlines(d.extra_description if d else None),
invoice.capture_user or '',
]
def generate():
buf = io.StringIO()
writer = csv.writer(buf, delimiter=delim, lineterminator='\r\n')
writer.writerow(HEADERS)
yield buf.getvalue()
for item in items:
buf.seek(0); buf.truncate(0)
writer.writerow(_item_to_list(item))
yield buf.getvalue()
# Sección de series al final
has_series = any(item.series for item in items)
if has_series:
buf.seek(0); buf.truncate(0)
writer.writerow([])
writer.writerow(SER_HEADERS)
yield buf.getvalue()
for item in items:
for s in sorted(item.series, key=lambda x: x.row):
buf.seek(0); buf.truncate(0)
writer.writerow([
invoice.invoice_number or '',
item.line_number or '',
s.row or '',
s.serial_numbers or '',
s.model or '',
s.number_id or '',
])
yield buf.getvalue()
safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura')
filename = f"{safe_num}_export.{ext}"
return StreamingResponse(
generate(),
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@staticmethod
def _export_items_xlsx(invoice, items, umt_map: dict):
"""Genera el archivo XLSX con openpyxl."""
import io
import re
from fastapi.responses import StreamingResponse
try:
import openpyxl
except ImportError:
raise ImportError("openpyxl no está instalado. Agrega 'openpyxl' a requirements.txt")
def _strip_newlines(text):
if not text:
return ''
return re.sub(r'[\r\n]+', ' ', str(text)).strip()
def _f(v):
return float(v) if v is not None else ''
HEADERS = [
'FACTURA', 'LINEA', 'PROCEDENCIA', 'FACTURA', 'LINEA', 'SI',
'CANTIDAD', 'UNIMED', 'COSTO UNITARIO',
'PESO NETO', 'PESO BRUTO', 'CANT BULTOS', 'CLAVE BULTOS',
'PAIS', 'FRACCION', 'PREFERENCIA', 'SECTOR', 'FRAC AMERICANA',
'ORDEN COMPRA', 'NUM PARTE',
'DESCRIP ESP', 'DESCRIP ING', 'MARCA', 'MODELO', 'CLASE', 'UMCLAVE',
'VALORIMPOMN', 'VALORIMPOME', 'LOTE',
'DESCRIPCIÓN EXTRA EN ESPAÑOL', 'USUARIO QUE CAPTURO LA FACTURA',
]
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Partidas"
ws.append(HEADERS)
for item in items:
q = item.quantity
f = item.financial
c = item.customs
d = item.description
cls = item.class_info
part = item.part_info
uom = item.unit_of_measure_info
pais = c.origin_country if c else ''
fraccion = (c.octave_fraction or c.fraction) if c else ''
umclave = umt_map.get(c.fraction, '') if c and c.fraction else ''
ws.append([
invoice.invoice_number or '',
item.line_number or '',
'TEM',
invoice.invoice_number or '',
item.line_number or '',
'SI',
_f(q.quantity if q else None),
uom.code if uom else '',
_f(f.unit_cost_capture if f else None),
_f(q.net_weight if q else None),
_f(q.gross_weight if q else None),
_f(q.package_quantity if q else None),
str(q.package_id or '') if q else '',
str(pais or ''),
str(fraccion),
str(c.fraction_type or '') if c else '',
str(c.sector or '') if c else '',
str(c.american_fraction or '') if c else '',
str(invoice.purchase_order or ''),
str(part.part_number or '') if part else '',
_strip_newlines(d.description_spanish if d else None),
_strip_newlines(d.description_english if d else None),
str(d.brand or '') if d else '',
str(d.model or '') if d else '',
str(cls.class_code or '') if cls else '',
str(umclave),
_f(f.value_mxn if f else None),
_f(f.value_usd if f else None),
str(d.lot or '') if d else '',
_strip_newlines(d.extra_description if d else None),
str(invoice.capture_user or ''),
])
has_series = any(item.series for item in items)
if has_series:
ws.append([])
ws.append(['FACTURA', 'LINEA', 'RENGLON', 'SERIE', 'MODELO', 'NUMID'])
for item in items:
for s in sorted(item.series, key=lambda x: x.row):
ws.append([
invoice.invoice_number or '',
item.line_number or '',
s.row or '',
s.serial_numbers or '',
s.model or '',
s.number_id or '',
])
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura')
filename = f"{safe_num}_export.xlsx"
return StreamingResponse(
iter([buf.read()]),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@staticmethod
def export_gm_transport(
db: Session,
invoice: "models.InvoiceHeader",
):
"""Genera CSV para interfaz GM Transport (OF 5 del POPUP legacy).
Replica las 21 columnas del formato GM Transport. Pedimento formateado
como: AÑO ADUANA PATENTE NUMERO con espacios dobles entre campos.
"""
import io
import csv
import re
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import joinedload
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.invoices.models import InvoiceComplianceMx
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
from api.v1.modules.a76.pedmientos.models.pedimento_dates import PedimentoDates
def _strip(text):
if not text:
return ''
return re.sub(r'[\r\n]+', ' ', str(text)).strip()
# Datos de pedimento y compliance son por factura (no por partida)
compliance = (
db.query(InvoiceComplianceMx)
.filter(InvoiceComplianceMx.invoice_id == invoice.id)
.first()
)
pedimento = None
ped_date = None
if compliance and compliance.pedimento_id:
pedimento = db.query(Pedimentos).filter(Pedimentos.id == compliance.pedimento_id).first()
if pedimento:
ped_date_row = (
db.query(PedimentoDates)
.filter(PedimentoDates.pedimento_id == pedimento.id)
.first()
)
ped_date = ped_date_row.entry_date if ped_date_row else None
def _format_pedimento() -> str:
if not pedimento or not pedimento.pedimento_number:
return ''
return f"{pedimento.year or ''} {pedimento.customs_office or ''} {pedimento.license or ''} {pedimento.pedimento_number or ''}"
# Partidas con sus sub-modelos cargados (joinedload — mismo patrón que ItemService)
items = (
db.query(LineItem)
.options(
joinedload(LineItem.quantity),
joinedload(LineItem.customs),
joinedload(LineItem.class_info),
)
.filter(
LineItem.invoice_id == invoice.id,
LineItem.tenant_id == invoice.tenant_id,
LineItem.company_id == invoice.company_id,
)
.order_by(LineItem.line_number)
.all()
)
ped_str = _format_pedimento()
fecha_str = ped_date.strftime('%d/%m/%Y') if ped_date else ''
aduana_str = compliance.aduana if compliance else ''
HEADERS = [
'CANTIDAD', 'ID UNIDAD EMBALAJE', 'DESC. MATERIAL CARGA', 'PESO', 'ID UNIDAD PESO',
'CODIGO DE PRODUCTO Y SERVICIO', 'CLAVE UNIDAD DE MEDIDA Y EMBALAJE', 'CLAVE UNIDAD',
'CLAVE FRACCIÓN ARANCELARIA', 'UUID COMERCIO EXTERIOR', 'ES MATERIAL PELIGROSO?',
'CLAVE MATERIAL PELIGROSO', 'TIPO EMBALAJE', 'DESCRIPCIÓN EMBALAJE',
'APLICA TARIFA', 'TARIFA', 'IMPORTE', 'IMPORTE BASE',
'NÚMERO DE PEDIMENTO', 'FECHA', 'ADUANA',
]
def _item_to_list(item: LineItem):
q = item.quantity
c = item.customs
cls = item.class_info
return [
q.quantity if q else '',
'',
_strip(cls.description_es if cls else None),
q.gross_weight if q else '',
'',
cls.fraction if cls else '',
'',
'',
("'" + c.fraction) if c and c.fraction else '',
'',
'NO',
'', '', '', '', '', '', '',
ped_str,
fecha_str,
aduana_str,
]
def generate():
buf = io.StringIO()
writer = csv.writer(buf, delimiter=',', lineterminator='\r\n')
writer.writerow(HEADERS)
yield buf.getvalue()
for item in items:
buf.seek(0); buf.truncate(0)
writer.writerow(_item_to_list(item))
yield buf.getvalue()
safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura')
filename = f"{safe_num}_gm_transport.csv"
return StreamingResponse(
generate(),
media_type='text/csv; charset=utf-8',
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@staticmethod
def export_carta_porte(db: Session, invoice) -> "StreamingResponse":
"""
Genera el CSV de Interfaz Carta Porte (OF-4).
24 columnas: datos de partida + RFC proveedor/enviado a/importador + pedimento + régimen.
"""
import io
import csv
import re
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import joinedload
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.invoices.models import InvoiceComplianceMx
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.a76.clients_and_providers.models import ClientProvider
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction
from api.v1.modules.public.reference_data.carta_porte_codes.models import CartaPorte
def _strip(text):
if not text:
return ''
return re.sub(r'[\r\n]+', ' ', str(text)).strip()
# Datos fijos por factura: compliance, pedimento, RFCs
compliance = (
db.query(InvoiceComplianceMx)
.filter(InvoiceComplianceMx.invoice_id == invoice.id)
.first()
)
pedimento = None
ped_date = None
regime = ''
aduana_str = compliance.aduana if compliance else ''
provider_rfc = shipped_to_rfc = sold_to_rfc = ''
if compliance:
if compliance.pedimento_id:
pedimento = db.query(Pedimentos).filter(Pedimentos.id == compliance.pedimento_id).first()
if pedimento:
regime = pedimento.regime or ''
ped_date_row = (
db.query(PedimentoDates)
.filter(PedimentoDates.pedimento_id == pedimento.id)
.first()
)
ped_date = ped_date_row.entry_date if ped_date_row else None
def _get_rfc(client_id):
if not client_id:
return ''
# Identificador fiscal efectivo: RFC (nacional) o TAX-ID (extranjero)
cp = db.query(ClientProvider).filter(ClientProvider.id == client_id).first()
return (cp.fiscal_id or '') if cp else ''
provider_rfc = _get_rfc(compliance.provider_id)
shipped_to_rfc = _get_rfc(compliance.shipped_to_id)
sold_to_rfc = _get_rfc(compliance.sold_to_id)
def _format_pedimento() -> str:
if not pedimento or not pedimento.pedimento_number:
return ''
return (
f"{pedimento.year or ''} {pedimento.customs_office or ''}"
f" {pedimento.license or ''} {pedimento.pedimento_number or ''}"
)
# Partidas con joinedload (mismo patrón que export_items)
items = (
db.query(LineItem)
.options(
joinedload(LineItem.quantity),
joinedload(LineItem.financial),
joinedload(LineItem.customs),
joinedload(LineItem.description),
joinedload(LineItem.class_info),
joinedload(LineItem.unit_of_measure_info),
)
.filter(
LineItem.invoice_id == invoice.id,
LineItem.tenant_id == invoice.tenant_id,
LineItem.company_id == invoice.company_id,
)
.order_by(LineItem.line_number)
.all()
)
# Bulk lookups: UMT por fracción aduanera, CartaPorte por fracción de clase
customs_fractions = {
item.customs.fraction for item in items if item.customs and item.customs.fraction
}
class_fractions = {
item.class_info.fraction for item in items
if item.class_info and item.class_info.fraction
}
umt_map = (
{r.code: r.umt for r in db.query(TariffFraction.code, TariffFraction.umt)
.filter(TariffFraction.code.in_(customs_fractions)).all()}
if customs_fractions else {}
)
cp_map = (
{r.code: r for r in db.query(CartaPorte)
.filter(CartaPorte.code.in_(class_fractions)).all()}
if class_fractions else {}
)
ped_str = _format_pedimento()
fecha_str = ped_date.strftime('%d/%m/%Y') if ped_date else ''
HEADERS = [
'FACTURA', 'CLASE', 'DESCRIPCION CLASE', 'CODIGO DE PRODUCTO Y SERVICIO',
'DESCRIPCION CÓDIGO', 'CANTIDAD', 'UNIDAD DE MEDIDA', 'UNIDAD DE MEDIDA SAT',
'PESO NETO', 'PESO BRUTO', 'FRACCION', 'VALOR MN', 'VALOR ME',
'RFC PROVEEDOR', 'RFC ENVIADO A:', 'PEDIMENTO', 'FECHA INICIO PEDIMENTO',
'MATERIAL PELIGROSO', 'ADUANA', 'TIPO DE MATERIAL', 'DESCRIPCION DE LA MATERIA',
'TIPO DE DOCUMENTO', 'RFC IMPORTADOR', 'REGIMEN ADUANERO',
]
def _item_to_list(item: LineItem):
q = item.quantity
fin = item.financial
c = item.customs
desc = item.description
cls = item.class_info
uom = item.unit_of_measure_info
customs_frac = c.fraction if c else ''
class_frac = cls.fraction if cls else None
cp = cp_map.get(class_frac) if class_frac else None
return [
invoice.invoice_number or '',
cls.class_code if cls else '',
_strip(desc.description_spanish if desc else None),
cp.code if cp else '',
cp.description if cp else '',
q.quantity if q else '',
uom.code if uom else '',
umt_map.get(customs_frac, ''),
q.net_weight if q else '',
q.gross_weight if q else '',
customs_frac,
fin.value_mxn if fin else '',
fin.value_usd if fin else '',
provider_rfc,
shipped_to_rfc,
ped_str,
fecha_str,
'NO',
aduana_str,
cls.material_key if cls else '',
'', # DESCRIPCION DE LA MATERIA — tabla material_types no mapeada
'01', # TIPO DE DOCUMENTO
sold_to_rfc,
regime,
]
def generate():
buf = io.StringIO()
writer = csv.writer(buf, delimiter=',', lineterminator='\r\n')
writer.writerow(HEADERS)
yield buf.getvalue()
for item in items:
buf.seek(0); buf.truncate(0)
writer.writerow(_item_to_list(item))
yield buf.getvalue()
safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura')
filename = f"{safe_num}_carta_porte.csv"
return StreamingResponse(
generate(),
media_type='text/csv; charset=utf-8',
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@staticmethod
def export_tfc(db: Session, invoice) -> "StreamingResponse":
"""Interfaz TFC (OF-6). 17 columnas. Pedimento desglosado en 4 campos."""
import io
import csv
import re
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import joinedload
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.invoices.models import InvoiceComplianceMx
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
from api.v1.modules.a76.pedmientos.models.pedimento_dates import PedimentoDates
def _strip(text):
if not text:
return ''
return re.sub(r'[\r\n]+', ' ', str(text)).strip()
compliance = (
db.query(InvoiceComplianceMx)
.filter(InvoiceComplianceMx.invoice_id == invoice.id)
.first()
)
pedimento = None
if compliance and compliance.pedimento_id:
pedimento = db.query(Pedimentos).filter(Pedimentos.id == compliance.pedimento_id).first()
ped_year = pedimento.year or '' if pedimento else ''
ped_office = pedimento.customs_office or '' if pedimento else ''
ped_license = pedimento.license or '' if pedimento else ''
ped_number = pedimento.pedimento_number or '' if pedimento else ''
items = (
db.query(LineItem)
.options(
joinedload(LineItem.quantity),
joinedload(LineItem.customs),
joinedload(LineItem.class_info),
joinedload(LineItem.description),
)
.filter(
LineItem.invoice_id == invoice.id,
LineItem.tenant_id == invoice.tenant_id,
LineItem.company_id == invoice.company_id,
)
.order_by(LineItem.line_number)
.all()
)
HEADERS = [
'ID DESTINO', 'BIENES TRANSPORTADOS', 'CANTIDAD MERCANCIA', 'CLAVE DE UNIDAD',
'PESO EN KG', 'DESCRIPCION DE MERCANCIA', 'MATERIAL PELIGROSO', 'CLAVE DE MATERIAL',
'CLAVE EMBALAJE (SOLO SI ES MATERIAL PELIGROSO)', 'FRACCION ARANCELARIA',
'UUID DE COMERCIO EXTERIOR', 'DESCRIPCION GUIA DE IDENTIFICACION',
'PESO KG GUIA DE IDENTIFICACION', 'PEDIMENTO - VALIDACION', 'PEDIMENTO - ADUANA',
'PEDIMENTO - PATENTE', 'PEDIMENTO NUMERACION PROGRESIVA',
]
def _item_to_list(item: LineItem):
q = item.quantity
c = item.customs
cls = item.class_info
d = item.description
return [
'1',
cls.fraction if cls else '',
q.quantity if q else '',
'',
q.net_weight if q else '',
_strip(d.description_spanish if d else None),
'NO',
'', '',
c.fraction if c else '',
'', '', '',
ped_year,
ped_office,
ped_license,
ped_number,
]
def generate():
buf = io.StringIO()
writer = csv.writer(buf, delimiter=',', lineterminator='\r\n')
writer.writerow(HEADERS)
yield buf.getvalue()
for item in items:
buf.seek(0); buf.truncate(0)
writer.writerow(_item_to_list(item))
yield buf.getvalue()
safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura')
filename = f"{safe_num}_tfc.csv"
return StreamingResponse(
generate(),
media_type='text/csv; charset=utf-8',
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@staticmethod
def export_carta_porte_consolidada(db: Session, invoice) -> "StreamingResponse":
"""Interfaz Carta Porte Consolidada (OF-8). 24 cols, igual que OF-4 pero
partidas agrupadas por clase: SUM de cantidades/pesos/valores."""
import io
import csv
import re
from decimal import Decimal
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import joinedload
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.invoices.models import InvoiceComplianceMx
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.a76.clients_and_providers.models import ClientProvider
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction
from api.v1.modules.public.reference_data.carta_porte_codes.models import CartaPorte
UOM_SAT = {
'PZA': 'H87', 'KGS': 'KGM', 'MT': 'MTR', 'LT': 'LTR',
'JGO': 'SET', 'GR': 'GRM', 'M2': 'MTK', 'PAR': 'PR', 'CAJA': 'XBX',
}
MAT_DESC = {
'01': 'Materia prima',
'02': 'Materia procesada',
'03': 'Materia terminada(producto terminado)',
'04': 'Materia para la industria manufacturera',
'05': 'Otra',
}
MATERIAL_EXCLUIDOS = {'TERR', 'INSTA', 'EDIF'}
def _strip(text):
if not text:
return ''
return re.sub(r'[\r\n]+', ' ', str(text)).strip()
def _dec(v):
if v is None:
return Decimal('0')
return Decimal(str(v))
compliance = (
db.query(InvoiceComplianceMx)
.filter(InvoiceComplianceMx.invoice_id == invoice.id)
.first()
)
pedimento = None
ped_date = None
regime = ''
aduana_str = compliance.aduana if compliance else ''
provider_rfc = shipped_to_rfc = sold_to_rfc = ''
if compliance:
if compliance.pedimento_id:
pedimento = db.query(Pedimentos).filter(Pedimentos.id == compliance.pedimento_id).first()
if pedimento:
regime = pedimento.regime or ''
ped_date_row = (
db.query(PedimentoDates)
.filter(PedimentoDates.pedimento_id == pedimento.id)
.first()
)
ped_date = ped_date_row.entry_date if ped_date_row else None
def _get_rfc(client_id):
if not client_id:
return ''
# Identificador fiscal efectivo: RFC (nacional) o TAX-ID (extranjero)
cp = db.query(ClientProvider).filter(ClientProvider.id == client_id).first()
return (cp.fiscal_id or '') if cp else ''
provider_rfc = _get_rfc(compliance.provider_id)
shipped_to_rfc = _get_rfc(compliance.shipped_to_id)
sold_to_rfc = _get_rfc(compliance.sold_to_id)
def _format_pedimento() -> str:
if not pedimento or not pedimento.pedimento_number:
return ''
return (
f"{pedimento.year or ''} {pedimento.customs_office or ''}"
f" {pedimento.license or ''} {pedimento.pedimento_number or ''}"
)
items = (
db.query(LineItem)
.options(
joinedload(LineItem.quantity),
joinedload(LineItem.financial),
joinedload(LineItem.customs),
joinedload(LineItem.description),
joinedload(LineItem.class_info),
joinedload(LineItem.unit_of_measure_info),
)
.filter(
LineItem.invoice_id == invoice.id,
LineItem.tenant_id == invoice.tenant_id,
LineItem.company_id == invoice.company_id,
)
.order_by(LineItem.line_number)
.all()
)
customs_fractions = {
item.customs.fraction for item in items if item.customs and item.customs.fraction
}
class_fractions = {
item.class_info.fraction for item in items
if item.class_info and item.class_info.fraction
}
umt_map = (
{r.code: r.umt for r in db.query(TariffFraction.code, TariffFraction.umt)
.filter(TariffFraction.code.in_(customs_fractions)).all()}
if customs_fractions else {}
)
cp_map = (
{r.code: r for r in db.query(CartaPorte)
.filter(CartaPorte.code.in_(class_fractions)).all()}
if class_fractions else {}
)
# Agrupación por class_code
groups: dict = {}
group_order: list = []
for item in items:
key = item.class_info.class_code if item.class_info else ''
if key not in groups:
group_order.append(key)
cls = item.class_info
c = item.customs
uom = item.unit_of_measure_info
class_frac = cls.fraction if cls else None
cp = cp_map.get(class_frac) if class_frac else None
mat_key = cls.material_key if cls else ''
tipo_mat = '' if mat_key in MATERIAL_EXCLUIDOS else '05'
groups[key] = {
'class_code': key,
'description': _strip(item.description.description_spanish if item.description else None),
'cp_code': cp.code if cp else '',
'cp_desc': cp.description if cp else '',
'uom': uom.code if uom else '',
'uom_sat': UOM_SAT.get(uom.code if uom else '', ''),
'customs_frac': c.fraction if c else '',
'tipo_mat': tipo_mat,
'mat_desc': MAT_DESC.get(tipo_mat, ''),
'quantity': _dec(item.quantity.quantity if item.quantity else None),
'net_weight': _dec(item.quantity.net_weight if item.quantity else None),
'gross_weight': _dec(item.quantity.gross_weight if item.quantity else None),
'value_mxn': _dec(item.financial.value_mxn if item.financial else None),
'value_usd': _dec(item.financial.value_usd if item.financial else None),
}
else:
g = groups[key]
g['quantity'] += _dec(item.quantity.quantity if item.quantity else None)
g['net_weight'] += _dec(item.quantity.net_weight if item.quantity else None)
g['gross_weight'] += _dec(item.quantity.gross_weight if item.quantity else None)
g['value_mxn'] += _dec(item.financial.value_mxn if item.financial else None)
g['value_usd'] += _dec(item.financial.value_usd if item.financial else None)
ped_str = _format_pedimento()
fecha_str = ped_date.strftime('%d/%m/%Y') if ped_date else ''
HEADERS = [
'FACTURA', 'CLASE', 'DESCRIPCION CLASE', 'CODIGO DE PRODUCTO Y SERVICIO',
'DESCRIPCION CÓDIGO', 'CANTIDAD', 'UNIDAD DE MEDIDA', 'UNIDAD DE MEDIDA SAT',
'PESO NETO', 'PESO BRUTO', 'FRACCION', 'VALOR MN', 'VALOR ME',
'RFC PROVEEDOR', 'RFC ENVIADO A:', 'PEDIMENTO', 'FECHA INICIO PEDIMENTO',
'MATERIAL PELIGROSO', 'ADUANA', 'TIPO DE MATERIAL', 'DESCRIPCION DE LA MATERIA',
'TIPO DE DOCUMENTO', 'RFC IMPORTADOR', 'REGIMEN ADUANERO',
]
def generate():
buf = io.StringIO()
writer = csv.writer(buf, delimiter=',', lineterminator='\r\n')
writer.writerow(HEADERS)
yield buf.getvalue()
for key in group_order:
g = groups[key]
buf.seek(0); buf.truncate(0)
writer.writerow([
invoice.invoice_number or '',
g['class_code'],
g['description'],
g['cp_code'],
g['cp_desc'],
g['quantity'],
g['uom'],
g['uom_sat'],
g['net_weight'],
g['gross_weight'],
g['customs_frac'],
g['value_mxn'],
g['value_usd'],
provider_rfc,
shipped_to_rfc,
ped_str,
fecha_str,
'NO',
aduana_str,
g['tipo_mat'],
g['mat_desc'],
'01',
sold_to_rfc,
regime,
])
yield buf.getvalue()
safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura')
filename = f"{safe_num}_carta_porte_consolidada.csv"
return StreamingResponse(
generate(),
media_type='text/csv; charset=utf-8',
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@staticmethod
def export_aviso_cruce(db: Session, invoice) -> "StreamingResponse":
"""Interfaz Aviso Cruce (OF-7). 4 columnas: descripción, UMC, cantidad, valor USD."""
import io
import csv
import re
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import joinedload
from api.v1.modules.a76.items.models import LineItem
def _strip(text):
if not text:
return ''
return re.sub(r'[\r\n]+', ' ', str(text)).strip()
items = (
db.query(LineItem)
.options(
joinedload(LineItem.description),
joinedload(LineItem.quantity),
joinedload(LineItem.financial),
joinedload(LineItem.unit_of_measure_info),
)
.filter(
LineItem.invoice_id == invoice.id,
LineItem.tenant_id == invoice.tenant_id,
LineItem.company_id == invoice.company_id,
)
.order_by(LineItem.line_number)
.all()
)
HEADERS = ['DESCRIPCIÓN', 'UMC', 'CANTIDAD', 'VALOR DÓLARES']
def _item_to_list(item: LineItem):
d = item.description
q = item.quantity
f = item.financial
uom = item.unit_of_measure_info
return [
_strip(d.description_spanish if d else None),
uom.code if uom else '',
q.quantity if q else '',
f.value_usd if f else '',
]
def generate():
buf = io.StringIO()
writer = csv.writer(buf, delimiter=',', lineterminator='\r\n')
writer.writerow(HEADERS)
yield buf.getvalue()
for item in items:
buf.seek(0)
buf.truncate(0)
writer.writerow(_item_to_list(item))
yield buf.getvalue()
safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura')
filename = f"{safe_num}_aviso_cruce.csv"
return StreamingResponse(
generate(),
media_type='text/csv; charset=utf-8',
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@staticmethod
def copy_header_only(
db: Session,
invoice_id: int,
tenant_id: int,
company_id: int,
) -> "models.InvoiceHeader":
"""Copia el encabezado sin partidas, zeroeando totales acumulados.
Equivale a COPIA_ENC_A_SCAII del legacy Clarion."""
original = db.query(models.InvoiceHeader).filter(
models.InvoiceHeader.id == invoice_id,
models.InvoiceHeader.tenant_id == tenant_id,
models.InvoiceHeader.company_id == company_id,
).first()
if not original:
raise HTTPException(status_code=404, detail="Factura no encontrada")
base_num = original.invoice_number or str(invoice_id)
candidate = f"{base_num}-ENC"
count = 1
while db.query(models.InvoiceHeader).filter(
models.InvoiceHeader.invoice_number == candidate,
models.InvoiceHeader.tenant_id == tenant_id,
models.InvoiceHeader.company_id == company_id,
).first():
candidate = f"{base_num}-ENC-{count}"
count += 1
EXCLUDE_COLS = {
"id", "invoice_number", "status", "capture_date", "capture_user",
"who_processed", "processed_date", "process_log", "status_rec",
"status_rep", "comments_status", "vu_observations", "cfdi_uuid",
"path_pdf", "path_xml", "created_at", "updated_at",
}
header_data = {
c.name: getattr(original, c.name)
for c in models.InvoiceHeader.__table__.columns
if c.name not in EXCLUDE_COLS
}
header_data["invoice_number"] = candidate
header_data["status"] = models.InvoiceStatus.PENDING
header_data["party_count"] = 0
new_invoice = models.InvoiceHeader(**header_data)
db.add(new_invoice)
db.flush()
comp = db.query(models.InvoiceComplianceMx).filter(
models.InvoiceComplianceMx.invoice_id == invoice_id
).first()
if comp:
EXCLUDE_COMP = {
"id", "invoice_id", "pedimento_id", "pedimento_r1", "pedimento_k1",
"vucem_operation_num", "electronic_signature", "certificate_number",
"niu_number", "code_signature", "edocument", "created_at", "updated_at",
}
comp_data = {
c.name: getattr(comp, c.name)
for c in models.InvoiceComplianceMx.__table__.columns
if c.name not in EXCLUDE_COMP
}
comp_data["invoice_id"] = new_invoice.id
comp_data["tenant_id"] = tenant_id
comp_data["company_id"] = company_id
db.add(models.InvoiceComplianceMx(**comp_data))
for log in db.query(models.InvoiceLogistics).filter(
models.InvoiceLogistics.invoice_id == invoice_id
).all():
EXCLUDE_LOG = {"id", "invoice_id", "created_at", "updated_at"}
log_data = {
c.name: getattr(log, c.name)
for c in models.InvoiceLogistics.__table__.columns
if c.name not in EXCLUDE_LOG
}
log_data["invoice_id"] = new_invoice.id
log_data["tenant_id"] = tenant_id
log_data["company_id"] = company_id
db.add(models.InvoiceLogistics(**log_data))
fin = db.query(models.InvoiceFinancials).filter(
models.InvoiceFinancials.invoice_id == invoice_id
).first()
if fin:
EXCLUDE_FIN = {"id", "invoice_id", "created_at", "updated_at"}
ZERO_FIN = {
"value_mn", "value_me", "value_mc",
"customs_value_mn", "customs_value_me",
"raw_material_value_mn", "raw_material_value_me",
"aggregate_value_mn", "aggregate_value_me", "aggregate_value_mc",
"mexican_value_mn", "mexican_value_me", "mexican_value_mc",
"national_packaging_mn", "national_packaging_me", "national_packaging_mc",
"iva_mn", "iva_me", "iva_mc", "tax_value_me",
"total_quantity", "total_packages", "bundle_count",
"gross_weight", "net_weight",
}
fin_data = {}
for c in models.InvoiceFinancials.__table__.columns:
if c.name in EXCLUDE_FIN:
continue
fin_data[c.name] = 0 if c.name in ZERO_FIN else getattr(fin, c.name)
fin_data["invoice_id"] = new_invoice.id
fin_data["tenant_id"] = tenant_id
fin_data["company_id"] = company_id
db.add(models.InvoiceFinancials(**fin_data))
db.commit()
db.refresh(new_invoice)
return new_invoice
@staticmethod
def export_aaduanal_rs(db: Session, invoice) -> "StreamingResponse":
"""Interfaz AAduanal_RS (OF-10). 16 columnas, headers en inglés."""
import io
import csv
import re
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import joinedload
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.invoices.models import InvoiceComplianceMx
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
def _strip(text):
if not text:
return ''
return re.sub(r'[\r\n]+', ' ', str(text)).strip()
def _fmt_frac(frac):
if not frac or len(frac) < 7:
return frac or ''
return f"{frac[0:2]}.{frac[2:4]}.{frac[4:6]}.{frac[6:]}"
compliance = (
db.query(InvoiceComplianceMx)
.filter(InvoiceComplianceMx.invoice_id == invoice.id)
.first()
)
pedimento = None
if compliance and compliance.pedimento_id:
pedimento = db.query(Pedimentos).filter(Pedimentos.id == compliance.pedimento_id).first()
ped_num = ''
if pedimento:
ped_num = (
(pedimento.customs_office or '')
+ (pedimento.license or '')
+ (pedimento.pedimento_number or '')
)
inv_date = invoice.invoice_date.strftime('%Y%m%d') if invoice.invoice_date else ''
edocument = (compliance.edocument or '') if compliance else ''
vu_obs = re.sub(r'[\t\r\n]+', '', invoice.vu_observations or '') if invoice.vu_observations else ''
items = (
db.query(LineItem)
.options(
joinedload(LineItem.quantity),
joinedload(LineItem.customs),
joinedload(LineItem.description),
joinedload(LineItem.financial),
joinedload(LineItem.unit_of_measure_info),
joinedload(LineItem.part_info),
)
.filter(
LineItem.invoice_id == invoice.id,
LineItem.tenant_id == invoice.tenant_id,
LineItem.company_id == invoice.company_id,
)
.order_by(LineItem.line_number)
.all()
)
HEADERS = [
'indPedNum', 'ihdInvNum', 'ihdInvDate', 'ihdPartNum', 'ihdQty',
'ihdPartSKU', 'ihdusunitVal', 'ihdustotalval', 'ihdmexwt', 'ihdPartCtryOrig',
'ihdPgmCode', 'ihdTariffNum', 'partSpanDesc', 'cpartRegla8', 'COVE', 'e-document',
]
def _row(item: LineItem):
q = item.quantity
c = item.customs
d = item.description
f = item.financial
uom = item.unit_of_measure_info
part = item.part_info
return [
ped_num,
invoice.invoice_number or '',
inv_date,
part.part_number if part else '',
q.quantity if q else '',
(uom.code or '')[:2] if uom else '',
f.unit_cost_usd if f else '',
f.value_usd if f else '',
q.gross_weight if q else '',
c.origin_country if c else '',
'REGLA-8' if (c and c.octave_fraction) else '',
_fmt_frac(c.fraction if c else None),
_strip(d.description_spanish if d else None),
_fmt_frac(c.octave_fraction if c else None),
edocument,
vu_obs,
]
def generate():
buf = io.StringIO()
writer = csv.writer(buf, delimiter=',', lineterminator='\r\n')
writer.writerow(HEADERS)
yield buf.getvalue()
for item in items:
buf.seek(0)
buf.truncate(0)
writer.writerow(_row(item))
yield buf.getvalue()
safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura')
filename = f"{safe_num}_aaduanal_rs.csv"
return StreamingResponse(
generate(),
media_type='text/csv; charset=utf-8',
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@staticmethod
def export_caaarem(db: Session, invoice) -> "StreamingResponse":
"""Interfaz CAAAREM (OF-9). 47 columnas desnormalizadas: fila 1 = encabezado + partida 1, filas 2+ = 27 vacíos + partida."""
import io
import csv
import re
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import joinedload
from api.v1.modules.a76.invoices.models import InvoiceComplianceMx
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.clients_and_providers.models import ClientProvider
from api.v1.modules.a76.general_catalogs.company.models import Company
def _strip(text):
if not text:
return ''
return re.sub(r'[\r\n]+', ' ', str(text)).strip()
def _vinculacion(v):
# Clarion: CASE 0→'0', 1/2→'1'
if not v or v == '0':
return '0'
return '1'
def _currency_to_country(currency):
# Clarion: USD→'USA', MXP→'MEX', else ''
if currency == 'USD':
return 'USA'
if currency in ('MXP', 'MXN'):
return 'MEX'
return ''
compliance = (
db.query(InvoiceComplianceMx)
.filter(InvoiceComplianceMx.invoice_id == invoice.id)
.first()
)
financials = invoice.financials
logistics = invoice.logistics
company = db.query(Company).filter(
Company.tenant_id == invoice.tenant_id,
Company.id == invoice.company_id,
).first()
provider = None
if compliance and compliance.provider_id:
provider = (
db.query(ClientProvider)
.options(joinedload(ClientProvider.address))
.filter(
ClientProvider.id == compliance.provider_id,
ClientProvider.tenant_id == invoice.tenant_id,
)
.first()
)
items = (
db.query(LineItem)
.options(
joinedload(LineItem.quantity),
joinedload(LineItem.customs),
joinedload(LineItem.description),
joinedload(LineItem.financial),
joinedload(LineItem.unit_of_measure_info),
joinedload(LineItem.part_info),
joinedload(LineItem.series),
)
.filter(
LineItem.invoice_id == invoice.id,
LineItem.tenant_id == invoice.tenant_id,
LineItem.company_id == invoice.company_id,
)
.order_by(LineItem.line_number)
.all()
)
HEADERS = [
'TIPO OPERACION', 'CLIENTE', 'PROVEEDOR', 'E-DOCUMENT', 'SUBDIVISION',
'NUMERO DE FACTURA', 'NUMERO DE EXPORTADOR', 'FECHA FACTURA', 'VALOR TOTAL FACTURA',
'CERTIFICADO DE ORIGEN', 'MONEDA FACTURACION', 'OBSERVACION', 'VINCULACION FACTURA',
'INCOTERM', 'PAIS DE FACTURACION', 'PESO TOTAL FACTURA', 'GUIA MASTER', 'GUIA HOUSE',
'BULTOS TOTAL', 'SEGURO', 'MONEDA SEGURO', 'FLETE', 'MONEDA FLETE',
'EMBALAJE', 'MONEDA EMBALAJE', 'OTROS', 'MONEDA OTROS',
'NUMERO DE PARTE', 'FRACCION ARANCELARIA', 'DESCRIPCION PEDIMENTO', 'CANTIDAD',
'U.M. COMERCIAL', 'VALOR TOTAL', 'CANTIDAD TARIFA', 'U.M. TARIFA',
'PAIS ORIGEN / DESTINO', 'PAIS COMPRADOR / VENDEDOR', 'VINCULACION', 'VALORACION',
'PESO', 'BULTOS', 'VALOR AGREGADO', 'MONEDA VALOR AGREGADO',
'SERIE', 'MARCA', 'MODELO', 'SUBMODELO',
]
def _header_vals():
currency = financials.currency if financials else ''
niu = compliance.niu_number if compliance else ''
is_rail = bool(logistics and logistics.is_rail)
return [
'1',
compliance.sold_to_header or '' if compliance else '',
compliance.provider_header or '' if compliance else '',
compliance.edocument or '' if compliance else '',
'1' if (compliance and compliance.subdivision) else '0',
invoice.invoice_number or '',
company.manufacturer_id or '' if company else '',
invoice.invoice_date.strftime('%Y%m%d') if invoice.invoice_date else '',
financials.value_me if financials else '',
'1' if (compliance and compliance.acts_as) else '0',
currency,
_strip(invoice.vu_observations),
_vinculacion(provider.linking if provider else None),
logistics.incoterm or '' if logistics else '',
_currency_to_country(currency),
financials.gross_weight if financials else '',
niu if is_rail else '',
niu if is_rail else '',
financials.total_packages if financials else '',
financials.insurance if financials else '',
currency,
financials.freight if financials else '',
currency,
financials.packaging if financials else '',
currency,
financials.other_increments if financials else '',
currency,
]
def _item_vals(item: LineItem):
q = item.quantity
c = item.customs
d = item.description
f = item.financial
uom = item.unit_of_measure_info
part = item.part_info
first_serie = item.series[0] if item.series else None
return [
_strip(part.part_number if part else ''),
c.fraction if c else '',
_strip(d.description_spanish if d else ''),
q.quantity if q else '',
uom.customs_code if uom else '',
f.value_usd if f else '',
q.net_weight if q else '', # CANTIDAD TARIFA simplificado (caso UMClave=1)
uom.customs_code if uom else '', # U.M. TARIFA simplificado
c.origin_country if c else '',
provider.address.country if (provider and provider.address) else '',
_vinculacion(provider.linking if provider else None),
item.valuation_method or '',
q.net_weight if q else '',
q.package_quantity if q else '',
'0',
'USD',
first_serie.serial_numbers if first_serie else 'S/S',
d.brand or 'S/M' if d else 'S/M',
d.model or 'S/M' if d else 'S/M',
first_serie.sub_model if (first_serie and first_serie.sub_model) else '',
]
def generate():
buf = io.StringIO()
writer = csv.writer(buf, delimiter=',', lineterminator='\r\n')
writer.writerow(HEADERS)
yield buf.getvalue()
for idx, item in enumerate(items):
buf.seek(0)
buf.truncate(0)
if idx == 0:
writer.writerow(_header_vals() + _item_vals(item))
else:
writer.writerow([''] * 27 + _item_vals(item))
yield buf.getvalue()
safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura')
filename = f"{safe_num}_caaarem.csv"
return StreamingResponse(
generate(),
media_type='text/csv; charset=utf-8',
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@staticmethod
def export_cp_genesis(db: Session, invoice) -> "StreamingResponse":
"""Interfaz Carta Porte Genesis (OF-11). 31 columnas, una fila por partida."""
import io
import csv
import re
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import joinedload
from api.v1.modules.a76.invoices.models import InvoiceComplianceMx
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.clients_and_providers.models import ClientProvider
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.classes.models import Class
UOM_MAP = {
'PZA': 'H87', 'KGS': 'KGM', 'MT': 'MTR', 'LT': 'LTR',
'JGO': 'XKI', 'LB': 'LBR', 'GAL': 'GLL', 'FT': 'LF',
}
def _uom_to_sat(code):
return UOM_MAP.get(code or '', '')
def _rfc_or_taxid(cp):
# Clarion: TIPOEXTNAC N→RFC, E→TAXID truncado a 9 chars
if not cp:
return ''
val = cp.fiscal_id or ''
if cp.type_nat_foreign == 'E':
return val[:9]
return val
def _proceso(prov):
# Dirección de tráfico según ciudad del proveedor
if not prov or not prov.address:
return ''
city = (prov.address.city or '').upper()
if 'JUAREZ' in city:
return 'EXPORTACION'
if 'EL PASO' in city or 'ELPASO' in city:
return 'IMPORTACION'
return ''
def _ped_map(ped):
# (IDocAdu, ClaveTM, DescripMP, CDocAdu)
if not ped:
return ('', '', '', '')
MAP = {
'AF': ('ITR', '05', 'Otra', '18'),
'V1': ('ITR', '05', 'Otra', '18'),
'A1': ('IMD', '05', 'Otra', '01'),
'A3': ('IMD', '05', 'Otra', '01'),
}
return MAP.get(ped.pedimento_code or '', ('', '', '', ''))
def _fmt_ped(ped):
if not ped:
return ''
y = str(ped.year or '').zfill(2)
co = (ped.customs_office or '')[:2]
lic = (ped.license or '').zfill(4)
num = (ped.pedimento_number or '').zfill(7)
return f"{y} {co} {lic} {num}"
compliance = (
db.query(InvoiceComplianceMx)
.filter(InvoiceComplianceMx.invoice_id == invoice.id)
.first()
)
financials = invoice.financials
company = db.query(Company).filter(
Company.tenant_id == invoice.tenant_id,
Company.id == invoice.company_id,
).first()
provider = None
if compliance and compliance.provider_id:
provider = (
db.query(ClientProvider)
.options(joinedload(ClientProvider.address))
.filter(
ClientProvider.id == compliance.provider_id,
ClientProvider.tenant_id == invoice.tenant_id,
)
.first()
)
shipped_to = None
if compliance and compliance.shipped_to_id:
shipped_to = (
db.query(ClientProvider)
.options(joinedload(ClientProvider.address))
.filter(
ClientProvider.id == compliance.shipped_to_id,
ClientProvider.tenant_id == invoice.tenant_id,
)
.first()
)
pedimento = None
if compliance and compliance.pedimento_id:
pedimento = db.query(Pedimentos).filter(Pedimentos.id == compliance.pedimento_id).first()
items = (
db.query(LineItem)
.options(
joinedload(LineItem.quantity),
joinedload(LineItem.customs),
joinedload(LineItem.financial),
joinedload(LineItem.class_info).joinedload(Class.unit_of_measure_info),
)
.filter(
LineItem.invoice_id == invoice.id,
LineItem.tenant_id == invoice.tenant_id,
LineItem.company_id == invoice.company_id,
)
.order_by(LineItem.line_number)
.all()
)
HEADERS = [
'ORIGEN', 'NombreRemitente', 'RFC o NumRegIdtrib Remitente', 'ResidenciaFiscal Remitente',
'DESTINO', 'NombreDestinatario', 'RFC o NumRegIdtrib Destinatario', 'ResidenciaFiscal Destinatario',
'BienesTransp', 'Descripcion', 'Cantidad', 'ClaveUnidad', 'Unidad',
'MaterialPeligroso', 'CveMaterialPeligroso', 'Embalaje', 'DescripEmbalaje',
'PesoEnKg', 'ValorMercancia', 'Moneda', 'TranspInternac',
'FraccionArancelaria', 'UUIDComercioExt', 'RegimenAduanero', 'TipoMateria',
'DescripcionMateria', 'TipoDocumento', 'Numero de Pedimento', 'IdentDocAduanero',
'RFCImpo', 'PaisOrigenDestino',
]
ped_fmt = _fmt_ped(pedimento)
idoc, clave_tm, descrip_mp, cdoc = _ped_map(pedimento)
currency = financials.currency if financials else ''
proceso = _proceso(provider)
def _row(item: LineItem):
q = item.quantity
c = item.customs
f = item.financial
ci = item.class_info
uom_info = ci.unit_of_measure_info if ci else None
val_mer = f.value_mc if (f and currency == 'USD') else ''
return [
provider.address.city if (provider and provider.address) else '',
provider.name if provider else '',
_rfc_or_taxid(provider),
'USA',
shipped_to.address.city if (shipped_to and shipped_to.address) else '',
shipped_to.name if shipped_to else '',
_rfc_or_taxid(shipped_to),
'MEX',
c.fraction if c else '',
ci.description_es if ci else '',
q.quantity if q else '',
_uom_to_sat(ci.unit_of_measure if ci else ''),
uom_info.description if uom_info else '',
'', '', # MaterialPeligroso, CveMaterialPeligroso
'', '', # Embalaje, DescripEmbalaje
q.net_weight if q else '',
val_mer,
currency,
proceso,
ci.fraction if ci else '',
'', # UUIDComercioExt
idoc,
clave_tm,
descrip_mp,
cdoc,
ped_fmt,
ped_fmt,
company.rfc if company else '',
provider.address.country if (provider and provider.address) else '',
]
def generate():
buf = io.StringIO()
writer = csv.writer(buf, delimiter=',', lineterminator='\r\n')
writer.writerow(HEADERS)
yield buf.getvalue()
for item in items:
buf.seek(0)
buf.truncate(0)
writer.writerow(_row(item))
yield buf.getvalue()
safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura')
filename = f"{safe_num}_cp_genesis.csv"
return StreamingResponse(
generate(),
media_type='text/csv; charset=utf-8',
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)