# Plan: Módulo Comercial — Presupuestos / Cotizaciones (M36–M43)

**Fecha**: 2026-05-24
**Fuente funcional**: `Novasis_ERP_Presupuestos_v1.pdf` (v1.0 — Mayo 2026)
**Estándares**: [`backend/CLAUDE.md`](../CLAUDE.md) y memorias del proyecto (selectores buscables, no `alert/confirm` nativos, `MonedaInput`)
**Estado**: Fase 0–7 IMPLEMENTADAS (esquema + migración + módulo NestJS + CRUD + PDF + email/portal/tracking + aprobación interna + conversión a OV + dashboard/seguimiento + cron + **permisos por rol (PermissionGuard activo) + script de provisión + ScreenGuia/tour ampliado**; lint del módulo en verde, build frontend OK, tests cálculo 6/6). **Único pendiente**: `convert-to-credit` (M42, Solicitud de Crédito — diferido por decisión del usuario).

> **Actualización 2026-05-26**: Fase 3 (M40) implementada — `POST /:id/send-email` (PDF adjunto), controller público `pub/presupuesto/:token` (datos, PDF, accept, reject, pixel `track/open`), plantilla `presupuesto.hbs`, página SPA `PresupuestoPublico.jsx` (`/pub/presupuesto/:token`), acción "Enviar" + panel de seguimiento en `DetalleTab`.
> **Actualización 2026-05-26 (Fase 4 — M41)**: aprobación interna por monto — `presupuestos-aprobacion.service.ts` (`submit-review` auto-aprueba bajo umbral / `IN_REVIEW` con registro `presupuesto_aprobacion` por nivel; `approve` → APPROVED; `reject-approval` → vuelve a DRAFT). Endpoints `POST /:id/submit-review|approve|reject-approval`. `send-email` bloqueado si supera el umbral y no está aprobado. Frontend: botones por estado en `DetalleTab` (vía `useConfirmDialog`), aviso "pendiente de aprobación", y KPI/bandeja "Por aprobar" (filtro `IN_REVIEW`) en `ListaTab`.
> **Actualización 2026-05-26 (Fase 5 — M42, PARCIAL: solo Orden de Venta)**: conversión a OV — migración aditiva `20260526_pedido_presupuesto_origen` (`pedidos.presupuesto_id` + FK + index). `presupuestos-conversion.service.ts` delega en `PedidosService.crearOrden` + `confirmarOrden` (OV queda CONFIRMADA/APROBADA bloqueada; sin sucursal → sin reserva de stock; rollback de la OV si falla la confirmación). Sólo se copian líneas con `producto_id`; `precio_unitario` (IVA incl.) → `precio_negociado`; IVA tomado del producto. Endpoints `GET /presupuestos/aceptados?cliente_id=` y `POST /:id/convert-to-order`. Frontend: botón "Convertir a Orden de Venta" en `DetalleTab` (estado ACCEPTED) y `PresupuestoAceptadoBanner` en `OrdenVentaNueva` (vía 2: elegir cliente → presupuesto aceptado → genera y navega a la OV). **Pendiente**: `convert-to-credit` (Solicitud de Crédito) — requiere UX de financiación (cuotas/cronograma); diferido por decisión del usuario.
> **Actualización 2026-05-27 (UX + validaciones)**: seguimiento comercial centralizado en **Dashboard** (widget "Presupuestos" con expansión inline; sin pestaña `Pipeline` en `PresupuestosTemplate`). Se eliminó duplicación de KPIs al expandir (el bloque expandido oculta KPIs del panel interno) y se alineó el estilo de KPIs/íconos al bloque superior del Dashboard. En `Nuevo presupuesto` se validan como obligatorios `Moneda` y `Vigencia`.
> **Nota**: Posterior al plan original se agregó un sub-módulo de **Condiciones de Pago** (catálogo): migración `20260525_presupuesto_condicion_pago` (tabla + FK `presupuesto_cab.condicion_pago_id`), `presupuesto-condiciones-pago.controller/service`, `CondicionesPagoTab.jsx`. La fuente de verdad del default operativo es `es_default` del catálogo; `condicion_pago_default` queda deprecado a nivel funcional (compatibilidad técnica, sin uso en alta).

