trabajando en guardar presupuestos publicos

This commit is contained in:
2025-10-05 16:30:28 +02:00
parent 892c473266
commit 14ca264ae2
24 changed files with 1118 additions and 163 deletions

19
pom.xml
View File

@ -120,6 +120,25 @@
<version>8.10.1</version>
</dependency>
<!-- GeoIP2 (MaxMind) -->
<dependency>
<groupId>com.maxmind.geoip2</groupId>
<artifactId>geoip2</artifactId>
<version>4.2.0</version>
</dependency>
<!-- HTTP client (Spring Web) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>

BIN
src.zip Normal file

Binary file not shown.

View File

@ -6,15 +6,10 @@ import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.Locale;
import java.util.Map;
import org.springframework.context.MessageSource;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring6.SpringTemplateEngine;
import com.imprimelibros.erp.common.email.EmailService;
import com.imprimelibros.erp.users.User;
@ -26,26 +21,17 @@ public class PasswordResetService {
private final PasswordResetTokenRepository tokenRepo;
private final UserDao userRepo;
private final PasswordEncoder passwordEncoder;
private final JavaMailSender mailSender;
private final SpringTemplateEngine templateEngine;
private final MessageSource messages;
private final EmailService emailService;
public PasswordResetService(
PasswordResetTokenRepository tokenRepo,
UserDao userRepo,
PasswordEncoder passwordEncoder,
JavaMailSender mailSender,
SpringTemplateEngine templateEngine,
MessageSource messages,
EmailService emailService
) {
this.tokenRepo = tokenRepo;
this.userRepo = userRepo;
this.passwordEncoder = passwordEncoder;
this.mailSender = mailSender;
this.templateEngine = templateEngine;
this.messages = messages;
this.emailService = emailService;
}

View File

@ -1,6 +1,5 @@
package com.imprimelibros.erp.common.email;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import org.springframework.context.MessageSource;

View File

@ -0,0 +1,70 @@
package com.imprimelibros.erp.common.jpa;
import jakarta.persistence.*;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.Instant;
import com.imprimelibros.erp.users.User;
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AbstractAuditedSoftDeleteEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Auditoría temporal
@CreatedDate
@Column(name = "created_at", updatable = false)
private Instant createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private Instant updatedAt;
// Auditoría por usuario (nullable si público anónimo)
@CreatedBy
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "created_by")
private User createdBy;
@LastModifiedBy
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "updated_by")
private User updatedBy;
// Soft delete
@Column(name = "deleted", nullable = false)
private boolean deleted = false;
@Column(name = "deleted_at")
private Instant deletedAt;
// Getters/Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
public User getCreatedBy() { return createdBy; }
public void setCreatedBy(User createdBy) { this.createdBy = createdBy; }
public User getUpdatedBy() { return updatedBy; }
public void setUpdatedBy(User updatedBy) { this.updatedBy = updatedBy; }
public boolean isDeleted() { return deleted; }
public void setDeleted(boolean deleted) { this.deleted = deleted; }
public Instant getDeletedAt() { return deletedAt; }
public void setDeletedAt(Instant deletedAt) { this.deletedAt = deletedAt; }
}

View File

@ -0,0 +1,33 @@
package com.imprimelibros.erp.common.web;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Arrays;
import java.util.List;
public final class IpUtils {
private IpUtils() {}
private static final List<String> HEADERS = Arrays.asList(
"X-Forwarded-For",
"X-Real-IP",
"CF-Connecting-IP",
"True-Client-IP",
"X-Client-IP",
"X-Forwarded",
"Forwarded-For",
"Forwarded"
);
public static String getClientIp(HttpServletRequest request) {
for (String h : HEADERS) {
String v = request.getHeader(h);
if (v != null && !v.isBlank() && !"unknown".equalsIgnoreCase(v)) {
// X-Forwarded-For puede traer lista: "client, proxy1, proxy2"
String first = v.split(",")[0].trim();
if (!first.isBlank()) return first;
}
}
return request.getRemoteAddr();
}
}

View File

@ -0,0 +1,36 @@
package com.imprimelibros.erp.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import com.imprimelibros.erp.users.User;
import java.util.Optional;
@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorAware")
public class JpaAuditConfig {
@Bean
public AuditorAware<User> auditorAware() {
return () -> {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) return Optional.empty();
Object principal = auth.getPrincipal();
if (principal instanceof User u) return Optional.of(u);
if (principal instanceof UserDetails ud) {
// Si tu principal es UserDetails y no la entidad User,
// aquí podrías cargar User por username si lo necesitas.
return Optional.empty();
}
return Optional.empty();
};
}
}

View File

@ -0,0 +1,37 @@
package com.imprimelibros.erp.presupuesto;
import java.util.Optional;
public interface GeoIpService {
class GeoData {
public final String pais;
public final String region;
public final String ciudad;
public GeoData(String pais, String region, String ciudad) {
this.pais = pais;
this.region = region;
this.ciudad = ciudad;
}
public String getPais() {
return pais;
}
public String getRegion() {
return region;
}
public String getCiudad() {
return ciudad;
}
}
/**
* @param ip Ip original (no anonimizada) - la implementación debe manejar IPv4/IPv6.
* @return GeoData si se pudo resolver; Optional.empty() en caso de error o IP privada.
*/
Optional<GeoData> lookup(String ip);
}

View File

