diff --git a/.gitignore b/.gitignore index 88317a96..6aad309e 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ wheels/ backend/.env frontend/.env backend/SCRIPTS/ + # IDEs .vscode/ .idea/ diff --git a/backend/alembic/versions/7937209f9718_seed_initial_data.py b/backend/alembic/versions/7937209f9718_seed_initial_data.py index a6fda94d..e41ecd80 100644 --- a/backend/alembic/versions/7937209f9718_seed_initial_data.py +++ b/backend/alembic/versions/7937209f9718_seed_initial_data.py @@ -73,9 +73,6 @@ from api.v1.modules.a76.general_catalogs.units_of_measure.seed_adua import ( from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.seed import ( seed as tariff_fractions_seed, ) -from api.v1.modules.a76.general_catalogs.fractions.historical_tariff_fractions.seed import ( - seed as historical_tariff_fractions_seed, -) from api.v1.modules.public.reference_data.trailer_types.seed import ( seed as trailer_types_seed, ) @@ -429,25 +426,7 @@ def upgrade() -> None: f"INSERT INTO a76.unit_of_measure_oma (code, description) VALUES {val_add_oma} ON CONFLICT ON CONSTRAINT uq_uom_oma_code DO NOTHING;" ) - # TABLA MAESTRA UOM - # TODO: Generar tenant_id y company_id correctos - val_uom = ", ".join( - [ - f"({format_value(code)}, {format_value(desc)}, {format_value(desc_en)}, " - f"{format_value(customs)}, {format_value(american)}, {format_value(ace)}, {format_value(oma)}, 1, 1)" - for code, desc, desc_en, customs, american, ace, oma in units_of_measure_seed - ] - ) - op.execute("ALTER TABLE a76.units_of_measure DISABLE TRIGGER ALL;") - op.execute( - f""" - INSERT INTO a76.units_of_measure - (code, description, description_en, customs_code, american_code, ace_code, oma_code, tenant_id, company_id) - VALUES {val_uom} - ON CONFLICT (code, tenant_id, company_id) DO NOTHING; - """ - ) - op.execute("ALTER TABLE a76.units_of_measure ENABLE TRIGGER ALL;") + # TABLA MAESTRA UOM se genera ahora al crear una empresa # --- SEEDS CORE (Permissions) --- @@ -501,45 +480,7 @@ def upgrade() -> None: """ ) - def format_bool(val): - """Convert boolean string to SQL boolean.""" - if val is None or str(val).strip() == "" or str(val).upper() == "NONE": - return "NULL" - return "TRUE" if str(val).upper() == "TRUE" else "FALSE" - - def format_timestamp(val): - """Format timestamp for PostgreSQL.""" - if val is None or str(val).strip() == "" or str(val).upper() == "NONE": - return "NULL" - # El valor ya viene en formato 'YYYY-MM-DD HH:MM:SS' - return f"'{str(val)}'" - - values_historical_fractions = ", ".join( - [ - f"({format_value(historical_fraction)}, {format_value(nico)},{format_value(unit_measure)}, {format_value(country)}, " - f"{format_value(fraction_type)}, {format_value(sector)}, {format_value(import_tax)}, " - f"{format_value(export_tax)}, {format_timestamp(pub_date)}, {format_bool(is_immex)}, " - f"{format_bool(normal_temp)}, {format_bool(services_temp)}, {format_bool(certified_temp)}, " - f"{format_bool(by_log)}, {format_timestamp(end_date)}, " - f"1, 1)" # tenant_id=1, company_id=1 - for historical_fraction, nico, unit_measure, country, fraction_type, sector, import_tax, export_tax, pub_date, is_immex, normal_temp, services_temp, certified_temp, by_log, end_date in historical_tariff_fractions_seed - ] - ) - - if values_historical_fractions: - op.execute("SET session_replication_role = replica;") - op.execute( - f""" - INSERT INTO a76.historical_tariff_fractions - (historical_fraction, nico, unit_of_measure_code, country, fraction_type, sector, - import_tax_rate, export_tax_rate, publication_date, is_immex, - normal_temporality, services_temporality, certified_temporality, by_log, end_date, - tenant_id, company_id) - VALUES {values_historical_fractions} - ON CONFLICT DO NOTHING; - """ - ) - op.execute("SET session_replication_role = DEFAULT;") + # Historical Fractions se generan ahora al crear una empresa def downgrade() -> None: diff --git a/backend/api/v1/modules/a76/general_catalogs/company/service.py b/backend/api/v1/modules/a76/general_catalogs/company/service.py index 06b85f7d..e042cea9 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/service.py @@ -3,6 +3,7 @@ Capa de servicio para lógica de negocio de empresa """ import logging +from datetime import datetime from typing import List, Optional, Tuple, Dict, Any from fastapi import HTTPException @@ -12,7 +13,10 @@ from sqlalchemy.orm import Session from .dto import CompanyCreateDTO, CompanyResponseDTO, CompanyUpdateDTO from .models import Company from ...audit_log.services.service import AuditService +from ..units_of_measure.seed import seed as units_of_measure_seed +from ..fractions.historical_tariff_fractions.seed import seed as historical_tariff_fractions_seed from core.context import get_user_context +from sqlalchemy import text logger = logging.getLogger(__name__) @@ -34,7 +38,7 @@ class CompanyService: filters: Optional[Dict[str, Any]] = None, ) -> Tuple[List[Company], int]: """Get all companies for a tenant with pagination""" - query = db.query(Company).filter(Company.tenant_id == tenant_id) + query = db.query(Company).filter(Company.tenant_id == tenant_id, Company.deleted_at.is_(None)) # Apply filters if provided if filters: @@ -62,6 +66,7 @@ class CompanyService: .filter( Company.id == company_id, Company.tenant_id == tenant_id, + Company.deleted_at.is_(None) ) .first() ) @@ -381,7 +386,10 @@ class CompanyService: if addr_ind2: self.db.add(CompanyAddress(**addr_ind2, address_type='industrial2', company_id=db_company.id)) - # 7. Commit + # 7. Seed company data (tenant/company dependent) + self._seed_company_data(self.db, tenant_id, db_company.id) + + # 8. Commit self.db.commit() self.db.refresh(db_company) @@ -584,17 +592,9 @@ class CompanyService: # ---------------------- try: - # Cascading deletes are handled by relationship settings, but manual is safer here - if company.certification: db.delete(company.certification) - if company.prevalidator: db.delete(company.prevalidator) - if company.electronic_agent: db.delete(company.electronic_agent) - if company.ventanilla_unica: db.delete(company.ventanilla_unica) - if company.cfdi: db.delete(company.cfdi) - for cert in company.digital_certificates: db.delete(cert) - for addr in company.addresses: db.delete(addr) - + company.deleted_at = datetime.utcnow() + db.flush() - db.delete(company) db.commit() # --- Audit Log --- @@ -698,11 +698,68 @@ class CompanyService: # Custom methods + def _seed_company_data(self, db: Session, tenant_id: int, company_id: int): + """Seeds tenant/company dependent data for a new company""" + def format_value(val): + if val is None or str(val).strip() == "" or str(val).upper() == "NONE": + return "NULL" + return f"'{str(val).replace(chr(39), chr(39)*2)}'" + + # 1. Units of Measure + val_uom = ", ".join( + [ + f"({format_value(code)}, {format_value(desc)}, {format_value(desc_en)}, " + f"{format_value(customs)}, {format_value(american)}, {format_value(ace)}, {format_value(oma)}, {tenant_id}, {company_id})" + for code, desc, desc_en, customs, american, ace, oma in units_of_measure_seed + ] + ) + + db.execute(text("ALTER TABLE a76.units_of_measure DISABLE TRIGGER ALL;")) + db.execute(text(f"INSERT INTO a76.units_of_measure (code, description, description_en, customs_code, american_code, ace_code, oma_code, tenant_id, company_id) VALUES {val_uom} ON CONFLICT (code, tenant_id, company_id) DO NOTHING;")) + db.execute(text("ALTER TABLE a76.units_of_measure ENABLE TRIGGER ALL;")) + + # 2. Historical Tariff Fractions + def format_bool(val): + if val is None or str(val).strip() == "" or str(val).upper() == "NONE": + return "NULL" + return "TRUE" if str(val).upper() == "TRUE" else "FALSE" + + def format_timestamp(val): + if val is None or str(val).strip() == "" or str(val).upper() == "NONE": + return "NULL" + return f"'{str(val)}'" + + values_historical = ", ".join( + [ + f"({format_value(historical_fraction)}, {format_value(nico)}, {format_value(unit_measure)}, {format_value(country)}, " + f"{format_value(fraction_type)}, {format_value(sector)}, {format_value(import_tax)}, " + f"{format_value(export_tax)}, {format_timestamp(pub_date)}, {format_bool(is_immex)}, " + f"{format_bool(normal_temp)}, {format_bool(services_temp)}, {format_bool(certified_temp)}, " + f"{format_bool(by_log)}, {format_timestamp(end_date)}, " + f"{tenant_id}, {company_id})" + for (historical_fraction, nico, unit_measure, country, fraction_type, sector, import_tax, + export_tax, pub_date, is_immex, normal_temp, services_temp, certified_temp, by_log, end_date) in historical_tariff_fractions_seed + ] + ) + + if values_historical: + db.execute(text("SET session_replication_role = replica;")) + db.execute(text(f""" + INSERT INTO a76.historical_tariff_fractions + (historical_fraction, nico, unit_of_measure_code, country, fraction_type, sector, + import_tax_rate, export_tax_rate, publication_date, is_immex, + normal_temporality, services_temporality, certified_temporality, by_log, end_date, + tenant_id, company_id) + VALUES {values_historical} + ON CONFLICT DO NOTHING; + """)) + db.execute(text("SET session_replication_role = DEFAULT;")) + def get_companies_by_tenant(self, tenant_id: int) -> List[Company]: """Get all companies for a tenant""" return ( self.db.query(Company) - .filter(Company.tenant_id == tenant_id) + .filter(Company.tenant_id == tenant_id, Company.deleted_at.is_(None)) .order_by(Company.name) .all() ) @@ -711,7 +768,7 @@ class CompanyService: """Check if a company exists for a tenant""" return ( self.db.query(Company) - .filter(Company.tenant_id == tenant_id) + .filter(Company.tenant_id == tenant_id, Company.deleted_at.is_(None)) .first() is not None ) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/winsaai/__init__.py b/backend/api/v1/modules/a76/reports/importacion/winsaai/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/__init__.py b/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/routes.py b/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/routes.py new file mode 100644 index 00000000..5d2062ef --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/routes.py @@ -0,0 +1,78 @@ +from fastapi import APIRouter, Depends, BackgroundTasks +from sqlalchemy.orm import Session +from core.database import get_core_db +from .schemas import WinsaaiGenerationRequest, WinsaaiResponse +from .task import generate_winsaai_task +from core.celery_app import celery_app +from celery.result import AsyncResult +from typing import Dict, Any +import logging + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["WINSAAI"]) + +@router.post("/generate", response_model=WinsaaiResponse) +async def trigger_winsaai_generation( + request: WinsaaiGenerationRequest, + db: Session = Depends(get_core_db) +): + task = generate_winsaai_task.delay(request.invoice_ids, request.is_temporal) + return WinsaaiResponse( + task_id=task.id, + status="PENDING", + message="WINSAAI generation task started" + ) + +@router.get("/status/{task_id}") +async def get_task_status(task_id: str): + logger.info(f"Polling status for WINSAAI task {task_id}") + task_result = AsyncResult(task_id, app=celery_app) + + try: + state = task_result.state + info = task_result.info + logger.debug(f"Task {task_id} state: {state}, info: {info}") + except Exception as e: + logger.error(f"Error polling task {task_id}: {str(e)}") + return {"task_id": task_id, "state": "FAILURE", "result": str(e), "info": None} + + response = { + "task_id": task_id, + "state": state, + "result": None, + "info": None + } + + if state == 'FAILURE': + # Safely try to get result, but handle cases where it can't be deserialized + try: + # result = task_result.result # This might trigger the ValueError + # Better: if it's FAILURE, just report it as failed and maybe look into info/meta + response["result"] = "Task failed. Check worker logs for details." + if isinstance(info, dict) and 'error' in info: + response["result"] = info['error'] + except Exception: + response["result"] = "Task failed (error reading exception details)" + elif state == 'SUCCESS': + response["result"] = task_result.result + elif state in ['PROCESSING', 'PROGRESS']: + response["info"] = info + + return response + +@router.get("/download/{task_id}") +async def download_winsaai_file(task_id: str): + res = AsyncResult(task_id) + if not res.ready(): + return {"error": "Task not ready"} + + result = res.result + if isinstance(result, dict) and "filepath" in result: + from fastapi.responses import FileResponse + return FileResponse( + path=result["filepath"], + filename=result["filename"], + media_type="text/plain" + ) + return {"error": "File not found"} diff --git a/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/schemas.py b/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/schemas.py new file mode 100644 index 00000000..a41d017f --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/schemas.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel, Field +from typing import List, Optional +from datetime import datetime + +class WinsaaiGenerationRequest(BaseModel): + invoice_ids: List[int] = Field(..., description="List of invoice IDs to process") + is_temporal: bool = Field(True, description="Whether to generate as temporal (SCAII) or definitive") + +class ErrorValidacion(BaseModel): + partida: Optional[int] = None + linea: int + descripcion: str + soluciones: Optional[str] = None + identificador: Optional[str] = None + +class WinsaaiResponse(BaseModel): + task_id: str + status: str = "PENDING" + message: str = "WINSAAI generation task started" + validation_errors: List[ErrorValidacion] = [] diff --git a/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/service.py b/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/service.py new file mode 100644 index 00000000..c00cafce --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/service.py @@ -0,0 +1,340 @@ +""" +Invoice WINSAAI Service – Legacy Format +Generates the legacy WINSAAI .txt file that matches the output of the +original Clarion system, used with the WINSAAI utility. + +Format reference (from real system example): + 501|1|REGIME|PED_NUM|ADUANA|IGI|IVA|DTA|PREVAL|OTROS|WEIGHT|BULTOS|7|7|7||... + 503||GUIA|| + 505|FACTURA|YYYYMMDD|INCOTERM|MONEDA|VALOR_ME|VALOR_MN|...|CANT_LINES|SOLD_TO_SHORT|SOLD_TO_NAME| + 551|FRACCION|DESC|VAL_MN|VAL_ME|CANT|UM|CANT_TAR|0|0|FPAGO|||P_ORIG|P_IMP|||||0||PESO|||MONEDA|FACTURA||0|PESO|PESO|MET_VALOR|LINEA|||... + 558|| + 999| +""" + +import re +from typing import List, Optional +from sqlalchemy.orm import Session, joinedload + +from api.v1.modules.a76.invoices.models import ( + InvoiceHeader, InvoiceComplianceMx, InvoiceFinancials, InvoiceLogistics, +) +from api.v1.modules.a76.items.models import LineItem +from api.v1.modules.a76.items.line_quantities.models import LineQuantity +from api.v1.modules.a76.items.line_financials.models import LineFinancial +from api.v1.modules.a76.items.line_customs.models import LineCustom +from api.v1.modules.a76.items.line_descriptions.models import LineDescription +from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure +from api.v1.modules.a76.parts.models import Part +from api.v1.modules.a76.clients_and_providers.models import ClientProvider +from api.v1.modules.a76.customs_brokers.models import CustomsBroker +from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos +from api.v1.modules.a76.pedmientos.models.pedimento_contributions import PedimentoContributions + + +# ───────────────────────────────────────────────────────────────────────────── +# HELPERS +# ───────────────────────────────────────────────────────────────────────────── + +def _s(val) -> str: + """Safe string conversion – return '' for None.""" + if val is None: + return "" + return str(val).strip() + + +def _n(val, decimals: int = 0) -> str: + """Format numeric value without leading zeros, with optional decimal places.""" + if val is None: + return "0" + try: + f = float(val) + if decimals: + return f"{f:.{decimals}f}" + else: + # Integer representation, no decimals + return str(int(f)) + except (TypeError, ValueError): + return "0" + + +def _fmt_date_ymd(dt) -> str: + """Format as YYYYMMDD (legacy WINSAAI style).""" + if not dt: + return "" + return dt.strftime("%Y%m%d") + + +def _clean(text: str) -> str: + """Remove pipes and line breaks that would corrupt the pipe-delimited format.""" + if not text: + return "" + text = re.sub(r'[\r\n]+', ' ', text) + text = text.replace('|', '/') + return text.strip() + + +def _sanitize_filename(name: str) -> str: + return re.sub(r'[/\\:*?"<>|]', '', name) + + +# ───────────────────────────────────────────────────────────────────────────── +# SERVICE +# ───────────────────────────────────────────────────────────────────────────── + +class WinsaaiService: + def __init__(self, db: Session, company_id: int, tenant_id: int): + self.db = db + self.company_id = company_id + self.tenant_id = tenant_id + + # ── ENTRY POINT ────────────────────────────────────────────────────────── + + def generate_winsaai_data(self, invoice_ids: List[int], is_temporal: bool) -> List[str]: + all_lines: List[str] = [] + for inv_id in invoice_ids: + invoice = ( + self.db.query(InvoiceHeader) + .options( + joinedload(InvoiceHeader.compliance_mx), + joinedload(InvoiceHeader.financials), + joinedload(InvoiceHeader.logistics), + joinedload(InvoiceHeader.details), + ) + .filter(InvoiceHeader.id == inv_id) + .first() + ) + if not invoice: + continue + all_lines.extend(self._gen_legacy(invoice)) + # Terminator + all_lines.append("999|") + return all_lines + + # ── MAIN GENERATOR ─────────────────────────────────────────────────────── + + def _gen_legacy(self, inv: InvoiceHeader) -> List[str]: + lines: List[str] = [] + + comp: Optional[InvoiceComplianceMx] = inv.compliance_mx + fin: Optional[InvoiceFinancials] = inv.financials + log: Optional[InvoiceLogistics] = inv.logistics + + # ── Pedimento ──────────────────────────────────────────────────────── + ped: Optional[Pedimentos] = None + if comp and comp.pedimento_id: + ped = self.db.query(Pedimentos).filter(Pedimentos.id == comp.pedimento_id).first() + + ped_full = (ped.pedimento_number or "") if ped else "" + # Clarion: SUB(Pedimento,4,4) → chars 3-7 = license/patente + # SUB(Pedimento,9,7) → after hyphen = 7-digit number + # Legacy example uses just the number portion (the integer part) + # We'll parse: aduana(3) + año(2) + patente(4) + num(7) or use raw sub-strings + ped_aduana = _s(ped.customs_office) if ped else "" + ped_regime = _s(ped.regime) if ped else "" + + # Try to extract 7-digit number from pedimento_number + # Formats: "010 IN 3130441" or "01023130441" + ped_num_raw = "".join(filter(str.isdigit, ped_full)) + if len(ped_num_raw) >= 7: + ped_num_short = ped_num_raw[-7:] # last 7 digits (the folio) + else: + ped_num_short = ped_num_raw + + # ── Partners ───────────────────────────────────────────────────────── + sold_to = self._get_partner(comp.sold_to_id) if comp and comp.sold_to_id else None + provider = self._get_partner(comp.provider_id) if comp and comp.provider_id else None + + # ── Financial totals ───────────────────────────────────────────────── + tc = float(fin.exchange_rate or 1) if fin else 1.0 + currency = _s(fin.currency_type) if fin else "USD" + if currency.upper() in ("MXP", "MXN"): + currency = "MXP" + else: + currency = "USD" + + valor_me_hdr = float(fin.value_me or 0) if fin else 0.0 + freight = float(fin.freight or 0) if fin else 0.0 + insurance = float(fin.insurance or 0) if fin else 0.0 + gross_wt_hdr = float(fin.gross_weight or 0) if fin else 0.0 + bundles = int(fin.bundle_count or 0) if fin else 0 + incoterm = _s(log.incoterm) if log else "" + + # ── Items (needed early for summing) ───────────────────────────────── + items = ( + self.db.query(LineItem) + .filter(LineItem.invoice_id == inv.id) + .order_by(LineItem.line_number) + .all() + ) + + # Sum values from line items when header totals are missing/zero + sum_val_me = 0.0 + sum_val_mn = 0.0 + sum_gross = 0.0 # goes into 501 (peso bruto total) + sum_bultos = 0 + for _item in items: + _fin = _item.financial + _qty = _item.quantity + if _fin: + sum_val_me += float(_fin.value_usd or 0) + sum_val_mn += float(_fin.value_mxn or 0) + if _qty: + sum_gross += float(_qty.gross_weight or _qty.net_weight or 0) + sum_bultos += int(_qty.package_quantity or 0) + + # Use header value if present, otherwise sum from items + valor_me = valor_me_hdr if valor_me_hdr > 0 else sum_val_me + valor_mn = (valor_me * tc) if sum_val_mn == 0 else sum_val_mn + gross_wt = gross_wt_hdr if gross_wt_hdr > 0 else sum_gross + if bundles == 0 and sum_bultos > 0: + bundles = sum_bultos + + # ── Contributions (DTA, Prevalidación, IGI, IVA) ───────────────────── + igi_total = 0.0 + iva_total = 0.0 + dta_total = 0.0 + preval_total = 0.0 + if ped: + contribs = ( + self.db.query(PedimentoContributions) + .filter(PedimentoContributions.pedimento_id == ped.id) + .all() + ) + for c in contribs: + code = (c.contribucion or "").upper() + abrev = (c.abreviacion or "").upper() + amount = float(c.importe or 0) + if "IGI" in code or abrev == "IGI": + igi_total += amount + elif "IVA" in code or abrev == "IVA": + iva_total += amount + elif "DTA" in code or abrev == "DTA": + dta_total += amount + elif "PREVAL" in code or "15" in code: + preval_total += amount + + + + # ── REGISTRO 501 ────────────────────────────────────────────────────── + # Legacy: 501|1|REGIME|PED_NUM|ADUANA|IGI|IVA|DTA|PREVAL|0|GROSS_WT|BUNDLES|7|7|7||... + # Example: 501|1|IN|3130441|ENTRADA|0|0|0|0|0|4156.960|0|7|7|7||||160|1764|... + lines.append( + f"501|1|{ped_regime}|{ped_num_short}|{ped_aduana}|" + f"{_n(igi_total, 3)}|{_n(iva_total, 3)}|{_n(dta_total, 3)}|{_n(preval_total, 3)}|0|" + f"{_n(gross_wt, 3)}|{bundles}|7|7|7||||" + f"{_n(freight, 3)}|{_n(insurance, 3)}|1|||||||||||||||" + ) + + # ── REGISTRO 503 ────────────────────────────────────────────────────── + niu = _s(comp.niu_number) if comp else "" + guide = _s(comp.guide_type_to_identify) if comp else "" + lines.append(f"503|{niu}|{guide}||") + + # ── REGISTRO 505 ────────────────────────────────────────────────────── + # Legacy: 505|FACTURA|YYYYMMDD|INCOTERM|MONEDA|VALOR_ME|VALOR_MN||||||||||CANT_LINES|SOLD_SHORT|SOLD_NAME| + # Example: 505|1111|20230525|DAP|USD|69760.00|69760.00||||||||||3|ENTRADA|ENTRADA GROUP...| + sold_short = _s(sold_to.short_name) if sold_to else "" + sold_name = _clean(_s(sold_to.name)) if sold_to else "" + invoicenum = _s(inv.invoice_number) + fecha_fac = _fmt_date_ymd(inv.invoice_date) + + lines.append( + f"505|{invoicenum}|{fecha_fac}|{incoterm}|{currency}|" + f"{_n(valor_me, 2)}|{_n(valor_mn, 2)}||||||||||" + f"{len(items)}|{sold_short}|{sold_name}|" + ) + + # ── REGISTROS 551 + 558 por partida ────────────────────────────────── + # Legacy 551 format (from example): + # 551|FRACCION|DESC|VAL_MN(int)|VAL_ME(dec)|CANT(3dec)|UM_ADUANA|CANT_TAR(3dec)|0|0|FPAGO|||P_ORIG|P_IMP|||||0||PESO|||MONEDA|FACTURA||0|PESO|PESO|MET_VALOR|LINEA|||||||FPAGO||||||||99| + linea_num = 0 + for item in items: + linea_num += 1 + item_qty: Optional[LineQuantity] = item.quantity + item_fin: Optional[LineFinancial] = item.financial + item_cust: Optional[LineCustom] = item.customs + item_desc: Optional[LineDescription] = item.description + + # Fraccion + fraccion = "" + if item_cust and item_cust.fraction: + fraccion = item_cust.fraction[:8] + + # Descripción + part = None + if item.part_number_id: + part = self.db.query(Part).filter(Part.id == item.part_number_id).first() + desc = "" + if part: + desc = _clean(part.description_spanish or part.description or "") + if not desc and item_desc: + desc = _clean(item_desc.description_spanish or item_desc.description1 or "") + + # Valores + val_me = float(item_fin.value_usd or 0) if item_fin else 0.0 + val_mn = float(item_fin.value_mxn or val_me * tc) if item_fin else val_me * tc + + # Cantidades + cant = float(item_qty.quantity or 0) if item_qty else 0.0 + + # UM aduanas (Clave_AMex) + um_aduana = "" + if item.unit_of_measure: + uom = self.db.query(UnitOfMeasure).filter(UnitOfMeasure.id == item.unit_of_measure).first() + if uom: + um_aduana = _s(uom.customs_code) + + # Cantidad tarifa + cant_tar = float(item_qty.quantity_uma or cant) if item_qty else cant + + # Forma de pago (del item) + forma_pago = _s(item.payment_method) + + # País origen y país importador + pais_orig = _s(item_cust.origin_country) if item_cust else "" + pais_imp = "" + if sold_to and sold_to.address: + pais_imp = _s(sold_to.address.country) + + # Peso neto + peso = float(item_qty.net_weight or 0) if item_qty else 0.0 + + # MetValor + met_valor = _s(item.valuation_method) or _s(comp.value_method if comp else "") + + # Legacy 551 field layout (counting from example): + # 551|frac|desc|val_mn|val_me|cant|um|cant_tar|0|0|fpago|||p_orig|p_imp|||||0||peso|||moneda|factura||0|peso|peso|met_valor|linea|||||||fpago||||||||99| + lines.append( + f"551|{fraccion}|{desc}|{_n(val_mn, 0)}|{_n(val_me, 2)}|" + f"{_n(cant, 3)}|{um_aduana}|{_n(cant_tar, 3)}|0|0|{forma_pago}|||" + f"{pais_orig}|{pais_imp}|||||0||{_n(peso, 3)}|||" + f"{currency}|{invoicenum}||0|{_n(peso, 3)}|{_n(peso, 3)}|" + f"{met_valor}|{linea_num}|||||||{forma_pago}||||||||99|" + ) + + # ── REGISTRO 558 ────────────────────────────────────────────────── + # Legacy: always emit an empty 558 after each 551 + # If there is additional info, emit it, otherwise just empty: 558|| + info = "" + if item_desc and hasattr(item_desc, 'extra_description'): + info = _clean(item_desc.extra_description or "") + lines.append(f"558|{info}|") + + return lines + + # ── HELPERS ────────────────────────────────────────────────────────────── + + def _get_partner(self, partner_id: int) -> Optional[ClientProvider]: + if not partner_id: + return None + return ( + self.db.query(ClientProvider) + .options(joinedload(ClientProvider.address)) + .filter(ClientProvider.id == partner_id) + .first() + ) + + def get_filename(self, invoice: InvoiceHeader) -> str: + inv_num = _sanitize_filename(invoice.invoice_number or "") + return f"{inv_num}.txt" diff --git a/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/task.py b/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/task.py new file mode 100644 index 00000000..d21daac9 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/task.py @@ -0,0 +1,62 @@ +from core.celery_app import celery_app +from core.database import CoreSessionLocal +from .service import WinsaaiService +import os +import tempfile + +import logging +import sys + +logger = logging.getLogger(__name__) + +@celery_app.task(bind=True) +def generate_winsaai_task(self, invoice_ids: list, is_temporal: bool): + logger.info(f"Starting WINSAAI task {self.request.id} for {invoice_ids}") + db = CoreSessionLocal() + try: + self.update_state(state='PROCESSING', meta={'current': 10, 'status': 'Cargando servicio...'}) + from api.v1.modules.a76.invoices.models import InvoiceHeader as _InvH + _first = db.query(_InvH).filter(_InvH.id == invoice_ids[0]).first() if invoice_ids else None + _company_id = _first.company_id if _first else 0 + _tenant_id = _first.tenant_id if _first else 0 + service = WinsaaiService(db, _company_id, _tenant_id) + + self.update_state(state='PROCESSING', meta={'current': 30, 'status': 'Generando datos...'}) + lines = service.generate_winsaai_data(invoice_ids, is_temporal) + + self.update_state(state='PROCESSING', meta={'current': 70, 'status': 'Guardando archivo...'}) + + # Determine filename + from api.v1.modules.a76.invoices.models import InvoiceHeader + first_invoice = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_ids[0]).first() if invoice_ids else None + filename = f"{first_invoice.invoice_number}.txt" if first_invoice else f"winsaai_{self.request.id}.txt" + + temp_dir = tempfile.gettempdir() + filepath = os.path.join(temp_dir, filename) + + with open(filepath, "w", encoding="latin-1") as f: + for line in lines: + f.write(line + "\n") + + self.update_state(state='PROCESSING', meta={'current': 90, 'status': 'Finalizando...'}) + + import base64 + with open(filepath, "rb") as f: + encoded_content = base64.b64encode(f.read()).decode('utf-8') + + logger.info(f"WINSAAI task {self.request.id} completed successfully") + return { + "status": "success", + "content": encoded_content, + "filepath": filepath, + "filename": filename, + "file_name": filename, + "media_type": "text/plain", + "validation_errors": [] + } + except Exception as e: + logger.error(f"Error in generate_winsaai_task: {str(e)}", exc_info=True) + # No actualizar estado a FAILURE manualmente, dejar que Celery lo haga al re-lanzar + raise e + finally: + db.close() diff --git a/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/__init__.py b/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/routes.py b/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/routes.py new file mode 100644 index 00000000..995177d2 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/routes.py @@ -0,0 +1,75 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from core.database import get_core_db +from .schemas import PedimentosWinsaaiGenerationRequest, WinsaaiResponse +from .task import generate_pedimentos_winsaai_task +from core.celery_app import celery_app +from celery.result import AsyncResult +from typing import Dict, Any +import logging + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["WINSAAI-Pedimentos"]) + +@router.post("/generate", response_model=WinsaaiResponse) +async def trigger_pedimentos_generation( + request: PedimentosWinsaaiGenerationRequest, + db: Session = Depends(get_core_db) +): + task = generate_pedimentos_winsaai_task.delay( + request.pedimento_ids, + request.is_temporal, + request.is_by_class + ) + return WinsaaiResponse( + task_id=task.id, + status="PENDING", + message="WINSAAI pedimentos generation task started" + ) + +@router.get("/status/{task_id}") +async def get_task_status(task_id: str): + logger.info(f"Polling status for WINSAAI pedimento task {task_id}") + task_result = AsyncResult(task_id, app=celery_app) + + try: + state = task_result.state + info = task_result.info + except Exception as e: + logger.error(f"Error polling task {task_id}: {str(e)}") + return {"task_id": task_id, "state": "FAILURE", "result": str(e), "info": None} + + response = { + "task_id": task_id, + "state": state, + "result": None, + "info": None + } + + if state == 'FAILURE': + response["result"] = "Task failed. Check worker logs for details." + if isinstance(info, dict) and 'error' in info: + response["result"] = info['error'] + elif state == 'SUCCESS': + response["result"] = task_result.result + elif state in ['PROCESSING', 'PROGRESS']: + response["info"] = info + + return response + +@router.get("/download/{task_id}") +async def download_file(task_id: str): + res = AsyncResult(task_id) + if not res.ready(): + raise HTTPException(status_code=400, detail="Task not ready") + + result = res.result + if isinstance(result, dict) and "filepath" in result: + from fastapi.responses import FileResponse + return FileResponse( + path=result["filepath"], + filename=result["filename"], + media_type="text/plain" + ) + raise HTTPException(status_code=404, detail="File not found") diff --git a/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/schemas.py b/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/schemas.py new file mode 100644 index 00000000..8bbbb72e --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/schemas.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel, Field +from typing import List, Optional + +class PedimentosWinsaaiGenerationRequest(BaseModel): + pedimento_ids: List[int] = Field(..., description="List of Pedimento IDs to process") + is_temporal: bool = Field(True, description="Whether to generate as temporal (SCAII) or definitive") + is_by_class: bool = Field(False, description="Whether to group items by Class (SCAF)") + +class ErrorValidacion(BaseModel): + partida: Optional[int] = None + linea: int + descripcion: str + soluciones: Optional[str] = None + identificador: Optional[str] = None + +class WinsaaiResponse(BaseModel): + task_id: str + status: str = "PENDING" + message: str = "WINSAAI generation task started" + validation_errors: List[ErrorValidacion] = [] diff --git a/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/service.py b/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/service.py new file mode 100644 index 00000000..84d0f8eb --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/service.py @@ -0,0 +1,460 @@ +""" +Pedimentos WINSAAI Service – Legacy Format +Generates the legacy WINSAAI .txt file for a pedimento, consolidating +all linked invoices and their line items into a single file. + +Format: + 501| + 503| + 505| + 551| (repeated) + 558| + 999| +""" + +import re +from typing import List, Optional +from decimal import Decimal +from sqlalchemy.orm import Session, joinedload +from fastapi import HTTPException + +from api.v1.modules.a76.invoices.models import ( + InvoiceHeader, InvoiceComplianceMx, InvoiceFinancials, InvoiceLogistics +) +from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos +from api.v1.modules.a76.items.models import LineItem +from api.v1.modules.a76.items.line_quantities.models import LineQuantity +from api.v1.modules.a76.items.line_financials.models import LineFinancial +from api.v1.modules.a76.items.line_customs.models import LineCustom +from api.v1.modules.a76.items.line_descriptions.models import LineDescription +from api.v1.modules.a76.general_catalogs.company.models import Company +from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure +from api.v1.modules.a76.customs_brokers.models import CustomsBroker +from api.v1.modules.a76.clients_and_providers.models import ClientProvider +from api.v1.modules.a76.parts.models import Part +from api.v1.modules.a76.classes.models import Class +from .schemas import ErrorValidacion + + +# ───────────────────────────────────────────────────────────────────────────── +# HELPERS +# ───────────────────────────────────────────────────────────────────────────── + +def _s(val) -> str: + if val is None: + return "" + return str(val).strip() + + +def _n(val, decimals: int = 0) -> str: + if val is None: + return "0" + try: + f = float(val) + if decimals: + return f"{f:.{decimals}f}" + return str(int(f)) + except (TypeError, ValueError): + return "0" + + +def _fmt_date_ymd(dt) -> str: + if not dt: + return "" + return dt.strftime("%Y%m%d") + + +def _clean(text: str) -> str: + if not text: + return "" + text = re.sub(r'[\r\n]+', ' ', text) + text = text.replace('|', '/') + return text.strip() + + +# ───────────────────────────────────────────────────────────────────────────── +# SERVICE +# ───────────────────────────────────────────────────────────────────────────── + +class PedimentosWinsaaiService: + def __init__(self, db: Session): + self.db = db + self.validation_errors: List[ErrorValidacion] = [] + self.line_count = 0 + + # ── ENTRY POINT ────────────────────────────────────────────────────────── + + def generate_pedimentos_data( + self, + pedimento_ids: List[int], + is_temporal: bool = True, + is_by_class: bool = False, + ) -> List[str]: + lines: List[str] = [] + self.line_count = 0 + self.validation_errors = [] + + company = self.db.query(Company).first() + if not company: + raise HTTPException(status_code=404, detail="Company information not found") + + for p_id in pedimento_ids: + pedimento = self.db.query(Pedimentos).filter(Pedimentos.id == p_id).first() + if not pedimento: + continue + + # All invoices linked to this pedimento + invoices = ( + self.db.query(InvoiceHeader) + .join(InvoiceHeader.compliance_mx) + .filter(InvoiceComplianceMx.pedimento_id == p_id) + .options( + joinedload(InvoiceHeader.compliance_mx), + joinedload(InvoiceHeader.financials), + joinedload(InvoiceHeader.logistics), + ) + .all() + ) + + if not invoices: + self.validation_errors.append(ErrorValidacion( + linea=self.line_count, + descripcion=f"Pedimento {pedimento.pedimento_number} no tiene facturas vinculadas", + identificador=pedimento.pedimento_number, + )) + continue + + # ── Consolidate totals across all invoices for 501 ──────────────── + total_peso = 0.0 + total_bultos = 0 + total_flete = 0.0 + total_seguro = 0.0 + total_embalaje = 0.0 + total_otros = 0.0 + + for inv in invoices: + fin = inv.financials + if fin: + total_flete += float(fin.freight or 0) + total_seguro += float(fin.insurance or 0) + total_embalaje += float(getattr(fin, 'packaging', None) or 0) + total_otros += float(getattr(fin, 'other_increments', None) or 0) + + h_peso = float(fin.gross_weight or 0) + h_bultos = int(fin.bundle_count or 0) + if h_peso > 0: + total_peso += h_peso + else: + rows = ( + self.db.query(LineQuantity.gross_weight) + .join(LineItem) + .filter(LineItem.invoice_id == inv.id) + .all() + ) + total_peso += sum(float(r[0] or 0) for r in rows) + + if h_bultos > 0: + total_bultos += h_bultos + else: + rows = ( + self.db.query(LineQuantity.package_quantity) + .join(LineItem) + .filter(LineItem.invoice_id == inv.id) + .all() + ) + total_bultos += sum(int(r[0] or 0) for r in rows) + + # ── REGISTRO 501 (once per pedimento) ──────────────────────────── + lines.append(self._record_501( + pedimento, invoices[0], + total_peso, total_bultos, + total_flete, total_seguro, total_embalaje, total_otros, + )) + + # ── One 503 + 505 + 551s per invoice ───────────────────────────── + for inv_idx, inv in enumerate(invoices, 1): + # 503 + lines.append(self._record_503(inv)) + + # Sum financial totals for 505 + items = ( + self.db.query(LineItem) + .options( + joinedload(LineItem.financial), + joinedload(LineItem.quantity), + joinedload(LineItem.customs), + joinedload(LineItem.description), + joinedload(LineItem.part_info), + joinedload(LineItem.unit_of_measure_info), + joinedload(LineItem.class_info), + joinedload(LineItem.fa_data), + ) + .filter(LineItem.invoice_id == inv.id) + .all() + ) + + sum_me = sum(float(it.financial.value_usd or 0) for it in items if it.financial) + sum_mn = sum(float(it.financial.value_mxn or 0) for it in items if it.financial) + # Fallback to header + if inv.financials: + hdr_me = float(inv.financials.value_me or 0) + if hdr_me > 0: + sum_me = hdr_me + + # 505 + lines.append(self._record_505(inv, sum_mn, sum_me, inv_idx)) + + # Items — optionally grouped by class + items_to_process = self._group_items(items, is_by_class) + + for it_idx, (item, source_items) in enumerate(items_to_process, 1): + tc = float(inv.financials.exchange_rate or 1) if inv.financials else 1.0 + lines.append(self._record_551(item, inv, tc, it_idx)) + + # 554 identifiers + cust = item.customs + if cust and cust.fraction_type: + ftype = cust.fraction_type + if ftype == "TLCS": + lines.append(f"554|TL|{_s(cust.origin_country)}|") + elif ftype == "PROSEC": + lines.append(f"554|PS|{_s(cust.sector)}|") + elif ftype == "ALADI": + lines.append(f"554|AL|{_s(cust.sector)}|") + + # 558 observations + parts: List[str] = [] + if item.description: + if item.description.brand: + parts.append(f"MARCA: {item.description.brand}") + if item.description.model: + parts.append(f"MODELO: {item.description.model}") + if item.description.extra_description: + parts.append(item.description.extra_description) + for src in source_items: + if src.fa_data and src.fa_data.equipment_message: + if src.fa_data.equipment_message not in parts: + parts.append(src.fa_data.equipment_message) + + full_obs = _clean(". ".join(parts)) + if full_obs: + for i in range(0, len(full_obs), 120): + lines.append(f"558|{full_obs[i:i+120]}|") + else: + lines.append("558||") + + self.line_count += len(items_to_process) + + lines.append("999|") + return lines + + # ── RECORD BUILDERS ────────────────────────────────────────────────────── + + def _record_501( + self, + ped: Pedimentos, + rep_inv: InvoiceHeader, + peso: float, bultos: int, + flete: float, seguro: float, embalaje: float, otros: float, + ) -> str: + """ + Legacy: 501|1|REGIME|NUM7|ADUANA|IGI|IVA|DTA|PREVAL|0|PESO|BULTOS|7|7|7||||FLETE|SEGURO|... + Example: 501|1|IN|3130441|ENTRADA|0|0|0|0|0|4156.960|0|7|7|7||||160|1764|... + """ + regime = _s(ped.regime) + aduana = _s(ped.customs_office) + num_raw = "".join(filter(str.isdigit, _s(ped.pedimento_number))) + num7 = num_raw[-7:] if len(num_raw) >= 7 else num_raw + + return ( + f"501|1|{regime}|{num7}|{aduana}|0|0|0|0|0|" + f"{_n(peso, 3)}|{bultos}|7|7|7||||" + f"{_n(flete, 3)}|{_n(seguro, 3)}|1|||||||||||||||||" + ) + + def _record_503(self, inv: InvoiceHeader) -> str: + comp = inv.compliance_mx + niu = _s(comp.niu_number) if comp else "" + guide = _s(comp.guide_type_to_identify) if comp else "" + return f"503|{niu}|{guide}||" + + def _record_505( + self, + inv: InvoiceHeader, + val_mn: float, + val_me: float, + seq: int, + ) -> str: + """ + Legacy: 505|FACTURA|YYYYMMDD|INCOTERM|MONEDA|VAL_ME|VAL_MN|...|COUNT|SHORT|NAME| + """ + comp = inv.compliance_mx + fin = inv.financials + log = inv.logistics + + invoicenum = _s(inv.invoice_number) + fecha = _fmt_date_ymd(inv.invoice_date) + incoterm = _s(log.incoterm) if log else "" + currency = _s(fin.currency_type) if fin else "USD" + currency = "MXP" if currency.upper() in ("MXP", "MXN") else "USD" + tc = float(fin.exchange_rate or 1) if fin else 1.0 + + # val_mn fallback + if val_mn == 0 and val_me > 0: + val_mn = val_me * tc + + sold_to_id = comp.sold_to_id if comp else None + sold_to = self._get_partner(sold_to_id) + sold_short = _s(sold_to.short_name) if sold_to else "" + sold_name = _clean(_s(sold_to.name)) if sold_to else "" + + # Count items for this invoice + item_count = ( + self.db.query(LineItem) + .filter(LineItem.invoice_id == inv.id) + .count() + ) + + return ( + f"505|{invoicenum}|{fecha}|{incoterm}|{currency}|" + f"{_n(val_me, 2)}|{_n(val_mn, 2)}||||||||||" + f"{item_count}|{sold_short}|{sold_name}|" + ) + + def _record_551( + self, + item: LineItem, + inv: InvoiceHeader, + tc: float, + line_num: int, + ) -> str: + """ + Legacy 551: + 551|FRACCION|DESC|VAL_MN|VAL_ME|CANT|UM|CANT_TAR|0|0|FPAGO|||P_ORIG|P_IMP|||||0||PESO|||MONEDA|FACTURA||0|PESO|PESO|MET_VALOR|LINEA|||...99| + """ + fin = item.financial + qty = item.quantity + cust = item.customs + desc = item.description + + # Fracción + fraccion = "" + if cust and cust.fraction: + fraccion = cust.fraction[:8] + + # Descripción + part = None + if item.part_number_id: + part = self.db.query(Part).filter(Part.id == item.part_number_id).first() + description = "" + if part: + description = _clean(part.description_spanish or part.description or "") + if not description and desc: + description = _clean(getattr(desc, 'description_spanish', None) or getattr(desc, 'description1', None) or "") + if not description and item.class_info: + description = _clean(getattr(item.class_info, 'description_es', None) or "") + + # Valores + val_me = float(fin.value_usd or 0) if fin else 0.0 + val_mn = float(fin.value_mxn or val_me * tc) if fin else val_me * tc + + # Cantidades + cant = float(qty.quantity or 0) if qty else 0.0 + + # UM aduanas + um_aduana = "" + if item.unit_of_measure: + uom = self.db.query(UnitOfMeasure).filter(UnitOfMeasure.id == item.unit_of_measure).first() + if uom: + um_aduana = _s(uom.customs_code) + + cant_tar = float(qty.quantity_uma or cant) if qty else cant + + # FormaPago y MetValor + forma_pago = _s(item.payment_method) + met_valor = _s(item.valuation_method) + if not met_valor and inv.compliance_mx: + met_valor = _s(inv.compliance_mx.value_method) + + # País origen e importador + pais_orig = _s(cust.origin_country) if cust else "" + pais_imp = "" + comp = inv.compliance_mx + if comp and comp.sold_to_id: + sold_to = self._get_partner(comp.sold_to_id) + if sold_to and sold_to.address: + pais_imp = _s(sold_to.address.country) + + # Peso neto + peso = float(qty.net_weight or 0) if qty else 0.0 + + # Currency and invoice number + fin_obj = inv.financials + currency = _s(fin_obj.currency_type) if fin_obj else "USD" + currency = "MXP" if currency.upper() in ("MXP", "MXN") else "USD" + invoicenum = _s(inv.invoice_number) + + return ( + f"551|{fraccion}|{description}|{_n(val_mn, 0)}|{_n(val_me, 2)}|" + f"{_n(cant, 3)}|{um_aduana}|{_n(cant_tar, 3)}|0|0|{forma_pago}|||" + f"{pais_orig}|{pais_imp}|||||0||{_n(peso, 3)}|||" + f"{currency}|{invoicenum}||0|{_n(peso, 3)}|{_n(peso, 3)}|" + f"{met_valor}|{line_num}|||||||{forma_pago}||||||||99|" + ) + + # ── HELPERS ────────────────────────────────────────────────────────────── + + def _group_items(self, items, is_by_class: bool): + """Returns list of (item, source_items). Groups by class if is_by_class=True.""" + if not is_by_class: + return [(it, [it]) for it in items] + + groups = {} + for it in items: + fraction = (it.class_info.fraction if it.class_info else getattr(it.customs, 'fraction', '')) or '' + country = getattr(it.customs, 'origin_country', '') or '' + key = (it.class_id, fraction, country, it.valuation_method) + if key not in groups: + groups[key] = { + 'rep': it, + 'val_me': 0.0, 'val_mn': 0.0, + 'qty': 0.0, 'qty_uma': 0.0, + 'gross': 0.0, 'net': 0.0, 'bultos': 0, + 'sources': [], + } + g = groups[key] + g['val_me'] += float(it.financial.value_usd or 0) if it.financial else 0 + g['val_mn'] += float(it.financial.value_mxn or 0) if it.financial else 0 + g['qty'] += float(it.quantity.quantity or 0) if it.quantity else 0 + g['qty_uma'] += float(it.quantity.quantity_uma or 0) if it.quantity else 0 + g['gross'] += float(it.quantity.gross_weight or 0) if it.quantity else 0 + g['net'] += float(it.quantity.net_weight or 0) if it.quantity else 0 + g['bultos'] += int(it.quantity.package_quantity or 0) if it.quantity else 0 + g['sources'].append(it) + + result = [] + for g in groups.values(): + rep = g['rep'] + if rep.financial: + rep.financial.value_usd = g['val_me'] + rep.financial.value_mxn = g['val_mn'] + if rep.quantity: + rep.quantity.quantity = g['qty'] + rep.quantity.quantity_uma = g['qty_uma'] + rep.quantity.gross_weight = g['gross'] + rep.quantity.net_weight = g['net'] + rep.quantity.package_quantity = g['bultos'] + if rep.class_info and rep.customs: + rep.customs.fraction = getattr(rep.class_info, 'fraction', rep.customs.fraction) + result.append((rep, g['sources'])) + return result + + def _get_partner(self, partner_id: Optional[int]) -> Optional[ClientProvider]: + if not partner_id: + return None + return ( + self.db.query(ClientProvider) + .options(joinedload(ClientProvider.address)) + .filter(ClientProvider.id == partner_id) + .first() + ) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/task.py b/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/task.py new file mode 100644 index 00000000..119b93d0 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/task.py @@ -0,0 +1,53 @@ +import logging +from typing import List +import base64 +import os +from core.celery_app import celery_app +from core.database import CoreSessionLocal +from .service import PedimentosWinsaaiService + +logger = logging.getLogger(__name__) + +@celery_app.task(bind=True) +def generate_pedimentos_winsaai_task(self, pedimento_ids: List[int], is_temporal: bool, is_by_class: bool = False): + logger.info(f"Starting Pedimentos WINSAAI task {self.request.id} for pedimentos {pedimento_ids} (by_class={is_by_class})") + db = CoreSessionLocal() + try: + self.update_state(state='PROCESSING', meta={'current': 10, 'status': 'Cargando servicio...'}) + service = PedimentosWinsaaiService(db) + + self.update_state(state='PROCESSING', meta={'current': 30, 'status': 'Generando datos...'}) + lines = service.generate_pedimentos_data(pedimento_ids, is_temporal, is_by_class) + + self.update_state(state='PROCESSING', meta={'current': 70, 'status': 'Guardando archivo...'}) + # Use first pedimento number for filename + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + ped = db.query(Pedimentos).filter(Pedimentos.id == pedimento_ids[0]).first() if pedimento_ids else None + if ped: + filename = f"{ped.year}-{ped.license}-{ped.pedimento_number}.txt" + else: + filename = "pedimentos.txt" + filepath = f"/tmp/{filename}" + + # Clarion uses ANSI/UTF-8 usually, but let's stick to UTF-8 for now unless specific requirement + with open(filepath, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + + self.update_state(state='PROCESSING', meta={'current': 90, 'status': 'Finalizando...'}) + with open(filepath, "rb") as f: + content_b64 = base64.b64encode(f.read()).decode("utf-8") + + return { + "status": "success", + "content": content_b64, + "filepath": filepath, + "filename": filename, + "file_name": filename, + "media_type": "text/plain", + "validation_errors": [e.model_dump() for e in service.validation_errors] + } + except Exception as e: + logger.error(f"Error in generate_pedimentos_winsaai_task: {str(e)}", exc_info=True) + raise e + finally: + db.close() diff --git a/backend/api/v1/modules/a76/reports/importacion/winsaai/router.py b/backend/api/v1/modules/a76/reports/importacion/winsaai/router.py new file mode 100644 index 00000000..409edbfe --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/winsaai/router.py @@ -0,0 +1,9 @@ +from fastapi import APIRouter +from .invoices.routes import router as invoices_router + +router = APIRouter() + +router.include_router(invoices_router, prefix="/invoices", tags=["WINSAAI - Invoices"]) + +from .pedimentos.routes import router as pedimentos_router +router.include_router(pedimentos_router, prefix="/pedimentos", tags=["WINSAAI - Pedimentos"]) diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 2098bb1a..33416d44 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -45,6 +45,7 @@ from .manifests.manifiesto_anexo.routes import router as manifest_anexos_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 from .reports.importacion.transmission.definitive.MAINX30.routes import router as transmission_definitive_router +from .reports.importacion.winsaai.router import router as winsaai_router # Router principal @@ -154,6 +155,12 @@ router.include_router( tags=["a76 / reports"] ) +router.include_router( + winsaai_router, + prefix="/a76/reports/importacion/winsaai", + tags=["a76 / reports"] +) + # Registrar router de bitácora from .audit_log.router import router as audit_log_router router.include_router(audit_log_router, prefix="/a76/audit-log", tags=["Audit Log"]) diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index 44287df9..3ebcdde3 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -47,6 +47,9 @@ celery_app.conf.update( "api.v1.modules.a76.reports.exportacion.transmission.MAINX30.task", "api.v1.modules.a76.reports.importacion.transmission.temporal.MAINX30.task", "api.v1.modules.a76.reports.importacion.transmission.definitive.MAINX30.task", + "api.v1.modules.a76.reports.importacion.winsaai.invoices.task", + "api.v1.modules.a76.reports.importacion.winsaai.pedimentos.task", + "api.v1.modules.core.help_center.tasks", "api.v1.modules.core.help_center.tasks" ] # Ruta al módulo donde están las tareas ) diff --git a/docker-compose.yml b/docker-compose.yml index 6a61948f..3be97b57 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -266,7 +266,6 @@ services: max-size: "10m" max-file: "3" - # celery celery_worker: build: ./backend diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 00000000..d02723d3 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12723 @@ + + + + + + Anexo76 - Gestión de Comercio Exterior + + + + + + +

