From ce37aa77b21563d53d1480ee0221b578618255d7 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Mon, 12 Jan 2026 16:29:07 -0600 Subject: [PATCH] Se edito el reporte de factura, se le puso las partidas, ademas se inicio todo el procedo de celery --- .../importacion/facturas/mex/schemas.py | 4 + .../importacion/facturas/mex/service.py | 174 ++++++++---------- .../a76/reports/importacion/facturas/task.py | 45 +++++ .../facturas/templates/factura_mex_ver.html | 4 + backend/core/celery_app.py | 24 +++ backend/requirements.txt | 5 + docker-compose.yml | 31 ++++ 7 files changed, 194 insertions(+), 93 deletions(-) create mode 100644 backend/api/v1/modules/a76/reports/importacion/facturas/task.py create mode 100644 backend/core/celery_app.py 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 e2f73cc3..a74709dc 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 @@ -55,6 +55,10 @@ class PartidaSchema(BaseModel): descripcion: str fraccion: str origen: str + + advalorem:Optional[str] = "" + preferencia:Optional[str] = "" + cantidad_importacion: Union[float, str] unidad_medida: str cantidad_bultos: int 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 8f02cbd1..1e10d5d2 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 @@ -14,14 +14,16 @@ 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 + 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 + +# --- MODELO DE FRACCIONES --- +from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction # --- SCHEMAS --- from .schemas import ( @@ -50,14 +52,16 @@ class FacturaImportacionMexService: 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" - ) - + 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() @@ -79,61 +83,32 @@ class FacturaImportacionMexService: def obtener_datos(self, db: Session, invoice_id: int, company_id: int) -> FacturaImportacionCompleta: try: - # 1. Cabecera - 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") + 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") - # 2. Relaciones Críticas (Compliance y Logistics) - # Nota: Usamos la relación ORM 'compliance_mx' que definiste en el modelo compliance = header.compliance_mx - - # Si logistics es una lista, tomamos el primero, si no, None logistics = header.logistics[0] if header.logistics else None - - # Pedimento: Prioridad al de Compliance, si no, al de Header 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 - - # 3. Mapeo Actores (¡AQUÍ ESTABA EL DETALLE!) - # A) Proveedor: Sacado de compliance.provider_id proveedor_id = compliance.provider_id if compliance else None - if proveedor_id: - cliente_proveedor = self._obtener_datos_cliente(db, proveedor_id, "Proveedor / Supplier") - else: - cliente_proveedor = ClienteSchema(header="Proveedor", nombre="No Asignado", direccion="", tax_id="", - codigo_postal="", ciudad="", estado="", pais="") + 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="") - # B) Agente Aduanal: Sacado de compliance.customs_broker_id 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 # Asumiendo que CustomsBroker tiene 'name' + if broker: nombre_agente = broker.name - # C) Importador (Company) company = db.query(Company).filter(Company.id == header.company_id).first() cliente_vendido = ClienteSchema( header="Importador / Consignatario", nombre=getattr(company, 'name', "Empresa Local"), direccion="DOMICILIO FISCAL", - num_exterior="", - colonia="", - codigo_postal="", - ciudad="", - estado="", - pais="MEX", + num_exterior="", colonia="", codigo_postal="", ciudad="", estado="", pais="MEX", tax_id=getattr(company, 'rfc', ""), - programa=getattr(company, 'program', "IMMEX"), - autorizacion=getattr(company, 'program_number', "") + programa=getattr(company, 'program', "IMMEX"), autorizacion=getattr(company, 'program_number', "") ) - # 4. Mapeo Factura - # Nota: Muchos datos vienen de 'compliance', no de 'header' remesa_valor = str(compliance.remesa) if (compliance and compliance.remesa) else "" acuse_valor = str(compliance.edocument) if (compliance and compliance.edocument) else "N/A" @@ -144,30 +119,22 @@ class FacturaImportacionMexService: moneda=getattr(header, 'currency', "USD") or "USD", incoterm=logistics.incoterm if logistics else "", observaciones=header.observation_es or header.observation_en or "", - - # Pedimento y Agente pedimento=pedimento.pedimento_number if pedimento else "", clave_pedimento=pedimento.pedimento_code if pedimento else "", regimen=pedimento.regime if pedimento else "", patente=pedimento.license if pedimento else "", - agente_aduanal=nombre_agente, # Agregamos el nombre real - - # Transporte + agente_aduanal=nombre_agente, transporte=str(logistics.transport_type.value) if (logistics and logistics.transport_type) else "", num_transporte=logistics.trailer_num if logistics else "", placas=logistics.license_plate if logistics else "", - transportista=logistics.carrier_id if logistics else "", # Si carrier_id es ID, aquí habría que buscar nombre - - # Otros + transportista=logistics.carrier_id if logistics else "", aduana=pedimento.customs_office if pedimento else "", precinto=logistics.seal_number if logistics else "", destino=logistics.destination_goods if logistics else "", - remesa=remesa_valor, - acuse_electronico=acuse_valor + remesa=remesa_valor, acuse_electronico=acuse_valor ) - # 5. Mapeo Partidas - lines = db.query(LineItem).filter(LineItem.item_id == header.id).all() + lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter(Item.invoice_id == header.id).all() partidas_list = [] for line in lines: @@ -175,13 +142,54 @@ class FacturaImportacionMexService: 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 "" + + # --- LÓGICA DE FRACCIÓN DESDE BASE DE DATOS --- + # Limpiar la fracción de la BD (quitar puntos y asegurar 8 dígitos) + 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() + + # Reglas de negocio solicitadas: + # 1. Preferencia default: "General" + # 2. AdValorem default: "0%" si no existe o es vacío + 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%" # O podrías poner "EXENTO" si prefieres + + fraccion_imprimir = fraccion_db.fraction or fraccion_raw + else: + # Si no existe en la tabla, aplicamos el fallback visual de puntos + fraccion_imprimir = self._format_fraccion_fallback(fraccion_limpia) + partidas_list.append(PartidaSchema( - numero_parte=part_master.part_number if part_master else str(line.part_number or "S/N"), - descripcion=part_master.description_spanish if part_master else "S/D", - fraccion=part_master.fraction if part_master else "", - origen="MEX", + 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 "KG", + 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 if qty else "", peso_neto=self.formatear_numero(qty.net_weight if qty else 0), @@ -190,21 +198,17 @@ class FacturaImportacionMexService: valor_total=self.formatear_numero(fin.total_commercial_value if fin else 0) )) - # 6. Totales totales = self.calcular_totales(partidas_list, Decimal(factura_schema.tipo_cambio)) return FacturaImportacionCompleta( - cliente_proveedor=cliente_proveedor, - cliente_vendido=cliente_vendido, - cliente_enviado=cliente_vendido, - factura=factura_schema, - partidas=partidas_list, - totales=totales + cliente_proveedor=cliente_proveedor, cliente_vendido=cliente_vendido, + cliente_enviado=cliente_vendido, 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 procesando datos: {str(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) @@ -214,40 +218,24 @@ class FacturaImportacionMexService: 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) + 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") -> Tuple[bytes, str, str]: datos = self.obtener_datos(db, invoice_id, company_id) - 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() + '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() } - 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" - - 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 - } + if formato == "html": return html_content.encode('utf-8'), nombre, "text/html" + 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()) return pdf, nombre, "application/pdf" \ 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 new file mode 100644 index 00000000..d16988bb --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/task.py @@ -0,0 +1,45 @@ +import base64 +import logging +from core.celery_app import celery_app +from core.database import CoreSessionLocal +from .mex.service import FacturaImportacionMexService + +logger = logging.getLogger(__name__) + +@celery_app.task(name="generar_pdf_factura_async") +def generar_pdf_factura_async(invoice_id: int, company_id: int): + """ + Esta tarea la ejecuta el Worker. No usa FastAPI, usa directamente SQLAlchemy. + """ + # 1. Abrimos conexión a la DB + db = CoreSessionLocal() + try: + logger.info(f"Worker procesando factura {invoice_id}...") + + # 2. Instanciamos el servicio de reportes + service = FacturaImportacionMexService() + + # 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 + ) + + # 4. Codificamos a base64 para que viaje seguro por Valkey + pdf_base64 = base64.b64encode(pdf_bytes).decode('utf-8') + + return { + "status": "success", + "file_name": nombre, + "content": pdf_base64, + "media_type": media_type + } + + except Exception as e: + logger.error(f"Error en Celery Worker: {str(e)}") + return {"status": "error", "message": str(e)} + + finally: + # 5. MUY IMPORTANTE: Cerramos la conexión para no saturar Postgres + db.close() \ 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 eff919ee..7d5cf1d9 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 @@ -285,6 +285,10 @@

