diff --git a/backend/api/v1/modules/a76/reports/importacion/consolidados/temporary/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/consolidados/temporary/mex/service.py new file mode 100644 index 00000000..5cff09b4 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/consolidados/temporary/mex/service.py @@ -0,0 +1,630 @@ +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 = "" + conductor_nombre = "" + + # Block Logic (Clarion Style) for transportista_info + transport_lines = [] + + 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 + + # Clarion Logic: Name first + # Line 1: Name + transport_lines.append(transporter_obj.name or "") + + # Line 2: Streets + if transporter_obj.streets: + transport_lines.append(transporter_obj.streets) + + # Line 3: City, State, Country + loc_line = "" + if transporter_obj.city: + loc_line = transporter_obj.city + if transporter_obj.state: + loc_line += f", {transporter_obj.state}, " + else: + loc_line += ", " + else: + if transporter_obj.state: + loc_line = f"{transporter_obj.state}," + + country_desc = transporter_obj.country or "" + if loc_line: + loc_line += f" {country_desc}" + elif country_desc: + loc_line = country_desc + + if loc_line.strip(", "): + transport_lines.append(loc_line) + + # 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: + conductor_nombre = 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 "" + + # --- Building the rest of the block --- + + # Line 4: Driver + if conductor_nombre: + transport_lines.append(f"Driver/Conductor: {conductor_nombre}") + + # Line 5: Conveyance / Transporte + t_label = "Conveyance / Transporte" + t_val = placas_val # Default to Truck Plate + + if logistics.transport_type: + ttype = str(logistics.transport_type).lower() + if "caja" in ttype or "trailer" in ttype: + t_label = "Trailer / Caja" + t_val = placas_remolque_val or num_transporte_val + elif "placa" in ttype: + t_label = "Plates / Placas" + elif "camion" in ttype or "truck" in ttype: + t_label = "Truck / Camión" + + if t_val: + transport_lines.append(f"{t_label}: {t_val}") + + # Line 6: SCAC / CAAT + codes_line = "" + if scac_val: + codes_line = f"SCAC Code/Clave: {scac_val}" + if caat_val: + if codes_line: + codes_line += f", CAAT Code/Clave: {caat_val}" + else: + codes_line = f"CAAT Code/Clave: {caat_val}" + + if codes_line: + transport_lines.append(codes_line) + + # Join with newlines + transport_block_str = "\n".join([l for l in transport_lines if l]) + + 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 "", + transportista_info=transport_block_str + ) + + 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 + + # --- Optimización: Cargar Facturas en Memoria --- + invoices_list = db.query(InvoiceHeader).filter(InvoiceHeader.id.in_(target_invoice_ids)).all() + invoice_map = {inv.id: inv for inv in invoices_list} + + 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 (MOVED INSIDE MAIN LOOP) --- + 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 "" + + # Key for aggregation + us_frac_clean = us_fraction_raw.strip() + agg_key = (us_frac_clean, origin_final) + # --- 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/facturas/mex/schemas.py b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/schemas.py index 9d4455b8..e9a52c8a 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/schemas.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/schemas.py @@ -30,6 +30,7 @@ class ClienteSchema(BaseModel): class FacturaSchema(BaseModel): numero: str + titulo_documento: str = "Factura de Importacion" # Titulo dinámico basado en document_type fecha: str tipo_cambio: float moneda: str diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py index b6746120..619ae2f5 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py @@ -54,7 +54,47 @@ class FacturaImportacionMexService: loader=FileSystemLoader(self.template_dir), autoescape=select_autoescape(["html", "xml"]), ) - self.template = self.jinja_env.get_template("factura_mex_ver.html") + self.template = self.jinja_env.get_template('factura_mex_ver.html') + + def _get_document_title(self, invoice_type: str, is_american: bool = False) -> str: + """ + Determina el título del documento basado en el tipo de factura. + + Args: + invoice_type: Tipo de factura (TEM, DEF, MEX, CR) + is_american: Si es factura americana (True) o mexicana (False) + + Returns: + Título formateado para la factura + """ + # Mapeo para facturas mexicanas + mexican_titles = { + "MEX": "Factura Importación Compras Mexicanas", + "DEF": "Importación Definitiva", + "TEM": "Importación Temporal", + "CR": "Importación de Cambio de Régimen", + } + + # Mapeo para facturas americanas + american_titles = { + "MEX": "Mexican Purchases Import Invoice", + "DEF": "Definitive Importation", + "TEM": "Temporary Importation", + "CR": "Regime Change Importation", + } + + # Seleccionar el mapa correcto + titles = american_titles if is_american else mexican_titles + + # Obtener el título (normalizar a mayúsculas) + invoice_type_upper = invoice_type.upper() if invoice_type else "" + title = titles.get(invoice_type_upper, "") + + # Fallback a genéricos si no se encuentra + if not title: + return "Commercial Invoice" if is_american else "Factura de Importación" + + return title def _get_wkhtmltopdf_config(self): path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf" @@ -141,13 +181,7 @@ class FacturaImportacionMexService: ), ) - def obtener_datos( - self, - db: Session, - invoice_id: int, - company_id: int, - progress_callback: Optional[Callable] = None, - ) -> FacturaImportacionCompleta: + def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None, currency_code: str = 'ORIGINAL') -> FacturaImportacionCompleta: try: if progress_callback: progress_callback(10, "Buscando factura...") @@ -338,19 +372,19 @@ class FacturaImportacionMexService: if drv_obj: licencia_cond_val = drv_obj.license_number or "" + # Determine Currency + moneda_final = getattr(header, 'currency', "USD") or "USD" + if currency_code == 'MXN': + moneda_final = 'MXN' + elif currency_code == 'USD': + moneda_final = 'USD' + factura_schema = FacturaSchema( numero=header.invoice_number or "S/N", + titulo_documento=self._get_document_title(header.invoice_type or "", is_american=False), 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", + tipo_cambio=float(financials.exchange_rate) if (financials and financials.exchange_rate) else (float(pedimento.exchange_rate) if pedimento and pedimento.exchange_rate else 1.0), + moneda=moneda_final, incoterm=(logistics.incoterm or "") if logistics else "", observaciones=header.observation_es or header.observation_en or "", pedimento=( @@ -568,21 +602,12 @@ class FacturaImportacionMexService: valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0), ) - def generar_factura_completa( - self, - db: Session, - invoice_id: int, - company_id: int, - formato: str = "pdf", - progress_callback: Optional[Callable] = None, - ) -> 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...") - + def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", progress_callback: Optional[Callable] = None, currency_code: str = 'ORIGINAL') -> Tuple[bytes, str, str]: + if progress_callback: progress_callback(5, "Iniciando servicio de reporte...") + datos = self.obtener_datos(db, invoice_id, company_id, progress_callback, currency_code) + + if progress_callback: progress_callback(80, "Renderizando plantilla...") + # LOGO LOGIC logo_b64 = None try: diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py b/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py index f7c0d72d..324bb91a 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py @@ -40,9 +40,10 @@ async def get_task_status( async def trigger_descarga_factura( invoice_id: int, company_id: int = Query(..., description="ID de la empresa"), + invoice_type: str = Query('mexican', description="Tipo de factura: 'mexican' o 'american'"), 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_factura_async.delay(invoice_id, company_id) + task = generar_pdf_factura_async.delay(invoice_id, company_id, invoice_type) return {"task_id": task.id, "message": "Generación iniciada"} \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/task.py b/backend/api/v1/modules/a76/reports/importacion/facturas/task.py index 6cdf1342..fabd082f 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/task.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/task.py @@ -5,35 +5,36 @@ from celery import current_task, states from core.database import CoreSessionLocal from .mex.service import FacturaImportacionMexService +from .usa.service import FacturaImportacionUsaService logger = logging.getLogger(__name__) @celery_app.task(name="generar_pdf_factura_async", bind=True) -def generar_pdf_factura_async(self, invoice_id: int, company_id: int): +def generar_pdf_factura_async(self, invoice_id: int, company_id: int, invoice_type: str = 'mexican', currency_code: str = 'ORIGINAL'): - # 1. Abrimos conexión a la DB db = CoreSessionLocal() try: - logger.info(f"Worker procesando factura {invoice_id}...") + logger.info(f"Worker procesando factura {invoice_id} ({invoice_type}, {currency_code})...") - # 2. Instanciamos el servicio de reportes - service = FacturaImportacionMexService() + if invoice_type == 'american': + service = FacturaImportacionUsaService() + else: + service = FacturaImportacionMexService() - # 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 + progress_callback=progress_callback, + currency_code=currency_code ) - # 4. Codificamos a base64 para que viaje seguro por Valkey + pdf_base64 = base64.b64encode(pdf_bytes).decode('utf-8') return { @@ -48,5 +49,5 @@ def generar_pdf_factura_async(self, invoice_id: int, company_id: int): return {"status": "error", "message": str(e)} finally: - # 5. MUY IMPORTANTE: Cerramos la conexión para no saturar Postgres + db.close() \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_mex_ver.html b/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_mex_ver.html index ade499e4..215d8fa8 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_mex_ver.html +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_mex_ver.html @@ -273,7 +273,7 @@
-