Anexo76

Gestión de Comercio Exterior

Bienvenido a Anexo76

Plataforma SaaS para gestión de comercio exterior conforme a Anexos 24, 30 y 22 del SAT. + Ideal para maquilas, empresas IMMEX y agentes aduanales.

Registrarse

Características principales

Multi-tenant

Arquitectura híbrida con BD compartida o dedicada según necesidades

Seguridad

Autenticación con Keycloak y control de acceso basado en roles

Licencias

Planes flexibles desde Free hasta Enterprise con features personalizadas

© 2025 Anexo76. Desarrollado para la industria de comercio exterior mexicana.

+ + +
+ + diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 3a253424..b1857839 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -62,7 +62,7 @@ "electronic_notices": "Avisos electrónicos", "back_flush": "Back Flush", "crossing_notice": "Aviso de cruce" - }, + }, "fractions": { "title": "Fracciones", "sitar": "Fracciones Sitar", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 358f8e01..343ed257 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -40,94 +40,49 @@ function onTokenRefreshed(token: string) { } /** - * Intenta refrescar el token usando el refresh token + * Refresca el token silenciosamente usando el endpoint server-side. + * + * El servidor lee el refresh_token desde la cookie HttpOnly, + * llama a Keycloak, actualiza las cookies y devuelve el nuevo access_token. + * El refresh_token NUNCA es leído por este código JavaScript. */ async function refreshToken(): Promise { if (!browser) return null; - let refreshTokenValue = localStorage.getItem('refresh_token'); - - // Si no está en localStorage, intentar obtenerlo de las cookies - if (!refreshTokenValue) { - const getCookie = (name: string): string | null => { - const value = `; ${document.cookie}`; - const parts = value.split(`; ${name}=`); - if (parts.length === 2) return parts.pop()?.split(';').shift() || null; - return null; - }; - - refreshTokenValue = getCookie('refresh_token'); - if (refreshTokenValue) { - localStorage.setItem('refresh_token', refreshTokenValue); - } - } - - if (!refreshTokenValue) { - console.error('❌ [API] No hay refresh token disponible'); - return null; - } - try { - const response = await fetch(`${API_BASE_URL}/v1/auth/refresh`, { + const response = await fetch('/api-sveltekit/auth/silent-refresh', { method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ refresh_token: refreshTokenValue }), - credentials: 'include' + credentials: 'include', // Envía cookies HttpOnly automáticamente + headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { - console.error('❌ [API] Refresh token expirado o inválido, status:', response.status); - // Si el refresh token también está expirado, limpiar todo - localStorage.removeItem('access_token'); - localStorage.removeItem('refresh_token'); - // Limpiar cookies también + console.error('❌ [API] Silent refresh falló, status:', response.status); + // Limpiar la cookie del access_token (no HttpOnly) para forzar re-login document.cookie = 'access_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC'; - document.cookie = 'refresh_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC'; - // Redirigir al login después de un pequeño delay para que el usuario vea el mensaje - setTimeout(() => { - if (browser) { - window.location.href = '/login'; - } - }, 2000); + setTimeout(() => { window.location.href = '/login'; }, 1500); return null; } - const data = await response.json(); + const data = await response.json() as { access_token?: string }; - // Guardar los nuevos tokens if (data.access_token) { - localStorage.setItem('access_token', data.access_token); + // Actualizar cookie no-HttpOnly del access_token + const secure = window.location.protocol === 'https:' ? '; Secure' : ''; + document.cookie = `access_token=${data.access_token}; path=/; max-age=${60 * 60 * 24 * 7}; SameSite=Lax${secure}`; - if (data.refresh_token) { - localStorage.setItem('refresh_token', data.refresh_token); - } - - // Actualizar también las cookies - const isSecure = window.location.protocol === 'https:'; - const secureFlag = isSecure ? '; Secure' : ''; - - document.cookie = `access_token=${data.access_token}; path=/; max-age=${60 * 60 * 24 * 7}; SameSite=Lax${secureFlag}`; - if (data.refresh_token) { - document.cookie = `refresh_token=${data.refresh_token}; path=/; max-age=${60 * 60 * 24 * 30}; SameSite=Lax${secureFlag}`; - } - - // Actualizar el authStore si está disponible + // Actualizar authStore en memoria try { const { authStore } = await import('./auth'); authStore.setToken(data.access_token); - } catch (e) { - // Si no se puede importar authStore, no es crítico - console.warn('⚠️ [API] No se pudo actualizar authStore:', e); - } + } catch {} return data.access_token; } return null; } catch (error) { - console.error('❌ [API] Error refreshing token:', error); + console.error('❌ [API] Error en silent refresh:', error); return null; } } diff --git a/frontend/src/lib/api/dashboard/a76/reports/reports-winsaai.ts b/frontend/src/lib/api/dashboard/a76/reports/reports-winsaai.ts new file mode 100644 index 00000000..bfad4d0f --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/reports/reports-winsaai.ts @@ -0,0 +1,86 @@ +const BASE_URL = import.meta.env.VITE_API_URL || ''; + +export interface WinsaaiGenerationRequest { + invoice_ids: number[]; + is_temporal: boolean; +} + +export interface WinsaaiResponse { + task_id: string; + status: string; + message: string; +} + +export const reportsWinsaaiApi = { + invoices: { + triggerGeneration: async (payload: WinsaaiGenerationRequest): Promise => { + const token = localStorage.getItem('access_token'); + const response = await fetch(`${BASE_URL}/v1/a76/reports/importacion/winsaai/invoices/generate`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }); + if (!response.ok) throw new Error('Error al iniciar la generación WINSAAI de facturas'); + return await response.json(); + }, + + getTaskStatus: async (taskId: string): Promise => { + const token = localStorage.getItem('access_token'); + const response = await fetch(`${BASE_URL}/v1/a76/reports/importacion/winsaai/invoices/status/${taskId}`, { + method: 'GET', + headers: { 'Authorization': `Bearer ${token}` } + }); + if (!response.ok) throw new Error('Error al consultar estado WINSAAI de facturas'); + return await response.json(); + }, + + downloadFile: async (taskId: string): Promise => { + const token = localStorage.getItem('access_token'); + return await fetch(`${BASE_URL}/v1/a76/reports/importacion/winsaai/invoices/download/${taskId}`, { + method: 'GET', + headers: { 'Authorization': `Bearer ${token}` } + }); + } + }, + + pedimentos: { + triggerGeneration: async (pedimentoIds: number[], isTemporal: boolean = true, isByClass: boolean = false): Promise => { + const token = localStorage.getItem('access_token'); + const response = await fetch(`${BASE_URL}/v1/a76/reports/importacion/winsaai/pedimentos/generate`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + pedimento_ids: pedimentoIds, + is_temporal: isTemporal, + is_by_class: isByClass + }) + }); + if (!response.ok) throw new Error('Error al iniciar la generación WINSAAI de pedimentos'); + return await response.json(); + }, + + getTaskStatus: async (taskId: string): Promise => { + const token = localStorage.getItem('access_token'); + const response = await fetch(`${BASE_URL}/v1/a76/reports/importacion/winsaai/pedimentos/status/${taskId}`, { + method: 'GET', + headers: { 'Authorization': `Bearer ${token}` } + }); + if (!response.ok) throw new Error('Error al consultar estado WINSAAI de pedimentos'); + return await response.json(); + }, + + downloadFile: async (taskId: string): Promise => { + const token = localStorage.getItem('access_token'); + return await fetch(`${BASE_URL}/v1/a76/reports/importacion/winsaai/pedimentos/download/${taskId}`, { + method: 'GET', + headers: { 'Authorization': `Bearer ${token}` } + }); + } + } +}; diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index e75c29ff..f35153e4 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -1,11 +1,21 @@ /** * Servicio de autenticación con Keycloak + * + * Seguridad de tokens: + * - access_token → en memoria (authStore) + cookie no-HttpOnly (para SSR) + * - refresh_token → cookie HttpOnly únicamente (JS nunca lo lee directamente) + * - El refresh se hace server-side via /api-sveltekit/auth/silent-refresh + * - NO se usa localStorage para tokens */ + import Keycloak from 'keycloak-js'; import { writable, derived } from 'svelte/store'; import { browser } from '$app/environment'; +// ───────────────────────────────────────────────────────── // Tipos +// ───────────────────────────────────────────────────────── + export interface User { id: string; username: string; @@ -22,51 +32,51 @@ export interface AuthState { token: string | null; } +// ───────────────────────────────────────────────────────── // Configuración de Keycloak +// ───────────────────────────────────────────────────────── + const keycloakConfig = { url: import.meta.env.VITE_KEYCLOAK_URL, realm: import.meta.env.VITE_KEYCLOAK_REALM, clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID }; -// Instancia de Keycloak let keycloakInstance: Keycloak | null = null; -// Helper para obtener cookies +// ───────────────────────────────────────────────────────── +// Cookie helpers (solo para access_token no-HttpOnly) +// ───────────────────────────────────────────────────────── + +/** Lee el valor de una cookie no-HttpOnly */ const getCookie = (name: string): string | null => { if (!browser) return null; const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); - if (parts.length === 2) return parts.pop()?.split(';').shift() || null; + if (parts.length === 2) return parts.pop()?.split(';').shift() ?? null; return null; }; -// Helper para establecer cookies con las opciones correctas según el entorno +/** Escribe una cookie no-HttpOnly */ const setCookie = (name: string, value: string, days: number = 7) => { if (!browser) return; - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + days); - - // En desarrollo (localhost), no usar Secure flag - const isSecure = window.location.protocol === 'https:'; - const secureFlag = isSecure ? '; Secure' : ''; - - const cookieString = `${name}=${value}; path=/; expires=${expirationDate.toUTCString()}; SameSite=Lax${secureFlag}`; - document.cookie = cookieString; - - // Verificar que se estableció - const verification = getCookie(name); + const exp = new Date(); + exp.setDate(exp.getDate() + days); + const secure = window.location.protocol === 'https:' ? '; Secure' : ''; + document.cookie = `${name}=${value}; path=/; expires=${exp.toUTCString()}; SameSite=Lax${secure}`; }; -// Helper para eliminar cookies +/** Elimina una cookie */ const deleteCookie = (name: string) => { if (!browser) return; - const isSecure = window.location.protocol === 'https:'; - const secureFlag = isSecure ? '; Secure' : ''; - document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax${secureFlag}`; + const secure = window.location.protocol === 'https:' ? '; Secure' : ''; + document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax${secure}`; }; -// Store de autenticación +// ───────────────────────────────────────────────────────── +// Auth store (tokens solo en memoria) +// ───────────────────────────────────────────────────────── + const createAuthStore = () => { const { subscribe, set, update } = writable({ isAuthenticated: false, @@ -78,16 +88,16 @@ const createAuthStore = () => { return { subscribe, setAuthenticated: (authenticated: boolean) => - update((state) => ({ ...state, isAuthenticated: authenticated })), + update((s) => ({ ...s, isAuthenticated: authenticated })), setLoading: (loading: boolean) => - update((state) => ({ ...state, isLoading: loading })), - setUser: (user: User | null) => update((state) => ({ ...state, user })), - setToken: (token: string | null) => update((state) => ({ ...state, token })), - setTokens: (accessToken: string, refreshToken?: string) => { - update((state) => ({ ...state, token: accessToken })); - if (browser && refreshToken) { - localStorage.setItem('refresh_token', refreshToken); - } + update((s) => ({ ...s, isLoading: loading })), + setUser: (user: User | null) => update((s) => ({ ...s, user })), + setToken: (token: string | null) => update((s) => ({ ...s, token })), + /** ⚠️ Los tokens ya NO se guardan en localStorage; solo en memoria. */ + setTokens: (accessToken: string, _refreshToken?: string) => { + update((s) => ({ ...s, token: accessToken })); + // El refresh_token llega en cookie HttpOnly desde el servidor; + // el cliente no lo almacena ni lo lee en ningún momento. }, reset: () => set({ @@ -101,14 +111,17 @@ const createAuthStore = () => { export const authStore = createAuthStore(); -// Derived store para verificar si está autenticado -export const isAuthenticated = derived(authStore, ($auth) => $auth.isAuthenticated); +export const isAuthenticated = derived(authStore, ($a) => $a.isAuthenticated); +export const currentUser = derived(authStore, ($a) => $a.user); -// Derived store para obtener el usuario -export const currentUser = derived(authStore, ($auth) => $auth.user); +// ───────────────────────────────────────────────────────── +// Inicialización +// ───────────────────────────────────────────────────────── /** - * Inicializa la autenticación (Keycloak o token-based) + * Inicializa el estado de autenticación en el cliente. + * - Si hay un access_token en la cookie no-HttpOnly, lo usa. + * - En cualquier caso intenta inicializar Keycloak JS (para el flujo SSO). */ export const initAuth = async (): Promise => { if (!browser) return false; @@ -116,38 +129,28 @@ export const initAuth = async (): Promise => { try { authStore.setLoading(true); - // Primero intentar restaurar sesión desde localStorage - const token = localStorage.getItem('access_token'); - if (token) { - authStore.setToken(token); + // Restaurar token desde cookie no-HttpOnly (password login flow) + const cookieToken = getCookie('access_token'); + if (cookieToken) { + authStore.setToken(cookieToken); authStore.setAuthenticated(true); - - // Sincronizar con cookies si no existe - const cookieToken = getCookie('access_token'); - if (!cookieToken) { - setCookie('access_token', token); - } - - await loadUserInfo(token); + await loadUserInfo(cookieToken).catch(() => {}); authStore.setLoading(false); return true; } - // Si no hay token local, intentar con Keycloak - await initKeycloak(); - + // Sin token local, intentar Keycloak JS (flujo SSO) + const authenticated = await initKeycloak(); authStore.setLoading(false); - return false; - } catch (error) { - console.error('Error inicializando autenticación:', error); + return authenticated; + } catch (err) { + console.error('[auth] Error en initAuth:', err); authStore.setLoading(false); return false; } }; -/** - * Inicializa Keycloak - */ +/** Inicializa Keycloak JS para el flujo SSO con PKCE */ export const initKeycloak = async (): Promise => { if (!browser) return false; @@ -163,22 +166,18 @@ export const initKeycloak = async (): Promise => { if (authenticated) { await updateAuthState(); - setupTokenRefresh(); + setupKeycloakTokenHooks(); } return authenticated; - } catch (error) { - console.error('Error inicializando Keycloak:', error); + } catch (err) { + console.error('[auth] Error inicializando Keycloak:', err); return false; } }; -// Variable para rastrear el tenant anterior let previousTenantId: number | undefined = undefined; -/** - * Actualiza el estado de autenticación con los datos de Keycloak - */ const updateAuthState = async () => { if (!keycloakInstance?.authenticated) { authStore.reset(); @@ -187,22 +186,22 @@ const updateAuthState = async () => { try { const profile = await keycloakInstance.loadUserProfile(); - const token = keycloakInstance.token || null; - const tokenParsed = keycloakInstance.tokenParsed as any; + const token = keycloakInstance.token ?? null; + const parsed = keycloakInstance.tokenParsed as any; - const roles = tokenParsed?.realm_access?.roles || []; - const tenantId = tokenParsed?.tenant_id || tokenParsed?.attributes?.tenant_id; - const newTenantId = tenantId ? parseInt(tenantId) : undefined; + const roles: string[] = parsed?.realm_access?.roles ?? []; + const tenantId: number | undefined = parsed?.tenant_id + ? parseInt(parsed.tenant_id) + : undefined; - // Detectar si cambió el tenant - const tenantChanged = previousTenantId !== undefined && previousTenantId !== newTenantId; + const tenantChanged = previousTenantId !== undefined && previousTenantId !== tenantId; const user: User = { - id: profile.id || '', - username: profile.username || '', + id: profile.id ?? '', + username: profile.username ?? '', email: profile.email, - name: `${profile.firstName || ''} ${profile.lastName || ''}`.trim(), - tenantId: newTenantId, + name: `${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim(), + tenantId, roles }; @@ -210,71 +209,83 @@ const updateAuthState = async () => { authStore.setUser(user); authStore.setToken(token); - // Si cambió el tenant, limpiar el store de compañías if (tenantChanged && browser) { try { const { companyStore } = await import('./stores/company.svelte'); companyStore.clear(); - } catch (error) { - console.error('Error al limpiar store de compañías:', error); - } + } catch {} } - // Actualizar el tenant anterior - previousTenantId = newTenantId; - } catch (error) { - console.error('Error actualizando estado de autenticación:', error); + previousTenantId = tenantId; + } catch (err) { + console.error('[auth] Error actualizando estado:', err); authStore.reset(); } }; +// ───────────────────────────────────────────────────────── +// Keycloak JS token hooks (solo para el flujo SSO) +// ───────────────────────────────────────────────────────── + /** - * Configura el refresh automático del token + * Configura los callbacks de Keycloak JS para notificar al SessionManager + * sobre cambios de token y eventos de sesión SSO. */ -const setupTokenRefresh = () => { +const setupKeycloakTokenHooks = () => { if (!keycloakInstance) return; - // Refrescar token cada 60 segundos si está cerca de expirar keycloakInstance.onTokenExpired = () => { keycloakInstance ?.updateToken(70) .then((refreshed) => { - if (refreshed) { - authStore.setToken(keycloakInstance?.token || null); + if (refreshed && keycloakInstance?.token) { + authStore.setToken(keycloakInstance.token); + import('./session-manager') + .then(({ getSessionManager }) => { + getSessionManager()?.updateToken(keycloakInstance!.token!); + }) + .catch(() => {}); } }) .catch(() => { - console.error('Error refrescando token'); - logout(); + console.error('[auth] No se pudo refrescar el token de Keycloak'); + void logout(); }); }; + + keycloakInstance.onAuthRefreshSuccess = () => { + if (keycloakInstance?.token) authStore.setToken(keycloakInstance.token); + }; + + keycloakInstance.onAuthRefreshError = () => { + console.error('[auth] Error en refresh de Keycloak — cerrando sesión'); + void logout(); + }; + + keycloakInstance.onAuthLogout = () => { + authStore.reset(); + }; }; -/** - * Inicia sesión con Keycloak (OAuth flow) - */ +// ───────────────────────────────────────────────────────── +// Login +// ───────────────────────────────────────────────────────── + +/** Inicia sesión con Keycloak (OAuth redirect flow) */ export const loginWithKeycloak = async (tenantSlug?: string) => { if (!keycloakInstance) { - console.error('Keycloak no está inicializado'); + console.error('[auth] Keycloak no está inicializado'); return; } - - const options: any = { - redirectUri: window.location.origin + '/callback' - }; - - if (tenantSlug) { - options.loginHint = tenantSlug; - } - + const options: any = { redirectUri: window.location.origin + '/callback' }; + if (tenantSlug) options.loginHint = tenantSlug; await keycloakInstance.login(options); }; /** - * Inicia sesión con credenciales (username/password) - * Nota: Esta función ya no se usa directamente desde el login form, - * el login ahora se hace mediante form actions del servidor. - * Se mantiene para compatibilidad con SSO y otros flujos. + * Login con usuario/contraseña (legacy — el login principal es via form action del servidor). + * Los tokens se guardan en cookies (vía setCookie) y en memoria (authStore). + * NO se guardan en localStorage. */ export const login = async (credentials: { username: string; @@ -282,235 +293,185 @@ export const login = async (credentials: { tenant_slug: string; }): Promise<{ success: boolean; error?: string; data?: any }> => { try { - // Usar la API centralizada const { api } = await import('./api'); const response = await api.auth.login(credentials); - // Si hay error en la respuesta if (response.error) { - return { - success: false, - error: response.error - }; + return { success: false, error: response.error }; } - // Guardar tokens y actualizar estado const loginData = response.data; if (loginData?.access_token) { + // Guardar en memoria y en cookie no-HttpOnly para SSR authStore.setToken(loginData.access_token); authStore.setAuthenticated(true); - - // Guardar también en localStorage para persistencia - if (browser) { - localStorage.setItem('access_token', loginData.access_token); - if (loginData.refresh_token) { - localStorage.setItem('refresh_token', loginData.refresh_token); - } - - // Guardar en cookies para que el servidor pueda acceder - setCookie('access_token', loginData.access_token); - if (loginData.refresh_token) { - setCookie('refresh_token', loginData.refresh_token); - } - } - - // Cargar información del usuario + setCookie('access_token', loginData.access_token); + // El refresh_token llega en cookie HttpOnly desde el servidor. + // NO lo guardamos en JS. await loadUserInfo(loginData.access_token); } - return { - success: true, - data: loginData - }; - } catch (error) { - console.error('Error en login:', error); - return { - success: false, - error: 'Error de conexión con el servidor' - }; + return { success: true, data: loginData }; + } catch (err) { + console.error('[auth] Error en login:', err); + return { success: false, error: 'Error de conexión con el servidor' }; } }; -/** - * Carga la información del usuario desde el token - */ +// ───────────────────────────────────────────────────────── +// User info +// ───────────────────────────────────────────────────────── + const loadUserInfo = async (token: string) => { try { - // Guardar temporalmente el token para que api.ts lo use authStore.setToken(token); - - // Usar la API centralizada const { api } = await import('./api'); const response = await api.auth.me(); - if (response.data) { - const data = response.data; - const user: User = { - id: data.sub || '', - username: data.preferred_username || data.username || '', - email: data.email, - name: data.name, - tenantId: data.tenant_id, - roles: data.realm_access?.roles || [] - }; - authStore.setUser(user); + const d = response.data; + authStore.setUser({ + id: d.sub ?? '', + username: d.preferred_username ?? d.username ?? '', + email: d.email, + name: d.name, + tenantId: d.tenant_id, + roles: d.realm_access?.roles ?? [] + }); } - } catch (error) { - console.error('Error cargando información del usuario:', error); + } catch (err) { + console.error('[auth] Error cargando info del usuario:', err); } }; -/** - * Cierra sesión - */ +// ───────────────────────────────────────────────────────── +// Logout +// ───────────────────────────────────────────────────────── + export const logout = async () => { if (!browser) return; try { - // Capturar tokens antes de limpiar nada - const refreshToken = localStorage.getItem('refresh_token'); - const accessToken = localStorage.getItem('access_token'); + // Detener el SessionManager + try { + const { destroySessionManager } = await import('./session-manager'); + destroySessionManager(); + } catch {} // Limpiar store de compañías try { const { companyStore } = await import('./stores/company.svelte'); companyStore.clear(); - } catch (error) { - console.error('Error al limpiar store de compañías:', error); - } + } catch {} - // Limpiar estado local + // Limpiar estado en memoria authStore.reset(); - localStorage.removeItem('access_token'); - localStorage.removeItem('refresh_token'); + + // Eliminar cookie no-HttpOnly del access_token deleteCookie('access_token'); - deleteCookie('refresh_token'); + // La cookie HttpOnly del refresh_token la limpia el servidor - // Si hay instancia de Keycloak, hacer logout de Keycloak + // Logout de Keycloak JS si estaba autenticado con SSO if (keycloakInstance?.authenticated) { - // Primero notificamos al servidor para limpieza de cookies (SvelteKit) try { - await fetch('/logout', { - method: 'POST' - }); - } catch (e) { - console.error("Error calling server logout:", e); - } - + await fetch('/logout', { method: 'POST' }); + } catch {} await keycloakInstance.logout({ redirectUri: window.location.origin + '/login' }); return; } - // Llamar al endpoint del servidor para limpiar cookies de SvelteKit - // Usar un formulario para hacer POST y permitir la redirección + // Para login con password: POST al logout route del servidor const form = document.createElement('form'); form.method = 'POST'; form.action = '/logout'; - - - document.body.appendChild(form); form.submit(); - - } catch (error) { - console.error('Error durante logout:', error); - // Asegurar que se redirija al login aunque haya error + } catch (err) { + console.error('[auth] Error durante logout:', err); window.location.href = '/login'; } }; -/** - * Verifica si el usuario tiene un rol específico - */ +// ───────────────────────────────────────────────────────── +// Token accessors +// ───────────────────────────────────────────────────────── + export const hasRole = (role: string): boolean => { if (!keycloakInstance?.authenticated) return false; return keycloakInstance.hasRealmRole(role); }; -/** - * Obtiene el token de acceso actual - */ +/** Obtiene el access token desde memoria (Keycloak JS o authStore) */ export const getToken = (): string | null => { - // Intentar obtener de Keycloak primero - if (keycloakInstance?.token) { - return keycloakInstance.token; - } + // Prioridad 1: Keycloak JS en memoria + if (keycloakInstance?.token) return keycloakInstance.token; - // Si no, intentar de localStorage - if (browser) { - let token = localStorage.getItem('access_token'); + // Prioridad 2: authStore en memoria + let token: string | null = null; + const unsub = authStore.subscribe((s) => { token = s.token; }); + unsub(); + if (token) return token; - // Si no hay token en localStorage, intentar de las cookies - if (!token) { - token = getCookie('access_token'); - // Si lo encontramos en cookies, sincronizarlo a localStorage - if (token) { - localStorage.setItem('access_token', token); - } - } - - return token; - } + // Prioridad 3: cookie no-HttpOnly (fallback para acceso inicial antes del onMount) + if (browser) return getCookie('access_token'); return null; }; /** - * Refresca el access token usando el refresh token + * Refresca el access token usando el endpoint server-side seguro. + * El servidor lee el refresh_token de la cookie HttpOnly. + * @returns true si el refresh fue exitoso */ export const refreshAccessToken = async (): Promise => { if (!browser) return false; - const refreshToken = localStorage.getItem('refresh_token'); - if (!refreshToken) { - return false; + // Con Keycloak JS activo, usar su mecanismo nativo + if (keycloakInstance?.authenticated) { + try { + const refreshed = await keycloakInstance.updateToken(70); + if (refreshed || keycloakInstance.token) { + authStore.setToken(keycloakInstance.token ?? null); + return true; + } + } catch { + await logout(); + return false; + } } + // Flujo de contraseña: usar el endpoint server-side seguro try { - const { api } = await import('./api'); - const response = await api.auth.refresh(refreshToken); + const resp = await fetch('/api-sveltekit/auth/silent-refresh', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' } + }); - if (response.error || !response.data) { - console.error('Failed to refresh token:', response.error); - // Si falla el refresh, hacer logout + if (!resp.ok) { await logout(); return false; } - // Actualizar tokens - const newAccessToken = response.data.access_token; - const newRefreshToken = response.data.refresh_token; - - authStore.setToken(newAccessToken); - localStorage.setItem('access_token', newAccessToken); - - if (newRefreshToken) { - localStorage.setItem('refresh_token', newRefreshToken); + const data = await resp.json() as { access_token?: string }; + if (data.access_token) { + authStore.setToken(data.access_token); + setCookie('access_token', data.access_token); + return true; } - - // Actualizar también la cookie - setCookie('access_token', newAccessToken); - return true; - - } catch (error) { - await logout(); - return false; + } catch (err) { + console.error('[auth] Error en refreshAccessToken:', err); } + + await logout(); + return false; }; -/** - * Obtiene el refresh token - */ +/** @deprecated El refresh_token ya no se expone en JS. */ export const getRefreshToken = (): string | null => { - if (!browser) return null; - return localStorage.getItem('refresh_token'); + console.warn('[auth] getRefreshToken() está deprecado — el refresh_token no se expone en JS.'); + return null; }; -/** - * Obtiene la instancia de Keycloak - */ -export const getKeycloakInstance = (): Keycloak | null => { - return keycloakInstance; -}; +export const getKeycloakInstance = (): Keycloak | null => keycloakInstance; diff --git a/frontend/src/lib/components/dashboard/customs_brokers/data-table-actions.svelte b/frontend/src/lib/components/dashboard/customs_brokers/data-table-actions.svelte index d32046fc..5e203e5b 100644 --- a/frontend/src/lib/components/dashboard/customs_brokers/data-table-actions.svelte +++ b/frontend/src/lib/components/dashboard/customs_brokers/data-table-actions.svelte @@ -1,16 +1,16 @@ + + + + + + + + + ⚠️ Sesión por expirar + + + Tu sesión cerrará automáticamente por inactividad en + + {formatTime(remainingSeconds)} + . +
+ ¿Deseas continuar trabajando? +
+
+ + + + + +
+
+
diff --git a/frontend/src/lib/components/sidebar/nav-main.svelte b/frontend/src/lib/components/sidebar/nav-main.svelte index ff5a24f7..18f8fb37 100644 --- a/frontend/src/lib/components/sidebar/nav-main.svelte +++ b/frontend/src/lib/components/sidebar/nav-main.svelte @@ -1,12 +1,12 @@ - - {#snippet child({ props })} - + {#snippet child({ props })} + +
-
- {#if activeCompanyLogoUrl} - {companyStore.activeCompany?.name { - (e.currentTarget as HTMLImageElement).style.display = "none"; - }} - /> - {:else} - {activeCompanyInitials} - {/if} -
-
- - {companyStore.activeCompany?.name || "Seleccionar compañía"} + {#if activeCompanyLogoUrl} + {companyStore.activeCompany?.name { + (e.currentTarget as HTMLImageElement).style.display = 'none'; + }} + /> + {:else} + {activeCompanyInitials} + {/if} +
+
+ + {companyStore.activeCompany?.name || 'Seleccionar compañía'} + + {#if companyStore.activeCompany?.rfc} + + {companyStore.activeCompany.rfc} - {#if companyStore.activeCompany?.rfc} - - {companyStore.activeCompany.rfc} - - {/if} -
- + {/if} +
+ - - -
- {/snippet} -
+ + + + {/snippet} + - - Mis Compañías - - + Mis Compañías + {#if companyStore.loading} Cargando... @@ -113,25 +111,28 @@ {:else} {#each companyStore.companies as company, index (company.id)} - companyStore.setActiveCompany(company)} - class="gap-2 p-2 cursor-pointer" + companyStore.setActiveCompany(company)} + class="cursor-pointer gap-2 p-2" > -
+
{#if company.logo} - {company.name} {:else} - {company.name.slice(0, 2).toUpperCase()} + {company.name.slice(0, 2).toUpperCase()} {/if}
-
- {company.name} +
+ {company.name} {#if company.rfc} - {company.rfc} + {company.rfc} {/if}
{#if companyStore.activeCompany?.id === company.id} diff --git a/frontend/src/lib/components/ui/dialog/dialog-content.svelte b/frontend/src/lib/components/ui/dialog/dialog-content.svelte index a647d566..f58ca8b8 100644 --- a/frontend/src/lib/components/ui/dialog/dialog-content.svelte +++ b/frontend/src/lib/components/ui/dialog/dialog-content.svelte @@ -1,9 +1,9 @@ - - + + Close {/if} - + diff --git a/frontend/src/lib/components/ui/dialog/index.ts b/frontend/src/lib/components/ui/dialog/index.ts index 1d37c04e..dce1d9dc 100644 --- a/frontend/src/lib/components/ui/dialog/index.ts +++ b/frontend/src/lib/components/ui/dialog/index.ts @@ -1,4 +1,4 @@ -import { Dialog } from "bits-ui"; +import { Dialog as DialogPrimitive } from "bits-ui"; import Title from "./dialog-title.svelte"; import Footer from "./dialog-footer.svelte"; @@ -9,8 +9,8 @@ import Description from "./dialog-description.svelte"; import Trigger from "./dialog-trigger.svelte"; import Close from "./dialog-close.svelte"; -const Root = Dialog.Root; -const Portal = Dialog.Portal; +const Root = DialogPrimitive.Root; +const Portal = DialogPrimitive.Portal; export { Root, diff --git a/frontend/src/lib/components/ui/sheet/index.ts b/frontend/src/lib/components/ui/sheet/index.ts index 94a584fb..2c191acd 100644 --- a/frontend/src/lib/components/ui/sheet/index.ts +++ b/frontend/src/lib/components/ui/sheet/index.ts @@ -1,5 +1,5 @@ -import { Dialog } from "bits-ui"; -const SheetPrimitive = Dialog; +import { Dialog as DialogPrimitive } from "bits-ui"; +const SheetPrimitive = DialogPrimitive; import Trigger from "./sheet-trigger.svelte"; import Close from "./sheet-close.svelte"; import Overlay from "./sheet-overlay.svelte"; diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-menu-button.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-menu-button.svelte index 4bef683f..c358b708 100644 --- a/frontend/src/lib/components/ui/sidebar/sidebar-menu-button.svelte +++ b/frontend/src/lib/components/ui/sidebar/sidebar-menu-button.svelte @@ -1,47 +1,45 @@ @@ -90,10 +88,10 @@
@@ -475,3 +573,38 @@ + + + + + ¿Generar Interface Agente Aduanal? + + Se generará el reporte WINSAAI para el pedimento seleccionado. +
+ + +
+
+
+
+ + +
+
+
+ +{#if showProgressDialog && currentTaskId} + (showProgressDialog = false)} + onComplete={onPdfComplete} + /> +{/if} diff --git a/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.svelte b/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.svelte index 37d4cd35..8e342ed2 100644 --- a/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.svelte @@ -432,6 +432,31 @@ } } + // Validar tipo de cambio en create y update + if (generalFormData) { + const rate = generalFormData.exchange_rate; + if ( + rate === null || + rate === undefined || + String(rate).trim() === '' || + Number(rate) <= 0 + ) { + saving = false; + activeTab = 'general'; + const date = generalFormData.entry_date || ''; + toast.error( + Number(rate) <= 0 && rate !== null && rate !== undefined + ? 'El tipo de cambio debe ser mayor a 0. Registra el tipo de cambio para la fecha de entrada.' + : 'No hay tipo de cambio registrado para la fecha de entrada. Por favor, regístralo antes de guardar.' + ); + if (date) { + missingExchangeRateDate = date; + showExchangeRateDialog = true; + } + return; + } + } + // Construir el payload unificado const payload: any = { // Datos generales diff --git a/frontend/src/routes/dashboard/users/+page.svelte b/frontend/src/routes/dashboard/users/+page.svelte index 1edb8a30..2420caa4 100644 --- a/frontend/src/routes/dashboard/users/+page.svelte +++ b/frontend/src/routes/dashboard/users/+page.svelte @@ -2,9 +2,16 @@ import { usersAPI, type User, type UserStats } from '$lib/api/dashboard/users'; import { userRolesAPI, type UserRole } from '$lib/api/dashboard/admin/user-roles'; import { rolesAPI, type CompanyRole } from '$lib/api/dashboard/admin/roles'; - import { rolePermissionsAPI, type RolePermission } from '$lib/api/dashboard/admin/role-permissions'; + import { + rolePermissionsAPI, + type RolePermission + } from '$lib/api/dashboard/admin/role-permissions'; import { permissionsAPI, type Permission } from '$lib/api/dashboard/admin/permissions'; - import { userPermissionsAPI, type UserPermission, type EffectiveUserPermissions } from '$lib/api/dashboard/admin/user-permissions'; + import { + userPermissionsAPI, + type UserPermission, + type EffectiveUserPermissions + } from '$lib/api/dashboard/admin/user-permissions'; import { companyStore } from '$lib/stores/company.svelte'; import * as Card from '$lib/components/ui/card'; import * as Dialog from '$lib/components/ui/dialog'; @@ -20,13 +27,13 @@ import * as Tooltip from '$lib/components/ui/tooltip'; import { Badge } from '$lib/components/ui/badge'; import { Checkbox } from '$lib/components/ui/checkbox'; - import { - Plus, - Pencil, - Trash2, - Key, - UserCheck, - UserX, + import { + Plus, + Pencil, + Trash2, + Key, + UserCheck, + UserX, Search, RefreshCw, Users, @@ -109,7 +116,7 @@ // Dividir el código en partes const parts = permission.code.split('.'); - + // Si solo tiene module.action, formatear simple if (parts.length === 2) { const [module, action] = parts; @@ -120,8 +127,8 @@ // La última parte es la acción, el resto son módulos/submódulos const action = parts[parts.length - 1]; const modules = parts.slice(0, -1); - - const modulePath = modules.map(m => getModuleLabel(m)).join(' › '); + + const modulePath = modules.map((m) => getModuleLabel(m)).join(' › '); return `${modulePath} - ${getActionLabel(action)}`; } @@ -164,9 +171,7 @@ // Roles disponibles que NO están asignados const unassignedRoles = $derived( - availableRoles.filter( - role => !userRoles.some(ur => ur.company_role_id === role.id) - ) + availableRoles.filter((role) => !userRoles.some((ur) => ur.company_role_id === role.id)) ); // Usuario seleccionado para editar/eliminar @@ -250,7 +255,7 @@ page_size: pageSize, search: searchTerm || undefined }); - + users = response.users; totalPages = response.total_pages; } catch (error: any) { @@ -275,8 +280,13 @@ // Crear usuario async function handleCreate() { - if (!createForm.email || !createForm.username || !createForm.first_name || - !createForm.last_name || !createForm.password) { + if ( + !createForm.email || + !createForm.username || + !createForm.first_name || + !createForm.last_name || + !createForm.password + ) { toast.error('Por favor complete todos los campos requeridos'); return; } @@ -385,7 +395,9 @@ try { await usersAPI.delete(selectedUser.id, companyId, softDelete); - toast.success(softDelete ? 'Usuario desactivado exitosamente' : 'Usuario eliminado permanentemente'); + toast.success( + softDelete ? 'Usuario desactivado exitosamente' : 'Usuario eliminado permanentemente' + ); showDeleteDialog = false; selectedUser = null; await Promise.all([loadUsers(), loadStats()]); @@ -429,10 +441,14 @@ } try { - await usersAPI.changePassword(selectedUser.id, { - password: passwordForm.password, - temporary: passwordForm.temporary - }, companyId); + await usersAPI.changePassword( + selectedUser.id, + { + password: passwordForm.password, + temporary: passwordForm.temporary + }, + companyId + ); toast.success('Contraseña actualizada exitosamente'); showPasswordDialog = false; selectedUser = null; @@ -449,11 +465,7 @@ async function openRolesDialog(user: User) { selectedUser = user; showRolesDialog = true; - await Promise.all([ - loadUserRoles(), - loadAvailableRoles(), - loadUserEffectivePermissions() - ]); + await Promise.all([loadUserRoles(), loadAvailableRoles(), loadUserEffectivePermissions()]); } async function loadAvailableRoles() { @@ -550,34 +562,34 @@ function openUserPermissionsDialog() { if (!effectivePermissions) return; - + // Permisos disponibles son todos los permisos que no están: // - Ya concedidos individualmente // - Ya revocados individualmente - const grantedIds = new Set(effectivePermissions.granted_permissions.map(p => p.id)); - const revokedIds = new Set(effectivePermissions.revoked_permissions.map(p => p.id)); - + const grantedIds = new Set(effectivePermissions.granted_permissions.map((p) => p.id)); + const revokedIds = new Set(effectivePermissions.revoked_permissions.map((p) => p.id)); + availablePermissionsForUser = allPermissionsState.filter( - p => !grantedIds.has(p.id) && !revokedIds.has(p.id) + (p) => !grantedIds.has(p.id) && !revokedIds.has(p.id) ); - + selectedUserPermissionIds = []; showUserPermissionsDialog = true; } function openGrantUserPermDialog() { if (!effectivePermissions) return; - + // Permisos disponibles son todos los permisos que no están: // - Ya concedidos individualmente // - Ya revocados individualmente - const grantedIds = new Set(effectivePermissions.granted_permissions.map(p => p.id)); - const revokedIds = new Set(effectivePermissions.revoked_permissions.map(p => p.id)); - + const grantedIds = new Set(effectivePermissions.granted_permissions.map((p) => p.id)); + const revokedIds = new Set(effectivePermissions.revoked_permissions.map((p) => p.id)); + availablePermissionsForUser = allPermissionsState.filter( - p => !grantedIds.has(p.id) && !revokedIds.has(p.id) + (p) => !grantedIds.has(p.id) && !revokedIds.has(p.id) ); - + selectedUserPermissionIds = []; permissionSearchQuery = ''; showGrantUserPermDialog = true; @@ -593,11 +605,11 @@ try { // Conceder cada permiso seleccionado await Promise.all( - selectedUserPermissionIds.map(permId => + selectedUserPermissionIds.map((permId) => userPermissionsAPI.grant(userId, companyId, permId) ) ); - + toast.success(`${selectedUserPermissionIds.length} permiso(s) concedido(s)`); // Cerrar todos los diálogos showGrantUserPermDialog = false; @@ -644,7 +656,7 @@ function toggleUserPermission(permId: number) { if (selectedUserPermissionIds.includes(permId)) { - selectedUserPermissionIds = selectedUserPermissionIds.filter(id => id !== permId); + selectedUserPermissionIds = selectedUserPermissionIds.filter((id) => id !== permId); } else { selectedUserPermissionIds = [...selectedUserPermissionIds, permId]; } @@ -735,7 +747,7 @@ function updatePermissionsByModuleForRole() { const grouped = new Map(); - + const filtered = availablePermissionsState.filter((p) => { if (!permissionSearchQuery.trim()) return true; const search = permissionSearchQuery.toLowerCase(); @@ -745,14 +757,14 @@ p.action.toLowerCase().includes(search) ); }); - + filtered.forEach((p) => { if (!grouped.has(p.module)) { grouped.set(p.module, []); } grouped.get(p.module)!.push(p); }); - + permissionsByModuleState = grouped; } @@ -823,23 +835,23 @@ try { await rolesAPI.delete(roleToDelete.id, companyId); - + // Limpiar estado si el rol eliminado estaba seleccionado if (selectedRoleForPermissions?.id === roleToDelete.id) { selectedRoleForPermissions = null; rolePermissionsState = []; } - + showDeleteRoleDialog = false; const deletedRoleId = roleToDelete.id; roleToDelete = null; - + // Recargar listas await Promise.all([loadRolesTab(), loadAvailableRoles()]); - + // Verificar que el rol ya no esté en la lista - rolesState = rolesState.filter(r => r.id !== deletedRoleId); - + rolesState = rolesState.filter((r) => r.id !== deletedRoleId); + toast.success('Rol eliminado permanentemente'); } catch (error: any) { // Manejar error específico cuando el rol tiene usuarios asignados @@ -857,7 +869,11 @@ if (!companyId || !selectedRoleForPermissions || selectedPermissionIds.length === 0) return; try { - await rolePermissionsAPI.assignMultiple(selectedRoleForPermissions.id, companyId, selectedPermissionIds); + await rolePermissionsAPI.assignMultiple( + selectedRoleForPermissions.id, + companyId, + selectedPermissionIds + ); toast.success('Permisos asignados correctamente'); showPermissionsDialog = false; await selectRoleForPermissions(selectedRoleForPermissions); @@ -890,7 +906,6 @@ } } - // Cargar datos reactivamente cuando cambia la compañía $effect(() => { const companyId = companyStore.activeCompany?.id; @@ -900,9 +915,9 @@ }); -
+
-
+

Gestión de Usuarios y Roles

Administra usuarios, roles y permisos de tu organización

@@ -913,256 +928,270 @@ - + Usuarios - + Roles y Permisos - +
-
- - {#if stats} -
- - - Total Usuarios - - - -
{stats.total_users}
-
-
+ + {#if stats} +
+ + + Total Usuarios + + + +
{stats.total_users}
+
+
- - - Activos - - - -
{stats.active_users}
-
-
+ + + Activos + + + +
{stats.active_users}
+
+
- - - Disponibles - - - -
{stats.users_available}
-

de {stats.max_users_allowed} permitidos

-
-
+ + + Disponibles + + + +
{stats.users_available}
+

de {stats.max_users_allowed} permitidos

+
+
- - - Uso de Licencia - - -
{stats.usage_percentage.toFixed(1)}%
-
-
= 70 && stats.usage_percentage < 90} - class:bg-red-600={stats.usage_percentage >= 90} - style="width: {stats.usage_percentage}%" - >
-
-
-
-
- {/if} - - - - -
- Usuarios -
-
- - -
- -
-
-
- - - - - Usuario - Email - Nombre - Rol - Estado - Acciones - - - - {#if loading && users.length === 0} - - - - - - {:else if users.length === 0} - - - No se encontraron usuarios - - - {:else} - {#each users as user} - - {user.username} - -
- {user.email} - {#if user.email_verified} - Verificado - {/if} -
-
- {user.first_name} {user.last_name} - - {#if user.role} -
- {#each user.role.split(', ') as roleName} - {roleName} - {/each} -
- {:else} - - - {/if} -
- - {#if user.enabled} - Activo - {:else} - Inactivo - {/if} - - -
- - - - - Gestionar roles - - - - - - Editar usuario - - - - - - Restablecer contrasena - - - - - - Eliminar usuario - -
-
-
- {/each} - {/if} -
-
- - - {#if totalPages > 1} -
-

- Página {currentPage} de {totalPages} -

-
- - -
+ + + Uso de Licencia + + +
{stats.usage_percentage.toFixed(1)}%
+
+
= 70 && stats.usage_percentage < 90} + class:bg-red-600={stats.usage_percentage >= 90} + style="width: {stats.usage_percentage}%" + >
+
+
+
{/if} -
-
+ + + + +
+ Usuarios +
+
+ + +
+ +
+
+
+ + + + + Usuario + Email + Nombre + Rol + Estado + Acciones + + + + {#if loading && users.length === 0} + + + + + + {:else if users.length === 0} + + + No se encontraron usuarios + + + {:else} + {#each users as user} + + {user.username} + +
+ {user.email} + {#if user.email_verified} + Verificado + {/if} +
+
+ {user.first_name} {user.last_name} + + {#if user.role} +
+ {#each user.role.split(', ') as roleName} + {roleName} + {/each} +
+ {:else} + - + {/if} +
+ + {#if user.enabled} + Activo + {:else} + Inactivo + {/if} + + +
+ + + {#snippet child({ props })} + + {/snippet} + + Gestionar roles + + + + {#snippet child({ props })} + + {/snippet} + + Editar usuario + + + + {#snippet child({ props })} + + {/snippet} + + Restablecer contrasena + + + + {#snippet child({ props })} + + {/snippet} + + Eliminar usuario + +
+
+
+ {/each} + {/if} +
+
+ + + {#if totalPages > 1} +
+

+ Página {currentPage} de {totalPages} +

+
+ + +
+
+ {/if} +
+
- -
+ +
-
-
-

+
+
+

Roles

@@ -1172,10 +1201,10 @@
{#if loadingRoles} -

Cargando...

+

Cargando...

{:else if rolesState.length === 0} -
-

No hay roles disponibles

+
+

No hay roles disponibles

@@ -1256,19 +1288,19 @@ Módulo Acción Permiso - Acciones + Acciones {#if loadingPermissionsState} - + Cargando permisos... {:else if rolePermissionsState.length === 0} - + Este rol no tiene permisos asignados @@ -1276,16 +1308,25 @@ {#each rolePermissionsState as rp (rp.id)} - {rp.permission?.module ? getModuleLabel(rp.permission.module) : '-'} + {rp.permission?.module + ? getModuleLabel(rp.permission.module) + : '-'} - {rp.permission?.action ? getActionLabel(rp.permission.action) : '-'} + {rp.permission?.action + ? getActionLabel(rp.permission.action) + : '-'} {#if rp.permission}
{formatPermissionLabel(rp.permission)}
- {rp.permission.code} + {rp.permission.code}
{:else} - @@ -1308,8 +1349,8 @@
{:else} -
- +
+

Selecciona un rol para ver sus permisos

{/if} @@ -1327,7 +1368,8 @@ Crear Nuevo Usuario - Ingresa la información del nuevo usuario. Se enviará un correo para configurar su contraseña. + Ingresa la información del nuevo usuario. Se enviará un correo para configurar su + contraseña.
@@ -1356,7 +1398,7 @@
- + @@ -1391,12 +1433,17 @@
- +
- + @@ -1421,12 +1468,17 @@

- +
- + @@ -1446,11 +1498,14 @@ Cancelar - handleDelete(false)} class="bg-destructive text-destructive-foreground hover:bg-destructive/90"> - + handleDelete(false)} + class="text-destructive-foreground bg-destructive hover:bg-destructive/90" + > + Eliminar Permanente @@ -1461,9 +1516,7 @@ Gestionar Roles - {selectedUser?.username} - - Asigna o remueve roles para este usuario - + Asigna o remueve roles para este usuario
@@ -1475,27 +1528,25 @@
{:else if userRoles.length === 0} -
+
Este usuario no tiene roles asignados
{:else}
{#each userRoles as userRole} -
+
-
{userRole.company_role?.name || 'Rol desconocido'}
+
+ {userRole.company_role?.name || 'Rol desconocido'} +
- Asignado el {new Date(userRole.created_at).toLocaleDateString()} + Asignado el {new Date(userRole.created_at).toLocaleDateString()}
-
@@ -1503,24 +1554,33 @@
{/if}
- +
{#if unassignedRoles.length === 0}

- No hay roles disponibles. Para cambiar de rol, primero remueve el rol actual usando el botón de eliminar arriba. + No hay roles disponibles. Para cambiar de rol, primero remueve el rol actual usando el + botón de eliminar arriba.

{:else}
selectedRoleId = v ? parseInt(v) : 0} + value={typeof selectedRoleId === 'number' + ? selectedRoleId.toString() + : selectedRoleId} + onValueChange={(v: string | undefined) => (selectedRoleId = v ? parseInt(v) : 0)} > - {selectedRoleId && selectedRoleId !== 0 - ? availableRoles.find(r => r.id === (typeof selectedRoleId === 'string' ? parseInt(selectedRoleId) : selectedRoleId))?.name || 'Selecciona un rol' + {selectedRoleId && selectedRoleId !== 0 + ? availableRoles.find( + (r) => + r.id === + (typeof selectedRoleId === 'string' + ? parseInt(selectedRoleId) + : selectedRoleId) + )?.name || 'Selecciona un rol' : 'Selecciona un rol'} @@ -1530,7 +1590,7 @@
@@ -1539,25 +1599,28 @@
- - + - + Permisos Individuales - {selectedUser?.username} @@ -1565,24 +1628,30 @@ -
+
{#if loadingUserPermissions}
{:else if effectivePermissions} -
+
-
{effectivePermissions.role_permissions.length}
+
+ {effectivePermissions.role_permissions.length} +
Del Rol
-
{effectivePermissions.granted_permissions.length}
+
+ {effectivePermissions.granted_permissions.length} +
Concedidos Extra
-
{effectivePermissions.revoked_permissions.length}
+
+ {effectivePermissions.revoked_permissions.length} +
Revocados
@@ -1591,17 +1660,17 @@ {#if effectivePermissions.granted_permissions.length > 0}
-
-
+
{#each effectivePermissions.granted_permissions as perm} -
-
-
{formatPermissionLabel(perm)}
+
+
+
{formatPermissionLabel(perm)}
{perm.code}
- - + @@ -1706,7 +1785,7 @@ - + Conceder Permisos Extra a {selectedUser?.username} @@ -1714,7 +1793,7 @@ -
+
{#if availablePermissionsForUser.length === 0} -

+

No hay permisos disponibles para conceder

{:else}
{#each [...availablePermissionsForUser.reduce((map, p) => { - const module = p.module; - if (!map.has(module)) map.set(module, []); - map.get(module).push(p); - return map; - }, new Map())] as [module, permissions] (module)} -
-

+ const module = p.module; + if (!map.has(module)) map.set(module, []); + map.get(module).push(p); + return map; + }, new Map())] as [module, permissions] (module)} +
+

{getModuleLabel(module)} - ({permissions.length} {permissions.length === 1 ? 'permiso' : 'permisos'}) + ({permissions.length} {permissions.length === 1 ? 'permiso' : 'permisos'})

{#each permissions as permission (permission.id)} - {#if !permissionSearchQuery || formatPermissionLabel(permission).toLowerCase().includes(permissionSearchQuery.toLowerCase()) || permission.code.toLowerCase().includes(permissionSearchQuery.toLowerCase())} -
+ {#if !permissionSearchQuery || formatPermissionLabel(permission) + .toLowerCase() + .includes(permissionSearchQuery.toLowerCase()) || permission.code + .toLowerCase() + .includes(permissionSearchQuery.toLowerCase())} +
toggleUserPermission(permission.id)} /> -
+
-
- {getActionLabel(permission.action)} - {permission.code} +
+ {getActionLabel(permission.action)} + {permission.code}
@@ -1774,8 +1865,11 @@
- - + @@ -1784,12 +1878,12 @@ - + Agregar Permisos a "{selectedRoleForPermissions?.name}" -
+
{#if availablePermissionsState.length === 0} -

+

No hay permisos disponibles para asignar

{:else}
{#each [...permissionsByModuleState] as [module, permissions] (module)} -
-

+
+

{getModuleLabel(module)} - ({permissions.length} {permissions.length === 1 ? 'permiso' : 'permisos'}) + ({permissions.length} {permissions.length === 1 ? 'permiso' : 'permisos'})

{#each permissions as permission (permission.id)} -
+
togglePermission(permission.id)} /> -
+
-
- {getActionLabel(permission.action)} - {permission.code} +
+ {getActionLabel(permission.action)} + {permission.code}
@@ -1902,12 +2003,17 @@ ¿Eliminar rol permanentemente? {#if roleToDelete} - Estás a punto de eliminar el rol "{roleToDelete.name}" de forma permanente. + Estás a punto de eliminar el rol "{roleToDelete.name}" de forma + permanente.

- Esta acción NO se puede deshacer y el rol será eliminado completamente del sistema. + Esta acción NO se puede deshacer y el rol será + eliminado completamente del sistema.

{#if rolePermissionsState.length > 0 && selectedRoleForPermissions?.id === roleToDelete.id} - Este rol tiene {rolePermissionsState.length} {rolePermissionsState.length === 1 ? 'permiso asignado' : 'permisos asignados'}. + Este rol tiene {rolePermissionsState.length} + {rolePermissionsState.length === 1 ? 'permiso asignado' : 'permisos asignados'}. {/if}
Si solo deseas desactivarlo temporalmente, usa el interruptor "Activo" en el modo de edición. @@ -1916,7 +2022,10 @@ Cancelar - + Eliminar permanentemente diff --git a/frontend/static/silent-check-sso.html b/frontend/static/silent-check-sso.html new file mode 100644 index 00000000..455d67bc --- /dev/null +++ b/frontend/static/silent-check-sso.html @@ -0,0 +1,14 @@ + + + + Silent SSO Check + + + + + diff --git a/scripts/init_first_time.sh b/scripts/init_first_time.sh index de1c32ae..d9f09601 100755 --- a/scripts/init_first_time.sh +++ b/scripts/init_first_time.sh @@ -137,6 +137,7 @@ POSTGRES_PORT="${POSTGRES_PORT:-5432}" POSTGRES_DB="${POSTGRES_DB:-anexo76_core}" POSTGRES_USER="${POSTGRES_USER:-postgres}" POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-postgres}" +BACKEND_URL="${BACKEND_URL:-http://localhost:8000}" DEMO_USERNAME="demo" DEMO_PASSWORD="demo123" @@ -601,27 +602,6 @@ fi echo -e "${GREEN}✓ Tenant ID: ${TENANT_ID}${NC}" -# Insertar company si no existe -COMPANY_EXISTS=$(exec_pg_sql "SELECT COUNT(*) FROM a76.company WHERE tenant_id = ${TENANT_ID};") -COMPANY_EXISTS=$(echo "$COMPANY_EXISTS" | xargs) - -if [ "$COMPANY_EXISTS" = "0" ]; then - exec_pg_sql "INSERT INTO a76.company (tenant_id, name, rfc, is_service_company, created_at, updated_at) VALUES (${TENANT_ID}, '${COMPANY_NAME}', '${COMPANY_RFC}', false, now(), now());" >/dev/null - exec_pg_sql_client "INSERT INTO a76.company (tenant_id, name, rfc, is_service_company, created_at, updated_at) VALUES (${TENANT_ID}, '${COMPANY_NAME}', '${COMPANY_RFC}', false, now(), now());" >/dev/null - echo -e "${GREEN}✓ Company creada (Hub y Cliente)${NC}" -else - echo -e "${YELLOW}⚠ Company ya existe para este tenant${NC}" -fi - -# Obtener ID de la company (necesario para user_tenants) -COMPANY_ID=$(exec_pg_sql "SELECT id FROM a76.company WHERE tenant_id = ${TENANT_ID} LIMIT 1;" | xargs) -if [ -z "$COMPANY_ID" ]; then - echo -e "${RED}✗ Error: No se pudo obtener el ID de la company${NC}" - exit 1 -fi -COMPANY_INFO=$(exec_pg_sql "SELECT id, name FROM a76.company WHERE tenant_id = ${TENANT_ID} LIMIT 1;") -echo -e "${GREEN}✓ Company ID: ${COMPANY_ID} | ${COMPANY_INFO}${NC}" - # Agregar tenant_id al usuario demo en Keycloak echo -e "\n${YELLOW}Asignando tenant_id al usuario demo...${NC}" @@ -636,14 +616,71 @@ curl -s -X PUT "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/users/${USER_ID}" echo -e "${GREEN}✓ Atributo tenant_id asignado al usuario${NC}" -# Agregar relación usuario-tenant en la base de datos (usar company_id real) + + +# Obtener token de demo user para usar la API con su tenant +echo "Obteniendo token de usuario demo..." +DEMO_TOKEN_RESPONSE=$(curl -s -X POST "${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=${DEMO_USERNAME}" \ + -d "password=${DEMO_PASSWORD}" \ + -d "grant_type=password" \ + -d "client_id=anexo76-backend" \ + -d "client_secret=${BACKEND_SECRET}") + +DEMO_ACCESS_TOKEN=$(echo "$DEMO_TOKEN_RESPONSE" | jq -r '.access_token // empty') + +if [ -z "$DEMO_ACCESS_TOKEN" ]; then + echo -e "${RED}✗ Error: No se pudo obtener el token de usuario demo${NC}" + exit 1 +fi + +# Insertar company si no existe +COMPANY_EXISTS=$(exec_pg_sql "SELECT COUNT(*) FROM a76.company WHERE tenant_id = ${TENANT_ID};") +COMPANY_EXISTS=$(echo "$COMPANY_EXISTS" | xargs) + +if [ "$COMPANY_EXISTS" = "0" ]; then + echo " → Creando company mediante API..." + CREATE_COMPANY_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${BACKEND_URL}/api/v1/a76/company" \ + -H "Authorization: Bearer ${DEMO_ACCESS_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{ + \"name\": \"${COMPANY_NAME}\", + \"rfc\": \"${COMPANY_RFC}\", + \"is_service_company\": false + }") + + HTTP_CODE=$(echo "$CREATE_COMPANY_RESPONSE" | tail -n1) + RESPONSE_BODY=$(echo "$CREATE_COMPANY_RESPONSE" | head -n -1) + + if [ "$HTTP_CODE" = "201" ]; then + COMPANY_ID=$(echo "$RESPONSE_BODY" | jq -r '.id // empty') + echo -e "${GREEN}✓ Company creada exitosamente via API (Hub, Cliente replicado por DB trigger o servicio asíncrono)${NC}" + else + echo -e "${RED}✗ Error al crear company via API (HTTP ${HTTP_CODE})${NC}" + echo "Respuesta: $RESPONSE_BODY" + exit 1 + fi +else + echo -e "${YELLOW}⚠ Company ya existe para este tenant${NC}" + # Obtener información de la company existente + COMPANY_ID=$(exec_pg_sql "SELECT id FROM a76.company WHERE tenant_id = ${TENANT_ID} LIMIT 1;" | xargs) +fi + +COMPANY_INFO=$(exec_pg_sql "SELECT name FROM a76.company WHERE id = ${COMPANY_ID} AND tenant_id = ${TENANT_ID} LIMIT 1;") +echo -e "${GREEN}✓ Company: ${COMPANY_INFO}${NC}" +echo -e "${GREEN}✓ Company ID: ${COMPANY_ID}${NC}" + +# Agregar relación usuario-tenant en la base de datos echo -e "\n${YELLOW}Creando relación usuario-tenant en la base de datos...${NC}" -exec_pg_sql "INSERT INTO core.user_tenants (keycloak_user_id, tenant_id, company_id, role, is_active, created_at, updated_at) VALUES ('${USER_ID}', ${TENANT_ID}, 1, 'admin', true, now(), now()) ON CONFLICT (keycloak_user_id, tenant_id, company_id) DO UPDATE SET is_active = true, updated_at = CURRENT_TIMESTAMP;" >/dev/null -exec_pg_sql_client "INSERT INTO core.user_tenants (keycloak_user_id, tenant_id, company_id, role, is_active, created_at, updated_at) VALUES ('${USER_ID}', ${TENANT_ID}, 1, 'admin', true, now(), now()) ON CONFLICT (keycloak_user_id, tenant_id, company_id) DO UPDATE SET is_active = true, updated_at = CURRENT_TIMESTAMP;" >/dev/null +exec_pg_sql "INSERT INTO core.user_tenants (keycloak_user_id, tenant_id, company_id, role, is_active, created_at, updated_at) VALUES ('${USER_ID}', ${TENANT_ID}, ${COMPANY_ID}, 'admin', true, now(), now()) ON CONFLICT (keycloak_user_id, tenant_id, company_id) DO UPDATE SET is_active = true, updated_at = CURRENT_TIMESTAMP;" >/dev/null +exec_pg_sql_client "INSERT INTO core.user_tenants (keycloak_user_id, tenant_id, company_id, role, is_active, created_at, updated_at) VALUES ('${USER_ID}', ${TENANT_ID}, ${COMPANY_ID}, 'admin', true, now(), now()) ON CONFLICT (keycloak_user_id, tenant_id, company_id) DO UPDATE SET is_active = true, updated_at = CURRENT_TIMESTAMP;" >/dev/null echo -e "${GREEN}✓ Relación usuario-tenant creada en la base de datos (Hub y Cliente)${NC}" + + ############################################################################### # 9. Crear licencia Enterprise para el tenant ###############################################################################