{{ partida.numero_parte }}

{{ partida.descripcion }}

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

+

+ {% if partida.advalorem %}ADV: {{ partida.advalorem }}{% endif %} + {% if partida.preferencia %} / PREF: {{ partida.preferencia }}{% endif %} +

{{ partida.cantidad_importacion }}

diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py new file mode 100644 index 00000000..520248ce --- /dev/null +++ b/backend/core/celery_app.py @@ -0,0 +1,24 @@ +import os +from celery import Celery + +valkey_url = os.getenv("VALKEY_URL", "redis://localhost:6379/0") + +celery_app = Celery( + "anexo76_tasks", + broker=valkey_url, + backend=valkey_url, + include=["api.v1.modules.a76.reports.importacion.facturas.task"] # Ruta al módulo donde están las tareas +) + +# Configuraciones adicionales +celery_app.conf.update( + task_track_started=True, + task_serializer="json", + accept_content=["json"], + result_serializer="json", + timezone="America/Mexico_City", + enable_utc=True, +) + +if __name__ == "__main__": + celery_app.start() \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 8ed4b755..ff7a7cfa 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -42,3 +42,8 @@ pylint==4.0.2 # reportes Jinja2==3.1.6 pdfkit==1.0.0 + +# Desarrollo en seguno plano +celery==5.3.6 +redis==5.0.1 +flower==2.0.1 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 655340d1..bbca1665 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -250,6 +250,37 @@ services: memory: 1G reservations: memory: 512M +# celery + celery_worker: + build: ./backend + container_name: a76_worker + command: celery -A core.celery_app worker --loglevel=info + environment: + - VALKEY_URL=redis://valkey:6379/0 + depends_on: + - backend + - valkey + valkey: + image: valkey/valkey:7.2 + container_name: a76_valkey + restart: always + ports: + - "6379:6379" + networks: + - backend-net + + flower: + image: mher/flower + container_name: a76_flower + command: celery -A core.celery_app flower + ports: + - "5555:5555" + environment: + - CELERY_BROKER_URL=redis://valkey:6379/0 + depends_on: + - valkey + networks: + - backend-net volumes: postgres_app_data: