workspace-v1/projects/gamilit/docs/90-transversal/restructuracion-v2/US-AE-005-parametrizacion-gamificacion.md
Adrian Flores Cortes 967ab360bb Initial commit: Workspace v1 with 3-layer architecture
Structure:
- control-plane/: Registries, SIMCO directives, CI/CD templates
- projects/: Gamilit, ERP-Suite, Trading-Platform, Betting-Analytics
- shared/: Libs catalog, knowledge-base

Key features:
- Centralized port, domain, database, and service registries
- 23 SIMCO directives + 6 fundamental principles
- NEXUS agent profiles with delegation rules
- Validation scripts for workspace integrity
- Dockerfiles for all services
- Path aliases for quick reference

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 00:35:19 -06:00

24 KiB
Raw Blame History

US-AE-005: Parametrización Dinámica de Gamificación

📋 Metadata

Campo Valor
ID US-AE-005
Épica EXT-002 (Admin Extendido CORE)
Módulo Admin - Configuración del Sistema
Prioridad Alta (v2 CORE)
Story Points 12
Presupuesto $4,800 MXN
Sprint TBD
Estado 🆕 Nueva
Versión 1.0
Fecha creación 2025-11-08

🎯 Historia de Usuario

Como super administrador de la plataforma GAMILIT Quiero configurar dinámicamente los parámetros del sistema de gamificación Para ajustar la dificultad, recompensas y progresión sin necesidad de modificar el código

📝 Descripción

Actualmente (v1), todos los parámetros de gamificación están hardcoded en el código:

  • XP otorgado por cada ejercicio
  • Multiplicadores de XP por nivel/rango
  • Thresholds de XP para subir de rango
  • Criterios de desbloqueo de insignias
  • Cantidad de ML Coins otorgadas por logros

Esta US permite al super admin configurar estos valores dinámicamente a través de una interfaz web, permitiendo:

  • Ajustar la dificultad de progresión en tiempo real
  • Experimentar con diferentes economías de gamificación (A/B testing)
  • Personalizar la experiencia por institución educativa (multi-tenancy)

Contexto de Alcance v2

Esta funcionalidad es parte del Alcance v2 (Ampliación) - Portal de Admin CORE:

  • En v1: Valores hardcoded en código
  • En v2: Valores configurables dinámicamente por super admin

🔗 Referencias

Épica y Documentación

  • Épica: EXT-002 (Admin Extendido)
  • Alcance: 2.2.1.5 Administración y Escalabilidad (v2 - Parametrización)
  • RF relacionado: RF-SYS-001 (Sistema de configuración global)
  • ET relacionado: ET-SYS-001 (Implementación de system_settings)
  • Infraestructura base: EAI-006 (Configuración del Sistema)

Objetos de BD

Tabla principal: system_configuration.system_settings

CREATE TABLE system_configuration.system_settings (
  setting_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  category VARCHAR(50) NOT NULL, -- 'gamification', 'system', 'notifications'
  key VARCHAR(100) NOT NULL,
  value JSONB NOT NULL,
  value_type VARCHAR(20) NOT NULL, -- 'number', 'string', 'boolean', 'json'
  description TEXT,
  is_public BOOLEAN DEFAULT FALSE,
  min_value NUMERIC,
  max_value NUMERIC,
  default_value JSONB,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  updated_by UUID REFERENCES auth.users(id),
  UNIQUE(category, key)
);

Settings de gamificación:

// Categoría: gamification
{
  "xp_per_exercise_correct": 10,        // XP base por ejercicio correcto
  "xp_per_exercise_excellent": 20,      // XP por ejercicio con >90% accuracy
  "xp_multiplier_streak_3": 1.2,        // Multiplicador por racha de 3 días
  "xp_multiplier_streak_7": 1.5,        // Multiplicador por racha de 7 días
  "xp_multiplier_level_2": 1.1,         // Multiplicador por estar en nivel 2+
  "xp_multiplier_level_5": 1.3,         // Multiplicador por estar en nivel 5+
  "coins_per_module_complete": 50,      // ML Coins por completar módulo
  "coins_per_rank_promotion": 100,      // ML Coins por subir de rango
  "coins_per_achievement": 25,          // ML Coins por desbloquear insignia
  "rank_thresholds": {                  // XP requerido para cada rango
    "ix_chel_aprendiz": 0,
    "ah_puch_explorador": 500,
    "kukulkan_sabio": 1500,
    "itzamna_maestro": 3500
  },
  "achievement_criteria_first_module": 1,    // Completar 1 módulo
  "achievement_criteria_streak_master": 7,   // 7 días consecutivos
  "achievement_criteria_xp_milestone": 1000, // Alcanzar 1000 XP
  "help_cost_hint": 10,                 // Costo en ML Coins de pista
  "help_cost_skip": 25,                 // Costo de saltar pregunta
  "help_cost_extra_time": 15            // Costo de tiempo extra
}

Backend

  • Endpoint GET: GET /api/v1/admin/gamification/settings
  • Endpoint UPDATE: PUT /api/v1/admin/gamification/settings
  • Service: GamificationConfigService
  • Cache: Redis con TTL de 5 minutos

Frontend

  • Ruta: /admin/settings/gamification
  • Componente: GamificationSettingsPanel.tsx
  • Tabs: XP & Niveles | ML Coins | Rangos | Insignias | Ayudas

Criterios de Aceptación

CA-1: Ver Configuración Actual

Dado que soy un super admin autenticado Cuando accedo a "Configuración" → "Gamificación" Entonces:

  • Veo una interfaz organizada en 5 tabs:
    1. XP & Niveles (xp_per_exercise, multiplicadores)
    2. ML Coins (recompensas por logros)
    3. Rangos Maya (thresholds de XP)
    4. Insignias (criterios de desbloqueo)
    5. Ayudas (costos en ML Coins)
  • Cada setting muestra:
    • Nombre descriptivo
    • Valor actual
    • Valor por defecto (badge "Default")
    • Descripción/tooltip explicativo
    • Input apropiado (number, slider, JSON editor)
  • Los cambios NO se aplican inmediatamente (modo borrador)
  • Hay un botón "Guardar cambios" (disabled si no hay cambios)
  • Hay un botón "Restaurar defaults"

CA-2: Modificar XP y Niveles

Dado que estoy en el tab "XP & Niveles" Cuando modifico los valores de XP Entonces:

  • Puedo cambiar xp_per_exercise_correct (min: 1, max: 100)
  • Puedo cambiar xp_per_exercise_excellent (min: xp_correct, max: 200)
  • Puedo cambiar multiplicadores de racha (min: 1.0, max: 3.0)
  • Puedo cambiar multiplicadores de nivel (min: 1.0, max: 5.0)
  • Veo validaciones en tiempo real:
    • xp_excellent >= xp_correct
    • Multiplicadores entre 1.0 y 3.0
    • No valores negativos
  • Veo un preview del impacto:
    • "Un alumno nivel 2 con racha de 3 días recibirá: X XP"
  • Los cambios se marcan en amarillo (pending)
  • El botón "Guardar cambios" se habilita

CA-3: Modificar Thresholds de Rangos

Dado que estoy en el tab "Rangos Maya" Cuando modifico los thresholds de XP Entonces:

  • Veo una tabla con los 4 rangos:
    Rango Nombre XP Requerido
    1 Ix Chel (Aprendiz) 0 (fijo)
    2 Ah Puch (Explorador) 500 ✏️
    3 Kukulkan (Sabio) 1500 ✏️
    4 Itzamna (Maestro) 3500 ✏️
  • Puedo editar los valores inline (double-click)
  • Validaciones:
    • Rango 2 XP > 0
    • Rango 3 XP > Rango 2 XP
    • Rango 4 XP > Rango 3 XP
    • Máximo 10,000 XP por rango
  • Veo un preview:
    • "Con estos valores, un alumno promedio alcanzará Maestro en ~X semanas"
  • Hay un botón "Previsualizar impacto" que muestra:
    • Distribución actual de alumnos por rango
    • Distribución proyectada con nuevos valores
    • Alumnos que subirían/bajarían de rango

CA-4: Modificar Criterios de Insignias

Dado que estoy en el tab "Insignias" Cuando modifico los criterios de desbloqueo Entonces:

  • Veo una lista de todas las insignias configurables:
    • "Primer Módulo" → Completar X módulos (default: 1)
    • "Maestro de Racha" → X días consecutivos (default: 7)
    • "Coleccionista de XP" → Alcanzar X XP (default: 1000)
    • (más insignias...)
  • Puedo editar cada criterio numérico
  • Validaciones:
    • Valores >= 1
    • No exceder límites razonables (ej: 365 días racha)
  • Veo estadísticas:
    • "X% de alumnos actuales cumplen este criterio"
    • "Si lo subes a Y, solo Z% lo cumplirán"

CA-5: Guardar Cambios

Dado que he modificado uno o más settings Cuando hago clic en "Guardar cambios" Y confirmo en el modal de confirmación Entonces:

  • Veo un modal de confirmación:
    • "¿Estás seguro de aplicar estos cambios?"
    • Lista de cambios a aplicar (antes → después)
    • "Estos cambios afectarán a todos los usuarios inmediatamente"
    • Checkboxes de confirmación
  • Al confirmar:
    • Se guardan en system_settings
    • Se invalida cache de Redis
    • Se crea evento en auditoría
    • Veo toast "Configuración actualizada"
    • Los settings cambian de "pending" a "saved"
  • Los cambios se aplican inmediatamente en la plataforma
  • El backend recarga settings desde DB (cache invalidado)

CA-6: Restaurar Defaults

Dado que estoy en cualquier tab Cuando hago clic en "Restaurar defaults" Y confirmo la acción Entonces:

  • Todos los settings del tab actual vuelven a valores por defecto
  • Se marca como pending (no se guarda automáticamente)
  • Debo hacer clic en "Guardar cambios" para aplicar

CA-7: Validaciones y Restricciones

Dado que soy super admin Entonces:

  • Solo yo puedo acceder a esta configuración (role check)
  • No puedo establecer valores que rompan el sistema:
    • XP negativo
    • Thresholds decrecientes
    • Costos de ayudas > balance máximo
  • Veo warnings si:
    • Los cambios son muy drásticos (>50% diferencia)
    • Pueden afectar negativamente la experiencia
  • Hay un changelog de cambios (auditoría)

CA-8: Preview e Impacto

Dado que he modificado settings Cuando hago clic en "Previsualizar impacto" Entonces:

  • Veo un modal con simulaciones:
    • Distribución de alumnos por rango (antes vs después)
    • XP promedio necesario para completar un módulo
    • Tiempo estimado para alcanzar cada rango
    • Economía de ML Coins (ganancia vs gasto promedio)
  • Puedo simular diferentes escenarios
  • Puedo exportar el análisis a PDF/CSV

🏗️ Diseño Técnico

Backend (NestJS)

Service: GamificationConfigService

// apps/backend/src/modules/admin/services/gamification-config.service.ts

import { Injectable } from '@nestjs/common';
import { SupabaseService } from '@/shared/services/supabase.service';
import { CacheService } from '@/shared/services/cache.service';
import { AuditService } from '@/modules/audit/audit.service';

const CACHE_KEY = 'gamification:config';
const CACHE_TTL = 300; // 5 minutos

@Injectable()
export class GamificationConfigService {
  constructor(
    private supabase: SupabaseService,
    private cache: CacheService,
    private audit: AuditService,
  ) {}

  async getSettings(): Promise<GamificationSettings> {
    // 1. Intentar obtener de caché
    const cached = await this.cache.get<GamificationSettings>(CACHE_KEY);
    if (cached) return cached;

    // 2. Cargar desde DB
    const { data, error } = await this.supabase
      .from('system_settings')
      .select('*')
      .eq('category', 'gamification');

    if (error) throw new InternalServerErrorException(error.message);

    // 3. Convertir a objeto estructurado
    const settings = this.transformToSettings(data);

    // 4. Guardar en caché
    await this.cache.set(CACHE_KEY, settings, CACHE_TTL);

    return settings;
  }

  async updateSettings(
    adminId: string,
    updates: Partial<GamificationSettings>,
  ): Promise<{ success: boolean }> {
    // 1. Validar cambios
    this.validateSettings(updates);

    // 2. Calcular preview de impacto
    const impact = await this.calculateImpact(updates);

    // 3. Guardar en DB
    const settingsArray = this.transformToArray(updates);

    for (const setting of settingsArray) {
      await this.supabase
        .from('system_settings')
        .upsert({
          category: 'gamification',
          key: setting.key,
          value: setting.value,
          value_type: setting.type,
          updated_by: adminId,
          updated_at: new Date().toISOString(),
        });
    }

    // 4. Invalidar caché
    await this.cache.del(CACHE_KEY);

    // 5. Auditoría
    await this.audit.log({
      event_type: 'gamification_settings_updated',
      user_id: adminId,
      metadata: { changes: updates, impact },
    });

    // 6. Notificar a otros servidores (si hay múltiples instancias)
    await this.pubsub.publish('config:invalidate', { key: CACHE_KEY });

    return { success: true };
  }

  private validateSettings(settings: Partial<GamificationSettings>): void {
    const errors: string[] = [];

    // Validar XP
    if (settings.xp_per_exercise_correct !== undefined) {
      if (settings.xp_per_exercise_correct < 1 || settings.xp_per_exercise_correct > 100) {
        errors.push('xp_per_exercise_correct debe estar entre 1 y 100');
      }
    }

    if (settings.xp_per_exercise_excellent !== undefined && settings.xp_per_exercise_correct !== undefined) {
      if (settings.xp_per_exercise_excellent < settings.xp_per_exercise_correct) {
        errors.push('xp_excellent debe ser mayor o igual a xp_correct');
      }
    }

    // Validar thresholds de rangos
    if (settings.rank_thresholds) {
      const thresholds = Object.values(settings.rank_thresholds);
      for (let i = 1; i < thresholds.length; i++) {
        if (thresholds[i] <= thresholds[i - 1]) {
          errors.push('Los thresholds de rangos deben ser crecientes');
        }
      }
    }

    // Validar multiplicadores
    if (settings.xp_multiplier_streak_3 !== undefined) {
      if (settings.xp_multiplier_streak_3 < 1.0 || settings.xp_multiplier_streak_3 > 3.0) {
        errors.push('Multiplicadores deben estar entre 1.0 y 3.0');
      }
    }

    if (errors.length > 0) {
      throw new BadRequestException(errors.join(', '));
    }
  }

  async calculateImpact(updates: Partial<GamificationSettings>) {
    // Consultar datos actuales de alumnos
    const { data: students } = await this.supabase
      .from('profiles')
      .select('user_id, current_xp, current_rank')
      .eq('role', 'student');

    const currentSettings = await this.getSettings();
    const newSettings = { ...currentSettings, ...updates };

    // Simular redistribución de rangos
    const currentDistribution = this.calculateRankDistribution(students, currentSettings);
    const newDistribution = this.calculateRankDistribution(students, newSettings);

    return {
      current: currentDistribution,
      projected: newDistribution,
      changes: {
        promotions: newDistribution.promotions,
        demotions: newDistribution.demotions,
      },
    };
  }
}

Controller: AdminConfigController

// apps/backend/src/modules/admin/controllers/admin-config.controller.ts

@Controller('admin/gamification')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('super_admin')
export class AdminConfigController {
  constructor(private configService: GamificationConfigService) {}

  @Get('settings')
  async getSettings() {
    return this.configService.getSettings();
  }

  @Put('settings')
  async updateSettings(@Req() req, @Body() dto: UpdateGamificationSettingsDto) {
    return this.configService.updateSettings(req.user.id, dto);
  }

  @Post('settings/preview')
  async previewImpact(@Body() dto: UpdateGamificationSettingsDto) {
    return this.configService.calculateImpact(dto);
  }

  @Post('settings/restore-defaults')
  async restoreDefaults(@Req() req) {
    const defaults = this.configService.getDefaultSettings();
    return this.configService.updateSettings(req.user.id, defaults);
  }
}

Frontend (React + TypeScript)

Componente: GamificationSettingsPanel

// apps/frontend/src/features/admin/components/GamificationSettingsPanel.tsx

import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { useGamificationSettings } from '../hooks/useGamificationSettings';
import { XpSettings } from './XpSettings';
import { CoinsSettings } from './CoinsSettings';
import { RankSettings } from './RankSettings';
import { AchievementSettings } from './AchievementSettings';
import { HelpSettings } from './HelpSettings';
import { PreviewModal } from './PreviewModal';
import { useState } from 'react';

export function GamificationSettingsPanel() {
  const {
    settings,
    updateSettings,
    restoreDefaults,
    previewImpact,
    hasChanges,
    pendingChanges,
    isLoading,
  } = useGamificationSettings();

  const [showPreview, setShowPreview] = useState(false);

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-2xl font-bold">Configuración de Gamificación</h1>
          <p className="text-gray-600">
            Ajusta los parámetros del sistema de puntos, niveles y recompensas
          </p>
        </div>

        <div className="flex gap-3">
          {hasChanges && (
            <Button
              variant="outline"
              onClick={() => setShowPreview(true)}
            >
              Previsualizar impacto
            </Button>
          )}
          <Button
            variant="outline"
            onClick={() => restoreDefaults()}
            disabled={!hasChanges}
          >
            Restaurar defaults
          </Button>
          <Button
            onClick={() => updateSettings(pendingChanges)}
            disabled={!hasChanges}
            isLoading={isLoading}
          >
            Guardar cambios
          </Button>
        </div>
      </div>

      <Tabs defaultValue="xp">
        <TabsList>
          <TabsTrigger value="xp">XP & Niveles</TabsTrigger>
          <TabsTrigger value="coins">ML Coins</TabsTrigger>
          <TabsTrigger value="ranks">Rangos Maya</TabsTrigger>
          <TabsTrigger value="achievements">Insignias</TabsTrigger>
          <TabsTrigger value="help">Ayudas</TabsTrigger>
        </TabsList>

        <TabsContent value="xp">
          <XpSettings settings={settings} />
        </TabsContent>

        <TabsContent value="coins">
          <CoinsSettings settings={settings} />
        </TabsContent>

        <TabsContent value="ranks">
          <RankSettings settings={settings} />
        </TabsContent>

        <TabsContent value="achievements">
          <AchievementSettings settings={settings} />
        </TabsContent>

        <TabsContent value="help">
          <HelpSettings settings={settings} />
        </TabsContent>
      </Tabs>

      <PreviewModal
        open={showPreview}
        onClose={() => setShowPreview(false)}
        changes={pendingChanges}
      />
    </div>
  );
}

Hook: useGamificationSettings

// apps/frontend/src/features/admin/hooks/useGamificationSettings.ts

import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminApi } from '@/api/admin';
import { toast } from 'sonner';

export function useGamificationSettings() {
  const queryClient = useQueryClient();
  const [pendingChanges, setPendingChanges] = useState({});

  const { data: settings, isLoading } = useQuery({
    queryKey: ['gamification-settings'],
    queryFn: () => adminApi.getGamificationSettings(),
  });

  const updateMutation = useMutation({
    mutationFn: (changes) => adminApi.updateGamificationSettings(changes),
    onSuccess: () => {
      queryClient.invalidateQueries(['gamification-settings']);
      setPendingChanges({});
      toast.success('Configuración actualizada exitosamente');
    },
    onError: (error: any) => {
      toast.error(error.response?.data?.message || 'Error al guardar configuración');
    },
  });

  const previewMutation = useMutation({
    mutationFn: (changes) => adminApi.previewGamificationImpact(changes),
  });

  const restoreDefaultsMutation = useMutation({
    mutationFn: () => adminApi.restoreGamificationDefaults(),
    onSuccess: () => {
      queryClient.invalidateQueries(['gamification-settings']);
      setPendingChanges({});
      toast.success('Valores por defecto restaurados');
    },
  });

  const updateSettings = (changes) => {
    updateMutation.mutate(changes);
  };

  const restoreDefaults = () => {
    if (confirm('¿Restaurar todos los valores por defecto?')) {
      restoreDefaultsMutation.mutate();
    }
  };

  const previewImpact = async (changes) => {
    return previewMutation.mutateAsync(changes);
  };

  const changeSetting = (key, value) => {
    setPendingChanges((prev) => ({ ...prev, [key]: value }));
  };

  const hasChanges = Object.keys(pendingChanges).length > 0;

  return {
    settings: { ...settings, ...pendingChanges },
    updateSettings,
    restoreDefaults,
    previewImpact,
    changeSetting,
    hasChanges,
    pendingChanges,
    isLoading: isLoading || updateMutation.isPending,
  };
}

🧪 Testing

Test Cases

TC-1: Validación de thresholds crecientes

it('should reject decreasing rank thresholds', async () => {
  await expect(
    configService.updateSettings(adminId, {
      rank_thresholds: {
        ix_chel_aprendiz: 0,
        ah_puch_explorador: 500,
        kukulkan_sabio: 400, // ERROR: menor que anterior
        itzamna_maestro: 3500,
      },
    })
  ).rejects.toThrow('Los thresholds de rangos deben ser crecientes');
});

TC-2: Cache invalidation

it('should invalidate cache after update', async () => {
  await configService.updateSettings(adminId, { xp_per_exercise_correct: 15 });

  const cached = await cache.get(CACHE_KEY);
  expect(cached).toBeNull();

  const fresh = await configService.getSettings();
  expect(fresh.xp_per_exercise_correct).toBe(15);
});

TC-3: Preview accuracy

it('should calculate impact correctly', async () => {
  const impact = await configService.calculateImpact({
    rank_thresholds: {
      ix_chel_aprendiz: 0,
      ah_puch_explorador: 1000, // Aumentado de 500
      kukulkan_sabio: 1500,
      itzamna_maestro: 3500,
    },
  });

  expect(impact.changes.demotions).toBeGreaterThan(0);
});

Coverage Objetivo

  • Backend: 95%+ (lógica de validación crítica)
  • Frontend: 80%+ (UI components)

📊 Métricas

Métrica Valor Objetivo
Load time settings < 300ms
Save changes < 1s
Preview calculation < 3s (con 10k alumnos)
Cache hit rate > 95%

🔐 Seguridad

Validaciones

  • Solo super_admin puede acceder (RolesGuard)
  • Validación de rangos (min/max)
  • Validación de coherencia (thresholds crecientes)
  • Audit trail completo
  • Preview antes de aplicar cambios drásticos

Rate Limiting

  • Máximo 10 cambios por hora por admin (prevenir experimentos excesivos)

🚀 Deployment

Migraciones

  • No requiere nuevas tablas (usa system_settings de EAI-006)
  • Requiere seed de valores por defecto

Seed de Defaults

INSERT INTO system_configuration.system_settings (category, key, value, value_type, description) VALUES
('gamification', 'xp_per_exercise_correct', '10', 'number', 'XP otorgado por ejercicio correcto'),
('gamification', 'xp_per_exercise_excellent', '20', 'number', 'XP otorgado por ejercicio excelente (>90%)'),
('gamification', 'xp_multiplier_streak_3', '1.2', 'number', 'Multiplicador por racha de 3 días'),
-- ... más settings

📚 Documentación para Usuario

Manual de Admin

Título: Configuración del Sistema de Gamificación

¿Qué puedes configurar?

  • Puntos de experiencia (XP) por actividad
  • Monedas lectoras (ML Coins) otorgadas
  • Umbrales de XP para cada rango Maya
  • Criterios de desbloqueo de insignias
  • Costos de ayudas

Recomendaciones:

  • Haz cambios pequeños e iterativos
  • Usa "Previsualizar impacto" antes de guardar
  • Monitorea métricas de engagement después de cambios

🔗 Dependencias

Épicas/US Relacionadas

  • EAI-006: Infraestructura de system_settings
  • EAI-003: Sistema de gamificación base
  • RF-SYS-001: Sistema de configuración global

Bloqueantes

  • Tabla system_settings debe existir
  • Cache service operativo
  • Audit service operativo

📅 Estimación

Actividad Esfuerzo
Backend (service, validations, cache) 3.5 SP
Backend (preview/impact calculations) 2 SP
Frontend (5 tabs + modales) 4.5 SP
Testing (unit + integration) 1.5 SP
Seed data + documentation 0.5 SP
TOTAL 12 SP

Presupuesto: $4,800 MXN (12 SP × $400/SP)


Generado: 2025-11-08 Autor: Product Owner + Tech Lead Revisión: Pendiente Aprobación: Pendiente