guardando presupuestos anonimos

This commit is contained in:
2025-10-11 14:14:47 +02:00
parent d4d83fe118
commit a1359f37b0
28 changed files with 697 additions and 232 deletions

View File

@ -5,13 +5,13 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.ui.Model;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.List;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
@ -33,7 +33,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.server.ResponseStatusException;
import jakarta.validation.Validator;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.type.TypeReference;
@ -48,12 +48,12 @@ import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import com.imprimelibros.erp.presupuesto.service.PresupuestoService;
import com.imprimelibros.erp.presupuesto.validation.PresupuestoValidationGroups;
import com.imprimelibros.erp.users.UserDao;
import com.imprimelibros.erp.users.User;
import com.imprimelibros.erp.users.UserDetailsImpl;
import com.imprimelibros.erp.presupuesto.service.PresupuestoFormDataMapper;
import com.imprimelibros.erp.presupuesto.service.PresupuestoFormDataMapper.PresupuestoFormDataDto;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Valid;
@Controller
@ -71,6 +71,9 @@ public class PresupuestoController {
@Autowired
protected MessageSource messageSource;
@Autowired
private Validator validator;
private final ObjectMapper objectMapper;
private final TranslationService translationService;
private final PresupuestoDatatableService dtService;
@ -472,6 +475,8 @@ public class PresupuestoController {
HttpServletRequest request) {
Presupuesto p = objectMapper.convertValue(body.get("presupuesto"), Presupuesto.class);
Boolean save = objectMapper.convertValue(body.get("save"), Boolean.class);
String mode = objectMapper.convertValue(body.get("mode"), String.class);
@SuppressWarnings("unchecked")
List<Map<String, Object>> serviciosList = (List<Map<String, Object>>) body.getOrDefault("servicios", List.of());
@ -479,7 +484,7 @@ public class PresupuestoController {
String sessionId = request.getSession(true).getId();
String ip = request.getRemoteAddr();
var resumen = presupuestoService.getResumenPublico(p, serviciosList, locale, sessionId, ip);
var resumen = presupuestoService.getResumen(p, serviciosList, save, mode, locale, sessionId, ip);
return ResponseEntity.ok(resumen);
}
@ -498,7 +503,15 @@ public class PresupuestoController {
"app.yes",
"app.cancelar",
"presupuesto.delete.ok.title",
"presupuesto.delete.ok.text");
"presupuesto.delete.ok.text",
"presupuesto.add.tipo",
"presupuesto.add.anonimo",
"presupuesto.add.cliente",
"presupuesto.add.next",
"presupuesto.add.cancel",
"presupuesto.add.select-client",
"presupuesto.add.error.options",
"presupuesto.add.error.options-client");
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
@ -557,6 +570,35 @@ public class PresupuestoController {
return "imprimelibros/presupuestos/presupuesto-form";
}
@GetMapping(value = { "/add/{mode}", "/add/{mode}/{cliente_id}", "/add2/{cliente_id}" })
public String getPresupuestoEditForm(
@PathVariable(name = "mode", required = false) String mode,
@PathVariable(name = "cliente_id", required = false) Long clienteId,
RedirectAttributes redirectAttributes,
Model model,
Authentication authentication,
Locale locale) {
List<String> keys = List.of(
"presupuesto.plantilla-cubierta",
"presupuesto.plantilla-cubierta-text",
"presupuesto.impresion-cubierta",
"presupuesto.impresion-cubierta-help");
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
model.addAttribute("pod", variableService.getValorEntero("POD"));
model.addAttribute("ancho_alto_min", variableService.getValorEntero("ancho_alto_min"));
model.addAttribute("ancho_alto_max", variableService.getValorEntero("ancho_alto_max"));
model.addAttribute("appMode", "add");
if (!mode.equals("public")) {
model.addAttribute("cliente_id", clienteId);
}
return "imprimelibros/presupuestos/presupuesto-form";
}
@GetMapping(value = "/api/get", produces = "application/json")
public ResponseEntity<PresupuestoFormDataDto> getPresupuesto(
@RequestParam("id") Long id, Authentication authentication) {
@ -588,51 +630,102 @@ public class PresupuestoController {
@Transactional
public ResponseEntity<?> delete(@PathVariable Long id, Authentication auth, Locale locale) {
Presupuesto p = presupuestoRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND,
messageSource.getMessage("presupuesto.error.not-found", null, locale)));
Presupuesto p = presupuestoRepository.findById(id).orElse(null);
if (p == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("message", messageSource.getMessage("presupuesto.error.not-found", null, locale)));
}
boolean isUser = auth != null && auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_USER"));
// compara por IDs (no uses equals entre tipos distintos)
Long ownerId = p.getUser() != null ? p.getUser().getId() : null;
User currentUser = null;
Long currentUserId = null;
if (auth != null && auth.getPrincipal() instanceof UserDetailsImpl udi) {
currentUserId = udi.getId();
currentUser = userRepo.findById(currentUserId).orElse(null);
} else if (auth != null) {
currentUserId = userRepo.findIdByUserNameIgnoreCase(auth.getName()).orElse(null); // fallback
currentUser = userRepo.findById(currentUserId).orElse(null);
currentUserId = userRepo.findIdByUserNameIgnoreCase(auth.getName()).orElse(null);
}
boolean isOwner = ownerId != null && ownerId.equals(currentUserId);
if (isUser && !isOwner) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
messageSource.getMessage("presupuesto.error.delete-permission-denied", null, locale));
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("message",
messageSource.getMessage("presupuesto.error.delete-permission-denied", null, locale)));
}
if (p.getEstado() != null && !p.getEstado().equals(Presupuesto.Estado.borrador)) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
messageSource.getMessage("presupuesto.error.delete-not-draft", null, locale));
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("message",
messageSource.getMessage("presupuesto.error.delete-not-draft", null, locale)));
}
// SOFT DELETE (no uses deleteById)
p.setDeleted(true);
p.setDeletedAt(Instant.now());
p.setDeletedBy(currentUser);
try {
p.setDeleted(true);
p.setDeletedAt(Instant.now());
presupuestoRepository.save(p);
return ResponseEntity.ok(Map.of("message",
messageSource.getMessage("presupuesto.exito.eliminado", null, locale)));
if (auth != null && auth.getPrincipal() instanceof UserDetailsImpl udi) {
p.setDeletedBy(userRepo.getReferenceById(udi.getId()));
} else if (auth != null) {
userRepo.findByUserNameIgnoreCase(auth.getName()).ifPresent(p::setDeletedBy);
}
presupuestoRepository.saveAndFlush(p);
return ResponseEntity.ok(Map.of("message",
messageSource.getMessage("presupuesto.exito.eliminado", null, locale)));
} catch (Exception ex) {
// Devuelve SIEMPRE algo en el catch
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message",
messageSource.getMessage("presupuesto.error.delete-internal-error", null, locale),
"detail",
ex.getClass().getSimpleName() + ": " + (ex.getMessage() != null ? ex.getMessage() : "")));
}
}
@PostMapping(path="/save")
public ResponseEntity<?> save(
@RequestBody Map<String, Object> body,
Locale locale, HttpServletRequest request) {
Presupuesto presupuesto = objectMapper.convertValue(body.get("presupuesto"), Presupuesto.class);
Long id = objectMapper.convertValue(body.get("id"), Long.class);
String mode = objectMapper.convertValue(body.get("mode"), String.class);
@SuppressWarnings("unchecked")
List<Map<String, Object>> serviciosList = (List<Map<String, Object>>) body.getOrDefault("servicios", List.of());
Set<ConstraintViolation<Presupuesto>> violations = validator.validate(presupuesto,
PresupuestoValidationGroups.All.class);
if (!violations.isEmpty()) {
Map<String, String> errores = new HashMap<>();
for (ConstraintViolation<Presupuesto> v : violations) {
String campo = v.getPropertyPath().toString();
String mensaje = messageSource.getMessage(v.getMessage().replace("{", "").replace("}", ""), null, locale);
errores.put(campo, mensaje);
}
return ResponseEntity.badRequest().body(errores);
}
try {
if (mode.equals("public")) {
var resumen = presupuestoService.getTextosResumen(presupuesto, serviciosList, locale);
presupuesto = presupuestoService.generateTotalizadores(presupuesto, serviciosList, resumen, locale);
if (id != null) {
presupuesto.setId(id); // para que actualice, no cree uno nuevo
}
}
Map<String, Object> saveResult = presupuestoService.guardarPresupuesto(presupuesto);
return ResponseEntity.ok(Map.of("id", saveResult.get("presupuesto_id"),
"message", messageSource.getMessage("presupuesto.exito.guardado", null, locale)));
} catch (Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message",
messageSource.getMessage("presupuesto.error.save-internal-error", null, locale),
"detail",
ex.getClass().getSimpleName() + ": " + (ex.getMessage() != null ? ex.getMessage() : "")));
}
}
}

