# Alcance opcional por categoría en reglas de precio y planes de cuotas — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Permitir que una `regla_precio` o un `plan_cuota` se acoten opcionalmente a una categoría de producto (específica o padre), además del rango de costo/precio que ya usan, sin romper el comportamiento actual de ninguno de los dos motores.

**Architecture:** `reglas_precio` ya tiene el ámbito modelado (`alcance`/`categoria_id`) desde Fase 1 — se destraba el matching que hoy fuerza `alcance:'global'`. `planes_cuotas` no tiene ningún ámbito hoy — se agregan 3 columnas nullable y un filtro de inclusión en `generarGrillaCuotas` (un producto puede seguir recibiendo varios planes simultáneos; no hay "ganador único" como en reglas de precio).

**Tech Stack:** NestJS + Prisma + PostgreSQL (`novasispy-backend-api`), Jest para tests unitarios, React + MUI + TanStack Query (`novasispy-erp`).

**Spec:** `docs/plan-motor-precios-rentabilidad.md` §11 (FASE 6), mismo repo.

## Global Constraints

- No commitear nada (regla del usuario: solo se commitea con pedido explícito) — **ninguna tarea de este plan incluye un paso de `git commit`**. Dejar los cambios en el working tree.
- Categoría es siempre **opcional** en ambos motores: si es `null`, el comportamiento debe ser idéntico al actual (retrocompatible, sin migración de datos).
- El matching de categoría mira **un solo nivel hacia arriba** (la categoría del producto y su padre directo), no cadenas de 3+ niveles.
- `marca` y `producto` como alcance quedan fuera de este trabajo (ya modelados para `reglas_precio`, sin tocar).
- `monto_minimo`/`monto_maximo` de `planes_cuotas` NO cambian de significado — siguen validando el monto de la venta al elegir un plan a mano. El rango de precio nuevo (`precio_producto_desde`/`precio_producto_hasta`) es un concepto distinto: precio de catálogo del producto, usado solo para decidir si el plan entra en la grilla automática.
- Migraciones: seguir el flujo obligatorio de `CLAUDE.md` del backend — directorio `prisma/migrations/YYYYMMDD_nombre/migration.sql` (nunca un `.sql` suelto), SQL idempotente (`IF NOT EXISTS`).

---

### Task 1: Migración Prisma — columnas de alcance en `planes_cuotas`

**Files:**
- Modify: `prisma/schema.prisma:5360-5393` (modelo `planes_cuotas`)
- Modify: `prisma/schema.prisma:2926-2945` (modelo `categorias`, agregar relación inversa)
- Create: `prisma/migrations/20260827_planes_cuotas_alcance_categoria/migration.sql`

**Interfaces:**
- Produces: columnas `planes_cuotas.categoria_id` (`String?`), `planes_cuotas.precio_producto_desde` (`Decimal?`), `planes_cuotas.precio_producto_hasta` (`Decimal?`), relación `planes_cuotas.categoria` — usadas por las Tasks 3 y 5.

- [ ] **Step 1: Editar el modelo `planes_cuotas` en `prisma/schema.prisma`**

Reemplazar el bloque completo (líneas 5360-5393) por:

```prisma
model planes_cuotas {
  id                      String    @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
  empresa_id              String    @db.Uuid
  codigo                  String    @db.VarChar(20)
  nombre                  String    @db.VarChar(100)
  descripcion             String?
  tipo_calculo            String    @default("automatico") @db.VarChar(20) // manual, automatico, mixto
  cantidad_cuotas         Int?      @db.SmallInt
  intervalo_dias          Int       @default(30) @db.SmallInt
  tasa_interes            Decimal   @default(0) @db.Decimal(8, 4)
  tipo_interes            String    @default("frances") @db.VarChar(20) // simple, compuesto, frances
  cuota_inicial_requerida Boolean   @default(false)
  cuota_inicial_minima    Decimal?  @db.Decimal(5, 2)
  monto_minimo            Decimal?  @db.Decimal(18, 4)
  monto_maximo            Decimal?  @db.Decimal(18, 4)
  activo                  Boolean   @default(true)
  es_default              Boolean   @default(false)
  usuario_creacion        String?   @db.VarChar(100)
  usuario_actualizacion   String?   @db.VarChar(100)
  fecha_creacion          DateTime  @default(now()) @db.Timestamp(6)
  fecha_actualizacion     DateTime? @db.Timestamp(6)

  // Alcance opcional (Fase 6, docs/plan-motor-precios-rentabilidad.md §11) — si están
  // null, el plan aplica a cualquier producto/precio, igual que antes de esta fase.
  categoria_id            String?   @db.Uuid
  precio_producto_desde   Decimal?  @db.Decimal(19, 4)
  precio_producto_hasta   Decimal?  @db.Decimal(19, 4) // null = sin tope superior

  // Relaciones
  empresas               empresas                 @relation("planes_cuotas_empresa", fields: [empresa_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
  categoria              categorias?              @relation(fields: [categoria_id], references: [id], onDelete: SetNull, onUpdate: NoAction)
  cuotas_calculadas      cuotas_calculadas[]
  planes_cuotas_detalles planes_cuotas_detalles[]
  cuotas_individuales    cuotas_individuales[]    @relation("cuotas_individuales_plan")

  @@unique([empresa_id, codigo], name: "unique_plan_cuotas_codigo")
  @@index([empresa_id], map: "idx_planes_cuotas_empresa")
  @@index([activo], map: "idx_planes_cuotas_activo")
  @@index([tipo_calculo], map: "idx_planes_cuotas_tipo")
  @@index([es_default], map: "idx_planes_cuotas_default")
  @@index([empresa_id, categoria_id], map: "idx_planes_cuotas_categoria")
}
```

- [ ] **Step 2: Agregar la relación inversa en el modelo `categorias`**

En `prisma/schema.prisma:2944`, después de la línea `reglas_precio              reglas_precio[]`, agregar:

```prisma
  planes_cuotas              planes_cuotas[]
```

- [ ] **Step 3: Crear el directorio y el SQL de la migración**

```bash
mkdir -p prisma/migrations/20260827_planes_cuotas_alcance_categoria
```

Crear `prisma/migrations/20260827_planes_cuotas_alcance_categoria/migration.sql`:

```sql
-- Alcance opcional por categoría en planes de cuotas (Fase 6). Todas las columnas
-- nullable: un plan sin estos valores sigue aplicando a cualquier producto, igual
-- que antes de esta migración. Ver docs/plan-motor-precios-rentabilidad.md §11.
ALTER TABLE planes_cuotas
  ADD COLUMN IF NOT EXISTS categoria_id UUID,
  ADD COLUMN IF NOT EXISTS precio_producto_desde DECIMAL(19, 4),
  ADD COLUMN IF NOT EXISTS precio_producto_hasta DECIMAL(19, 4);

DO $$ BEGIN
  ALTER TABLE planes_cuotas
    ADD CONSTRAINT planes_cuotas_categoria_id_fkey
    FOREIGN KEY (categoria_id) REFERENCES categorias(id) ON DELETE SET NULL;
EXCEPTION
  WHEN duplicate_object THEN NULL;
END $$;

CREATE INDEX IF NOT EXISTS idx_planes_cuotas_categoria ON planes_cuotas (empresa_id, categoria_id);
```