---

## Contexto de partida

El ERP **no tenía** gestión de presupuestos/cotizaciones. El PDF especifica el ciclo comercial completo: elaboración → aprobación interna → envío por email con PDF → portal del cliente sin login → tracking → conversión a venta → dashboard de seguimiento comercial.

**Reconciliación de stack** (el PDF asume `Next.js · Puppeteer · Nodemailer`; el stack real es otro):

| PDF asume | Realidad del proyecto |
|---|---|
| Next.js | Backend **NestJS 11 + Prisma + PostgreSQL** / Frontend **React 18 + Vite SPA** (JS/JSX, MUI v7, TanStack Query/Table, react-hook-form, sonner, @iconify/react) |
| Puppeteer | Microservicio **`generador-pdf`** (Express + handlebars + `@pdfme` + `jspdf`); ya genera invoice, orden-compra, orden-pago, recibos, estado-cuenta, solicitud-credito |
| Nodemailer | **`@nestjs-modules/mailer`** (SMTP + Handlebars) ya configurado + cola BullMQ `email-queue` |

El módulo se apoya en infraestructura existente sin romperla: módulo `facturas`, `pedidos` (Órdenes de Venta), `solicitud_credito`, selectores (`ClienteSelector`), `MonedaInput`, `mail`, microservicio PDF, permisos (`modulos`/`perfiles_privilegios`), tours.

**Flujo de conversión confirmado con el usuario** — la conversión **NO** crea una factura borrador editable directa:

> **Implementado hoy**: Presupuesto ACEPTADO → **Orden de Venta** (creada CONFIRMADA/APROBADA, bloqueada, sin edición) → Factura.
> **Diferido**: conversión a Solicitud de Crédito (`convert-to-credit`, M42 pendiente).
> La facturación **siempre** se hace sobre la Orden de Venta / Solicitud de Crédito (flujos `pedido_id` / `solicitud_credito_id` ya existentes en `factura_cab`), nunca directo del presupuesto. Además, la pantalla de Orden de Venta permite, al elegir el cliente, seleccionar un presupuesto aceptado de ese cliente y generar la OV desde él.

---

## Decisiones de diseño

