Desarrollo

FarmaOrden: control operativo para cadenas de boticas

SaaS multi-tenant para farmacias y boticas del Perú, construido alrededor de una idea incómoda: el dueño de 20 locales no puede estar en todos, así que cada sol y cada caja de medicamento tienen que quedar atribuidos a una persona.

Problema

Los sistemas para boticas del Perú compiten por ayudar a vender. Catálogo, caja, un POS rápido. Y están bien.

Pero el problema del dueño de una cadena no es vender: es no estar. Tiene cinco locales abiertos veinticuatro horas con dos o tres turnos cada uno, y no hay forma de que vea los quince. La pregunta que le quita el sueño no es cuánto vendió, sino cuánto se está perdiendo y quién.

Esa diferencia define el producto entero. FarmaOrden no es un sistema de gestión farmacéutica más: es un sistema de control operativo. Los competidores ayudan a vender; este ayuda a saber cuánto falta.

Contexto

El cliente cero es una cadena de Cajamarca: cinco sedes, dos RUC distintos, operación de veinticuatro horas y dos o tres turnos por sede.

Ese perfil obliga a cosas que un sistema genérico no tiene. El rubro farmacéutico peruano trae reglas que un desarrollador sin contexto local implementaría mal: el fraccionamiento —vender tres pastillas de una caja de cien—, las operaciones de S/ 5 o menos que van en boleta consolidada, el canje de boleta por factura que no debe tocar la caja, la cuarentena de las devoluciones, y las compras que entran con y sin sustento tributario.

Arquitectura

POS (PWA, IndexedDB) ──cobra e imprime sin servidor──┐
                                                     │ cola de salida
Back-office (Vue + Inertia) ── Laravel 13 ───────────┤ idempotente
                                    │                 │
                     BillingEngine ─┴─ NexoCPE  ◄─────┘
                     IdentityService ─ NexoID
                     MessagingService ─ NexoConecta
PiezaDecisión
POSPWA independiente con IndexedDB. El servidor no participa en la venta: busca, cobra e imprime sin él. Presupuesto de menos de 150 KB de JavaScript
Kardexstock_movements y la bitácora son append-only, con un trigger de PostgreSQL que lanza excepción ante un UPDATE o un DELETE. Una corrección genera un movimiento nuevo; jamás edita el anterior
StockSiempre en unidades base. Cada producto tiene su unidad y cada presentación un factor: vender una caja de cien descuenta cien
Dineronumeric(14,4) para precios unitarios, porque una pastilla puede costar S/ 0.0345. Nunca un float
MódulosRegistro y activación por tenant, con eventos de dominio y slots de interfaz. Lo específico de un cliente es un módulo, nunca un if en el núcleo
Servicios NexoLa facturación, el WhatsApp y la consulta de RUC salen del repositorio y pasan a tres servicios propios, consumidos solo por interfaz

Qué resuelve

  • Inventario con lotes y vencimiento, FEFO, cuarentena, mermas y stock mínimo que sale del consumo real de cada sede en lugar de fijarse a mano sobre 2,500 productos.
  • Caja y turnos con arqueo ciego: lo esperado se calcula y se guarda solo dentro de la misma transacción en que se registra lo declarado. Ningún endpoint lo expone antes, y el historial de faltantes queda por persona.
  • Facturación electrónica —boleta, factura, notas, resumen diario, boleta consolidada de hasta S/ 5 y el canje de boleta por factura—, decidiendo qué documento corresponde y administrando los correlativos. Firmar y enviar son de NexoCPE.
  • Cumplimiento sanitario: libro de recetas, balance mensual de psicotrópicos con su cierre firmado, retiro de lote que dice dónde está la mercadería y a quién se le vendió, oferta obligatoria de genérico en el POS y reporte de precios al Observatorio de DIGEMID. Solo lo controlado bloquea una venta; lo demás avisa y deja rastro.
  • Anti-fraude: dieciocho reglas, score de riesgo y expediente de incidente.
  • Traslados entre sedes y almacén central con reposición, donde el camión en la carretera es un almacén de tránsito y no un estado, para que la suma del kardex se conserve mientras la mercadería viaja.

