feature/reporte-saldos-vencimiento

This commit is contained in:
2026-04-21 09:56:27 -06:00
parent 1cd81ebc03
commit 0a4cfb554b
10 changed files with 964 additions and 0 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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}"'},
)

View File

@@ -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

View File

@@ -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",

View File

@@ -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);
}
};

View File

@@ -516,6 +516,10 @@ export function getSidebarData(): SidebarData {
title: "Partes descargadas",
url: "/dashboard/reports/partes-descargadas",
},
{
title: "Reporte de Vencimiento",
url: "/dashboard/reports/vencimiento",
},
],
},
{

View File

@@ -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'
};
};

View 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>