| # | Decisión | Resolución |
|---|---|---|
| 1 | Alcance | Plan completo por fases (M36–M43). Fases 0–2 cerradas con validación funcional + build; resto con lineamientos ejecutables. |
| 2 | Enums en Prisma | `presupuesto_estado`, `presupuesto_item_tipo`, `presupuesto_iva_categoria`, `presupuesto_aprobacion_resultado` como `enum` Prisma (regla CLAUDE.md). |
| 3 | Totales en cabecera | Presupuesto es **no fiscal** → totales (exento/base+iva 5/10, descuento global, total, total_usd) se guardan en `presupuesto_cab`, sin tabla `_subtotales` aparte. |
| 4 | Moneda | FK `moneda_id` → tabla `moneda` existente (reutiliza catálogo PYG/USD y formato), no enum. `tipo_cambio_usd` congelado al emitir. |
| 5 | Vendedor | `vendedor_id` → `vendedores_cobradores` (consistente con `factura_cab.vendedor_id`), no `rrhh_empleados`. |
| 6 | Motor de cálculo IVA | Helper puro `presupuestos.calc.ts` (Ley 125/91, IVA incluido): IVA10=subtotal/11, IVA5=subtotal/21, EXENTO=0; descuento global sobre total bruto. Validado en backend; se replica en frontend para feedback. |
| 7 | PDF | **Extender el microservicio `generador-pdf`** con plantilla `presupuesto` (fuente única para preview, descarga y adjunto de email). El backend lo invoca con el patrón de `kude.service.ts`. |
| 8 | Email + portal público | SMTP + Handlebars existentes; ruta pública SPA `/pub/presupuesto/:token` (precedente real: `/pay/ca/:token` → `PagoPublicoCA`); endpoints públicos + pixel tracking. |
| 9 | **Conversión a ventas** | **Implementado**: Presupuesto ACEPTADO → **Orden de Venta** (`pedidos`) confirmada/bloqueada; facturación desde OV. **Pendiente**: vía Solicitud de Crédito (`convert-to-credit`). Doble vía operativa actual: desde el detalle del presupuesto y desde la pantalla de OV (selector de presupuestos aceptados del cliente). |
| 10 | Ítems convertibles | Sólo líneas con `producto_id` se copian hoy a `pedido_detalle` (exige `producto_id` NOT NULL, igual que `factura_det`). Líneas `SUBTITULO`/`TEXTO_LIBRE`/sin producto son informativas. La conversión advierte si hay líneas facturables sin producto. Para Solicitud de Crédito se aplicará la misma regla cuando se implemente `convert-to-credit`. |
| 11 | Multi-empresa | Todas las tablas con `empresa_id` indexado; numeración única por empresa; queries siempre `WHERE empresa_id`. |
| 12 | Permisos | Módulo `PRESUPUESTOS` + privilegios genéricos (LEER/CREAR/EDITAR/ELIMINAR/EXPORTAR/IMPRIMIR) + `PRES_APROBAR`, `PRES_CONVERTIR`. **`PermissionGuard` activo (Fase 7)** con `@RequirePermission` por endpoint; provisionar con `pnpm provision:presupuestos` antes de exponerlo a usuarios no superAdmin. |
| 13 | Selectores buscables / diálogos | Dropdowns con `SearchableSelect`/Autocomplete; confirmaciones con `useConfirmDialog`; toasts `sonner`. Nunca `<select>` nativo ni `alert/confirm/prompt`. |
| 14 | Inputs monetarios | Formato monetario consistente en UI (`formatMoney`/componentes de moneda donde aplica) y validaciones de negocio en backend/frontend. |

---

## Alcance funcional (M36–M43)

| Módulo | Descripción | Prioridad | Estado |
|---|---|---|---|
| M36 | Catálogo y parámetros globales del módulo (`presupuesto_config`) | Alta | ✅ |
| M37 | Gestión de presupuestos — CRUD, versionado, ciclo de vida | Crítica | ✅ |
| M38 | Motor de cálculo — subtotales, IVA 5/10/exento, descuentos, multimoneda | Crítica | ✅ (tests 6/6) |
| M39 | Generación de PDF (extensión `generador-pdf`) | Crítica | ✅ (preview + descarga) |
| M40 | Envío por email + portal del cliente sin login + tracking | Crítica | ✅ |
| M41 | Workflow de aprobación interna por monto | Alta | ✅ |
| M42 | **Conversión a Orden de Venta** ✅ / Solicitud de Crédito ⏳ | Alta | 🟡 |
| M43 | Reportes y pipeline comercial + jobs | Media | ✅ |

---

## Parámetros — tabla `presupuesto_config` (M36, uno por empresa)

Se autoaprovisiona con defaults en el primer acceso (`getConfig`). **UI**: `Configuración › Presupuestos › Configuración Global` (`components/configuracion/PresupuestosConfigTab.jsx`, registrado en `ConfiguracionNew.jsx`), con secciones: Numeración y vigencia · Aprobación por monto · Valores por defecto · Email · PDF.