Decisiones clave

  • No existe un desplegable para elegir vendedor. En ninguna pantalla. El vendedor es el usuario autenticado del turno, y punto. Es el agujero de control que define al producto frente a la competencia: si se puede elegir quién vendió, no se puede atribuir nada.
  • El servidor no participa en la venta. El POS cobra e imprime desde IndexedDB y la sincronización va en cola después, con una clave de idempotencia generada en el cliente. Reenviar una venta nunca la duplica.
  • Nada de comisiones a médicos. El Código de Ética del Colegio Médico del Perú lo prohíbe expresamente en su artículo 22. Se construye trazabilidad de recetas y prescriptores, sin cálculo ni pago de comisión.
  • Nada de «precios regulados DIGEMID». En Perú los precios de medicamentos son libres desde el D. Leg. 757, de 1991; el Observatorio publica precios referenciales. Se construye un comparador de precio de mercado, nunca un tope.
  • No se lee el buzón de SUNAT. Exigiría custodiar las claves SOL de los clientes y hacer scraping. Se reemplaza por calendario tributario y estado de los comprobantes propios.

El día es el de la botica, no el del servidor. Lo destapó correr la suite a las 00:47 UTC, cuando en Lima aún era el día anterior. Una columna timestamptz comparada con whereDate usa el día del servidor, así que las cinco últimas horas del día peruano —de 19:00 a 23:59, la franja de más movimiento— caían en el día siguiente. El resumen diario no encontraba esas boletas, y como la operación es idempotente por fecha, el día se cerraba sin ellas dentro. En el canje de boleta por factura el error iba en la dirección peligrosa: al fechar un día más tarde, el octavo día contaba siete y el canje se hacía. Ahora toda fecha de calendario sale del contexto del tenant, y una prueba saca del esquema qué columnas son instantes y falla si alguna aparece dentro de un whereDate.

Cómo se construye

Veinte sesiones de construcción y 142 commits. Laravel 13 y PHP 8.4, PostgreSQL 16, Redis con Horizon, back-office en Vue 3 con Inertia y el POS como PWA aparte.

Catorce reglas no negociables escritas en la memoria del proyecto, varias con una prueba que las vigila: hay un test que falla si un modelo de negocio se queda sin el trait de aislamiento, y otro que falla si aparece un if ($tenant->id === N) en el núcleo. La suite de contrato contra el ambiente de pruebas de SUNAT existe y se lanza a mano —no corre en integración continua a propósito, porque el ambiente beta se cae y un CI que falla por causas ajenas enseña al equipo a ignorar el CI.

Qué queda por delante

QA visual y el piloto en una farmacia real. Del plan Cadena solo falta el traslado entre RUC distintos, pendiente de resolver la regla de precio de transferencia.

Aprendizajes

Un módulo del marketplace no se construye por su valor comercial, sino porque obliga a que la arquitectura de extensión funcione. Al construir el primero salieron cuatro cosas a medias en el núcleo que llevaban trece sesiones pareciendo listas: no existían las rutas de los módulos, los slots de interfaz se validaban sin pintarse, activar un módulo no le daba sus permisos a nadie, y el escaneo de aislamiento no cubría sus modelos.

Lo que separa un respaldo de una esperanza es la verificación. El módulo de copias restaura en una base desechable y comprueba cuatro cosas, y la cuarta es la que lo justifica: que el kardex restaurado siga rechazando un UPDATE. Un volcado que perdiera los triggers restauraría una base con los mismos datos, los mismos importes y los mismos índices, y con un kardex editable. Nada en pantalla lo diría. De paso apareció un defecto dentro del propio módulo: los discos de Laravel vienen con throw => false, así que contra unas credenciales malas el comando informaba «respaldo creado» cada noche mientras al otro lado no llegaba nada.

Y el monitor no puede preguntar «¿responde el servidor?». El POS cobra e imprime sin él, así que la botica puede estar vendiendo doce horas con el backend caído y nadie enterarse. La alerta menos obvia es el latido del programador de tareas: si ese contenedor muere, el cierre fiscal, el respaldo y la reconciliación del kardex dejan de pasar sin un solo error en ningún log.

Proyectos relacionados

IntegracionesEn beta de SUNAT

NexoCPE: facturación electrónica SUNAT como servicio

Servicio multi-tenant que emite comprobantes electrónicos ante SUNAT por API —facturas, boletas, notas y guías—, con cada emisor firmando con su propio certificado y el punto de venta sin esperar nunca a SUNAT.

Núcleo emisor completo y aceptado por SUNAT en beta; piloto en curso con Podoplus

PHP 8.3Laravel 12PostgreSQL 16Redis 7Horizon
Ver caso
DesarrolloEn producción

Diseña Estudio Dental: la clínica entera en un sistema

Sistema integral para una clínica odontológica: historia clínica con odontograma FDI, agenda, laboratorio, CRM, caja multimoneda y facturación electrónica SUNAT, pensado para usarse en el iPad del consultorio.

Historia clínica, odontograma, agenda, caja y facturación electrónica en un mismo sistema

Laravel 11PHP 8.4Vue 3PiniaElement Plus
Ver caso