@ -9,14 +9,35 @@ import com.imprimelibros.erp.presupuesto.validation.PresupuestoValidationGroups;
import com.imprimelibros.erp.presupuesto.validation.Tamanio;
import com.imprimelibros.erp.common.HtmlStripConverter;
import com.imprimelibros.erp.common.jpa.AbstractAuditedSoftDeleteEntity;
import jakarta.persistence.*;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import com.imprimelibros.erp.users.User;
@ConsistentTiradas(groups = PresupuestoValidationGroups.DatosGenerales.class)
@Tamanio(groups = PresupuestoValidationGroups.DatosGenerales.class)
@EntityListeners(AuditingEntityListener.class)
@Entity
@Table(name = "presupuesto")
public class Presupuesto implements Cloneable{
@Table(name = "presupuesto", indexes = {
@Index(name = "idx_presupuesto_origen_estado", columnList = "origen, estado"),
@Index(name = "idx_presupuesto_session", columnList = "session_id"),
@Index(name = "idx_presupuesto_user", columnList = "user_id"),
@Index(name = "idx_presupuesto_deleted", columnList = "deleted"),
@Index(name = "idx_presupuesto_geo", columnList = "pais, region, ciudad")
})
@SQLDelete(sql = "UPDATE presupuesto SET deleted = 1, deleted_at = NOW(3) WHERE id = ?")
@SQLRestriction("deleted = 0")
public class Presupuesto extends AbstractAuditedSoftDeleteEntity implements Cloneable {
public enum TipoEncuadernacion {
fresado("presupuesto.fresado"),
@ -69,6 +90,14 @@ public class Presupuesto implements Cloneable{
}
}
public enum Origen {
publico, privado
}
public enum Estado {
borrador, aceptado, modificado
}
@Override
public Presupuesto clone() {
try {
@ -78,9 +107,89 @@ public class Presupuesto implements Cloneable{
}
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ====== NUEVOS: Origen/Estado/Usuario/Session/Geo/IP/Totales/JSONs ======
@Enumerated(EnumType.STRING)
@Column(name = "origen", nullable = false)
private Origen origen = Origen.publico;
@Enumerated(EnumType.STRING)
@Column(name = "estado", nullable = false)
private Estado estado = Estado.borrador;
// Usuario autenticado (nullable en público)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
// Continuidad en público
@Column(name = "session_id", length = 64)
private String sessionId;
@Column(name = "visitor_id", length = 64)
private String visitorId;
// IP anonimizada / truncada + geolocalización resumida (para estadísticas)
@Column(name = "ip_hash", length = 88) // SHA-256 base64 ≈ 44 chars; dejamos margen
private String ipHash;
@Column(name = "ip_trunc", length = 64)
private String ipTrunc; // p.ej. "192.168.0.0" o "2a02:xxxx::"
@Column(name = "pais", length = 64)
private String pais;
@Column(name = "region", length = 128)
private String region;
@Column(name = "ciudad", length = 128)
private String ciudad;
// Totales de la tirada seleccionada y del presupuesto
@Column(name = "precio_unitario", precision = 12, scale = 4)
private BigDecimal precioUnitario;
@Column(name = "precio_total_tirada", precision = 12, scale = 2)
private BigDecimal precioTotalTirada;
@Column(name = "servicios_total", precision = 12, scale = 2)
private BigDecimal serviciosTotal;
@Column(name = "base_imponible", precision = 12, scale = 2)
private BigDecimal baseImponible;
@Column(name = "iva_tipo", precision = 5, scale = 2)
private BigDecimal ivaTipo;
@Column(name = "iva_importe", precision = 12, scale = 2)
private BigDecimal ivaImporte;
@Column(name = "total_con_iva", precision = 12, scale = 2)
private BigDecimal totalConIva;
// JSONs de apoyo (todas las tiradas, servicios, bloques de
// maquetación/marcapáginas y snapshot)
@Lob
@Column(name = "precios_por_tirada_json", columnDefinition = "json")
private String preciosPorTiradaJson; // [{tirada, precio_unitario, precio_total_tirada}, ...]
@Lob
@Column(name = "servicios_json", columnDefinition = "json")
private String serviciosJson;
@Lob
@Column(name = "datos_maquetacion_json", columnDefinition = "json")
private String datosMaquetacionJson;
@Lob
@Column(name = "datos_marcapaginas_json", columnDefinition = "json")
private String datosMarcapaginasJson;
@Lob
@Column(name = "pricing_snapshot", columnDefinition = "json")
private String pricingSnapshotJson;
// ====== TUS CAMPOS ORIGINALES ======
@NotNull(message = "{presupuesto.errores.tipo-encuadernacion}", groups = PresupuestoValidationGroups.DatosGenerales.class)
@Enumerated(EnumType.STRING)
@ -192,39 +301,267 @@ public class Presupuesto implements Cloneable{
@NotNull(message = "{presupuesto.errores.acabado-cubierta}", groups = PresupuestoValidationGroups.Cubierta.class)
@Column(name = "acabado")
private Integer acabado = 1;
private Integer acabado = 1;
@Column(name = "sobrecubierta")
private Boolean sobrecubierta = false;
@Column(name = "papel_sobrecubierta_id")
private Integer papelSobrecubiertaId = 2;
@Column(name = "gramaje_sobrecubierta")
private Integer gramajeSobrecubierta = 170;
@Column(name = "tamanio_solapas_sobrecubierta")
private Integer tamanioSolapasSobrecubierta = 80;
@Column(name = "acabado_sobrecubierta")
private Integer acabadoSobrecubierta = 0; // 0: sin acabado,
private Integer acabadoSobrecubierta = 0;
@Column(name = "faja")
private Boolean faja = false;
@Column(name = "papel_faja_id")
private Integer papelFajaId = 2;
@Column(name = "gramaje_faja")
private Integer gramajeFaja = 170;
@Column(name = "tamanio_solapas_faja")
private Integer tamanioSolapasFaja = 80;
@Column(name = "acabado_faja")
private Integer acabadoFaja = 0; // 0: sin acabado
private Integer acabadoFaja = 0;
@Column(name = "alto_faja")
private Integer altoFaja = 0;
@Column(name = "presupuesto_maquetacion")
private Boolean presupuestoMaquetacion = false;
@Column(name = "presupuesto_maquetacion_data")
private String presupuestoMaquetacionData;
// ====== AUDIT ======
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@CreatedBy
@Column(name = "created_by", updatable = false) // BIGINT o VARCHAR: ajusta el tipo del campo
private Long createdBy; // o String si tu columna es texto
// Getters y Setters
// ====== MÉTODOS AUX ======
public String resumenPresupuesto() {
return String.format("%s - %s - %dx%d mm - %d Páginas (N:%d C:%d) - Tira:%d",
this.titulo,
this.tipoEncuadernacion,
this.ancho,
this.alto,
(this.paginasNegro != null ? this.paginasNegro : 0)
+ (this.paginasColorTotal != null ? this.paginasColorTotal : 0),
this.paginasNegro != null ? this.paginasNegro : 0,
this.paginasColorTotal != null ? this.paginasColorTotal : 0,
this.selectedTirada != null ? this.selectedTirada : 0);
}
public Integer[] getTiradas() {
return new Integer[] { tirada1, tirada2, tirada3, tirada4 };
}
// ====== GETTERS/SETTERS (incluye nuevos y existentes) ======
public Origen getOrigen() {
return origen;
}
public void setOrigen(Origen origen) {
this.origen = origen;
}
public Estado getEstado() {
return estado;
}
public void setEstado(Estado estado) {
this.estado = estado;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public String getSessionId() {
return sessionId;
}
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
public String getVisitorId() {
return visitorId;
}
public void setVisitorId(String visitorId) {
this.visitorId = visitorId;
}
public String getIpHash() {
return ipHash;
}
public void setIpHash(String ipHash) {
this.ipHash = ipHash;
}
public String getIpTrunc() {
return ipTrunc;
}
public void setIpTrunc(String ipTrunc) {
this.ipTrunc = ipTrunc;
}
public String getPais() {
return pais;
}
public void setPais(String pais) {
this.pais = pais;
}
public String getRegion() {
return region;
}
public void setRegion(String region) {
this.region = region;
}
public String getCiudad() {
return ciudad;
}
public void setCiudad(String ciudad) {
this.ciudad = ciudad;
}
public BigDecimal getPrecioUnitario() {
return precioUnitario;
}
public void setPrecioUnitario(BigDecimal precioUnitario) {
this.precioUnitario = precioUnitario;
}
public BigDecimal getPrecioTotalTirada() {
return precioTotalTirada;
}
public void setPrecioTotalTirada(BigDecimal precioTotalTirada) {
this.precioTotalTirada = precioTotalTirada;
}
public BigDecimal getServiciosTotal() {
return serviciosTotal;
}
public void setServiciosTotal(BigDecimal serviciosTotal) {
this.serviciosTotal = serviciosTotal;
}
public BigDecimal getBaseImponible() {
return baseImponible;
}
public void setBaseImponible(BigDecimal baseImponible) {
this.baseImponible = baseImponible;
}
public BigDecimal getIvaTipo() {
return ivaTipo;
}
public void setIvaTipo(BigDecimal ivaTipo) {
this.ivaTipo = ivaTipo;
}
public BigDecimal getIvaImporte() {
return ivaImporte;
}
public void setIvaImporte(BigDecimal ivaImporte) {
this.ivaImporte = ivaImporte;
}
public BigDecimal getTotalConIva() {
return totalConIva;
}
public void setTotalConIva(BigDecimal totalConIva) {
this.totalConIva = totalConIva;
}
public String getPreciosPorTiradaJson() {
return preciosPorTiradaJson;
}
public void setPreciosPorTiradaJson(String preciosPorTiradaJson) {
this.preciosPorTiradaJson = preciosPorTiradaJson;
}
public String getServiciosJson() {
return serviciosJson;
}
public void setServiciosJson(String serviciosJson) {
this.serviciosJson = serviciosJson;
}
public String getDatosMaquetacionJson() {
return datosMaquetacionJson;
}
public void setDatosMaquetacionJson(String datosMaquetacionJson) {
this.datosMaquetacionJson = datosMaquetacionJson;
}
public String getDatosMarcapaginasJson() {
return datosMarcapaginasJson;
}
public void setDatosMarcapaginasJson(String datosMarcapaginasJson) {
this.datosMarcapaginasJson = datosMarcapaginasJson;
}
public String getPricingSnapshotJson() {
return pricingSnapshotJson;
}
public void setPricingSnapshotJson(String pricingSnapshotJson) {
this.pricingSnapshotJson = pricingSnapshotJson;
}
public TipoEncuadernacion getTipoEncuadernacion() {
return tipoEncuadernacion;
}
public void setTipoEncuadernacion(TipoEncuadernacion tipoEncuadernacion) {
this.tipoEncuadernacion = tipoEncuadernacion;
}
public String getTitulo() {
return titulo;
}
public void setTitulo(String titulo) {
this.titulo = titulo;
}
public String getAutor() {
return autor;
}
@ -269,14 +606,18 @@ public class Presupuesto implements Cloneable{
return tirada4;
}
public Integer[] getTiradas() {
return new Integer[] { tirada1, tirada2, tirada3, tirada4 };
}
public void setTirada4(Integer tirada4) {
this.tirada4 = tirada4;
}
public Integer getSelectedTirada() {
return selectedTirada;
}
public void setSelectedTirada(Integer selectedTirada) {
this.selectedTirada = selectedTirada;
}
public Integer getAncho() {
return ancho;
}
@ -301,22 +642,6 @@ public class Presupuesto implements Cloneable{
this.formatoPersonalizado = formatoPersonalizado;
}
public String getTitulo() {
return titulo;
}
public void setTitulo(String titulo) {
this.titulo = titulo;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Integer getPaginasNegro() {
return paginasNegro;
}
@ -333,14 +658,6 @@ public class Presupuesto implements Cloneable{
this.paginasColor = paginasColor;
}
public TipoEncuadernacion getTipoEncuadernacion() {
return tipoEncuadernacion;
}
public void setTipoEncuadernacion(TipoEncuadernacion tipoEncuadernacion) {
this.tipoEncuadernacion = tipoEncuadernacion;
}
public String getPosicionPaginasColor() {
return posicionPaginasColor;
}
@ -557,36 +874,5 @@ public class Presupuesto implements Cloneable{
this.altoFaja = altoFaja;
}
public Integer getSelectedTirada() {
return selectedTirada;
}
public void setSelectedTirada(Integer selectedTirada) {
this.selectedTirada = selectedTirada;
}
public Boolean getPresupuestoMaquetacion() {
return presupuestoMaquetacion;
}
public void setPresupuestoMaquetacion(Boolean presupuestoMaquetacion) {
this.presupuestoMaquetacion = presupuestoMaquetacion;
}
public String getPresupuestoMaquetacionData() {
return presupuestoMaquetacionData;
}
public void setPresupuestoMaquetacionData(String presupuestoMaquetacionData) {
this.presupuestoMaquetacionData = presupuestoMaquetacionData;
}
public String resumenPresupuesto() {
return String.format("%s - %s - %dx%d mm - %d Páginas (N:%d C:%d) - Tira:%d",
this.titulo,
this.tipoEncuadernacion,
this.ancho,
this.alto,
this.paginasNegro + this.paginasColorTotal,
this.paginasNegro,
this.paginasColorTotal,
this.selectedTirada != null ? this.selectedTirada : 0);
}
}

View File

@ -29,6 +29,7 @@ import com.imprimelibros.erp.presupuesto.classes.PresupuestoMaquetacion;
import com.imprimelibros.erp.presupuesto.classes.PresupuestoMarcapaginas;
import com.imprimelibros.erp.presupuesto.validation.PresupuestoValidationGroups;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
@Controller
@ -388,13 +389,22 @@ public class PresupuestoController {
// Se hace un post para no tener problemas con la longitud de la URL
@PostMapping("/public/resumen")
public ResponseEntity<?> getResumen(@RequestBody Map<String, Object> body, Locale locale) {
public ResponseEntity<?> getResumen(
@RequestBody Map<String, Object> body,
Locale locale,
HttpServletRequest request) {
Presupuesto p = objectMapper.convertValue(body.get("presupuesto"), Presupuesto.class);
@SuppressWarnings("unchecked")
List<Map<String, Object>> serviciosList = (List<Map<String, Object>>) body.getOrDefault("servicios", List.of());
return ResponseEntity.ok(presupuestoService.getResumen(p, serviciosList, locale));
String sessionId = request.getSession(true).getId();
String ip = request.getRemoteAddr();
var resumen = presupuestoService.getResumenPublico(p, serviciosList, locale, sessionId, ip);
return ResponseEntity.ok(resumen);
}
}

View File

@ -0,0 +1,26 @@
package com.imprimelibros.erp.presupuesto;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.*;
@Repository
public interface PresupuestoRepository extends JpaRepository<Presupuesto, Long> {
Optional<Presupuesto> findFirstBySessionIdAndOrigenAndEstadoInOrderByUpdatedAtDesc(
String sessionId,
Presupuesto.Origen origen,
Collection<Presupuesto.Estado> estados);
List<Presupuesto> findByOrigenAndEstado(Presupuesto.Origen origen, Presupuesto.Estado estado);
// Incluye borrados (ignora @Where) usando native
@Query(value = "SELECT * FROM presupuesto WHERE id = :id", nativeQuery = true)
Optional<Presupuesto> findAnyById(@Param("id") Long id);
Optional<Presupuesto> findTopBySessionIdAndEstadoOrderByCreatedAtDesc(String sessionId, Presupuesto.Estado estado);
}

View File

@ -13,6 +13,7 @@ import java.text.NumberFormat;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
@ -53,18 +54,23 @@ public class PresupuestoService {
@Autowired
protected MarcapaginasRepository marcapaginasRepository;
@Autowired
protected PresupuestoRepository presupuestoRepository;
private final PresupuestadorItems presupuestadorItems;
private final PresupuestoFormatter presupuestoFormatter;
private final skApiClient apiClient;
private final GeoIpService geoIpService;
public PresupuestoService(PresupuestadorItems presupuestadorItems, PresupuestoFormatter presupuestoFormatter, skApiClient apiClient) {
public PresupuestoService(PresupuestadorItems presupuestadorItems, PresupuestoFormatter presupuestoFormatter,
skApiClient apiClient, GeoIpService geoIpService) {
this.presupuestadorItems = presupuestadorItems;
this.presupuestoFormatter = presupuestoFormatter;
this.apiClient = apiClient;
this.geoIpService = geoIpService;
}
public boolean validateDatosGenerales(int[] tiradas) {
for (int tirada : tiradas) {
if (tirada <= 0) {
return false; // Invalid tirada found
@ -74,7 +80,6 @@ public class PresupuestoService {
}
public Boolean isPOD(Presupuesto presupuesto) {
int pod_value = variableService.getValorEntero("POD");
return (presupuesto.getTirada1() != null && presupuesto.getTirada1() <= pod_value) ||
(presupuesto.getTirada2() != null && presupuesto.getTirada2() <= pod_value) ||
@ -83,7 +88,6 @@ public class PresupuestoService {
}
public Map<String, Object> obtenerOpcionesColor(Presupuesto presupuesto, Locale locale) {
List<ImagenPresupuesto> opciones = new ArrayList<>();
if (presupuesto.getPaginasColor() > 0) {
@ -121,7 +125,6 @@ public class PresupuestoService {
}
public Map<String, Object> obtenerOpcionesPapelInterior(Presupuesto presupuesto, Locale locale) {
List<ImagenPresupuesto> opciones = new ArrayList<>();
opciones.add(this.presupuestadorItems.getPapelOffsetBlanco(locale));
@ -144,7 +147,6 @@ public class PresupuestoService {
}
boolean yaSeleccionado = opciones.stream().anyMatch(ImagenPresupuesto::isSelected);
if (!yaSeleccionado && !opciones.isEmpty()) {
ImagenPresupuesto primeraOpcion = opciones.get(0);
primeraOpcion.setSelected(true);
@ -153,12 +155,10 @@ public class PresupuestoService {
Map<String, Object> response = new HashMap<>();
response.put("opciones_papel_interior", opciones);
return response;
}
public Map<String, Object> obtenerOpcionesGramajeInterior(Presupuesto presupuesto) {
List<String> gramajes = new ArrayList<>();
final int BLANCO_OFFSET_ID = 3;
@ -168,7 +168,6 @@ public class PresupuestoService {
final int ESTUCADO_MATE_ID = 2;
if (presupuesto.getPapelInteriorId() != null && presupuesto.getPapelInteriorId() == BLANCO_OFFSET_ID) {
gramajes.add("80");
gramajes.add("90");
if (presupuesto.getTipoImpresion() == Presupuesto.TipoImpresion.negrohq ||
@ -180,32 +179,25 @@ public class PresupuestoService {
}
} else if (presupuesto.getPapelInteriorId() != null
&& presupuesto.getPapelInteriorId() == BLANCO_OFFSET_VOLUMEN_ID) {
if (presupuesto.getTipoImpresion() == Presupuesto.TipoImpresion.negro ||
presupuesto.getTipoImpresion() == Presupuesto.TipoImpresion.color) {
gramajes.add("80");
}
} else if (presupuesto.getPapelInteriorId() != null && presupuesto.getPapelInteriorId() == AHUESADO_OFFSET_ID) {
gramajes.add("80");
gramajes.add("90");
if (presupuesto.getTipoImpresion() == Presupuesto.TipoImpresion.negrohq ||
presupuesto.getTipoImpresion() == Presupuesto.TipoImpresion.colorhq) {
gramajes.add("100");
}
} else if (presupuesto.getPapelInteriorId() != null
&& presupuesto.getPapelInteriorId() == AHUESADO_OFFSET_VOLUMEN_ID) {
if (presupuesto.getTipoImpresion() == Presupuesto.TipoImpresion.negro ||
presupuesto.getTipoImpresion() == Presupuesto.TipoImpresion.color) {
gramajes.add("70");
gramajes.add("80");
}
} else if (presupuesto.getPapelInteriorId() != null && presupuesto.getPapelInteriorId() == ESTUCADO_MATE_ID) {
if (presupuesto.getTipoImpresion() != Presupuesto.TipoImpresion.color) {
gramajes.add("90");
}
@ -234,7 +226,6 @@ public class PresupuestoService {
}
public Map<String, Object> obtenerOpcionesPapelCubierta(Presupuesto presupuesto, Locale locale) {
List<ImagenPresupuesto> opciones = new ArrayList<>();
if (presupuesto.getTipoCubierta() == Presupuesto.TipoCubierta.tapaBlanda) {
@ -255,7 +246,6 @@ public class PresupuestoService {
}
public Map<String, Object> obtenerOpcionesGramajeCubierta(Presupuesto presupuesto) {
List<String> gramajes = new ArrayList<>();
final int CARTULINA_GRAFICA_ID = 5;
@ -268,7 +258,6 @@ public class PresupuestoService {
gramajes.add("350");
} else if (presupuesto.getPapelCubiertaId() != null && presupuesto.getPapelCubiertaId() == ESTUCADO_MATE_ID) {
if (presupuesto.getTipoCubierta() == Presupuesto.TipoCubierta.tapaBlanda) {
gramajes.add("250");
gramajes.add("300");
gramajes.add("350");
@ -283,7 +272,6 @@ public class PresupuestoService {
}
public Map<String, Object> toSkApiRequest(Presupuesto presupuesto) {
final int SK_CLIENTE_ID = 1284;
final int SK_PAGINAS_CUADERNILLO = 32;
@ -343,7 +331,6 @@ public class PresupuestoService {
}
public Integer getTipoImpresionId(Presupuesto presupuesto) {
if (presupuesto.getTipoEncuadernacion() == Presupuesto.TipoEncuadernacion.fresado) {
if (presupuesto.getTipoCubierta() == Presupuesto.TipoCubierta.tapaDura ||
presupuesto.getTipoCubierta() == Presupuesto.TipoCubierta.tapaDuraLomoRedondo) {
@ -437,12 +424,6 @@ public class PresupuestoService {
return resultado;
}
public Map<String, Object> aplicarMargenTiradas(Map<String, Object> data) {
// implementar margenes
return (Map<String, Object>) data;
}
public String obtenerPrecioRetractilado(Presupuesto presupuesto, Locale locale) {
Integer[] tiradas = presupuesto.getTiradas();
Integer tirada_min = Arrays.stream(tiradas)
@ -460,7 +441,6 @@ public class PresupuestoService {
}
public Map<String, Object> obtenerServiciosExtras(Presupuesto presupuesto, Locale locale) {
List<Object> opciones = new ArrayList<>();
Double price_prototipo = this.obtenerPrototipo(presupuesto);
@ -532,8 +512,8 @@ public class PresupuestoService {
put("price", messageSource.getMessage("presupuesto.consultar-soporte", null, locale));
put("priceUnit", "");
} else {
put("price", NumberFormat.getNumberInstance(locale)
.format(Math.round(price_prototipo * 100.0) / 100.0));
put("price",
NumberFormat.getNumberInstance(locale).format(Math.round(price_prototipo * 100.0) / 100.0));
put("priceUnit", messageSource.getMessage("app.currency-symbol", null, locale));
}
}
@ -561,10 +541,8 @@ public class PresupuestoService {
}
private Double obtenerPrototipo(Presupuesto presupuesto) {
// Obtenemos el precio de 1 unidad para el ejemplar de prueba
HashMap<String, Object> price = new HashMap<>();
// make a copy of "presupuesto" to avoid modifying the original object
Presupuesto presupuestoTemp = presupuesto.clone();
presupuestoTemp.setTirada1(1);
presupuestoTemp.setTirada2(null);
@ -575,7 +553,8 @@ public class PresupuestoService {
} else if (presupuestoTemp.getTipoImpresion() == Presupuesto.TipoImpresion.negro) {
presupuestoTemp.setTipoImpresion(Presupuesto.TipoImpresion.negrohq);
}
String priceStr = apiClient.getPrice(this.toSkApiRequest(presupuestoTemp), presupuestoTemp.getTipoEncuadernacion(), presupuestoTemp.getTipoCubierta());
String priceStr = apiClient.getPrice(this.toSkApiRequest(presupuestoTemp),
presupuestoTemp.getTipoEncuadernacion(), presupuestoTemp.getTipoCubierta());
Double price_prototipo = 0.0;
try {
price = new ObjectMapper().readValue(priceStr, new TypeReference<>() {
@ -584,7 +563,6 @@ public class PresupuestoService {
if (price_prototipo < 25) {
price_prototipo = 25.0;
}
} catch (JsonProcessingException e) {
} catch (Exception exception) {
}
@ -595,7 +573,6 @@ public class PresupuestoService {
try {
List<MaquetacionPrecios> lista = maquetacionPreciosRepository.findAll();
// helper para obtener un precio por clave
java.util.function.Function<String, Double> price = key -> lista.stream()
.filter(p -> key.equals(p.getKey()))
.map(MaquetacionPrecios::getValue)
@ -604,13 +581,10 @@ public class PresupuestoService {
BigDecimal precio = BigDecimal.ZERO;
// millar_maquetacion * (numCaracteres / 1000.0)
BigDecimal millares = BigDecimal.valueOf(presupuestoMaquetacion.getNumCaracteres()).divide(
BigDecimal.valueOf(1000), 6,
RoundingMode.HALF_UP);
BigDecimal.valueOf(1000), 6, RoundingMode.HALF_UP);
precio = precio.add(millares.multiply(BigDecimal.valueOf(price.apply("millar_maquetacion"))));
// Numero de paginas estimado
int numPaginas = 0;
Integer matricesPorPagina = maquetacionMatricesRepository.findMatrices(
presupuestoMaquetacion.getFormato(),
@ -618,7 +592,7 @@ public class PresupuestoService {
if (matricesPorPagina != null && matricesPorPagina > 0) {
numPaginas = presupuestoMaquetacion.getNumCaracteres() / matricesPorPagina;
}
// Precio por pagina estimado
BigDecimal precioRedondeado = precio.setScale(2, RoundingMode.HALF_UP);
double precioPaginaEstimado = 0.0;
if (numPaginas > 0) {
@ -627,7 +601,6 @@ public class PresupuestoService {
.doubleValue();
}
// tabla, columna, foto
precio = precio
.add(BigDecimal.valueOf(presupuestoMaquetacion.getNumTablas())
.multiply(BigDecimal.valueOf(price.apply("tabla"))));
@ -652,7 +625,6 @@ public class PresupuestoService {
precio = precio.add(BigDecimal.valueOf(price.apply("epub")));
}
// redondeo final
precioRedondeado = precio.setScale(2, RoundingMode.HALF_UP);
HashMap<String, Object> out = new HashMap<>();
@ -684,9 +656,7 @@ public class PresupuestoService {
public HashMap<String, Object> getPrecioMarcapaginas(PresupuestoMarcapaginas presupuestoMarcapaginas,
Locale locale) {
try {
List<Marcapaginas> m = marcapaginasRepository.findPrecios(presupuestoMarcapaginas);
if (m.isEmpty() || m.get(0) == null) {
HashMap<String, Object> out = new HashMap<>();
@ -774,47 +744,52 @@ public class PresupuestoService {
return out;
}
/**
* 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) {
Map<String, Object> resumen = new HashMap<>();
resumen.put("titulo", presupuesto.getTitulo());
Presupuesto pressupuestoTemp = presupuesto.clone();
resumen.put("imagen", "/assets/images/imprimelibros/presupuestador/" + presupuesto.getTipoEncuadernacion() + ".png");
resumen.put("imagen_alt", messageSource.getMessage("presupuesto." + presupuesto.getTipoEncuadernacion(), null, locale));
resumen.put("imagen",
"/assets/images/imprimelibros/presupuestador/" + presupuesto.getTipoEncuadernacion() + ".png");
resumen.put("imagen_alt",
messageSource.getMessage("presupuesto." + presupuesto.getTipoEncuadernacion(), null, locale));
boolean hayDepositoLegal = servicios != null && servicios.stream()
.map(m -> java.util.Objects.toString(m.get("id"), "")) // null-safe -> String
.map(m -> java.util.Objects.toString(m.get("id"), ""))
.map(String::trim)
.anyMatch("deposito-legal"::equals);
if(hayDepositoLegal){
pressupuestoTemp.setSelectedTirada(presupuesto.getSelectedTirada()+4);
if (hayDepositoLegal) {
pressupuestoTemp.setSelectedTirada(
presupuesto.getSelectedTirada() != null ? presupuesto.getSelectedTirada() + 4 : 4);
}
HashMap<String, Object> precios = this.calcularPresupuesto(pressupuestoTemp, locale);
if(precios.containsKey("error")){
if (precios.containsKey("error")) {
resumen.put("error", precios.get("error"));
return resumen;
}
HashMap<String, Object> linea = new HashMap<>();
Double precio_unitario = 0.0;
Double precio_total = 0.0;
Integer counter = 0;
linea.put("descripcion", presupuestoFormatter.resumen(presupuesto, servicios, locale));
linea.put("cantidad", presupuesto.getSelectedTirada() != null ? presupuesto.getSelectedTirada() : 0);
precio_unitario = ((List<Double>) ((Map<String, Object>) precios.get("data")).get("precios"))
.get(0);
precio_total = precio_unitario * presupuesto.getSelectedTirada();
precio_unitario = ((List<Double>) ((Map<String, Object>) precios.get("data")).get("precios")).get(0);
precio_total = precio_unitario
* (presupuesto.getSelectedTirada() != null ? presupuesto.getSelectedTirada() : 0);
linea.put("precio_unitario", precio_unitario);
linea.put("precio_total", BigDecimal.valueOf(precio_total).setScale(2, RoundingMode.HALF_UP));
resumen.put("linea" + counter, linea);
counter++;
if(hayDepositoLegal) {
if (hayDepositoLegal) {
linea = new HashMap<>();
linea.put("descripcion", messageSource.getMessage("presupuesto.resumen-deposito-legal", null, locale));
linea.put("cantidad", 4);
@ -825,15 +800,15 @@ public class PresupuestoService {
}
List<Map<String, Object>> serviciosExtras = new ArrayList<>();
if(servicios != null){
if (servicios != null) {
for (Map<String, Object> servicio : servicios) {
HashMap<String, Object> servicioData = new HashMap<>();
servicioData.put("id", servicio.get("id"));
servicioData.put("descripcion", servicio.get("label"));
servicioData.put("precio", servicio.get("id").equals("marcapaginas") ?
Double.parseDouble(servicio.get("price").toString())/Double.parseDouble(servicio.get("units").toString()) :
servicio.get("price"));
servicioData.put("precio", servicio.get("id").equals("marcapaginas")
? Double.parseDouble(servicio.get("price").toString())
/ Double.parseDouble(servicio.get("units").toString())
: servicio.get("price"));
servicioData.put("unidades", servicio.get("units"));
serviciosExtras.add(servicioData);
}
@ -843,10 +818,217 @@ public class PresupuestoService {
return resumen;
}
public HashMap<String, Object> calcularPresupuesto(Presupuesto presupuesto, Locale locale) {
/**
* PÚBLICO: calcula el resumen y GUARDA el presupuesto completo como BORRADOR.
* Se invoca al entrar en la pestaña "Resumen" del presupuestador público.
*/
// PresupuestoService.java
@Transactional
public Map<String, Object> getResumenPublico(
Presupuesto presupuesto,
List<Map<String, Object>> servicios,
Locale locale,
String sessionId,
String ip) {
// 1) Calcula el resumen (como ya haces)
Map<String, Object> resumen = getResumen(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)
double precioUnit = 0.0;
int cantidad = presupuesto.getSelectedTirada() != null ? presupuesto.getSelectedTirada() : 0;
try {
@SuppressWarnings("unchecked")
List<Double> precios = (List<Double>) ((Map<String, Object>) resumen.getOrDefault("precios", Map.of()))
.getOrDefault("precios", List.of());
if (precios.isEmpty()) {
// si no venía en "resumen", recalcúlalo directamente
var preciosCalc = this.calcularPresupuesto(presupuesto, locale);
precios = (List<Double>) ((Map<String, Object>) preciosCalc.get("data")).get("precios");
}
precioUnit = precios.get(0);
// guarda el snapshot completo de precios para auditoría
presupuesto.setPreciosPorTiradaJson(new ObjectMapper().writeValueAsString(precios));
} catch (Exception ignore) {
}
BigDecimal precioTotalTirada = BigDecimal.valueOf(precioUnit)
.multiply(BigDecimal.valueOf(cantidad))
.setScale(2, RoundingMode.HALF_UP);
// servicios_total
BigDecimal serviciosTotal = BigDecimal.ZERO;
if (servicios != null) {
for (Map<String, Object> s : servicios) {
try {
double unidades = Double.parseDouble(String.valueOf(s.getOrDefault("units", 0)));
double precio = Double.parseDouble(String.valueOf(
s.get("id").equals("marcapaginas")
? (Double.parseDouble(String.valueOf(s.get("price"))) / unidades) // unidad
: s.getOrDefault("price", 0)));
serviciosTotal = serviciosTotal.add(
BigDecimal.valueOf(precio).multiply(BigDecimal.valueOf(unidades)));
} catch (Exception ignore) {
}
}
try {
presupuesto.setServiciosJson(new ObjectMapper().writeValueAsString(servicios));
} catch (Exception ignore) {
}
}
// base imponible, IVA y total (si tienes IVA configurable, úsalo; si no, 0)
BigDecimal baseImponible = precioTotalTirada.add(serviciosTotal);
BigDecimal ivaTipo = BigDecimal.ZERO;
try {
double iva = 4.0; // 0..100
ivaTipo = BigDecimal.valueOf(iva);
} catch (Exception ignore) {
}
BigDecimal ivaImporte = baseImponible.multiply(ivaTipo).divide(BigDecimal.valueOf(100), 2,
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.
presupuesto.setIpTrunc(ip);
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);
presupuesto.setServiciosTotal(serviciosTotal);
presupuesto.setBaseImponible(baseImponible);
presupuesto.setIvaTipo(ivaTipo);
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());
}
// 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;
}
/**
* Copia de campos "actualizables" para no machacar otros (created_at, etc.)
*/
private Presupuesto mergePresupuesto(Presupuesto target, Presupuesto src) {
// Campos funcionales
target.setTitulo(src.getTitulo());
target.setTipoEncuadernacion(src.getTipoEncuadernacion());
target.setTipoCubierta(src.getTipoCubierta());
target.setTipoImpresion(src.getTipoImpresion());
target.setPaginasNegro(src.getPaginasNegro());
target.setPaginasColor(src.getPaginasColor());
target.setPaginasColorTotal(src.getPaginasColorTotal());
target.setPosicionPaginasColor(src.getPosicionPaginasColor());
target.setAncho(src.getAncho());
target.setAlto(src.getAlto());
target.setPapelInteriorId(src.getPapelInteriorId());
target.setGramajeInterior(src.getGramajeInterior());
target.setPapelCubiertaId(src.getPapelCubiertaId());
target.setGramajeCubierta(src.getGramajeCubierta());
target.setCubiertaCaras(src.getCubiertaCaras());
target.setSolapasCubierta(src.getSolapasCubierta());
target.setTamanioSolapasCubierta(src.getTamanioSolapasCubierta());
target.setSobrecubierta(src.getSobrecubierta());
target.setPapelSobrecubiertaId(src.getPapelSobrecubiertaId());
target.setGramajeSobrecubierta(src.getGramajeSobrecubierta());
target.setTamanioSolapasSobrecubierta(src.getTamanioSolapasSobrecubierta());
target.setAcabado(src.getAcabado());
target.setCabezada(src.getCabezada());
target.setTipoCubierta(src.getTipoCubierta());
target.setSelectedTirada(src.getSelectedTirada());
target.setTirada1(src.getTirada1());
target.setTirada2(src.getTirada2());
target.setTirada3(src.getTirada3());
target.setTirada4(src.getTirada4());
// Metadatos y totales
target.setEstado(Presupuesto.Estado.borrador);
target.setOrigen(src.getOrigen());
target.setSessionId(src.getSessionId());
target.setIpHash(src.getIpHash());
target.setIpTrunc(src.getIpTrunc());
target.setPais(src.getPais());
target.setRegion(src.getRegion());
target.setCiudad(src.getCiudad());
target.setServiciosJson(src.getServiciosJson());
target.setPreciosPorTiradaJson(src.getPreciosPorTiradaJson());
target.setPrecioUnitario(src.getPrecioUnitario());
target.setPrecioTotalTirada(src.getPrecioTotalTirada());
target.setServiciosTotal(src.getServiciosTotal());
target.setBaseImponible(src.getBaseImponible());
target.setIvaTipo(src.getIvaTipo());
target.setIvaImporte(src.getIvaImporte());
target.setTotalConIva(src.getTotalConIva());
target.setCreatedBy(target.getCreatedBy() == null ? src.getCreatedBy() : target.getCreatedBy()); // no pisar si
// ya existe
return target;
}
/**
* 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<>();
String priceStr = apiClient.getPrice(this.toSkApiRequest(presupuesto), presupuesto.getTipoEncuadernacion(), presupuesto.getTipoCubierta());
String priceStr = apiClient.getPrice(this.toSkApiRequest(presupuesto), presupuesto.getTipoEncuadernacion(),
presupuesto.getTipoCubierta());
try {
price = new ObjectMapper().readValue(priceStr, new TypeReference<>() {
@ -855,7 +1037,6 @@ public class PresupuestoService {
price = new HashMap<>();
price.put("error", messageSource.getMessage("presupuesto.error-obtener-precio", null, locale));
}
return price;
}
}

View File

@ -0,0 +1,26 @@
package com.imprimelibros.erp.presupuesto.geo;
import com.imprimelibros.erp.presupuesto.GeoIpService;
import java.util.List;
import java.util.Optional;
public class CompositeGeoIpService implements GeoIpService {
private final List<GeoIpService> delegates;
public CompositeGeoIpService(List<GeoIpService> delegates) {
this.delegates = delegates;
}
@Override
public Optional<GeoData> lookup(String ip) {
for (GeoIpService d : delegates) {
try {
Optional<GeoData> r = d.lookup(ip);
if (r.isPresent()) return r;
} catch (Exception ignore) { /* tolerante */ }
}
return Optional.empty();
}
}

View File

@ -0,0 +1,66 @@
package com.imprimelibros.erp.presupuesto.geo;
import com.imprimelibros.erp.presupuesto.GeoIpService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.core.io.ResourceLoader;
import org.springframework.web.client.RestTemplate;
import java.util.ArrayList;
import java.util.List;
@Configuration
public class GeoIpConfig {
// MaxMind (local). Activar con: geoip.maxmind.enabled=true
@Bean
@ConditionalOnProperty(prefix = "geoip.maxmind", name = "enabled", havingValue = "true")
public GeoIpService maxMindGeoIpService(ResourceLoader loader,
GeoIpProperties props) {
String path = props.getMaxmind().dbClasspathLocation;
return new MaxMindGeoIpService(loader, path);
}
// HTTP. Activar con: geoip.http.enabled=true
@Bean
@ConditionalOnProperty(prefix = "geoip.http", name = "enabled", havingValue = "true")
public GeoIpService httpGeoIpService(GeoIpProperties props) {
RestTemplate rt = new RestTemplate();
return new HttpGeoIpService(rt, props.getHttp().endpointTemplate);
}
// Composite. Activo si cualquiera de los anteriores lo está: geoip.enabled=true
@Bean
@ConditionalOnProperty(prefix = "geoip", name = "enabled", havingValue = "true", matchIfMissing = true)
@Primary
public GeoIpService compositeGeoIpService(List<GeoIpService> delegates) {
// Si no hay ninguno, lista vacía → Composite devuelve empty y no rompe nada
return new CompositeGeoIpService(new ArrayList<>(delegates));
}
@Bean
public GeoIpProperties geoIpProperties() {
return new GeoIpProperties();
}
// --- Props holder simple ---
public static class GeoIpProperties {
private MaxMindProps maxmind = new MaxMindProps();
private HttpProps http = new HttpProps();
public MaxMindProps getMaxmind() { return maxmind; }
public HttpProps getHttp() { return http; }
public static class MaxMindProps {
public boolean enabled = false;
public String dbClasspathLocation = "classpath:geoip/GeoLite2-City.mmdb";
}
public static class HttpProps {
public boolean enabled = false;
// {ip} será reemplazado por la IP
public String endpointTemplate = "https://ipapi.co/{ip}/json/";
}
}
}

View File

@ -0,0 +1,72 @@
package com.imprimelibros.erp.presupuesto.geo;
import com.imprimelibros.erp.presupuesto.GeoIpService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
import java.util.Map;
import java.util.Optional;
public class HttpGeoIpService implements GeoIpService {
private final RestTemplate restTemplate;
private final String endpointTemplate; // p.ej. "https://ipapi.co/{ip}/json/"
public HttpGeoIpService(RestTemplate restTemplate, String endpointTemplate) {
this.restTemplate = restTemplate;
this.endpointTemplate = endpointTemplate;
}
@Override
public Optional<GeoData> lookup(String ip) {
try {
if (ip == null || ip.isBlank()) return Optional.empty();
if (isPrivateIp(ip)) return Optional.empty();
String url = endpointTemplate.replace("{ip}", ip);
ResponseEntity<Map> resp = restTemplate.getForEntity(url, Map.class);
if (!resp.getStatusCode().is2xxSuccessful() || resp.getBody() == null) {
return Optional.empty();
}
Map body = resp.getBody();
// Campos más comunes según proveedor
String pais = firstNonBlank(
(String) body.get("country_name"),
(String) body.get("country")
);
String region = firstNonBlank(
(String) body.get("region"),
(String) body.get("regionName"),
(String) body.get("state")
);
String ciudad = firstNonBlank(
(String) body.get("city")
);
if (isBlank(pais) && isBlank(region) && isBlank(ciudad)) {
return Optional.empty();
}
return Optional.of(new GeoData(pais, region, ciudad));
} catch (Exception e) {
return Optional.empty();
}
}
private static boolean isBlank(String s) { return s == null || s.isBlank(); }
private static String firstNonBlank(String... vals) {
for (String v : vals) if (!isBlank(v)) return v;
return null;
}
private static boolean isPrivateIp(String ip) {
return ip.startsWith("10.") ||
ip.startsWith("192.168.") ||
ip.matches("^172\\.(1[6-9]|2\\d|3[0-1])\\..*") ||
ip.equals("127.0.0.1") ||
ip.startsWith("169.254.") ||
ip.equals("::1") ||
ip.startsWith("fe80:") ||
ip.startsWith("fc00:") || ip.startsWith("fd00:");
}
}

View File

@ -0,0 +1,77 @@
package com.imprimelibros.erp.presupuesto.geo;
import com.imprimelibros.erp.presupuesto.GeoIpService;
import com.maxmind.geoip2.DatabaseReader;
import com.maxmind.geoip2.exception.GeoIp2Exception;
import com.maxmind.geoip2.model.CityResponse;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import java.io.IOException;
import java.net.InetAddress;
import java.util.Optional;
public class MaxMindGeoIpService implements GeoIpService, AutoCloseable {
private final DatabaseReader dbReader;
public MaxMindGeoIpService(ResourceLoader resourceLoader, String dbClasspathLocation) {
try {
Resource resource = resourceLoader.getResource(dbClasspathLocation);
this.dbReader = new DatabaseReader.Builder(resource.getInputStream()).build();
} catch (IOException e) {
throw new RuntimeException("No se pudo cargar la base de MaxMind desde: " + dbClasspathLocation, e);
}
}
@Override
public Optional<GeoData> lookup(String ip) {
try {
if (ip == null || ip.isBlank()) return Optional.empty();
// Ignora IPs privadas habituales
if (isPrivateIp(ip)) return Optional.empty();
InetAddress addr = InetAddress.getByName(ip);
CityResponse resp = dbReader.city(addr);
String pais = safe(resp.getCountry() != null ? resp.getCountry().getNames().get("es") : null,
resp.getCountry() != null ? resp.getCountry().getName() : null);
String region = safe(resp.getMostSpecificSubdivision() != null ? resp.getMostSpecificSubdivision().getNames().get("es") : null,
resp.getMostSpecificSubdivision() != null ? resp.getMostSpecificSubdivision().getName() : null);
String ciudad = safe(resp.getCity() != null ? resp.getCity().getNames().get("es") : null,
resp.getCity() != null ? resp.getCity().getName() : null);
if (isAllBlank(pais, region, ciudad)) return Optional.empty();
return Optional.of(new GeoData(pais, region, ciudad));
} catch (IOException | GeoIp2Exception e) {
return Optional.empty();
}
}
private static String safe(String... candidates) {
for (String c : candidates) if (c != null && !c.isBlank()) return c;
return null;
}
private static boolean isAllBlank(String... vals) {
for (String v : vals) if (v != null && !v.isBlank()) return false;
return true;
}
private static boolean isPrivateIp(String ip) {
// Simplificado: rangos privados IPv4, y link-local/loopback. IPv6 simplificado.
return ip.startsWith("10.") ||
ip.startsWith("192.168.") ||
ip.matches("^172\\.(1[6-9]|2\\d|3[0-1])\\..*") ||
ip.equals("127.0.0.1") ||
ip.startsWith("169.254.") ||
ip.equals("::1") ||
ip.startsWith("fe80:") ||
ip.startsWith("fc00:") || ip.startsWith("fd00:");
}
@Override
public void close() throws Exception {
if (dbReader != null) dbReader.close();
}
}

View File

@ -7,7 +7,6 @@ import java.lang.reflect.Method;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.FlushModeType;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.PersistenceUnit;
import jakarta.persistence.criteria.*;
import jakarta.validation.ConstraintValidator;

View File

@ -73,4 +73,11 @@ spring.mail.properties.mail.smtp.starttls.enable=true
#
# Remove JSESSIONID from URL
#
server.servlet.session.persistent=false
server.servlet.session.persistent=false
#
# GeoIP
#
geoip.enabled=true
geoip.maxmind.enabled=true
geoip.http.enabled=true

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 MiB

View File

@ -1418,6 +1418,11 @@ class PresupuestoCliente {
data: JSON.stringify(body)
}).then((data) => {
$('#resumen-titulo').text(data.titulo);
if (resumen.presupuesto_id) {
window.PRESUPUESTO_ID = resumen.presupuesto_id;
}
body.presupuesto.id = window.PRESUPUESTO_ID || body.presupuesto.id || null;
this.#updateResumenTable(data);
}).catch((error) => {
console.error("Error obtener resumen: ", error);

View File

@ -1,4 +1,4 @@
package com.imprimelibros.erp;
package com.imprimelibros.erp.presupuesto;
import static org.junit.jupiter.api.Assertions.*;
@ -13,7 +13,6 @@ import org.springframework.boot.test.context.SpringBootTest;
import java.util.Locale;
import com.imprimelibros.erp.presupuesto.PresupuestoService;
import com.imprimelibros.erp.presupuesto.classes.PresupuestoMaquetacion;
import com.imprimelibros.erp.presupuesto.maquetacion.MaquetacionMatrices.FontSize;
import com.imprimelibros.erp.presupuesto.maquetacion.MaquetacionMatrices.Formato;

View File

@ -1,4 +1,4 @@
package com.imprimelibros.erp;
package com.imprimelibros.erp.presupuesto;
import static org.junit.jupiter.api.Assertions.*;
@ -14,7 +14,6 @@ import org.springframework.boot.test.context.SpringBootTest;
import java.util.Locale;
import com.imprimelibros.erp.presupuesto.PresupuestoService;
import com.imprimelibros.erp.presupuesto.classes.PresupuestoMarcapaginas;
import com.imprimelibros.erp.presupuesto.marcapaginas.Marcapaginas;

View File

@ -1,4 +1,4 @@
package com.imprimelibros.erp;
package com.imprimelibros.erp.presupuesto;
import static org.junit.jupiter.api.Assertions.*;

View File

@ -0,0 +1,22 @@
# BBDD de test (H2 en memoria)
spring.datasource.url=jdbc:h2:mem:imprimelibros;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
# JPA/Hibernate
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.show-sql=false
# Evitar interferencias de seguridad/auditoría en tests
spring.main.allow-bean-definition-overriding=true
# Desactiva MaxMind para no requerir el .mmdb en test
geoip.maxmind.enabled=false
# Activa el servicio HTTP
geoip.http.enabled=true
# Usa una URL plantilla controlable (la “levanta” MockRestServiceServer)
geoip.http.endpoint-template=http://geoip.test/{ip}