| Campo | Default | Descripción |
|---|---|---|
| `prefijo_numero` | `PRES-{YYYY}-` | Prefijo de numeración (reemplaza `{YYYY}`/`{YY}`) |
| `dias_vigencia` | `30` | Días emisión→vencimiento |
| `monto_umbral_aprobacion` | `50.000.000` | Umbral que dispara aprobación (M41) |
| `moneda_base` | `PYG` | Moneda por defecto |
| `email_remitente` | — | Remitente de los envíos |
| `asunto_email_template` | `Presupuesto {numero} — {empresa}` | Plantilla de asunto |
| `dias_alerta_vencimiento` | `3` | Días de anticipo para alertar al vendedor |
| `umbral_nivel1/2/3` | `10M / 50M / 200M` | Tramos de aprobación (M41) |
| `texto_legal_pdf` | — | Pie legal del PDF |

Nota de compatibilidad: la columna `presupuesto_config.condicion_pago_default` se mantiene sin drop en DB, pero no participa del fallback de creación. El fallback usa catálogo: `es_default` y, si no existe, primera condición activa por `orden/nombre`.

---

## Modelo de datos (Prisma) — IMPLEMENTADO

Migración aplicada: `backend/prisma/migrations/20260523_modulo_presupuestos/migration.sql` (idempotente; sólo crea objetos nuevos).

```prisma
enum presupuesto_estado { DRAFT IN_REVIEW APPROVED SENT VIEWED ACCEPTED REJECTED EXPIRED CONVERTED CANCELLED }
enum presupuesto_item_tipo { PRODUCTO SERVICIO TEXTO_LIBRE SUBTITULO DESCUENTO_GLOBAL }
enum presupuesto_iva_categoria { EXENTO IVA_5 IVA_10 }
enum presupuesto_aprobacion_resultado { APROBADO RECHAZADO }
```

- **`presupuesto_cab`** — cabecera: `numero`, `version`, `presupuesto_origen_id` (self-FK versionado), `empresa_id`/`cliente_id`/`vendedor_id`/`moneda_id`, `titulo`, `estado`, `fecha_emision`/`fecha_vigencia`, condiciones/notas; **totales** (`subtotal_exento`, `base_iva5`, `iva5`, `base_iva10`, `iva10`, `descuento_global_pct/monto`, `total`, `total_usd`); **tracking** (`token_acceso_publico`, `fecha_envio_email`, `fecha_vista_cliente`, `fecha_respuesta_cliente`, `motivo_rechazo`); **conversión** (`factura_id`, `pedido_id`, `cdc_sifen`, `fecha_conversion`, `usuario_conversion_id`); auditoría + `deleted`. Único `(empresa_id, numero, version)`.
- **`presupuesto_det`** — líneas (cascade): `orden`, `tipo`, `producto_id?`, `descripcion`, `notas_item`, `cantidad`, `unidad_medida`, `precio_unitario`, `descuento_pct`, `iva_categoria`, `subtotal_con_iva`, `monto_iva`.
- **`presupuesto_email`** — log de envíos/tracking.
- **`presupuesto_aprobacion`** — workflow (M41).
- **`presupuesto_historial`** — bitácora de cambios de estado.
- **`presupuesto_config`** — parámetros M36.

Back-relations en: `empresas`, `clientes`, `vendedores_cobradores`, `moneda`, `productos`.

**Pendiente (Fase 5, migración aditiva)**: `presupuesto_cab.solicitud_credito_id`, `solicitud_credito.presupuesto_id` (+ relations de SC-side).  
**Ya implementado**: `pedidos.presupuesto_id` (FK + index).

---

## Máquina de estados del presupuesto

```
DRAFT ──> IN_REVIEW ──> APPROVED ──> SENT ──> VIEWED ──> ACCEPTED ──> CONVERTED
  │           │ (umbral)     │          (email/portal)        │ (OV)
  └── (sin umbral) ──────────┘                                └─> (rechazo) REJECTED
Sistema: SENT/VIEWED con fecha_vigencia < hoy ──> EXPIRED (job diario)
Admin/vendedor: cualquiera (salvo CONVERTED) ──> CANCELLED
```

Cada transición registra fila en `presupuesto_historial`.

---

## Flujo de conversión a ventas (M42)

