# Integración de facturación con POSTouch — Diseño

Fecha: 2026-09-07
Tiquete relacionado: Freshdesk #24140
Tenant de prueba: autocentro (mbinvautocentrorodriguez, 132.226.40.48:3310)

## 1. Contexto y objetivo

Hoy el sistema (tll) cubre cotización y aprobación de OTs, pero la facturación real
ocurre por completo dentro de POSTouch (ERP/POS legacy, PHP Symfony 2.3, en
`C:\laragon\www\POSTouch`), sistema que **no podemos modificar**. El objetivo de este
proyecto es integrarnos al flujo de facturación de POSTouch tal como ya funciona,
sin tocar su código ni sus tablas transaccionales por escritura directa, para que:

1. En cuanto una OT termina el trabajo (`TERMINADA`), la venta caiga **automáticamente
   y completa** en POSTouch, sin que el cajero tenga que volver a teclear productos.
2. El sistema detecte cuándo esa venta ya fue facturada y **pagada** (incluyendo que la
   factura electrónica quedó realmente firmada ante la SAT).
3. Solo entonces se habilite la entrega del vehículo (estado `ENTREGADA`).
4. Todo esto sea un parámetro activable por tenant — hay talleres que facturan por
   este medio y otros que no.
5. Los productos "de una sola vez" (creados desde el taller, no dados de alta en el
   catálogo ERP `maeplu`) también puedan facturarse en POSTouch sin fricción.
6. La bitácora de eventos de la OT tenga un solo lugar consolidado de consulta,
   protegido por permiso.

## 2. Hallazgos clave sobre POSTouch (solo lectura, no se modifica nada de ese repo)

- `Hmaetra`/`Hdettra`/`Hdetmed` (encabezado, líneas, formas de pago de una factura) se
  relacionan por `tienda+caja+tipo+serie+transac`, sin FK real en BD.
- `Hdettra.plu` se valida en código (no en BD) contra `maeplu` — un PLU que no existe
  ahí no se puede facturar.
- Existe un endpoint HTTP real y ya usado en producción por la integración "Webifica":
  `POST /create_ordenes_edngt` (`DomicilioController.php:395`), que arma un pedido con
  cliente + productos + formas de pago y llama internamente a
  `Facturacion::facturar()`. Es el mecanismo que vamos a reutilizar — evita reimplementar
  correlativos, inventario, impresión y FEL a mano vía SQL directo (explícitamente
  desaconsejado: alto riesgo de romper atomicidad/trazabilidad).
- El endpoint exige que las formas de pago cuadren con el total exacto — no se puede
  enviar "sin pagar". Existe un código de forma de pago **99 = por_liquidar**
  (`FormaPago.php:1000`), diseñado para "vender ahora, cobrar después": la factura se
  crea completa de inmediato, y el cajero solo "liquida" el medio real cuando el
  cliente paga, sin recargar productos.
- El campo `Hmaetra.pedido` **no sirve** como referencia manual: en venta de mostrador
  siempre vale 0, es un correlativo interno exclusivo del flujo de domicilios
  (`Documento.php:256`, `Domicilio::datosMaestro()`), nunca un input visible al cajero.
- `Hmaetra.observaciones` (varchar 255, nullable) sí es un campo libre visible/editable
  en la pantalla normal de venta (`dialogosTransaccion.html.twig`), útil como canal de
  referencia manual para el flujo de fallback (ver §6).
- La certificación FEL (firma electrónica ante la SAT) ocurre **síncronamente** dentro
  de la misma llamada que crea la factura (`Domicilio.php:1013-1021` →
  `FacturaElectronica.php`), pero si el certificador falla, **no hay rollback**: la
  factura queda creada sin firma válida (`hmaetra_fel.es_contingencia = 'S'`), y
  POSTouch reintenta la firma en un proceso propio posterior
  (`revisionAutomaticaFacturasPendientesDeFirma()`). La respuesta HTTP de
  `create_ordenes_edngt` NO refleja este fallo.
- Validación de cuadre de línea (`Transaccion.php:1955-1977`): tolerancia de solo
  Q0.005 por línea contra `cantidad × precio − descuento` recalculado por POSTouch. Si
  no cuadra, se rechaza **antes** de crear cualquier factura (falla segura, sin
  riesgo de documento fiscal a medias).
- `ParametrosGlobales.getPrecioDecimales()` define los decimales de redondeo de esa
  tienda — nuestros cálculos de total/IVA deben usar exactamente esa configuración
  para no chocar con la validación anterior.
- Riesgo menor detectado: si un producto no está marcado como servicio y su `costo` es
  0, la tienda puede bloquear la venta "por debajo de costo"
  (`Transaccion.php:1988`). Los PLUs de una sola vez deben poblar `costo` de forma
  consistente.

## 3. Parámetro por tenant