View File

@ -8,10 +8,11 @@ import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import java.util.Locale;
import java.text.NumberFormat;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -38,6 +39,8 @@ import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto.TipoCubierta;
import com.imprimelibros.erp.presupuesto.maquetacion.MaquetacionMatricesRepository;
import com.imprimelibros.erp.presupuesto.marcapaginas.MarcapaginasRepository;
import com.imprimelibros.erp.users.UserDao;
import com.imprimelibros.erp.users.UserDetailsImpl;
import com.imprimelibros.erp.externalApi.skApiClient;
@Service
@ -65,13 +68,15 @@ public class PresupuestoService {
private final PresupuestoFormatter presupuestoFormatter;
private final skApiClient apiClient;
private final GeoIpService geoIpService;
private final UserDao userRepo;
public PresupuestoService(PresupuestadorItems presupuestadorItems, PresupuestoFormatter presupuestoFormatter,
skApiClient apiClient, GeoIpService geoIpService) {
skApiClient apiClient, GeoIpService geoIpService, UserDao userRepo) {
this.presupuestadorItems = presupuestadorItems;
this.presupuestoFormatter = presupuestoFormatter;
this.apiClient = apiClient;
this.geoIpService = geoIpService;
this.userRepo = userRepo;
}
public boolean validateDatosGenerales(int[] tiradas) {
@ -439,9 +444,8 @@ public class PresupuestoService {
presupuesto.getSelectedTirada() != null ? presupuesto.getSelectedTirada() : tirada_min);
Double precio_retractilado = apiClient.getRetractilado(requestBody);
return precio_retractilado != null
? NumberFormat.getNumberInstance(locale)
.format(Math.round(precio_retractilado * 100.0) / 100.0)
: "0,00";
? String.valueOf(Math.round(precio_retractilado * 100.0) / 100.0)
: "0.00";
}
public Map<String, Object> obtenerServiciosExtras(Presupuesto presupuesto, Locale locale) {
@ -517,7 +521,7 @@ public class PresupuestoService {
put("priceUnit", "");
} else {
put("price",
NumberFormat.getNumberInstance(locale).format(Math.round(price_prototipo * 100.0) / 100.0));
String.valueOf(Math.round(price_prototipo * 100.0) / 100.0));
put("priceUnit", messageSource.getMessage("app.currency-symbol", null, locale));
}
}
@ -723,7 +727,8 @@ public class PresupuestoService {
* Calcula el resumen (SIN persistir cambios de estado).
* Mantiene firma para no romper llamadas existentes.
*/
public Map<String, Object> getResumen(Presupuesto presupuesto, List<Map<String, Object>> servicios, Locale locale) {
public Map<String, Object> getTextosResumen(Presupuesto presupuesto, List<Map<String, Object>> servicios,
Locale locale) {
Map<String, Object> resumen = new HashMap<>();
resumen.put("titulo", presupuesto.getTitulo());
@ -798,25 +803,88 @@ public class PresupuestoService {
* Se invoca al entrar en la pestaña "Resumen" del presupuestador público.
*/
// PresupuestoService.java
@Transactional
public Map<String, Object> getResumenPublico(
public Map<String, Object> getResumen(
Presupuesto presupuesto,
List<Map<String, Object>> servicios,
Boolean save,
String mode,
Locale locale,
String sessionId,
String ip) {
// 1) Calcula el resumen (como ya haces)
Map<String, Object> resumen = getResumen(presupuesto, servicios, locale);
Map<String, Object> resumen = getTextosResumen(presupuesto, servicios, locale);
if (resumen.containsKey("error"))
return resumen;
// 2) Totales a partir del resumen
// - precio_unitario: primer precio devuelto por la API
// - cantidad: selected_tirada
// - precio_total_tirada
// - servicios_total (si hay)
presupuesto = generateTotalizadores(presupuesto, servicios, resumen, locale);
// 3) Enriquecer el Presupuesto a persistir
presupuesto.setEstado(Presupuesto.Estado.borrador);
if (mode.equals("public")) {
presupuesto.setOrigen(Presupuesto.Origen.publico);
presupuesto.setSessionId(sessionId);
// IP: guarda hash y trunc (si tienes campos). Si no, guarda tal cual en
// ip_trunc/ip_hash según tu modelo.
String ipTrunc = anonymizeIp(ip);
presupuesto.setIpTrunc(ipTrunc);
presupuesto.setIpHash(Integer.toHexString(ip.hashCode()));
// ubicación (si tienes un servicio GeoIP disponible; si no, omite estas tres
// líneas)
try {
GeoIpService.GeoData geo = geoIpService.lookup(ip).orElse(null);
presupuesto.setPais(geo.getPais());
presupuesto.setRegion(geo.getRegion());
presupuesto.setCiudad(geo.getCiudad());
} catch (Exception ignore) {
}
} else
presupuesto.setOrigen(Presupuesto.Origen.privado);
// 4) UPSERT: si viene id -> actualiza; si no, reusa el último borrador de la
// sesión
Presupuesto entidad;
if (presupuesto.getId() != null) {
entidad = presupuestoRepository.findById(presupuesto.getId()).orElse(presupuesto);
} else {
entidad = presupuestoRepository
.findTopBySessionIdAndEstadoOrderByCreatedAtDesc(sessionId, Presupuesto.Estado.borrador)
.orElse(presupuesto);
// Si se reutiliza un borrador existente, copia el ID a nuestro objeto para
// hacer merge
presupuesto.setId(entidad.getId());
}
// 5) Guardar/actualizar
entidad = mergePresupuesto(entidad, presupuesto);
if (save != null && save) {
// Si NO es para guardar (solo calcular resumen), devolver sin persistir
this.guardarPresupuesto(presupuesto);
}
// Opcional: devolver el id guardado al frontend para que lo envíe en llamadas
// siguientes
resumen.put("presupuesto_id", entidad.getId());
resumen.put("precio_unitario", presupuesto.getPrecioUnitario());
resumen.put("precio_total_tirada", presupuesto.getPrecioTotalTirada());
resumen.put("servicios_total", presupuesto.getServiciosTotal());
resumen.put("base_imponible", presupuesto.getBaseImponible());
resumen.put("iva_tipo", presupuesto.getIvaTipo());
resumen.put("iva_importe", presupuesto.getIvaImporte());
resumen.put("total_con_iva", presupuesto.getTotalConIva());
return resumen;
}
public Presupuesto generateTotalizadores(
Presupuesto presupuesto,
List<Map<String, Object>> servicios,
Map<String, Object> resumen,
Locale locale) {
// Genera los totalizadores (precio unitario, total tirada, etc.) sin guardar
double precioUnit = 0.0;
int cantidad = presupuesto.getSelectedTirada() != null ? presupuesto.getSelectedTirada() : 0;
try {
@ -871,27 +939,6 @@ public class PresupuestoService {
RoundingMode.HALF_UP);
BigDecimal totalConIva = baseImponible.add(ivaImporte);
// 3) Enriquecer el Presupuesto a persistir
presupuesto.setEstado(Presupuesto.Estado.borrador);
presupuesto.setOrigen(Presupuesto.Origen.publico);
presupuesto.setSessionId(sessionId);
// IP: guarda hash y trunc (si tienes campos). Si no, guarda tal cual en
// ip_trunc/ip_hash según tu modelo.
String ipTrunc = anonymizeIp(ip);
presupuesto.setIpTrunc(ipTrunc);
presupuesto.setIpHash(Integer.toHexString(ip.hashCode()));
// ubicación (si tienes un servicio GeoIP disponible; si no, omite estas tres
// líneas)
try {
GeoIpService.GeoData geo = geoIpService.lookup(ip).orElse(null);
presupuesto.setPais(geo.getPais());
presupuesto.setRegion(geo.getRegion());
presupuesto.setCiudad(geo.getCiudad());
} catch (Exception ignore) {
}
// precios y totales
presupuesto.setPrecioUnitario(BigDecimal.valueOf(precioUnit).setScale(6, RoundingMode.HALF_UP));
presupuesto.setPrecioTotalTirada(precioTotalTirada);
@ -901,46 +948,33 @@ public class PresupuestoService {
presupuesto.setIvaImporte(ivaImporte);
presupuesto.setTotalConIva(totalConIva);
// 4) UPSERT: si viene id -> actualiza; si no, reusa el último borrador de la
// sesión
Presupuesto entidad;
if (presupuesto.getId() != null) {
entidad = presupuestoRepository.findById(presupuesto.getId()).orElse(presupuesto);
} else {
entidad = presupuestoRepository
.findTopBySessionIdAndEstadoOrderByCreatedAtDesc(sessionId, Presupuesto.Estado.borrador)
.orElse(presupuesto);
// Si se reutiliza un borrador existente, copia el ID a nuestro objeto para
// hacer merge
presupuesto.setId(entidad.getId());
return presupuesto;
}
@Transactional
public HashMap<String, Object> guardarPresupuesto(Presupuesto presupuesto) {
HashMap<String, Object> result = new HashMap<>();
try {
Presupuesto p = presupuestoRepository.saveAndFlush(presupuesto);
result.put("success", true);
result.put("presupuesto_id", p.getId());
return result;
} catch (Exception e) {
System.out.println("Error guardando presupuesto: " + e.getMessage());
result.put("success", false);
return result;
}
// 5) Guardar/actualizar
entidad = mergePresupuesto(entidad, presupuesto);
presupuestoRepository.saveAndFlush(entidad);
// Opcional: devolver el id guardado al frontend para que lo envíe en llamadas
// siguientes
resumen.put("presupuesto_id", entidad.getId());
resumen.put("precio_unitario", presupuesto.getPrecioUnitario());
resumen.put("precio_total_tirada", presupuesto.getPrecioTotalTirada());
resumen.put("servicios_total", presupuesto.getServiciosTotal());
resumen.put("base_imponible", presupuesto.getBaseImponible());
resumen.put("iva_tipo", presupuesto.getIvaTipo());
resumen.put("iva_importe", presupuesto.getIvaImporte());
resumen.put("total_con_iva", presupuesto.getTotalConIva());
return resumen;
}
/**
* PRIVADO (futuro botón "Guardar"): persiste el presupuesto como borrador.
*/
@Transactional
public Presupuesto guardarPrivado(Presupuesto presupuesto) {
presupuesto.setEstado(Presupuesto.Estado.borrador);
return presupuestoRepository.saveAndFlush(presupuesto);
}
public HashMap<String, Object> calcularPresupuesto(Presupuesto presupuesto, Locale locale) {
HashMap<String, Object> price = new HashMap<>();
@ -957,9 +991,8 @@ public class PresupuestoService {
return price;
}
public Boolean canAccessPresupuesto(Presupuesto presupuesto, Authentication authentication) {
boolean isUser = authentication.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_USER"));
@ -972,7 +1005,6 @@ public class PresupuestoService {
}
return true;
}
// =======================================================================
// Métodos privados
@ -1095,5 +1127,4 @@ public class PresupuestoService {
return ip;
}
}

View File

@ -1,9 +1,14 @@
package com.imprimelibros.erp.presupuesto.validation;
import jakarta.validation.GroupSequence;
public class PresupuestoValidationGroups {
public interface DatosGenerales {}
public interface Interior {}
public interface Cubierta {}
@GroupSequence({DatosGenerales.class, Interior.class, Cubierta.class})
public interface All {}
}