```
 Presupuesto ACEPTADO
   │  [Convertir] → Orden de Venta
   │  (también desde la pantalla de OV: elegir cliente → seleccionar presupuesto aceptado)
   ▼
 Orden de Venta (`pedidos`)
   • ítems copiados desde presupuesto_det
   • creada y confirmada → BLOQUEADA (sin edición)
   • origen: pedidos.presupuesto_id
   • presupuesto → CONVERTED (+ pedido_id)
   ▼
 Facturación (flujo existente): factura_cab.pedido_id
   • Siempre se factura la OV, nunca el presupuesto directo
```

**Reglas (implementadas hoy)**: sólo líneas con `producto_id` pasan a la OV; mapeo IVA `EXENTO/IVA_5/IVA_10` → `porcentaje_iva` 0/5/10; `precio_unitario` (con IVA incl.) → `precio_negociado`. Al convertir: `presupuesto.estado=CONVERTED`, `fecha_conversion`, `usuario_conversion_id`, token invalidado.  
**Pendiente**: flujo equivalente hacia Solicitud de Crédito (`convert-to-credit`).

---

## Motor de cálculo (M38) — implementado y testeado

`backend/src/presupuestos/presupuestos.calc.ts` (funciones puras). Tests `presupuestos.calc.spec.ts` (6/6 OK):
- `subtotal = precio × cantidad × (1 − desc%/100)`; ej. 100.000×2×0.9 = 180.000.
- IVA10 = 180.000/11 = 16.364; base = 163.636. IVA5 = subtotal/21. EXENTO = 0.
- Descuento global sobre total bruto; `total_usd = total / tipo_cambio`.

---

## API REST (`/api/v1/presupuestos`)

**Implementados (Fase 1–2)**: `GET /`, `POST /`, `GET /:id`, `PUT /:id` (sólo DRAFT), `DELETE /:id` (→CANCELLED), `POST /:id/items`, `PUT /:id/items/:itemId`, `DELETE /:id/items/:itemId`, `POST /:id/reorder-items`, `POST /:id/new-version`, `GET /config`, `PUT /config`, `GET /:id/pdf`.

**Implementados (Fase 3 — M40)**: `POST /:id/send-email`; públicos `GET /pub/presupuesto/:token`, `GET .../:token/pdf`, `POST .../:token/accept`, `POST .../:token/reject`, `GET .../track/open/:token` (pixel GIF 1×1).

**Implementados (Fase 4 — M41)**: `POST /:id/submit-review`, `POST /:id/approve`, `POST /:id/reject-approval`.

**Implementados (Fase 5 — M42, parcial)**: `GET /presupuestos/aceptados?cliente_id=`, `POST /:id/convert-to-order`.

**Implementados (Fase 6 — M43)**: `GET /presupuestos/dashboard?desde=&hasta=` (resumen + embudo + por estado + serie mensual) + cron diario `vencerExpirados` (SENT/VIEWED vencidos → EXPIRED).

**Pendientes**:
- M42: `POST /:id/convert-to-credit` (Solicitud de Crédito — diferido).

---

## Permisos (en `seed.ts` — ya agregados)

Módulo `PRESUPUESTOS`; privilegios `PRES_APROBAR`, `PRES_CONVERTIR` (+ genéricos). **Provisión automática**: `pnpm provision:presupuestos` (`scripts/provision-presupuestos.ts`) crea el módulo + privilegios `PRES_*` si faltan, activa el módulo en toda suscripción Activa/EnGracia y asigna los privilegios a los perfiles admin (`superAdmin`, `admin`, `Administrador`, `Gerente`). Para otros perfiles, asignar desde Configuración › Perfiles.

---

## Frontend (Fase 1–2)

