Merge branch 'feat/pedidos' into 'main'

Feat/pedidos

See merge request jjimenez/erp-imprimelibros!22
This commit is contained in:
2025-11-10 20:07:18 +00:00
33 changed files with 1658 additions and 165 deletions

View File

@ -193,6 +193,11 @@
<plugin> <plugin>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId> <artifactId>spring-boot-maven-plugin</artifactId>
<!-- IMPORTANTE: incluir dependencias con scope=system en el fat-jar -->
<configuration>
<!-- Esto hace que meta las dependencias con scope=system en BOOT-INF/lib -->
<includeSystemScope>true</includeSystemScope>
</configuration>
</plugin> </plugin>
<!-- (Migraciones) Plugin Maven para generar/ejecutar changelogs --> <!-- (Migraciones) Plugin Maven para generar/ejecutar changelogs -->

View File

@ -1,6 +1,8 @@
package com.imprimelibros.erp.cart; package com.imprimelibros.erp.cart;
import java.util.HashMap;
import java.util.Locale; import java.util.Locale;
import java.util.Map;
import org.springframework.context.MessageSource; import org.springframework.context.MessageSource;
import com.imprimelibros.erp.cart.dto.DireccionCardDTO; import com.imprimelibros.erp.cart.dto.DireccionCardDTO;
@ -98,4 +100,53 @@ public class CartDireccion {
); );
} }
public Map<String, Object> toSkMap(Integer numeroUnidades, Double pesoKg, Boolean palets, Boolean ejemplarPrueba) {
Map<String, Object> direccion = new HashMap<>();
direccion.put("cantidad", numeroUnidades);
direccion.put("peso", pesoKg);
direccion.put("att", this.getDireccion().getAtt());
direccion.put("email", this.getDireccion().getUser().getUserName());
direccion.put("direccion", this.getDireccion().getDireccion());
direccion.put("pais_code3", this.getDireccion().getPaisCode3());
direccion.put("cp", this.getDireccion().getCp());
direccion.put("municipio", this.getDireccion().getCiudad());
direccion.put("provincia", this.getDireccion().getProvincia());
direccion.put("telefono", this.getDireccion().getTelefono());
direccion.put("entregaPieCalle", palets ? 1 : 0);
direccion.put("is_ferro_prototipo", ejemplarPrueba ? 1 : 0);
direccion.put("num_ferro_prototipo", ejemplarPrueba ? 1 : 0);
Map<String, Object> map = new HashMap<>();
map.put("direccion", direccion);
map.put("unidades", numeroUnidades);
map.put("entregaPalets", palets ? 1 : 0);
return map;
}
public Map<String, Object> toSkMapDepositoLegal() {
Map<String, Object> direccion = new HashMap<>();
direccion.put("cantidad", 4);
direccion.put("peso", 0);
direccion.put("att", "Unidades para Depósito Legal (sin envío)");
direccion.put("email", "");
direccion.put("direccion", "");
direccion.put("pais_code3", "esp");
direccion.put("cp", "");
direccion.put("municipio", "");
direccion.put("provincia", "");
direccion.put("telefono", "");
direccion.put("entregaPieCalle", 0);
direccion.put("is_ferro_prototipo", 0);
direccion.put("num_ferro_prototipo", 0);
Map<String, Object> map = new HashMap<>();
map.put("direccion", direccion);
map.put("unidades", 4);
map.put("entregaPalets", 0);
return map;
}
} }

View File