- [ ] **Step 4: Aplicar la migración localmente**

Run: `cd /var/www/html/proyectos/novasispy-backend-api && npx prisma db execute --file prisma/migrations/20260827_planes_cuotas_alcance_categoria/migration.sql --schema prisma/schema.prisma`
Expected: sin errores (o `NOTICE` de `duplicate_object` si se corre dos veces — es idempotente).

- [ ] **Step 5: Marcar la migración como aplicada y regenerar el cliente**

Run: `npx prisma migrate resolve --applied "20260827_planes_cuotas_alcance_categoria"`
Run: `npx prisma generate`
Expected: ambos comandos terminan sin error; `npx prisma generate` regenera el cliente con los tipos nuevos (`categoria_id`, `precio_producto_desde`, `precio_producto_hasta` en `Prisma.planes_cuotasGetPayload`/`planes_cuotasCreateInput`).

---

### Task 2: DTOs de `planes_cuotas` — campos nuevos opcionales

**Files:**
- Modify: `src/planes-cuotas/dto/crear-plan-cuotas.dto.ts:86-96`
- Modify: `src/planes-cuotas/dto/actualizar-plan-cuotas.dto.ts:65-75`

**Interfaces:**
- Consumes: nada nuevo (Task 1 ya generó los tipos Prisma).
- Produces: `CrearPlanCuotasDto.categoriaId?: string`, `CrearPlanCuotasDto.precioProductoDesde?: number`, `CrearPlanCuotasDto.precioProductoHasta?: number` (y lo mismo en `ActualizarPlanCuotasDto`) — consumidos por Task 3.

- [ ] **Step 1: Agregar los campos a `CrearPlanCuotasDto`**

En `src/planes-cuotas/dto/crear-plan-cuotas.dto.ts`, `IsUUID` ya está importado (se usa en `empresaId`, línea 8) — no hace falta tocar el import. Después del campo `montoMaximo` (línea 96), agregar:

```typescript
  @ApiPropertyOptional({
    description:
      'Categoría (específica o padre) a la que se acota este plan en la grilla automática de cuotas. Vacío = aplica a cualquier categoría, igual que antes de esta opción.',
  })
  @IsOptional()
  @IsUUID()
  categoriaId?: string;

  @ApiPropertyOptional({
    description:
      'Precio de catálogo (contado) del producto desde el cual este plan entra en la grilla automática. No confundir con montoMinimo (que valida el monto de la venta).',
  })
  @IsOptional()
  @IsNumber()
  @Min(0)
  precioProductoDesde?: number;

  @ApiPropertyOptional({
    description: 'Precio de catálogo (contado) del producto hasta el cual este plan entra en la grilla automática. Vacío = sin tope superior.',
  })
  @IsOptional()
  @IsNumber()
  @Min(0)
  precioProductoHasta?: number;
```

- [ ] **Step 2: Agregar los mismos campos a `ActualizarPlanCuotasDto`**

En `src/planes-cuotas/dto/actualizar-plan-cuotas.dto.ts`, agregar `IsUUID` al import de `class-validator` (línea 1) y, después de `montoMaximo` (línea 75), agregar el mismo bloque de arriba (idéntico, ya que en este DTO todos los campos son opcionales).

- [ ] **Step 3: Verificar que compila**

Run: `cd /var/www/html/proyectos/novasispy-backend-api && npx tsc --noEmit -p tsconfig.json`
Expected: sin errores nuevos relacionados a `crear-plan-cuotas.dto.ts` / `actualizar-plan-cuotas.dto.ts`.

---

### Task 3: `PlanesCuotasService` — mapear los campos nuevos + test

**Files:**
- Modify: `src/planes-cuotas/planes-cuotas.service.ts:45-71` (`crearPlan`)
- Modify: `src/planes-cuotas/planes-cuotas.service.ts:173-207` (`actualizarPlan`)
- Test: `src/planes-cuotas/planes-cuotas.service.spec.ts` (nuevo)

**Interfaces:**
- Consumes: `CrearPlanCuotasDto`/`ActualizarPlanCuotasDto` con `categoriaId?`, `precioProductoDesde?`, `precioProductoHasta?` (Task 2).
- Produces: nada nuevo hacia otras tasks — este cambio es hoja (solo CRUD).

- [ ] **Step 1: Escribir el test que falla primero**

Crear `src/planes-cuotas/planes-cuotas.service.spec.ts`:

```typescript
import { PlanesCuotasService } from './planes-cuotas.service';

describe('PlanesCuotasService - alcance por categoría (Fase 6)', () => {
  const createService = (prismaOverrides: Record<string, any> = {}) => {
    const prisma = {
      planes_cuotas: {
        findFirst: jest.fn().mockResolvedValue(null),
        updateMany: jest.fn().mockResolvedValue({ count: 0 }),
        create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'plan-1', ...data })),
        update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'plan-1', ...data })),
      },
      ...prismaOverrides,
    };
    const auditService = { log: jest.fn().mockResolvedValue(undefined) };
    return new PlanesCuotasService(prisma as any, auditService as any);
  };

  it('crearPlan persiste categoriaId y el rango de precio de producto', async () => {
    const service = createService();

    await service.crearPlan(
      {
        empresaId: 'emp-1',
        codigo: 'PLAN-CAT',
        nombre: 'Plan Celulares 12 cuotas',
        tipoCalculo: 'automatico',
        cantidadCuotas: 12,
        intervaloDias: 30,
        tasaInteres: 0,
        tipoInteres: 'frances',
        cuotaInicialRequerida: false,
        activo: true,
        esDefault: false,
        categoriaId: 'cat-celulares',
        precioProductoDesde: 500000,
        precioProductoHasta: 3000000,
      } as any,
      'user-1',
    );

    const dataEnviada = (service as any).prisma.planes_cuotas.create.mock.calls[0][0].data;
    expect(dataEnviada.categoria_id).toBe('cat-celulares');
    expect(dataEnviada.precio_producto_desde).toBe(500000);
    expect(dataEnviada.precio_producto_hasta).toBe(3000000);
  });

  it('crearPlan sin categoría ni rango deja los campos undefined (retrocompatible)', async () => {
    const service = createService();

    await service.crearPlan(
      {
        empresaId: 'emp-1',
        codigo: 'PLAN-GLOBAL',
        nombre: 'Plan 3 cuotas',
        tipoCalculo: 'automatico',
        cantidadCuotas: 3,
        intervaloDias: 30,
        tasaInteres: 0,
        tipoInteres: 'frances',
        cuotaInicialRequerida: false,
        activo: true,
        esDefault: false,
      } as any,
      'user-1',
    );

    const dataEnviada = (service as any).prisma.planes_cuotas.create.mock.calls[0][0].data;
    expect(dataEnviada.categoria_id).toBeUndefined();
    expect(dataEnviada.precio_producto_desde).toBeUndefined();
    expect(dataEnviada.precio_producto_hasta).toBeUndefined();
  });

  it('actualizarPlan solo toca los campos de alcance cuando vienen en el dto', async () => {
    const prisma = {
      planes_cuotas: {
        findFirst: jest.fn().mockResolvedValue({ id: 'plan-1', empresa_id: 'emp-1' }),
        updateMany: jest.fn().mockResolvedValue({ count: 0 }),
        update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'plan-1', ...data })),
      },
    };
    const service = createService(prisma);

    await service.actualizarPlan('plan-1', 'emp-1', { categoriaId: 'cat-x' } as any, 'user-1');

    const dataEnviada = (service as any).prisma.planes_cuotas.update.mock.calls[0][0].data;
    expect(dataEnviada.categoria_id).toBe('cat-x');
    expect(dataEnviada.precio_producto_desde).toBeUndefined();
    expect(dataEnviada.precio_producto_hasta).toBeUndefined();
  });
});
```

- [ ] **Step 2: Correr el test y verificar que falla**

Run: `cd /var/www/html/proyectos/novasispy-backend-api && npx jest src/planes-cuotas/planes-cuotas.service.spec.ts --runInBand`
Expected: FAIL — `dataEnviada.categoria_id` es `undefined` en el primer test (el service todavía no mapea el campo).

- [ ] **Step 3: Mapear los campos en `crearPlan`**

En `src/planes-cuotas/planes-cuotas.service.ts:58-59`, después de `monto_maximo: dto.montoMaximo,`, agregar:

```typescript
        categoria_id: dto.categoriaId,
        precio_producto_desde: dto.precioProductoDesde,
        precio_producto_hasta: dto.precioProductoHasta,
```

- [ ] **Step 4: Mapear los campos en `actualizarPlan`**

En `src/planes-cuotas/planes-cuotas.service.ts:194`, después de `...(dto.montoMaximo !== undefined && { monto_maximo: dto.montoMaximo }),`, agregar:

```typescript
        ...(dto.categoriaId !== undefined && { categoria_id: dto.categoriaId }),
        ...(dto.precioProductoDesde !== undefined && { precio_producto_desde: dto.precioProductoDesde }),
        ...(dto.precioProductoHasta !== undefined && { precio_producto_hasta: dto.precioProductoHasta }),
```

- [ ] **Step 5: Correr el test de nuevo y verificar que pasa**

Run: `npx jest src/planes-cuotas/planes-cuotas.service.spec.ts --runInBand`
Expected: PASS (3 tests).

---

### Task 4: `PreciosEngineService.calcularPrecio` — matching por categoría (específica/padre) + test

**Files:**
- Modify: `src/reglas-precio/precios-engine.service.ts:9-22` (tipo `ReglaPrecioRow`)
- Modify: `src/reglas-precio/precios-engine.service.ts:211-273` (`calcularPrecio`)
- Test: `src/reglas-precio/precios-engine.service.spec.ts` (nuevo)

**Interfaces:**
- Consumes: `productos.categoria_id`, `categorias.padre_id` (ya existen), `reglas_precio.alcance`/`categoria_id` (ya existen desde Fase 1).
- Produces: `calcularPrecio()` sigue devolviendo `CalculoPrecio` (sin cambios de firma) — usado por `recalcularLista` (Task ya existente, sin tocar) y por el endpoint `GET /reglas-precio/producto/:id/sugerido`.

- [ ] **Step 1: Escribir el test que falla primero**

Crear `src/reglas-precio/precios-engine.service.spec.ts`:

```typescript
import { PreciosEngineService } from './precios-engine.service';

const reglaBase = {
  id: 'r-global',
  nombre: 'Global +40%',
  prioridad: 1,
  moneda: 'PYG',
  costo_desde: 0,
  costo_hasta: null,
  metodo: 'margen_sobre_costo',
  valor: 40,
  redondeo_activo: false,
  redondeo_tipo: 'superior',
  redondeo_multiplo: 1,
  lista_precios_id: 'lista-1',
  alcance: 'global',
  categoria_id: null,
};

describe('PreciosEngineService.calcularPrecio - alcance por categoría (Fase 6)', () => {
  const createService = (producto: any, candidatas: any[]) => {
    const findManyMock = jest.fn().mockResolvedValue(candidatas);
    const prisma = {
      productos: { findUnique: jest.fn().mockResolvedValue(producto) },
      lista_precios: { findUnique: jest.fn().mockResolvedValue({ moneda: 'PYG' }) },
      reglas_precio: { findMany: findManyMock },
    };
    const auditService = { log: jest.fn().mockResolvedValue(undefined) };
    const service = new PreciosEngineService(prisma as any, auditService as any);
    return { service, findManyMock };
  };

  it('una regla de categoría específica gana sobre una regla global para el mismo costo', async () => {
    const producto = {
      precio_costo: 500000,
      categoria_id: 'cat-hijo',
      marca_id: null,
      empresa_id: 'emp-1',
      categoria: { padre_id: 'cat-padre' },
    };
    const reglaCategoriaEspecifica = {
      ...reglaBase,
      id: 'r-cat',
      nombre: 'Categoría específica +25%',
      valor: 25,
      alcance: 'categoria',
      categoria_id: 'cat-hijo',
    };
    const { service } = createService(producto, [reglaBase, reglaCategoriaEspecifica]);

    const resultado = await service.calcularPrecio('prod-1', 'lista-1');

    expect(resultado.reglaAplicadaId).toBe('r-cat');
    expect(resultado.precioSugerido).toBe(625000); // 500000 * 1.25
  });

  it('una regla de categoría padre gana sobre una regla global para el mismo costo', async () => {
    const producto = {
      precio_costo: 500000,
      categoria_id: 'cat-hijo',
      marca_id: null,
      empresa_id: 'emp-1',
      categoria: { padre_id: 'cat-padre' },
    };
    const reglaPadre = {
      ...reglaBase,
      id: 'r-padre',
      nombre: 'Categoría padre +30%',
      valor: 30,
      alcance: 'categoria',
      categoria_id: 'cat-padre',
    };
    const { service } = createService(producto, [reglaBase, reglaPadre]);

    const resultado = await service.calcularPrecio('prod-1', 'lista-1');

    expect(resultado.reglaAplicadaId).toBe('r-padre');
    expect(resultado.precioSugerido).toBe(650000); // 500000 * 1.30
  });

  it('la query a reglas_precio incluye alcance categoria (específica y padre) además de global', async () => {
    const producto = {
      precio_costo: 500000,
      categoria_id: 'cat-hijo',
      marca_id: null,
      empresa_id: 'emp-1',
      categoria: { padre_id: 'cat-padre' },
    };
    const { service, findManyMock } = createService(producto, [reglaBase]);

    await service.calcularPrecio('prod-1', 'lista-1');

    const where = findManyMock.mock.calls[0][0].where;
    expect(where.OR).toEqual(
      expect.arrayContaining([
        { alcance: 'global' },
        { alcance: 'categoria', categoria_id: 'cat-hijo' },
        { alcance: 'categoria', categoria_id: 'cat-padre' },
      ]),
    );
  });

  it('producto sin categoría solo compite con reglas globales', async () => {
    const producto = { precio_costo: 500000, categoria_id: null, marca_id: null, empresa_id: 'emp-1', categoria: null };
    const { service, findManyMock } = createService(producto, [reglaBase]);

    const resultado = await service.calcularPrecio('prod-1', 'lista-1');

    expect(resultado.reglaAplicadaId).toBe('r-global');
    const where = findManyMock.mock.calls[0][0].where;
    expect(where.OR).toEqual([{ alcance: 'global' }]);
  });
});
```

- [ ] **Step 2: Correr el test y verificar que falla**