`frontend/src/components/organismos/PresupuestosDesign/`: `PresupuestosTemplate.jsx`, `ListaTab.jsx` (filtros + búsqueda + paginación + acciones), `FormPresupuesto.jsx` (cliente/vendedor/productos/moneda; `Moneda` y `Vigencia` obligatorias en alta), `DetalleTab.jsx`, `PdfPreviewModal.jsx`, `CondicionesPagoTab.jsx`. `pages/Presupuestos.jsx`. `api/presupuestos.service.js` + `tanstack/PresupuestosStack.jsx`. Enums de estado en `components/_standards/enums/`. Registro en `utils/dataEstatica.jsx` + `routers/routes.jsx`. Tour en `tours/definitions/presupuestosTour.js`.

**Seguimiento comercial (M43, UX actual)**: widget en `DashboardTemplateV2.jsx` con KPIs + expansión inline de gráficos (usa `PipelineTab.jsx` con KPIs ocultos en expandido para evitar duplicación).

---

## Plan de implementación

- **Fase 0 (M36)** ✅: esquema + migración + `presupuesto_config` + módulo/privilegios en seed.
- **Fase 1 (M37+M38)** ✅: backend + frontend base (service/tanstack/enums/lista/form/detalle/página/ruta/menú) + tests de cálculo.
- **Fase 2 (M39)** ✅: plantilla `generador-pdf/src/presupuesto/` + `presupuestos-pdf.service.ts` + `GET /:id/pdf` + preview/descarga en detalle.
- **Fase 3 (M40)** ✅: `send-email` (PDF adjunto vía `presupuestos-publico.service`) + controller público `pub/presupuesto/:token` + pixel tracking (`track/open`) + `pages/PresupuestoPublico.jsx` + acción "Enviar" y panel de seguimiento en `DetalleTab`.
- **Fase 4 (M41)** ✅: `submit-review`/`approve`/`reject-approval` + niveles por monto + bandeja + notificaciones.
- **Fase 5 (M42)** 🟡: migración `pedidos.presupuesto_id` ✅; `convert-to-order` (OV CONFIRMADA bloqueada) ✅; `GET /aceptados` ✅; `PresupuestoAceptadoBanner` en `OrdenVentaNueva.jsx` ✅. **Pendiente**: `convert-to-credit` + migración SC-side (`solicitud_credito.presupuesto_id`, `presupuesto_cab.solicitud_credito_id`) — diferido.
- **Fase 6 (M43)** ✅: `GET /dashboard` (`presupuestos-dashboard.service.ts`) + `PipelineTab.jsx` (Recharts: KPIs, embudo, por estado, serie mensual) + cron `presupuestos.cron.ts` (`@Cron('15 6 * * *')` → `vencerExpirados`). **Actualización UX 2026-05-27**: seguimiento movido a Dashboard (widget con expansión inline), sin pestaña `Pipeline` en Presupuestos.
- **Fase 7** ✅: `PermissionGuard` + `@RequirePermission('PRESUPUESTOS', …)` por endpoint (LEER/CREAR/EDITAR/ELIMINAR/IMPRIMIR/PRES_APROBAR/PRES_CONVERTIR) en `presupuestos.controller` y `presupuesto-condiciones-pago.controller` (el portal público queda exento); script `scripts/provision-presupuestos.ts` (`pnpm provision:presupuestos`) que crea el módulo + privilegios `PRES_*`, lo activa en suscripciones activas y asigna privilegios a perfiles admin; `ScreenGuia` + tour ampliado en `PresupuestosTemplate`. Limpieza: `update()` sin el parámetro `user_id` muerto → lint del módulo en verde.

---

## Verificación (end-to-end)

1. **DB / migraciones**:
   - `20260705_presupuesto_sucursal_numeracion` aplicada (campo `presupuesto_cab.sucursal_id` + FK + `tipo_documento` código 500 "Presupuesto").
   - `20260706_presupuesto_estado_facturado` aplicada (`enum presupuesto_estado` incluye `FACTURADO`).
   - `prisma validate` / `prisma generate` OK.