@ -14,6 +14,7 @@ import java.util.Objects;
import com.imprimelibros.erp.presupuesto.classes.PresupuestoFormatter; import com.imprimelibros.erp.presupuesto.classes.PresupuestoFormatter;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto; import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import com.imprimelibros.erp.presupuesto.service.PresupuestoService;
import com.imprimelibros.erp.cart.dto.CartDireccionRepository; import com.imprimelibros.erp.cart.dto.CartDireccionRepository;
import com.imprimelibros.erp.cart.dto.DireccionCardDTO; import com.imprimelibros.erp.cart.dto.DireccionCardDTO;
import com.imprimelibros.erp.cart.dto.DireccionShipment; import com.imprimelibros.erp.cart.dto.DireccionShipment;
@ -21,7 +22,8 @@ import com.imprimelibros.erp.cart.dto.UpdateCartRequest;
import com.imprimelibros.erp.common.Utils; import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.direcciones.DireccionService; import com.imprimelibros.erp.direcciones.DireccionService;
import com.imprimelibros.erp.externalApi.skApiClient; import com.imprimelibros.erp.externalApi.skApiClient;
import com.imprimelibros.erp.pedido.PedidoService; import com.imprimelibros.erp.pedidos.Pedido;
import com.imprimelibros.erp.pedidos.PedidoService;
import com.imprimelibros.erp.presupuesto.PresupuestoRepository; import com.imprimelibros.erp.presupuesto.PresupuestoRepository;
@Service @Service
@ -36,12 +38,13 @@ public class CartService {
private final DireccionService direccionService; private final DireccionService direccionService;
private final skApiClient skApiClient; private final skApiClient skApiClient;
private final PedidoService pedidoService; private final PedidoService pedidoService;
private final PresupuestoService presupuestoService;
public CartService(CartRepository cartRepo, CartItemRepository itemRepo, public CartService(CartRepository cartRepo, CartItemRepository itemRepo,
CartDireccionRepository cartDireccionRepo, MessageSource messageSource, CartDireccionRepository cartDireccionRepo, MessageSource messageSource,
PresupuestoFormatter presupuestoFormatter, PresupuestoRepository presupuestoRepo, PresupuestoFormatter presupuestoFormatter, PresupuestoRepository presupuestoRepo,
Utils utils, DireccionService direccionService, skApiClient skApiClient, Utils utils, DireccionService direccionService, skApiClient skApiClient,
PedidoService pedidoService) { PedidoService pedidoService, PresupuestoService presupuestoService) {
this.cartRepo = cartRepo; this.cartRepo = cartRepo;
this.itemRepo = itemRepo; this.itemRepo = itemRepo;
this.cartDireccionRepo = cartDireccionRepo; this.cartDireccionRepo = cartDireccionRepo;
@ -51,16 +54,14 @@ public class CartService {
this.direccionService = direccionService; this.direccionService = direccionService;
this.skApiClient = skApiClient; this.skApiClient = skApiClient;
this.pedidoService = pedidoService; this.pedidoService = pedidoService;
this.presupuestoService = presupuestoService;
} }
public Cart findById(Long cartId) { public Cart findById(Long cartId) {
return cartRepo.findById(cartId) return cartRepo.findById(cartId)
.orElseThrow(() -> new IllegalArgumentException("Carrito no encontrado")); .orElseThrow(() -> new IllegalArgumentException("Carrito no encontrado"));
} }
/** Devuelve el carrito activo o lo crea si no existe. */ /** Devuelve el carrito activo o lo crea si no existe. */
@Transactional @Transactional
public Cart getOrCreateActiveCart(Long userId) { public Cart getOrCreateActiveCart(Long userId) {
@ -190,14 +191,13 @@ public class CartService {
return resumen; return resumen;
} }
public Map<String, Object> getCartSummary(Cart cart, Locale locale) { public Map<String, Object> getCartSummaryRaw(Cart cart, Locale locale) {
double base = 0.0; double base = 0.0;
double iva4 = 0.0; double iva4 = 0.0;
double iva21 = 0.0; double iva21 = 0.0;
double shipment = 0.0; double shipment = 0.0;
boolean errorShipementCost = false;
Boolean errorShipementCost = false;
List<CartItem> items = cart.getItems(); List<CartItem> items = cart.getItems();
List<CartDireccion> direcciones = cart.getDirecciones(); List<CartDireccion> direcciones = cart.getDirecciones();
@ -208,55 +208,58 @@ public class CartService {
base += p.getBaseImponible().doubleValue(); base += p.getBaseImponible().doubleValue();
iva4 += p.getIvaImporte4().doubleValue(); iva4 += p.getIvaImporte4().doubleValue();
iva21 += p.getIvaImporte21().doubleValue(); iva21 += p.getIvaImporte21().doubleValue();
if (cart.getOnlyOneShipment() != null && cart.getOnlyOneShipment()) { if (cart.getOnlyOneShipment() != null && cart.getOnlyOneShipment()) {
// Si es envío único, que es a españa y no ha canarias if (direcciones != null && !direcciones.isEmpty()) {
if (direcciones != null && direcciones.size() > 0) {
CartDireccion cd = direcciones.get(0); CartDireccion cd = direcciones.get(0);
Boolean freeShipment = direccionService.checkFreeShipment(cd.getDireccion().getCp(), boolean freeShipment = direccionService.checkFreeShipment(
cd.getDireccion().getCp(),
cd.getDireccion().getPaisCode3()) && !cd.getIsPalets(); cd.getDireccion().getPaisCode3()) && !cd.getIsPalets();
if (!freeShipment) { if (!freeShipment) {
Integer unidades = p.getSelectedTirada(); Integer unidades = p.getSelectedTirada();
Map<String, Object> res = getShippingCost(cd, peso, unidades, locale); Map<String, Object> res = getShippingCost(cd, peso, unidades, locale);
if (res.get("success").equals(Boolean.FALSE)) { if (Boolean.FALSE.equals(res.get("success"))) {
errorShipementCost = true; errorShipementCost = true;
} } else {
else{
shipment += (Double) res.get("shipment"); shipment += (Double) res.get("shipment");
iva21 += (Double) res.get("iva21"); iva21 += (Double) res.get("iva21");
} }
} }
// si tiene prueba de envio, hay que añadir el coste
if (p.getServiciosJson() != null && p.getServiciosJson().contains("ejemplar-prueba")) {
// ejemplar de prueba
if (p.getServiciosJson() != null && p.getServiciosJson().contains("ejemplar-prueba")) {
Map<String, Object> res = getShippingCost(cd, peso, 1, locale); Map<String, Object> res = getShippingCost(cd, peso, 1, locale);
if (res.get("success").equals(Boolean.FALSE)) { if (Boolean.FALSE.equals(res.get("success"))) {
errorShipementCost = true; errorShipementCost = true;
} } else {
else{
shipment += (Double) res.get("shipment"); shipment += (Double) res.get("shipment");
iva21 += (Double) res.get("iva21"); iva21 += (Double) res.get("iva21");
} }
} }
} }
} else { } else {
// envio por cada presupuesto
// buscar la direccion asignada a este presupuesto
if (direcciones == null) if (direcciones == null)
continue; continue;
List<CartDireccion> cd_presupuesto = direcciones.stream() List<CartDireccion> cd_presupuesto = direcciones.stream()
.filter(d -> d.getPresupuesto() != null && d.getPresupuesto().getId().equals(p.getId()) .filter(d -> d.getPresupuesto() != null
&& d.getUnidades() != null && d.getUnidades() != null && d.getUnidades() > 0) && d.getPresupuesto().getId().equals(p.getId())
&& d.getUnidades() != null
&& d.getUnidades() > 0)
.toList(); .toList();
Boolean firstDirection = true;
boolean firstDirection = true;
for (CartDireccion cd : cd_presupuesto) { for (CartDireccion cd : cd_presupuesto) {
Integer unidades = cd.getUnidades(); Integer unidades = cd.getUnidades();
if (firstDirection) { if (firstDirection) {
Boolean freeShipment = direccionService.checkFreeShipment(cd.getDireccion().getCp(), boolean freeShipment = direccionService.checkFreeShipment(
cd.getDireccion().getCp(),
cd.getDireccion().getPaisCode3()) && !cd.getIsPalets(); cd.getDireccion().getPaisCode3()) && !cd.getIsPalets();
if (!freeShipment && unidades != null && unidades > 0) { if (!freeShipment && unidades != null && unidades > 0) {
Map<String, Object> res = getShippingCost(cd, peso, unidades, locale); Map<String, Object> res = getShippingCost(cd, peso, unidades, locale);
if (res.get("success").equals(Boolean.FALSE)) { if (Boolean.FALSE.equals(res.get("success"))) {
errorShipementCost = true; errorShipementCost = true;
} else { } else {
shipment += (Double) res.get("shipment"); shipment += (Double) res.get("shipment");
@ -266,7 +269,7 @@ public class CartService {
firstDirection = false; firstDirection = false;
} else { } else {
Map<String, Object> res = getShippingCost(cd, peso, unidades, locale); Map<String, Object> res = getShippingCost(cd, peso, unidades, locale);
if (res.get("success").equals(Boolean.FALSE)) { if (Boolean.FALSE.equals(res.get("success"))) {
errorShipementCost = true; errorShipementCost = true;
} else { } else {
shipment += (Double) res.get("shipment"); shipment += (Double) res.get("shipment");
@ -274,18 +277,19 @@ public class CartService {
} }
} }
} }
// ejemplar de prueba // ejemplar de prueba
CartDireccion cd_prueba = direcciones.stream() CartDireccion cd_prueba = direcciones.stream()
.filter(d -> d.getPresupuesto() != null && d.getPresupuesto().getId().equals(p.getId()) .filter(d -> d.getPresupuesto() != null
&& d.getPresupuesto().getId().equals(p.getId())
&& d.getUnidades() == null) && d.getUnidades() == null)
.findFirst().orElse(null); .findFirst().orElse(null);
if (cd_prueba != null) {
if (cd_prueba != null) {
Map<String, Object> res = getShippingCost(cd_prueba, peso, 1, locale); Map<String, Object> res = getShippingCost(cd_prueba, peso, 1, locale);
if (res.get("success").equals(Boolean.FALSE)) { if (Boolean.FALSE.equals(res.get("success"))) {
errorShipementCost = true; errorShipementCost = true;
} } else {
else{
shipment += (Double) res.get("shipment"); shipment += (Double) res.get("shipment");
iva21 += (Double) res.get("iva21"); iva21 += (Double) res.get("iva21");
} }
@ -293,11 +297,44 @@ public class CartService {
} }
} }
double total = base + iva4 + iva21 + shipment; double totalBeforeDiscount = base + iva4 + iva21 + shipment;
int fidelizacion = pedidoService.getDescuentoFidelizacion(); int fidelizacion = pedidoService.getDescuentoFidelizacion();
double descuento = (total) * fidelizacion / 100.0; double descuento = totalBeforeDiscount * fidelizacion / 100.0;
total -= descuento; double total = totalBeforeDiscount - descuento;
// Redondeo a 2 decimales
base = Utils.round2(base);
iva4 = Utils.round2(iva4);
iva21 = Utils.round2(iva21);
shipment = Utils.round2(shipment);
descuento = Utils.round2(descuento);
total = Utils.round2(total);
Map<String, Object> summary = new HashMap<>();
summary.put("base", base);
summary.put("iva4", iva4);
summary.put("iva21", iva21);
summary.put("shipment", shipment);
summary.put("fidelizacion", fidelizacion);
summary.put("descuento", descuento);
summary.put("total", total);
summary.put("amountCents", Math.round(total * 100));
summary.put("errorShipmentCost", errorShipementCost);
summary.put("cartId", cart.getId());
return summary;
}
public Map<String, Object> getCartSummary(Cart cart, Locale locale) {
Map<String, Object> raw = getCartSummaryRaw(cart, locale);
double base = (Double) raw.get("base");
double iva4 = (Double) raw.get("iva4");
double iva21 = (Double) raw.get("iva21");
double shipment = (Double) raw.get("shipment");
int fidelizacion = (Integer) raw.get("fidelizacion");
double descuento = (Double) raw.get("descuento");
double total = (Double) raw.get("total");
Map<String, Object> summary = new HashMap<>(); Map<String, Object> summary = new HashMap<>();
summary.put("base", Utils.formatCurrency(base, locale)); summary.put("base", Utils.formatCurrency(base, locale));
@ -305,11 +342,11 @@ public class CartService {
summary.put("iva21", Utils.formatCurrency(iva21, locale)); summary.put("iva21", Utils.formatCurrency(iva21, locale));
summary.put("shipment", Utils.formatCurrency(shipment, locale)); summary.put("shipment", Utils.formatCurrency(shipment, locale));
summary.put("fidelizacion", fidelizacion + "%"); summary.put("fidelizacion", fidelizacion + "%");
summary.put("descuento", Utils.formatCurrency(-descuento, locale)); summary.put("descuento", Utils.formatCurrency(-descuento, locale)); // negativo para mostrar
summary.put("total", Utils.formatCurrency(total, locale)); summary.put("total", Utils.formatCurrency(total, locale));
summary.put("amountCents", Math.round(total * 100)); summary.put("amountCents", raw.get("amountCents"));
summary.put("errorShipmentCost", errorShipementCost); summary.put("errorShipmentCost", raw.get("errorShipmentCost"));
summary.put("cartId", cart.getId()); summary.put("cartId", raw.get("cartId"));
return summary; return summary;
} }
@ -404,13 +441,160 @@ public class CartService {
// delete cart directions by direccion id in ACTIVE carts // delete cart directions by direccion id in ACTIVE carts
@Transactional @Transactional
public void deleteCartDireccionesByDireccionId(Long direccionId) { public void deleteCartDireccionesByDireccionId(Long direccionId) {
/*List<CartDireccion> cartDirecciones = cartDireccionRepo.findByDireccion_IdAndCart_Status(direccionId, Cart.Status.ACTIVE);
for (CartDireccion cd : cartDirecciones) {
cartDireccionRepo.deleteById(cd.getId());
}*/
cartDireccionRepo.deleteByDireccionIdAndCartStatus(direccionId, Cart.Status.ACTIVE); cartDireccionRepo.deleteByDireccionIdAndCartStatus(direccionId, Cart.Status.ACTIVE);
} }
@Transactional
public Long crearPedido(Long cartId, Locale locale) {
Cart cart = this.getCartById(cartId);
List<CartItem> items = cart.getItems();
List<Map<String, Object>> presupuestoRequests = new ArrayList<>();
List<Long> presupuestoIds = new ArrayList<>();
for (Integer i = 0; i < items.size(); i++) {
CartItem item = items.get(i);
Presupuesto p = item.getPresupuesto();
Map<String, Object> data_to_send = presupuestoService.toSkApiRequest(p, true);
data_to_send.put("createPedido", 0);
if (items.size() > 1) {
// Recuperar el mapa anidado datosCabecera
@SuppressWarnings("unchecked")
Map<String, Object> datosCabecera = (Map<String, Object>) data_to_send.get("datosCabecera");
if (datosCabecera != null) {
Object tituloOriginal = datosCabecera.get("titulo");
datosCabecera.put(
"titulo",
"[" + (i + 1) + "/" + items.size() + "] " + (tituloOriginal != null ? tituloOriginal : ""));
}
}
Map<String, Object> direcciones_presupuesto = this.getDireccionesPresupuesto(cart, p);
data_to_send.put("direcciones", direcciones_presupuesto.get("direcciones"));
data_to_send.put("direccionesFP1", direcciones_presupuesto.get("direccionesFP1"));
Map<String, Object> result = skApiClient.savePresupuesto(data_to_send);
if (result.containsKey("error")) {
System.out.println("Error al guardar presupuesto en SK: " + result.get("error"));
// decide si seguir con otros items o abortar:
// continue; o bien throw ...
continue;
}
Object dataObj = result.get("data");
if (!(dataObj instanceof Map<?, ?> dataRaw)) {
System.out.println("Formato inesperado de 'data' en savePresupuesto: " + result);
continue;
}
@SuppressWarnings("unchecked")
Map<String, Object> dataMap = (Map<String, Object>) dataRaw;
Long presId = ((Number) dataMap.get("id")).longValue();
String skin = ((String) dataMap.get("iskn")).toString();
p.setProveedor("Safekat");
p.setProveedorRef1(skin);
p.setProveedorRef2(presId);
p.setEstado(Presupuesto.Estado.aceptado);
presupuestoRepo.save(p);
presupuestoIds.add(p.getId());
presupuestoRequests.add(dataMap);
}
// Crear el pedido en base a los presupuestos guardados
if (presupuestoRequests.isEmpty()) {
throw new IllegalStateException("No se pudieron guardar los presupuestos en SK.");
} else {
ArrayList<Long> presupuestoSkIds = new ArrayList<>();
for (Map<String, Object> presData : presupuestoRequests) {
Long presId = ((Number) presData.get("id")).longValue();
presupuestoSkIds.add(presId);
}
Map<String, Object> ids = new HashMap<>();
ids.put("presupuesto_ids", presupuestoSkIds);
Long pedidoId = skApiClient.crearPedido(ids);
if (pedidoId == null) {
throw new IllegalStateException("No se pudo crear el pedido en SK.");
}
Pedido pedidoInterno = pedidoService.crearPedido(presupuestoIds, this.getCartSummaryRaw(cart, locale),
"Safekat", String.valueOf(pedidoId), cart.getUserId());
return pedidoInterno.getId();
}
}
public Map<String, Object> getDireccionesPresupuesto(Cart cart, Presupuesto presupuesto) {
List<Map<String, Object>> direccionesPresupuesto = new ArrayList<>();
List<Map<String, Object>> direccionesPrueba = new ArrayList<>();
List<CartDireccion> direcciones = cart.getDirecciones().stream()
.filter(d -> d.getPresupuesto() != null && d.getPresupuesto().getId().equals(presupuesto.getId()))
.toList();
if (cart.getOnlyOneShipment()) {
direcciones = direcciones.stream().limit(1).toList();
if (!direcciones.isEmpty()) {
direccionesPresupuesto.add(direcciones.get(0).toSkMap(
presupuesto.getSelectedTirada(),
presupuesto.getPeso(),
direcciones.get(0).getIsPalets(),
false));
if (presupuesto.getServiciosJson() != null
&& presupuesto.getServiciosJson().contains("ejemplar-prueba")) {
direccionesPrueba.add(direcciones.get(0).toSkMap(
1,
presupuesto.getPeso(),
false,
true));
}
if (presupuesto.getServiciosJson() != null
&& presupuesto.getServiciosJson().contains("deposito-legal")) {
direccionesPresupuesto.add(direcciones.get(0).toSkMapDepositoLegal());
}
Map<String, Object> direccionesRet = new HashMap<>();
direccionesRet.put("direcciones", direccionesPresupuesto);
direccionesRet.put("direccionesFP1", direccionesPrueba.get(0));
return direccionesRet;
}
} else {
for (CartDireccion cd : cart.getDirecciones()) {
// direccion de ejemplar de prueba
if (cd.getPresupuesto() == null || !cd.getPresupuesto().getId().equals(presupuesto.getId())) {
continue;
}
if (cd.getUnidades() == null || cd.getUnidades() <= 0) {
direccionesPrueba.add(cd.toSkMap(
1,
presupuesto.getPeso(),
false,
true));
} else {
direccionesPresupuesto.add(cd.toSkMap(
cd.getUnidades(),
presupuesto.getPeso(),
cd.getIsPalets(),
false));
}
}
if (presupuesto.getServiciosJson() != null
&& presupuesto.getServiciosJson().contains("deposito-legal")) {
CartDireccion cd = new CartDireccion();
direccionesPresupuesto.add(cd.toSkMapDepositoLegal());
}
}
Map<String, Object> direccionesRet = new HashMap<>();
direccionesRet.put("direcciones", direccionesPresupuesto);
if (!direccionesPrueba.isEmpty())
direccionesRet.put("direccionesFP1", direccionesPrueba.get(0));
else {
direccionesRet.put("direccionesFP1", new ArrayList<>());
}
return direccionesRet;
}
/*************************************** /***************************************
* MÉTODOS PRIVADOS * MÉTODOS PRIVADOS
***************************************/ ***************************************/

View File

@ -47,10 +47,37 @@ public class Utils {
this.messageSource = messageSource; this.messageSource = messageSource;
} }
public static List<Map<String, Object>> decodeJsonList(String json) {
ObjectMapper mapper = new ObjectMapper();
try {
return mapper.readValue(json, new TypeReference<List<Map<String, Object>>>() {
});
} catch (JsonProcessingException e) {
return new ArrayList<>();
}
}
public static Map<String, Object> decodeJsonMap(String json) {
ObjectMapper mapper = new ObjectMapper();
try {
return mapper.readValue(json, new TypeReference<Map<String, Object>>() {
});
} catch (JsonProcessingException e) {
return new HashMap<>();
}
}
public static double round2(double value) {
return BigDecimal.valueOf(value)
.setScale(2, RoundingMode.HALF_UP)
.doubleValue();
}
public static boolean isCurrentUserAdmin() { public static boolean isCurrentUserAdmin() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth.getAuthorities().stream() return auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN") || a.getAuthority().equals("ROLE_SUPERADMIN")); .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")
|| a.getAuthority().equals("ROLE_SUPERADMIN"));
} }
public static Long currentUserId(Principal principal) { public static Long currentUserId(Principal principal) {

View File

@ -2,6 +2,8 @@ package com.imprimelibros.erp.direcciones;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.io.Serializable; import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;
import org.hibernate.annotations.SQLDelete; import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction; import org.hibernate.annotations.SQLRestriction;

View File

@ -128,6 +128,125 @@ public class skApiClient {
}); });
} }
public Map<String, Object> savePresupuesto(Map<String, Object> requestBody) {
return performWithRetryMap(() -> {
String url = this.skApiUrl + "api/guardar";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(authService.getToken());
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
ResponseEntity<String> response = restTemplate.exchange(
url,
HttpMethod.POST,
entity,
String.class);
ObjectMapper mapper = new ObjectMapper();
try {
Map<String, Object> responseBody = mapper.readValue(
response.getBody(),
new TypeReference<Map<String, Object>>() {
});
// Si la API devuelve "error" a nivel raíz
if (responseBody.get("error") != null) {
// Devolvemos un mapa con sólo el error para que el caller decida
return Map.of("error", responseBody.get("error"));
}
Object dataObj = responseBody.get("data");
if (dataObj instanceof Map<?, ?> dataRaw) {
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) dataRaw;
Boolean success = (Boolean) (responseBody.get("success") != null ? responseBody.get("success")
: false);
Long id = ((Integer) data.get("id")).longValue();
String iskn = (String) data.get("iskn");
// OJO: aquí mantengo tu lógica tal cual (success == null o false => OK)
// Si tu API realmente usa success=true como éxito, esto habría que invertirlo.
if (success != null && success) {
if (id != null && iskn != null) {
data.put("id", Long.valueOf(id));
data.put("iskn", iskn);
}
} else {
// Tu lógica actual: si success es true u otra cosa → error 2
return Map.of("error", 2);
}
// Devolvemos sólo la parte interesante: el data ya enriquecido
return Map.of("data", data);
}
// Si data no es un Map, devolvemos error genérico
return Map.of("error", 1);
} catch (JsonProcessingException e) {
e.printStackTrace();
return Map.of("error", 1);
}
});
}
public Long crearPedido(Map<String, Object> requestBody) {
Map<String, Object> result = performWithRetryMap(() -> {
String url = this.skApiUrl + "api/crear-pedido";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(authService.getToken());
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
ResponseEntity<String> response = restTemplate.exchange(
url,
HttpMethod.POST,
entity,
String.class);
ObjectMapper mapper = new ObjectMapper();
try {
Map<String, Object> responseBody = mapper.readValue(
response.getBody(),
new TypeReference<Map<String, Object>>() {
});
// Si la API devuelve "error" a nivel raíz
if (responseBody.get("error") != null) {
// Devolvemos un mapa con sólo el error para que el caller decida
return Map.of("error", responseBody.get("error"));
}
Boolean success = (Boolean) (responseBody.get("success") != null ? responseBody.get("success") : false);
Long id = ((Integer) responseBody.get("id")).longValue();
if (success != null && id != null && success) {
return Map.of("data", id);
} else {
// Tu lógica actual: si success es true u otra cosa → error 2
return Map.of("error", 2);
}
} catch (JsonProcessingException e) {
e.printStackTrace();
return Map.of("error", 1);
}
});
if (result.get("error") != null) {
throw new RuntimeException("Error al crear el pedido: " + result.get("error"));
}
return (Long) result.get("data");
}
public Integer getMaxSolapas(Map<String, Object> requestBody, Locale locale) { public Integer getMaxSolapas(Map<String, Object> requestBody, Locale locale) {
try { try {
String jsonResponse = performWithRetry(() -> { String jsonResponse = performWithRetry(() -> {
@ -238,7 +357,6 @@ public class skApiClient {
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(authService.getToken()); headers.setBearerAuth(authService.getToken());
ResponseEntity<String> response = restTemplate.exchange( ResponseEntity<String> response = restTemplate.exchange(
uri, uri,
HttpMethod.GET, HttpMethod.GET,
@ -255,10 +373,10 @@ public class skApiClient {
return Map.of("error", messageSource.getMessage("direcciones.error.noShippingCost", null, locale)); return Map.of("error", messageSource.getMessage("direcciones.error.noShippingCost", null, locale));
} else { } else {
Double total = Optional.ofNullable(responseBody.get("data")) Double total = Optional.ofNullable(responseBody.get("data"))
.filter(Number.class::isInstance) .filter(Number.class::isInstance)
.map(Number.class::cast) .map(Number.class::cast)
.map(Number::doubleValue) .map(Number::doubleValue)
.orElse(0.0); .orElse(0.0);
return Map.of("data", total); return Map.of("data", total);
} }

View File

@ -295,11 +295,11 @@ public class PaymentController {
} }
@PostMapping(value = "/transfer/completed/{id}", produces = "application/json") @PostMapping(value = "/transfer/completed/{id}", produces = "application/json")
public ResponseEntity<Map<String, Object>> markTransferAsCaptured(@PathVariable Long id) { public ResponseEntity<Map<String, Object>> markTransferAsCaptured(@PathVariable Long id, Locale locale) {
Map<String, Object> response; Map<String, Object> response;
try { try {
paymentService.markBankTransferAsCaptured(id); paymentService.markBankTransferAsCaptured(id, locale);
response = Map.of("success", true); response = Map.of("success", true);
return ResponseEntity.ok(response); return ResponseEntity.ok(response);

View File

@ -15,6 +15,7 @@ import org.springframework.transaction.annotation.Transactional;
import com.imprimelibros.erp.payments.repo.WebhookEventRepository; import com.imprimelibros.erp.payments.repo.WebhookEventRepository;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Locale;
import java.util.Objects; import java.util.Objects;
@Service @Service
@ -36,7 +37,7 @@ public class PaymentService {
this.payRepo = payRepo; this.payRepo = payRepo;
this.txRepo = txRepo; this.txRepo = txRepo;
this.refundRepo = refundRepo; this.refundRepo = refundRepo;
this.redsysService = redsysService; this.redsysService = redsysService;
this.webhookEventRepo = webhookEventRepo; this.webhookEventRepo = webhookEventRepo;
this.cartService = cartService; this.cartService = cartService;
} }
@ -82,7 +83,7 @@ public class PaymentService {
} }
@Transactional @Transactional
public void handleRedsysNotification(String dsSignature, String dsMerchantParameters) throws Exception { public void handleRedsysNotification(String dsSignature, String dsMerchantParameters, Locale locale) throws Exception {
// 0) Intentamos parsear la notificación. Si falla, registramos el webhook crudo // 0) Intentamos parsear la notificación. Si falla, registramos el webhook crudo
// y salimos. // y salimos.
@ -197,7 +198,7 @@ public class PaymentService {
} }
if (authorized) { if (authorized) {
processOrder(notif.cartId); processOrder(notif.cartId, locale);
} }
payRepo.save(p); payRepo.save(p);
@ -317,7 +318,7 @@ public class PaymentService {
} }
@Transactional @Transactional
public void markBankTransferAsCaptured(Long paymentId) { public void markBankTransferAsCaptured(Long paymentId, Locale locale) {
Payment p = payRepo.findById(paymentId) Payment p = payRepo.findById(paymentId)
.orElseThrow(() -> new IllegalArgumentException("Payment no encontrado: " + paymentId)); .orElseThrow(() -> new IllegalArgumentException("Payment no encontrado: " + paymentId));
@ -354,7 +355,7 @@ public class PaymentService {
// 4) Procesar el pedido asociado al carrito (si existe) // 4) Procesar el pedido asociado al carrito (si existe)
if (p.getOrderId() != null) { if (p.getOrderId() != null) {
processOrder(p.getOrderId()); processOrder(p.getOrderId(), locale);
} }
} }
@ -450,17 +451,26 @@ public class PaymentService {
return code >= 0 && code <= 99; return code >= 0 && code <= 99;
} }
private Boolean processOrder(Long cartId) { /**
// GENERAR PEDIDO A PARTIR DEL CARRITO * Procesa el pedido asociado al carrito:
* - bloquea el carrito
* - crea el pedido a partir del carrito
*
*/
private Boolean processOrder(Long cartId, Locale locale) {
Cart cart = this.cartService.findById(cartId); Cart cart = this.cartService.findById(cartId);
if (cart != null) { if (cart != null) {
// Bloqueamos el carrito // Bloqueamos el carrito
this.cartService.lockCartById(cart.getId()); this.cartService.lockCartById(cart.getId());
// order ID es generado dentro de createOrderFromCart donde se marcan los // Creamos el pedido
// presupuestos como no editables Long orderId = this.cartService.crearPedido(cart.getId(), locale);
// Long orderId = if(orderId == null){
// this.cartService.pedidoService.createOrderFromCart(cart.getId(), p.getId()); return false;
// p.setOrderId(orderId); }
else{
// envio de correo de confirmacion de pedido podria ir aqui
}
} }
return true; return true;

View File

@ -1,26 +0,0 @@
package com.imprimelibros.erp.pedido;
import org.springframework.stereotype.Service;
@Service
public class PedidoService {
public int getDescuentoFidelizacion() {
// descuento entre el 1% y el 6% para clientes fidelidad (mas de 1500€ en el ultimo año)
double totalGastado = 1600.0; // Ejemplo, deberías obtenerlo del historial del cliente
if(totalGastado < 1200) {
return 0;
} else if(totalGastado >= 1200 && totalGastado < 1999) {
return 1;
} else if(totalGastado >= 2000 && totalGastado < 2999) {
return 2;
} else if(totalGastado >= 3000 && totalGastado < 3999) {
return 3;
} else if(totalGastado >= 4000 && totalGastado < 4999) {
return 4;
} else if(totalGastado >= 5000) {
return 5;
}
return 0;
}
}

View File

@ -0,0 +1,191 @@
package com.imprimelibros.erp.pedidos;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "pedidos")
public class Pedido {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Campos económicos
@Column(name = "base", nullable = false)
private Double base;
@Column(name = "envio", nullable = false)
private Double envio = 0.0;
@Column(name = "iva4", nullable = false)
private Double iva4 = 0.0;
@Column(name = "iva21", nullable = false)
private Double iva21 = 0.0;
@Column(name = "descuento", nullable = false)
private Double descuento = 0.0;
@Column(name = "total", nullable = false)
private Double total = 0.0;
// Datos de proveedor
@Column(name = "proveedor", length = 100)
private String proveedor;
@Column(name = "proveedor_ref", length = 100)
private String proveedorRef;
// Auditoría básica (coincidiendo con las columnas que se ven en la captura)
@Column(name = "created_by")
private Long createdBy;
@Column(name = "updated_by")
private Long updatedBy;
@Column(name = "deleted_by")
private Long deletedBy;
@Column(name = "deleted", nullable = false)
private boolean deleted = false;
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
// --- Getters y setters ---
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Double getBase() {
return base;
}
public void setBase(Double base) {
this.base = base;
}
public Double getEnvio() {
return envio;
}
public void setEnvio(Double envio) {
this.envio = envio;
}
public Double getIva4() {
return iva4;
}
public void setIva4(Double iva4) {
this.iva4 = iva4;
}
public Double getIva21() {
return iva21;
}
public void setIva21(Double iva21) {
this.iva21 = iva21;
}
public Double getDescuento() {
return descuento;
}
public void setDescuento(Double descuento) {
this.descuento = descuento;
}
public Double getTotal() {
return total;
}
public void setTotal(Double total) {
this.total = total;
}
public String getProveedor() {
return proveedor;
}
public void setProveedor(String proveedor) {
this.proveedor = proveedor;
}
public String getProveedorRef() {
return proveedorRef;
}
public void setProveedorRef(String proveedorRef) {
this.proveedorRef = proveedorRef;
}
public Long getCreatedBy() {
return createdBy;
}
public void setCreatedBy(Long createdBy) {
this.createdBy = createdBy;
}
public Long getUpdatedBy() {
return updatedBy;
}
public void setUpdatedBy(Long updatedBy) {
this.updatedBy = updatedBy;
}
public Long getDeletedBy() {
return deletedBy;
}
public void setDeletedBy(Long deletedBy) {
this.deletedBy = deletedBy;
}
public boolean isDeleted() {
return deleted;
}
public void setDeleted(boolean deleted) {
this.deleted = deleted;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public LocalDateTime getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(LocalDateTime deletedAt) {
this.deletedAt = deletedAt;
}
}

View File

@ -0,0 +1,71 @@
package com.imprimelibros.erp.pedidos;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
@Entity
@Table(name = "pedidos_lineas")
public class PedidoLinea {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "pedido_id", nullable = false)
private Pedido pedido;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "presupuesto_id", nullable = false)
private Presupuesto presupuesto;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "created_by", nullable = false)
private Long createdBy;
// --- Getters y setters ---
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Pedido getPedido() {
return pedido;
}
public void setPedido(Pedido pedido) {
this.pedido = pedido;
}
public Presupuesto getPresupuesto() {
return presupuesto;
}
public void setPresupuesto(Presupuesto presupuesto) {
this.presupuesto = presupuesto;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public Long getCreatedBy() {
return createdBy;
}
public void setCreatedBy(Long createdBy) {
this.createdBy = createdBy;
}
}

View File

@ -0,0 +1,14 @@
package com.imprimelibros.erp.pedidos;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface PedidoLineaRepository extends JpaRepository<PedidoLinea, Long> {
List<PedidoLinea> findByPedidoId(Long pedidoId);
List<PedidoLinea> findByPresupuestoId(Long presupuestoId);
}

View File

@ -0,0 +1,10 @@
package com.imprimelibros.erp.pedidos;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface PedidoRepository extends JpaRepository<Pedido, Long> {
// aquí podrás añadir métodos tipo:
// List<Pedido> findByDeletedFalse();
}

View File

@ -0,0 +1,102 @@
package com.imprimelibros.erp.pedidos;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.imprimelibros.erp.presupuesto.PresupuestoRepository;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import com.imprimelibros.erp.presupuesto.service.PresupuestoService;
@Service
public class PedidoService {
private final PedidoRepository pedidoRepository;
private final PedidoLineaRepository pedidoLineaRepository;
private final PresupuestoRepository presupuestoRepository;
public PedidoService(PedidoRepository pedidoRepository, PedidoLineaRepository pedidoLineaRepository,
PresupuestoRepository presupuestoRepository) {
this.pedidoRepository = pedidoRepository;
this.pedidoLineaRepository = pedidoLineaRepository;
this.presupuestoRepository = presupuestoRepository;
}
public int getDescuentoFidelizacion() {
// descuento entre el 1% y el 6% para clientes fidelidad (mas de 1500€ en el
// ultimo año)
double totalGastado = 1600.0; // Ejemplo, deberías obtenerlo del historial del cliente
if (totalGastado < 1200) {
return 0;
} else if (totalGastado >= 1200 && totalGastado < 1999) {
return 1;
} else if (totalGastado >= 2000 && totalGastado < 2999) {
return 2;
} else if (totalGastado >= 3000 && totalGastado < 3999) {
return 3;
} else if (totalGastado >= 4000 && totalGastado < 4999) {
return 4;
} else if (totalGastado >= 5000) {
return 5;
}
return 0;
}
/**
* Crea un pedido a partir de:
* - lista de IDs de presupuesto
* - resumen numérico del carrito (getCartSummaryRaw)
* - datos de proveedor
* - usuario que crea el pedido
*/
@Transactional
public Pedido crearPedido(List<Long> presupuestoIds,
Map<String, Object> cartSummaryRaw,
String proveedor,
String proveedorRef,
Long userId) {
Pedido pedido = new Pedido();
// Datos económicos (ojo con las claves, son las del summaryRaw)
pedido.setBase((Double) cartSummaryRaw.getOrDefault("base", 0.0d));
pedido.setEnvio((Double) cartSummaryRaw.getOrDefault("shipment", 0.0d));
pedido.setIva4((Double) cartSummaryRaw.getOrDefault("iva4", 0.0d));
pedido.setIva21((Double) cartSummaryRaw.getOrDefault("iva21", 0.0d));
pedido.setDescuento((Double) cartSummaryRaw.getOrDefault("descuento", 0.0d));
pedido.setTotal((Double) cartSummaryRaw.getOrDefault("total", 0.0d));
// Proveedor
pedido.setProveedor(proveedor);
pedido.setProveedorRef(proveedorRef);
// Auditoría mínima
pedido.setCreatedBy(userId);
pedido.setCreatedAt(LocalDateTime.now());
pedido.setDeleted(false);
pedido.setUpdatedAt(LocalDateTime.now());
pedido.setUpdatedBy(userId);
// Guardamos el pedido
Pedido saved = pedidoRepository.save(pedido);
// Crear líneas del pedido
for (Long presupuestoId : presupuestoIds) {
Presupuesto presupuesto = presupuestoRepository.getReferenceById(presupuestoId);
PedidoLinea linea = new PedidoLinea();
linea.setPedido(saved);
linea.setPresupuesto(presupuesto);
linea.setCreatedBy(userId);
linea.setCreatedAt(LocalDateTime.now());
pedidoLineaRepository.save(linea);
}
return saved;
}
}

View File

@ -52,6 +52,7 @@ import com.imprimelibros.erp.users.UserDao;
import com.imprimelibros.erp.users.UserDetailsImpl; import com.imprimelibros.erp.users.UserDetailsImpl;
import com.imprimelibros.erp.presupuesto.service.PresupuestoFormDataMapper; import com.imprimelibros.erp.presupuesto.service.PresupuestoFormDataMapper;
import com.imprimelibros.erp.presupuesto.service.PresupuestoFormDataMapper.PresupuestoFormDataDto; import com.imprimelibros.erp.presupuesto.service.PresupuestoFormDataMapper.PresupuestoFormDataDto;
import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.common.web.IpUtils; import com.imprimelibros.erp.common.web.IpUtils;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@ -561,6 +562,20 @@ public class PresupuestoController {
return "redirect:/presupuesto"; return "redirect:/presupuesto";
} }
if(presupuestoOpt.get().getEstado() == Presupuesto.Estado.aceptado){
Map<String, Object> resumen = presupuestoService.getTextosResumen(
presupuestoOpt.get(),
Utils.decodeJsonList(presupuestoOpt.get().getServiciosJson()),
Utils.decodeJsonMap(presupuestoOpt.get().getDatosMaquetacionJson()),
Utils.decodeJsonMap(presupuestoOpt.get().getDatosMarcapaginasJson()),
locale);
model.addAttribute("resumen", resumen);
model.addAttribute("presupuesto", presupuestoOpt.get());
return "imprimelibros/presupuestos/presupuestador-view";
}
if (!presupuestoService.canAccessPresupuesto(presupuestoOpt.get(), authentication)) { if (!presupuestoService.canAccessPresupuesto(presupuestoOpt.get(), authentication)) {
// Añadir mensaje flash para mostrar alerta // Añadir mensaje flash para mostrar alerta
redirectAttributes.addFlashAttribute("errorMessage", redirectAttributes.addFlashAttribute("errorMessage",
@ -573,7 +588,7 @@ public class PresupuestoController {
String path = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()) String path = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes())
.getRequest().getRequestURI(); .getRequest().getRequestURI();
String mode = path.contains("/view/") ? "view" : "edit"; String mode = path.contains("/view/") ? "view" : "edit";
if (mode.equals("view")) { if (mode.equals("view") || presupuestoOpt.get().getEstado() != Presupuesto.Estado.borrador) {
model.addAttribute("appMode", "view"); model.addAttribute("appMode", "view");
} else { } else {
model.addAttribute("cliente_id", presupuestoOpt.get().getUser().getId()); model.addAttribute("cliente_id", presupuestoOpt.get().getUser().getId());

View File

@ -120,7 +120,7 @@ public class PresupuestoDatatableService {
String id = String.valueOf(p.getId()); String id = String.valueOf(p.getId());
String editBtn = "<a href=\"javascript:void(0);\" data-id=\"" + id + "\" class=\"link-success btn-edit-" + String editBtn = "<a href=\"javascript:void(0);\" data-id=\"" + id + "\" class=\"link-success btn-edit-" +
(p.getOrigen().equals(Presupuesto.Origen.publico) ? "anonimo" : "privado") + " fs-15\"><i class=\"ri-" + (p.getOrigen().equals(Presupuesto.Origen.publico) ? "anonimo" : "privado") + " fs-15\"><i class=\"ri-" +
(p.getOrigen().equals(Presupuesto.Origen.publico) ? "eye" : "pencil") + "-line\"></i></a>"; (p.getOrigen().equals(Presupuesto.Origen.publico) || p.getEstado() == Presupuesto.Estado.aceptado ? "eye" : "pencil") + "-line\"></i></a>";
String deleteBtn = borrador ? "<a href=\"javascript:void(0);\" data-id=\"" + id String deleteBtn = borrador ? "<a href=\"javascript:void(0);\" data-id=\"" + id
+ "\" class=\"link-danger btn-delete-" + "\" class=\"link-danger btn-delete-"

View File

@ -99,23 +99,27 @@ public class Presupuesto extends AbstractAuditedEntity implements Cloneable {
modificado("presupuesto.estado.modificado"); modificado("presupuesto.estado.modificado");
private final String messageKey; private final String messageKey;
Estado(String messageKey) { Estado(String messageKey) {
this.messageKey = messageKey; this.messageKey = messageKey;
} }
public String getMessageKey() { public String getMessageKey() {
return messageKey; return messageKey;
} }
} }
public enum Entrega{ public enum Entrega {
peninsula("presupuesto.entrega.peninsula"), peninsula("presupuesto.entrega.peninsula"),
canarias("presupuesto.entrega.canarias"), canarias("presupuesto.entrega.canarias"),
paises_ue("presupuesto.entrega.paises-ue"); paises_ue("presupuesto.entrega.paises-ue");
private final String messageKey; private final String messageKey;
Entrega(String messageKey) { Entrega(String messageKey) {
this.messageKey = messageKey; this.messageKey = messageKey;
} }
public String getMessageKey() { public String getMessageKey() {
return messageKey; return messageKey;
} }
@ -371,6 +375,18 @@ public class Presupuesto extends AbstractAuditedEntity implements Cloneable {
@Column(name = "alto_faja") @Column(name = "alto_faja")
private Integer altoFaja = 0; private Integer altoFaja = 0;
@Column(name = "comentario", columnDefinition = "TEXT")
private String comentario;
@Column(name = "proveedor", length = 100)
private String proveedor;
@Column(name = "proveedor_ref1", length = 100)
private String proveedorRef1;
@Column(name = "proveedor_ref2")
private Long proveedorRef2;
// ====== MÉTODOS AUX ====== // ====== MÉTODOS AUX ======
public String resumenPresupuesto() { public String resumenPresupuesto() {
@ -912,16 +928,48 @@ public class Presupuesto extends AbstractAuditedEntity implements Cloneable {
this.altoFaja = altoFaja; this.altoFaja = altoFaja;
} }
public Long getId(){ public Long getId() {
return id; return id;
} }
public void setId(Long id){ public String getComentario() {
return comentario;
}
public void setComentario(String comentario) {
this.comentario = comentario;
}
public String getProveedor() {
return proveedor;
}
public void setProveedor(String proveedor) {
this.proveedor = proveedor;
}
public String getProveedorRef1() {
return proveedorRef1;
}
public void setProveedorRef1(String proveedorRef1) {
this.proveedorRef1 = proveedorRef1;
}
public Long getProveedorRef2() {
return proveedorRef2;
}
public void setProveedorRef2(Long proveedorRef2) {
this.proveedorRef2 = proveedorRef2;
}
public void setId(Long id) {
this.id = id; this.id = id;
} }
public Double getPeso(){ public Double getPeso() {
// get peso from first element of pricingSnapshotJson (need to parse JSON) // get peso from first element of pricingSnapshotJson (need to parse JSON)
// pricingSnapshotJson = {"xxx":{"peso":0.5,...}} is a String // pricingSnapshotJson = {"xxx":{"peso":0.5,...}} is a String
if (this.pricingSnapshotJson != null && !this.pricingSnapshotJson.isEmpty()) { if (this.pricingSnapshotJson != null && !this.pricingSnapshotJson.isEmpty()) {

View File

@ -290,6 +290,10 @@ public class PresupuestoService {
} }
public Map<String, Object> toSkApiRequest(Presupuesto presupuesto) { public Map<String, Object> toSkApiRequest(Presupuesto presupuesto) {
return toSkApiRequest(presupuesto, false);
}
public Map<String, Object> toSkApiRequest(Presupuesto presupuesto, Boolean toSave) {
final int SK_CLIENTE_ID = 1284; final int SK_CLIENTE_ID = 1284;
final int SK_PAGINAS_CUADERNILLO = 32; final int SK_PAGINAS_CUADERNILLO = 32;
@ -311,9 +315,16 @@ public class PresupuestoService {
Map<String, Object> body = new HashMap<>(); Map<String, Object> body = new HashMap<>();
body.put("tipo_impresion_id", this.getTipoImpresionId(presupuesto)); body.put("tipo_impresion_id", this.getTipoImpresionId(presupuesto));
body.put("tirada", Arrays.stream(presupuesto.getTiradas()) if (toSave) {
.filter(Objects::nonNull) body.put("tirada", Arrays.stream(presupuesto.getTiradas())
.collect(Collectors.toList())); .filter(Objects::nonNull)
.map(tirada -> tirada + 4)
.collect(Collectors.toList()));
} else {
body.put("tirada", Arrays.stream(presupuesto.getTiradas())
.filter(Objects::nonNull)
.collect(Collectors.toList()));
}
body.put("tamanio", tamanio); body.put("tamanio", tamanio);
body.put("tipo", presupuesto.getTipoEncuadernacion()); body.put("tipo", presupuesto.getTipoEncuadernacion());
body.put("clienteId", SK_CLIENTE_ID); body.put("clienteId", SK_CLIENTE_ID);
@ -343,9 +354,39 @@ public class PresupuestoService {
faja.put("alto", presupuesto.getAltoFaja()); faja.put("alto", presupuesto.getAltoFaja());
body.put("faja", faja); body.put("faja", faja);
} }
// body.put("servicios", servicios);
if (toSave) {
Map<String, Object> data = new HashMap<>();
data.put("input_data", body);
data.put("ferroDigital", 1);
data.put("ferro", 0);
data.put("marcapaginas", 0);
data.put("retractilado5", 0);
if (presupuesto.getServiciosJson() != null
&& presupuesto.getServiciosJson().indexOf("ejemplar-prueba") > 0) {
data.put("prototipo", 1);
} else {
data.put("prototipo", 0);
}
if (presupuesto.getServiciosJson() != null && presupuesto.getServiciosJson().indexOf("retractilado") > 0) {
data.put("retractilado", 1);
} else {
data.put("retractilado", 0);
}
data.put("ivaReducido", presupuesto.getIvaReducido() ? 1 : 0);
data.put("confirmar", 1);
Map<String, Object> datosCabecera = new HashMap<>();
datosCabecera.put("titulo", presupuesto.getTitulo());
datosCabecera.put("autor", presupuesto.getAutor());
datosCabecera.put("isbn", presupuesto.getIsbn());
datosCabecera.put("coleccion", "");
datosCabecera.put("referenciaCliente", presupuesto.getId());
data.put("datosCabecera", datosCabecera);
return data;
}
return body; return body;
} }
public Integer getTipoImpresionId(Presupuesto presupuesto) { public Integer getTipoImpresionId(Presupuesto presupuesto) {
@ -599,11 +640,11 @@ public class PresupuestoService {
.doubleValue(); .doubleValue();
} }
// precio calculado por matrices * num. cols -1 * precio por columna // precio calculado por matrices * num. cols -1 * precio por columna
if (presupuestoMaquetacion.getNumColumnas() > 1) { if (presupuestoMaquetacion.getNumColumnas() > 1) {
precio = precio.add(precio.multiply( precio = precio.add(precio.multiply(
BigDecimal.valueOf(presupuestoMaquetacion.getNumColumnas() - 1)) BigDecimal.valueOf(presupuestoMaquetacion.getNumColumnas() - 1))
.multiply(BigDecimal.valueOf(price.apply("columnas"))) ); .multiply(BigDecimal.valueOf(price.apply("columnas"))));
} }
precio = precio precio = precio
@ -877,6 +918,7 @@ public class PresupuestoService {
/ Double.parseDouble(servicio.get("units").toString()) / Double.parseDouble(servicio.get("units").toString())
: servicio.get("price")); : servicio.get("price"));
servicioData.put("unidades", servicio.get("units")); servicioData.put("unidades", servicio.get("units"));
servicioData.put("id", servicio.get("id"));
serviciosExtras.add(servicioData); serviciosExtras.add(servicioData);
} }
} }

View File

@ -1,9 +1,22 @@
package com.imprimelibros.erp.redsys; package com.imprimelibros.erp.redsys;
import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.payments.PaymentService; import com.imprimelibros.erp.payments.PaymentService;
import com.imprimelibros.erp.payments.model.Payment; import com.imprimelibros.erp.payments.model.Payment;
import com.imprimelibros.erp.redsys.RedsysService.FormPayload; import com.imprimelibros.erp.redsys.RedsysService.FormPayload;
import jakarta.servlet.ServletContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.thymeleaf.context.WebContext;
import org.thymeleaf.web.IWebExchange;
import org.thymeleaf.web.servlet.JakartaServletWebApplication;
import org.thymeleaf.spring6.SpringTemplateEngine;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.context.MessageSource; import org.springframework.context.MessageSource;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@ -22,37 +35,51 @@ public class RedsysController {
private final PaymentService paymentService; private final PaymentService paymentService;
private final MessageSource messageSource; private final MessageSource messageSource;
private final SpringTemplateEngine templateEngine;
private final ServletContext servletContext;
public RedsysController(PaymentService paymentService, MessageSource messageSource) { public RedsysController(PaymentService paymentService, MessageSource messageSource,
SpringTemplateEngine templateEngine, ServletContext servletContext) {
this.paymentService = paymentService; this.paymentService = paymentService;
this.messageSource = messageSource; this.messageSource = messageSource;
this.templateEngine = templateEngine;
this.servletContext = servletContext;
} }
@PostMapping(value = "/crear", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) @PostMapping(value = "/crear", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
@ResponseBody @ResponseBody
public ResponseEntity<byte[]> crearPago(@RequestParam("amountCents") Long amountCents, public ResponseEntity<byte[]> crearPago(@RequestParam("amountCents") Long amountCents,
@RequestParam("method") String method, @RequestParam("cartId") Long cartId) throws Exception { @RequestParam("method") String method, @RequestParam("cartId") Long cartId,
HttpServletRequest request,
HttpServletResponse response, Locale locale)
throws Exception {
if ("bank-transfer".equalsIgnoreCase(method)) { if ("bank-transfer".equalsIgnoreCase(method)) {
// 1) Creamos el Payment interno SIN orderId (null) // 1) Creamos el Payment interno SIN orderId (null)
Payment p = paymentService.createBankTransferPayment(cartId, amountCents, "EUR"); Payment p = paymentService.createBankTransferPayment(cartId, amountCents, "EUR");
// 2) Mostramos instrucciones de transferencia // 1⃣ Crear la "aplicación" web de Thymeleaf (Jakarta)
String html = """ JakartaServletWebApplication app = JakartaServletWebApplication.buildApplication(servletContext);
<html><head><meta charset="utf-8"><title>Pago por transferencia</title></head>
<body> // 2⃣ Construir el intercambio web desde request/response
<h2>Pago por transferencia bancaria</h2> response.setContentType("text/html;charset=UTF-8");
<p>Hemos registrado tu intención de pedido.</p> response.setCharacterEncoding("UTF-8");
<p><strong>Importe:</strong> %s €</p> IWebExchange exchange = app.buildExchange(request, response);
<p><strong>IBAN:</strong> ES00 1234 5678 9012 3456 7890</p>
<p><strong>Concepto:</strong> TRANSF-%d</p> // 3⃣ Crear el contexto WebContext con Locale
<p>En cuanto recibamos la transferencia, procesaremos tu pedido.</p> WebContext ctx = new WebContext(exchange, locale);
<p><a href="/checkout/resumen">Volver al resumen</a></p>
</body></html> String importeFormateado = Utils.formatCurrency(amountCents / 100.0, locale);
""".formatted( ctx.setVariable("importe", importeFormateado);
String.format("%.2f", amountCents / 100.0), ctx.setVariable("concepto", "TRANSF-" + p.getId());
p.getId() // usamos el ID del Payment como referencia Authentication auth = SecurityContextHolder.getContext().getAuthentication();
); boolean isAuth = auth != null
&& auth.isAuthenticated()
&& !(auth instanceof AnonymousAuthenticationToken);
ctx.setVariable("isAuth", isAuth);
// 3) Renderizamos la plantilla a HTML
String html = templateEngine.process("imprimelibros/pagos/transfer", ctx);
byte[] body = html.getBytes(StandardCharsets.UTF_8); byte[] body = html.getBytes(StandardCharsets.UTF_8);
return ResponseEntity.ok() return ResponseEntity.ok()
@ -92,7 +119,8 @@ public class RedsysController {
// GET: cuando el usuario cae aquí sin parámetros, o Redsys redirige por GET // GET: cuando el usuario cae aquí sin parámetros, o Redsys redirige por GET
@GetMapping("/ok") @GetMapping("/ok")
public String okGet(RedirectAttributes redirectAttrs, Model model, Locale locale) { public String okGet(RedirectAttributes redirectAttrs, Model model, Locale locale) {
String msg = messageSource.getMessage("checkout.success.payment", null, "Pago realizado con éxito. Gracias por su compra.", locale); String msg = messageSource.getMessage("checkout.success.payment", null,
"Pago realizado con éxito. Gracias por su compra.", locale);
model.addAttribute("successPago", msg); model.addAttribute("successPago", msg);
redirectAttrs.addFlashAttribute("successPago", msg); redirectAttrs.addFlashAttribute("successPago", msg);
return "redirect:/cart"; return "redirect:/cart";
@ -103,10 +131,10 @@ public class RedsysController {
@PostMapping(value = "/ok", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) @PostMapping(value = "/ok", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
@ResponseBody @ResponseBody
public ResponseEntity<String> okPost(@RequestParam("Ds_Signature") String signature, public ResponseEntity<String> okPost(@RequestParam("Ds_Signature") String signature,
@RequestParam("Ds_MerchantParameters") String merchantParameters) { @RequestParam("Ds_MerchantParameters") String merchantParameters, Locale locale) {
try { try {
// opcional: idempotente, si /notify ya ha hecho el trabajo no pasa nada // opcional: idempotente, si /notify ya ha hecho el trabajo no pasa nada
paymentService.handleRedsysNotification(signature, merchantParameters); paymentService.handleRedsysNotification(signature, merchantParameters, locale);
return ResponseEntity.ok("<h2>Pago realizado correctamente</h2><a href=\"/cart\">Volver</a>"); return ResponseEntity.ok("<h2>Pago realizado correctamente</h2><a href=\"/cart\">Volver</a>");
} catch (Exception e) { } catch (Exception e) {
return ResponseEntity.badRequest() return ResponseEntity.badRequest()
@ -117,7 +145,9 @@ public class RedsysController {
@GetMapping("/ko") @GetMapping("/ko")
public String koGet(RedirectAttributes redirectAttrs, Model model, Locale locale) { public String koGet(RedirectAttributes redirectAttrs, Model model, Locale locale) {
String msg = messageSource.getMessage("checkout.error.payment", null, "Error al procesar el pago: el pago ha sido cancelado o rechazado Por favor, inténtelo de nuevo.", locale); String msg = messageSource.getMessage("checkout.error.payment", null,
"Error al procesar el pago: el pago ha sido cancelado o rechazado Por favor, inténtelo de nuevo.",
locale);
model.addAttribute("errorPago", msg); model.addAttribute("errorPago", msg);
redirectAttrs.addFlashAttribute("errorPago", msg); redirectAttrs.addFlashAttribute("errorPago", msg);
return "redirect:/cart"; return "redirect:/cart";
@ -127,11 +157,11 @@ public class RedsysController {
@ResponseBody @ResponseBody
public ResponseEntity<String> koPost( public ResponseEntity<String> koPost(
@RequestParam("Ds_Signature") String signature, @RequestParam("Ds_Signature") String signature,
@RequestParam("Ds_MerchantParameters") String merchantParameters) { @RequestParam("Ds_MerchantParameters") String merchantParameters, Locale locale) {
try { try {
// Procesamos la notificación IGUAL que en /ok y /notify // Procesamos la notificación IGUAL que en /ok y /notify
paymentService.handleRedsysNotification(signature, merchantParameters); paymentService.handleRedsysNotification(signature, merchantParameters, locale);
// Mensaje para el usuario (pago cancelado/rechazado) // Mensaje para el usuario (pago cancelado/rechazado)
String html = "<h2>Pago cancelado o rechazado</h2><a href=\"/checkout\">Volver</a>"; String html = "<h2>Pago cancelado o rechazado</h2><a href=\"/checkout\">Volver</a>";
@ -146,9 +176,9 @@ public class RedsysController {
@PostMapping(value = "/notify", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) @PostMapping(value = "/notify", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
@ResponseBody @ResponseBody
public String notifyRedsys(@RequestParam("Ds_Signature") String signature, public String notifyRedsys(@RequestParam("Ds_Signature") String signature,
@RequestParam("Ds_MerchantParameters") String merchantParameters) { @RequestParam("Ds_MerchantParameters") String merchantParameters, Locale locale) {
try { try {
paymentService.handleRedsysNotification(signature, merchantParameters); paymentService.handleRedsysNotification(signature, merchantParameters, locale);
return "OK"; return "OK";
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); // 👈 para ver el motivo del 500 en logs e.printStackTrace(); // 👈 para ver el motivo del 500 en logs

View File

@ -1,29 +1,39 @@
databaseChangeLog: databaseChangeLog:
- changeSet: - changeSet:
id: 0009-drop-unique-refund-gateway-id id: 0010-drop-unique-tx-gateway
author: JJO author: JJO
changes:
# 1⃣ Eliminar la UNIQUE constraint sobre gateway_refund_id
- dropUniqueConstraint:
constraintName: uq_refund_gateway_id
tableName: refunds
# 2⃣ Crear un índice normal (no único) para acelerar búsquedas por gateway_refund_id # ✅ Solo ejecuta el changeSet si existe la UNIQUE constraint
preConditions:
- onFail: MARK_RAN
- uniqueConstraintExists:
tableName: payment_transactions
constraintName: uq_tx_gateway_txid_type
changes:
# 1⃣ Eliminar la UNIQUE constraint si existe
- dropUniqueConstraint:
tableName: payment_transactions
constraintName: uq_tx_gateway_txid_type
# 2⃣ Crear un índice normal (no único) sobre gateway_transaction_id
- createIndex: - createIndex:
tableName: refunds tableName: payment_transactions
indexName: idx_refunds_gateway_refund_id indexName: idx_payment_tx_gateway_txid
columns: columns:
- column: - column:
name: gateway_refund_id name: gateway_transaction_id
rollback: rollback:
# 🔙 1) Eliminar el índice normal creado en este changeSet # 🔙 1) Eliminar el índice normal creado en este changeSet
- dropIndex: - dropIndex:
indexName: idx_refunds_gateway_refund_id tableName: payment_transactions
tableName: refunds indexName: idx_payment_tx_gateway_txid
# Si tu versión de Liquibase lo soporta, puedes añadir:
# ifExists: true
# 🔙 2) Restaurar la UNIQUE constraint original # 🔙 2) Restaurar la UNIQUE constraint original
- addUniqueConstraint: - addUniqueConstraint:
tableName: refunds tableName: payment_transactions
columnNames: gateway_refund_id columnNames: gateway_transaction_id, type
constraintName: uq_refund_gateway_id constraintName: uq_tx_gateway_txid_type

View File

@ -2,14 +2,21 @@ databaseChangeLog:
- changeSet: - changeSet:
id: 0010-drop-unique-tx-gateway id: 0010-drop-unique-tx-gateway
author: JJO author: JJO
changes:
# 1⃣ Eliminar la UNIQUE constraint sobre (gateway_transaction_id, type) # ✅ Solo ejecuta el changeSet si existe la UNIQUE constraint
- dropUniqueConstraint: preConditions:
constraintName: uq_tx_gateway_txid_type - onFail: MARK_RAN
- uniqueConstraintExists:
tableName: payment_transactions tableName: payment_transactions
constraintName: uq_tx_gateway_txid_type
changes:
# 1⃣ Eliminar la UNIQUE constraint si existe
- dropUniqueConstraint:
tableName: payment_transactions
constraintName: uq_tx_gateway_txid_type
# 2⃣ Crear un índice normal (no único) sobre gateway_transaction_id # 2⃣ Crear un índice normal (no único) sobre gateway_transaction_id
# para poder seguir buscando rápido por este campo
- createIndex: - createIndex:
tableName: payment_transactions tableName: payment_transactions
indexName: idx_payment_tx_gateway_txid indexName: idx_payment_tx_gateway_txid
@ -20,8 +27,8 @@ databaseChangeLog:
rollback: rollback:
# 🔙 1) Eliminar el índice normal creado en este changeSet # 🔙 1) Eliminar el índice normal creado en este changeSet
- dropIndex: - dropIndex:
indexName: idx_payment_tx_gateway_txid
tableName: payment_transactions tableName: payment_transactions
indexName: idx_payment_tx_gateway_txid
# 🔙 2) Restaurar la UNIQUE constraint original # 🔙 2) Restaurar la UNIQUE constraint original
- addUniqueConstraint: - addUniqueConstraint:

View File

@ -0,0 +1,210 @@
databaseChangeLog:
- changeSet:
id: 0011-update-pedidos-presupuesto
author: jjo
changes:
# 1) Nuevas columnas en PRESUPUESTO
- addColumn:
tableName: presupuesto
columns:
- column:
name: comentario
type: TEXT
afterColumn: pricing_snapshot
constraints:
nullable: true
- column:
name: proveedor
type: VARCHAR(100)
constraints:
nullable: true
- column:
name: proveedor_ref1
type: VARCHAR(100)
constraints:
nullable: true
- column:
name: proveedor_ref2
type: BIGINT
constraints:
nullable: true
# 2) Cambios en PEDIDOS
# 2.1 Eliminar FK fk_pedidos_presupuesto
- dropForeignKeyConstraint:
baseTableName: pedidos
constraintName: fk_pedidos_presupuesto
# 2.2 Eliminar columna presupuesto_id
- dropColumn:
tableName: pedidos
columnName: presupuesto_id
# 2.3 Añadir nuevas columnas después de id
- addColumn:
tableName: pedidos
columns:
- column:
name: base
type: DOUBLE
afterColumn: id
constraints:
nullable: false
- column:
name: envio
type: DOUBLE
defaultValueNumeric: 0
constraints:
nullable: false
- column:
name: iva4
type: DOUBLE
defaultValueNumeric: 0
constraints:
nullable: false
- column:
name: iva21
type: DOUBLE
defaultValueNumeric: 0
constraints:
nullable: false
- column:
name: descuento
type: DOUBLE
defaultValueNumeric: 0
constraints:
nullable: false
- column:
name: total
type: DOUBLE
defaultValueNumeric: 0
constraints:
nullable: false
- column:
name: proveedor
type: VARCHAR(100)
afterColumn: total
constraints:
nullable: true
- column:
name: proveedor_ref
type: VARCHAR(100)
afterColumn: proveedor
constraints:
nullable: true
# 3) Crear tabla PEDIDOS_LINEAS
- createTable:
tableName: pedidos_lineas
columns:
- column:
name: id
type: BIGINT
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: pedido_id
type: BIGINT
constraints:
nullable: false
- column:
name: presupuesto_id
type: BIGINT
constraints:
nullable: false
- column:
name: created_at
type: DATETIME(3)
constraints:
nullable: true
- column:
name: created_by
type: BIGINT
constraints:
nullable: false
# FKs de pedidos_lineas
- addForeignKeyConstraint:
baseTableName: pedidos_lineas
baseColumnNames: pedido_id
constraintName: fk_pedidos_lineas_pedido
referencedTableName: pedidos
referencedColumnNames: id
onDelete: RESTRICT
- addForeignKeyConstraint:
baseTableName: pedidos_lineas
baseColumnNames: presupuesto_id
constraintName: fk_pedidos_lineas_presupuesto
referencedTableName: presupuesto
referencedColumnNames: id
onDelete: RESTRICT
- addForeignKeyConstraint:
baseTableName: pedidos_lineas
baseColumnNames: created_by
constraintName: fk_pedidos_lineas_created_by_user
referencedTableName: users
referencedColumnNames: id
onDelete: RESTRICT
rollback:
# 3) Eliminar tabla pedidos_lineas y sus FKs
- dropTable:
tableName: pedidos_lineas
# 2) Revertir cambios en PEDIDOS
- dropColumn:
tableName: pedidos
columns:
- column:
name: base
- column:
name: envio
- column:
name: iva4
- column:
name: iva21
- column:
name: descuento
- column:
name: total
- column:
name: proveedor
- column:
name: proveedor_ref
# 2.2 Volver a crear presupuesto_id
- addColumn:
tableName: pedidos
columns:
- column:
name: presupuesto_id
type: BIGINT
constraints:
nullable: true
# 2.1 Volver a crear la FK fk_pedidos_presupuesto
- addForeignKeyConstraint:
baseTableName: pedidos
baseColumnNames: presupuesto_id
constraintName: fk_pedidos_presupuesto
referencedTableName: presupuesto
referencedColumnNames: id
onDelete: RESTRICT
# 1) Eliminar columnas añadidas en PRESUPUESTO
- dropColumn:
tableName: presupuesto
columns:
- column:
name: comentario
- column:
name: proveedor
- column:
name: proveedor_ref1
- column:
name: proveedor_ref2

View File

@ -18,4 +18,6 @@ databaseChangeLog:
- include: - include:
file: db/changelog/changesets/0009-add-composite-unique-txid-type.yml file: db/changelog/changesets/0009-add-composite-unique-txid-type.yml
- include: - include:
file: db/changelog/changesets/0010-drop-unique-tx-gateway.yml file: db/changelog/changesets/0010-drop-unique-tx-gateway.yml
- include:
file: db/changelog/changesets/0011-update-pedidos-presupuesto.yml

View File

@ -7,6 +7,7 @@ app.seleccionar=Seleccionar
app.guardar=Guardar app.guardar=Guardar
app.editar=Editar app.editar=Editar
app.add=Añadir app.add=Añadir
app.back=Volver
app.eliminar=Eliminar app.eliminar=Eliminar
app.imprimir=Imprimir app.imprimir=Imprimir
app.acciones.siguiente=Siguiente app.acciones.siguiente=Siguiente

View File

@ -28,6 +28,9 @@ pagos.transferencia.finalizar.text=¿Estás seguro de que deseas marcar esta tra
pagos.transferencia.finalizar.success=Transferencia bancaria marcada como completada con éxito. pagos.transferencia.finalizar.success=Transferencia bancaria marcada como completada con éxito.
pagos.transferencia.finalizar.error.general=Error al finalizar la transferencia bancaria pagos.transferencia.finalizar.error.general=Error al finalizar la transferencia bancaria
pagos.transferencia.ok.title=Pago por transferencia bancaria
pagos.transferencia.ok.text=Ha realizado su pedido correctamente. Para completar el pago, realice una transferencia bancaria con los siguientes datos:<br>Titular de la cuenta: Impresión Imprime Libros SL<br>IBAN: ES00 1234 5678 9012 3456 7890<br>Importe: {0}<br>Concepto: {1}<br>Le rogamos que nos envíe el justificante de la transferencia respondiendo al correo de confirmación de pedido que le acabamos de enviar.<br>Si no encuentra el mensaje, por favor revise la carpeta de correo no deseado y añada <a href="mailto:contacto@imprimelibros.com">contacto@imprimelibros.com</a>
pagos.refund.title=Devolución pagos.refund.title=Devolución
pagos.refund.text=Introduce la cantidad a devolver (en euros): pagos.refund.text=Introduce la cantidad a devolver (en euros):
pagos.refund.success=Devolución solicitada con éxito. Si no se refleja inmediatamente, espere unos minutos y actualiza la página. pagos.refund.success=Devolución solicitada con éxito. Si no se refleja inmediatamente, espere unos minutos y actualiza la página.

View File

@ -12,6 +12,11 @@ $(() => {
// remove name from container . direccion-card // remove name from container . direccion-card
container.find('.direccion-card input[type="hidden"]').removeAttr('name'); container.find('.direccion-card input[type="hidden"]').removeAttr('name');
if (container.find('.direccion-card').length === 0) {
// no addresses, no need to submit
$("alert-empty").removeClass("d-none");
}
container.find('.direccion-card').each(function (i) { container.find('.direccion-card').each(function (i) {
$(this).find('.direccion-id').attr('name', 'direcciones[' + i + '].id'); $(this).find('.direccion-id').attr('name', 'direcciones[' + i + '].id');
$(this).find('.direccion-cp').attr('name', 'direcciones[' + i + '].cp'); $(this).find('.direccion-cp').attr('name', 'direcciones[' + i + '].cp');

View File

@ -0,0 +1,64 @@
import { formateaMoneda } from "../utils.js";
$(() => {
const locale = $("html").attr("lang") || "es-ES";
$.ajaxSetup({
beforeSend: function (xhr) {
const token = document.querySelector('meta[name="_csrf"]')?.content;
const header = document.querySelector('meta[name="_csrf_header"]')?.content;
if (token && header) xhr.setRequestHeader(header, token);
}
});
$(".moneda").each((index, element) => {
const valor = $(element).text().trim();
const tr = $(element).closest(".tr");
if (tr.data("servicio-id") == "marcapaginas") {
$(element).text(formateaMoneda(valor, 4, locale, 'EUR'));
}
else {
$(element).text(formateaMoneda(valor, 2, locale, 'EUR'));
}
});
$(".moneda4").each((index, element) => {
const valor = $(element).text().trim();
$(element).text(formateaMoneda(valor, 4, locale, 'EUR'));
});
$('.btn-imprimir').on('click', (e) => {
e.preventDefault();
// obtén el id de donde lo tengas (data-attr o variable global)
const id = $('#presupuesto-id').val();
const url = `/api/pdf/presupuesto/${id}?mode=download`;
// Truco: crear <a> y hacer click
const a = document.createElement('a');
a.href = url;
a.target = '_self'; // descarga en la misma pestaña
// a.download = `presupuesto-${id}.pdf`; // opcional, tu server ya pone filename
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
$('.add-cart-btn').on('click', async () => {
const presupuestoId = $('#presupuesto-id').val();
const res = await $.ajax({
url: `/cart/add/${presupuestoId}`,
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
// Si el backend devuelve { redirect: "/cart" }
if (res?.redirect) {
window.location.assign(res.redirect); // o replace()
}
});
});

View File

@ -37,10 +37,8 @@
</div> </div>
<div th:if="${successPago}" class="alert alert-success alert-fadeout my-1" role="alert" th:text="${successPago}"></div> <div th:if="${successPago}" class="alert alert-success alert-fadeout my-1" role="alert" th:text="${successPago}"></div>
<div th:if="${items.isEmpty()}"> <div id="alert-empty" th:class="'alert alert-info ' + ${items.isEmpty() ? '' : 'd-none'}" role="alert" th:text="#{cart.empty}"></div>
<div id="alert-empty"class="alert alert-info" role="alert" th:text="#{cart.empty}"></div>
</div>
<div th:insert="~{imprimelibros/cart/_cartContent :: cartContent(${items}, ${cartId})}"></div> <div th:insert="~{imprimelibros/cart/_cartContent :: cartContent(${items}, ${cartId})}"></div>

View File

@ -1,13 +1,14 @@
<html th:lang="${#locale.country != '' ? #locale.language + '-' + #locale.country : #locale.language}" <html th:lang="${#locale.country != '' ? #locale.language + '-' + #locale.country : #locale.language}" th:with="isAuth=${isAuth != null
th:with="isAuth=${#authorization.expression('isAuthenticated()')}" ? isAuth
: (#authorization == null ? false : #authorization.expression('isAuthenticated()'))}"
th:attrappend="data-layout=${isAuth} ? 'semibox' : 'horizontal'" data-sidebar-visibility="show" data-topbar="light" th:attrappend="data-layout=${isAuth} ? 'semibox' : 'horizontal'" data-sidebar-visibility="show" data-topbar="light"
data-sidebar="light" data-sidebar-size="lg" data-sidebar-image="none" data-preloader="disable" data-sidebar="light" data-sidebar-size="lg" data-sidebar-image="none" data-preloader="disable"
xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"> xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head> <head>
<meta name="_csrf" th:content="${_csrf.token}" /> <meta name="_csrf" th:content="${_csrf != null ? _csrf.token : ''}" />
<meta name="_csrf_header" th:content="${_csrf.headerName}" /> <meta name="_csrf_header" th:content="${_csrf != null ? _csrf.headerName : ''}" />
<th:block layout:fragment="pagetitle" /> <th:block layout:fragment="pagetitle" />
<th:block th:replace="~{imprimelibros/partials/head-css :: head-css}" /> <th:block th:replace="~{imprimelibros/partials/head-css :: head-css}" />
@ -18,10 +19,11 @@
<body> <body>
<div th:replace="~{imprimelibros/partials/topbar :: topbar}" /> <div th:replace="~{imprimelibros/partials/topbar :: topbar}" />
<div th:if="${#authorization.expression('isAuthenticated()')}"> <div th:if="${isAuth}">
<div th:replace="~{imprimelibros/partials/sidebar :: sidebar}" /> <div th:replace="~{imprimelibros/partials/sidebar :: sidebar}" />
</div> </div>
<section class="main-content"> <section class="main-content">
<div class="page-content"> <div class="page-content">
<div class="container-fluid"> <div class="container-fluid">
@ -39,10 +41,11 @@
<th:block layout:fragment="pagejs" /> <th:block layout:fragment="pagejs" />
<script th:src="@{/assets/js/app.js}"></script> <script th:src="@{/assets/js/app.js}"></script>
<script th:src="@{/assets/js/pages/imprimelibros/languageBundle.js}"></script> <script th:src="@{/assets/js/pages/imprimelibros/languageBundle.js}"></script>
<th:block th:if="${#authorization.expression('isAuthenticated()')}"> <th:block th:if="${isAuth}">
<script src="/assets/js/pages/imprimelibros/cart-badge.js"></script> <script src="/assets/js/pages/imprimelibros/cart-badge.js"></script>
</th:block> </th:block>
</body> </body>
</html> </html>

View File

@ -0,0 +1,73 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{imprimelibros/layout}">
<head>
<th:block layout:fragment="pagetitle" />
<th:block th:replace="~{imprimelibros/partials/head-css :: head-css}" />
<th:block layout:fragment="pagecss">
<link href="/assets/libs/datatables/dataTables.bootstrap5.min.css" rel="stylesheet" />
</th:block>
</head>
<body>
<div th:replace="~{imprimelibros/partials/topbar :: topbar}" />
<div th:replace="~{imprimelibros/partials/sidebar :: sidebar}"
sec:authorize="isAuthenticated() and hasAnyRole('SUPERADMIN','ADMIN')">
<th:block layout:fragment="content">
<div th:if="${isAuth}">
<div class="container-fluid">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/"><i class="ri-home-5-fill"></i></a></li>
</ol>
</nav>
</div>
<div class="container-fluid">
<div class="row" id="card">
<div class="card">
<div class="card-body">
<h3 th:text="#{pagos.transferencia.ok.title}"></h3>
<span th:utext="#{pagos.transferencia.ok.text(${importe}, ${concepto})}"></span>
<div class="d-flex flex-wrap justify-content-center">
<a th:href="@{/}" class="btn btn-secondary mt-3">
<i class="ri-home-5-fill me-1"></i>
<span th:text="#{app.back}">Volver</span>
</a>
</div>
</div>
</div>
</div>
</div>
<!--end row-->
</div>
</div>
</th:block>
<th:block th:replace="~{theme/partials/vendor-scripts :: scripts}" />
<th:block layout:fragment="pagejs">
<script th:inline="javascript">
window.languageBundle = /*[[${languageBundle}]]*/ {};
</script>
<!-- JS de Buttons y dependencias -->
<div th:if="${appMode} == 'view'">
<script type="module" th:src="@{/assets/js/pages/imprimelibros/presupuestador/wizard-publicos.js}"></script>
</div>
<div th:if="${appMode} == 'edit'">
<script type="module" th:src="@{/assets/js/pages/imprimelibros/presupuestador/wizard-privado.js}"></script>
</div>
<script type="module" th:src="@{/assets/js/pages/imprimelibros/presupuestos/resumen-view.js}"></script>
</th:block>
</body>
</html>

View File

@ -64,7 +64,7 @@
</div> </div>
</div> </div>
<div th:if="${#authorization.expression('isAuthenticated()')}" <div th:if="${isAuth}"
class="ms-1 header-item d-none d-sm-flex"> class="ms-1 header-item d-none d-sm-flex">
<button type="button" id="btn_cart" <button type="button" id="btn_cart"
class="btn btn-icon btn-topbar material-shadow-none btn-ghost-secondary rounded-circle light-dark-mode"> class="btn btn-icon btn-topbar material-shadow-none btn-ghost-secondary rounded-circle light-dark-mode">
@ -80,7 +80,7 @@
<div th:if="${#authorization.expression('isAuthenticated()')}"> <div th:if="${isAuth}">
<div class="dropdown ms-sm-3 header-item topbar-user"> <div class="dropdown ms-sm-3 header-item topbar-user">
<button type="button" class="btn" id="page-header-user-dropdown" data-bs-toggle="dropdown" <button type="button" class="btn" id="page-header-user-dropdown" data-bs-toggle="dropdown"
aria-haspopup="true" aria-expanded="false"> aria-haspopup="true" aria-expanded="false">
@ -114,9 +114,9 @@
</div> </div>
</div> </div>
<!-- Si NO está autenticado --> <!-- Si NO está autenticado -->
<div th:unless="${#authorization.expression('isAuthenticated()')}"> <div th:unless="${isAuth}">
<a href="/login" class="btn btn-outline-primary ms-sm-3"> <a href="/login" class="btn btn-outline-primary ms-sm-3">
<i class="mdi mdi-login"></i> <label th:text="#{login.login}">Iniciar sesión</p> <i class="mdi mdi-login"></i> <label th:text="#{login.login}">Iniciar sesión</label>
</a> </a>
</div> </div>

View File

@ -0,0 +1,196 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{imprimelibros/layout}">
<head>
<th:block layout:fragment="pagetitle" />
<th:block th:replace="~{imprimelibros/partials/head-css :: head-css}" />
<th:block layout:fragment="pagecss">
<link th:href="@{/assets/libs/datatables/dataTables.bootstrap5.min.css}" rel="stylesheet" />
</th:block>
<th:block layout:fragment="pagecss">
<link th:href="@{/assets/css/presupuestador.css}" rel="stylesheet" />
</th:block>
</head>
<body>
<div th:replace="~{imprimelibros/partials/topbar :: topbar}" />
<div th:replace="~{imprimelibros/partials/sidebar :: sidebar}"
sec:authorize="isAuthenticated() and hasAnyRole('SUPERADMIN','ADMIN')">
<th:block layout:fragment="content">
<div th:if="${#authorization.expression('isAuthenticated()')}">
<div class="container-fluid">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/"><i class="ri-home-5-fill"></i></a></li>
<li class="breadcrumb-item"><a href="/presupuesto" th:text="#{presupuesto.title}"></a></li>
<li class="breadcrumb-item active" aria-current="page" th:if="${appMode == 'add'}"
th:text="#{presupuesto.add}">
Nuevo presupuesto
</li>
<li class="breadcrumb-item active" aria-current="page" th:text="#{presupuesto.editar.title}"
th:if="${appMode == 'edit'}">
Editar presupuesto
</li>
</ol>
</nav>
</div>
<div class="container-fluid">
<input type="hidden" id="presupuesto-id" th:value="${presupuesto.id}" />
<div class="row" id="card presupuesto-row animate-fadeInUpBounce">
<div class="card">
<div class="card-header">
<h4 class="card-title mb-0 text-uppercase" th:text="${resumen.titulo}">Resumen del
presupuesto</h4>
</div>
<div class="card-body">
<div class="card col-12 col-sm-9 mx-auto">
<h5 id="resumen-titulo" class="text-center"></h5>
<table id="resumen-tabla-final" class="table table-borderless table-striped mt-3"
th:data-currency="#{app.currency}">
<thead>
<tr>
<th></th>
<th th:text="#{presupuesto.resumen.tabla.descripcion}">Descripción
</th>
<th class="text-end" th:text="#{presupuesto.resumen.tabla.cantidad}">
Cantidad</th>
<th class="text-end"
th:text="#{presupuesto.resumen.tabla.precio-unidad}">Precio
unitario
</th>
<th class="text-end"
th:text="#{presupuesto.resumen.tabla.precio-total}">Precio total
</th>
</tr>
</thead>
<tbody>
<tr th:if="${resumen['linea0']}">
<td><img style="max-width: 60px; height: auto;" th:src="${resumen['imagen']}" th:alt="${resumen['imagen_alt']}" class="img-fluid" /></td>
<td class="text-start" th:utext="${resumen['linea0'].descripcion}">
Descripción 1</td>
<td class="text-end" th:text="${resumen['linea0'].cantidad}">1</td>
<td class="text-end moneda4"
th:text="${resumen['linea0'].precio_unitario}">
100,00 €
</td>
<td class="text-end moneda" th:text="${resumen['linea0'].precio_total}">
100,00
</td>
</tr>
<tr th:if="${resumen['linea1']}">
<td></td>
<td class="text-start" th:utext="${resumen['linea1'].descripcion}">
Descripción 2</td>
<td class="text-end" th:text="${resumen['linea1'].cantidad}">1</td>
<td class="text-end moneda4"
th:text="${resumen['linea1'].precio_unitario}">
50,00 €
</td>
<td class="text-end moneda" th:text="${resumen['linea1'].precio_total}">
50,00 €
</td>
</tr>
<th:block th:each="servicio :${resumen['servicios']}">
<tr th:attr="data-servicio-id=${servicio['id']}">
<td></td>
<td class="text-start" th:utext="${servicio['descripcion']}">
Descripción 3</td>
<td class="text-end" th:text="${servicio['unidades']}">1</td>
<td class="text-end moneda" th:text="${servicio['precio']}">
25,00 €
</td>
<td class="text-end moneda"
th:text="${servicio['precio'] * servicio['unidades']}">
25,00 €
</td>
</tr>
</th:block>
</tbody>
<tfoot>
<tr class="table-active">
<th colspan="4" class="text-end"
th:text="#{presupuesto.resumen.tabla.base}">Total</th>
<th class="text-end moneda" id="resumen-base"
th:text="${presupuesto.baseImponible}">0,00 €</th>
</tr>
<tr th:if="${presupuesto.ivaImporte4 > 0}" id="tr-resumen-iva4"
class="table-active">
<th colspan="4" class="text-end"
th:text="#{presupuesto.resumen.tabla.iva4}">IVA (4%)</th>
<th class="text-end moneda" id="resumen-iva4"
th:text="${presupuesto.ivaImporte4}">0,00 €</th>
</tr>
<tr th:if="${presupuesto.ivaImporte21 > 0}" id="tr-resumen-iva21"
class="table-active">
<th colspan="4" class="text-end"
th:text="#{presupuesto.resumen.tabla.iva21}">IVA (21%)</th>
<th class="text-end moneda" id="resumen-iva21"
th:text="${presupuesto.ivaImporte21}">0,00 €</th>
</tr>
<tr class="table-active">
<th colspan="4" class="text-end"
th:text="#{presupuesto.resumen.tabla.total}">Total con IVA
</th>
<th class="text-end moneda" id="resumen-total"
th:text="${presupuesto.totalConIva}">0,00 €</th>
</tfoot>
</table>
</div>
<div class="buttons-row center">
<button type="button"
class="btn btn-secondary d-flex align-items-center mx-2 btn-imprimir">
<i class="ri-printer-line me-2"></i>
<span th:text="#{app.imprimir}">Imprimir</span>
</button>
<button type="button"
class="btn btn-secondary d-flex align-items-center mx-2 add-cart-btn">
<i class="ri-shopping-cart-line me-2"></i>
<span th:text="#{presupuesto.add-to-cart}">Añadir a la cesta</span>
</button>
</div>
</div>
</div>
</div>
</div>
<!--end row-->
</div>
</div>
</th:block>
<th:block th:replace="~{theme/partials/vendor-scripts :: scripts}" />
<th:block layout:fragment="pagejs">
<script th:inline="javascript">
window.languageBundle = /*[[${languageBundle}]]*/ {};
</script>
<!-- JS de Buttons y dependencias -->
<div th:if="${appMode} == 'view'">
<script type="module" th:src="@{/assets/js/pages/imprimelibros/presupuestador/wizard-publicos.js}"></script>
</div>
<div th:if="${appMode} == 'edit'">
<script type="module" th:src="@{/assets/js/pages/imprimelibros/presupuestador/wizard-privado.js}"></script>
</div>
<script type="module" th:src="@{/assets/js/pages/imprimelibros/presupuestos/resumen-view.js}"></script>
</th:block>
</body>
</html>

View File

@ -0,0 +1,27 @@
package com.imprimelibros.erp.presupuesto;
import java.util.Locale;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import com.imprimelibros.erp.cart.CartService;
@SpringBootTest
public class savePresupuestosTest {
@Autowired
private CartService cartService;
@Test
void testGuardarPresupuesto() {
Locale locale = new Locale("es", "ES");
Long resultado = cartService.crearPedido(9L, locale);
System.out.println("📦 Presupuesto guardado:");
System.out.println(resultado);
// Aquí irían las aserciones para verificar que el presupuesto se guardó correctamente
}
}