feature/implementacion de hub en EFC

This commit is contained in:
2026-06-08 07:19:01 -06:00
parent a9931d2838
commit e1716d65a7
20 changed files with 3749 additions and 649 deletions

View File

@@ -1,128 +1,373 @@
import tempfile
from api.utils.storage_service import storage_service
from celery import shared_task
from api.organization.models import Organizacion
from django.utils import timezone
from api.reports.models import ReportDocument
from api.customs.models import Pedimento, Cove, EDocument, Partida
from django.db.models import Q, Exists, OuterRef
# from django.db.models import Q,
from api.record.models import Document
import csv
import io
import logging
import os
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
import tempfile
import traceback
from collections import defaultdict
@shared_task
def generate_report_document(report_id):
import openpyxl
from openpyxl.styles import Alignment, Font, PatternFill
from celery import shared_task
from celery.exceptions import SoftTimeLimitExceeded
from django.core.files.uploadedfile import SimpleUploadedFile
from django.db.models import Q
from django.utils import timezone
from api.customs.models import Cove, EDocument, Partida, Pedimento
from api.organization.models import Organizacion
from api.record.models import Document
from api.reports.models import ReportDocument
from api.utils.storage_service import storage_service
from core.redis_events import publish_task_event
logger = logging.getLogger('api.reports.tasks')
# ── helpers ───────────────────────────────────────────────────────────────────
def _estado(flag: bool) -> str:
return 'RECUPERADO' if flag else 'PENDIENTE'
def _build_pedimento_filters(filters: dict) -> Q:
q = Q()
if filters.get('organizacion_id'):
q &= Q(organizacion_id=filters['organizacion_id'])
if filters.get('fecha_pago__gte'):
q &= Q(fecha_pago__gte=filters['fecha_pago__gte'])
if filters.get('fecha_pago__lte'):
q &= Q(fecha_pago__lte=filters['fecha_pago__lte'])
if filters.get('patente'):
q &= Q(patente=filters['patente'])
if filters.get('aduana'):
q &= Q(aduana=filters['aduana'])
if filters.get('pedimento'):
q &= Q(pedimento=filters['pedimento'])
if filters.get('pedimento_app'):
q &= Q(pedimento_app=filters['pedimento_app'])
if filters.get('regimen'):
q &= Q(regimen=filters['regimen'])
if filters.get('tipo_operacion'):
q &= Q(tipo_operacion_id=filters['tipo_operacion'])
rfc_val = filters.get('contribuyente__rfc')
if rfc_val:
if rfc_val == 'SIN_RFC':
q &= Q(contribuyente__isnull=True)
else:
q &= Q(contribuyente__rfc=rfc_val)
return q
def _apply_user_rfc_filter(q: Q, user, requested_rfc: str | None) -> Q:
"""Restringe el queryset a los importadores visibles del usuario."""
# SIN_RFC ya fue aplicado en _build_pedimento_filters como contribuyente__isnull=True
if requested_rfc == 'SIN_RFC':
return q
user_rfcs = user.rfc.all()
if not user_rfcs.exists():
if requested_rfc:
q &= Q(contribuyente__rfc=requested_rfc)
return q
if requested_rfc:
if user_rfcs.filter(rfc=requested_rfc).exists():
q &= Q(contribuyente__rfc=requested_rfc)
else:
q &= Q(contribuyente__in=user_rfcs)
else:
q &= Q(contribuyente__in=user_rfcs)
return q
# ── tarea principal ───────────────────────────────────────────────────────────
@shared_task(bind=True, queue='reports', soft_time_limit=600, time_limit=660)
def generate_report_document(self, report_id):
task_id = self.request.id
report = None
def _fail(msg, exc=None):
"""Marca el reporte como error, notifica al frontend y loguea. Sin re-raise."""
tb = traceback.format_exc() if exc else ''
full_msg = f"{msg}\n\n{tb}".strip() if tb else msg
logger.error('[reporte_cumplimiento] report=%s FALLO: %s', report_id, full_msg)
if report:
report.status = 'error'
report.error_message = full_msg
report.finished_at = timezone.now()
report.save(update_fields=['status', 'error_message', 'finished_at'])
publish_task_event(task_id, 'failed', msg, progress=0)
# ── 1. Obtener reporte ────────────────────────────────────────────────────
try:
report = ReportDocument.objects.get(id=report_id)
report.status = 'processing'
report.save(update_fields=['status'])
filters = report.filters or {}
pedimentos_filters = Q()
if filters.get('organizacion_id'):
pedimentos_filters &= Q(organizacion_id=filters['organizacion_id'])
if filters.get('fecha_pago__gte'):
pedimentos_filters &= Q(fecha_pago__gte=filters['fecha_pago__gte'])
if filters.get('fecha_pago__lte'):
pedimentos_filters &= Q(fecha_pago__lte=filters['fecha_pago__lte'])
if filters.get('contribuyente__rfc'):
pedimentos_filters &= Q(contribuyente__rfc=filters['contribuyente__rfc'])
if filters.get('patente'):
pedimentos_filters &= Q(patente=filters['patente'])
if filters.get('aduana'):
pedimentos_filters &= Q(aduana=filters['aduana'])
if filters.get('pedimento'):
pedimentos_filters &= Q(pedimento=filters['pedimento'])
if filters.get('pedimento_app'):
pedimentos_filters &= Q(pedimento_app=filters['pedimento_app'])
if filters.get('regimen'):
pedimentos_filters &= Q(regimen=filters['regimen'])
if filters.get('tipo_operacion'):
pedimentos_filters &= Q(tipo_operacion_id=filters['tipo_operacion'])
# Consulta asíncrona de los modelos
pedimentos = Pedimento.objects.filter(pedimentos_filters)
filename = filters.get('filename')
if filename:
filename = f"{filename}.csv" if not filename.endswith('.csv') else filename
else:
filename = f"report_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}.csv"
except ReportDocument.DoesNotExist:
logger.error('[reporte_cumplimiento] ReportDocument %s no existe', report_id)
publish_task_event(task_id, 'failed', f'Reporte {report_id} no encontrado', progress=0)
return
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.csv', encoding='utf-8', newline='') as f:
tmp_path = f.name
# Escribir CSV en archivo temporal
with open(tmp_path, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
headers = [
'aduana', 'patente', 'regimen', 'pedimento', 'pedimento_app', 'clave_pedimento',
'tipo_operacion_id', 'contribuyente_id', 'tipo_documento', 'numero_documento', 'estado', 'acuse_estado'
]
writer.writerow(headers)
for ped in pedimentos:
for cove in Cove.objects.filter(pedimento=ped):
writer.writerow([
ped.aduana, ped.patente, ped.regimen, ped.pedimento, ped.pedimento_app,
ped.clave_pedimento, ped.tipo_operacion_id, ped.contribuyente_id,
'COVE', cove.numero_cove, cove.cove_descargado, cove.acuse_cove_descargado
])
for edoc in EDocument.objects.filter(pedimento=ped):
writer.writerow([
ped.aduana, ped.patente, ped.regimen, ped.pedimento, ped.pedimento_app,
ped.clave_pedimento, ped.tipo_operacion_id, ped.contribuyente_id,
'EDOC', edoc.numero_edocument, edoc.edocument_descargado, edoc.acuse_descargado
])
for partida in Partida.objects.filter(pedimento=ped):
writer.writerow([
ped.aduana, ped.patente, ped.regimen, ped.pedimento, ped.pedimento_app,
ped.clave_pedimento, ped.tipo_operacion_id, ped.contribuyente_id,
'PARTIDA', partida.numero_partida, partida.descargado, ''
])
# ============ NUEVO: Guardar en MinIO ============
# Leer archivo temporal
with open(tmp_path, 'rb') as f:
file_content = f.read()
# Crear UploadedFile
uploaded_file = SimpleUploadedFile(
name=filename,
content=file_content,
content_type='text/csv'
logger.info('[reporte_cumplimiento] Iniciando report=%s user=%s', report_id, report.user_id)
report.status = 'processing'
report.save(update_fields=['status'])
publish_task_event(task_id, 'processing', 'Iniciando generación de reporte...', progress=5)
try:
filters = report.filters or {}
org_id = filters.get('organizacion_id')
# ── 2. Filtros y organización ─────────────────────────────────────────
q = _build_pedimento_filters(filters)
q = _apply_user_rfc_filter(q, report.user, filters.get('contribuyente__rfc'))
nombre_org = ''
if org_id:
try:
nombre_org = Organizacion.objects.get(id=org_id).nombre
except Organizacion.DoesNotExist:
pass
logger.info('[reporte_cumplimiento] report=%s org=%s filtros=%s', report_id, nombre_org, filters)
publish_task_event(task_id, 'processing', f'Consultando RFCs de {nombre_org}...', progress=10)
# ── 3. Listar RFCs (consulta liviana) ────────────────────────────────
rfcs_list = list(
Pedimento.objects.filter(q)
.exclude(contribuyente__isnull=True)
.values_list('contribuyente__rfc', flat=True)
.distinct()
.order_by('contribuyente__rfc')
)
# Guardar en storage
if Pedimento.objects.filter(q, contribuyente__isnull=True).exists():
rfcs_list.append('SIN_RFC')
total_rfcs = len(rfcs_list)
total_pedimentos = Pedimento.objects.filter(q).count()
logger.info('[reporte_cumplimiento] report=%s total_rfcs=%d total_pedimentos=%d',
report_id, total_rfcs, total_pedimentos)
if total_rfcs == 0:
logger.warning('[reporte_cumplimiento] report=%s sin pedimentos para los filtros dados', report_id)
publish_task_event(
task_id, 'processing',
f'{total_rfcs} RFC(s) — {total_pedimentos} pedimentos', progress=15,
)
# ── 4. Crear workbook ─────────────────────────────────────────────────
wb = openpyxl.Workbook()
ws = wb.active
ws.title = 'Reporte Cumplimiento'
title_fill = PatternFill(start_color='1F4E79', end_color='1F4E79', fill_type='solid')
title_font = Font(color='FFFFFF', bold=True, size=12)
sub_fill = PatternFill(start_color='2E75B6', end_color='2E75B6', fill_type='solid')
sub_font = Font(color='FFFFFF', bold=True, size=10)
col_h_fill = PatternFill(start_color='D6E4F0', end_color='D6E4F0', fill_type='solid')
col_h_font = Font(bold=True, size=10)
footer_fill = PatternFill(start_color='E2EFDA', end_color='E2EFDA', fill_type='solid')
center = Alignment(horizontal='center', vertical='center', wrap_text=True)
top_left = Alignment(horizontal='left', vertical='top', wrap_text=True)
COL_HEADERS = [
'Año', 'Aduana', 'Patente', 'Pedimento',
'Nomenclatura Completo Pedimento', 'Clav', 'Tipo Operación',
'Expediente Sí', 'Documento', 'Estatus',
]
TOTAL_COLS = len(COL_HEADERS)
current_row = 1
safe_total = max(total_rfcs, 1)
# ── 5. Procesar RFC por RFC ───────────────────────────────────────────
for rfc_idx, rfc in enumerate(rfcs_list):
pct = 20 + int((rfc_idx / safe_total) * 65)
publish_task_event(
task_id, 'processing',
f'RFC {rfc_idx + 1}/{total_rfcs}: {rfc}', progress=pct,
)
rfc_q = (
q & Q(contribuyente__isnull=True) if rfc == 'SIN_RFC'
else q & Q(contribuyente__rfc=rfc)
)
peds = list(
Pedimento.objects.filter(rfc_q)
.select_related('contribuyente', 'tipo_operacion')
.order_by('fecha_pago')
)
if not peds:
logger.warning('[reporte_cumplimiento] report=%s rfc=%s sin pedimentos, omitido', report_id, rfc)
continue
ped_ids = [p.id for p in peds]
razon_social = nombre_org or 'Desconocido'
logger.info('[reporte_cumplimiento] report=%s rfc=%s pedimentos=%d',
report_id, rfc, len(peds))
# documentos de este RFC solamente
coves_map: dict = defaultdict(list)
for c in Cove.objects.filter(pedimento_id__in=ped_ids):
coves_map[c.pedimento_id].append(c)
edocs_map: dict = defaultdict(list)
for e in EDocument.objects.filter(pedimento_id__in=ped_ids):
edocs_map[e.pedimento_id].append(e)
partidas_map: dict = defaultdict(list)
for p in Partida.objects.filter(pedimento_id__in=ped_ids).order_by('numero_partida'):
partidas_map[p.pedimento_id].append(p)
remesa_ped_ids: set = set(
Document.objects.filter(pedimento_id__in=ped_ids, document_type_id=15)
.values_list('pedimento_id', flat=True)
)
total_coves = sum(len(v) for v in coves_map.values())
total_edocs = sum(len(v) for v in edocs_map.values())
total_partidas = sum(len(v) for v in partidas_map.values())
est_rows = len(peds) + total_partidas + total_coves * 2 + total_edocs * 2 + len(remesa_ped_ids)
logger.info('[reporte_cumplimiento] report=%s rfc=%s docs coves=%d edocs=%d partidas=%d remesas=%d filas_estimadas=%d',
report_id, rfc, total_coves, total_edocs, total_partidas, len(remesa_ped_ids), est_rows)
# encabezado sección
ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=TOTAL_COLS)
cell = ws.cell(row=current_row, column=1, value='Reporte Integración de Expedientes.')
cell.fill, cell.font, cell.alignment = title_fill, title_font, center
current_row += 1
ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=TOTAL_COLS)
cell = ws.cell(row=current_row, column=1, value=f'Razón Social Importador: {razon_social}')
cell.fill, cell.font = sub_fill, sub_font
current_row += 1
ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=TOTAL_COLS)
cell = ws.cell(row=current_row, column=1, value=f'RFC: {rfc}')
cell.fill, cell.font = sub_fill, sub_font
current_row += 1
for col_i, header in enumerate(COL_HEADERS, 1):
cell = ws.cell(row=current_row, column=col_i, value=header)
cell.fill, cell.font, cell.alignment = col_h_fill, col_h_font, center
current_row += 1
total_exp = len(peds)
exp_con_docs = exp_completos = 0
for ped in peds:
doc_rows = [('PEDIMENTO COMPLETO', _estado(ped.existe_expediente))]
for partida in partidas_map[ped.id]:
doc_rows.append((f'PARTIDA{partida.numero_partida}', _estado(partida.descargado)))
if ped.remesas:
doc_rows.append(('REMESA', _estado(ped.id in remesa_ped_ids)))
for cove in coves_map[ped.id]:
doc_rows.append((f'COVE{cove.numero_cove}', _estado(cove.cove_descargado)))
doc_rows.append((f'ACUSE COVE{cove.numero_cove}', _estado(cove.acuse_cove_descargado)))
for edoc in edocs_map[ped.id]:
doc_rows.append((f'EDOCUMENTO{edoc.numero_edocument}', _estado(edoc.edocument_descargado)))
doc_rows.append((f'ACUSE EDOCUMENTO{edoc.numero_edocument}', _estado(edoc.acuse_descargado)))
if len(doc_rows) > 1:
exp_con_docs += 1
if all(e == 'RECUPERADO' for _, e in doc_rows):
exp_completos += 1
n_rows = len(doc_rows)
start_row = current_row
anio = ped.fecha_pago.year % 100 if ped.fecha_pago else ''
base_vals = [
anio, ped.aduana or '', ped.patente or '', ped.pedimento or '',
ped.pedimento_app or '', ped.clave_pedimento or '',
ped.tipo_operacion.tipo if ped.tipo_operacion else '',
'SI' if ped.existe_expediente else 'NO',
]
# Sin merge_cells — para datasets grandes merge es O(n^2) y cuelga el proceso.
# Los datos base solo se escriben en la primera fila; el resto queda vacío,
# visualmente equivalente al merge pero sin el costo de memoria/CPU.
for offset, (doc_nombre, doc_est) in enumerate(doc_rows):
r = start_row + offset
if offset == 0:
for col, val in enumerate(base_vals, 1):
ws.cell(row=r, column=col, value=val)
ws.cell(row=r, column=9, value=doc_nombre)
ws.cell(row=r, column=10, value=doc_est)
current_row += n_rows
ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=TOTAL_COLS)
cell = ws.cell(
row=current_row, column=1,
value=(f'Total de Expedientes= {total_exp} '
f'Total De Expedientes Con Documentos= {exp_con_docs} '
f'Total De Expedientes Completos= {exp_completos}'),
)
cell.fill = footer_fill
cell.font = Font(bold=True)
current_row += 2
del peds, ped_ids, coves_map, edocs_map, partidas_map, remesa_ped_ids
for i, w in enumerate([6, 8, 8, 12, 32, 8, 16, 12, 32, 14], 1):
ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = w
# ── 6. Serializar y subir ─────────────────────────────────────────────
logger.info('[reporte_cumplimiento] report=%s serializando Excel...', report_id)
publish_task_event(task_id, 'processing', 'Serializando Excel...', progress=88)
filename = f"reporte_cumplimiento_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}.xlsx"
buf = io.BytesIO()
wb.save(buf)
excel_bytes = buf.getvalue()
logger.info('[reporte_cumplimiento] report=%s Excel size=%.1fKB', report_id, len(excel_bytes) / 1024)
publish_task_event(task_id, 'processing', 'Subiendo a almacenamiento...', progress=93)
ruta = storage_service.save_report(
file=uploaded_file,
organizacion_id=filters.get('organizacion_id'),
file=SimpleUploadedFile(
name=filename,
content=excel_bytes,
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
),
organizacion_id=org_id,
metadata={
'report_id': str(report.id),
'report_type': 'cumplimiento',
'user_id': str(report.user.id) if report.user else None
}
'user_id': str(report.user.id) if report.user else None,
},
)
if ruta:
report.file = ruta
logger.info('[reporte_cumplimiento] report=%s guardado en storage=%s', report_id, ruta)
report.file = ruta
report.status = 'ready'
else:
report.status = 'error'
report.error_message = 'Error al guardar el archivo en storage'
# Limpiar temporal
os.unlink(tmp_path)
_fail('Error al guardar el archivo en almacenamiento (storage retornó None)')
return
report.finished_at = timezone.now()
report.save(update_fields=['status', 'file', 'finished_at', 'error_message'])
except Exception as e:
report.status = 'error'
report.error_message = str(e)
report.finished_at = timezone.now()
report.save(update_fields=['status', 'error_message', 'finished_at'])
resultado = {
'report_id': str(report.id),
'total_rfcs': total_rfcs,
'total_pedimentos': total_pedimentos,
}
publish_task_event(task_id, 'completed', 'Reporte generado exitosamente.', progress=100, resultado=resultado)
logger.info('[reporte_cumplimiento] report=%s COMPLETADO rfcs=%d pedimentos=%d',
report_id, total_rfcs, total_pedimentos)
return resultado
except SoftTimeLimitExceeded:
_fail('El reporte tardó más de 10 minutos y fue cancelado. Intenta con un rango de fechas más acotado.')
except Exception as exc:
_fail(str(exc), exc=exc)
# ── reporte de control de pedimentos (sin cambios) ────────────────────────────
@shared_task
def generate_report_control_pedimento(report_id):
@@ -133,8 +378,6 @@ def generate_report_control_pedimento(report_id):
report.save(update_fields=['status'])
filters = report.filters or {}
# Construir filtros
pedimentos_filters = {}
if filters.get('organizacion_id'):
pedimentos_filters['organizacion_id'] = filters['organizacion_id']
@@ -145,15 +388,12 @@ def generate_report_control_pedimento(report_id):
if filters.get('pedimento_app'):
pedimentos_filters['pedimento_app'] = filters['pedimento_app']
# pedimentos por organizacion
pedimentos_qs = Pedimento.objects.filter(**pedimentos_filters)
pedimentos_total = pedimentos_qs.count()
pedimento_ids = list(pedimentos_qs.values_list('id', flat=True))
rfcs_raw = list(pedimentos_qs.values_list('agente_aduanal', flat=True))
# inicializar totales
pedimentos_completos = 0
total_documentos = 0
documentos_sin_descargar = 0
@@ -161,17 +401,15 @@ def generate_report_control_pedimento(report_id):
nombre_organizacion = ''
if filters.get('organizacion_id'):
try:
# Asumo que tienes un modelo Organizacion - ajusta según tu modelo real
organizacion = Organizacion.objects.get(id=filters['organizacion_id'])
nombre_organizacion = organizacion.nombre # ajusta el campo según tu modelo
nombre_organizacion = organizacion.nombre
except Organizacion.DoesNotExist:
nombre_organizacion = f"ID: {filters['organizacion_id']}"
except Exception as e:
nombre_organizacion = f"Error: {str(e)}"
# lista de rfc
rfc_list = ', '.join(sorted(set([rfc for rfc in rfcs_raw if rfc])))
fecha_inicio = ''
fecha_fin = ''
@@ -179,109 +417,78 @@ def generate_report_control_pedimento(report_id):
primer_pedimento = pedimentos_qs.order_by('fecha_pago').first()
if primer_pedimento and primer_pedimento.fecha_pago:
fecha_inicio = primer_pedimento.fecha_pago.strftime('%Y-%m-%d')
ultimo_pedimento = pedimentos_qs.order_by('-fecha_pago').first()
if ultimo_pedimento and ultimo_pedimento.fecha_pago:
fecha_fin = ultimo_pedimento.fecha_pago.strftime('%Y-%m-%d')
# Para cada pedimento, verificar si está completo
for pedimento in pedimentos_qs:
# Contar documentos de este pedimento
docs_pedimento = 0
docs_pendientes_pedimento = 0
# COVES
coves_count = Cove.objects.filter(pedimento_id=pedimento.id).count()
coves_pendientes = Cove.objects.filter(pedimento_id=pedimento.id, cove_descargado=False).count()
docs_pedimento += coves_count
docs_pendientes_pedimento += coves_pendientes
# PARTIDAS
partidas_count = Partida.objects.filter(pedimento_id=pedimento.id).count()
partidas_pendientes = Partida.objects.filter(pedimento_id=pedimento.id, descargado=False).count()
docs_pedimento += partidas_count
docs_pendientes_pedimento += partidas_pendientes
# EDOCUMENTS
edocs_count = EDocument.objects.filter(pedimento_id=pedimento.id).count()
edocs_pendientes = EDocument.objects.filter(pedimento_id=pedimento.id, edocument_descargado=False).count()
docs_pedimento += edocs_count
docs_pendientes_pedimento += edocs_pendientes
# Acumular totales
total_documentos += docs_pedimento
documentos_sin_descargar += docs_pendientes_pedimento
# Si no tiene documentos pendientes, está completo
if docs_pendientes_pedimento == 0 and docs_pedimento > 0:
pedimentos_completos += 1
# 3. PORCENTAJE
porcentaje_faltantes = (documentos_sin_descargar / total_documentos * 100) if total_documentos > 0 else 0
# 4. GENERAR CSV CON DETALLES
filename = f"report_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}.csv"
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.csv', encoding='utf-8', newline='') as tmp:
tmp_path = tmp.name
todas_las_filas = []
# Recopilar datos detallados - UNA FILA POR CADA DOCUMENTO
for pedimento in pedimentos_qs:
# DATOS BASE DEL PEDIMENTO (se repiten en cada fila)
datos_base_pedimento = [
pedimento.aduana or '',
pedimento.patente or '',
pedimento.regimen or '',
pedimento.pedimento or '', # No. Pedimento (7 dígitos)
pedimento.pedimento_app or '', # No. Pedimento App completo
pedimento.pedimento or '',
pedimento.pedimento_app or '',
pedimento.clave_pedimento or '',
pedimento.tipo_operacion.tipo if pedimento.tipo_operacion else '',
str(pedimento.contribuyente_id) if pedimento.contribuyente_id else ''
]
# COVES - Una fila por cada COVE
coves = Cove.objects.filter(pedimento_id=pedimento.id)
for cove in coves:
estado = 'VERDADERO' if cove.cove_descargado else 'FALSO'
fila = datos_base_pedimento + [
# str(cove.id), # Identificador de documento
cove.numero_cove,
'COVE', # Tipo de documento
estado
]
fila = datos_base_pedimento + [cove.numero_cove, 'COVE', estado]
todas_las_filas.append(fila)
# PARTIDAS - Una fila por cada Partida
partidas = Partida.objects.filter(pedimento_id=pedimento.id)
for partida in partidas:
estado = 'VERDADERO' if partida.descargado else 'FALSO'
fila = datos_base_pedimento + [
# str(partida.id),
partida.numero_partida,
'PARTIDA', # Tipo de documento
estado
]
fila = datos_base_pedimento + [partida.numero_partida, 'PARTIDA', estado]
todas_las_filas.append(fila)
# EDOCUMENTS - Una fila por cada EDocument
edocuments = EDocument.objects.filter(pedimento_id=pedimento.id)
for edoc in edocuments:
estado = 'VERDADERO' if edoc.edocument_descargado else 'FALSO'
fila = datos_base_pedimento + [
# str(edoc.id),
edoc.numero_edocument,
'EDOCUMENT', # Tipo de documento
estado
]
fila = datos_base_pedimento + [edoc.numero_edocument, 'EDOCUMENT', estado]
todas_las_filas.append(fila)
# 5. ESCRIBIR ARCHIVO CSV
import csv
with open(tmp_path, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
# SECCIÓN DE TOTALES
writer.writerow(['RESUMEN DEL REPORTE - CONTROL DE PEDIMENTOS'])
writer.writerow(['ORGANIZACION:', nombre_organizacion])
writer.writerow([])
@@ -294,20 +501,15 @@ def generate_report_control_pedimento(report_id):
writer.writerow(['LISTA RFC:', rfc_list])
writer.writerow([])
writer.writerow([])
# ENCABEZADOS DE DATOS (según requerimiento)
headers = [
'ADUANA', 'PATENTE', 'REGIMEN', 'NO. PEDIMENTO', 'PEDIMENTO_APP',
'CLAVE_PEDIMENTO', 'TIPO_OPERACION', 'CONTRIBUYENTE_ID',
'ADUANA', 'PATENTE', 'REGIMEN', 'NO. PEDIMENTO', 'PEDIMENTO_APP',
'CLAVE_PEDIMENTO', 'TIPO_OPERACION', 'CONTRIBUYENTE_ID',
'IDENTIFICADOR_DOCUMENTO', 'TIPO_DOCUMENTO', 'ESTADO'
]
writer.writerow(headers)
# DATOS DETALLADOS
for fila in todas_las_filas:
writer.writerow(fila)
with open(tmp_path, 'rb') as f:
file_content = f.read()
@@ -344,4 +546,4 @@ def generate_report_control_pedimento(report_id):
report.status = 'error'
report.error_message = str(e)
report.finished_at = timezone.now()
report.save(update_fields=['status', 'error_message', 'finished_at'])
report.save(update_fields=['status', 'error_message', 'finished_at'])

View File

@@ -1,3 +1,446 @@
"""
Tests para generate_report_document (T2026-04-001).
Ejecución:
python manage.py test api.reports.tests
python manage.py test api.reports.tests.TestEstadoHelper
"""
import io
import uuid
from unittest.mock import MagicMock, call, patch
import openpyxl
from django.contrib.auth import get_user_model
from django.db.models import Q
from django.test import TestCase
# Create your tests here.
from api.customs.models import Cove, EDocument, Importador, Partida, Pedimento
from api.licence.models import Licencia
from api.organization.models import Organizacion
from api.reports.models import ReportDocument
from api.reports.tasks.report_document import (
_apply_user_rfc_filter,
_estado,
generate_report_document,
)
User = get_user_model()
FAKE_PATH = 'reports/test/reporte.xlsx'
# ── fixtures ──────────────────────────────────────────────────────────────────
def _licencia(nombre='Plan Test'):
return Licencia.objects.create(nombre=nombre, almacenamiento=10)
def _org(nombre='Org Test'):
lic = _licencia(f'Lic {nombre}')
return Organizacion.objects.create(nombre=nombre, is_active=True, is_verified=True, licencia=lic)
def _user(org, username='tuser', rfcs=None):
u = User.objects.create_user(username=username, password='pass', organizacion=org)
if rfcs:
u.rfc.set(rfcs)
return u
def _imp(org, rfc='RFC000000001', nombre='Importador Test'):
return Importador.objects.create(rfc=rfc, nombre=nombre, organizacion=org)
def _ped(org, imp=None, num='0000001'):
return Pedimento.objects.create(
pedimento=num,
pedimento_app=f'25-160-3910-{num}',
organizacion=org,
contribuyente=imp,
aduana='160',
patente='3910',
regimen='ITE',
clave_pedimento='A1',
)
def _reporte(user, org_id, extra=None):
filtros = {'organizacion_id': str(org_id)}
if extra:
filtros.update(extra)
return ReportDocument.objects.create(
user=user, filters=filtros, status='pending', report_type='cumplimiento'
)
def _excel_desde_mock(mock_save):
"""Parsea el workbook que recibió storage_service.save_report."""
uf = mock_save.call_args[1]['file']
return openpyxl.load_workbook(io.BytesIO(uf.read()))
def _docs_col(ws):
"""Devuelve {documento: estatus} leyendo columnas 9 y 10 del worksheet."""
return {
ws.cell(row=r, column=9).value: ws.cell(row=r, column=10).value
for r in range(1, ws.max_row + 1)
if ws.cell(row=r, column=9).value
}
def _col1_values(ws):
"""Devuelve todos los valores no vacíos de la columna 1."""
return [
str(ws.cell(row=r, column=1).value)
for r in range(1, ws.max_row + 1)
if ws.cell(row=r, column=1).value
]
# ── 1. Helpers ────────────────────────────────────────────────────────────────
class TestEstadoHelper(TestCase):
def test_true_retorna_recuperado(self):
self.assertEqual(_estado(True), 'RECUPERADO')
def test_false_retorna_pendiente(self):
self.assertEqual(_estado(False), 'PENDIENTE')
# ── 2. Filtro de RFC por usuario ──────────────────────────────────────────────
class TestApplyUserRfcFilter(TestCase):
@classmethod
def setUpTestData(cls):
cls.org = _org()
cls.imp1 = _imp(cls.org, rfc='RFC000000001')
cls.imp2 = _imp(cls.org, rfc='RFC000000002')
def test_sin_rfcs_asignados_sin_filtro_retorna_q_vacio(self):
user = _user(self.org, username='u_admin')
q = _apply_user_rfc_filter(Q(), user, None)
self.assertEqual(str(q), str(Q()))
def test_sin_rfcs_asignados_con_filtro_explicito_aplica_filtro(self):
user = _user(self.org, username='u_admin2')
q = _apply_user_rfc_filter(Q(), user, 'RFC000000001')
self.assertIn('RFC000000001', str(q))
def test_con_rfcs_sin_filtro_restringe_a_sus_importadores(self):
user = _user(self.org, username='u_imp1', rfcs=[self.imp1])
q = _apply_user_rfc_filter(Q(), user, None)
self.assertIn('contribuyente', str(q))
def test_con_rfcs_pide_el_suyo_se_filtra_por_ese_rfc(self):
user = _user(self.org, username='u_imp2', rfcs=[self.imp1])
q = _apply_user_rfc_filter(Q(), user, 'RFC000000001')
self.assertIn('RFC000000001', str(q))
def test_con_rfcs_pide_ajeno_se_usa_el_suyo_no_el_solicitado(self):
user = _user(self.org, username='u_imp3', rfcs=[self.imp1])
q = _apply_user_rfc_filter(Q(), user, 'RFC000000002')
self.assertNotIn('RFC000000002', str(q))
self.assertIn('contribuyente', str(q))
# ── 3. Tarea completa ─────────────────────────────────────────────────────────
# Todos los tests en esta clase mockean Redis (publish_task_event) y MinIO
# (storage_service.save_report) para no depender de infraestructura externa.
@patch('api.reports.tasks.report_document.publish_task_event')
@patch('api.reports.tasks.report_document.storage_service.save_report',
return_value=FAKE_PATH)
class TestGenerateReportDocument(TestCase):
@classmethod
def setUpTestData(cls):
cls.org = _org('Org Reporte')
cls.imp = _imp(cls.org, rfc='MTK8610143000', nombre='Servicios TETAKAWI')
cls.user = _user(cls.org, username='rep_user')
def _run(self, report):
generate_report_document.apply(args=[str(report.id)])
report.refresh_from_db()
# ── 3.1 Sin pedimentos ────────────────────────────────────────────────────
def test_sin_pedimentos_genera_excel_vacio_y_status_ready(self, mock_save, mock_pub):
report = _reporte(self.user, self.org.id)
self._run(report)
self.assertEqual(report.status, 'ready')
self.assertEqual(report.file, FAKE_PATH)
mock_save.assert_called_once()
# El workbook no debe tener datos de RFCs
wb = _excel_desde_mock(mock_save)
ws = wb.active
col1 = _col1_values(ws)
self.assertFalse(col1, 'Excel vacío no debe tener contenido en col 1')
# ── 3.2 RFC aparece en encabezado ─────────────────────────────────────────
def test_rfc_del_importador_aparece_en_excel(self, mock_save, mock_pub):
_ped(self.org, self.imp, '1000001')
report = _reporte(self.user, self.org.id)
self._run(report)
self.assertEqual(report.status, 'ready')
wb = _excel_desde_mock(mock_save)
ws = wb.active
col1 = ' '.join(_col1_values(ws))
self.assertIn('MTK8610143000', col1)
# ── 3.3 PEDIMENTO COMPLETO ────────────────────────────────────────────────
def test_pedimento_completo_recuperado_cuando_existe_expediente(self, mock_save, mock_pub):
ped = _ped(self.org, self.imp, '1000002')
ped.existe_expediente = True
ped.save(update_fields=['existe_expediente'])
report = _reporte(self.user, self.org.id)
self._run(report)
docs = _docs_col(_excel_desde_mock(mock_save).active)
self.assertEqual(docs.get('PEDIMENTO COMPLETO'), 'RECUPERADO')
def test_pedimento_completo_pendiente_cuando_no_tiene_expediente(self, mock_save, mock_pub):
_ped(self.org, self.imp, '1000003') # existe_expediente=False por default
report = _reporte(self.user, self.org.id)
self._run(report)
docs = _docs_col(_excel_desde_mock(mock_save).active)
self.assertEqual(docs.get('PEDIMENTO COMPLETO'), 'PENDIENTE')
# ── 3.4 Partidas ──────────────────────────────────────────────────────────
def test_partidas_con_estado_correcto(self, mock_save, mock_pub):
ped = _ped(self.org, self.imp, '1000004')
Partida.objects.create(
pedimento=ped, organizacion=self.org, numero_partida=1, descargado=True
)
Partida.objects.create(
pedimento=ped, organizacion=self.org, numero_partida=2, descargado=False
)
report = _reporte(self.user, self.org.id)
self._run(report)
docs = _docs_col(_excel_desde_mock(mock_save).active)
self.assertEqual(docs.get('PARTIDA1'), 'RECUPERADO')
self.assertEqual(docs.get('PARTIDA2'), 'PENDIENTE')
# ── 3.5 COVEs y acuses ────────────────────────────────────────────────────
def test_cove_y_acuse_con_estados_distintos(self, mock_save, mock_pub):
ped = _ped(self.org, self.imp, '1000005')
Cove.objects.create(
pedimento=ped, organizacion=self.org,
numero_cove='654001',
cove_descargado=True,
acuse_cove_descargado=False,
)
report = _reporte(self.user, self.org.id)
self._run(report)
docs = _docs_col(_excel_desde_mock(mock_save).active)
self.assertEqual(docs.get('COVE654001'), 'RECUPERADO')
self.assertEqual(docs.get('ACUSE COVE654001'), 'PENDIENTE')
# ── 3.6 EDocumentos y acuses ──────────────────────────────────────────────
def test_edocumento_y_acuse_con_estados_distintos(self, mock_save, mock_pub):
ped = _ped(self.org, self.imp, '1000006')
EDocument.objects.create(
pedimento=ped, organizacion=self.org,
numero_edocument='EDOC001',
edocument_descargado=False,
acuse_descargado=True,
)
report = _reporte(self.user, self.org.id)
self._run(report)
docs = _docs_col(_excel_desde_mock(mock_save).active)
self.assertEqual(docs.get('EDOCUMENTOEDOC001'), 'PENDIENTE')
self.assertEqual(docs.get('ACUSE EDOCUMENTOEDOC001'), 'RECUPERADO')
# ── 3.7 Remesa ────────────────────────────────────────────────────────────
def test_remesa_recuperada_cuando_document_tipo_15_existe(self, mock_save, mock_pub):
"""Pedimento.remesas=True y el query de Document devuelve el pedimento_id."""
ped = Pedimento.objects.create(
pedimento='1000007', pedimento_app='25-160-3910-1000007',
organizacion=self.org, contribuyente=self.imp,
aduana='160', patente='3910', remesas=True,
)
report = _reporte(self.user, self.org.id)
# Patch solo el query de Document dentro del task
with patch('api.reports.tasks.report_document.Document') as MockDoc:
mock_qs = MagicMock()
mock_qs.values_list.return_value = [ped.id]
MockDoc.objects.filter.return_value = mock_qs
self._run(report)
docs = _docs_col(_excel_desde_mock(mock_save).active)
self.assertEqual(docs.get('REMESA'), 'RECUPERADO')
def test_remesa_pendiente_cuando_no_hay_document(self, mock_save, mock_pub):
"""Pedimento.remesas=True pero el query de Document devuelve lista vacía."""
Pedimento.objects.create(
pedimento='1000008', pedimento_app='25-160-3910-1000008',
organizacion=self.org, contribuyente=self.imp,
aduana='160', patente='3910', remesas=True,
)
report = _reporte(self.user, self.org.id)
with patch('api.reports.tasks.report_document.Document') as MockDoc:
mock_qs = MagicMock()
mock_qs.values_list.return_value = []
MockDoc.objects.filter.return_value = mock_qs
self._run(report)
docs = _docs_col(_excel_desde_mock(mock_save).active)
self.assertEqual(docs.get('REMESA'), 'PENDIENTE')
def test_sin_remesa_no_aparece_fila_remesa(self, mock_save, mock_pub):
"""Pedimento.remesas=False → no debe aparecer fila REMESA."""
_ped(self.org, self.imp, '1000009') # remesas=False por default
report = _reporte(self.user, self.org.id)
self._run(report)
docs = _docs_col(_excel_desde_mock(mock_save).active)
self.assertNotIn('REMESA', docs)
# ── 3.8 Múltiples RFCs ───────────────────────────────────────────────────
def test_multiples_rfcs_generan_secciones_separadas(self, mock_save, mock_pub):
imp2 = _imp(self.org, rfc='TEC140624802', nombre='TEC Importaciones')
_ped(self.org, self.imp, '1000010')
_ped(self.org, imp2, '1000011')
report = _reporte(self.user, self.org.id)
self._run(report)
self.assertEqual(report.status, 'ready')
contenido = ' '.join(_col1_values(_excel_desde_mock(mock_save).active))
self.assertIn('MTK8610143000', contenido)
self.assertIn('TEC140624802', contenido)
# ── 3.9 Restricción por RFC de usuario ───────────────────────────────────
def test_importador_solo_ve_sus_pedimentos(self, mock_save, mock_pub):
imp2 = _imp(self.org, rfc='XYZ999999999', nombre='Externo')
_ped(self.org, self.imp, '1000012')
_ped(self.org, imp2, '1000013')
user_restr = _user(self.org, username='u_restr', rfcs=[self.imp])
report = _reporte(user_restr, self.org.id)
self._run(report)
self.assertEqual(report.status, 'ready')
contenido = ' '.join(_col1_values(_excel_desde_mock(mock_save).active))
self.assertIn('MTK8610143000', contenido)
self.assertNotIn('XYZ999999999', contenido)
# ── 3.10 Formato del archivo ──────────────────────────────────────────────
def test_archivo_descargado_es_xlsx_valido(self, mock_save, mock_pub):
_ped(self.org, self.imp, '1000014')
report = _reporte(self.user, self.org.id)
self._run(report)
uf = mock_save.call_args[1]['file']
self.assertTrue(uf.name.endswith('.xlsx'), f'Esperado .xlsx, recibido: {uf.name}')
try:
wb = openpyxl.load_workbook(io.BytesIO(uf.read()))
self.assertIsNotNone(wb)
except Exception as exc:
self.fail(f'Excel no es válido: {exc}')
def test_cabeceras_de_columna_presentes(self, mock_save, mock_pub):
_ped(self.org, self.imp, '1000015')
report = _reporte(self.user, self.org.id)
self._run(report)
ws = _excel_desde_mock(mock_save).active
cabeceras = None
for r in range(1, ws.max_row + 1):
if ws.cell(row=r, column=1).value == 'Año':
cabeceras = [ws.cell(row=r, column=c).value for c in range(1, 11)]
break
self.assertIsNotNone(cabeceras, 'No se encontró la fila de cabeceras')
for col in ('Año', 'Aduana', 'Patente', 'Pedimento', 'Documento', 'Estatus'):
self.assertIn(col, cabeceras, f'Cabecera "{col}" no encontrada')
# ── 3.11 Progreso en Redis ────────────────────────────────────────────────
def test_se_publican_eventos_de_progreso(self, mock_save, mock_pub):
_ped(self.org, self.imp, '1000016')
report = _reporte(self.user, self.org.id)
self._run(report)
self.assertGreaterEqual(mock_pub.call_count, 4, 'Se esperan mínimo 4 eventos')
def test_ultimo_evento_es_completed_con_100(self, mock_save, mock_pub):
_ped(self.org, self.imp, '1000017')
report = _reporte(self.user, self.org.id)
self._run(report)
ultimo = mock_pub.call_args_list[-1]
self.assertEqual(ultimo[0][1], 'completed')
self.assertEqual(ultimo[1].get('progress'), 100)
# ── 3.12 Manejo de errores ────────────────────────────────────────────────
def test_storage_none_deja_status_error(self, mock_save, mock_pub):
"""storage_service.save_report retorna None → report queda en error."""
mock_save.return_value = None
_ped(self.org, self.imp, '1000018')
report = _reporte(self.user, self.org.id)
self._run(report)
self.assertEqual(report.status, 'error')
self.assertIn('almacenamiento', report.error_message)
def test_storage_none_publica_evento_failed(self, mock_save, mock_pub):
mock_save.return_value = None
_ped(self.org, self.imp, '1000019')
report = _reporte(self.user, self.org.id)
self._run(report)
statuses = [c[0][1] for c in mock_pub.call_args_list]
self.assertIn('failed', statuses)
self.assertNotIn('completed', statuses)
def test_excepcion_guarda_traceback_en_error_message(self, mock_save, mock_pub):
"""Una excepción inesperada debe incluir traceback en error_message."""
mock_save.side_effect = RuntimeError('Fallo simulado de MinIO')
_ped(self.org, self.imp, '1000020')
report = _reporte(self.user, self.org.id)
try:
generate_report_document.apply(args=[str(report.id)])
except RuntimeError:
pass # apply() re-raise la excepción
report.refresh_from_db()
self.assertEqual(report.status, 'error')
self.assertIn('Fallo simulado de MinIO', report.error_message)
self.assertIn('Traceback', report.error_message)
def test_excepcion_publica_evento_failed(self, mock_save, mock_pub):
mock_save.side_effect = RuntimeError('Error MinIO')
_ped(self.org, self.imp, '1000021')
report = _reporte(self.user, self.org.id)
try:
generate_report_document.apply(args=[str(report.id)])
except RuntimeError:
pass
statuses = [c[0][1] for c in mock_pub.call_args_list]
self.assertIn('failed', statuses)

View File

@@ -70,14 +70,13 @@ def table_summary(request):
status='pending',
report_type='cumplimiento'
)
generate_report_document.delay(report.id)
task = generate_report_document.delay(report.id)
return Response({
"report_id": report.id,
"task_id": task.id,
"status": report.status,
"created_at": report.created_at,
# "download_url": report.file.url if report.file else None
"download_url": storage_service.get_file_url(report.file) if report.file else None
"download_url": storage_service.get_file_url(report.file) if report.file else None,
}, status=202)
@api_view(['GET'])
@@ -127,7 +126,7 @@ def report_document_download(request, report_id):
return Response({"error": "El archivo aún no está disponible"}, status=404)
ruta = str(report.file)
with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:
with tempfile.NamedTemporaryFile(delete=False, suffix='.xlsx') as tmp:
tmp_path = tmp.name
success = storage_service.download_file(ruta, tmp_path)