2. **Numeración por sucursal (Presupuestos)**:
   - Configurar numeración activa para tipo documento **Presupuesto** en una sucursal.
   - Crear presupuesto con esa sucursal y verificar formato `PRES-{año}-{est}-{pexp}-{correlativo}`.
   - Crear otro presupuesto en otra sucursal y verificar correlativo independiente por sucursal/punto.
   - Caso sin numeración configurada: verificar fallback al esquema empresa (`prefijo_numero`).
3. **Alta/edición frontend**:
   - En alta: `Sucursal`, `Moneda` y `Vigencia` obligatorias.
   - `sucursal_id` persistido en cabecera y visible en detalle.
4. **Cálculo (motor M38)**:
   - `npx jest src/presupuestos/presupuestos.calc.spec.ts` (casos IVA/descuento/moneda).
   - Revisar coherencia `subtotal`, IVA, descuento global y total en cabecera/detalle.
5. **PDF (M39) con cotización**:
   - Moneda PYG: no mostrar línea de cotización.
   - Moneda distinta de PYG con TC: mostrar `Cotización: 1 <MONEDA> = Gs ...`.
   - Si existe `total_equivalente`, mostrar "Equivalente Gs".
6. **Envío y portal público (M40)**:
   - Envío email: destinatario principal obligatorio (cliente o manual), CC opcional.
   - `generate-link`: copiar enlace sin cerrar diálogo.
   - Portal: responder ACCEPT/REJECT, tracking VIEWED por apertura.
7. **Conversión Presupuesto → OV (M42)**:
   - Convertir presupuesto ACCEPTED con descuento global.
   - Verificar en OV creada:
     - `descuento_global_porcentaje` arrastrado desde `presupuesto.descuento_global_pct`.
     - `tipo_cambio` y `condicion_pago_id` arrastrados.
     - items con `precio_negociado` y `descuento_porcentaje` correctos.
8. **Facturación desde OV en POS (caso reportado)**:
   - Seleccionar OV generada desde presupuesto con descuento global.
   - Verificar precarga en POS:
     - descuento global aplicado en resumen,
     - descuento por ítem respeta `descuento_porcentaje` explícito,
     - sin descuentos implícitos por diferencia `precio_lista` vs `precio_negociado`.
   - Compatibilidad OV históricas: si la OV no tiene `descuento_global_porcentaje`, POS recupera el valor desde `presupuesto_id`.
9. **Cambio de estado post-facturación**:
   - Al facturar completamente la OV originada en presupuesto:
     - OV pasa a `facturada`,
     - presupuesto pasa de `CONVERTED` a `FACTURADO`,
     - se agrega entrada en `presupuesto_historial` con referencia de OV/factura.
10. **Dashboard / métricas (M43)**:
    - Verificar que `FACTURADO` se contemple como convertido en KPI/funnel/montos convertidos.
11. **Crons**:
    - Forzar vencimiento y validar `SENT/VIEWED -> EXPIRED` con historial.
12. **Regresión general**:
    - Conversión y facturación en PYG y USD.
    - Escenarios con descuento global = 0, >0 y descuentos por ítem combinados.
    - Confirmar que no rompe flujo de OV manual (sin presupuesto).

---

## Riesgos y mitigaciones

| Riesgo | Mitigación |
|---|---|
| `pedido_detalle`/`factura_det` exigen `producto_id` | Conversión sólo copia líneas con producto; advertir si hay líneas facturables de texto libre. |
| Lockout por `PermissionGuard` (módulo no provisionado en la suscripción — ni el superAdmin de subsidiaria bypasea la capa 1) | `pnpm provision:presupuestos` activa el módulo en las suscripciones y asigna privilegios a perfiles admin (idempotente). |
| `migrate deploy` re-aplicando la migración | SQL idempotente (`IF NOT EXISTS` / `EXCEPTION duplicate_object`). |
| Doble vía de conversión genera OV duplicadas | Al convertir, presupuesto→CONVERTED (estado final) bloquea reconversión. |
