diff --git a/pom.xml b/pom.xml index 9054bfb..578047b 100644 --- a/pom.xml +++ b/pom.xml @@ -120,6 +120,25 @@ 8.10.1 + + + com.maxmind.geoip2 + geoip2 + 4.2.0 + + + + + org.springframework.boot + spring-boot-starter-web + + + + com.h2database + h2 + test + + diff --git a/src.zip b/src.zip new file mode 100644 index 0000000..bcb1f3b Binary files /dev/null and b/src.zip differ diff --git a/src/main/java/com/imprimelibros/erp/auth/PasswordResetService.java b/src/main/java/com/imprimelibros/erp/auth/PasswordResetService.java index cfba12f..5ea5137 100644 --- a/src/main/java/com/imprimelibros/erp/auth/PasswordResetService.java +++ b/src/main/java/com/imprimelibros/erp/auth/PasswordResetService.java @@ -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; } diff --git a/src/main/java/com/imprimelibros/erp/common/email/EmailService.java b/src/main/java/com/imprimelibros/erp/common/email/EmailService.java index d94f8fa..4b64166 100644 --- a/src/main/java/com/imprimelibros/erp/common/email/EmailService.java +++ b/src/main/java/com/imprimelibros/erp/common/email/EmailService.java @@ -1,6 +1,5 @@ package com.imprimelibros.erp.common.email; -import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; import org.springframework.context.MessageSource; diff --git a/src/main/java/com/imprimelibros/erp/common/jpa/AbstractAuditedSoftDeleteEntity.java b/src/main/java/com/imprimelibros/erp/common/jpa/AbstractAuditedSoftDeleteEntity.java new file mode 100644 index 0000000..8d6a117 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/common/jpa/AbstractAuditedSoftDeleteEntity.java @@ -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; } +} diff --git a/src/main/java/com/imprimelibros/erp/common/web/IpUtils.java b/src/main/java/com/imprimelibros/erp/common/web/IpUtils.java new file mode 100644 index 0000000..41068c8 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/common/web/IpUtils.java @@ -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 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(); + } +} diff --git a/src/main/java/com/imprimelibros/erp/config/JpaAuditConfig.java b/src/main/java/com/imprimelibros/erp/config/JpaAuditConfig.java new file mode 100644 index 0000000..5729aaf --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/config/JpaAuditConfig.java @@ -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 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(); + }; + } +} diff --git a/src/main/java/com/imprimelibros/erp/presupuesto/GeoIpService.java b/src/main/java/com/imprimelibros/erp/presupuesto/GeoIpService.java new file mode 100644 index 0000000..b222f43 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/presupuesto/GeoIpService.java @@ -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 lookup(String ip); +} diff --git a/src/main/java/com/imprimelibros/erp/presupuesto/Presupuesto.java b/src/main/java/com/imprimelibros/erp/presupuesto/Presupuesto.java index 50033f0..3a6b04b 100644 --- a/src/main/java/com/imprimelibros/erp/presupuesto/Presupuesto.java +++ b/src/main/java/com/imprimelibros/erp/presupuesto/Presupuesto.java @@ -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); - } + } diff --git a/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoController.java b/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoController.java index 55ffb02..045c7ee 100644 --- a/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoController.java +++ b/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoController.java @@ -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 body, Locale locale) { + public ResponseEntity getResumen( + @RequestBody Map body, + Locale locale, + HttpServletRequest request) { + Presupuesto p = objectMapper.convertValue(body.get("presupuesto"), Presupuesto.class); @SuppressWarnings("unchecked") List> serviciosList = (List>) 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); } } diff --git a/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoRepository.java b/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoRepository.java new file mode 100644 index 0000000..7a4ec33 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoRepository.java @@ -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 { + + Optional findFirstBySessionIdAndOrigenAndEstadoInOrderByUpdatedAtDesc( + String sessionId, + Presupuesto.Origen origen, + Collection estados); + + List findByOrigenAndEstado(Presupuesto.Origen origen, Presupuesto.Estado estado); + + // Incluye borrados (ignora @Where) usando native + @Query(value = "SELECT * FROM presupuesto WHERE id = :id", nativeQuery = true) + Optional findAnyById(@Param("id") Long id); + + Optional findTopBySessionIdAndEstadoOrderByCreatedAtDesc(String sessionId, Presupuesto.Estado estado); + +} diff --git a/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoService.java b/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoService.java index 43f1357..d71706a 100644 --- a/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoService.java +++ b/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoService.java @@ -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 obtenerOpcionesColor(Presupuesto presupuesto, Locale locale) { - List opciones = new ArrayList<>(); if (presupuesto.getPaginasColor() > 0) { @@ -121,7 +125,6 @@ public class PresupuestoService { } public Map obtenerOpcionesPapelInterior(Presupuesto presupuesto, Locale locale) { - List 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 response = new HashMap<>(); response.put("opciones_papel_interior", opciones); - return response; } public Map obtenerOpcionesGramajeInterior(Presupuesto presupuesto) { - List 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 obtenerOpcionesPapelCubierta(Presupuesto presupuesto, Locale locale) { - List opciones = new ArrayList<>(); if (presupuesto.getTipoCubierta() == Presupuesto.TipoCubierta.tapaBlanda) { @@ -255,7 +246,6 @@ public class PresupuestoService { } public Map obtenerOpcionesGramajeCubierta(Presupuesto presupuesto) { - List 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 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 aplicarMargenTiradas(Map data) { - - // implementar margenes - return (Map) 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 obtenerServiciosExtras(Presupuesto presupuesto, Locale locale) { - List 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 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 lista = maquetacionPreciosRepository.findAll(); - // helper para obtener un precio por clave java.util.function.Function 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 out = new HashMap<>(); @@ -684,9 +656,7 @@ public class PresupuestoService { public HashMap getPrecioMarcapaginas(PresupuestoMarcapaginas presupuestoMarcapaginas, Locale locale) { - try { - List m = marcapaginasRepository.findPrecios(presupuestoMarcapaginas); if (m.isEmpty() || m.get(0) == null) { HashMap 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 getResumen(Presupuesto presupuesto, List> servicios, Locale locale) { - Map 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 precios = this.calcularPresupuesto(pressupuestoTemp, locale); - if(precios.containsKey("error")){ + if (precios.containsKey("error")) { resumen.put("error", precios.get("error")); return resumen; } - + HashMap 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) ((Map) precios.get("data")).get("precios")) - .get(0); - precio_total = precio_unitario * presupuesto.getSelectedTirada(); + precio_unitario = ((List) ((Map) 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> serviciosExtras = new ArrayList<>(); - - if(servicios != null){ + if (servicios != null) { for (Map servicio : servicios) { HashMap 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 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 getResumenPublico( + Presupuesto presupuesto, + List> servicios, + Locale locale, + String sessionId, + String ip) { + + // 1) Calcula el resumen (como ya haces) + Map 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 precios = (List) ((Map) 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) ((Map) 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 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 calcularPresupuesto(Presupuesto presupuesto, Locale locale) { HashMap 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; } } diff --git a/src/main/java/com/imprimelibros/erp/presupuesto/geo/CompositeGeoIpService.java b/src/main/java/com/imprimelibros/erp/presupuesto/geo/CompositeGeoIpService.java new file mode 100644 index 0000000..337dc91 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/presupuesto/geo/CompositeGeoIpService.java @@ -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 delegates; + + public CompositeGeoIpService(List delegates) { + this.delegates = delegates; + } + + @Override + public Optional lookup(String ip) { + for (GeoIpService d : delegates) { + try { + Optional r = d.lookup(ip); + if (r.isPresent()) return r; + } catch (Exception ignore) { /* tolerante */ } + } + return Optional.empty(); + } +} diff --git a/src/main/java/com/imprimelibros/erp/presupuesto/geo/GeoIpConfig.java b/src/main/java/com/imprimelibros/erp/presupuesto/geo/GeoIpConfig.java new file mode 100644 index 0000000..faf110f --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/presupuesto/geo/GeoIpConfig.java @@ -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 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/"; + } + } +} diff --git a/src/main/java/com/imprimelibros/erp/presupuesto/geo/HttpGeoIpService.java b/src/main/java/com/imprimelibros/erp/presupuesto/geo/HttpGeoIpService.java new file mode 100644 index 0000000..144f563 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/presupuesto/geo/HttpGeoIpService.java @@ -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 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 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:"); + } +} diff --git a/src/main/java/com/imprimelibros/erp/presupuesto/geo/MaxMindGeoIpService.java b/src/main/java/com/imprimelibros/erp/presupuesto/geo/MaxMindGeoIpService.java new file mode 100644 index 0000000..a3d199c --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/presupuesto/geo/MaxMindGeoIpService.java @@ -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 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(); + } +} diff --git a/src/main/java/com/imprimelibros/erp/shared/validation/NoRangeOverlapValidator.java b/src/main/java/com/imprimelibros/erp/shared/validation/NoRangeOverlapValidator.java index 25eda3d..edcca65 100644 --- a/src/main/java/com/imprimelibros/erp/shared/validation/NoRangeOverlapValidator.java +++ b/src/main/java/com/imprimelibros/erp/shared/validation/NoRangeOverlapValidator.java @@ -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; diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 058e8ac..d1aa346 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -73,4 +73,11 @@ spring.mail.properties.mail.smtp.starttls.enable=true # # Remove JSESSIONID from URL # -server.servlet.session.persistent=false \ No newline at end of file +server.servlet.session.persistent=false + +# +# GeoIP +# +geoip.enabled=true +geoip.maxmind.enabled=true +geoip.http.enabled=true diff --git a/src/main/resources/geoip/GeoLite2-City.mmdb b/src/main/resources/geoip/GeoLite2-City.mmdb new file mode 100644 index 0000000..b6dacf4 Binary files /dev/null and b/src/main/resources/geoip/GeoLite2-City.mmdb differ diff --git a/src/main/resources/static/assets/js/pages/imprimelibros/presupuestador/presupuestador.js b/src/main/resources/static/assets/js/pages/imprimelibros/presupuestador/presupuestador.js index d73e7a7..aace3a7 100644 --- a/src/main/resources/static/assets/js/pages/imprimelibros/presupuestador/presupuestador.js +++ b/src/main/resources/static/assets/js/pages/imprimelibros/presupuestador/presupuestador.js @@ -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); diff --git a/src/test/java/com/imprimelibros/erp/presupuestoMaquetacionTest.java b/src/test/java/com/imprimelibros/erp/presupuesto/presupuestoMaquetacionTest.java similarity index 95% rename from src/test/java/com/imprimelibros/erp/presupuestoMaquetacionTest.java rename to src/test/java/com/imprimelibros/erp/presupuesto/presupuestoMaquetacionTest.java index c2b291f..9cc25f9 100644 --- a/src/test/java/com/imprimelibros/erp/presupuestoMaquetacionTest.java +++ b/src/test/java/com/imprimelibros/erp/presupuesto/presupuestoMaquetacionTest.java @@ -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; diff --git a/src/test/java/com/imprimelibros/erp/presupuestoMarcapaginasTest.java b/src/test/java/com/imprimelibros/erp/presupuesto/presupuestoMarcapaginasTest.java similarity index 95% rename from src/test/java/com/imprimelibros/erp/presupuestoMarcapaginasTest.java rename to src/test/java/com/imprimelibros/erp/presupuesto/presupuestoMarcapaginasTest.java index 598c3e9..447ee46 100644 --- a/src/test/java/com/imprimelibros/erp/presupuestoMarcapaginasTest.java +++ b/src/test/java/com/imprimelibros/erp/presupuesto/presupuestoMarcapaginasTest.java @@ -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; diff --git a/src/test/java/com/imprimelibros/erp/skApiClientTest.java b/src/test/java/com/imprimelibros/erp/presupuesto/skApiClientTest.java similarity index 98% rename from src/test/java/com/imprimelibros/erp/skApiClientTest.java rename to src/test/java/com/imprimelibros/erp/presupuesto/skApiClientTest.java index 0428018..87ecb0d 100644 --- a/src/test/java/com/imprimelibros/erp/skApiClientTest.java +++ b/src/test/java/com/imprimelibros/erp/presupuesto/skApiClientTest.java @@ -1,4 +1,4 @@ -package com.imprimelibros.erp; +package com.imprimelibros.erp.presupuesto; import static org.junit.jupiter.api.Assertions.*; diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 0000000..5ca21e0 --- /dev/null +++ b/src/test/resources/application-test.properties @@ -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}