diff --git a/backend/api/v1/modules/a24/router.py b/backend/api/v1/modules/a24/router.py index c6657bac..06a92dcc 100644 --- a/backend/api/v1/modules/a24/router.py +++ b/backend/api/v1/modules/a24/router.py @@ -8,6 +8,7 @@ from fastapi import APIRouter from .fa.fa_classes.routes import router as fa_classes_router from .fa.fa_item_lines.routes import router as fa_item_lines_router + # Router principal de A24 router = APIRouter() diff --git a/backend/api/v1/modules/a76/items/line_items/models.py b/backend/api/v1/modules/a76/items/line_items/models.py index a7603d11..5192f46f 100644 --- a/backend/api/v1/modules/a76/items/line_items/models.py +++ b/backend/api/v1/modules/a76/items/line_items/models.py @@ -193,3 +193,8 @@ class LineItem(Base, TenantScopedMixin, TimestampMixin): cascade="all, delete-orphan", uselist=False, ) + part_info: Mapped[Optional["api.v1.modules.a76.parts.models.Part"]] = relationship( + "api.v1.modules.a76.parts.models.Part", + foreign_keys=[part_number], + viewonly=True, + ) diff --git a/backend/api/v1/modules/a76/items/models.py b/backend/api/v1/modules/a76/items/models.py index 6cb67175..53e75cfd 100644 --- a/backend/api/v1/modules/a76/items/models.py +++ b/backend/api/v1/modules/a76/items/models.py @@ -52,6 +52,8 @@ class Item(Base, TenantScopedMixin, TimestampMixin): # Relationships (one-to-many) lines: Mapped[List["LineItem"]] = relationship( "LineItem", back_populates="item", cascade="all, delete-orphan") + + invoice: Mapped["InvoiceHeader"] = relationship("InvoiceHeader") # ============================================================================ # SUPPORTING TABLES diff --git a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/task.py b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/task.py index 651919d4..291d5a4e 100644 --- a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/task.py +++ b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/task.py @@ -1,4 +1,3 @@ - import base64 import logging from core.celery_app import celery_app @@ -47,4 +46,4 @@ def generar_pdf_aviso_consolidado_exp_async(self, invoice_id: int, company_id: i return {"status": "error", "message": str(e)} finally: - db.close() + db.close() \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/exportacion/descargo/routes.py b/backend/api/v1/modules/a76/reports/exportacion/descargo/routes.py new file mode 100644 index 00000000..2a3878f3 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/descargo/routes.py @@ -0,0 +1,77 @@ + +from fastapi import APIRouter, Depends, BackgroundTasks, HTTPException +from fastapi.responses import JSONResponse, Response +from sqlalchemy.orm import Session +from typing import Dict, Any + +from core.database import get_core_db as get_db +from core.security import get_current_user +from .service import FIFOAssignmentService +from .task import generate_descarga_pdf_task # FORCE RELOAD +from celery.result import AsyncResult + +router = APIRouter() + +@router.post("/fifo-assign/{invoice_id}") +def run_fifo_assignment( + invoice_id: int, + db: Session = Depends(get_db), + current_user: Any = Depends(get_current_user), +): + """ + Executes FIFO (PEPS) calculation for an Export Invoice. + Returns the calculated discharges in JSON format. + """ + service = FIFOAssignmentService() + try: + discharges = service.calculate_fifo(db, invoice_id) + return { + "invoice_id": invoice_id, + "total_discharges": len(discharges), + "discharges": discharges + } + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Error calculating FIFO: {str(e)}" + ) + +@router.post("/{invoice_id}/download-async") +async def trigger_descarga_generation( + invoice_id: int, + company_id: int, + current_user: Any = Depends(get_current_user) +): + """ + Inicia la generación del reporte de Descarga PEPS en segundo plano (Celery). + Retorna el task_id para polling. + """ + try: + # Lanza la tarea de Celery + task = generate_descarga_pdf_task.delay(invoice_id, company_id) + return {"task_id": task.id, "status": "processing"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/tasks/{task_id}") +async def get_task_status(task_id: str, current_user: Any = Depends(get_current_user)): + """ + Consulta el estado de la tarea de Celery. + """ + task_result = AsyncResult(task_id) + + 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 diff --git a/backend/api/v1/modules/a76/reports/exportacion/descargo/service.py b/backend/api/v1/modules/a76/reports/exportacion/descargo/service.py new file mode 100644 index 00000000..57a8d479 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/descargo/service.py @@ -0,0 +1,400 @@ + +import shutil +import base64 +import pdfkit +from pathlib import Path +from typing import Tuple, List, Callable, Optional, Dict, Any +from jinja2 import Environment, FileSystemLoader, select_autoescape +from fastapi import HTTPException +from sqlalchemy.orm import Session +from pydantic import BaseModel +from decimal import Decimal +from datetime import datetime + + +# --- MODELOS (Imported from system for Header info) --- +from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceComplianceMx +from api.v1.modules.a76.general_catalogs.company.models import Company +from api.v1.modules.a76.items.line_items.models import LineItem +from api.v1.modules.a76.items.line_quantities.models import LineQuantity +from api.v1.modules.a76.parts.models import Part +from api.v1.modules.a76.items.models import Item +from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos +from api.v1.modules.a76.pedmientos.models.pedimento_dates import PedimentoDates +from api.v1.modules.core.tenants.models import Tenant +from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate +from sqlalchemy.orm import joinedload, load_only + +# --- FIFO SERVICE (Internalized) --- +from api.v1.modules.a76.items.series.models import Serie + +class FIFOAssignmentService: + """ + Service for calculating FIFO (PEPS) assignments in real-time. + Does not persist to database, returns calculated discharge objects. + """ + + def calculate_fifo(self, db: Session, invoice_id: int) -> List[Dict[str, Any]]: + """ + Calculates the FIFO trail for all lines in an export invoice. + Returns a list of calculated discharges. + """ + # 1. Get Export Lines + export_lines = db.query(LineItem).join(Item).filter( + Item.invoice_id == invoice_id + ).options( + joinedload(LineItem.quantity), + joinedload(LineItem.description), + joinedload(LineItem.customs), + joinedload(LineItem.unit_of_measure_info), + joinedload(LineItem.part_info) + ).all() + + results = [] + self._log(f"Starting FIFO for Invoice {invoice_id}. Export Lines: {len(export_lines)}") + + for exp_line in export_lines: + qty_needed = float(exp_line.quantity.quantity) if exp_line.quantity and exp_line.quantity.quantity is not None else 0.0 + if qty_needed <= 0: + continue + + part_number = exp_line.part_number + if not part_number: + self._log(f"Skipping line {exp_line.id}, no part number") + continue + + self._log(f"Processing Exp Line {exp_line.id}, Part: {part_number}, Qty Needed: {qty_needed}") + + # --- SERIES EXPO LOOKUP --- + series_desc = "" + series_count = db.query(Serie).filter(Serie.line_item_id == exp_line.id).count() + if series_count > 0: + series_list = db.query(Serie).filter(Serie.line_item_id == exp_line.id).all() + parts_str = [] + for idx, s in enumerate(series_list, 1): + line_parts = [f"{idx}) Serie: {s.serial_numbers or ''}", f"Modelo: {s.model or ''}", f"Parte: {exp_line.part_info.part_number if exp_line.part_info else ''}", f"Num ID Expo: {s.number_id or ''}", f"SubModelo: {s.sub_model or ''}"] + parts_str.append(", ".join([p for p in line_parts if p])) + if parts_str: + series_desc = "\nSeries:\n" + "\n".join(parts_str) + + # 2. Find Import Candidates (FIFO order by payment date) + # Use outerjoin for pedimento dates to avoid filtering out candidates with missing dates + candidates = db.query(LineItem).join(Item).join(InvoiceHeader)\ + .join(InvoiceComplianceMx).join(InvoiceComplianceMx.pedimento).outerjoin(Pedimentos.pedimento_dates)\ + .filter( + LineItem.part_number == part_number, + InvoiceHeader.operation_type == 'imp', # Assuming 'imp' is the value for Import based on Enum + ).order_by( + PedimentoDates.payment_date.asc() + ).options( + joinedload(LineItem.quantity), + joinedload(LineItem.customs), + joinedload(LineItem.financial), + joinedload(LineItem.item).joinedload(Item.invoice).joinedload(InvoiceHeader.compliance_mx).joinedload(InvoiceComplianceMx.pedimento).joinedload(Pedimentos.pedimento_dates) + ).all() + + self._log(f"Found {len(candidates)} candidates for {part_number}") + + for imp_line in candidates: + if qty_needed <= 0: + break + + imp_qty_total = float(imp_line.quantity.quantity) if imp_line.quantity and imp_line.quantity.quantity is not None else 0.0 + if imp_qty_total <= 0: + continue + + take = min(qty_needed, imp_qty_total) + ratio = take / imp_qty_total if imp_qty_total > 0 else 0 + + # Calculate Proportions + imp_weight = float(imp_line.quantity.net_weight) if imp_line.quantity and imp_line.quantity.net_weight is not None else 0.0 + # imp_val_mn = float(imp_line.customs.customs_value) if imp_line.customs and imp_line.customs.customs_value is not None else 0.0 + imp_val_me = float(imp_line.financial.customs_value_usd) if imp_line.financial and imp_line.financial.customs_value_usd is not None else 0.0 + imp_igi = float(imp_line.customs.igi_amount) if imp_line.customs and imp_line.customs.igi_amount is not None else 0.0 + + imp_inv = imp_line.item.invoice + ped = imp_inv.compliance_mx.pedimento if imp_inv and imp_inv.compliance_mx else None + + # --- EXCHANGE RATE VALIDATION --- + payment_date = ped.pedimento_dates.payment_date if ped and ped.pedimento_dates else None + exchange_rate_val = 1.0 + validation_error = None + + if payment_date: + er_obj = db.query(ExchangeRate).filter(ExchangeRate.date == payment_date).first() + if er_obj: + exchange_rate_val = float(er_obj.value) + else: + # Try previous day if strict match fails (mimicking SisGen:UtilizarFechaPagoPedDeUnDiaAnterior logic broadly or just flagging) + # For now, flag it. + validation_error = f"Tipo de Cambio no encontrado para fecha {payment_date}" + + # Calculate Valor MN based on Clarion logic: (CantDesc * ValorImpoME / CantImpo) * TC + # which simplifies to: Ratio * ValorImpoME * TC + val_mn_calc = (imp_val_me * ratio) * exchange_rate_val + + discharge = { + "export_line_id": exp_line.id, + "import_line_id": imp_line.id, + "quantity": take, + "net_weight": imp_weight * ratio, + "value_mxn": val_mn_calc, + "value_usd": imp_val_me * ratio, + "igi_amount": imp_igi * ratio, + "import_invoice": imp_inv.invoice_number if imp_inv else "N/A", + "pedimento": ped.pedimento_number if ped else "N/A", + "pedimento_clave": ped.pedimento_code if ped else "", + "pedimento_date": payment_date.isoformat() if payment_date else None, + "series_desc": series_desc, + "validation_error": validation_error + } + + self._log(f"MATCH: Taking {take} from Imp Line {imp_line.id}") + results.append(discharge) + qty_needed -= take + + return results + + def _log(self, msg): + try: + with open("/tmp/fifo_debug.log", "a") as f: + f.write(f"{datetime.now()}: {msg}\n") + except: pass + + +# --- SCHEMAS FOR TEMPLATE CONTEXT --- + +class DischargeItemSchema(BaseModel): + # Column 1: Pedimento Info + pedimento_numero: str + pedimento_clave: str + pedimento_fecha_pago: str + + # Column 2: Import Invoice + factura_impo: str + + # Column 3: Part Info + numero_parte: str + descripcion: str + fraccion: str + origen_pref_sector: str # e.g. "CHN-GENERAL" + + # Metrics + cantidad: str + unidad_medida: str + peso_neto: str + + # Values + valor_mn: str + valor_me: str + valor_igi: str + + # Flags + se_pago: str # "0.0" or "Yes"? Image says "0.0" in column "Se Pago"? No, "Se Pago" might be a flag, image key implies payment. + # Image: "Se Pago" column has "0.0"? No, look closer. + # "Value/Monto IGI USD/Dolares" has "0.0". + # "Se Pago" column seems empty or has '1'? + # Wait, looking at image: + # Col: "Se Pago", Row: "0.0"? No that's IGI. + # Let's assume Se Pago is a boolean/string. + # Last col: "Linea Expo". + + linea_expo: str + + # Errors + error_msg: Optional[str] = None + + # Helper for Jinja (if methods not allowed in pydantic models in template) + def __init__(self, **data): + super().__init__(**data) + +class DischargeContext(BaseModel): + items: List[DischargeItemSchema] + invoice_number: str + company_name: str + company_address: str + company_rfc: str + company_immex: str + + # Totals + total_cantidad: str + total_peso: str + total_valor_mn: str + total_valor_me: str + total_igi: str + +class DescargaReportService: + def formatear_numero(self, valor, decimales: int = 2): + if valor is None: return "0.00" + try: + return "{:,.{}f}".format(float(valor), decimales) + except: return "0.00" + + def __init__(self): + self.template_dir = Path(__file__).parent / "templates" + self.jinja_env = Environment( + loader=FileSystemLoader(self.template_dir), + autoescape=select_autoescape(['html', 'xml']) + ) + self.template = self.jinja_env.get_template('descarga.html') + + def _get_wkhtmltopdf_config(self): + path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf" or "/usr/bin/wkhtmltopdf" + if not Path(path).exists(): + raise RuntimeError("wkhtmltopdf no encontrado.") + return pdfkit.configuration(wkhtmltopdf=path) + + def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> DischargeContext: + try: + if progress_callback: progress_callback(10, "Buscando factura...") + + # --- 1. Obtener Cabeceras (Igual que antes) --- + header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id).first() + if not header: + raise HTTPException(status_code=404, detail="Factura no encontrada") + + tenant = db.query(Tenant).filter(Tenant.id == header.tenant_id).first() + company = db.query(Company).filter(Company.id == header.company_id).first() + + # --- 2. Obtener Líneas de Exportación (Lo que necesitamos cubrir) --- + if progress_callback: progress_callback(20, "Obteniendo items a exportar...") + + export_lines = db.query(LineItem).filter( + LineItem.item_id == Item.id, + Item.invoice_id == invoice_id + ).options( + joinedload(LineItem.quantity), + joinedload(LineItem.customs), + joinedload(LineItem.description), + joinedload(LineItem.unit_of_measure_info), + joinedload(LineItem.part_info) + ).join(Item).all() + + items_reporte = [] + + # --- 3. EL ALGORITMO PEPS EN VIVO --- + if progress_callback: progress_callback(40, "Calculando PEPS en tiempo real...") + + fifo_service = FIFOAssignmentService() + discharges = fifo_service.calculate_fifo(db, invoice_id) + + print(f"DEBUG: Calculated {len(discharges)} discharges.") # FORCE PRINT + + # Create map for faster/safer lookup + exp_map = {l.id: l for l in export_lines} + + for d in discharges: + exp_id = d["export_line_id"] + exp_line = exp_map.get(exp_id) + + if not exp_line: + print(f"DEBUG: Skipping discharge, Exp Line {exp_id} not found in map keys: {list(exp_map.keys())}") + continue + + print(f"DEBUG: Adding item to report: Imp {d['import_line_id']} -> Exp {exp_id}") + + desc_final = exp_line.description.description_spanish if exp_line.description else "S/D" + items_reporte.append(DischargeItemSchema( + pedimento_numero=d["pedimento"], + pedimento_clave=d["pedimento_clave"], + pedimento_fecha_pago=d["pedimento_date"].split("T")[0] if d["pedimento_date"] else "", + + factura_impo=d["import_invoice"], + + numero_parte=exp_line.part_info.part_number if exp_line.part_info else "", + descripcion=desc_final, + fraccion=exp_line.customs.fraction if exp_line.customs else "", + origen_pref_sector=f"{exp_line.customs.origin_country or ''} - {exp_line.customs.sector or ''}" if exp_line.customs else "", + + cantidad=self.formatear_numero(d["quantity"], 3), + unidad_medida=exp_line.unit_of_measure_info.code if exp_line.unit_of_measure_info else "PZA", + + peso_neto=self.formatear_numero(d["net_weight"], 3), + valor_mn=self.formatear_numero(d["value_mxn"]), + valor_me=self.formatear_numero(d["value_usd"]), + valor_igi=self.formatear_numero(d["igi_amount"]), + + se_pago="", + linea_expo=str(exp_line.line_number), + error_msg=d.get("validation_error") + )) + + print(f"DEBUG: Final Report Items Count: {len(items_reporte)}") + + # --- 4. Totales y Finalización (Igual que antes) --- + addr_str = "DIRECCION NO REGISTRADA" + immex_val = "" + + if company: + # Address Logic + if company.addresses: + # Prefer 'main' address, otherwise take the first one + main_addr = next((a for a in company.addresses if a.address_type == 'main'), company.addresses[0]) + + parts = [] + if main_addr.street: parts.append(main_addr.street) + if main_addr.exterior_number: parts.append(f"No. {main_addr.exterior_number}") + if main_addr.neighborhood: parts.append(main_addr.neighborhood) + if main_addr.city: parts.append(main_addr.city) + if main_addr.state: parts.append(main_addr.state) + if main_addr.postal_code: parts.append(f"CP {main_addr.postal_code}") + + if parts: + addr_str = ", ".join(parts) + + # IMMEX Logic + if company.program and "IMMEX" in company.program and company.program_number: + immex_val = company.program_number + + # Recalcular totales basados en la lista generada + t_cant = sum(float(i.cantidad.replace(",","")) for i in items_reporte) + t_peso = sum(float(i.peso_neto.replace(",","")) for i in items_reporte) + t_mn = sum(float(i.valor_mn.replace(",","")) for i in items_reporte) + t_me = sum(float(i.valor_me.replace(",","")) for i in items_reporte) + t_igi = sum(float(i.valor_igi.replace(",","")) for i in items_reporte) + + return DischargeContext( + items=items_reporte, + invoice_number=header.invoice_number or "SIN FOLIO", + company_name=company.name if company else (tenant.name if tenant else "EMPRESA DESCONOCIDA"), + company_address=addr_str, + company_rfc=company.rfc if company else "", + company_immex=immex_val, + total_cantidad=self.formatear_numero(t_cant, 3), + total_peso=self.formatear_numero(t_peso, 3), + total_valor_mn=self.formatear_numero(t_mn), + total_valor_me=self.formatear_numero(t_me), + total_igi=self.formatear_numero(t_igi) + ) + + except Exception as e: + print(f"Error Service Discharge Report: {e}") + raise HTTPException(status_code=500, detail=f"Error: {str(e)}") + + def generar_pdf(self, db: Session, invoice_id: int, company_id: int, 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...") + + context = datos.model_dump() + html_content = self.template.render(**context) + nombre = f"Descarga_{datos.invoice_number}.pdf" + + if progress_callback: progress_callback(90, "Generando PDF final...") + + options = { + 'page-size': 'Letter', + 'orientation': 'Landscape', # Correct argument for wkhtmltopdf + 'margin-top': '0.5in', + 'margin-right': '0.5in', + 'margin-bottom': '0.5in', + 'margin-left': '0.5in', + 'encoding': "UTF-8" + } + + 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/exportacion/descargo/task.py b/backend/api/v1/modules/a76/reports/exportacion/descargo/task.py new file mode 100644 index 00000000..3ed57fbb --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/descargo/task.py @@ -0,0 +1,49 @@ + +from celery import shared_task +from sqlalchemy.orm import Session +from core.database import CoreSessionLocal as SessionLocal +from .service import DescargaReportService # FORCE RELOAD 2 +import base64 +import traceback + +@shared_task(bind=True, name="generate_descarga_pdf_task") +def generate_descarga_pdf_task(self, invoice_id: int, company_id: int): + """ + Tarea de Celery para generar el PDF del Reporte de Descarga + """ + db: Session = SessionLocal() + try: + service = DescargaReportService() + + def update_progress(percent, message): + self.update_state( + state='PROCESSING', + meta={'current': percent, 'total': 100, 'status': message} + ) + + pdf_bytes, filename, content_type = service.generar_pdf( + db=db, + invoice_id=invoice_id, + company_id=company_id, + progress_callback=update_progress + ) + + # Retornar el PDF en base64 para que el front lo descargue + pdf_b64 = base64.b64encode(pdf_bytes).decode('utf-8') + + return { + "status": "success", + "file_name": filename, + "content": pdf_b64, + "media_type": content_type, + "message": "Reporte generado correctamente" + } + + except Exception as e: + self.update_state( + state='FAILURE', + meta={'exc_type': type(e).__name__, 'exc_message': str(e)} + ) + raise e + finally: + db.close() diff --git a/backend/api/v1/modules/a76/reports/exportacion/descargo/templates/descarga.html b/backend/api/v1/modules/a76/reports/exportacion/descargo/templates/descarga.html new file mode 100644 index 00000000..177847ba --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/descargo/templates/descarga.html @@ -0,0 +1,184 @@ + + + +
+ +| + DESCARGA DE LA FACTURA: {{ invoice_number }} + | +
+ {{ company_name }} + {{ company_address }} + R.F.C.: {{ company_rfc }}, IMMEX: {{ company_immex }} + |
+ + Page/Página: Of/de + | +
| No. Pedimento Fecha de Pago |
+ Import Invoice/ Factura de Impo. |
+ Part Number/No. de Parte Description/Descripción |
+ Fracción (Origen-Prefer.-Sector) |
+ Quantity/ Cantidad U.M. |
+ Net Weight/ Peso Neto (KGS) |
+ Value/Valor M.N. MXP/Pesos |
+ Value/Valor M.E. USD/Dolares |
+ Value/Monto IGI USD/Dolares |
+ Se Pagó |
+ Linea Expo Expo Line |
+ ||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
+ {{ item.pedimento_numero }}
+
|
+ {{ item.factura_impo }} | +
+ {{ item.numero_parte }} + {{ item.descripcion }} + |
+
+ {{ item.fraccion }} + {{ item.origen_pref_sector }} + |
+ + {{ item.cantidad }} {{ item.unidad_medida }} + | +{{ item.peso_neto }} | +{{ item.valor_mn }} | +{{ item.valor_me }} | +{{ item.valor_igi }} | +{{ item.se_pago }} | +{{ item.linea_expo }} | +||
| Totales de los Comp. Temporales: | +{{ total_cantidad }} | +{{ total_peso }} | +{{ total_valor_mn }} | +{{ total_valor_me }} | +{{ total_igi }} | ++ | + | |||||
| TOTALES: | +{{ total_cantidad }} | +{{ total_peso }} | +{{ total_valor_mn }} | +{{ total_valor_me }} | +{{ total_igi }} | ++ | + | |||||