From be3150b53c10aeb98e1d07e827fd91badf711b38 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Fri, 6 Mar 2026 16:09:45 -0600 Subject: [PATCH] genracion de reportes por saldos temporales --- backend/api/v1/common/tenant_crud_routes.py | 60 +- backend/api/v1/modules/a76/classes/service.py | 15 +- .../a76/clients_and_providers/service.py | 12 +- .../a76/general_catalogs/ports/routes.py | 1 + .../a76/general_catalogs/ports/service.py | 9 +- backend/api/v1/modules/a76/parts/routes.py | 1 + backend/api/v1/modules/a76/parts/service.py | 10 +- .../a76/pedmientos/routes/pedimentos.py | 2 +- .../a76/pedmientos/services/pedimentos.py | 9 +- .../a76/reports/movements/saldos/__init__.py | 0 .../a76/reports/movements/saldos/csv_utils.py | 886 ++++++++ .../a76/reports/movements/saldos/routes.py | 81 + .../a76/reports/movements/saldos/schemas.py | 52 + .../a76/reports/movements/saldos/tasks.py | 103 + backend/api/v1/modules/a76/router.py | 7 + .../reference_data/material_types/routes.py | 2 +- .../reference_data/pedimento_codes/routes.py | 2 +- backend/core/celery_app.py | 1 + backend/core/email.py | 5 +- .../lib/api/dashboard/a76/saldos-report.ts | 59 + .../dashboard/reports/invoices/+page.svelte | 1839 +++++++++++++---- 21 files changed, 2701 insertions(+), 455 deletions(-) create mode 100644 backend/api/v1/modules/a76/reports/movements/saldos/__init__.py create mode 100644 backend/api/v1/modules/a76/reports/movements/saldos/csv_utils.py create mode 100644 backend/api/v1/modules/a76/reports/movements/saldos/routes.py create mode 100644 backend/api/v1/modules/a76/reports/movements/saldos/schemas.py create mode 100644 backend/api/v1/modules/a76/reports/movements/saldos/tasks.py create mode 100644 frontend/src/lib/api/dashboard/a76/saldos-report.ts diff --git a/backend/api/v1/common/tenant_crud_routes.py b/backend/api/v1/common/tenant_crud_routes.py index 98f4b184..db0ed8e5 100644 --- a/backend/api/v1/common/tenant_crud_routes.py +++ b/backend/api/v1/common/tenant_crud_routes.py @@ -139,6 +139,7 @@ class TenantCRUDRoutes( async def list_resources( request: Request, company_id: int = Query(..., description="Company ID"), + all_companies: bool = Query(False, description="Whether to search in all companies of the tenant"), page: int = Query(1, ge=1, description="Page number"), page_size: int = Query( self.default_page_size, @@ -149,19 +150,32 @@ class TenantCRUDRoutes( db: Session = Depends(self.db_dependency), current_user: Dict[str, Any] = Depends(self.auth_dependency), ): - tenant_id = validate_access_to_resource( - db, - company_id, - current_user, - self.list_permissions, - self.require_all, - ) + from core.security import get_tenant_from_token + + if all_companies: + tenant_id = get_tenant_from_token(current_user) + if not tenant_id: + tenant_id = current_user.get("tenant_id") + if not tenant_id: + raise HTTPException(status_code=400, detail="Tenant ID not found in token") + # In all_companies mode, we don't filter by company_id, + # but we still need the tenant_id from the session/token. + target_company_id = None + else: + tenant_id = validate_access_to_resource( + db, + company_id, + current_user, + self.list_permissions, + self.require_all, + ) + target_company_id = company_id skip = (page - 1) * page_size # Extraer todos los parámetros de búsqueda dinámicamente # Excluimos los parámetros estándar de paginación y control - standard_params = {"company_id", "page", "page_size"} + standard_params = {"company_id", "all_companies", "page", "page_size"} filters = { k: v for k, v in request.query_params.items() @@ -169,7 +183,7 @@ class TenantCRUDRoutes( } items, total = self.service.get_all( - db, tenant_id, company_id, skip, page_size, filters + db, tenant_id, target_company_id, skip, page_size, filters ) @@ -192,6 +206,7 @@ class TenantCRUDRoutes( ) async def list_resources( company_id: int = Query(..., description="Company ID"), + all_companies: bool = Query(False, description="Whether to search in all companies of the tenant"), page: int = Query(1, ge=1, description="Page number"), page_size: int = Query( self.default_page_size, @@ -202,18 +217,29 @@ class TenantCRUDRoutes( db: Session = Depends(self.db_dependency), current_user: Dict[str, Any] = Depends(self.auth_dependency), ): - tenant_id = validate_access_to_resource( - db, - company_id, - current_user, - self.list_permissions, - self.require_all, - ) + from core.security import get_tenant_from_token + + if all_companies: + tenant_id = get_tenant_from_token(current_user) + if not tenant_id: + tenant_id = current_user.get("tenant_id") + if not tenant_id: + raise HTTPException(status_code=400, detail="Tenant ID not found in token") + target_company_id = None + else: + tenant_id = validate_access_to_resource( + db, + company_id, + current_user, + self.list_permissions, + self.require_all, + ) + target_company_id = company_id skip = (page - 1) * page_size items, total = self.service.get_all( - db, tenant_id, company_id, skip, page_size, None + db, tenant_id, target_company_id, skip, page_size, None ) return { diff --git a/backend/api/v1/modules/a76/classes/service.py b/backend/api/v1/modules/a76/classes/service.py index 904adddf..48a94a2d 100644 --- a/backend/api/v1/modules/a76/classes/service.py +++ b/backend/api/v1/modules/a76/classes/service.py @@ -32,7 +32,7 @@ class ClassService: def get_all( db: Session, tenant_id: int, - company_id: int, + company_id: Optional[int], skip: int = 0, limit: int = 100, filters: Optional[Dict[str, Any]] = None, @@ -40,9 +40,10 @@ class ClassService: """ Get all classes for a tenant with pagination and filters """ - query = db.query(Class).filter( - Class.tenant_id == tenant_id, Class.company_id == company_id - ) + query = db.query(Class).filter(Class.tenant_id == tenant_id) + + if company_id is not None: + query = query.filter(Class.company_id == company_id) if filters: if filters.get("class_code"): @@ -77,7 +78,7 @@ class ClassService: def get_all_with_fa_data( db: Session, tenant_id: int, - company_id: int, + company_id: Optional[int], skip: int = 0, limit: int = 1000, filters: Optional[Dict[str, Any]] = None, @@ -98,9 +99,11 @@ class ClassService: QClasses.tenant_id == tenant_id )) .filter(Class.tenant_id == tenant_id) - .filter(Class.company_id == company_id) ) + if company_id is not None: + query = query.filter(Class.company_id == company_id) + # Apply filters if provided if filters: if filters.get("class_code"): diff --git a/backend/api/v1/modules/a76/clients_and_providers/service.py b/backend/api/v1/modules/a76/clients_and_providers/service.py index 417228ee..aaf76471 100644 --- a/backend/api/v1/modules/a76/clients_and_providers/service.py +++ b/backend/api/v1/modules/a76/clients_and_providers/service.py @@ -34,16 +34,16 @@ class ClientProviderService: def get_all( db: Session, tenant_id: int, - company_id: int, + company_id: Optional[int], skip: int = 0, limit: int = 50, filters: Optional[Dict[str, Any]] = None, ) -> Tuple[List[ClientProvider], int]: - """Get all clients/providers for a tenant/company with pagination""" - query = db.query(ClientProvider).filter( - ClientProvider.tenant_id == tenant_id, - ClientProvider.company_id == company_id, - ) + """Get all clients/providers for a tenant with pagination""" + query = db.query(ClientProvider).filter(ClientProvider.tenant_id == tenant_id) + + if company_id is not None: + query = query.filter(ClientProvider.company_id == company_id) # Apply filters if provided if filters: diff --git a/backend/api/v1/modules/a76/general_catalogs/ports/routes.py b/backend/api/v1/modules/a76/general_catalogs/ports/routes.py index 3d03c33d..e90d8ab6 100644 --- a/backend/api/v1/modules/a76/general_catalogs/ports/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/ports/routes.py @@ -13,4 +13,5 @@ router = TenantCRUDRoutes( tags=["a76.general_catalogs.ports"], resource_name="Port", enable_list=True, + max_page_size=1000, ).router diff --git a/backend/api/v1/modules/a76/general_catalogs/ports/service.py b/backend/api/v1/modules/a76/general_catalogs/ports/service.py index e10f3529..7e5e1af2 100644 --- a/backend/api/v1/modules/a76/general_catalogs/ports/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/ports/service.py @@ -11,14 +11,15 @@ class PortService: def get_all( db: Session, tenant_id: int, - company_id: int, + company_id: Optional[int], skip: int = 0, limit: int = 100, filters: Optional[Dict[str, Any]] = None ) -> Tuple[List[Port], int]: - query = db.query(Port).filter( - Port.tenant_id == tenant_id - ) + query = db.query(Port).filter(Port.tenant_id == tenant_id) + + if company_id is not None: + query = query.filter(Port.company_id == company_id) if filters: # Add filters here if needed diff --git a/backend/api/v1/modules/a76/parts/routes.py b/backend/api/v1/modules/a76/parts/routes.py index 17bf937a..59c25a58 100644 --- a/backend/api/v1/modules/a76/parts/routes.py +++ b/backend/api/v1/modules/a76/parts/routes.py @@ -28,5 +28,6 @@ router.include_router( id_name="part_id", enable_list=True, enable_filters=True, + max_page_size=1000, ).router ) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/parts/service.py b/backend/api/v1/modules/a76/parts/service.py index 5db4bb5f..d6c4025d 100644 --- a/backend/api/v1/modules/a76/parts/service.py +++ b/backend/api/v1/modules/a76/parts/service.py @@ -26,16 +26,16 @@ class PartService: def get_all( db: Session, tenant_id: int, - company_id: int, + company_id: Optional[int], skip: int = 0, limit: int = 100, filters: Optional[Dict[str, Any]] = None, ) -> tuple[List[Part], int]: - query = db.query(Part).filter( - Part.tenant_id == tenant_id, - Part.company_id == company_id - ) + query = db.query(Part).filter(Part.tenant_id == tenant_id) + + if company_id is not None: + query = query.filter(Part.company_id == company_id) if filters: if filters.get("q"): diff --git a/backend/api/v1/modules/a76/pedmientos/routes/pedimentos.py b/backend/api/v1/modules/a76/pedmientos/routes/pedimentos.py index b912cf39..890247f3 100644 --- a/backend/api/v1/modules/a76/pedmientos/routes/pedimentos.py +++ b/backend/api/v1/modules/a76/pedmientos/routes/pedimentos.py @@ -83,7 +83,7 @@ crud_router = TenantCRUDRoutes( enable_list=True, # Enable GET / with pagination enable_filters=True, # Enable status, client_id, year filters default_page_size=50, - max_page_size=100, + max_page_size=1000, ).router # Include the CRUD routes into our main router diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py b/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py index 92f4d00f..e95c99da 100644 --- a/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py @@ -65,7 +65,7 @@ class PedimentosService: def get_all( db: Session, tenant_id: int, - company_id: int, + company_id: Optional[int], skip: int = 0, limit: int = 100, filters: Optional[Dict[str, Any]] = None, @@ -76,6 +76,7 @@ class PedimentosService: Args: db: Database session tenant_id: Tenant ID + company_id: Optional Company ID skip: Number of records to skip limit: Maximum number of records to return filters: Optional filters dict @@ -83,8 +84,10 @@ class PedimentosService: Returns: Tuple of (list of pedimentos, total count) """ - query = db.query(Pedimentos).filter( - Pedimentos.tenant_id == tenant_id, Pedimentos.company_id == company_id) + query = db.query(Pedimentos).filter(Pedimentos.tenant_id == tenant_id) + + if company_id is not None: + query = query.filter(Pedimentos.company_id == company_id) if filters: if filters.get("status"): diff --git a/backend/api/v1/modules/a76/reports/movements/saldos/__init__.py b/backend/api/v1/modules/a76/reports/movements/saldos/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/reports/movements/saldos/csv_utils.py b/backend/api/v1/modules/a76/reports/movements/saldos/csv_utils.py new file mode 100644 index 00000000..ddc10f80 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/saldos/csv_utils.py @@ -0,0 +1,886 @@ +""" +CSV generation utilities for Saldos Temporales report. + +Translated from Clarion routines: + GENERA_EXCEL_CSV_PEDIMENTO (range_type = 'pedimento') + GENERA_EXCEL_CSV_FECHA (range_type = 'payment_date' | 'invoice_date') + GENERA_EXCEL_CSV_PARTE (range_type = 'parts') + GENERA_EXCEL_CSV_CLASE (range_type = 'classes') + +Real Anexo76 tables (schema a76): + QEqiMaq / SPartidasImp → a76.item_lines + QFacImp → a76.invoice_header + a76.invoice_compliance_mx + QPedimentos → a76.pedimentos + a76.pedimento_dates + QClaAct → a76.classes + QSeriesImpo → a76.item_line_series + sFracciones → a76.tariff_fractions (column: umt = UMAbreviacion) + GTipoCambio → a76.exchange_rate + Line quantities → a76.item_line_quantities + Line financials → a76.item_line_financials + Line customs → a76.item_line_customs + Line descriptions → a76.item_line_descriptions +""" + +import csv +import io +import logging +import pytz +from decimal import Decimal, ROUND_HALF_UP, InvalidOperation +from datetime import datetime, date +from typing import Dict, List, Optional + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from .schemas import SaldosFilter + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# 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) -> str: + """Format a date value to MM/DD/YY (Clarion @D06 equivalent).""" + if val is None: + return "" + if isinstance(val, (datetime, date)): + return val.strftime("%m/%d/%y") + return str(val) + + +def _fmt_num(d: Decimal) -> str: + """Format Decimal without scientific notation and trailing zeros.""" + if d is None: + return "" + s = "{:f}".format(d) + if "." in s: + s = s.rstrip("0").rstrip(".") + if s == "-0": + return "0" + return s + + +def _clean(text: str) -> str: + """Remove newlines/carriage returns (Clarion SACARCOMASENTERS equivalent).""" + return str(text or "").replace("\n", " ").replace("\r", "").replace(",", " ").strip() + + +def _subpartida_sql(level: str) -> str: + # NOTE: is_sub_part column does not exist yet in item_lines — returns empty + return "" + + +# --------------------------------------------------------------------------- +# Per-row lookups (per Clarion row-level ACCESS calls) +# --------------------------------------------------------------------------- + +def _get_exchange_rate(db: Session, fecha, tenant_id: int, company_id: int) -> Optional[Decimal]: + """GTipoCambio → a76.exchange_rate""" + if not fecha: + return None + try: + row = db.execute( + text( + "SELECT value FROM a76.exchange_rate " + "WHERE tenant_id = :tid AND company_id = :cid AND date::date = :fecha " + "LIMIT 1" + ), + {"tid": tenant_id, "cid": company_id, "fecha": fecha}, + ).fetchone() + if row: + return _d(row[0]) + except Exception as e: + logger.warning(f"exchange_rate not found for {fecha}: {e}") + return None + + +def _get_series_data(db: Session, line_item_id) -> Dict: + """ + QSeriesImpo → a76.item_line_series + Returns: SeriesSolas, ModeloSeries, NumParteSeries, NoId + """ + out = {"SeriesSolas": None, "ModeloSeries": None, "NumParteSeries": None, "NoId": None} + if not line_item_id: + return out + try: + rows = db.execute( + text( + "SELECT serial_numbers, model, sub_model, number_id " + "FROM a76.item_line_series " + "WHERE line_item_id = :lid ORDER BY id" + ), + {"lid": line_item_id}, + ).fetchall() + except Exception: + return out + + serials, models, numbers_id = [], [], [] + for i, r in enumerate(rows, 1): + if r[0]: + serials.append(f"{i}) {r[0]}") + if r[1]: + models.append(f"{i}) {r[1]}") + if r[3]: + numbers_id.append(f"{i}) {r[3]}") + + out["SeriesSolas"] = ", ".join(serials) if serials else None + out["ModeloSeries"] = ", ".join(models) if models else None + out["NumParteSeries"] = None # sub_model used as num-parte-series if relevant + out["NoId"] = ", ".join(numbers_id) if numbers_id else None + return out + + +def _get_um_tarifa(db: Session, fraccion: str) -> str: + """ + sFracciones → a76.tariff_fractions + Clarion: SUB(fraccion,1,8) for code and SUB(fraccion,9,2) for historico + We match on the first 8 chars of the code. + """ + if not fraccion: + return "" + frac_code = fraccion.replace("'", "")[:8] # strip leading quote and take 8 chars + try: + row = db.execute( + text( + "SELECT umt FROM a76.tariff_fractions " + "WHERE LEFT(code, 8) = :frac LIMIT 1" + ), + {"frac": frac_code}, + ).fetchone() + if row and row[0]: + return str(row[0]) + except Exception as e: + logger.debug(f"UMTarifa not found for {frac_code}: {e}") + return "" + + +def _get_fraccion_ame(db: Session, class_id) -> str: + """QClaAct.FraccionAme → a76.classes.us_fraction""" + if not class_id: + return "" + try: + row = db.execute( + text("SELECT us_fraction FROM a76.classes WHERE id = :cid LIMIT 1"), + {"cid": class_id}, + ).fetchone() + if row and row[0]: + return str(row[0]) + except Exception: + pass + return "" + + +# --------------------------------------------------------------------------- +# Base SQL (shared across all range types) +# --------------------------------------------------------------------------- + +BASE_SELECT = """ + ih.invoice_number AS "C1", + ih.company_id AS "company_id", + CONCAT(ped.year,'-',ped.license,'-',ped.pedimento_number) AS "C2", + COALESCE(icm.provider_header,'') AS "C3", + COALESCE(icm.sold_to_header,'') AS "C_sold_to", + il.rectification AS "C4", + icm.pedimento_id AS "C5", + icm.pedimento_r1 AS "C5R1", + pd.payment_date AS "C7", + ped.customs_office AS "C8", + pd.start_date AS "C9", + ih.invoice_date AS "C11", + ih.emission_date AS "C12_emission", + cl.id AS "C13_class_id", + COALESCE(cl.class_code,'') AS "C13", + REPLACE(REPLACE(COALESCE(ild.description_spanish,''),CHR(10),''),CHR(13),' ') AS "C14", + 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(ilq.quantity_returned, 0) AS "C18", + COALESCE(uom.code,'') AS "C19", + COALESCE(ilf.value_mxn, 0) AS "C20", + COALESCE(ilf.value_returned_mxn, 0) AS "C21", + COALESCE(ilf.value_usd, 0) AS "C22", + COALESCE(ilf.value_returned_usd, 0) AS "C23", + COALESCE(ilq.net_weight, 0) AS "C24", + COALESCE(ilc.fraction,'') AS "C26", + COALESCE(ilc.fraction_type,'') AS "C27", + COALESCE(ilc.advalorem,'') AS "C28", + COALESCE(ilc.sector,'') AS "C29", + COALESCE(ilq.net_weight, 0) AS "C30", + COALESCE(ild.brand,'') AS "C32", + COALESCE(ild.model,'') AS "C33", + il.id AS "C34", + il.line_number AS "C35", + COALESCE(p.part_number,'') AS "C36", + COALESCE(ilq.quantity_returned_temp, 0) AS "C37", + COALESCE(il.location,'') AS "C38", + '' AS "C39", + COALESCE(icm.edocument,'') AS "C40", + COALESCE(icm.vucem_operation_num,'') AS "C41", + COALESCE(cl.material_key,'') AS "C42", + CONCAT(ped.year,'-',ped.customs_office,'-',ped.license,'-',ped.pedimento_number) AS "C43", + '' AS "C44", + COALESCE(ilc.octave_fraction,'') AS "C45", + '' AS "C47", + COALESCE(ped.pedimento_code,'') AS "C48", + COALESCE(ilc.rate,'') AS "C49", + COALESCE(il.iv32_type_key,'') AS "C50", + COALESCE(il.guide_number,'') AS "C_embarque", + COALESCE(c_proj.name, '') AS "C_proyecto" +""" + +BASE_JOINS = """ + JOIN a76.invoice_header ih ON ih.id = il.invoice_id + LEFT JOIN a76.company c_proj ON c_proj.id = ih.company_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.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 +""" + +# --------------------------------------------------------------------------- +# 5 query builders +# --------------------------------------------------------------------------- + +def _get_base_query_filters(filters: SaldosFilter) -> tuple: + """Helper to return base where-clause and parameters for company/tenant.""" + company_filter = "" + params: dict = {"tenant_id": filters.tenant_id} + + # If Shelter option is true, ignore company_id to fetch all projects in the tenant. + if not filters.shelter: + company_filter = "AND ih.company_id = :company_id" + params["company_id"] = filters.company_id + + return company_filter, params + +def _query_ped(filters: SaldosFilter) -> tuple: + level_filter = _subpartida_sql(filters.level) + date_filter = "" + company_filter, params = _get_base_query_filters(filters) + + if filters.start_date and filters.end_date: + s, e = filters.start_date, filters.end_date + if s > e: + s, e = e, s + date_filter = "AND CONCAT(ped.year,'-',ped.license,'-',ped.pedimento_number) BETWEEN :start AND :end" + params["start"] = s + params["end"] = e + sql = f""" + SELECT {BASE_SELECT} + FROM a76.item_lines il + {BASE_JOINS} + WHERE ih.tenant_id = :tenant_id + {company_filter} + {date_filter} + {level_filter} + ORDER BY CONCAT(ped.year,ped.customs_office,ped.license,ped.pedimento_number), + ih.invoice_date + """ + return sql, params + + +def _query_fpp(filters: SaldosFilter) -> tuple: + level_filter = _subpartida_sql(filters.level) + date_filter = "" + company_filter, params = _get_base_query_filters(filters) + + if filters.start_date and filters.end_date: + date_filter = "AND pd.payment_date BETWEEN :start AND :end" + params["start"] = filters.start_date + params["end"] = filters.end_date + sql = f""" + SELECT {BASE_SELECT} + FROM a76.item_lines il + {BASE_JOINS} + WHERE ih.tenant_id = :tenant_id + {company_filter} + {date_filter} + {level_filter} + ORDER BY ih.invoice_date + """ + return sql, params + + +def _query_ffa(filters: SaldosFilter) -> tuple: + level_filter = _subpartida_sql(filters.level) + date_filter = "" + company_filter, params = _get_base_query_filters(filters) + + if filters.start_date and filters.end_date: + date_filter = "AND ih.invoice_date BETWEEN :start AND :end" + params["start"] = filters.start_date + params["end"] = filters.end_date + sql = f""" + SELECT {BASE_SELECT} + FROM a76.item_lines il + {BASE_JOINS} + WHERE ih.tenant_id = :tenant_id + {company_filter} + {date_filter} + {level_filter} + ORDER BY ih.invoice_date + """ + return sql, params + + +def _query_par(filters: SaldosFilter) -> tuple: + level_filter = _subpartida_sql(filters.level) + date_filter = "" + id_filter = "" + company_filter, params = _get_base_query_filters(filters) + + if filters.start_date and filters.end_date: + s, e = filters.start_date, filters.end_date + if s > e: + s, e = e, s + id_filter = "AND p.part_number BETWEEN :start AND :end" + params["start"] = s + params["end"] = e + if filters.date_start and filters.date_end: + date_filter = "AND ih.invoice_date BETWEEN :date_start AND :date_end" + params["date_start"] = filters.date_start + params["date_end"] = filters.date_end + sql = f""" + SELECT {BASE_SELECT} + FROM a76.item_lines il + {BASE_JOINS} + WHERE ih.tenant_id = :tenant_id + {company_filter} + {id_filter} + {date_filter} + {level_filter} + ORDER BY p.part_number, ih.invoice_date + """ + return sql, params + + +def _query_cla(filters: SaldosFilter) -> tuple: + level_filter = _subpartida_sql(filters.level) + date_filter = "" + id_filter = "" + company_filter, params = _get_base_query_filters(filters) + + if filters.start_date and filters.end_date: + s, e = filters.start_date, filters.end_date + if s > e: + s, e = e, s + id_filter = "AND cl.class_code BETWEEN :start AND :end" + params["start"] = s + params["end"] = e + if filters.date_start and filters.date_end: + date_filter = "AND ih.invoice_date BETWEEN :date_start AND :date_end" + params["date_start"] = filters.date_start + params["date_end"] = filters.date_end + sql = f""" + SELECT {BASE_SELECT} + FROM a76.item_lines il + {BASE_JOINS} + WHERE ih.tenant_id = :tenant_id + {company_filter} + {id_filter} + {date_filter} + {level_filter} + ORDER BY cl.class_code, ih.invoice_date + """ + return sql, params + + +# --------------------------------------------------------------------------- +# Row-level business logic (Clarion _build_row equivalent) +# --------------------------------------------------------------------------- + +def _fraccion(row: Dict, include_regla_octava: bool) -> str: + c26 = str(row.get("C26") or "") + c45 = str(row.get("C45") or "") + target = c45 if (include_regla_octava and c45) else c26 + if target and target[0] == "0": + return "'" + target + return target + + +def _build_row( + row: Dict, + db: Session, + filters: SaldosFilter, + *, + num_parte_field: str = "C36", +) -> Optional[Dict]: + """ + Apply business logic and enrich with per-row DB lookups. + Returns None if the row should be filtered out. + """ + use_mn = filters.currency == "national" + use_fp = filters.exchange_rate == "payment_date" + + # CANTIDADES + cant_orig = _d(row.get("C17")) + cant_ret = _d(row.get("C18")) + _d(row.get("C37")) + cant_saldo = cant_orig - cant_ret + + if filters.omit_low_balance and cant_saldo <= Decimal(0): + return None + + # PESO + peso_neto = _d(row.get("C30")) + peso_usado = (cant_ret * peso_neto / cant_orig) if cant_orig != 0 else Decimal(0) + peso_saldo = peso_neto - peso_usado + + # TIPO DE CAMBIO — per Clarion logic: + # Si TipoPedimentoTransporteE IN ('4','1','98E') → usar Fecha_Inicio, else Fecha_Pago + # If explicitly "invoice_date", we use invoice_date (C11) instead. + tc = Decimal(1) + fecha_pago = row.get("C7") + fecha_inicio = row.get("C9") + fecha_factura= row.get("C11") + transport_type = str(row.get("C44") or "") + + tc_fecha_display = None + # TIPO DE CAMBIO + tc_fecha = None + if use_fp: + tc_fecha = fecha_inicio if transport_type in ("1", "4", "98E") else fecha_pago + else: + tc_fecha = fecha_factura + + if tc_fecha: + target_company_id = row.get("company_id") or filters.company_id + tc_val = _get_exchange_rate(db, tc_fecha, filters.tenant_id, target_company_id) + if tc_val: + tc = tc_val + elif filters.shelter: + # In Shelter mode, missing exchange rate skips the row (enforce strict data) + return None + + # VALOR ORIGINAL + if use_mn: + valor_orig = _d(row.get("C22")) * tc if use_fp and fecha_pago else _d(row.get("C20")) + else: + valor_orig = _d(row.get("C22")) + + # VALOR USADO + if cant_orig != 0: + if use_mn: + if use_fp and fecha_pago: + valor_usado = cant_ret * _d(row.get("C22")) * tc / cant_orig + else: + valor_usado = cant_ret * _d(row.get("C20")) / cant_orig + else: + valor_usado = cant_ret * _d(row.get("C22")) / cant_orig + else: + valor_usado = Decimal(0) + + # VALOR SALDO — SubPartida logic + es_subpartida = str(row.get("C39") or "") + contiene_subp = str(row.get("C47") or "") + + if es_subpartida == "S": + valor_saldo = ( + _d(row.get("C20")) - _d(row.get("C21")) + if use_mn else + _d(row.get("C22")) - _d(row.get("C23")) + ) + elif es_subpartida == "P" and contiene_subp == "S": + valor_saldo = ( + valor_orig - _d(row.get("C21")) + if use_mn else + valor_orig - _d(row.get("C23")) + ) + else: + valor_saldo = valor_orig - valor_usado + + # BALANCE TYPE filter + is_repair = row.get("C4") + if filters.balance_type == "normal" and is_repair: + return None + if filters.balance_type == "repair" and not is_repair: + return None + + # FRACCION ARANCELARIA + fraccion = _fraccion(row, filters.include_regla_octava) + + # NUM PARTE display + if num_parte_field == "C13" or filters.print_class: + num_parte_display = str(row.get("C13") or "") + else: + num_parte_display = str(row.get("C36") or "") + + # Series (per-row DB lookup) + series_data = _get_series_data(db, row.get("C34")) + + # UMTarifa (Clarion: query sFracciones with fraccion) + um_tarifa = _get_um_tarifa(db, fraccion) + + # FraccionAme (Clarion: ACCESS:QClaAct → EqiCla:FraccionAme) + fraccion_ame = _get_fraccion_ame(db, row.get("C13_class_id")) + + # TipoCambio value to embed in CSV + tc_value = tc if tc != Decimal(1) else (tc if _get_exchange_rate(db, tc_fecha, filters.tenant_id, target_company_id) else None) + tipo_cambio_str = str(tc_value) if tc_value else "" + + # Pedimento R1 logic (Clarion: some companies invert ped / pedR1) + ped_impo = str(row.get("C2") or "") + ped_r1 = str(row.get("C5R1") or "") + + # Fechas formateadas + fecha_pago_fmt = _fmt_date(fecha_pago) + fecha_entrada_fmt = _fmt_date(row.get("C11")) + fecha_emision_fmt = _fmt_date(row.get("C12_emission")) + + # Aduana cruce (first 2-3 chars per Clarion) + aduana = str(row.get("C8") or "") + + return { + "TipoMovimiento": "Importación Temporal:", + "Pedimento": ped_impo, + "PedimentoR1": ped_r1, + "ClavePed": str(row.get("C48") or ""), # pedimento_code = Clave + "FechaPago": fecha_pago_fmt, + "FechaEntrada": fecha_entrada_fmt, + "FechaEmision": fecha_emision_fmt, + "Factura": "'" + str(row.get("C1") or ""), # leading ' for Excel + "Linea": str(row.get("C35") or ""), + "Tipo": "P" if es_subpartida == "P" else ("S" if es_subpartida == "S" else ""), + "NumParteClase": num_parte_display, + "NumParteFijo": str(row.get("C36") or ""), + "DescripcionEsp": _clean(row.get("C14") or ""), + "DescripcionIng": _clean(row.get("C15") or ""), + "CantidadOriginal": _fmt_num(cant_orig), + "UM": str(row.get("C19") or ""), + "PesoNeto": _fmt_num(peso_neto), + "ValorOriginal": _fmt_num(valor_orig), + "CantidadUsada": _fmt_num(cant_ret), + "PesoUsado": _fmt_num(peso_usado), + "ValorUsado": _fmt_num(valor_usado), + "CantidadSaldo": _fmt_num(cant_saldo), + "PesoSaldo": _fmt_num(peso_saldo), + "ValorSaldo": _fmt_num(valor_saldo), + "FraccionImpo": fraccion, + "Preferencia": str(row.get("C27") or ""), + "PaisOrigen": str(row.get("C16") or ""), + "Sector": str(row.get("C29") or ""), + "Marca": str(row.get("C32") or ""), + "Modelo": str(row.get("C33") or ""), + "SeriesSolas": series_data["SeriesSolas"], + "Assets": "", # QAssetTag — pending mapping + "EDocument": str(row.get("C40") or ""), + "NumOperacionVU": str(row.get("C41") or ""), + "TipoMaqEquipo": str(row.get("C42") or ""), + "UbicacionMaq": str(row.get("C38") or ""), + "ModeloSeries": series_data["ModeloSeries"], + "NumParteSeries": series_data["NumParteSeries"], + "NoId": series_data["NoId"], + "Pedimento18": str(row.get("C43") or ""), + "AduanaCruce": "'" + aduana, + "NumEmbarque": str(row.get("C_embarque") or ""), + "UMTarifa": um_tarifa, + "IdType": str(row.get("C50") or ""), + "Secuencia": "", # not in Anexo76 models, leave blank + "FraccionAmericana": fraccion_ame, + "Proyecto": str(row.get("C_proyecto") or "") if filters.shelter else "", + "TipoCambio": tipo_cambio_str, + # Internal only (not written to CSV, used for ordering) + "_FechaFacturaRaw": row.get("C11"), + "_PedimentoRaw": ped_impo, + "_NumParteRaw": str(row.get("C36") or ""), + "_ClaseRaw": str(row.get("C13") or ""), + } + + +# --------------------------------------------------------------------------- +# CSV header definitions per range type (mirrors Clarion GTxt:Linea headers) +# --------------------------------------------------------------------------- + +# Common columns (all range types end with these) +_COMMON_END = [ + "FraccionImpo", "Preferencia", "PaisOrigen", "Sector", + "Marca", "Modelo", "SeriesSolas", "Assets", + "EDocument", "NumOperacionVU", "TipoMaqEquipo", + "UbicacionMaq", "ModeloSeries", "NumParteSeries", "NoId", + "Pedimento18", "AduanaCruce", "NumEmbarque", + "UMTarifa", "IdType", "Secuencia", "FraccionAmericana", + "Proyecto", "TipoCambio", +] + +# Column list per range type +COLUMNS_PED = [ + "TipoMovimiento", "Pedimento", "ClavePed", "FechaPago", "PedimentoR1", + "NumParteClase", "NumParteFijo", "DescripcionEsp", "DescripcionIng", + "FechaEntrada", "FechaEmision", "Factura", "Linea", "Tipo", + "CantidadOriginal", "UM", "PesoNeto", "ValorOriginal", + "CantidadUsada", "PesoUsado", "ValorUsado", + "CantidadSaldo", "PesoSaldo", "ValorSaldo", +] + _COMMON_END + +COLUMNS_FECHA = [ + "TipoMovimiento", "FechaEntrada", "Pedimento", "FechaPago", "ClavePed", "PedimentoR1", + "NumParteClase", "NumParteFijo", "DescripcionEsp", "DescripcionIng", + "Factura", "FechaEmision", "Linea", "Tipo", + "CantidadOriginal", "UM", "PesoNeto", "ValorOriginal", + "CantidadUsada", "PesoUsado", "ValorUsado", + "CantidadSaldo", "PesoSaldo", "ValorSaldo", +] + _COMMON_END + +COLUMNS_PAR = [ + "TipoMovimiento", "NumParteClase", "NumParteFijo", "DescripcionEsp", "DescripcionIng", + "FechaEntrada", "FechaEmision", "Factura", "Linea", "Tipo", + "Pedimento", "ClavePed", "FechaPago", "PedimentoR1", + "CantidadOriginal", "UM", "PesoNeto", "ValorOriginal", + "CantidadUsada", "PesoUsado", "ValorUsado", + "CantidadSaldo", "PesoSaldo", "ValorSaldo", +] + _COMMON_END + +COLUMNS_CLA = [ + "TipoMovimiento", "NumParteClase", "NumParteFijo", "DescripcionEsp", "DescripcionIng", + "FechaEntrada", "FechaEmision", "Factura", "Linea", "Tipo", + "Pedimento", "ClavePed", "FechaPago", "PedimentoR1", + "CantidadOriginal", "UM", "PesoNeto", "ValorOriginal", + "CantidadUsada", "PesoUsado", "ValorUsado", + "CantidadSaldo", "PesoSaldo", "ValorSaldo", +] + _COMMON_END + +_HEADER_LABELS: Dict[str, str] = { + "TipoMovimiento": "Tipo Movimiento", + "Pedimento": "Pedimento", + "PedimentoR1": "Pedimento Rectificacion", + "ClavePed": "Clave", + "FechaPago": "Fecha Pago", + "FechaEntrada": "Fecha Entrada", + "FechaEmision": "Fecha de Emisión", + "Factura": "Factura", + "Linea": "Línea", + "Tipo": "Tipo", + "NumParteClase": "Num. Parte/Clase", + "NumParteFijo": "Num.Parte", + "DescripcionEsp": "Descripción Español", + "DescripcionIng": "Descripción Inglés", + "CantidadOriginal": "Cantidad Orig.", + "UM": "U.M.", + "PesoNeto": "Peso Orig.", + "ValorOriginal": "Valor Orig.", + "CantidadUsada": "Cantidad Usada", + "PesoUsado": "Peso Usado", + "ValorUsado": "Valor Usado", + "CantidadSaldo": "Cantidad Saldo", + "PesoSaldo": "Peso Saldo", + "ValorSaldo": "Valor Saldo", + "FraccionImpo": "Fracción Arancelaria", + "Preferencia": "Preferencia", + "PaisOrigen": "País", + "Sector": "Sector", + "Marca": "Marca", + "Modelo": "Modelo", + "SeriesSolas": "Series", + "Assets": "Asset Tags", + "EDocument": "E-Document", + "NumOperacionVU": "Núm. Operación", + "TipoMaqEquipo": "Tipo de Activo Fijo", + "UbicacionMaq": "Ubicacion", + "ModeloSeries": "Modelo", + "NumParteSeries": "Num. Parte Serie", + "NoId": "Num ID", + "Pedimento18": "Pedimento 18", + "AduanaCruce": "Aduana Cruce", + "NumEmbarque": "Advalorem", # Clarion col position: NumEmbarque after AduanaCruce + "UMTarifa": "U.M. Tarifa", + "IdType": "ID TYPE", + "Secuencia": "Secuencia", + "FraccionAmericana": "Fracción Americana", + "Proyecto": "", # conditional (Shelter) + "TipoCambio": "Tipo de Cambio", +} + +_COLUMNS_BY_RANGE = { + "pedimento": COLUMNS_PED, + "payment_date": COLUMNS_FECHA, + "invoice_date": COLUMNS_FECHA, + "parts": COLUMNS_PAR, + "classes": COLUMNS_CLA, +} + +# --------------------------------------------------------------------------- +# Main fetch +# --------------------------------------------------------------------------- + +def fetch_saldos_data(db: Session, filters: SaldosFilter) -> List[Dict]: + range_map = { + "pedimento": _query_ped, + "payment_date": _query_fpp, + "invoice_date": _query_ffa, + "parts": _query_par, + "classes": _query_cla, + } + + builder = range_map.get(filters.range_type, _query_ped) + sql, params = builder(filters) + + if filters.asset_type: + sql = sql.replace( + "ORDER BY", + "AND cl.material_key = :asset_type\n ORDER BY", + ) + params["asset_type"] = filters.asset_type + + try: + result = db.execute(text(sql), params) + raw_rows = [dict(r._mapping) for r in result] + except Exception as e: + logger.error(f"fetch_saldos_data SQL error [{filters.range_type}]: {e}", exc_info=True) + return [] + + num_parte_field = "C13" if filters.range_type == "classes" else "C36" + + processed: List[Dict] = [] + for raw in raw_rows: + # In-memory filters (Clarion CYCLE) + if filters.buyer and str(raw.get("C_sold_to") or "") != filters.buyer: + continue + if filters.provider and str(raw.get("C3") or "") != filters.provider: + continue + if filters.location and str(raw.get("C38") or "") != filters.location: + continue + if filters.asset_class and filters.range_type != "classes": + if str(raw.get("C13") or "") != filters.asset_class: + continue + if filters.pedimento_code and str(raw.get("C48") or "") != filters.pedimento_code: + continue + + row_data = _build_row(raw, db, filters, num_parte_field=num_parte_field) + if row_data is not None: + processed.append(row_data) + + logger.info(f"fetch_saldos_data: {len(processed)} rows (range_type={filters.range_type})") + return processed + + +# --------------------------------------------------------------------------- +# CSV builder +# --------------------------------------------------------------------------- + +def generate_saldos_csv(filters: SaldosFilter, db: Optional[Session] = None) -> str: + """Build the CSV string matching Clarion's GENERA_EXCEL_CSV_* output.""" + output = io.StringIO() + writer = csv.writer(output) + + columns = _COLUMNS_BY_RANGE.get(filters.range_type, COLUMNS_PED) + headers = [_HEADER_LABELS.get(c, c) for c in columns] + if filters.shelter: + # Enforce header label if Shelter is active + for i, col in enumerate(columns): + if col == "Proyecto": + headers[i] = "Proyecto" + + # Dynamically build legacy title + range_map_title = { + "pedimento": "Por RANGO de PEDIMENTO", + "payment_date": "Por RANGO de FECHA PAGO", + "invoice_date": "Por RANGO de FECHA FAC.", + "parts": "Por PARTE", + "classes": "Por CLASE", + } + rango_str = range_map_title.get(filters.range_type, "") + + report_type_map = { + "normal": "Normal", + "detailed": "Detallado", + "with_download": "Con Descargas", + } + tipo_reporte_str = report_type_map.get(filters.report_type, "Normal") + agrupacion_str = "Por CLASE" if filters.print_class else "Por PARTE" + + title_row = f"REPORTE DE SALDOS DE ACTIVO FIJO {rango_str}, {tipo_reporte_str} {agrupacion_str}" + + # Fetch company profile if DB available + c_name = "" + c_rfc = "" + c_immex = "" + main_addr_str1 = "" + main_addr_str2 = "" + main_addr_str3 = "" + ind_addr_str1 = "" + ind_addr_str2 = "" + ind_addr_str3 = "" + + if db is not None: + try: + from api.v1.modules.a76.general_catalogs.company.models import Company + + query = db.query(Company) + if filters.company_id: + query = query.filter(Company.id == filters.company_id) + + company = query.first() + if company: + c_name = company.name or "" + c_rfc = company.rfc or "" + # IMMEX often uses both program and program_number + p_base = company.program or "" + p_num = company.program_number or "" + if p_base and p_num: + c_immex = f"{p_base}-{p_num}" + else: + c_immex = p_base or p_num or "" + + for addr in company.addresses: + if addr.address_type == "main": + main_addr_str1 = f"Domicilio Fiscal: {addr.street or ''} Ext. Num: {addr.exterior_number or ''}".strip() + main_addr_str2 = f"{addr.neighborhood or ''} Código Postal: {addr.postal_code or ''}".strip() + main_addr_str3 = f"{addr.city or ''} {addr.state or ''}".strip() + elif addr.address_type == "industrial": + ind_addr_str1 = f"Domicilio Industrial: {addr.street or ''} Ext. Num: {addr.exterior_number or ''}".strip() + ind_addr_str2 = f"{addr.neighborhood or ''} Código Postal: {addr.postal_code or ''}".strip() + ind_addr_str3 = f"{addr.city or ''} {addr.state or ''}".strip() + except Exception as e: + logger.warning(f"Could not load company info for headers: {e}") + + # Format Spanish dates correctly (e.g. 5 MAR 2026 Hora Generación: 09:09PM) + # Use America/Mexico_City timezone to match user's local time (-06:00) + tz = pytz.timezone('America/Mexico_City') + now = datetime.now(tz) + mo_es = {1:"ENE", 2:"FEB", 3:"MAR", 4:"ABR", 5:"MAY", 6:"JUN", 7:"JUL", 8:"AGO", 9:"SEP", 10:"OCT", 11:"NOV", 12:"DIC"}[now.month] + fecha_gen = f"{now.day} {mo_es} {now.year}" + hora_gen = now.strftime("%I:%M%p").upper() + + # Write Title block strictly matching Clarion + writer.writerow([title_row]) + if c_name: writer.writerow([c_name]) + if main_addr_str1: writer.writerow([main_addr_str1]) + if main_addr_str2: writer.writerow([main_addr_str2]) + if main_addr_str3: writer.writerow([main_addr_str3]) + if ind_addr_str1: writer.writerow([ind_addr_str1]) + if ind_addr_str2: writer.writerow([ind_addr_str2]) + if ind_addr_str3: writer.writerow([ind_addr_str3]) + writer.writerow([f"R.F.C: {c_rfc}"]) + writer.writerow([f"IMMEX: {c_immex}"]) + writer.writerow([f"Fecha Generación: {fecha_gen} Hora Generación: {hora_gen}"]) + writer.writerow(["PROVEEDOR DE SOFTWARE: ADUANASOFT"]) + writer.writerow([]) + writer.writerow([]) + + writer.writerow(headers) + writer.writerow([]) # blank second header row (Clarion: ConDescarga=0 → blank) + + if db is not None: + rows = fetch_saldos_data(db, filters) + else: + rows = [{c: f"DEMO-{c}" for c in columns}] + + for row in rows: + writer.writerow([row.get(col, "") for col in columns]) + + writer.writerow([]) + return output.getvalue() diff --git a/backend/api/v1/modules/a76/reports/movements/saldos/routes.py b/backend/api/v1/modules/a76/reports/movements/saldos/routes.py new file mode 100644 index 00000000..2d7341cd --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/saldos/routes.py @@ -0,0 +1,81 @@ +""" +FastAPI routes for Saldos Temporales report. + +Endpoints: + POST /generate – trigger async CSV generation (returns task_id) + GET /task/{id} – poll task status +""" +import logging +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from core.database import get_core_db +from core.security import get_current_user +from .schemas import SaldosFilter + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["Reports - Saldos Temporales"]) + + +@router.post( + "/generate", + summary="Generate Saldos Temporales CSV (Async)", + description=( + "Triggers a background Celery task to generate the Saldos Temporales CSV. " + "Returns a `task_id` that can be polled via `/task/{task_id}`." + ), +) +def generate_saldos_report_async( + filters: SaldosFilter, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """Enqueue the Celery task and return its ID.""" + from .tasks import generate_saldos_temporales_async + from core.security import validate_access_to_resource + + logger.info( + f"User {current_user.get('preferred_username', 'unknown')} " + f"triggering Saldos Temporales report generation" + ) + + # validate_access_to_resource returns the integer tenant_id from DB + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Inject scoping fields (not from the UI body) + filters.company_id = company_id + filters.tenant_id = tenant_id + + filter_data = filters.model_dump() + user_email = current_user.get("email") + + task = generate_saldos_temporales_async.delay(filter_data, user_email) + return {"task_id": task.id} + + +@router.get( + "/task/{task_id}", + summary="Get Saldos Temporales Task Status", + description="Poll the status of a background Saldos Temporales generation task.", +) +def get_saldos_task_status(task_id: str): + """Return current status and (when ready) the result of the Celery task.""" + from celery.result import AsyncResult + from core.celery_app import celery_app + + task_result = AsyncResult(task_id, app=celery_app) + + response: dict = { + "task_id": task_id, + "status": task_result.status, + } + + if task_result.state == "PROCESSING": + response["meta"] = task_result.info + + if task_result.ready(): + response["result"] = task_result.result + + return response diff --git a/backend/api/v1/modules/a76/reports/movements/saldos/schemas.py b/backend/api/v1/modules/a76/reports/movements/saldos/schemas.py new file mode 100644 index 00000000..5eaeda47 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/saldos/schemas.py @@ -0,0 +1,52 @@ +""" +Schemas for Saldos Temporales report. +""" +from typing import Optional +from pydantic import BaseModel + + +class SaldosFilter(BaseModel): + """ + Filter parameters for the Saldos Temporales CSV report. + Maps directly to the UI options chosen by the user. + """ + # Range / Period + range_type: str = "pedimento" # pedimento | payment_date | invoice_date | parts | classes + start_date: Optional[str] = None # identifier "from" (e.g. pedimento number or part string) + end_date: Optional[str] = None # identifier "to" + date_start: Optional[str] = None # Explicit start date + date_end: Optional[str] = None # Explicit end date + + # Column 2 – Filtros e Identificadores + currency: str = "foreign" # foreign | national + report_type: str = "normal" # normal | detailed | with_download + exchange_rate: str = "invoice_date" # invoice_date | payment_date + omit_low_balance: bool = False + balance_type: str = "normal" # normal | repair | both + level: str = "all" # partida | subpartida | all + + # Column 3 – Configuración Final + end_date_as_cutoff: bool = False + show_pending_series: bool = False + include_asset_tag_images: bool = False + use_large_asset_tag_icons: bool = False + send_email: bool = False + julian_date: bool = False + include_regla_octava: bool = False + shelter: bool = False + + # Print options (Column 1) + print_part: bool = False + print_class: bool = False + + # Optional identifiers populated from catalog selectors + provider: Optional[str] = None + buyer: Optional[str] = None + asset_class: Optional[str] = None # Clase + asset_type: Optional[str] = None # Parte + location: Optional[str] = None + pedimento_code: Optional[str] = None # Clave de Pedimento + + # Security / scoping (set by the route, not by the UI) + company_id: Optional[int] = None + tenant_id: Optional[int] = None diff --git a/backend/api/v1/modules/a76/reports/movements/saldos/tasks.py b/backend/api/v1/modules/a76/reports/movements/saldos/tasks.py new file mode 100644 index 00000000..070543e1 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/saldos/tasks.py @@ -0,0 +1,103 @@ +""" +Celery task for asynchronous Saldos Temporales CSV generation. +""" +import base64 +import logging +from typing import Dict, Any + +from core.celery_app import celery_app +from core.email import EmailService +from datetime import datetime + +from .schemas import SaldosFilter +from .csv_utils import generate_saldos_csv + +logger = logging.getLogger(__name__) + + +@celery_app.task(bind=True, name="generate_saldos_temporales_async") +def generate_saldos_temporales_async( + self, + filter_data: Dict[str, Any], + user_email: str = None +): + """ + Async Celery task: generate Saldos Temporales CSV and optionally e-mail it. + """ + try: + # 1. Progress – initialising + self.update_state( + state="PROCESSING", + meta={"current": 10, "total": 100, "status": "Inicializando reporte de Saldos Temporales..."}, + ) + + # 2. Re-construct filter + filters = SaldosFilter(**filter_data) + + # 3. Fetch + generate CSV (real DB session) + self.update_state( + state="PROCESSING", + meta={"current": 40, "total": 100, "status": "Generando datos de Saldos Temporales..."}, + ) + logger.info(f"Saldos task: building CSV (range_type={filters.range_type})") + + from core.database import CoreSessionLocal + db = CoreSessionLocal() + try: + csv_content = generate_saldos_csv(filters, db=db) + finally: + db.close() + + # 4. Optional e-mail + 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: + import asyncio + from asgiref.sync import async_to_sync + + filename = ( + f"saldos_temporales_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + ) + result = async_to_sync(EmailService.send_report_email)( + recipient_email=user_email, + subject=f"Saldos Temporales – {datetime.now().strftime('%d/%m/%Y')}", + body_text="Se adjunta el reporte de Saldos Temporales generado.", + csv_content=csv_content, + filename=filename, + ) + email_sent = bool(result) + except Exception as e: + logger.error(f"Saldos task: email error: {e}") + + # 5. Encode to base64 and return + self.update_state( + state="PROCESSING", + meta={"current": 95, "total": 100, "status": "Finalizando..."}, + ) + + content_b64 = base64.b64encode(csv_content.encode("utf-8")).decode("utf-8") + filename = f"saldos_temporales_{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_saldos_temporales_async: {e}", exc_info=True) + self.update_state( + state="FAILURE", + meta={ + "exc_type": type(e).__name__, + "exc_message": str(e), + "custom": "Error generating Saldos Temporales report", + }, + ) + raise diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index eafc4d2b..ae3aa47e 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -38,6 +38,7 @@ from .reports.importacion.consolidados.routes import router as consolidated_repo from .reports.importacion.packing_list.routes import router as packing_list_router from .reports.exportacion.aviso_consolidado.routes import router as aviso_consolidado_export_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.exportacion.descargo.routes import router as discharge_reports_router from .manifests.manifest.routes import router as manifests_router from .manifests.driver.routes import router as manifest_drivers_router @@ -113,6 +114,12 @@ router.include_router( tags=["a76 / reports"] ) +router.include_router( + movement_saldos_router, + prefix="/a76/reports/movements/saldos", + tags=["a76 / reports"] +) + router.include_router( discharge_reports_router, prefix="/a76/reports/exportacion/descargo", diff --git a/backend/api/v1/modules/public/reference_data/material_types/routes.py b/backend/api/v1/modules/public/reference_data/material_types/routes.py index a90f7b06..159778bf 100644 --- a/backend/api/v1/modules/public/reference_data/material_types/routes.py +++ b/backend/api/v1/modules/public/reference_data/material_types/routes.py @@ -14,7 +14,7 @@ router = APIRouter(prefix="/material-types") @router.get("/", response_model=Dict[str, Any]) async def list_material_types( page: int = Query(1, ge=1, description="Número de página"), - page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), + page_size: int = Query(50, ge=1, le=1000, description="Tamaño de página"), type: str = Query(None, description="Filtrar por tipo (ACTIVO FIJO, MATERIALES, PRODUCTOS)"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), diff --git a/backend/api/v1/modules/public/reference_data/pedimento_codes/routes.py b/backend/api/v1/modules/public/reference_data/pedimento_codes/routes.py index d7d69e4e..93b188f6 100644 --- a/backend/api/v1/modules/public/reference_data/pedimento_codes/routes.py +++ b/backend/api/v1/modules/public/reference_data/pedimento_codes/routes.py @@ -14,7 +14,7 @@ router = APIRouter(prefix="/pedimento-codes") @router.get("/", response_model=Dict[str, Any]) def list_pedimento_codes( page: int = Query(1, ge=1, description="Número de página"), - page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), + page_size: int = Query(50, ge=1, le=1000, description="Tamaño de página"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index 4af76815..8ebbca7c 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -31,6 +31,7 @@ celery_app.conf.update( "api.v1.modules.a76.reports.importacion.packing_list.task", "api.v1.modules.a76.reports.exportacion.aviso_consolidado.task", "api.v1.modules.a76.reports.movements.invoices.tasks", + "api.v1.modules.a76.reports.movements.saldos.tasks", "api.v1.modules.a76.reports.exportacion.descargo.task", "api.v1.modules.a76.imports.tasks", "api.v1.modules.a76.customs_brokers.imports.tasks", diff --git a/backend/core/email.py b/backend/core/email.py index 41b81a67..c0880988 100644 --- a/backend/core/email.py +++ b/backend/core/email.py @@ -71,9 +71,10 @@ class EmailService: """ msg.attach(MIMEText(html_body, 'html')) - # CSV attachment + # CSV attachment with UTF-8 BOM for Excel compatibility attachment = MIMEBase('text', 'csv') - attachment.set_payload(csv_content.encode('utf-8')) + csv_bytes = b'\xef\xbb\xbf' + csv_content.encode('utf-8') + attachment.set_payload(csv_bytes) encoders.encode_base64(attachment) attachment.add_header( 'Content-Disposition', diff --git a/frontend/src/lib/api/dashboard/a76/saldos-report.ts b/frontend/src/lib/api/dashboard/a76/saldos-report.ts new file mode 100644 index 00000000..fb3629cd --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/saldos-report.ts @@ -0,0 +1,59 @@ +/** + * API Client for Saldos Temporales Report + */ +import { api } from '$lib/api'; + +// ===== TYPES ===== + +export interface SaldosFilter { + // Range / Period + range_type: string; // pedimento | payment_date | invoice_date | parts | classes + start_date?: string | null; // For pedimento/part/class identifiers + end_date?: string | null; // For pedimento/part/class identifiers + date_start?: string | null; // Explicit start date filter + date_end?: string | null; // Explicit end date filter + + // Column 2 – Filtros e Identificadores + currency: string; // foreign | national + report_type: string; // normal | detailed | with_download + exchange_rate: string; // invoice_date | payment_date + omit_low_balance: boolean; + balance_type: string; // normal | repair | both + level: string; // partida | subpartida | all + + // Column 3 – Configuración Final + end_date_as_cutoff: boolean; + show_pending_series: boolean; + include_asset_tag_images: boolean; + use_large_asset_tag_icons: boolean; + send_email: boolean; + julian_date: boolean; + include_regla_octava: boolean; + shelter: boolean; + + // Print options + print_part: boolean; + print_class: boolean; + + // Optional identifiers populated from catalog selectors + provider?: string | null; + buyer?: string | null; + asset_class?: string | null; + asset_type?: string | null; + location?: string | null; + pedimento_code?: string | null; +} + +// ===== API METHODS ===== + +export const saldosReportApi = { + /** Trigger async CSV generation; returns task_id */ + generateReportAsync: (filters: SaldosFilter, companyId: number) => + api.post<{ task_id: string }>(`/v1/a76/reports/movements/saldos/generate?company_id=${companyId}`, filters), + + /** Poll task status */ + getTaskStatus: (taskId: string) => + api.get<{ task_id: string; status: string; result?: any; meta?: any }>( + `/v1/a76/reports/movements/saldos/task/${taskId}` + ) +}; diff --git a/frontend/src/routes/dashboard/reports/invoices/+page.svelte b/frontend/src/routes/dashboard/reports/invoices/+page.svelte index 2776ce68..7334f4e3 100644 --- a/frontend/src/routes/dashboard/reports/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/reports/invoices/+page.svelte @@ -29,11 +29,19 @@ Calculator, Download, Folder, - Eye + Eye, + CalendarRange, + LayoutList, + Image as ImageIcon, + Maximize2, + Table2, + Scale, + Scan } from 'lucide-svelte'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as Dialog from '$lib/components/ui/dialog'; import { toast } from 'svelte-sonner'; + import PdfProgressDialog from '$lib/components/dashboard/invoices/pdf-progress-dialog.svelte'; import { invoiceMovementsApi, type MovementItem, @@ -41,13 +49,24 @@ type AllMovementsFilter } from '$lib/api/dashboard/a76/invoice-movements'; import type { BaseFilter } from '$lib/api/dashboard/a76/invoice-movements'; + import { saldosReportApi, type SaldosFilter } from '$lib/api/dashboard/a76/saldos-report'; import { api } from '$lib/api'; import { companyStore } from '$lib/stores/company.svelte'; + import { page } from '$app/state'; // --- ESTADO CON RUNAS (SVELTE 5) --- + let activeReportType = $state<'invoices' | 'saldos'>('invoices'); + const menuOptions = [ - { label: 'Saldos Temporales', icon: Calculator, items: [] }, + { + label: 'Saldos Temporales', + icon: Calculator, + onclick: () => { + activeReportType = 'saldos'; + showResults = false; + } + }, { label: 'Descargos', icon: FileSpreadsheet, items: [] }, { label: 'Catálogos', @@ -119,36 +138,107 @@ let filters = $state({ includeNA: false, - downloaded: 'all' as 'all' | 'downloaded' | 'not_downloaded' + downloaded: 'all' as 'all' | 'downloaded' | 'not_downloaded', + omitLowBalance: false, + balanceType: 'both' as 'normal' | 'repair' | 'both', + level: 'all' as 'partida' | 'subpartida' | 'all' }); let selectors = $state({ provider: '', soldTo: '', - pedimentoKey: '' + pedimentoKey: '', + assetClass: '', + assetType: '', + location: '' }); let config = $state({ - currency: 'capture' as 'foreign' | 'national' | 'capture', - reportType: 'normal' as 'normal' | 'detailed', + currency: 'foreign' as 'foreign' | 'national', + reportType: 'normal' as 'normal' | 'detailed' | 'with_download', exchangeRate: 'invoice_date' as 'invoice_date' | 'payment_date', sendEmail: false, julianDate: false, - shelter: false + shelter: false, + endDateAsCutoff: false, + showPendingSeries: false, + includeAssetTagImages: false, + useLargeAssetTagIcons: false, + includeReglaOctava: false }); + // --- ESTADO ESPECÍFICO SALDOS TEMPORALES --- + let saldosRangeType = $state<'pedimento' | 'payment_date' | 'invoice_date' | 'parts' | 'classes'>( + 'pedimento' + ); + let saldosIdentifiers = $state({ from: '', to: '' }); + let loading = $state(false); let results = $state([]); let showResults = $state(false); let reportTitle = $state('REPORTE DE FACTURAS'); let currencyLabel = $state('Moneda: Pesos'); + // Progression Dialog State + let showProgressDialog = $state(false); + let currentTaskId = $state(null); + let currentStatusFunction = $state<((taskId: string) => Promise) | null>(null); + // Diálogo de selección let dialogOpen = $state(false); - let dialogType: 'provider' | 'soldTo' | 'pedimentoKey' | null = $state(null); + let dialogType: + | 'provider' + | 'soldTo' + | 'pedimentoKey' + | 'assetClass' + | 'assetType' + | 'location' + | 'pedimentoFrom' + | 'pedimentoTo' + | 'partFrom' + | 'partTo' + | 'classFrom' + | 'classTo' + | null = $state(null); let clientsProviders = $state([]); let pedimentoCodes = $state([]); + let assetClasses = $state([]); + let assetTypes = $state([]); + let locations = $state([]); + let pedimentosList = $state([]); + let partsList = $state([]); let dialogSearch = $state(''); + let saldosPrintPart = $state(true); + let saldosPrintClass = $state(false); + + // Prevenir que ambas opciones se desmarquen (comportamiento Radio obligatorio) + $effect(() => { + if (!saldosPrintPart && !saldosPrintClass) { + if (saldosRangeType === 'classes') { + saldosPrintClass = true; + } else { + saldosPrintPart = true; + } + } + }); + + // Usamos efectos específicos para simular comportamiento de Radio + $effect(() => { + if (saldosPrintPart) saldosPrintClass = false; + }); + $effect(() => { + if (saldosPrintClass) saldosPrintPart = false; + }); + + // Reset condicional de opciones según el tipo de rango seleccionado (UX) + $effect(() => { + if (saldosRangeType === 'parts') { + saldosPrintPart = true; + } else if (saldosRangeType === 'classes') { + saldosPrintClass = true; + if (selectors.assetClass) selectors.assetClass = ''; + } + }); function handleTodasChange(checked: boolean) { if (checked) { @@ -195,6 +285,162 @@ } } + onMount(() => { + const type = page.url.searchParams.get('type'); + if (type === 'saldos') { + activeReportType = 'saldos'; + } + }); + + function handleSaldosRangeTypeChange(type: typeof saldosRangeType) { + saldosRangeType = type; + dates.from = ''; + dates.to = ''; + saldosIdentifiers.from = ''; + saldosIdentifiers.to = ''; + } + + function generateSaldosReport() { + // Validaciones de rango + if ( + saldosRangeType === 'pedimento' || + saldosRangeType === 'parts' || + saldosRangeType === 'classes' + ) { + if (!saldosIdentifiers.from || !saldosIdentifiers.to) { + toast.error('Debe ingresar ambos límites del rango (Desde/Hasta)'); + return; + } + } + + if ( + saldosRangeType === 'payment_date' || + saldosRangeType === 'invoice_date' || + saldosRangeType === 'parts' || + saldosRangeType === 'classes' + ) { + if (!dates.from || !dates.to) { + toast.error('Debe ingresar ambas fechas del rango (Desde/Hasta)'); + return; + } + } + + loading = true; + + const filter: SaldosFilter = { + // Range + range_type: saldosRangeType, + start_date: saldosIdentifiers.from || null, + end_date: saldosIdentifiers.to || null, + date_start: dates.from || null, + date_end: dates.to || null, + + // Column 2 + currency: config.currency, + report_type: config.reportType, + exchange_rate: config.exchangeRate, + omit_low_balance: filters.omitLowBalance, + balance_type: filters.balanceType, + level: filters.level, + + // Column 3 + end_date_as_cutoff: config.endDateAsCutoff, + show_pending_series: config.showPendingSeries, + include_asset_tag_images: config.includeAssetTagImages, + use_large_asset_tag_icons: config.useLargeAssetTagIcons, + send_email: config.sendEmail, + julian_date: config.julianDate, + include_regla_octava: config.includeReglaOctava, + shelter: config.shelter, + + // Print options + print_part: saldosPrintPart, + print_class: saldosPrintClass, + + // Selectors + provider: selectors.provider || null, + buyer: selectors.soldTo || null, + asset_class: selectors.assetClass || null, + asset_type: selectors.assetType || null, + location: selectors.location || null, + pedimento_code: selectors.pedimentoKey || null + }; + + saldosReportApi + .generateReportAsync(filter, companyStore.activeCompany?.id!) + .then((response) => { + const taskId = response.data?.task_id; + if (!taskId) { + toast.error('Error al iniciar la generación del reporte'); + loading = false; + return; + } + + // Adapter para PdfProgressDialog (mapea status->state, meta->info) + const getMappedTaskStatus = async (id: string) => { + const res = await saldosReportApi.getTaskStatus(id); + return { + state: res.data?.status || 'UNKNOWN', + info: res.data?.meta, + result: res.data?.result + }; + }; + + // Mostrar el dialogo de progreso + showProgressDialog = true; + currentTaskId = taskId; + currentStatusFunction = getMappedTaskStatus; + }) + .catch((error: any) => { + toast.error(error.message || 'Error al iniciar la generación del reporte'); + loading = false; + }); + } + + function closeProgressDialog() { + showProgressDialog = false; + currentTaskId = null; + currentStatusFunction = null; + } + + function onCsvComplete(result: any) { + try { + if (result.status === 'success') { + const base64 = result.content; + const binStr = atob(base64); + const len = binStr.length; + const arr = new Uint8Array(len); + for (let i = 0; i < len; i++) { + arr[i] = binStr.charCodeAt(i); + } + // Agregar Byte Order Mark (BOM) para que Excel interprete correctamente el UTF-8 + const bom = new Uint8Array([0xef, 0xbb, 0xbf]); + const blob = new Blob([bom, arr], { type: result.media_type || 'text/csv;charset=utf-8' }); + + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = result.file_name || 'saldos_temporales.csv'; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + toast.success('Reporte descargado exitosamente'); + } else { + toast.error('El worker reportó un error: ' + (result.message || 'Desconocido')); + } + } catch (e) { + console.error('Error al procesar descarga:', e); + toast.error('Error al procesar el archivo descargado'); + } finally { + setTimeout(() => { + showProgressDialog = false; + currentTaskId = null; + loading = false; + }, 1000); + } + } + // --- DERIVED STATE FOR EXPORT VALIDATION --- /** @@ -909,7 +1155,7 @@ loading = false; } - async function openSelectionDialog(type: 'provider' | 'soldTo' | 'pedimentoKey') { + async function openSelectionDialog(type: NonNullable) { dialogType = type; dialogSearch = ''; @@ -917,7 +1163,7 @@ // Cargar claves de pedimento try { const response = await api.get( - '/v1/public/refrence_data/pedimento-codes?page=1&page_size=100' + '/v1/public/reference_data/pedimento-codes/?page=1&page_size=1000' ); pedimentoCodes = response.data?.items || []; dialogOpen = true; @@ -928,6 +1174,95 @@ return; } + if (type === 'assetClass') { + try { + const companyId = companyStore.activeCompany?.id; + const response = await api.get( + `/v1/a76/classes/with-fa-data?company_id=${companyId}&page_size=1000${config.shelter ? '&all_companies=true' : ''}` + ); + assetClasses = response.data || []; + dialogOpen = true; + } catch (error: any) { + console.error('Error cargando clases:', error); + toast.error('Error al cargar las clases'); + } + return; + } + + if (type === 'assetType') { + try { + const response = await api.get( + `/v1/public/reference_data/material-types/?type=${encodeURIComponent('ACTIVO FIJO')}&page_size=1000` + ); + assetTypes = response.data?.items || []; + dialogOpen = true; + } catch (error: any) { + console.error('Error cargando tipos de activo:', error); + toast.error('Error al cargar los tipos de activo'); + } + return; + } + + if (type === 'location') { + try { + const companyId = companyStore.activeCompany?.id; + const response = await api.get( + `/v1/a76/ports/?company_id=${companyId}&page_size=1000${config.shelter ? '&all_companies=true' : ''}` + ); + locations = response.data?.items || []; + dialogOpen = true; + } catch (error: any) { + console.error('Error cargando localizaciones:', error); + toast.error('Error al cargar las localizaciones'); + } + return; + } + + if (type === 'pedimentoFrom' || type === 'pedimentoTo') { + try { + const companyId = companyStore.activeCompany?.id; + const response = await api.get( + `/v1/a76/pedimentos/?company_id=${companyId}&page_size=1000${config.shelter ? '&all_companies=true' : ''}` + ); + pedimentosList = response.data?.items || []; + dialogOpen = true; + } catch (error: any) { + console.error('Error cargando pedimentos:', error); + toast.error('Error al cargar los pedimentos'); + } + return; + } + + if (type === 'partFrom' || type === 'partTo') { + try { + const companyId = companyStore.activeCompany?.id; + const response = await api.get( + `/v1/a76/parts/?company_id=${companyId}&page_size=1000${config.shelter ? '&all_companies=true' : ''}` + ); + partsList = response.data?.items || []; + dialogOpen = true; + } catch (error: any) { + console.error('Error cargando partes:', error); + toast.error('Error al cargar las partes'); + } + return; + } + + if (type === 'classFrom' || type === 'classTo') { + try { + const companyId = companyStore.activeCompany?.id; + const response = await api.get( + `/v1/a76/classes/with-fa-data?company_id=${companyId}&page_size=1000${config.shelter ? '&all_companies=true' : ''}` + ); + assetClasses = response.data || []; + dialogOpen = true; + } catch (error: any) { + console.error('Error cargando clases:', error); + toast.error('Error al cargar las clases'); + } + return; + } + const companyId = companyStore.activeCompany?.id; if (!companyId) { toast.error('No hay compañía seleccionada'); @@ -938,7 +1273,7 @@ // No filtramos por tipo en el API para obtener todos los registros // Filtraremos client-side para incluir 'both' const response = await api.get( - `/v1/a76/clients-providers?company_id=${companyId}&limit=1000` + `/v1/a76/clients-providers?company_id=${companyId}&limit=1000${config.shelter ? '&all_companies=true' : ''}` ); const allItems = response.data?.items || []; @@ -964,29 +1299,157 @@ selectors.soldTo = item.name || ''; } else if (dialogType === 'pedimentoKey') { selectors.pedimentoKey = item.code || ''; + } else if (dialogType === 'assetClass') { + selectors.assetClass = item.class_code || ''; + } else if (dialogType === 'assetType') { + selectors.assetType = item.key || ''; + } else if (dialogType === 'location') { + selectors.location = item.location_code || ''; + } else if (dialogType === 'pedimentoFrom') { + saldosIdentifiers.from = `${item.year}-${item.license}-${item.pedimento_number}`; + } else if (dialogType === 'pedimentoTo') { + saldosIdentifiers.to = `${item.year}-${item.license}-${item.pedimento_number}`; + } else if (dialogType === 'partFrom') { + saldosIdentifiers.from = item.part_number || ''; + } else if (dialogType === 'partTo') { + saldosIdentifiers.to = item.part_number || ''; + } else if (dialogType === 'classFrom') { + saldosIdentifiers.from = item.class_code || ''; + } else if (dialogType === 'classTo') { + saldosIdentifiers.to = item.class_code || ''; } dialogOpen = false; } const filteredItems = $derived( dialogType === 'pedimentoKey' - ? pedimentoCodes.filter((item) => { - if (!dialogSearch) return true; - const searchLower = dialogSearch.toLowerCase(); - return ( - item.code?.toLowerCase().includes(searchLower) || - item.description?.toLowerCase().includes(searchLower) - ); - }) - : clientsProviders.filter((item) => { - if (!dialogSearch) return true; - const searchLower = dialogSearch.toLowerCase(); - return ( - item.name?.toLowerCase().includes(searchLower) || - item.rfc?.toLowerCase().includes(searchLower) || - item.tax_id?.toLowerCase().includes(searchLower) - ); - }) + ? pedimentoCodes + .filter((item) => { + if (!dialogSearch) return true; + const searchLower = dialogSearch.toLowerCase(); + return ( + item.code?.toLowerCase().includes(searchLower) || + item.description?.toLowerCase().includes(searchLower) + ); + }) + .sort((a, b) => + (a.code || '').localeCompare(b.code || '', undefined, { + numeric: true, + sensitivity: 'base' + }) + ) + : dialogType === 'assetClass' + ? assetClasses + .filter((item) => { + if (!dialogSearch) return true; + const searchLower = dialogSearch.toLowerCase(); + return ( + item.class_code?.toLowerCase().includes(searchLower) || + item.description_es?.toLowerCase().includes(searchLower) + ); + }) + .sort((a, b) => + (a.class_code || '').localeCompare(b.class_code || '', undefined, { + numeric: true, + sensitivity: 'base' + }) + ) + : dialogType === 'assetType' + ? assetTypes + .filter((item) => { + if (!dialogSearch) return true; + const searchLower = dialogSearch.toLowerCase(); + return ( + item.key?.toLowerCase().includes(searchLower) || + item.description?.toLowerCase().includes(searchLower) + ); + }) + .sort((a, b) => + (a.key || '').localeCompare(b.key || '', undefined, { + numeric: true, + sensitivity: 'base' + }) + ) + : dialogType === 'location' + ? locations + .filter((item) => { + if (!dialogSearch) return true; + const searchLower = dialogSearch.toLowerCase(); + return ( + item.location_code?.toLowerCase().includes(searchLower) || + item.location_description?.toLowerCase().includes(searchLower) + ); + }) + .sort((a, b) => + (a.location_code || '').localeCompare(b.location_code || '', undefined, { + numeric: true, + sensitivity: 'base' + }) + ) + : dialogType === 'pedimentoFrom' || dialogType === 'pedimentoTo' + ? pedimentosList + .filter((item) => { + if (!dialogSearch) return true; + const searchLower = dialogSearch.toLowerCase(); + return ( + item.pedimento_number?.toLowerCase().includes(searchLower) || + item.license?.toLowerCase().includes(searchLower) + ); + }) + .sort((a, b) => + (a.pedimento_number || '').localeCompare(b.pedimento_number || '', undefined, { + numeric: true, + sensitivity: 'base' + }) + ) + : dialogType === 'partFrom' || dialogType === 'partTo' + ? partsList + .filter((item) => { + if (!dialogSearch) return true; + const searchLower = dialogSearch.toLowerCase(); + return ( + item.part_number?.toLowerCase().includes(searchLower) || + item.description_spanish?.toLowerCase().includes(searchLower) + ); + }) + .sort((a, b) => + (a.part_number || '').localeCompare(b.part_number || '', undefined, { + numeric: true, + sensitivity: 'base' + }) + ) + : dialogType === 'classFrom' || dialogType === 'classTo' + ? assetClasses + .filter((item) => { + if (!dialogSearch) return true; + const searchLower = dialogSearch.toLowerCase(); + return ( + item.class_code?.toLowerCase().includes(searchLower) || + item.description_es?.toLowerCase().includes(searchLower) + ); + }) + .sort((a, b) => + (a.class_code || '').localeCompare(b.class_code || '', undefined, { + numeric: true, + sensitivity: 'base' + }) + ) + : clientsProviders + .filter((item) => { + if (!dialogSearch) return true; + const searchLower = dialogSearch.toLowerCase(); + return ( + item.name?.toLowerCase().includes(searchLower) || + item.rfc?.toLowerCase().includes(searchLower) || + item.tax_id?.toLowerCase().includes(searchLower) + ); + }) + .sort((a, b) => + (a.name || '').localeCompare(b.name || '', undefined, { + numeric: true, + sensitivity: 'base' + }) + ) ); @@ -997,8 +1460,21 @@

- - Reporte de Facturas + {#if activeReportType === 'invoices'} + + Reporte de Facturas + {:else} + + + Saldos Temporales + {/if}

-
Sistema Fiscal
+
+ {#if activeReportType === 'invoices'} + Reportes de Movimientos Impo/Expo + {:else} + Reportes de Control Fiscal + {/if} +
@@ -1014,7 +1496,7 @@
{#each menuOptions as item} - {#if item.items.length > 0} + {#if item.items && item.items.length > 0} {#snippet child({ props })} @@ -1051,7 +1533,15 @@ variant="outline" size="sm" class="flex h-10 w-full items-center justify-center gap-2 border-dashed text-muted-foreground transition-all hover:border-solid hover:border-primary/50 hover:bg-primary/5 hover:text-primary" - onclick={() => toast.info(`Acción rápida: ${item.label}`)} + onclick={() => { + if ('onclick' in item && item.onclick) { + (item as any).onclick(); + } else if ('url' in item && item.url) { + window.location.href = (item as any).url; + } else { + toast.info(`Acción rápida: ${item.label}`); + } + }} >