Factura de Importacion

+

{{ factura.titulo_documento }}

diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_usa_ver.html b/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_usa_ver.html new file mode 100644 index 00000000..b23783cc --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_usa_ver.html @@ -0,0 +1,594 @@ + + + + + + Commercial Invoice - {{ factura.numero }} + + + + +
+
+
+

{{ factura.titulo_documento }}

+
+

+
+
+

+


+
+
+
+
+ {% if logo_b64 %} +
+ +
+ {% endif %} +
+

{{ cliente_proveedor.header }}

+

{{ 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 %} Zip Code: {{ + 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:

+
+

{{ factura.numero }}

+
+

Date:

+
+

{{ factura.fecha }}

+
+

Ex. Rate:

+
+

{{ factura.tipo_cambio }}

+
+

INCOTERM:

+
+

{{ factura.incoterm or '' }}

+
+

Customs:

+
+

{{ factura.aduana }}

+
+
+
+ +
+
+

{{ cliente_vendido.header }}

+

{{ 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 %} Zip Code: {{ + cliente_vendido.codigo_postal }}{% endif %}

+

{{ cliente_vendido.ciudad }}, {{ cliente_vendido.estado }}, {{ cliente_vendido.pais }} +

+

Tax ID: {{ 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.header }}

+

{{ 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 %} Zip Code: {{ + cliente_enviado.codigo_postal }}{% endif %}

+

{{ cliente_enviado.ciudad }}, {{ cliente_enviado.estado }}, {{ cliente_enviado.pais }} +

+

Tax ID: {{ 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 %} +

+
+
+


+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% for partida in partidas %} + + + + + + + + + + + + {% endfor %} + + + + + + + + + + + + + + + + + + + + + +
+

Carrier:

+
+

{{ factura.transportista }}

+
+

SCAC: {{ factura.scac }}

+
+

INCOTERM:

+
+

{{ factura.incoterm }}

+
+

Customs: {{ factura.aduana }} / Ped: {{ factura.pedimento }}

+
+


+
+

Transport:

+
+

{{ factura.transporte }}: {{ factura.num_transporte }}

+
+

CAAT: {{ factura.caat }}

+
+

Plates: {{ factura.placas or '' }} / Trl: {{ factura.placas_remolque or + '' }}

+
+

Driver/Lic:

+
+

{{ factura.licencia_conductor or 'N/A' }}

+
+

Line

+
+

Part Number

+

Description

+
+

Commercial

+
+

Packaging

+
+

Weight (KGS)

+
+

Values

+
+

Quantity

+
+

U.M.

+
+

Type

+
+

Net

+
+

Gross

+
+

Unit

+
+

Total

+
+

{{ loop.index }}

+
+

{{ partida.numero_parte }}

+

{{ partida.descripcion }}

+

HTS Code: {{ partida.fraccion }} / Orig: {{ partida.origen or 'MEX' }}

+ +
+

{{ partida.cantidad_importacion }}

+
+

{{ partida.unidad_medida }}

+
+

+ {% if partida.cantidad_bultos != 0 %}{{ partida.cantidad_bultos }}{% endif %} + {{ partida.clave_bultos }} +

+
+

{{ partida.peso_neto }}

+
+

{{ partida.peso_bruto }}

+
+

${{ partida.valor_costo_unitario }}

+
+

${{ partida.valor_total }}

+
+

+ Remarks: + 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 }}

+
+

{{ factura.observaciones }}

+
+

+

{{ cliente_proveedor.nombre }}

+


+

Values expressed in: {{ factura.moneda + }}

+
+


+

+

I declare under penalty of perjury that the information contained in + this document is true and correct.

+
+ + + \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/temporary/__init__.py b/backend/api/v1/modules/a76/reports/importacion/facturas/temporary/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/temporary/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/temporary/mex/service.py new file mode 100644 index 00000000..200a3942 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/temporary/mex/service.py @@ -0,0 +1,403 @@ +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 +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 FacturaImportacionMexService: + 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('factura_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) + cliente_default = ClienteSchema( + header="Importador / consignatario:", + 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: + raw_header = compliance.sold_to_header or "CONSIGNATARIO" + clean_header = raw_header.replace("_", " ").capitalize() + ":" + 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: + # Clean header: "enviado_a" -> "Enviado a:" + raw_header_shipped = compliance.shipped_to_header or "DESTINATARIO" + clean_header_shipped = raw_header_shipped.replace("_", " ").capitalize() + ":" + + # 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 + ) + + if progress_callback: progress_callback(50, "Procesando partidas...") + lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter(Item.invoice_id == header.id).all() + partidas_list = [] + + for line in lines: + qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first() + fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == line.id).first() + part_master = db.query(Part).filter(Part.id == line.part_number).first() + + desc_final = "S/D" + num_parte_final = str(line.part_number or "S/N") + fraccion_raw = "" + origen_final = "MEX" + + if part_master: + desc_final = part_master.description_spanish or part_master.description_english or "Sin Desc." + num_parte_final = part_master.part_number + fraccion_raw = part_master.fraction if part_master.fraction else "" + + # Fetch Origin from Master Catalog (FaPart) + if part_master.fa_data and part_master.fa_data.origin_country: + origen_final = part_master.fa_data.origin_country + + + fraccion_limpia = fraccion_raw.replace(".", "").strip() + if fraccion_limpia: + fraccion_limpia = fraccion_limpia[:8].zfill(8) + + # Consultar tabla tariff_fractions + fraccion_db = db.query(TariffFraction).filter(TariffFraction.code == fraccion_limpia).first() + + + preferencia_txt = "General" + advalorem_txt = "0%" + fraccion_imprimir = fraccion_raw + + if fraccion_db: + # Si el valor en BD es None, "0", o vacío, dejarlo como "0%" o "EXENTO" + adv_db = fraccion_db.adv_impo + if adv_db and adv_db.strip() not in ["0", "0.0", "0.00", ""]: + advalorem_txt = adv_db if "%" in adv_db else f"{adv_db}%" + else: + advalorem_txt = "0%" + + fraccion_imprimir = fraccion_db.fraction or fraccion_raw + else: + + fraccion_imprimir = self._format_fraccion_fallback(fraccion_limpia) + + # Logic to determine values - Prioritize Specific Currency Columns + v_unitario = 0.0 + v_total = 0.0 + + if fin: + is_mxn = (factura_schema.moneda == 'MXN') + + # 1. Try Specific Currency Columns First + if is_mxn: + v_unitario = float(fin.unit_cost_commercial_mxn or 0.0) + v_total = float(fin.value_commercial_mxn or 0.0) + else: + v_unitario = float(fin.unit_cost_commercial_usd or 0.0) + v_total = float(fin.value_commercial_usd or 0.0) + + # 2. Fallback to Generic independently if Specific is 0 + if not v_unitario: + v_unitario = float(fin.commercial_unit_cost or 0.0) + + if not v_total: + v_total = float(fin.total_commercial_value or 0.0) + + # 3. Calculate from Quantity if still missing + cantidad = float(qty.quantity) if (qty and qty.quantity) else 0.0 + + if cantidad > 0: + if v_unitario > 0 and v_total == 0: + v_total = v_unitario * cantidad + elif v_total > 0 and v_unitario == 0: + v_unitario = v_total / cantidad + + partidas_list.append(PartidaSchema( + numero_parte=num_parte_final, + descripcion=desc_final, + fraccion=fraccion_imprimir, + origen=origen_final, + advalorem=advalorem_txt, + preferencia=preferencia_txt, + cantidad_importacion=self.formatear_numero(qty.quantity if qty else 0), + unidad_medida=qty.weight_unit if qty else "PZA", + cantidad_bultos=int(qty.package_quantity) if qty and qty.package_quantity else 0, + clave_bultos=(qty.package_key or "") if qty else "", + peso_neto=self.formatear_numero(qty.net_weight if qty else 0), + peso_bruto=self.formatear_numero(qty.gross_weight if qty else 0), + valor_costo_unitario=self.formatear_numero(v_unitario), + valor_total=self.formatear_numero(v_total) + )) + + totales = self.calcular_totales(partidas_list, Decimal(factura_schema.tipo_cambio)) + + 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" + 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) + ) + + 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"Factura_{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" \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py new file mode 100644 index 00000000..d5ec1d10 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py @@ -0,0 +1,444 @@ +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 +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 --- +# Reuse schemas from neighbor package as they fit the same data structure +from ..mex.schemas import ( + ClienteSchema, PartidaSchema, TotalesSchema, + FacturaSchema, FacturaImportacionCompleta +) + +class FacturaImportacionUsaService: + def __init__(self): + self.template_dir = Path(__file__).parent.parent / "templates" + self.jinja_env = Environment( + loader=FileSystemLoader(self.template_dir), + autoescape=select_autoescape(['html', 'xml']) + ) + self.template = self.jinja_env.get_template('factura_usa_ver.html') + + def _get_document_title(self, invoice_type: str, is_american: bool = True) -> str: + """ + Determina el título del documento basado en el tipo de factura. + + Args: + invoice_type: Tipo de factura (TEM, DEF, MEX, CR) + is_american: Si es factura americana (True) o mexicana (False) + + Returns: + Título formateado para la factura + """ + # Mapeo para facturas mexicanas + mexican_titles = { + "MEX": "Factura Importación Compras Mexicanas", + "DEF": "Importación Definitiva", + "TEM": "Importación Temporal", + "CR": "Importación de Cambio de Régimen", + } + + # Mapeo para facturas americanas + american_titles = { + "MEX": "Mexican Purchases Import Invoice", + "DEF": "Definitive Importation", + "TEM": "Temporary Importation", + "CR": "Regime Change Importation", + } + + # Seleccionar el mapa correcto + titles = american_titles if is_american else mexican_titles + + # Obtener el título (normalizar a mayúsculas) + invoice_type_upper = invoice_type.upper() if invoice_type else "" + title = titles.get(invoice_type_upper, "") + + # Fallback a genéricos si no se encuentra + if not title: + return "Commercial Invoice" if is_american else "Factura de Importación" + + return title + + def _get_wkhtmltopdf_config(self): + 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="Unknown", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="USA") + + addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == client_id).first() + prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == client_id).first() + + return ClienteSchema( + header=rol, + nombre=(main.name or main.short_name) or "N/A", + direccion=(addr.streets or "") if addr else "", + 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 "USA") if addr else "USA", + tax_id=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""), + programa="IMMEX" if (prog and prog.program) else "", + autorizacion=prog.program_number if prog else "", + prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "", + reg_emp=prog.val_certified_company_registry if (prog and hasattr(prog, 'val_certified_company_registry')) else ( + prog.certified_company_registry if (prog and prog.certified_company_registry) else "" + ), + cert=prog.is_certified_company if (prog and prog.is_certified_company) else "" + ) + + def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None, currency_code: str = 'ORIGINAL') -> FacturaImportacionCompleta: + try: + if progress_callback: progress_callback(10, "Searching invoice...") + header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first() + if not header: raise HTTPException(status_code=404, detail="Invoice not found") + + compliance = header.compliance_mx + logistics = header.logistics if header.logistics else None + financials = header.financials if header.financials else None + if progress_callback: progress_callback(20, "Fetching entry data...") + pedimento_id = compliance.pedimento_id if (compliance and compliance.pedimento_id) else header.related_doc_id + pedimento = db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first() if pedimento_id else None + + if progress_callback: progress_callback(30, "Fetching client and supplier...") + proveedor_id = compliance.provider_id if compliance else None + cliente_proveedor = self._obtener_datos_cliente(db, proveedor_id, "Supplier:") if proveedor_id else ClienteSchema(header="Supplier", nombre="Unassigned", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="") + + 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) + cliente_default = ClienteSchema( + header="Importer / Consignee:", + nombre=getattr(company, 'name', "Local Company"), + direccion="FISCAL ADDRESS", + num_exterior="", colonia="", codigo_postal="", ciudad="", estado="", pais="MEX", + tax_id=getattr(company, 'rfc', ""), + programa=getattr(company, 'program', "IMMEX"), autorizacion=getattr(company, 'program_number', "") + ) + + # Left Side Logic (Sold To) + cliente_vendido = cliente_default + if compliance and compliance.sold_to_id: + # Force English header for American Invoice + clean_header = "Sold To:" + # raw_header = compliance.sold_to_header or "SOLD_TO" + # clean_header = raw_header.replace("_", " ").title() + ":" + cliente_vendido = self._obtener_datos_cliente(db, compliance.sold_to_id, clean_header) + + # Right Side Logic (Shipped To) + cliente_enviado = cliente_default + if compliance and compliance.shipped_to_id: + # Force English header for American Invoice + clean_header_shipped = "Shipped To:" + # raw_header_shipped = compliance.shipped_to_header or "SHIPPED_TO" + # clean_header_shipped = raw_header_shipped.replace("_", " ").title() + ":" + + # Fetch client data + cliente_enviado = self._obtener_datos_cliente(db, compliance.shipped_to_id, clean_header_shipped) + + remesa_valor = str(compliance.remesa) if (compliance and compliance.remesa) else "" + acuse_valor = str(compliance.edocument) if (compliance and compliance.edocument) else "N/A" + + 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 "" # Plates + 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 (Plates) + 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: + veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.vehicle_num).first() + if veh_obj: + placas_val = veh_obj.plate_number or placas_val + + # 3. Trailer + if logistics.trailer_num: + trl_obj = db.query(Trailer).filter(Trailer.trailer_number == logistics.trailer_num).first() + if trl_obj: + placas_remolque_val = trl_obj.plate_number or "" + + # 4. Driver (License) + if logistics.carrier_id and logistics.driver_name: + drv_obj = db.query(Driver).filter( + Driver.transporter_key == logistics.carrier_id, + Driver.driver_name == logistics.driver_name + ).first() + if drv_obj: + licencia_cond_val = drv_obj.license_number or "" + + # Determine Currency + moneda_final = getattr(header, 'currency', "USD") or "USD" + if currency_code == 'MXN': + moneda_final = 'MXN' + elif currency_code == 'USD': + moneda_final = 'USD' + + factura_schema = FacturaSchema( + numero=header.invoice_number or "N/A", + titulo_documento=self._get_document_title(header.invoice_type or "", is_american=True), + fecha=str(header.invoice_date) if header.invoice_date else "", + tipo_cambio=float(financials.exchange_rate) if (financials and financials.exchange_rate) else (float(pedimento.exchange_rate) if pedimento and pedimento.exchange_rate else 1.0), + moneda=moneda_final, + incoterm=(logistics.incoterm or "") if logistics else "", + observaciones=header.observation_es or header.observation_en or "", + pedimento=f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}" if pedimento else "", + 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 + ) + + if progress_callback: progress_callback(50, "Processing items...") + lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter(Item.invoice_id == header.id).all() + partidas_list = [] + + for line in lines: + qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first() + fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == line.id).first() + part_master = db.query(Part).filter(Part.id == line.part_number).first() + + desc_final = "N/D" + num_parte_final = str(line.part_number or "N/A") + fraccion_raw = "" + origen_final = "MEX" + + if part_master: + # Prefer English description if available, else Spanish + desc_final = part_master.description_english or part_master.description_spanish or "No Desc." + num_parte_final = part_master.part_number + # Prefer US Fraction (HTS) if available + fraccion_raw = part_master.us_fraction if part_master.us_fraction else "" + + if part_master.fa_data and part_master.fa_data.origin_country: + origen_final = part_master.fa_data.origin_country + + + # FRACTION LOGIC: Use US Fraction (us_fraction) if available, otherwise blank + fraccion_imprimir = "" + + # Check part master US fraction + if part_master and part_master.us_fraction: + fraccion_imprimir = part_master.us_fraction.strip() + + # Optional: Format if needed, but raw is usually fine for US HTS + # If valid US fraction logic requires looking up in DB, we could add that here. + # For now, per requirement: "Si no tiene, pues de queda en blanco" + + # Default "General" and "0%" if no specific logic for US duties yet + preferencia_txt = "General" + advalorem_txt = "0%" + + # Prioritize USD for American Invoice logic if available? + # Sticking to same logic as Mex for now but could prioritize USD columns. + # Actually, duplicate logic from mex service for now to ensure consistency. + + v_unitario = 0.0 + v_total = 0.0 + + if fin: + is_mxn = (factura_schema.moneda == 'MXN') + + if is_mxn: + v_unitario = float(fin.unit_cost_commercial_mxn or 0.0) + v_total = float(fin.value_commercial_mxn or 0.0) + else: + v_unitario = float(fin.unit_cost_commercial_usd or 0.0) + v_total = float(fin.value_commercial_usd or 0.0) + + if not v_unitario: + v_unitario = float(fin.commercial_unit_cost or 0.0) + + if not v_total: + v_total = float(fin.total_commercial_value or 0.0) + + cantidad = float(qty.quantity) if (qty and qty.quantity) else 0.0 + + if cantidad > 0: + if v_unitario > 0 and v_total == 0: + v_total = v_unitario * cantidad + elif v_total > 0 and v_unitario == 0: + v_unitario = v_total / cantidad + + # UOM Mapping for English context + uom_raw = qty.weight_unit if qty else "PCS" + if uom_raw == "PZA": uom_raw = "PCS" + + partidas_list.append(PartidaSchema( + numero_parte=num_parte_final, + descripcion=desc_final, + fraccion=fraccion_imprimir, + origen=origen_final, + advalorem=advalorem_txt, + preferencia=preferencia_txt, + cantidad_importacion=self.formatear_numero(qty.quantity if qty else 0), + unidad_medida=uom_raw, + cantidad_bultos=int(qty.package_quantity) if qty and qty.package_quantity else 0, + clave_bultos=(qty.package_key or "") if qty else "", + peso_neto=self.formatear_numero(qty.net_weight if qty else 0), + peso_bruto=self.formatear_numero(qty.gross_weight if qty else 0), + valor_costo_unitario=self.formatear_numero(v_unitario), + valor_total=self.formatear_numero(v_total) + )) + + 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 USA: {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" + # Don't pluralize strictly in English without logic, kept simple. + + tc = float(tipo_cambio) if tipo_cambio else 1.0 + return TotalesSchema( + cantidad_total=self.formatear_numero(cant), bultos_total=bultos, clave_bultos=clave_comun, + peso_neto_total=self.formatear_numero(peso_n), peso_bruto_total=self.formatear_numero(peso_b), + valor_total_total=self.formatear_numero(valor), valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0) + ) + + def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", progress_callback: Optional[Callable] = None, currency_code: str = 'ORIGINAL') -> Tuple[bytes, str, str]: + if progress_callback: progress_callback(5, "Starting report service...") + datos = self.obtener_datos(db, invoice_id, company_id, progress_callback, currency_code) + + if progress_callback: progress_callback(80, "Rendering template...") + + # LOGO LOGIC + logo_b64 = None + try: + comp_logo = db.query(Company).filter(Company.id == company_id).first() + if comp_logo and comp_logo.logo: + p = Path(comp_logo.logo) + target_path = p + if not target_path.exists(): + 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') + 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"Commercial_Invoice_{datos.factura.numero}.{formato}" + if formato == "html": return html_content.encode('utf-8'), nombre, "text/html" + + if progress_callback: progress_callback(90, "Generating PDF...") + options = {'page-size': 'Letter', 'margin-top': '0.5in', 'margin-right': '0.5in', 'margin-bottom': '0.5in', 'margin-left': '0.5in', 'encoding': "UTF-8", 'enable-local-file-access': None} + pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config()) + + if progress_callback: progress_callback(100, "Completed") + return pdf, nombre, "application/pdf" diff --git a/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts b/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts index 33c619f7..10daa84d 100644 --- a/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts +++ b/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts @@ -1,15 +1,19 @@ -const BASE_URL = import.meta.env.VITE_API_URL || ''; +const BASE_URL = import.meta.env.VITE_API_URL || ''; export const invoicesReportsApi = { - - triggerPdfGeneration: async (invoiceId: number, companyId: number) => { - const params = new URLSearchParams({ company_id: companyId.toString() }); + + triggerPdfGeneration: async (invoiceId: number, companyId: number, invoiceType: string = 'mexican', currency: string = 'ORIGINAL') => { + const params = new URLSearchParams({ + company_id: companyId.toString(), + invoice_type: invoiceType, + currency_code: currency + }); const endpoint = `${BASE_URL}/v1/a76/reports/importacion/facturas/${invoiceId}/download-async?${params.toString()}`; - + const token = localStorage.getItem('access_token'); const response = await fetch(endpoint, { - method: 'POST', + method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' @@ -17,12 +21,12 @@ export const invoicesReportsApi = { }); if (!response.ok) throw new Error('Error al iniciar la generación'); - return await response.json(); + return await response.json(); }, getTaskStatus: async (taskId: string) => { - const endpoint = `${BASE_URL}/v1/a76/reports/importacion/facturas/tasks/${taskId}`; - + const endpoint = `${BASE_URL}/v1/a76/reports/importacion/facturas/tasks/${taskId}`; + const token = localStorage.getItem('access_token'); const response = await fetch(endpoint, { method: 'GET', diff --git a/frontend/src/lib/components/dashboard/invoices/download-invoice-button.svelte b/frontend/src/lib/components/dashboard/invoices/download-invoice-button.svelte index a04b550b..e70c4149 100644 --- a/frontend/src/lib/components/dashboard/invoices/download-invoice-button.svelte +++ b/frontend/src/lib/components/dashboard/invoices/download-invoice-button.svelte @@ -1,90 +1,23 @@ + + console.log('Download not implemented in this context')} +/> diff --git a/frontend/src/lib/components/dashboard/invoices/invoice-download-modal.svelte b/frontend/src/lib/components/dashboard/invoices/invoice-download-modal.svelte new file mode 100644 index 00000000..fcff77cc --- /dev/null +++ b/frontend/src/lib/components/dashboard/invoices/invoice-download-modal.svelte @@ -0,0 +1,184 @@ + + + + + + Descargar Factura + + Configure las opciones para generar el documento PDF. + + + +
+ +
+ +
+ + invoiceType = v}> + + {invoiceType === 'mexican' ? 'Factura Mexicana' : 'Factura Americana'} + + + Factura Mexicana + Factura Americana + + +
+ + +
+ + format = v} + disabled + > + +
+ {#if format === 'vertical'} + + Vertical + {:else} + + Horizontal + {/if} +
+
+ + +
+ + Vertical +
+
+ +
+ + Horizontal +
+
+
+
+
+
+ + +
+ +
+ {#each currencyOptions as option} + + {/each} +
+
+ +
+ +
+ +
+ {#each uomOptions as option} + + {/each} +
+
+ + +
+ +
+ {#each weightOptions as option} + + {/each} +
+
+
+ +
+ + + + + +
+
diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index 61138a73..29fa17a9 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -1,5 +1,7 @@
@@ -620,7 +648,7 @@ Desactualizar - @@ -631,4 +659,15 @@
+ + + + + + {#if selectedInvoice && companyStore.activeCompany} + + {/if} \ No newline at end of file