Nueva columna en `tenant_config` (control-plane, ver
`backend/src/control-plane/entities/tenant-config.entity.ts`):

- `facturacionPosHabilitada: boolean` (default `false`).
- Credenciales/config del endpoint POSTouch destino: URL base, `_username`/`_password`
  de servicio, identificador de "computadora", cifrados igual que
  `whatsappAccessTokenEncrypted` (mismo `CredentialsCryptoService` ya usado en esa
  entidad).
- `precioDecimales` de la tienda destino (leído/cacheado desde
  `ParametrosGlobales` vía consulta de solo lectura) para cuadrar el redondeo.

Si el flag está apagado, todo el flujo nuevo permanece inactivo y el sistema se
comporta exactamente como hoy.

## 4. Productos "de una sola vez" → espejo en `maeplu`

Cuando una cotización aprobada de un tenant con `facturacionPosHabilitada` contiene un
producto local (prefijo `TLLP-`, tabla `taller_productos`) que no exista aún en
`maeplu`, al llegar la OT a `TERMINADA` (ver §5) se inserta automáticamente una fila
espejo en `maeplu`:

- **INSERT-only**: nunca se hace UPDATE ni DELETE sobre `maeplu` — se mantiene toda la
  protección de solo lectura ya existente (`ReadonlyRepository`, `insert:false`/
  `update:false` en `Producto` entity, tests de guardia). Esta es la única excepción
  puntual y deliberada a esa regla, documentada explícitamente en el código.
- Mismo PLU (`TLLP-XXXXXXXX`) que ya usa el producto local — así el PLU es
  consistente en ambos sistemas.
- Se poblan `desclarga`, `precio`, `iva`, `pagaiva`, `es_servicio`, `usainventario` y
  `costo` desde el producto local; nunca dejar `costo = 0` en un producto no-servicio
  (usar `precio` como fallback si no hay costo definido) para evitar el bloqueo de
  "venta por debajo de costo".
- Si el PLU ya existe en `maeplu` (por ejemplo, alguien ya lo facturó manualmente
  antes), se omite el insert — no es un error.

## 5. Disparador del envío automático

El envío ocurre en `OtsService.changeEstado()` (`backend/src/ots/ots.service.ts`), en
la transición a `EstadoOT.TERMINADA`, si el tenant tiene `facturacionPosHabilitada`:

1. Se congela el total de la cotización aprobada (ya no se puede editar una vez
   `TERMINADA` — esto es una garantía que ya da el código actual, no algo que haya que
   construir: `addLinea`/`updateLinea`/`deleteLinea` bloquean edición en `TERMINADA`/
   `ENTREGADA`).
2. Se asegura el espejo en `maeplu` para cualquier producto de una sola vez (§4).
3. Se arma el payload de `create_ordenes_edngt` con cliente, líneas (cantidad, precio,
   monto redondeado a `precioDecimales` de la tienda) y una única forma de pago
   `media=99` (por_liquidar) por el total exacto.
4. Se llama al endpoint. Si responde éxito, la OT pasa al nuevo estado `FACTURADA`
   (§7) y se guarda la referencia de la venta creada (tienda/caja/tipo/serie/transac
   devueltos en la respuesta) en una nueva tabla local de seguimiento (§6).
5. Si falla (rechazo de línea, PLU faltante, error de red, etc.), la OT permanece en
   `TERMINADA` con un error visible y un botón de reintento manual — no se inventa un
   estado de error aparte.

No se modifica una venta ya creada en POSTouch bajo ningún escenario: como la
cotización se congela exactamente en el momento en que se dispara el envío, nunca
existe una edición posterior que reconciliar. No hay lógica de "borrar y recrear".

## 6. Seguimiento y detección de pago

Nueva tabla local (tenant DB), ej. `taller_ot_facturacion`:

- `otId`, `tienda`, `caja`, `tipo`, `serie`, `transac` (referencia a la venta creada en
  POSTouch), `estadoFel` (`pendiente`/`firmada`/`contingencia`), `fechaCreacion`,
  `fechaLiquidacion`.

Un job periódico (mismo patrón `ReadonlyRepository` ya usado para `maeplu`, **solo
SELECT** contra `hmaetra`/`hdettra`/`hdetmed` y la tabla de FEL de POSTouch) revisa,
para cada OT en estado `FACTURADA`:

- **Pagada**: `Hmaetra` sin `anulacion`, y `SUM(hdetmed.monto) WHERE media <> 99` cubre
  el total (es decir, ya se liquidó con un medio real).
- **Firmada (FEL)**: `face_firma`/`hmaetra_fel.es_contingencia` indica firma válida, no
  contingencia pendiente.

Solo cuando ambas condiciones se cumplen se considera la orden lista para entregar. Si
una OT queda en contingencia FEL más allá de un umbral configurable (ej. 30 minutos),
se marca una alerta visible para recepción — es un problema que debe resolverse dentro
de POSTouch, no algo que este sistema pueda o deba corregir automáticamente.

