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 '' row = db.query(ClientProvider.rfc).filter(ClientProvider.id == client_id).first() return row.rfc if row 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 '' row = db.query(ClientProvider.rfc).filter(ClientProvider.id == client_id).first() return row.rfc if row 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 '' if cp.type_nat_foreign == 'E': return (cp.rfc or '')[:9] return cp.rfc or '' 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}"'}, )