feature/reporte-saldos-vencimiento
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
FastAPI routes for Reporte de Vencimiento.
|
||||
"""
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, validate_access_to_resource
|
||||
|
||||
from .schemas import VencimientoFilter
|
||||
from .service import VencimientoReportService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["Reports - Vencimiento"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/generate",
|
||||
summary="Generate Reporte de Vencimiento CSV",
|
||||
)
|
||||
def generate_vencimiento_report(
|
||||
filters: VencimientoFilter = Body(...),
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Any = Depends(get_current_user),
|
||||
) -> StreamingResponse:
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
filters.company_id = company_id
|
||||
filters.tenant_id = tenant_id
|
||||
service = VencimientoReportService()
|
||||
return service.generate_csv_response(db=db, filters=filters)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Schemas for Reporte de Vencimiento.
|
||||
"""
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class VencimientoFilter(BaseModel):
|
||||
"""
|
||||
Filter parameters for the Reporte de Vencimiento CSV.
|
||||
Mirrors the UI from the legacy REPORTE DE VENCIMIENTO dialog.
|
||||
"""
|
||||
# Main parameter from the dialog header
|
||||
days_ahead: int = Field(default=0, ge=0, description="Facturas próximas a vencer en N días")
|
||||
|
||||
# TIPO DE MONEDA
|
||||
currency: str = Field(default="foreign", description="foreign | national")
|
||||
|
||||
# FILTRAR POR
|
||||
client_id: Optional[int] = Field(default=None, description="ID del cliente (sold_to_id)")
|
||||
|
||||
# OMITIR CANTIDADES
|
||||
min_balance: float = Field(default=0.0, description="Omitir balances menores a este valor (0 = no omitir)")
|
||||
|
||||
# FILTRO OPCIONAL
|
||||
conforme_anexo_31: bool = Field(default=False, description="Filtrar conforme al Anexo 31")
|
||||
usar_fecha_corte: bool = Field(default=False, description="Usar fecha de corte en lugar de end_date del pedimento")
|
||||
fecha_corte: Optional[str] = Field(default=None, description="Fecha de corte ISO (YYYY-MM-DD) cuando usar_fecha_corte=True")
|
||||
|
||||
# OUTPUT
|
||||
send_email: bool = Field(default=False)
|
||||
julian_date: bool = Field(default=False)
|
||||
|
||||
# Scoping (set by the route, NOT by UI)
|
||||
company_id: Optional[int] = None
|
||||
tenant_id: Optional[int] = None
|
||||
@@ -0,0 +1,412 @@
|
||||
"""
|
||||
Service for Reporte de Vencimiento CSV generation.
|
||||
|
||||
Translated from Clarion LLENA_SALDOS_VENC routine.
|
||||
|
||||
Logic:
|
||||
- Fetch all temporary import item lines whose pedimento end_date
|
||||
is <= baseline + days_ahead AND still have a positive balance.
|
||||
- baseline = fecha_corte (if usar_fecha_corte) else today.
|
||||
- No lower bound on end_date (mirrors Clarion FechaInicio = 1).
|
||||
- Output: CSV with 23 columns matching the legacy EXPORTAR A format.
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation
|
||||
from typing import Optional
|
||||
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .schemas import VencimientoFilter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decimal / format helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _d(value, decimals: int = 8) -> Decimal:
|
||||
try:
|
||||
exp = Decimal(10) ** -decimals
|
||||
return Decimal(str(value or 0)).quantize(exp, rounding=ROUND_HALF_UP)
|
||||
except (InvalidOperation, TypeError):
|
||||
return Decimal(0)
|
||||
|
||||
|
||||
def _fmt_date(val, julian: bool = False) -> str:
|
||||
"""Format date value.
|
||||
julian=False → MM/DD/YY (Clarion @D06)
|
||||
julian=True → raw ISO string as stored in DB
|
||||
"""
|
||||
if val is None:
|
||||
return ""
|
||||
if julian:
|
||||
return str(val) # ISO: 2025-03-15
|
||||
if hasattr(val, "strftime"):
|
||||
return val.strftime("%m/%d/%y")
|
||||
return str(val)
|
||||
|
||||
|
||||
def _fmt_num(d: Decimal) -> str:
|
||||
"""Format Decimal as plain fixed-point string (no scientific notation)."""
|
||||
if d is None:
|
||||
return ""
|
||||
# Use fixed-point format to prevent scientific notation for large values
|
||||
s = format(d, "f")
|
||||
if "." in s:
|
||||
s = s.rstrip("0").rstrip(".")
|
||||
return "0" if s in ("", "-0") else s
|
||||
|
||||
|
||||
def _text(val) -> str:
|
||||
"""Force a value to plain text with ' prefix so Excel never coerces it
|
||||
to a number (same as Clarion ''''&CLIP(...) pattern)."""
|
||||
return "'" + _clean(val)
|
||||
|
||||
|
||||
def _clean(text_val) -> str:
|
||||
"""Remove commas and newlines (SACARCOMASENTERS equivalent)."""
|
||||
return str(text_val or "").replace("\n", " ").replace("\r", "").replace(",", " ").strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SQL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_VENCIMIENTO_SQL = """
|
||||
SELECT
|
||||
ih.invoice_number AS "C1",
|
||||
ih.company_id AS "company_id",
|
||||
CONCAT(ped.year,'-',ped.license,'-',ped.pedimento_number) AS "C2",
|
||||
COALESCE(
|
||||
CONCAT(ped_r1.year,'-',ped_r1.license,'-',ped_r1.pedimento_number),
|
||||
''
|
||||
) AS "C_ped_r1",
|
||||
pd.payment_date AS "C7",
|
||||
pd.end_date AS "C_end_date",
|
||||
ih.invoice_date AS "C11",
|
||||
cl.id AS "C13_class_id",
|
||||
COALESCE(cl.us_fraction,'') AS "C_frac_ame",
|
||||
REPLACE(REPLACE(COALESCE(ild.description_english,''),CHR(10),''),CHR(13),' ') AS "C15",
|
||||
COALESCE(ilc.origin_country,'') AS "C16",
|
||||
COALESCE(ilq.quantity, 0) AS "C17",
|
||||
COALESCE(bal.qty_used, 0) AS "C18",
|
||||
COALESCE(uom.code,'') AS "C19",
|
||||
COALESCE(ilf.value_mxn, 0) AS "C20",
|
||||
COALESCE(bal.val_mn_used, 0) AS "C21",
|
||||
COALESCE(ilf.value_usd, 0) AS "C22",
|
||||
COALESCE(bal.val_me_used, 0) AS "C23",
|
||||
COALESCE(ilq.net_weight, 0) AS "C24",
|
||||
COALESCE(ilc.fraction,'') AS "C26",
|
||||
COALESCE(ilc.fraction_type,'') AS "C_frac_type",
|
||||
COALESCE(ilc.sector,'') AS "C_sector",
|
||||
COALESCE(p.part_number,'') AS "C36",
|
||||
COALESCE(p.commercial_part_number,'') AS "C_part_ref",
|
||||
icm.sold_to_id AS "C_sold_to_id"
|
||||
FROM a76.item_lines il
|
||||
JOIN a76.invoice_header ih ON ih.id = il.invoice_id
|
||||
JOIN a76.invoice_compliance_mx icm ON icm.invoice_id = ih.id
|
||||
LEFT JOIN a76.pedimentos ped ON ped.id = icm.pedimento_id
|
||||
LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = icm.pedimento_r1
|
||||
LEFT JOIN a76.pedimento_dates pd ON pd.pedimento_id = ped.id
|
||||
LEFT JOIN a76.classes cl ON cl.id = il.class_id
|
||||
LEFT JOIN a76.item_line_quantities ilq ON ilq.item_line_id = il.id
|
||||
LEFT JOIN a76.item_line_financials ilf ON ilf.item_line_id = il.id
|
||||
LEFT JOIN a76.item_line_customs ilc ON ilc.item_line_id = il.id
|
||||
LEFT JOIN a76.item_line_descriptions ild ON ild.item_line_id = il.id
|
||||
LEFT JOIN a76.parts p ON p.id = il.part_number_id
|
||||
LEFT JOIN a76.units_of_measure uom ON uom.id = il.unit_of_measure
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
import_item_line_id,
|
||||
SUM(CASE WHEN movement_type IN ('consumption','waste','scrap','destruction') THEN quantity
|
||||
WHEN movement_type = 'return' THEN -quantity
|
||||
ELSE 0 END) AS qty_used,
|
||||
SUM(CASE WHEN movement_type IN ('consumption','waste','scrap','destruction') THEN COALESCE(value_me, 0)
|
||||
WHEN movement_type = 'return' THEN -COALESCE(value_me, 0)
|
||||
ELSE 0 END) AS val_me_used,
|
||||
SUM(CASE WHEN movement_type IN ('consumption','waste','scrap','destruction') THEN COALESCE(value_mn, 0)
|
||||
WHEN movement_type = 'return' THEN -COALESCE(value_mn, 0)
|
||||
ELSE 0 END) AS val_mn_used
|
||||
FROM a24.balance_movement
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND (:bal_date_cutoff IS NULL OR operation_date <= :bal_date_cutoff)
|
||||
GROUP BY import_item_line_id
|
||||
) bal ON bal.import_item_line_id = il.id
|
||||
WHERE ih.tenant_id = :tenant_id
|
||||
AND ih.operation_type = 'imp'
|
||||
AND ih.company_id = :company_id
|
||||
AND COALESCE(ilq.quantity, 0) > 0
|
||||
AND COALESCE(ilq.quantity, 0) - COALESCE(bal.qty_used, 0) > 0
|
||||
AND pd.end_date <= :date_to
|
||||
{client_filter}
|
||||
{anexo31_filter}
|
||||
ORDER BY pd.end_date,
|
||||
CONCAT(ped.year, ped.customs_office, ped.license, ped.pedimento_number)
|
||||
"""
|
||||
|
||||
# CSV column headers (exact match to legacy EXPORTAR A)
|
||||
CSV_COLUMNS = [
|
||||
"Num. Factura Impo",
|
||||
"Pedimento",
|
||||
"Pedimento Rectificado",
|
||||
"Fecha Entrada",
|
||||
"Fecha Vencimiento",
|
||||
"Estatus/Dias",
|
||||
"Num. Parte",
|
||||
"Descripción",
|
||||
"Pais",
|
||||
"Cant. Original",
|
||||
"Cant. Usada",
|
||||
"Cant. Saldo",
|
||||
"U.M.",
|
||||
"Valor Original",
|
||||
"Valor Usado",
|
||||
"Valor Saldo",
|
||||
"Peso Original",
|
||||
"Peso Usado",
|
||||
"Peso Saldo",
|
||||
"Fraccion",
|
||||
"Tipo Fraccion",
|
||||
"Sector",
|
||||
"Fraccion Americana",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Company header (IMPRIMIR_EMPRESA equivalent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fetch_company_header_rows(db: Session, company_id: int) -> list[str]:
|
||||
"""
|
||||
Return a list of strings representing the company header lines.
|
||||
All fields are always emitted with their label, even when empty.
|
||||
"""
|
||||
row = db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
c.name, c.rfc, c.program, c.program_number,
|
||||
ca.street, ca.exterior_number, ca.neighborhood,
|
||||
ca.postal_code, ca.city, ca.state
|
||||
FROM a76.company c
|
||||
LEFT JOIN a76.company_address ca
|
||||
ON ca.company_id = c.id AND ca.address_type = 'main'
|
||||
WHERE c.id = :cid
|
||||
LIMIT 1
|
||||
"""),
|
||||
{"cid": company_id},
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return []
|
||||
|
||||
name, rfc, program, prog_num, street, ext_num, neighborhood, postal, city, state = row
|
||||
|
||||
def s(v) -> str:
|
||||
return str(v).strip() if v else ""
|
||||
|
||||
lines: list[str] = []
|
||||
|
||||
# Line 1: Company name (no label, same as legacy)
|
||||
lines.append(s(name))
|
||||
|
||||
# Line 2: Dirección (always emitted)
|
||||
dir_line = f"Dirección: {s(street)}"
|
||||
if s(ext_num):
|
||||
dir_line += f" Ext. Num: {s(ext_num)}"
|
||||
lines.append(dir_line)
|
||||
|
||||
# Line 3: Colonia + Código Postal (always emitted)
|
||||
lines.append(f"Colonia: {s(neighborhood)} Código Postal: {s(postal)}")
|
||||
|
||||
# Line 4: Ciudad + Estado (always emitted)
|
||||
ciudad_estado = f"{s(city)} {s(state)}".strip()
|
||||
lines.append(f"Ciudad: {ciudad_estado}")
|
||||
|
||||
# Line 5: RFC (always emitted)
|
||||
lines.append(f"R.F.C: {s(rfc)}")
|
||||
|
||||
# Line 6: Program (always emitted)
|
||||
prog_label = "SICEX" if s(program) == "Maquila" else (s(program) or "IMMEX")
|
||||
lines.append(f"{prog_label}: {s(prog_num)}")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core CSV generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_vencimiento_csv(filters: VencimientoFilter, db: Session) -> bytes:
|
||||
"""Build the CSV bytes for the Reporte de Vencimiento."""
|
||||
today = date.today()
|
||||
|
||||
# Legacy exact behavior:
|
||||
# FechaFinal = Today() + Loc:Dias
|
||||
# IF UsarFechaCorte AND FechaCorte <> '' THEN FechaFinal = FechaCorte
|
||||
# → fecha_corte completely replaces today+days_ahead; days_ahead is ignored when usar_fecha_corte=True
|
||||
if filters.usar_fecha_corte and filters.fecha_corte:
|
||||
try:
|
||||
date_to = date.fromisoformat(filters.fecha_corte)
|
||||
except ValueError:
|
||||
date_to = today + timedelta(days=filters.days_ahead)
|
||||
else:
|
||||
date_to = today + timedelta(days=filters.days_ahead)
|
||||
|
||||
# Build dynamic filter fragments
|
||||
client_filter = ""
|
||||
anexo31_filter = ""
|
||||
# Legacy: bal date filter only when usar_fecha_corte=1 (count all discharges otherwise)
|
||||
bal_date_cutoff = str(date_to) if filters.usar_fecha_corte and filters.fecha_corte else None
|
||||
params: dict = {
|
||||
"tenant_id": filters.tenant_id,
|
||||
"company_id": filters.company_id,
|
||||
"date_to": str(date_to),
|
||||
"bal_date_cutoff": bal_date_cutoff,
|
||||
}
|
||||
|
||||
if filters.client_id:
|
||||
client_filter = "AND icm.sold_to_id = :client_id"
|
||||
params["client_id"] = filters.client_id
|
||||
|
||||
if filters.conforme_anexo_31:
|
||||
# Mirrors legacy: only invoices marked to generate balances (IMMEX conforme)
|
||||
anexo31_filter = "AND icm.generate_balances = TRUE"
|
||||
|
||||
sql = _VENCIMIENTO_SQL.format(
|
||||
client_filter=client_filter,
|
||||
anexo31_filter=anexo31_filter,
|
||||
)
|
||||
|
||||
rows = db.execute(text(sql), params).mappings().fetchall()
|
||||
|
||||
# Currency flag: ME = foreign (USD), MN = national (MXP)
|
||||
use_me = filters.currency == "foreign"
|
||||
min_bal = _d(filters.min_balance)
|
||||
julian = filters.julian_date
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, lineterminator="\n")
|
||||
|
||||
# ── Header block (IMPRIMIR_EMPRESA) ──────────────────────────────────────
|
||||
writer.writerow(["", "", "", "", "REPORTE DE VENCIMIENTO"])
|
||||
|
||||
company_lines = _fetch_company_header_rows(db, filters.company_id)
|
||||
for line in company_lines:
|
||||
writer.writerow(["", "", "", "", line])
|
||||
|
||||
writer.writerow(["", ""]) # blank separator
|
||||
writer.writerow(CSV_COLUMNS) # column headers
|
||||
writer.writerow(["", ""]) # blank separator after headers
|
||||
|
||||
# ── Data rows ────────────────────────────────────────────────────────────
|
||||
for row in rows:
|
||||
# -- Quantities --
|
||||
cant_orig = _d(row["C17"])
|
||||
cant_used = _d(row["C18"])
|
||||
cant_saldo = cant_orig - cant_used
|
||||
|
||||
# Skip rows with zero or negative balance
|
||||
# Legacy: If CantidadOmitir <> 0 Then If CantidadSaldo <= CantidadOmitir Then Cycle
|
||||
if cant_saldo <= 0:
|
||||
continue
|
||||
if min_bal > 0 and cant_saldo <= min_bal:
|
||||
continue
|
||||
|
||||
# -- Peso (proportional calculation, mirrors legacy formula) --
|
||||
peso_neto = _d(row["C24"])
|
||||
if cant_orig != 0:
|
||||
peso_usado = (cant_used * peso_neto) / cant_orig
|
||||
else:
|
||||
peso_usado = Decimal(0)
|
||||
peso_saldo = peso_neto - peso_usado
|
||||
|
||||
# -- Valor según moneda (ME = foreign, MN = national) --
|
||||
# Legacy (UsarDescargos=1): ValorUsado = (CantUsada × ValorOrig) / CantOrig (proporcional, igual que peso)
|
||||
if use_me:
|
||||
valor_orig = _d(row["C22"])
|
||||
else:
|
||||
valor_orig = _d(row["C20"])
|
||||
if cant_orig != 0:
|
||||
valor_usado = (cant_used * valor_orig) / cant_orig
|
||||
else:
|
||||
valor_usado = Decimal(0)
|
||||
valor_saldo = valor_orig - valor_usado
|
||||
|
||||
# -- TextoVencimiento (días vs HOY, not baseline) --
|
||||
end_date = row.get("C_end_date")
|
||||
if end_date is not None:
|
||||
if hasattr(end_date, "date"):
|
||||
end_date = end_date.date()
|
||||
diff = (end_date - today).days
|
||||
if diff < 0:
|
||||
texto_venc = "VENCIDO"
|
||||
elif diff == 0:
|
||||
texto_venc = "VENCE HOY"
|
||||
else:
|
||||
texto_venc = str(diff)
|
||||
else:
|
||||
texto_venc = ""
|
||||
|
||||
writer.writerow([
|
||||
_text(row["C1"]), # Num. Factura Impo (prefijo ' para Excel)
|
||||
_text(row["C2"]), # Pedimento
|
||||
_text(row["C_ped_r1"]), # Pedimento Rectificado
|
||||
_fmt_date(row["C11"], julian), # Fecha Entrada
|
||||
_fmt_date(row["C_end_date"], julian), # Fecha Vencimiento
|
||||
texto_venc, # Estatus/Dias
|
||||
_text(row["C36"]), # Num. Parte (prefijo ' para Excel)
|
||||
_clean(row["C15"]), # Descripción
|
||||
_clean(row["C16"]), # Pais
|
||||
_fmt_num(cant_orig), # Cant. Original
|
||||
_fmt_num(cant_used), # Cant. Usada
|
||||
_fmt_num(cant_saldo), # Cant. Saldo
|
||||
_clean(row["C19"]), # U.M.
|
||||
_fmt_num(valor_orig), # Valor Original
|
||||
_fmt_num(valor_usado), # Valor Usado
|
||||
_fmt_num(valor_saldo), # Valor Saldo
|
||||
_fmt_num(peso_neto), # Peso Original
|
||||
_fmt_num(peso_usado), # Peso Usado
|
||||
_fmt_num(peso_saldo), # Peso Saldo
|
||||
_text(row["C26"]), # Fraccion (código arancelario, forzar texto)
|
||||
_clean(row["C_frac_type"]), # Tipo Fraccion
|
||||
_clean(row["C_sector"]), # Sector
|
||||
_text(row["C_frac_ame"]), # Fraccion Americana (código arancelario, forzar texto)
|
||||
])
|
||||
|
||||
return output.getvalue().encode("utf-8-sig")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service class (FastAPI integration)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class VencimientoReportService:
|
||||
|
||||
def generate_csv_response(
|
||||
self, db: Session, filters: VencimientoFilter
|
||||
) -> StreamingResponse:
|
||||
today = date.today()
|
||||
|
||||
if filters.usar_fecha_corte and filters.fecha_corte:
|
||||
try:
|
||||
date_to = date.fromisoformat(filters.fecha_corte)
|
||||
except ValueError:
|
||||
date_to = today + timedelta(days=filters.days_ahead)
|
||||
else:
|
||||
date_to = today + timedelta(days=filters.days_ahead)
|
||||
|
||||
filename = f"vencimiento_{today.isoformat()}_{date_to.isoformat()}.csv"
|
||||
|
||||
csv_bytes = generate_vencimiento_csv(filters, db)
|
||||
|
||||
return StreamingResponse(
|
||||
iter([csv_bytes]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Celery task for asynchronous Vencimiento CSV generation.
|
||||
Mirrors the pattern of generate_saldos_temporales_async.
|
||||
"""
|
||||
import base64
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any
|
||||
|
||||
from core.celery_app import celery_app
|
||||
from core.email import EmailService
|
||||
|
||||
from .schemas import VencimientoFilter
|
||||
from .service import generate_vencimiento_csv
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="generate_vencimiento_csv_async")
|
||||
def generate_vencimiento_csv_async(
|
||||
self,
|
||||
filter_data: Dict[str, Any],
|
||||
user_email: str = None,
|
||||
):
|
||||
"""
|
||||
Async Celery task: generate Reporte de Vencimiento CSV and optionally e-mail it.
|
||||
"""
|
||||
try:
|
||||
# 1. Inicializando
|
||||
self.update_state(
|
||||
state="PROCESSING",
|
||||
meta={"current": 10, "total": 100, "status": "Inicializando reporte de Vencimiento..."},
|
||||
)
|
||||
|
||||
# 2. Re-construir filtro
|
||||
filters = VencimientoFilter(**filter_data)
|
||||
|
||||
# 3. Generar CSV con sesión de BD propia
|
||||
self.update_state(
|
||||
state="PROCESSING",
|
||||
meta={"current": 40, "total": 100, "status": "Generando datos de Vencimiento..."},
|
||||
)
|
||||
logger.info(
|
||||
f"Vencimiento task: building CSV "
|
||||
f"(days_ahead={filters.days_ahead}, currency={filters.currency})"
|
||||
)
|
||||
|
||||
from core.database import CoreSessionLocal
|
||||
db = CoreSessionLocal()
|
||||
try:
|
||||
csv_bytes = generate_vencimiento_csv(filters, db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# csv_bytes ya viene como bytes (utf-8-sig), necesitamos el string para email
|
||||
csv_content = csv_bytes.decode("utf-8-sig")
|
||||
|
||||
# 4. Envío de correo opcional
|
||||
email_sent = False
|
||||
if filters.send_email and user_email:
|
||||
self.update_state(
|
||||
state="PROCESSING",
|
||||
meta={"current": 85, "total": 100, "status": "Enviando correo electrónico..."},
|
||||
)
|
||||
try:
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
filename = f"reporte_vencimiento_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
result = async_to_sync(EmailService.send_report_email)(
|
||||
recipient_email=user_email,
|
||||
subject=f"Reporte de Vencimiento – {datetime.now().strftime('%d/%m/%Y')}",
|
||||
body_text="Se adjunta el Reporte de Vencimiento generado.",
|
||||
csv_content=csv_content,
|
||||
filename=filename,
|
||||
)
|
||||
email_sent = bool(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Vencimiento task: email error: {e}")
|
||||
|
||||
# 5. Codificar a base64 y retornar
|
||||
self.update_state(
|
||||
state="PROCESSING",
|
||||
meta={"current": 95, "total": 100, "status": "Finalizando..."},
|
||||
)
|
||||
|
||||
content_b64 = base64.b64encode(csv_bytes).decode("utf-8")
|
||||
filename = f"reporte_vencimiento_{datetime.now().strftime('%Y%m%d')}.csv"
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"file_name": filename,
|
||||
"content": content_b64,
|
||||
"media_type": "text/csv",
|
||||
"email_sent": email_sent,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in generate_vencimiento_csv_async: {e}", exc_info=True)
|
||||
self.update_state(
|
||||
state="FAILURE",
|
||||
meta={
|
||||
"exc_type": type(e).__name__,
|
||||
"exc_message": str(e),
|
||||
"custom": "Error generating Vencimiento report",
|
||||
},
|
||||
)
|
||||
raise
|
||||
@@ -44,6 +44,7 @@ from .reports.exportacion.aviso_consolidado.routes import router as aviso_consol
|
||||
from .reports.exportacion.partes_descargadas.routes import router as downloaded_parts_reports_router
|
||||
from .reports.movements.invoices.routes import router as movement_invoices_router
|
||||
from .reports.movements.saldos.routes import router as movement_saldos_router
|
||||
from .reports.movements.vencimiento.routes import router as movement_vencimiento_router
|
||||
from .reports.exportacion.descargo.routes import router as discharge_reports_router
|
||||
from .reports.exportacion.transmission.MAINX30.routes import router as transmission_router
|
||||
from .reports.importacion.transmission.temporal.MAINX30.routes import router as transmission_temporal_router
|
||||
@@ -140,6 +141,12 @@ router.include_router(
|
||||
tags=["a76 / reports"]
|
||||
)
|
||||
|
||||
router.include_router(
|
||||
movement_vencimiento_router,
|
||||
prefix="/a76/reports/movements/vencimiento",
|
||||
tags=["a76 / reports"]
|
||||
)
|
||||
|
||||
router.include_router(
|
||||
discharge_reports_router,
|
||||
prefix="/a76/reports/exportacion/descargo",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* API Client for Reporte de Vencimiento (synchronous CSV download)
|
||||
*/
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export interface VencimientoFilter {
|
||||
days_ahead: number;
|
||||
currency: 'foreign' | 'national';
|
||||
client_id?: number | null;
|
||||
min_balance: number;
|
||||
conforme_anexo_31: boolean;
|
||||
usar_fecha_corte: boolean;
|
||||
fecha_corte?: string | null;
|
||||
send_email: boolean;
|
||||
julian_date: boolean;
|
||||
}
|
||||
|
||||
export const vencimientoReportApi = {
|
||||
/** Generate and download CSV synchronously */
|
||||
generate: async (companyId: number, filters: VencimientoFilter): Promise<void> => {
|
||||
const blob = await api.postBlob(
|
||||
`/v1/a76/reports/movements/vencimiento/generate?company_id=${companyId}`,
|
||||
filters
|
||||
);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `vencimiento_${today}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
@@ -516,6 +516,10 @@ export function getSidebarData(): SidebarData {
|
||||
title: "Partes descargadas",
|
||||
url: "/dashboard/reports/partes-descargadas",
|
||||
},
|
||||
{
|
||||
title: "Reporte de Vencimiento",
|
||||
url: "/dashboard/reports/vencimiento",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { getAuthTokens } from '$lib/server/api';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
const { accessToken } = getAuthTokens(cookies);
|
||||
|
||||
if (!accessToken) {
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Reporte de Vencimiento'
|
||||
};
|
||||
};
|
||||
312
frontend/src/routes/dashboard/reports/vencimiento/+page.svelte
Normal file
312
frontend/src/routes/dashboard/reports/vencimiento/+page.svelte
Normal file
@@ -0,0 +1,312 @@
|
||||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
CalendarClock,
|
||||
Printer,
|
||||
X,
|
||||
BadgeDollarSign,
|
||||
Users,
|
||||
Filter,
|
||||
Mail,
|
||||
Calendar,
|
||||
AlertTriangle
|
||||
} from 'lucide-svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { clientsProvidersApi, type ClientProvider } from '$lib/api/dashboard/a76/clients-providers';
|
||||
import { vencimientoReportApi } from '$lib/api/dashboard/a76/reports/reports-vencimiento';
|
||||
|
||||
const CLEAR_SELECT_VALUE = '__clear__';
|
||||
|
||||
let isGenerating = $state(false);
|
||||
let isCatalogLoading = $state(false);
|
||||
let lastCompanyId = $state<number | null>(null);
|
||||
let clients = $state<ClientProvider[]>([]);
|
||||
|
||||
// ── Form state ────────────────────────────────────────────────────────────
|
||||
let daysAhead = $state(0);
|
||||
let currency = $state<'foreign' | 'national'>('foreign');
|
||||
let clientId = $state('');
|
||||
let minBalance = $state('0');
|
||||
let conformeAnexo31 = $state(false);
|
||||
let usarFechaCorte = $state(false);
|
||||
let fechaCorte = $state('');
|
||||
let sendEmail = $state(false);
|
||||
let julianDate = $state(false);
|
||||
|
||||
// ── Derived ───────────────────────────────────────────────────────────────
|
||||
let clientOptions = $derived.by(() =>
|
||||
clients.filter((c) => c.client_or_provider === 'client' || c.client_or_provider === 'both')
|
||||
);
|
||||
|
||||
function normalizeSelectValue(value?: string) {
|
||||
return value === CLEAR_SELECT_VALUE ? '' : (value ?? '');
|
||||
}
|
||||
|
||||
function selectPlaceholder() {
|
||||
return isCatalogLoading ? 'Cargando...' : 'Selecciona...';
|
||||
}
|
||||
|
||||
// ── Catalog loading ───────────────────────────────────────────────────────
|
||||
async function loadCatalogs(companyId: number) {
|
||||
isCatalogLoading = true;
|
||||
try {
|
||||
const res = await clientsProvidersApi.list(companyId, 1, 1000);
|
||||
clients = res.data?.items ?? [];
|
||||
} catch {
|
||||
toast.error('No se pudieron cargar los catálogos');
|
||||
} finally {
|
||||
isCatalogLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const companyId = companyStore.activeCompany?.id ?? null;
|
||||
if (!companyId) {
|
||||
lastCompanyId = null;
|
||||
clients = [];
|
||||
return;
|
||||
}
|
||||
if (companyId === lastCompanyId) return;
|
||||
lastCompanyId = companyId;
|
||||
void loadCatalogs(companyId);
|
||||
});
|
||||
|
||||
// ── Generate ──────────────────────────────────────────────────────────────
|
||||
async function runReport() {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
toast.error('No hay empresa activa seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedDays = parseInt(String(daysAhead), 10);
|
||||
if (isNaN(parsedDays) || parsedDays < 0) {
|
||||
toast.error('El número de días debe ser un entero positivo');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedBalance = parseFloat(minBalance);
|
||||
if (isNaN(parsedBalance) || parsedBalance < 0) {
|
||||
toast.error('El balance mínimo debe ser un número positivo');
|
||||
return;
|
||||
}
|
||||
|
||||
isGenerating = true;
|
||||
try {
|
||||
await vencimientoReportApi.generate(companyId, {
|
||||
days_ahead: parsedDays,
|
||||
currency,
|
||||
client_id: clientId ? parseInt(clientId, 10) : null,
|
||||
min_balance: parsedBalance,
|
||||
conforme_anexo_31: conformeAnexo31,
|
||||
usar_fecha_corte: usarFechaCorte,
|
||||
fecha_corte: usarFechaCorte && fechaCorte ? fechaCorte : null,
|
||||
send_email: sendEmail,
|
||||
julian_date: julianDate
|
||||
});
|
||||
toast.success('Reporte descargado exitosamente');
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Error al generar el reporte');
|
||||
} finally {
|
||||
isGenerating = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="animate-in fade-in slide-in-from-bottom-4 flex min-h-full flex-col gap-2 pb-4 duration-500">
|
||||
<!-- Header -->
|
||||
<div class="flex shrink-0 items-center justify-between px-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="flex items-center gap-2 text-xl font-bold tracking-tight text-foreground">
|
||||
<CalendarClock class="h-6 w-6 text-primary" />
|
||||
Reporte de Vencimiento
|
||||
</h1>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">Reportes de Control Fiscal</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="grid min-h-0 flex-1 grid-cols-1 content-start items-start gap-3 text-foreground xl:grid-cols-3">
|
||||
|
||||
<!-- ── Card 1: Parámetro principal ───────────────────────────────────── -->
|
||||
<Card.Root class="flex h-full flex-col gap-0 py-0">
|
||||
<Card.Header class="shrink-0 border-b bg-muted/20 px-3 py-2">
|
||||
<Card.Title class="flex items-center gap-2 text-sm font-semibold text-primary">
|
||||
<AlertTriangle class="h-4 w-4" /> Vencimiento
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex-1 space-y-4 p-3">
|
||||
<!-- Días a vencer -->
|
||||
<div class="space-y-2 rounded-md border p-3">
|
||||
<p class="text-xs font-bold uppercase text-muted-foreground">
|
||||
Facturas próximas a vencer dentro de los
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
class="h-8 w-24 text-center"
|
||||
bind:value={daysAhead}
|
||||
/>
|
||||
<span class="text-sm text-muted-foreground">días.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tipo de moneda -->
|
||||
<div class="space-y-2 rounded-md border p-3">
|
||||
<p class="flex items-center gap-2 text-xs font-bold uppercase text-muted-foreground">
|
||||
<BadgeDollarSign class="h-3.5 w-3.5" /> Tipo de Moneda
|
||||
</p>
|
||||
<RadioGroup.Root bind:value={currency} class="flex flex-col gap-2">
|
||||
<div class="flex items-center space-x-2 rounded-md border px-3 py-1.5">
|
||||
<RadioGroup.Item value="foreign" id="currency-foreign" class="h-4 w-4" />
|
||||
<Label for="currency-foreign" class="cursor-pointer text-sm">Extranjera (USD)</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 rounded-md border px-3 py-1.5">
|
||||
<RadioGroup.Item value="national" id="currency-national" class="h-4 w-4" />
|
||||
<Label for="currency-national" class="cursor-pointer text-sm">Nacional (MXP)</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- ── Card 2: Filtros ────────────────────────────────────────────────── -->
|
||||
<Card.Root class="flex h-full flex-col gap-0 py-0">
|
||||
<Card.Header class="shrink-0 border-b bg-muted/20 px-3 py-2">
|
||||
<Card.Title class="flex items-center gap-2 text-sm font-semibold text-primary">
|
||||
<Filter class="h-4 w-4" /> Filtrar por
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex-1 space-y-4 p-3">
|
||||
<!-- Filtrar por cliente -->
|
||||
<div class="space-y-2 rounded-md border p-3">
|
||||
<p class="flex items-center gap-2 text-xs font-bold uppercase text-muted-foreground">
|
||||
<Users class="h-3.5 w-3.5" /> Cliente
|
||||
</p>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={clientId}
|
||||
onValueChange={(v) => (clientId = normalizeSelectValue(v))}
|
||||
>
|
||||
<Select.Trigger class="h-8 w-full text-xs">
|
||||
<span class="truncate">
|
||||
{#if clientId}
|
||||
{@const selected = clientOptions.find((c) => String(c.id) === clientId)}
|
||||
{selected?.name ?? selectPlaceholder()}
|
||||
{:else}
|
||||
{selectPlaceholder()}
|
||||
{/if}
|
||||
</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content class="max-h-[300px]">
|
||||
<Select.Item value={CLEAR_SELECT_VALUE}>Todos</Select.Item>
|
||||
{#if clientOptions.length}
|
||||
{#each clientOptions as item}
|
||||
<Select.Item value={String(item.id)}>{item.name}</Select.Item>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="px-3 py-2 text-xs text-muted-foreground">Sin opciones</div>
|
||||
{/if}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<!-- Omitir cantidades -->
|
||||
<div class="space-y-2 rounded-md border p-3">
|
||||
<p class="text-xs font-bold uppercase text-muted-foreground">
|
||||
Omitir cantidades con balance menor a
|
||||
</p>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.0001"
|
||||
class="h-8 w-36"
|
||||
bind:value={minBalance}
|
||||
/>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- ── Card 3: Opciones ───────────────────────────────────────────────── -->
|
||||
<Card.Root class="flex h-full flex-col gap-0 py-0">
|
||||
<Card.Header class="shrink-0 border-b bg-muted/20 px-3 py-2">
|
||||
<Card.Title class="flex items-center gap-2 text-sm font-semibold text-primary">
|
||||
<Calendar class="h-4 w-4" /> Filtro Opcional
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex-1 space-y-2 p-3">
|
||||
<div class="space-y-2 rounded-md border p-3">
|
||||
<div class="flex items-center space-x-2 rounded-md border px-3 py-2">
|
||||
<Checkbox id="opt-anexo31" bind:checked={conformeAnexo31} class="h-4 w-4" />
|
||||
<Label for="opt-anexo31" class="cursor-pointer text-sm">Conforme al Anexo 31</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 rounded-md border px-3 py-2">
|
||||
<Checkbox id="opt-fecha-corte" bind:checked={usarFechaCorte} class="h-4 w-4" />
|
||||
<Label for="opt-fecha-corte" class="cursor-pointer text-sm">Usar Fecha de Corte</Label>
|
||||
</div>
|
||||
{#if usarFechaCorte}
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs text-muted-foreground">Fecha de Corte</Label>
|
||||
<Input
|
||||
type="date"
|
||||
class="h-8 w-full"
|
||||
bind:value={fechaCorte}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- ── Bottom bar: extra opciones + acciones ──────────────────────────── -->
|
||||
<Card.Root class="gap-0 py-0 xl:col-span-3">
|
||||
<Card.Content class="p-3">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox id="opt-email" bind:checked={sendEmail} class="h-4 w-4" />
|
||||
<Label for="opt-email" class="flex cursor-pointer items-center gap-1.5 text-sm">
|
||||
<Mail class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Enviar por correo electrónico.
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox id="opt-julian" bind:checked={julianDate} class="h-4 w-4" />
|
||||
<Label for="opt-julian" class="flex cursor-pointer items-center gap-1.5 text-sm">
|
||||
<Calendar class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Usar Fecha Juliana en Reporte de Excel.
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer class="gap-2 border-t bg-muted/10 p-2.5">
|
||||
<Button
|
||||
class="h-9 flex-1 text-sm shadow-sm"
|
||||
size="default"
|
||||
onclick={runReport}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
<Printer class="mr-2 h-3.5 w-3.5" />
|
||||
{isGenerating ? 'Generando...' : 'Imprimir'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-9 w-9 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onclick={() => history.back()}
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user