Como fallback manual (ej. si el cajero necesita facturar algo fuera de este flujo
automático, o corregir una venta rechazada manualmente en POSTouch), se mantiene como
mecanismo secundario de referencia el campo `Hmaetra.observaciones`, donde el cajero
puede anotar el número de OT; el job de detección también intenta este match por texto
como respaldo.

## 7. Nuevo estado en la máquina de estados de la OT

`backend/src/ots/entities/orden-trabajo.entity.ts` (enum `EstadoOT`) y
`ALLOWED_ESTADO_TRANSITIONS` en `ots.service.ts`:

```
BORRADOR → COTIZADA → APROBADA → EN_PROCESO → TERMINADA → FACTURADA → ENTREGADA
```

- `TERMINADA → FACTURADA`: automático, disparado por el envío exitoso a POSTouch
  (§5). Si el tenant no tiene `facturacionPosHabilitada`, esta transición no aplica y
  el flujo sigue siendo `TERMINADA → ENTREGADA` exactamente como hoy.
- `FACTURADA → ENTREGADA`: solo permitido cuando el job de detección (§6) confirma
  pagada + firmada, **y** se cumplen los requisitos ya existentes de
  `ensureReadyForDelivery()` (firmas de cliente/recepción). Sigue siendo una acción
  manual del personal (captura de firma física), no automática.

## 8. Bitácora de eventos — consolidación y permiso

El permiso `logs` ya existe en `PermissionGuard` (solo `admin`/`super_admin` en
`read` por defecto) y el frontend ya lo respeta (`canViewLogs` en
`ots/[id]/page.tsx`). No hace falta tocar el modelo de permisos.

Lo que sí cambia es la UX: hoy la misma consulta de eventos
(`GET /ots/:id/eventos`) está incrustada en dos pantallas distintas (detalle de OT y
cotización), cada una con su propio filtro. Se consolida en una sola vista/modal
("Bitácora") accesible desde un botón dedicado en el detalle de la OT, que muestra
todos los eventos (incluyendo los nuevos: envío a POSTouch, cambio a FACTURADA,
liquidación detectada, alertas de FEL) en un solo lugar. Se eliminan las secciones
inline duplicadas.

## 9. Manejo de errores (resumen)

| Fallo | Dónde se detecta | Efecto |
|---|---|---|
| PLU de una sola vez no se pudo espejar en `maeplu` | Antes de llamar al endpoint | OT queda en `TERMINADA`, error visible, reintento manual |
| Descuadre de línea (redondeo) | Respuesta de error de `create_ordenes_edngt` | OT queda en `TERMINADA`, error visible, reintento manual (revisar `precioDecimales` del tenant) |
| POSTouch no responde / timeout | Llamada HTTP | OT queda en `TERMINADA`, reintento manual o automático con backoff |
| Certificación FEL en contingencia | Job de detección (§6) | OT permanece en `FACTURADA`, alerta a recepción tras el umbral configurado |
| Venta creada pero forma de pago 99 nunca liquidada | Job de detección (§6) | OT permanece en `FACTURADA` indefinidamente — es esperado (el cliente no ha pagado) |

## 10. Fuera de alcance / no se hace

- No se escribe SQL directo en `Pmaetra`/`Pdettra`/`Pdetmed`/`pedidos_bitacora` (riesgo
  de romper correlativos, inventario, FEL).
- No se usa el mecanismo de "venta aguantada" (`Amaetra`/`/aguantar`) — requeriría
  invocar una acción interna no diseñada para uso externo, sin la garantía de
  producción que sí tiene `create_ordenes_edngt`.
- No se toca ni reimplementa la lógica de firma/certificación FEL — la hace POSTouch
  exactamente igual que en cualquier venta normal.
- No se permite ninguna edición de la cotización una vez que la OT llega a
  `TERMINADA` (ya es una regla existente, se conserva).

## 11. Validaciones pendientes antes de implementar (spike de una sesión en sandbox)

Contra el tenant de prueba `autocentro` (132.226.40.48:3310, mbinvautocentrorodriguez):

1. Confirmar que `create_ordenes_edngt` acepta y procesa correctamente `media=99` en
   `formasPago` de principio a fin (creación real de `Hmaetra`+`Hdettra`+`Hdetmed`).
2. Confirmar el valor real de `ParametrosGlobales.precioDecimales` en esa tienda.
3. Confirmar el nombre exacto de la tabla/columna de estado FEL accesible por lectura
   (`hmaetra_fel.es_contingencia` u otra, según el esquema real de esa base).
4. Confirmar credenciales de servicio (`_username`/`_password`) a usar para las
   llamadas automáticas, y que ese usuario tiene permisos de facturación en esa
   tienda/caja.
