diff --git a/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/schemas.py b/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/schemas.py new file mode 100644 index 00000000..003af79d --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/schemas.py @@ -0,0 +1,97 @@ +from decimal import Decimal +from typing import List, Optional, Union +from pydantic import BaseModel, field_validator + + +class ClienteSchema(BaseModel): + header: str + nombre: str + # Ponemos valor por defecto "" y permitimos que sea opcional + direccion: Optional[str] = "" + num_exterior: Optional[str] = "" + num_interior: Optional[str] = "" + colonia: Optional[str] = "" + codigo_postal: Optional[str] = "" + ciudad: Optional[str] = "" + estado: Optional[str] = "" + pais: Optional[str] = "" + tax_id: str + programa: Optional[str] = "" + autorizacion: Optional[str] = "" + prosec: Optional[str] = "" + reg_emp: Optional[str] = "" + cert: Optional[str] = "" + + # Si llega un None, lo convertimos en "" automáticamente + @field_validator('direccion', 'nombre', mode='before') + @classmethod + def prevent_none(cls, v): + return v or "" + +class FacturaSchema(BaseModel): + numero: str + fecha: str + tipo_cambio: float + moneda: str + # Campos de aduanas (Opcionales por si A76 aún no los tiene) + pedimento: str = "" + clave_pedimento: str = "" + remesa: str = "" + acuse_electronico: str = "" + representante_legal: str = "" + nombre_empresa: str = "" + agente_aduanal: str = "" + patente: str = "" + precinto: str = "" + regimen: str = "" + transportista: str = "" + scac: str = "" + caat: str = "" + incoterm: str = "" + transporte: str = "" + num_transporte: str = "" + placas: str = "" + placas_remolque: str = "" + licencia_conductor: str = "" + caat: str = "" + scac: str = "" + aduana: str = "" + destino: str = "" + observaciones: str = "" + +class PartidaSchema(BaseModel): + numero_parte: str + descripcion: str + fraccion: str + origen: str + + advalorem:Optional[str] = "" + preferencia:Optional[str] = "" + + cantidad_importacion: Union[float, str] + unidad_medida: str + cantidad_bultos: int + clave_bultos: str + peso_neto: Union[float, str] + peso_bruto: Union[float, str] + valor_costo_unitario: Union[float, str] + valor_total: Union[float, str] + valor_estimado: Union[float, str] = "0.00" + +class TotalesSchema(BaseModel): + cantidad_total: Union[float, str] + bultos_total: int + clave_bultos: str = "" + peso_neto_total: Union[float, str] + peso_bruto_total: Union[float, str] + valor_total_total: Union[float, str] + valor_total_dolares: Union[float, str] + valor_estimado_total: Union[float, str] = "0.00" + +class FacturaImportacionCompleta(BaseModel): + cliente_proveedor: ClienteSchema + cliente_vendido: ClienteSchema + cliente_enviado: ClienteSchema + factura: FacturaSchema + partidas: List[PartidaSchema] + totales: TotalesSchema diff --git a/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py new file mode 100644 index 00000000..6bfab155 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py @@ -0,0 +1,565 @@ +import shutil +import base64 +import pdfkit +from pathlib import Path +from decimal import Decimal +from typing import Tuple, List, Callable, Optional + +from jinja2 import Environment, FileSystemLoader, select_autoescape +from fastapi import HTTPException +from sqlalchemy.orm import Session + +# --- MODELOS --- +from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics, InvoiceComplianceMx +from api.v1.modules.a76.items.line_financials.models import LineFinancial +from api.v1.modules.a76.items.line_quantities.models import LineQuantity +from api.v1.modules.a76.items.line_items.models import LineItem +from api.v1.modules.a76.clients_and_providers.models import ( + ClientProvider, ClientProviderAddress, ClientProviderPrograms +) +from api.v1.modules.a76.parts.models import Part +from api.v1.modules.a76.pedmientos.models import Pedimentos +from api.v1.modules.a76.general_catalogs.company.models import Company +from api.v1.modules.a76.customs_brokers.models import CustomsBroker +from api.v1.modules.a76.items.models import Item + +# --- TRANSPORTATION MODELS --- +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 +from api.v1.modules.a76.transportation.drivers.models import Driver + +# --- MODELO DE FRACCIONES --- +from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction + +# --- SCHEMAS --- +from .schemas import ( + ClienteSchema, PartidaSchema, TotalesSchema, + FacturaSchema, FacturaImportacionCompleta +) + +class ConsolidadoImportacionMexService: + def __init__(self): + self.template_dir = Path(__file__).parent.parent / "templates" + self.jinja_env = Environment( + loader=FileSystemLoader(self.template_dir), + autoescape=select_autoescape(['html', 'xml']) + ) + self.template = self.jinja_env.get_template('cons_mex_ver.html') + + def _get_wkhtmltopdf_config(self): + path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf" + if not Path(path).exists(): + raise RuntimeError("wkhtmltopdf no encontrado.") + return pdfkit.configuration(wkhtmltopdf=path) + + def formatear_numero(self, valor, decimales: int = 2): + if valor is None: return 0.0 + try: + return round(float(valor), decimales) + except: return 0.0 + + def _format_fraccion_fallback(self, fraccion_raw: str) -> str: + if not fraccion_raw or len(fraccion_raw) < 8: + return fraccion_raw + return f"{fraccion_raw[:4]}.{fraccion_raw[4:6]}.{fraccion_raw[6:]}" + + def _obtener_datos_cliente(self, db: Session, client_id: int, rol: str) -> ClienteSchema: + main = db.query(ClientProvider).filter(ClientProvider.id == client_id).first() + if not main: + return ClienteSchema(header=rol, nombre="Desconocido", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="MEX") + + addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == client_id).first() + prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == client_id).first() + + return ClienteSchema( + header=rol, + nombre=(main.name or main.short_name) or "S/N", + direccion=(addr.streets or "") if addr else "", + num_exterior=(addr.exterior_number or "") if addr else "", + num_interior=(addr.interior_number or "") if addr else "", + colonia=(addr.neighborhood or "") if addr else "", + codigo_postal=(addr.postal_code or "") if addr else "", + ciudad=(addr.city or "") if addr else "", + estado=(addr.state or "") if addr else "", + pais=(addr.country or "MEX") if addr else "MEX", + tax_id=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""), + programa="IMMEX" if (prog and prog.program) else "", + autorizacion=prog.program_number if prog else "", + prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "", + reg_emp=prog.val_certified_company_registry if (prog and hasattr(prog, 'val_certified_company_registry')) else ( + prog.certified_company_registry if (prog and prog.certified_company_registry) else "" + ), + cert=prog.is_certified_company if (prog and prog.is_certified_company) else "" + ) + + def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> FacturaImportacionCompleta: + try: + if progress_callback: progress_callback(10, "Buscando factura...") + header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first() + if not header: raise HTTPException(status_code=404, detail="Factura no encontrada") + + compliance = header.compliance_mx + logistics = header.logistics if header.logistics else None + financials = header.financials if header.financials else None + if progress_callback: progress_callback(20, "Obteniendo datos de pedimento...") + pedimento_id = compliance.pedimento_id if (compliance and compliance.pedimento_id) else header.related_doc_id + pedimento = db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first() if pedimento_id else None + + if progress_callback: progress_callback(30, "Obteniendo cliente y proveedor...") + proveedor_id = compliance.provider_id if compliance else None + cliente_proveedor = self._obtener_datos_cliente(db, proveedor_id, "Proveedor / supplier:") if proveedor_id else ClienteSchema(header="Proveedor", nombre="No Asignado", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="") + + nombre_agente = "" + if compliance and compliance.customs_broker_id: + broker = db.query(CustomsBroker).filter(CustomsBroker.id == compliance.customs_broker_id).first() + if broker: nombre_agente = broker.name + + company = db.query(Company).filter(Company.id == header.company_id).first() + # Datos Default (Company/Importer) - Used for fallback or Right Side (Enviado A) + # Default Header (Company) + cliente_default = ClienteSchema( + header="Importer / Consignee:", + nombre=getattr(company, 'name', "Empresa Local"), + direccion="DOMICILIO FISCAL", + num_exterior="", colonia="", codigo_postal="", ciudad="", estado="", pais="MEX", + tax_id=getattr(company, 'rfc', ""), + programa=getattr(company, 'program', "IMMEX"), autorizacion=getattr(company, 'program_number', "") + ) + + # Left Side Logic (Consignatario / Sold To) + cliente_vendido = cliente_default + if compliance and compliance.sold_to_id: + # Map known headers or default to Sold To / Vendido a + raw = (compliance.sold_to_header or "").upper() + if "CONSIGN" in raw: + clean_header = "Consignee / Consignatario:" + else: + clean_header = "Sold To / Vendido a:" + + cliente_vendido = self._obtener_datos_cliente(db, compliance.sold_to_id, clean_header) + + # Right Side Logic (Enviado A / Shipped To) + cliente_enviado = cliente_default + if compliance and compliance.shipped_to_id: + # Map to Shipped To / Enviado a + clean_header_shipped = "Shipped To / Enviado a:" + + # Fetch client data + cliente_enviado = self._obtener_datos_cliente(db, compliance.shipped_to_id, clean_header_shipped) + + remesa_valor = str(compliance.remesa) if (compliance and compliance.remesa) else "" + acuse_valor = str(compliance.edocument) if (compliance and compliance.edocument) else "N/A" + + patente_val = "" + if pedimento and pedimento.license: + patente_val = pedimento.license + elif 'broker' in locals() and broker and broker.license: + patente_val = broker.license + + + # --- Transport Data Fetching --- + transporte_txt = str(logistics.transport_type) if (logistics and logistics.transport_type) else "" + num_transporte_val = (logistics.trailer_num or "") if logistics else "" + + # Init values + placas_val = (logistics.license_plate or "") if logistics else "" # Placas Tracto + placas_remolque_val = "" + transportista_val = (logistics.carrier_id or "") if logistics else "" + caat_val = "" + scac_val = "" + licencia_cond_val = "" + + if logistics: + # 1. Transporter (CAAT / SCAC) + if logistics.carrier_id: + transporter_obj = db.query(Transporter).filter(Transporter.transporter_key == logistics.carrier_id).first() + if transporter_obj: + caat_val = transporter_obj.caat_code or "" + scac_val = transporter_obj.transport_code or "" # Mapping transport_code to SCAC + transportista_val = transporter_obj.name or logistics.carrier_id + + # 2. Vehicle (Placas Tracto) - Try transport_id first + if logistics.transport_id: + veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.transport_id).first() + if veh_obj: + placas_val = veh_obj.plate_number or placas_val + elif logistics.vehicle_num: # Fallback to vehicle_num if populated and transport_id failed/empty + veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.vehicle_num).first() + if veh_obj: + placas_val = veh_obj.plate_number or placas_val + + # 3. Trailer (Placas Remolque) + if logistics.trailer_num: + trl_obj = db.query(Trailer).filter(Trailer.trailer_number == logistics.trailer_num).first() + if trl_obj: + placas_remolque_val = trl_obj.plate_number or "" + + # 4. Driver (License) + if logistics.carrier_id and logistics.driver_name: + # Attempt to find driver by name + carrier + drv_obj = db.query(Driver).filter( + Driver.transporter_key == logistics.carrier_id, + Driver.driver_name == logistics.driver_name + ).first() + if drv_obj: + licencia_cond_val = drv_obj.license_number or "" + + factura_schema = FacturaSchema( + numero=header.invoice_number or "S/N", + fecha=str(header.invoice_date) if header.invoice_date else "", + tipo_cambio=float(financials.exchange_rate) if (financials and financials.exchange_rate) else (float(pedimento.exchange_rate) if pedimento and pedimento.exchange_rate else 1.0), + moneda=getattr(header, 'currency', "USD") or "USD", + incoterm=(logistics.incoterm or "") if logistics else "", + observaciones=header.observation_es or header.observation_en or "", + pedimento=f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}" if pedimento else "", + clave_pedimento=pedimento.pedimento_code if pedimento else "", + regimen=header.document_type or "", + patente=patente_val, + agente_aduanal=nombre_agente, + transporte=transporte_txt, + num_transporte=num_transporte_val, + placas=placas_val, + placas_remolque=placas_remolque_val, + transportista=transportista_val, + caat=caat_val, + scac=scac_val, + licencia_conductor=licencia_cond_val, + aduana=compliance.aduana if (compliance and compliance.aduana) else (pedimento.customs_office[:2] if (pedimento and pedimento.customs_office) else ""), + precinto=(logistics.seal_number or "") if logistics else "", + destino=(logistics.destination_goods or "") if logistics else "", + remesa=remesa_valor, acuse_electronico=acuse_valor, + representante_legal=getattr(company, 'responsible', "") or "", + nombre_empresa=getattr(company, 'name', "") or "" + ) + + if progress_callback: progress_callback(50, "Procesando partidas...") + + # --- Fetch Lines from SINGLE Invoice (Requested Scope Change) --- + # User requested to ONLY report items from the specific selected invoice, + # NOT consolidating all invoices from the same Pedimento. + target_invoice_ids = [header.id] + + lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter( + Item.invoice_id.in_(target_invoice_ids) + ).all() + + partidas_list = [] + + # --- AGGREGATION LOGIC (Refactoring based on Clarion) --- + from collections import defaultdict + # Key: (us_fraction_code, origin_country) + # Value: Object with accumulated fields + aggregated_data = defaultdict(lambda: { + "qty": 0.0, + "net_weight_kgs": 0.0, + "gross_weight_kgs": 0.0, + "total_value": 0.0, + "est_total_value": 0.0, + "description": "", + "advalorem_txt": "0%", + "unit_measure": "PZA", # Placeholder, takes first one found + "hts_code_print": "", + "part_number_display": "CONSOLIDADO" + }) + + # Pre-fetch US Tariff Fractions for efficiency if possible, or query inside loop (caching recommended) + # For simplicity in this step, we query inside or rely on Part data. + # Ideally fetch USTariffFraction from DB based on Part.us_fraction + + from api.v1.modules.a76.general_catalogs.us_tariff_fractions.models import USTariffFraction + + for line in lines: + qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first() + fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == line.id).first() + part_master = db.query(Part).filter(Part.id == line.part_number).first() + + # --- Resolver Identificadores --- + us_fraction_raw = "" + origin_final = "MEX" + + if part_master: + origin_final = part_master.fa_data.origin_country if (part_master.fa_data and part_master.fa_data.origin_country) else "MEX" + us_fraction_raw = part_master.us_fraction if part_master.us_fraction else "" + + # Cleaning Fraction + us_frac_clean = us_fraction_raw.strip() + + # Key for aggregation + agg_key = (us_frac_clean, origin_final) + + # --- Weights & Qty --- + # --- Optimización: Cargar Facturas en Memoria (Evitar N+1 y arreglar AttributeError) --- + # Pre-fetch invoices explicitly since line.item.invoice relationship might not exist + invoices_list = db.query(InvoiceHeader).filter(InvoiceHeader.id.in_(target_invoice_ids)).all() + invoice_map = {inv.id: inv for inv in invoices_list} + + for line in lines: + qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first() + fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == line.id).first() + part_master = db.query(Part).filter(Part.id == line.part_number).first() + # --- Weights & Qty --- + q_line = float(qty.quantity) if (qty and qty.quantity) else 0.0 + nw_line = float(qty.net_weight) if qty else 0.0 + gw_line = float(qty.gross_weight) if qty else 0.0 + + # --- Multi-Currency Normalization Logic --- + # Determine Line Currency context + # Use manual lookup instead of specific attribute + invoice_id = line.item.invoice_id if line.item else None + line_invoice = invoice_map.get(invoice_id) if invoice_id else None + + line_currency_is_mxn = False + line_exchange_rate = 1.0 + + if line_invoice and line_invoice.financials: + # Check explicit currency string AND code + curr_desc = str(line_invoice.financials.currency or "").upper() + curr_code = str(line_invoice.financials.currency_type or "").upper() + + # Logic: It is MXN if description says PESO/MX or code is MXN/MN + is_mx_desc = ("MX" in curr_desc or "PESO" in curr_desc) + is_mx_code = ("MXN" in curr_code or "MN" == curr_code) + + # But if code allows clarifying USD, prioritize that + is_usd_code = ("USD" in curr_code) + + if is_usd_code: + line_currency_is_mxn = False + elif is_mx_code or is_mx_desc: + line_currency_is_mxn = True + else: + line_currency_is_mxn = False # Default to Foreign/USD if unsure + + line_exchange_rate = float(line_invoice.financials.exchange_rate or 1.0) + + # Target Report Currency + report_is_mxn = (factura_schema.moneda == 'MXN') + + # DEBUG LOGGING + if line_invoice: + print(f"DEBUG: Line {line.id} - Inv {line_invoice.id} - CurrDesc: '{curr_desc}' Code: '{curr_code}' - Rate: {line_exchange_rate}") + print(f"DEBUG: Is MXN Context? {line_currency_is_mxn}. Report is MXN? {report_is_mxn}") + + # --- Get Financials for Line (Raw) --- + v_total_raw = 0.0 + v_unitario_raw = 0.0 + + if fin: + # NEW PRIORITY LOGIC (To avoid Inflation from dirty Customs Unit Cost) + # Priority 1: Use 'fin.value_usd' if it exists and > 0. + # Priority 2: Use 'fin.total_commercial_value' if it exists and > 0. + # Priority 3: Calculate using 'fin.unit_cost_commercial_usd' * 'q_line'. + # Priority 4: Only use 'fin.unit_cost_usd' * 'q_line' if commercial data is also missing. + + val_usd = float(fin.value_usd or 0.0) + total_comm = float(fin.total_commercial_value or 0.0) + unit_comm_usd = float(fin.unit_cost_commercial_usd or 0.0) + unit_usd = float(fin.unit_cost_usd or 0.0) + + # 1. Direct Total: Custom Value (Best case) + if val_usd > 0: + v_total_raw = val_usd + + # 2. Direct Total: Commercial Total + elif total_comm > 0: + # Convert if invoice currency is MXN + if line_currency_is_mxn and line_exchange_rate > 0: + v_total_raw = total_comm / line_exchange_rate + else: + v_total_raw = total_comm + + # 3. Calc from Commercial Unit Cost (Safe Fallback) + elif unit_comm_usd > 0 and q_line > 0: + v_total_raw = unit_comm_usd * q_line + + # 4. Calc from Customs Unit Cost (Unknown Risk - Last Resort) + elif unit_usd > 0 and q_line > 0: + v_total_raw = unit_usd * q_line + + else: + v_total_raw = 0.0 + + # NOTE: v_unitario_raw is left as 0.0 here. + # It will be calculated in the 'Calculation Gap Fill' block below: + # v_unitario_raw = v_total_raw / q_line + # This guarantees consistency and avoids the inflated unit cost record (198.00). + + # --- Calculation Gap Fill (Raw) --- + if q_line > 0: + if v_total_raw == 0 and v_unitario_raw > 0: + v_total_raw = v_unitario_raw * q_line + if v_unitario_raw == 0 and v_total_raw > 0: + v_unitario_raw = v_total_raw / q_line + + # --- Conversion to Report Currency (DISABLED TEMPORARILY) --- + # User confirms all are USD. Forcing direct sum to avoid logic errors in detection. + v_total_line = v_total_raw + v_unitario_line = v_unitario_raw + + # if report_is_mxn and not line_currency_is_mxn: + # # USD -> MXN + # v_total_line = v_total_raw * line_exchange_rate + # v_unitario_line = v_unitario_raw * line_exchange_rate + # elif not report_is_mxn and line_currency_is_mxn: + # # MXN -> USD + # if line_exchange_rate > 0: + # v_total_line = v_total_raw / line_exchange_rate + # v_unitario_line = v_unitario_raw / line_exchange_rate + # else: + # v_total_line = 0.0 + # v_unitario_line = 0.0 + + print(f"DEBUG: ValRaw: {v_total_raw} -> ValFinal: {v_total_line}") + + # --- Resolve Fraction Details (Description & Rate) --- + # Only if this is the first time we see this key (or overwrite, doesn't matter much as they should be same for same HTS) + # We check if we already have description set to avoid re-querying if we want optimization, + # but relying on DB query per distinct fraction is safer. + + current_agg = aggregated_data[agg_key] + + if not current_agg["description"]: + us_frac_db = db.query(USTariffFraction).filter(USTariffFraction.code == us_frac_clean).first() + if us_frac_db: + current_agg["description"] = us_frac_db.description or "Sin Descripción" + # Parse AdValorem from DB if available, else 0 ?? + # Creating logical placeholder. The provided Clarion code used `FraAme.Adv` + adv_val = us_frac_db.ad_valorem # Assuming field exists based on viewing file later? + # Wait, in us-tariff-fractions.ts I saw `ad_valorem: number | null`. + current_agg["advalorem_txt"] = f"{adv_val}%" if adv_val is not None else "0%" + else: + current_agg["description"] = part_master.description_spanish if part_master else "S/D" + + current_agg["hts_code_print"] = us_frac_clean + current_agg["unit_measure"] = qty.weight_unit if qty else "KGS" # Default to first found + + # --- Calculate Estimated Tax for this Line --- + rate = 0.0 + try: + clean_adv = current_agg["advalorem_txt"].replace("%", "").strip() + rate = float(clean_adv) / 100.0 + except: rate = 0.0 + + v_est_line = v_total_line * rate + + # --- Accumulate --- + current_agg["qty"] += q_line + current_agg["net_weight_kgs"] += nw_line + current_agg["gross_weight_kgs"] += gw_line + current_agg["total_value"] += v_total_line + current_agg["est_total_value"] += v_est_line + + + # --- Convert Aggregated Data to Schema List --- + partidas_list = [] + + for (hts, origin), data in aggregated_data.items(): + + # Calculate Unit Price based on Total Value / Total Qty + unit_price = 0.0 + if data["qty"] > 0: + unit_price = data["total_value"] / data["qty"] + + partidas_list.append(PartidaSchema( + numero_parte="VARIOS", # Or empty + descripcion=data["description"], + fraccion=data["hts_code_print"], + origen=origin, + advalorem=data["advalorem_txt"], + preferencia="General", + cantidad_importacion=self.formatear_numero(data["qty"]), + unidad_medida=data["unit_measure"], + cantidad_bultos=0, # Summing bultos might be tricky if not homogeneous, check logic later + clave_bultos="", + peso_neto=self.formatear_numero(data["net_weight_kgs"]), + peso_bruto=self.formatear_numero(data["gross_weight_kgs"]), + valor_costo_unitario=self.formatear_numero(unit_price), + valor_total=self.formatear_numero(data["total_value"]), + valor_estimado=self.formatear_numero(data["est_total_value"]) + )) + + # Sort by Fraction (HTS Code) + partidas_list.sort(key=lambda x: x.fraccion) + + totales = self.calcular_totales(partidas_list, Decimal(factura_schema.tipo_cambio)) + + return FacturaImportacionCompleta( + cliente_proveedor=cliente_proveedor, cliente_vendido=cliente_vendido, + cliente_enviado=cliente_enviado, factura=factura_schema, + partidas=partidas_list, totales=totales + ) + + except Exception as e: + print(f"Error Service A76: {e}") + raise HTTPException(status_code=500, detail=f"Error: {str(e)}") + + def calcular_totales(self, partidas: List[PartidaSchema], tipo_cambio: Decimal) -> TotalesSchema: + cant = sum(p.cantidad_importacion for p in partidas) + valor = sum(p.valor_total for p in partidas) + peso_n = sum(p.peso_neto for p in partidas) + peso_b = sum(p.peso_bruto for p in partidas) + bultos = sum(p.cantidad_bultos for p in partidas) + claves = [p.clave_bultos for p in partidas if p.clave_bultos] + clave_comun = max(set(claves), key=claves.count) if claves else "" + if bultos > 1 and clave_comun and not clave_comun.endswith("S"): clave_comun += "S" + v_est = sum(p.valor_estimado for p in partidas if isinstance(p.valor_estimado, (int, float, Decimal))) + + tc = float(tipo_cambio) if tipo_cambio else 1.0 + return TotalesSchema( + cantidad_total=self.formatear_numero(cant), bultos_total=bultos, clave_bultos=clave_comun, + peso_neto_total=self.formatear_numero(peso_n), peso_bruto_total=self.formatear_numero(peso_b), + valor_total_total=self.formatear_numero(valor), valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0), + valor_estimado_total=self.formatear_numero(v_est) + ) + + def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", progress_callback: Optional[Callable] = None) -> Tuple[bytes, str, str]: + if progress_callback: progress_callback(5, "Iniciando servicio de reporte...") + datos = self.obtener_datos(db, invoice_id, company_id, progress_callback) + + if progress_callback: progress_callback(80, "Renderizando plantilla...") + + # LOGO LOGIC + logo_b64 = None + try: + # Fetch company to get logo path + + comp_logo = db.query(Company).filter(Company.id == company_id).first() + if comp_logo and comp_logo.logo: + p = Path(comp_logo.logo) + + # Logic robusta de búsqueda (igual que en routes.py) + target_path = p + if not target_path.exists(): + # Intentar en la ruta estándar: app_data/logos/{id}/{nombre} + # Esto cubre el caso donde solo se guardó el nombre del archivo o la ruta absoluta cambió + fallback = Path(f"app_data/logos/{company_id}") / p.name + if fallback.exists(): + target_path = fallback + + if target_path.exists(): + with open(target_path, "rb") as image_file: + encoded_string = base64.b64encode(image_file.read()).decode('utf-8') + # Detect MIME type loosely + mime = "image/png" + if target_path.suffix.lower() in ['.jpg', '.jpeg']: mime = "image/jpeg" + logo_b64 = f"data:{mime};base64,{encoded_string}" + except Exception as e: + print(f"Error loading logo: {e}") + + context = { + 'cliente_proveedor': datos.cliente_proveedor.model_dump(), 'cliente_vendido': datos.cliente_vendido.model_dump(), + 'cliente_enviado': datos.cliente_enviado.model_dump(), 'factura': datos.factura.model_dump(), + 'partidas': [p.model_dump() for p in datos.partidas], 'totales': datos.totales.model_dump(), + 'logo_b64': logo_b64 + } + html_content = self.template.render(**context) + nombre = f"Consolidado_{datos.factura.numero}.{formato}" + if formato == "html": return html_content.encode('utf-8'), nombre, "text/html" + + if progress_callback: progress_callback(90, "Generando PDF final...") + options = {'page-size': 'Letter', 'margin-top': '0.5in', 'margin-right': '0.5in', 'margin-bottom': '0.5in', 'margin-left': '0.5in', 'encoding': "UTF-8", 'enable-local-file-access': None} + pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config()) + + if progress_callback: progress_callback(100, "Completado") + return pdf, nombre, "application/pdf" diff --git a/backend/api/v1/modules/a76/reports/importacion/consolidados/routes.py b/backend/api/v1/modules/a76/reports/importacion/consolidados/routes.py new file mode 100644 index 00000000..5254cfe4 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/consolidados/routes.py @@ -0,0 +1,48 @@ +from enum import Enum +from typing import Dict, Any +from fastapi import APIRouter, Depends, Query, Response, HTTPException +from sqlalchemy.orm import Session +from celery.result import AsyncResult +from core.celery_app import celery_app +from core.database import get_core_db +from core.security import get_current_user, validate_access_to_resource +from .mex.service import ConsolidadoImportacionMexService +from .task import generar_pdf_consolidado_async + +router = APIRouter() +servicio_mex = ConsolidadoImportacionMexService() + +@router.get("/tasks/{task_id}") +async def get_task_status( + task_id: str, + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_core_db) +): + task_result = AsyncResult(task_id, app=celery_app) + + response = { + "task_id": task_id, + "state": task_result.state, + "result": None, + "info": None + } + + if task_result.state == 'FAILURE': + response["result"] = str(task_result.result) + elif task_result.state == 'SUCCESS': + response["result"] = task_result.result + elif task_result.state == 'PROCESSING': + response["info"] = task_result.info + + return response + +@router.post("/{invoice_id}/download-async") +async def trigger_descarga_consolidado( + invoice_id: int, + company_id: int = Query(..., description="ID de la empresa"), + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_core_db) +): + validate_access_to_resource(db, company_id, current_user) + task = generar_pdf_consolidado_async.delay(invoice_id, company_id) + return {"task_id": task.id, "message": "Generación iniciada"} diff --git a/backend/api/v1/modules/a76/reports/importacion/consolidados/task.py b/backend/api/v1/modules/a76/reports/importacion/consolidados/task.py new file mode 100644 index 00000000..23e680c1 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/consolidados/task.py @@ -0,0 +1,52 @@ +import base64 +import logging +from core.celery_app import celery_app +from celery import current_task, states +from core.database import CoreSessionLocal + +from .mex.service import ConsolidadoImportacionMexService + +logger = logging.getLogger(__name__) + +@celery_app.task(name="generar_pdf_consolidado_async", bind=True) +def generar_pdf_consolidado_async(self, invoice_id: int, company_id: int): + + # 1. Abrimos conexión a la DB + db = CoreSessionLocal() + try: + logger.info(f"Worker procesando consolidado {invoice_id}...") + + # 2. Instanciamos el servicio de reportes + service = ConsolidadoImportacionMexService() + + # Update state to PROCESSING + self.update_state(state='PROCESSING', meta={'current': 5, 'total': 100, 'status': 'Iniciando generación...'}) + + def progress_callback(progress: int, status: str): + self.update_state(state='PROCESSING', meta={'current': progress, 'total': 100, 'status': status}) + + # 3. Generamos los bytes del PDF + pdf_bytes, nombre, media_type = service.generar_factura_completa( + db=db, + invoice_id=invoice_id, + company_id=company_id, + progress_callback=progress_callback + ) + + # 4. Codificamos a base64 para que viaje seguro por Valkey + pdf_base64 = base64.b64encode(pdf_bytes).decode('utf-8') + + return { + "status": "success", + "file_name": nombre, + "content": pdf_base64, + "media_type": media_type + } + + except Exception as e: + logger.error(f"Error en Celery Worker: {str(e)}") + return {"status": "error", "message": str(e)} + + finally: + # 5. MUY IMPORTANTE: Cerramos la conexión para no saturar Postgres + db.close() diff --git a/backend/api/v1/modules/a76/reports/importacion/consolidados/templates/cons_mex_ver.html b/backend/api/v1/modules/a76/reports/importacion/consolidados/templates/cons_mex_ver.html new file mode 100644 index 00000000..17f4051a --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/consolidados/templates/cons_mex_ver.html @@ -0,0 +1,535 @@ + + + +
+ +HTS Code / Fracción Americana (temporal)
+ + ++
{{ cliente_proveedor.nombre }}
+{{ cliente_proveedor.direccion }} + {% if cliente_proveedor.num_exterior %} Ext: {{ cliente_proveedor.num_exterior }}{% endif %} + {% if cliente_proveedor.num_interior %} Int: {{ cliente_proveedor.num_interior }}{% endif %} +
+{{ cliente_proveedor.colonia }} {% if cliente_proveedor.codigo_postal %} CP: {{ + cliente_proveedor.codigo_postal }}{% endif %}
+{{ cliente_proveedor.ciudad }}, {{ cliente_proveedor.estado }}, {{ cliente_proveedor.pais }}
+TAX ID: {{ cliente_proveedor.tax_id }} + {% if cliente_proveedor.programa and cliente_proveedor.programa != 'Ninguno' %} + {{ cliente_proveedor.programa }}: {{ cliente_proveedor.autorizacion }} + {% endif %} +
+|
+ Invoice No. / Num. Factura: + |
+
+ {{ factura.numero }} + |
+
|
+ Date / Fecha: + |
+
+ {{ factura.fecha }} + |
+
|
+ Rate / Cambio: + |
+
+ {{ factura.tipo_cambio }} + |
+
{{ cliente_vendido.nombre }}
+{{ cliente_vendido.direccion }} + {% if cliente_vendido.num_exterior %} Ext: {{ cliente_vendido.num_exterior }}{% endif %} + {% if cliente_vendido.num_interior %} Int: {{ cliente_vendido.num_interior }}{% endif %} +
+{{ cliente_vendido.colonia }} {% if cliente_vendido.codigo_postal %} CP: {{ + cliente_vendido.codigo_postal }}{% endif %}
+{{ cliente_vendido.ciudad }}, {{ cliente_vendido.estado }}, {{ cliente_vendido.pais }} +
+RFC: {{ cliente_vendido.tax_id }} + {% if cliente_vendido.programa and cliente_vendido.programa != 'Ninguno' %} + {{ cliente_vendido.programa }}: {{ cliente_vendido.autorizacion }} + {% endif %} +
++ {% if cliente_vendido.prosec %}PROSEC: {{ cliente_vendido.prosec }} {% endif %} + {% if cliente_vendido.reg_emp %}REG EMP: {{ cliente_vendido.reg_emp }} {% endif %} + {% if cliente_vendido.cert %}CERT: {{ cliente_vendido.cert }}{% endif %} +
+{{ cliente_enviado.nombre }}
+{{ cliente_enviado.direccion }} + {% if cliente_enviado.num_exterior %} Ext: {{ cliente_enviado.num_exterior }}{% endif %} + {% if cliente_enviado.num_interior %} Int: {{ cliente_enviado.num_interior }}{% endif %} +
+{{ cliente_enviado.colonia }} {% if cliente_enviado.codigo_postal %} CP: {{ + cliente_enviado.codigo_postal }}{% endif %}
+{{ cliente_enviado.ciudad }}, {{ cliente_enviado.estado }}, {{ cliente_enviado.pais }} +
+RFC: {{ cliente_enviado.tax_id }} + {% if cliente_enviado.programa and cliente_enviado.programa != 'Ninguno' %} + {{ cliente_enviado.programa }}: {{ cliente_enviado.autorizacion }} + {% endif %} +
++ {% if cliente_enviado.prosec %}PROSEC: {{ cliente_enviado.prosec }} {% endif %} + {% if cliente_enviado.reg_emp %}REG EMP: {{ cliente_enviado.reg_emp }} {% endif %} + {% if cliente_enviado.cert %}CERT: {{ cliente_enviado.cert }}{% endif %} +
+|
+ HTS Code / Fracción Americana (temporal) + + |
+
+ Country + |
+
+ Comm. Qty + |
+
+ Unit + |
+
+ Weight (KGS) +
+
+
+
+ Net +
+
+ Gross + |
+
+ Dutiable Values +
+
+
+
+ Unit +
+
+ Totals + |
+
+ Rate + |
+
+ Est. Duties + |
+ ||
|
+ {{ partida.fraccion }} + |
+
+ {{ partida.origen or 'MEX' }} + |
+
+ {{ partida.cantidad_importacion }} + |
+
+ {{ partida.unidad_medida }} + |
+
+ {{ partida.peso_neto }} + |
+
+ {{ partida.peso_bruto }} + |
+
+ ${{ partida.valor_costo_unitario }} + |
+
+ ${{ partida.valor_total }} + |
+
+ {{ partida.advalorem }} + |
+
+ ${{ partida.valor_estimado }} + |
+
|
+ + TOTALS + + |
+
+ {{ totales.cantidad_total }} + |
+
+ + {% if totales.bultos_total != 0 %}{{ totales.bultos_total }}{% endif %} + {{ totales.clave_bultos or '' }} + + |
+
+ {{ totales.peso_neto_total }} + |
+
+ {{ totales.peso_bruto_total }} + |
+ + + | +
+ ${{ totales.valor_total_total }} + |
+ + + | +
+ ${{ totales.valor_estimado_total }} + |
+ |
|
+ {{ factura.observaciones }} + |
+
+
+ {{ factura.representante_legal }} +{{ factura.nombre_empresa }} + |
+ ||||||||
|
+ Normal Por Parte +Declaro bajo protesta de decir verdad que la información contenida en + este documento es verdadera y me hago responsable de comprobar lo aquí declarado. + |
+ |||||||||
Cargando catálogo...
+No se encontraron fracciones.
+