PlantillaPresupuestoController.java
package com.licensis.notaire.api;
import com.licensis.notaire.config.JpaControllerProvider;
import com.licensis.notaire.jpa.PlantillaPresupuestoJpaController;
import com.licensis.notaire.jpa.exceptions.NonexistentEntityException;
import com.licensis.notaire.jpa.exceptions.PreexistingEntityException;
import com.licensis.notaire.negocio.PlantillaPresupuesto;
import com.licensis.notaire.negocio.PlantillaPresupuestoPK;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.persistence.OptimisticLockException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* REST controller for PlantillaPresupuesto (Budget Templates).
*
* Fix for issue #340: Added proper OptimisticLockException handling to return
* HTTP 409 Conflict instead of 500 on concurrent modification. Also added
* version-aware update that fetches current entity state before merging to
* prevent stale-data overwrites.
*
* Covers use cases: CU39, CU49, CU55 (Plantilla de presupuesto management).
*/
@RestController
@RequestMapping("/api/v1/plantilla-presupuestos")
@Tag(name = "PlantillaPresupuesto", description = "API para gestionar plantillas de presupuesto")
public class PlantillaPresupuestoController {
private static final Logger LOG = Logger.getLogger(PlantillaPresupuestoController.class.getName());
private PlantillaPresupuestoJpaController getJpaController() {
return new PlantillaPresupuestoJpaController(null, JpaControllerProvider.getEntityManagerFactory());
}
@GetMapping
@Operation(summary = "Obtener todas las plantillas de presupuesto")
@Transactional(readOnly = true)
public ResponseEntity<List<PlantillaPresupuesto>> getAll() {
try {
return ResponseEntity.ok(getJpaController().findPlantillaPresupuestoEntities());
} catch (Exception e) {
LOG.log(Level.SEVERE, "Error al obtener plantillas de presupuesto", e);
return ResponseEntity.internalServerError().build();
}
}
@GetMapping("/tipo-tramite/{idTipoTramite}")
@Operation(summary = "Obtener plantillas de presupuesto por tipo de tramite")
@Transactional(readOnly = true)
public ResponseEntity<List<PlantillaPresupuesto>> getByTipoTramite(@PathVariable Integer idTipoTramite) {
try {
return ResponseEntity.ok(getJpaController().findPlantillasDePresupuesto(idTipoTramite));
} catch (Exception e) {
LOG.log(Level.SEVERE, "Error al obtener plantillas por tipo de tramite " + idTipoTramite, e);
return ResponseEntity.internalServerError().build();
}
}
@ApiResponses({
@ApiResponse(responseCode = "201", description = "Creado"),
@ApiResponse(responseCode = "400", description = "Solicitud inválida"),
@ApiResponse(responseCode = "409", description = "Conflicto")
})
@PostMapping
@Operation(summary = "Crear nueva plantilla de presupuesto")
public ResponseEntity<?> create(@RequestBody PlantillaPresupuesto entity) {
try {
getJpaController().create(entity);
PlantillaPresupuestoPK pk = entity.getPlantillaPresupuestoPK();
return ResponseEntity.status(HttpStatus.CREATED)
.body(Map.of("fkIdTipoTramite", pk.getFkIdTipoTramite(), "fkIdConcepto", pk.getFkIdConcepto()));
} catch (PreexistingEntityException e) {
LOG.log(Level.WARNING, "Plantilla de presupuesto ya existe", e);
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "La plantilla de presupuesto ya existe para ese tipo de trámite y concepto"));
} catch (Exception e) {
LOG.log(Level.SEVERE, "Error al crear plantilla de presupuesto", e);
return ResponseEntity.internalServerError()
.body(Map.of("error", "Error interno al crear la plantilla"));
}
}
/**
* Update a PlantillaPresupuesto.
*
* To prevent OptimisticLockException (issue #340), we first retrieve the current
* persisted entity and copy the client's changes onto it. This ensures the
* @Version field is always current, avoiding stale-data conflicts when the
* caller does not send the version field.
*/
@PutMapping("/tipo-tramite/{idTipoTramite}/concepto/{idConcepto}")
@Operation(summary = "Actualizar plantilla de presupuesto")
public ResponseEntity<?> update(
@PathVariable Integer idTipoTramite,
@PathVariable Integer idConcepto,
@RequestBody PlantillaPresupuesto entity
) {
try {
PlantillaPresupuestoPK pk = new PlantillaPresupuestoPK(idTipoTramite, idConcepto);
// Fetch current state to carry the correct @Version value
PlantillaPresupuesto current = getJpaController().findPlantillaPresupuesto(pk);
if (current == null) {
return ResponseEntity.notFound().build();
}
// Apply incoming changes (only mutable fields — PK and version come from DB)
entity.setPlantillaPresupuestoPK(pk);
if (entity.getObservaciones() != null) {
current.setObservaciones(entity.getObservaciones());
}
// Propagate related entities if provided
if (entity.getConcepto() != null) {
current.setConcepto(entity.getConcepto());
}
if (entity.getTipoDeTramite() != null) {
current.setTipoDeTramite(entity.getTipoDeTramite());
}
getJpaController().edit(current);
return ResponseEntity.ok().build();
} catch (OptimisticLockException e) {
// Concurrent modification detected — client should re-fetch and retry
LOG.log(Level.WARNING, "Conflicto de concurrencia al actualizar plantilla de presupuesto", e);
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "Conflicto de concurrencia: la plantilla fue modificada por otro usuario. Recargue y vuelva a intentarlo."));
} catch (NonexistentEntityException e) {
return ResponseEntity.notFound().build();
} catch (Exception e) {
// Check if the root cause is an OptimisticLockException (can be wrapped in RollbackException)
Throwable cause = e.getCause();
while (cause != null) {
if (cause instanceof OptimisticLockException) {
LOG.log(Level.WARNING, "Conflicto de concurrencia (wrapped) al actualizar plantilla", cause);
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "Conflicto de concurrencia: la plantilla fue modificada por otro usuario."));
}
cause = cause.getCause();
}
LOG.log(Level.SEVERE, "Error al actualizar plantilla de presupuesto", e);
return ResponseEntity.internalServerError()
.body(Map.of("error", "Error interno al actualizar la plantilla"));
}
}
@DeleteMapping("/tipo-tramite/{idTipoTramite}/concepto/{idConcepto}")
@Operation(summary = "Eliminar plantilla de presupuesto")
public ResponseEntity<?> delete(@PathVariable Integer idTipoTramite, @PathVariable Integer idConcepto) {
try {
getJpaController().destroy(new PlantillaPresupuestoPK(idTipoTramite, idConcepto));
return ResponseEntity.ok().build();
} catch (NonexistentEntityException e) {
return ResponseEntity.notFound().build();
} catch (Exception e) {
LOG.log(Level.SEVERE, "Error al eliminar plantilla de presupuesto", e);
return ResponseEntity.internalServerError()
.body(Map.of("error", "Error interno al eliminar la plantilla"));
}
}
}