Run: `cd /var/www/html/proyectos/novasispy-backend-api && npx jest src/reglas-precio/precios-engine.service.spec.ts --runInBand`
Expected: FAIL — hoy `calcularPrecio` fuerza `alcance: 'global'` en el `where`, así que la regla de categoría nunca compite y `where.OR` no existe.

- [ ] **Step 3: Extender el tipo `ReglaPrecioRow`**

En `src/reglas-precio/precios-engine.service.ts:9-22`, reemplazar el tipo por:

```typescript
type ReglaPrecioRow = {
  id: string;
  nombre: string;
  prioridad: number;
  moneda: string;
  costo_desde: Prisma.Decimal;
  costo_hasta: Prisma.Decimal | null;
  metodo: string;
  valor: Prisma.Decimal;
  redondeo_activo: boolean;
  redondeo_tipo: string;
  redondeo_multiplo: Prisma.Decimal;
  lista_precios_id: string;
  alcance: string;
  categoria_id: string | null;
};
```

- [ ] **Step 4: Reescribir `calcularPrecio` con matching por categoría + orden de especificidad**

Reemplazar el método completo (`src/reglas-precio/precios-engine.service.ts:211-273`) por:

```typescript
  async calcularPrecio(productoId: string, listaPreciosId: string): Promise<CalculoPrecio> {
    const producto = await this.prisma.productos.findUnique({
      where: { id: productoId },
      select: {
        precio_costo: true,
        categoria_id: true,
        marca_id: true,
        empresa_id: true,
        categoria: { select: { padre_id: true } },
      },
    });
    if (!producto) {
      throw new HttpException({ success: false, message: 'Producto no encontrado' }, HttpStatus.NOT_FOUND);
    }

    const precioCosto = Number(producto.precio_costo || 0);
    if (precioCosto <= 0) {
      return {
        precioCosto,
        precioSugerido: null,
        reglaAplicadaId: null,
        reglaAplicadaNombre: null,
        desglose: 'El producto no tiene costo cargado (precio_costo).',
      };
    }

    const lista = await this.prisma.lista_precios.findUnique({
      where: { id: listaPreciosId },
      select: { moneda: true },
    });
    if (!lista) {
      throw new HttpException({ success: false, message: 'Lista de precios no encontrada' }, HttpStatus.NOT_FOUND);
    }

    // Categoría del producto y su padre directo (Fase 6, docs/plan-motor-precios-rentabilidad.md §11).
    // Una regla de alcance 'categoria' matchea si su categoria_id es la categoría
    // específica del producto O la categoría padre de esa categoría.
    const categoriaId = producto.categoria_id ?? null;
    const categoriaPadreId = producto.categoria?.padre_id ?? null;

    const alcanceOR: Prisma.reglas_precioWhereInput[] = [{ alcance: 'global' }];
    if (categoriaId) alcanceOR.push({ alcance: 'categoria', categoria_id: categoriaId });
    if (categoriaPadreId) alcanceOR.push({ alcance: 'categoria', categoria_id: categoriaPadreId });

    const candidatas = await this.prisma.reglas_precio.findMany({
      where: {
        lista_precios_id: listaPreciosId,
        activo: true,
        moneda: lista.moneda,
        OR: alcanceOR,
        costo_desde: { lte: precioCosto },
        AND: [
          { OR: [{ costo_hasta: null }, { costo_hasta: { gt: precioCosto } }] },
          { OR: [{ fecha_fin: null }, { fecha_fin: { gte: new Date() } }] },
        ],
        fecha_inicio: { lte: new Date() },
      },
      orderBy: [{ prioridad: 'desc' }, { costo_desde: 'desc' }],
    });

    // Desempate por especificidad primero: categoría específica > categoría padre >
    // global. Dentro del mismo nivel, se mantiene el desempate anterior (prioridad,
    // luego costo_desde). Ver §11.2 del spec.
    const especificidad = (r: ReglaPrecioRow): number => {
      if (r.alcance === 'categoria' && categoriaId && r.categoria_id === categoriaId) return 2;
      if (r.alcance === 'categoria' && categoriaPadreId && r.categoria_id === categoriaPadreId) return 1;
      return 0;
    };
    const ordenadas = [...(candidatas as ReglaPrecioRow[])].sort((a, b) => {
      const porEspecificidad = especificidad(b) - especificidad(a);
      if (porEspecificidad !== 0) return porEspecificidad;
      const porPrioridad = b.prioridad - a.prioridad;
      if (porPrioridad !== 0) return porPrioridad;
      return Number(b.costo_desde) - Number(a.costo_desde);
    });

    const regla = ordenadas[0];
    if (!regla) {
      return {
        precioCosto,
        precioSugerido: null,
        reglaAplicadaId: null,
        reglaAplicadaNombre: null,
        desglose: `Ningún rango de costo cubre ${precioCosto} ${lista.moneda} en esta lista.`,
      };
    }

    const precioSugerido = this.aplicarRegla(precioCosto, regla);

    return {
      precioCosto,
      precioSugerido,
      reglaAplicadaId: regla.id,
      reglaAplicadaNombre: regla.nombre,
      desglose: this.describirCalculo(precioCosto, regla, precioSugerido),
    };
  }
```

- [ ] **Step 5: Correr el test de nuevo y verificar que pasa**

Run: `npx jest src/reglas-precio/precios-engine.service.spec.ts --runInBand`
Expected: PASS (4 tests).

---

### Task 5: `PreciosEngineService.generarGrillaCuotas` — filtro de inclusión por categoría/precio + limpieza de filas obsoletas + test

**Files:**
- Modify: `src/reglas-precio/precios-engine.service.ts:469-521` (`generarGrillaCuotas`)
- Modify: `src/reglas-precio/precios-engine.service.ts:388-391` y `433-446` (`recalcularLista`, agregación de resultados)
- Test: `src/reglas-precio/precios-engine.service.spec.ts` (agregar `describe` nuevo al archivo de Task 4)

**Interfaces:**
- Consumes: `producto.categoria_id`/`categoria.padre_id` (igual que Task 4), `planes_cuotas.categoria_id`/`precio_producto_desde`/`precio_producto_hasta` (Task 1).
- Produces: **cambio de firma** — `generarGrillaCuotas()` pasa de devolver `number` a devolver `{ generadas: number; removidas: number }`. Único caller: `recalcularLista` (mismo archivo, se actualiza en este mismo Task). `removidas` cuenta las filas de `producto_precio_cuota` que existían de una generación anterior pero cuyo plan ya no matchea (se desactivan con `activo:false`, nunca se borran — hay facturas/solicitudes de crédito históricas que referencian esas filas por FK).

- [ ] **Step 1: Agregar los tests que fallan primero**

Agregar al final de `src/reglas-precio/precios-engine.service.spec.ts` (mismo archivo, nuevo `describe`):

