Quiero EmitirTratamientoPropuestaAction.
Antes de escribir una sola línea de código, quiero detenerme porque creo que llegamos al punto donde se define la arquitectura del sistema. Y en este punto me falta información importante. Si implemento la acción hoy, voy a tener que inventar reglas de negocio, y prefiero que esas reglas las definamos nosotros.
La acción EmitirTratamientoPropuestaAction es, en mi opinión, el caso de uso más importante del módulo. Va a modificar simultáneamente:
- el odontograma,
-
la tabla
tratamiento_propuestas, -
la tabla
tratamiento_propuesta_items, - el contador de números de propuesta.
Todo eso debe ocurrir dentro de una única transacción.
Por eso necesito que me respondas estas cuestiones antes de implementarla.
- ¿Qué parámetro recibe la Action?
¿Cuál de estas opciones preferís?
-
A.
Paciente $paciente -
B.
Odontograma $odontograma -
C.
int $pacienteId - D. otro (indicá cuál)
- ¿Qué devuelve?
-
A.
TratamientoPropuesta - B. DTO propio
-
C.
void
- Si no existe odontograma para el paciente
- excepción
- crear automáticamente
- otro
-
¿Qué sucede si no hay ningún tratamiento con
incluirEnPropuesta = true?
- excepción
- crear propuesta vacía
- otro
- ¿Cómo obtiene el precio?
Mi interpretación es:
Prestacion::find($codigo)->precio_base
¿Es correcto?
- ¿Cómo calcula subtotal?
Mi interpretación:
subtotal = Σ precio_base
porque inicialmente
cantidad = 1
y
descuento = 0
¿Es correcto?
- ¿La Action crea la propuesta ya en estado EMITIDA?
Entiendo que sí.
- ¿Qué hace exactamente con el JSON?
Mi interpretación es:
Para cada tratamiento emitido:
incluirEnPropuesta = false
y no toca ningún otro dato.
- ¿Cómo obtiene el siguiente número de propuesta?
Acá hay varias posibilidades.
- tabla de secuencias
- MAX(numero)+1 con bloqueo
- otro mecanismo
Todavía no lo definimos.
- Concurrencia
Si dos odontólogos emiten simultáneamente una propuesta para el mismo paciente, ¿qué comportamiento querés?
Mi sugerencia es que la Action tome un bloqueo pesimista (lockForUpdate) sobre el registro del odontograma y genere el número dentro de la misma transacción. De esa manera nunca habrá dos propuestas emitidas a partir de la misma versión del odontograma.
Una vez que respondamos estas diez cuestiones, creo que podremos implementar una EmitirTratamientoPropuestaAction prácticamente definitiva, sin tener que refactorizarla más adelante.
Mis Respuestas:
1. A.
2. A.
3. crear automáticamente.
4. crear propuesta vacía.
5. Tu interpretación es correcta.
6. Tu interpretación es correcta.
7. Sí.
8. Tu interpretacion es correcta pero incompleta. Además incrementa la propiedad revision.
9. MAX(numero)+1 con bloqueo.
10. Para decidir necesito más información. Quiero que me hagas una comparación entre controlar la concurrencia mediante la propiedad revision del JSON y ese lockForUpdate del que hablás.
Esta es una decisión arquitectónica importante. Y, en realidad, revision y lockForUpdate() no compiten entre sí: resuelven problemas distintos. Mi recomendación es usar ambos.
| Aspecto | revision (optimistic locking) | lockForUpdate() (pessimistic locking) |
|---|---|---|
| ¿Qué protege? | Detecta que alguien modificó el odontograma desde que fue leído. | Evita que dos transacciones lo modifiquen simultáneamente. |
| ¿Cuándo actúa? | Al intentar guardar. | Desde que comienza la transacción. |
| ¿Qué ocurre si dos usuarios editan a la vez? | Uno guarda y el otro recibe un conflicto (409). | El segundo espera a que termine el primero. |
| ¿Pierde trabajo un usuario? | No, porque se detecta el conflicto. | Puede perderlo si no existe además una verificación de revision. |
| ¿Sirve para APIs? | Excelente. | No, porque el cliente HTTP no mantiene bloqueos entre peticiones. |
| ¿Sirve dentro de una transacción? | Sí. | Sí. |
¿Qué pasa en tu sistema?
Supongamos dos odontólogos.
10:00
A abre el odontograma (revision = 17)
10:01
B abre el mismo odontograma (revision = 17)
Ambos trabajan durante varios minutos.
Si sólo existe lockForUpdate()
A las 10:10 A emite la propuesta.
La Action toma el bloqueo.
Actualiza:
propuesta
items
JSON
revision = 18
Libera el bloqueo.
A las 10:12 B intenta emitir.
Obtiene el bloqueo.
Pero su JSON está basado en la revisión 17, que ya no es válida.
Como no existe control por revision, B sobrescribe el trabajo de A utilizando información vieja.
Es decir, el bloqueo no detecta que el usuario trabajó sobre una copia obsoleta.
Si sólo existe revision
A guarda.
La revisión pasa a 18.
B intenta guardar con revisión 17.
La actualización falla.
Perfecto.
Pero...
¿qué pasa con el número de propuesta?
¿y si dos transacciones calculan simultáneamente
MAX(numero)+1
?
Las dos podrían obtener
248
y una terminaría chocando contra el UNIQUE.
Es solucionable, pero aparecen carreras innecesarias.
Si usamos ambos
Este sería el flujo que yo implementaría.
BEGIN
lockForUpdate(odontograma)
comprobar revision
calcular MAX(numero)+1
crear propuesta
crear items
modificar JSON
revision++
COMMIT
¿Qué aporta cada mecanismo?
lockForUpdate()
garantiza que solamente una transacción modifica ese odontograma a la vez;
protege el cálculo de
MAX(numero)+1;evita estados intermedios inconsistentes.
revision
detecta que el odontólogo trabajó sobre una copia vieja;
permite devolver un 409 Conflict con un mensaje claro;
protege también las modificaciones que no pasan por esta Action (por ejemplo, el editor del odontograma).
¿Cuál elegiría?
Yo no elegiría uno.
Los usaría para cosas distintas.
revisiones la regla de negocio del sistema: "nadie puede guardar sobre una versión antigua del odontograma".lockForUpdate()es un detalle técnico de implementación: "mientras se emite una propuesta, nadie puede modificar este odontograma dentro de otra transacción".
En otras palabras, revision protege al usuario y lockForUpdate() protege a la base de datos.
De hecho, si tuviera que eliminar uno de los dos, eliminaría antes lockForUpdate() que revision, porque el control de revisiones es parte esencial de tu modelo de negocio y ya lo venimos diseñando desde el principio. Pero mientras trabajemos con transacciones que crean propuestas, generan números secuenciales y modifican el odontograma, considero que ambos mecanismos se complementan muy bien.
Entendido. Entonces voy a responder la pregunta número 10: quiero que el comportamiento sea que la Action tome un bloqueo pesimista (lockForUpdate) sobre el registro del odontograma y genere el número dentro de la transacción. En otras palabras, quiero el comportamiento tal como lo has descripto vos.
Perfecto. Con esa respuesta, considero que ya está completamente especificado el caso de uso. De hecho, me gustaría dejar documentado el contrato funcional antes de escribir el código, porque a partir de aquí ya no deberíamos tomar decisiones de diseño, sino solamente implementarlas.
Contrato de EmitirTratamientoPropuestaAction
Entrada
public function execute(Paciente $paciente): TratamientoPropuesta
Precondiciones
Si el paciente no tiene odontograma, se crea automáticamente.
No importa si no hay tratamientos seleccionados para propuesta.
La propuesta puede quedar sin ítems.
Dentro de una única transacción
Obtener el odontograma del paciente.
Tomar
lockForUpdate()
sobre ese registro.
Verificar la
revision.Calcular
MAX(numero) + 1
también protegido por el bloqueo de la transacción.
Leer el JSON del odontograma.
Filtrar todos los tratamientos cuyo
incluirEnPropuesta == true
tanto de
marcas_dentales
soluciones
Crear
tratamiento_propuestas
con estado
EMITIDA
Para cada tratamiento seleccionado
buscar la prestación
copiar el precio vigente del catálogo
crear un
tratamiento_propuesta_itemdescuento = 0
cantidad = 1
importe = precio_unitario
Calcular
subtotal
descuento_total
total
Modificar el JSON:
para todos los tratamientos emitidos
incluirEnPropuesta = false
Incrementar
revision++
Guardar el odontograma.
Commit.
Retornar el modelo recién creado.
Me gustaría proponer una única mejora
Hay una decisión que todavía no habíamos discutido y creo que vale muchísimo la pena.
En lugar de:
public function execute(Paciente $paciente)
yo escribiría:
public function __invoke(Paciente $paciente): TratamientoPropuesta
De esa manera la Action pasa a ser invocable:
$propuesta = app(EmitirTratamientoPropuestaAction::class)($paciente);
o
$propuesta = new EmitirTratamientoPropuestaAction();
$propuesta = $propuesta($paciente);
En Laravel moderno es bastante habitual que las Actions sean clases invocables (__invoke) porque representan un único caso de uso y además se integran muy bien con el contenedor de dependencias.
No es una diferencia enorme respecto de execute(), pero creo que expresa mejor la intención: la clase es la operación.
Con esto, en mi opinión, la arquitectura del módulo quedó completamente definida. A partir del próximo paso ya no estaremos diseñando: estaremos implementando exactamente el comportamiento que acabamos de especificar.
Estoy de acuerdo con el contrato funcional que describís y considero que con la información que hay ya se puede empezar a codificar. También estoy de acuerdo con que la Action sea invocable. Quiero EmitirTratamientoPropuestaAction o que me digas qué otra cosa falta decidir antes de poder hacerlo.