```typescript
describe('PreciosEngineService.generarGrillaCuotas - filtro de inclusión por categoría/precio (Fase 6)', () => {
  const createService = (producto: any, planes: any[], filasExistentes: any[] = []) => {
    const prisma = {
      lista_precios_productos: {
        findFirst: jest.fn().mockResolvedValue({ precio_base: 1000000 }),
      },
      lista_precios: { findUnique: jest.fn().mockResolvedValue({ moneda: 'PYG' }) },
      productos: { findUnique: jest.fn().mockResolvedValue(producto) },
      planes_cuotas: { findMany: jest.fn().mockResolvedValue(planes) },
      producto_precio_cuota: {
        findMany: jest.fn().mockResolvedValue(filasExistentes),
        findFirst: jest.fn().mockResolvedValue(null),
        create: jest.fn().mockResolvedValue({}),
        update: jest.fn().mockResolvedValue({}),
        updateMany: jest.fn().mockResolvedValue({ count: filasExistentes.length }),
      },
    };
    const auditService = { log: jest.fn().mockResolvedValue(undefined) };
    const service = new PreciosEngineService(prisma as any, auditService as any);
    return { service, prisma };
  };

  const planSinAlcance = {
    id: 'plan-global',
    nombre: '3 cuotas',
    cantidad_cuotas: 3,
    tasa_interes: 0,
    tipo_interes: 'frances',
    categoria_id: null,
    precio_producto_desde: null,
    precio_producto_hasta: null,
  };

  it('un plan sin categoría ni rango sigue aplicando a cualquier producto (retrocompatible)', async () => {
    const producto = { categoria_id: 'cat-otra', categoria: { padre_id: null } };
    const { service, prisma } = createService(producto, [planSinAlcance]);

    const resultado = await service.generarGrillaCuotas('prod-1', 'lista-1', 'emp-1');

    expect(resultado).toEqual({ generadas: 1, removidas: 0 });
    expect(prisma.producto_precio_cuota.create).toHaveBeenCalledTimes(1);
  });

  it('un plan con categoría específica no aplica a un producto de otra categoría', async () => {
    const planCategoria = { ...planSinAlcance, id: 'plan-cat', categoria_id: 'cat-celulares' };
    const producto = { categoria_id: 'cat-otra', categoria: { padre_id: null } };
    const { service, prisma } = createService(producto, [planCategoria]);

    const resultado = await service.generarGrillaCuotas('prod-1', 'lista-1', 'emp-1');

    expect(resultado.generadas).toBe(0);
    expect(prisma.producto_precio_cuota.create).not.toHaveBeenCalled();
  });

  it('un plan con categoría padre aplica a un producto cuya categoría es hija de esa categoría', async () => {
    const planPadre = { ...planSinAlcance, id: 'plan-padre', categoria_id: 'cat-electro' };
    const producto = { categoria_id: 'cat-heladeras', categoria: { padre_id: 'cat-electro' } };
    const { service, prisma } = createService(producto, [planPadre]);

    const resultado = await service.generarGrillaCuotas('prod-1', 'lista-1', 'emp-1');

    expect(resultado.generadas).toBe(1);
    expect(prisma.producto_precio_cuota.create).toHaveBeenCalledTimes(1);
  });

  it('un plan con rango de precio excluye productos fuera de rango', async () => {
    const planConRango = { ...planSinAlcance, id: 'plan-rango', precio_producto_desde: 2000000 };
    const producto = { categoria_id: null, categoria: null };
    // lista_precios_productos.precio_base mockeado en 1.000.000 (por debajo del desde)
    const { service, prisma } = createService(producto, [planConRango]);

    const resultado = await service.generarGrillaCuotas('prod-1', 'lista-1', 'emp-1');

    expect(resultado.generadas).toBe(0);
    expect(prisma.producto_precio_cuota.create).not.toHaveBeenCalled();
  });

  it('conviven un plan global y uno acotado por categoría para el mismo producto', async () => {
    const planGlobal = { ...planSinAlcance, id: 'plan-global' };
    const planCategoria = { ...planSinAlcance, id: 'plan-cat', nombre: '6 cuotas', cantidad_cuotas: 6, categoria_id: 'cat-celulares' };
    const producto = { categoria_id: 'cat-celulares', categoria: { padre_id: null } };
    const { service, prisma } = createService(producto, [planGlobal, planCategoria]);

    const resultado = await service.generarGrillaCuotas('prod-1', 'lista-1', 'emp-1');

    expect(resultado.generadas).toBe(2);
    expect(prisma.producto_precio_cuota.create).toHaveBeenCalledTimes(2);
  });

  it('desactiva (no borra) una fila existente cuyo plan dejó de matchear, y la reporta como removida', async () => {
    // Escenario: el producto ya tenía una fila de "6 cuotas" generada antes de que
    // alguien le asignara categoria_id a ese plan; ahora el producto no matchea.
    const planCategoria = { ...planSinAlcance, id: 'plan-cat', nombre: '6 cuotas', cantidad_cuotas: 6, categoria_id: 'cat-celulares' };
    const producto = { categoria_id: 'cat-otra', categoria: { padre_id: null } };
    const filaExistente = { id: 'fila-6-cuotas', nombre: '6 cuotas', activo: true };
    const { service, prisma } = createService(producto, [planCategoria], [filaExistente]);

    const resultado = await service.generarGrillaCuotas('prod-1', 'lista-1', 'emp-1');

    expect(resultado).toEqual({ generadas: 0, removidas: 1 });
    expect(prisma.producto_precio_cuota.updateMany).toHaveBeenCalledWith(
      expect.objectContaining({
        where: expect.objectContaining({ id: { in: ['fila-6-cuotas'] } }),
        data: expect.objectContaining({ activo: false }),
      }),
    );
  });
});
```

- [ ] **Step 2: Correr los tests y verificar que fallan**

Run: `cd /var/www/html/proyectos/novasispy-backend-api && npx jest src/reglas-precio/precios-engine.service.spec.ts --runInBand`
Expected: los 4 tests nuevos que esperan exclusión (`toBe(0)`) FALLAN — hoy `generarGrillaCuotas` no filtra nada, siempre crea la fila.

- [ ] **Step 3: Reescribir `generarGrillaCuotas` con el filtro de inclusión y la desactivación de filas obsoletas**

Reemplazar el método completo (`src/reglas-precio/precios-engine.service.ts:469-521`) por:

```typescript
  async generarGrillaCuotas(
    productoId: string,
    listaPreciosId: string,
    empresaId: string,
    userId?: string,
  ): Promise<{ generadas: number; removidas: number }> {
    const precioLista = await this.prisma.lista_precios_productos.findFirst({
      where: { lista_precios_id: listaPreciosId, producto_id: productoId },
      select: { precio_base: true },
    });
    const lista = await this.prisma.lista_precios.findUnique({
      where: { id: listaPreciosId },
      select: { moneda: true },
    });
    if (!precioLista || !lista) return { generadas: 0, removidas: 0 };

    const precioContado = Number(precioLista.precio_base);
    if (precioContado <= 0) return { generadas: 0, removidas: 0 };

    // Categoría del producto y su padre directo (mismo criterio que calcularPrecio,
    // Fase 6 — docs/plan-motor-precios-rentabilidad.md §11).
    const producto = await this.prisma.productos.findUnique({
      where: { id: productoId },
      select: { categoria_id: true, categoria: { select: { padre_id: true } } },
    });
    const categoriaId = producto?.categoria_id ?? null;
    const categoriaPadreId = producto?.categoria?.padre_id ?? null;

    const planesActivos = await this.prisma.planes_cuotas.findMany({
      where: { empresa_id: empresaId, activo: true, cantidad_cuotas: { not: null } },
      select: {
        id: true,
        nombre: true,
        cantidad_cuotas: true,
        tasa_interes: true,
        tipo_interes: true,
        categoria_id: true,
        precio_producto_desde: true,
        precio_producto_hasta: true,
      },
    });

    // Filtro de inclusión (no hay "ganador único" como en reglas_precio): un plan
    // sin categoria_id/rango seteados sigue aplicando a cualquier producto.
    const planes = planesActivos.filter((plan) => {
      const matchCategoria =
        !plan.categoria_id || plan.categoria_id === categoriaId || plan.categoria_id === categoriaPadreId;
      const matchDesde = plan.precio_producto_desde === null || precioContado >= Number(plan.precio_producto_desde);
      const matchHasta = plan.precio_producto_hasta === null || precioContado <= Number(plan.precio_producto_hasta);
      return matchCategoria && matchDesde && matchHasta;
    });

    const nombresVigentes = new Set<string>();
    let generadas = 0;
    for (const plan of planes) {
      const cantCuotas = plan.cantidad_cuotas || 1;
      const tasa = Number(plan.tasa_interes || 0);
      // Interés simple sobre el total (Fase 1: mismo criterio usado en planes-cuotas).
      const montoTotal = precioContado * (1 + tasa / 100);
      const montoCuota = Math.ceil(montoTotal / cantCuotas / 100) * 100; // redondeo a 100 hacia arriba

      const nombre = `${cantCuotas} cuota${cantCuotas > 1 ? 's' : ''}`;
      nombresVigentes.add(nombre);
      const existente = await this.prisma.producto_precio_cuota.findFirst({
        where: { producto_id: productoId, nombre, empresa_id: empresaId },
      });

      const data = {
        empresa_id: empresaId,
        producto_id: productoId,
        nombre,
        cant_cuotas: cantCuotas,
        monto_cuota: montoCuota,
        monto_total: montoCuota * cantCuotas,
        moneda: lista.moneda,
        usuario: userId,
        activo: true,
      };

      if (existente) {
        await this.prisma.producto_precio_cuota.update({ where: { id: existente.id }, data: { ...data, updated_at: new Date() } });
      } else {
        await this.prisma.producto_precio_cuota.create({ data });
      }
      generadas++;
    }

    // Filas de una generación anterior cuyo plan ya no matchea (p. ej. alguien le
    // asignó categoría/rango a un plan después de que este producto ya tuviera su
    // fila). Se desactivan, nunca se borran: facturas/solicitudes de crédito
    // históricas referencian estas filas por FK. Ver riesgo en §11.6 del spec.
    const filasExistentes = await this.prisma.producto_precio_cuota.findMany({
      where: { producto_id: productoId, empresa_id: empresaId, activo: true },
      select: { id: true, nombre: true },
    });
    const idsAObsoletar = filasExistentes.filter((f) => !nombresVigentes.has(f.nombre)).map((f) => f.id);

    let removidas = 0;
    if (idsAObsoletar.length > 0) {
      const { count } = await this.prisma.producto_precio_cuota.updateMany({
        where: { id: { in: idsAObsoletar } },
        data: { activo: false, updated_at: new Date() },
      });
      removidas = count;
    }

    return { generadas, removidas };
  }
```

- [ ] **Step 4: Actualizar `recalcularLista` para el nuevo shape de retorno**

En `src/reglas-precio/precios-engine.service.ts:388-391`, cambiar:

```typescript
    let actualizados = 0;
    let omitidosPorOverride = 0;
    let sinReglaAplicable = 0;
    let cuotasGeneradas = 0;
```

por:

```typescript
    let actualizados = 0;
    let omitidosPorOverride = 0;
    let sinReglaAplicable = 0;
    let cuotasGeneradas = 0;
    let cuotasRemovidas = 0;
```

En `src/reglas-precio/precios-engine.service.ts:433-436`, cambiar:

```typescript
      if (generarCuotas) {
        const filas = await this.generarGrillaCuotas(producto.id, dto.listaPreciosId, empresaId, userId);
        cuotasGeneradas += filas;
      }
```

por:

```typescript
      if (generarCuotas) {
        const { generadas, removidas } = await this.generarGrillaCuotas(producto.id, dto.listaPreciosId, empresaId, userId);
        cuotasGeneradas += generadas;
        cuotasRemovidas += removidas;
      }
```

Y en el objeto `resultado` (líneas ~439-446), agregar `cuotas_removidas`:

```typescript
    const resultado = {
      lista_precios_id: dto.listaPreciosId,
      total_evaluados: productos.length,
      actualizados,
      omitidos_por_override: omitidosPorOverride,
      sin_regla_aplicable: sinReglaAplicable,
      cuotas_generadas: cuotasGeneradas,
      cuotas_removidas: cuotasRemovidas,
    };
```

- [ ] **Step 5: Correr los tests de nuevo y verificar que pasan**

Run: `npx jest src/reglas-precio/precios-engine.service.spec.ts --runInBand`
Expected: PASS (10 tests en total en el archivo).

---

### Task 6: Frontend — componente compartido `CategoriaAutocomplete`

Por `CLAUDE.md` del frontend, un patrón de UI genérico usado en más de una pantalla va en `_standards/`, no en el folder de cada pantalla. Este componente reemplaza además la lógica ad-hoc de agrupado padre→hijos que hoy vive inline en `ProductosTab.jsx` (no se toca `ProductosTab.jsx` en este plan — queda fuera de alcance — pero el componente nuevo sigue el mismo criterio de agrupado para que se vea igual en toda la app).

**Files:**
- Create: `src/components/_standards/CategoriaAutocomplete.jsx`
- Modify: `src/components/_standards/index.js`

**Interfaces:**
- Produces: `CategoriaAutocomplete({ value, onChange, allowGlobalLabel, size, sx, disabled })` — `value` es un `categoriaId` (string) o `null`; `onChange(categoriaId: string | null)`. Consumido por Tasks 7 y 9.

- [ ] **Step 1: Crear el componente**

Crear `src/components/_standards/CategoriaAutocomplete.jsx`:

```jsx
import { Autocomplete, Box, TextField, Typography } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { getCategorias } from "../../api/categorias.service";

/**
 * Selector de categoría con jerarquía padre→hijos (mismo agrupado visual que
 * ProductosTab.jsx), más una opción explícita para "sin categoría" (alcance
 * global). Si el usuario no tiene permiso INV_CAT_CATEGORIA_VER, la carga de
 * categorías falla en silencio y el selector queda con opciones vacías (no
 * bloquea el resto del formulario — la categoría es siempre opcional).
 *
 * Props:
 *   value            {string|null} — categoriaId seleccionado, o null (todas)
 *   onChange         {function}    — callback(categoriaId: string|null)
 *   allowGlobalLabel {string}      — texto de la opción "sin categoría"
 */
export function CategoriaAutocomplete({
  value,
  onChange,
  allowGlobalLabel = "Todas las categorías",
  size = "small",
  sx,
  disabled = false,
}) {
  const { data } = useQuery({
    queryKey: ["categorias-autocomplete"],
    queryFn: () => getCategorias(1, 500),
    staleTime: 5 * 60 * 1000,
    retry: false,
  });
  const categorias = data?.items || [];

  const opciones = useMemo(() => {
    const global = { id: null, descripcion: allowGlobalLabel, padre_id: null, __global: true };
    const padres = categorias.filter((c) => !c.padre_id);
    const result = [global];
    padres.forEach((padre) => {
      result.push(padre);
      const hijos = categorias.filter((c) => c.padre_id === padre.id);
      result.push(...hijos);
    });
    return result;
  }, [categorias, allowGlobalLabel]);

  const selected = opciones.find((o) => o.id === (value || null)) || opciones[0];

  return (
    <Autocomplete
      size={size}
      sx={sx}
      disabled={disabled}
      options={opciones}
      value={selected}
      onChange={(_, opt) => onChange?.(opt?.id ?? null)}
      getOptionLabel={(opt) => opt?.descripcion || ""}
      isOptionEqualToValue={(opt, val) => opt?.id === val?.id}
      renderOption={(props, option) => {
        const { key, ...liProps } = props;
        const isChild = !!option.padre_id;
        const parent = isChild ? categorias.find((c) => c.id === option.padre_id) : null;
        return (
          <li key={key} {...liProps}>
            <Box sx={{ display: "flex", flexDirection: "column", pl: isChild ? 3 : 0 }}>
              <Typography
                variant="body2"
                sx={{
                  fontWeight: option.__global || !isChild ? 600 : 400,
                  color: isChild ? "text.secondary" : "text.primary",
                }}
              >
                {isChild && "└ "}
                {option.descripcion}
              </Typography>
              {parent && (
                <Typography variant="caption" color="text.disabled" sx={{ pl: 2 }}>
                  en: {parent.descripcion}
                </Typography>
              )}
            </Box>
          </li>
        );
      }}
      renderInput={(params) => <TextField {...params} label="Categoría" placeholder="Buscar categoría..." />}
    />
  );
}
```

- [ ] **Step 2: Exportar desde el barrel**

En `src/components/_standards/index.js`, agregar después de la línea `export { MapPicker } from "./MapPicker";`:

```javascript
export { CategoriaAutocomplete } from "./CategoriaAutocomplete";
```

- [ ] **Step 3: Verificar que el lint no rompe**

Run: `cd /var/www/html/proyectos/novasispy-erp && npx eslint src/components/_standards/CategoriaAutocomplete.jsx src/components/_standards/index.js`
Expected: sin errores nuevos (0 problems, o solo warnings preexistentes del proyecto si el baseline ya tenía alguno en `index.js`).

---

### Task 7: Frontend — `ReglaPrecioFormDialog.jsx` — selector de categoría

**Files:**
- Modify: `src/components/organismos/reglas-precio/ReglaPrecioFormDialog.jsx`

**Interfaces:**
- Consumes: `CategoriaAutocomplete` (Task 6).
- Produces: el payload de `onSubmit` ahora incluye `alcance: "global"|"categoria"` y `categoriaId` (consumido por el backend de Task 4, sin cambios de contrato ya que el DTO ya soportaba estos campos desde Fase 1).

- [ ] **Step 1: Importar el componente y extender `buildDefaults`**

En `src/components/organismos/reglas-precio/ReglaPrecioFormDialog.jsx:21`, cambiar:

```jsx
import { InlineValidationBanner, MonedaInput } from "../../_standards";
```

por:

```jsx
import { CategoriaAutocomplete, InlineValidationBanner, MonedaInput } from "../../_standards";
```

En `buildDefaults` (líneas 35-47), agregar un campo:

```jsx
const buildDefaults = (regla) => ({
  nombre: regla?.nombre || "",
  listaPrecios: regla?.lista_precios || null,
  categoriaId: regla?.alcance === "categoria" ? regla?.categoria_id || null : null,
  costoDesde: regla ? Number(regla.costo_desde) : 0,
  sinTope: regla ? regla.costo_hasta === null : true,
  costoHasta: regla?.costo_hasta !== null && regla?.costo_hasta !== undefined ? Number(regla.costo_hasta) : 0,
  metodo: regla?.metodo || "margen_sobre_costo",
  valor: regla ? Number(regla.valor) : 0,
  redondeoActivo: regla ? regla.redondeo_activo : true,
  redondeoTipo: regla?.redondeo_tipo || "superior",
  redondeoMultiplo: regla ? Number(regla.redondeo_multiplo) : 1000,
  prioridad: regla ? Number(regla.prioridad) : 1,
});
```

- [ ] **Step 2: Agregar el selector al formulario**

En `src/components/organismos/reglas-precio/ReglaPrecioFormDialog.jsx`, después del `Autocomplete` de lista de precios (cierra en la línea 184, justo antes del `Box` del rango de costo en línea 186), agregar:

```jsx
          <CategoriaAutocomplete
            value={form.categoriaId}
            onChange={setField("categoriaId")}
            allowGlobalLabel="Todas las categorías (global)"
          />
```

- [ ] **Step 3: Actualizar `handleSubmit` para enviar `alcance`/`categoriaId`**

En `handleSubmit` (líneas 108-121), cambiar:

```jsx
    onSubmit({
      nombre: form.nombre.trim(),
      alcance: "global",
      moneda: monedaActiva,
```

por:

```jsx
    onSubmit({
      nombre: form.nombre.trim(),
      alcance: form.categoriaId ? "categoria" : "global",
      categoriaId: form.categoriaId || undefined,
      moneda: monedaActiva,
```

- [ ] **Step 4: Actualizar el comentario de cabecera del componente**

En `src/components/organismos/reglas-precio/ReglaPrecioFormDialog.jsx:49-51`, cambiar:

```jsx
/**
 * Alta/edición de una regla de precio (Fase 1: marcación por rango de costo,
 * alcance siempre 'global'). Ver docs/plan-motor-precios-rentabilidad.md.
 */
```

por:

```jsx
/**
 * Alta/edición de una regla de precio: marcación por rango de costo, con
 * alcance opcional por categoría (específica o padre, Fase 6). Ver
 * docs/plan-motor-precios-rentabilidad.md §11.
 */
```

- [ ] **Step 5: Verificar manualmente en el navegador**

Levantar el frontend (`npm run dev`), ir a Configuración → Precios → Reglas → "Nueva regla", confirmar que aparece el selector "Categoría" con la opción "Todas las categorías (global)" preseleccionada, que se puede elegir una categoría hija (con su padre mostrado debajo, como en el form de producto), y que al guardar no tira error de validación (el DTO backend ya acepta `alcance`/`categoriaId` desde Fase 1).

---

### Task 8: Frontend — `ReglasPrecioTemplate.jsx` — columna de alcance

**Files:**
- Modify: `src/components/templates/reglas-precio/ReglasPrecioTemplate.jsx:34-105` (definición de `columns`)

**Interfaces:**
- Consumes: `regla.alcance`, `regla.categoria` (ya vienen del backend — `listar()` en `precios-engine.service.ts:91-98` ya hace `include: { categoria: { select: { id, descripcion } } }`).

- [ ] **Step 1: Agregar la columna**

En `src/components/templates/reglas-precio/ReglasPrecioTemplate.jsx`, después de la columna `"rango"` (cierra en la línea 82) y antes de la columna `"metodo"` (línea 83), agregar:

```jsx
      {
        key: "alcance",
        label: "Alcance",
        minWidth: 150,
        render: (r) =>
          r.alcance === "categoria" ? (
            <Chip label={r.categoria?.descripcion || "Categoría"} size="small" color="info" sx={{ fontSize: "0.7rem" }} />
          ) : (
            <Typography variant="caption" color="text.secondary">
              Global
            </Typography>
          ),
      },
```

- [ ] **Step 2: Verificar manualmente**

En la misma pantalla del Step 5 de Task 7, tras crear una regla con categoría, confirmar que la tabla de listado muestra el chip con el nombre de la categoría, y que las reglas sin categoría muestran "Global".

---

### Task 9: Frontend — `PlanesCuotasTab.jsx` — categoría + rango de precio de producto

**Files:**
- Modify: `src/components/configuracion/PlanesCuotasTab.jsx`

**Interfaces:**
- Consumes: `CategoriaAutocomplete` (Task 6).
- Produces: el payload de `crearPlan`/`actualizarPlan` ahora incluye `categoriaId`, `precioProductoDesde`, `precioProductoHasta` (consumidos por el backend de Tasks 2-3).

- [ ] **Step 1: Importar el componente**

En `src/components/configuracion/PlanesCuotasTab.jsx:55-57`, cambiar:

```jsx
import { usePermission } from "../../hooks/usePermission";
import { AccesoRestringido } from "../common/AccesoRestringido";
import { MonedaInput } from "../common/MonedaInput";
```

por:

```jsx
import { CategoriaAutocomplete } from "../_standards";
import { usePermission } from "../../hooks/usePermission";
import { AccesoRestringido } from "../common/AccesoRestringido";
import { MonedaInput } from "../common/MonedaInput";
```

- [ ] **Step 2: Extender `defaultFormData`**

En `src/components/configuracion/PlanesCuotasTab.jsx:105-120`, agregar tres campos:

```jsx
const defaultFormData = {
  codigo: "",
  nombre: "",
  descripcion: "",
  tipoCalculo: "automatico",
  cantidadCuotas: 3,
  intervaloDias: 30,
  tasaInteres: 0,
  tipoInteres: "simple",
  cuotaInicialRequerida: false,
  cuotaInicialMinima: "",
  montoMinimo: "",
  montoMaximo: "",
  categoriaId: null,
  precioProductoDesde: "",
  precioProductoHasta: "",
  activo: true,
  esDefault: false,
};
```

- [ ] **Step 3: Cargar los valores al editar**

En `handleOpenEdit` (líneas 245-260), agregar dentro del objeto que arma `setFormData`, después de `montoMaximo: plan.monto_maximo?.toString() || "",`:

```jsx
      categoriaId: plan.categoria_id || null,
      precioProductoDesde: plan.precio_producto_desde?.toString() || "",
      precioProductoHasta: plan.precio_producto_hasta?.toString() || "",
```

- [ ] **Step 4: Enviar los campos en `handleSubmit`**

En `handleSubmit` (líneas 280-300), dentro del objeto `datos`, después de `montoMaximo: formData.montoMaximo ? Number(formData.montoMaximo) : undefined,`, agregar:

```jsx
      categoriaId: formData.categoriaId || undefined,
      precioProductoDesde: formData.precioProductoDesde ? Number(formData.precioProductoDesde) : undefined,
      precioProductoHasta: formData.precioProductoHasta ? Number(formData.precioProductoHasta) : undefined,
```

- [ ] **Step 5: Agregar los campos al formulario, junto a los límites existentes**

En `src/components/configuracion/PlanesCuotasTab.jsx`, después del segundo `MonedaInput` de "Monto Máximo" (líneas 1035-1042) y antes del `Box` de "Requiere cuota inicial" (línea 1043), agregar:

```jsx
            <Box sx={{ gridColumn: "1 / -1" }}>
              <CategoriaAutocomplete
                value={formData.categoriaId}
                onChange={(categoriaId) => setFormData((prev) => ({ ...prev, categoriaId }))}
                allowGlobalLabel="Cualquier categoría"
              />
            </Box>
            <MonedaInput
              size="small"
              label="Precio de producto desde"
              value={Number(formData.precioProductoDesde) || 0}
              onChange={(v) => setFormData((prev) => ({ ...prev, precioProductoDesde: v || "" }))}
              helperText="Precio de catálogo del producto (no el monto de la venta) desde el cual este plan se ofrece"
              fullWidth
            />
            <MonedaInput
              size="small"
              label="Precio de producto hasta"
              value={Number(formData.precioProductoHasta) || 0}
              onChange={(v) => setFormData((prev) => ({ ...prev, precioProductoHasta: v || "" }))}
              helperText="Vacío = sin tope superior"
              fullWidth
            />
```

- [ ] **Step 6: Verificar manualmente en el navegador**

En Configuración → Planes de Cuotas, abrir "Nuevo Plan", confirmar que la sección "Límites (opcional)" ahora también muestra el selector de categoría y el rango de precio de producto, que ambos son opcionales (se puede guardar sin tocarlos, igual que antes), y que al editar un plan existente sin estos valores no aparecen prellenados con nada raro (categoría en "Cualquier categoría", rango vacío).

---

### Task 10: Verificación end-to-end contra datos reales de Kety

No es una tarea de código — es la verificación manual de que las 5 tareas anteriores funcionan juntas, igual que se hizo para Fase 3 (ver `docs/plan-motor-precios-rentabilidad.md` §10.8).

**Files:** ninguno (solo lectura/uso de la app corriendo contra la empresa Comercial Kety, `empresa_id c9a80e4a-f640-4588-a80e-b0c706f6cd28`, login `2156747`/`2156747#`).

- [ ] **Step 1: Regla de precio por categoría específica**

Crear una regla de precio con categoría = una subcategoría concreta de Kety (ej. una hija de "Electrodomésticos" si existe, o crear una categoría de prueba), rango de costo que cubra un producto real de esa categoría, y un `valor` distinto al de cualquier regla global existente. Llamar `GET /reglas-precio/producto/:id/sugerido?listaPreciosId=...` (o usar el botón "Previsualizar impacto") para ese producto y confirmar que el precio sugerido usa la regla de categoría, no la global.

- [ ] **Step 2: Regla de precio por categoría padre**

Repetir el Step 1 pero con la regla apuntando a la categoría **padre** de ese producto (no a su categoría directa), confirmando que también aplica.

- [ ] **Step 3: Plan de cuotas acotado por categoría conviviendo con uno global**

Con un plan de cuotas ya existente y activo (global, sin categoría), crear un segundo plan con `categoriaId` apuntando a la misma categoría de un producto real, y `precioProductoDesde`/`precioProductoHasta` que cubra el precio contado de ese producto. Ejecutar el recálculo de la lista (botón "Recalcular precios" o `POST /reglas-precio/recalcular` con `generarCuotas: true`) y verificar en `producto_precio_cuota` (o en la respuesta del endpoint de financiación del ecommerce) que el producto ahora tiene **ambas** filas de cuotas (la del plan global y la del plan acotado).

- [ ] **Step 4: Retrocompatibilidad**

Elegir un producto de una categoría **distinta** a la del plan acotado del Step 3, recalcular, y confirmar que ese producto sigue recibiendo el plan global pero **no** el acotado — sin que ningún otro plan/producto existente en Kety haya perdido sus filas de cuotas previas por este cambio.
