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>
<groupId>org.springframework.boot</groupId>
<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>
<!-- (Migraciones) Plugin Maven para generar/ejecutar changelogs -->

View File

@ -1,6 +1,8 @@
package com.imprimelibros.erp.cart;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import org.springframework.context.MessageSource;
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.dto.Presupuesto;
import com.imprimelibros.erp.presupuesto.service.PresupuestoService;
import com.imprimelibros.erp.cart.dto.CartDireccionRepository;
import com.imprimelibros.erp.cart.dto.DireccionCardDTO;
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.direcciones.DireccionService;
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;
@Service
@ -36,12 +38,13 @@ public class CartService {
private final DireccionService direccionService;
private final skApiClient skApiClient;
private final PedidoService pedidoService;
private final PresupuestoService presupuestoService;
public CartService(CartRepository cartRepo, CartItemRepository itemRepo,
CartDireccionRepository cartDireccionRepo, MessageSource messageSource,
PresupuestoFormatter presupuestoFormatter, PresupuestoRepository presupuestoRepo,
Utils utils, DireccionService direccionService, skApiClient skApiClient,
PedidoService pedidoService) {
PedidoService pedidoService, PresupuestoService presupuestoService) {
this.cartRepo = cartRepo;
this.itemRepo = itemRepo;
this.cartDireccionRepo = cartDireccionRepo;
@ -51,16 +54,14 @@ public class CartService {
this.direccionService = direccionService;
this.skApiClient = skApiClient;
this.pedidoService = pedidoService;
this.presupuestoService = presupuestoService;
}
public Cart findById(Long cartId) {
return cartRepo.findById(cartId)
.orElseThrow(() -> new IllegalArgumentException("Carrito no encontrado"));
}
/** Devuelve el carrito activo o lo crea si no existe. */
@Transactional
public Cart getOrCreateActiveCart(Long userId) {
@ -190,14 +191,13 @@ public class CartService {
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 iva4 = 0.0;
double iva21 = 0.0;
double shipment = 0.0;
Boolean errorShipementCost = false;
boolean errorShipementCost = false;
List<CartItem> items = cart.getItems();
List<CartDireccion> direcciones = cart.getDirecciones();
@ -208,55 +208,58 @@ public class CartService {
base += p.getBaseImponible().doubleValue();
iva4 += p.getIvaImporte4().doubleValue();
iva21 += p.getIvaImporte21().doubleValue();
if (cart.getOnlyOneShipment() != null && cart.getOnlyOneShipment()) {
// Si es envío único, que es a españa y no ha canarias
if (direcciones != null && direcciones.size() > 0) {
if (direcciones != null && !direcciones.isEmpty()) {
CartDireccion cd = direcciones.get(0);
Boolean freeShipment = direccionService.checkFreeShipment(cd.getDireccion().getCp(),
boolean freeShipment = direccionService.checkFreeShipment(
cd.getDireccion().getCp(),
cd.getDireccion().getPaisCode3()) && !cd.getIsPalets();
if (!freeShipment) {
Integer unidades = p.getSelectedTirada();
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;
}
else{
} else {
shipment += (Double) res.get("shipment");
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);
if (res.get("success").equals(Boolean.FALSE)) {
if (Boolean.FALSE.equals(res.get("success"))) {
errorShipementCost = true;
}
else{
} else {
shipment += (Double) res.get("shipment");
iva21 += (Double) res.get("iva21");
}
}
}
} else {
// envio por cada presupuesto
// buscar la direccion asignada a este presupuesto
if (direcciones == null)
continue;
List<CartDireccion> cd_presupuesto = direcciones.stream()
.filter(d -> d.getPresupuesto() != null && d.getPresupuesto().getId().equals(p.getId())
&& d.getUnidades() != null && d.getUnidades() != null && d.getUnidades() > 0)
.filter(d -> d.getPresupuesto() != null
&& d.getPresupuesto().getId().equals(p.getId())
&& d.getUnidades() != null
&& d.getUnidades() > 0)
.toList();
Boolean firstDirection = true;
boolean firstDirection = true;
for (CartDireccion cd : cd_presupuesto) {
Integer unidades = cd.getUnidades();
if (firstDirection) {
Boolean freeShipment = direccionService.checkFreeShipment(cd.getDireccion().getCp(),
boolean freeShipment = direccionService.checkFreeShipment(
cd.getDireccion().getCp(),
cd.getDireccion().getPaisCode3()) && !cd.getIsPalets();
if (!freeShipment && unidades != null && unidades > 0) {
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;
} else {
shipment += (Double) res.get("shipment");
@ -266,7 +269,7 @@ public class CartService {
firstDirection = false;
} else {
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;
} else {
shipment += (Double) res.get("shipment");
@ -274,18 +277,19 @@ public class CartService {
}
}
}
// ejemplar de prueba
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)
.findFirst().orElse(null);
if (cd_prueba != null) {
if (cd_prueba != null) {
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;
}
else{
} else {
shipment += (Double) res.get("shipment");
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();
double descuento = (total) * fidelizacion / 100.0;
total -= descuento;
double descuento = totalBeforeDiscount * fidelizacion / 100.0;
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<>();
summary.put("base", Utils.formatCurrency(base, locale));
@ -305,11 +342,11 @@ public class CartService {
summary.put("iva21", Utils.formatCurrency(iva21, locale));
summary.put("shipment", Utils.formatCurrency(shipment, locale));
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("amountCents", Math.round(total * 100));
summary.put("errorShipmentCost", errorShipementCost);
summary.put("cartId", cart.getId());
summary.put("amountCents", raw.get("amountCents"));
summary.put("errorShipmentCost", raw.get("errorShipmentCost"));
summary.put("cartId", raw.get("cartId"));
return summary;
}
@ -404,13 +441,160 @@ public class CartService {
// delete cart directions by direccion id in ACTIVE carts
@Transactional
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);
}
@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
***************************************/

View File

@ -47,10 +47,37 @@ public class Utils {
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() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
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) {

View File

@ -2,6 +2,8 @@ package com.imprimelibros.erp.direcciones;
import jakarta.persistence.*;
import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;
import org.hibernate.annotations.SQLDelete;
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) {
try {
String jsonResponse = performWithRetry(() -> {
@ -238,7 +357,6 @@ public class skApiClient {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(authService.getToken());
ResponseEntity<String> response = restTemplate.exchange(
uri,
HttpMethod.GET,
@ -255,10 +373,10 @@ public class skApiClient {
return Map.of("error", messageSource.getMessage("direcciones.error.noShippingCost", null, locale));
} else {
Double total = Optional.ofNullable(responseBody.get("data"))
.filter(Number.class::isInstance)
.map(Number.class::cast)
.map(Number::doubleValue)
.orElse(0.0);
.filter(Number.class::isInstance)
.map(Number.class::cast)
.map(Number::doubleValue)
.orElse(0.0);
return Map.of("data", total);
}

View File

@ -295,11 +295,11 @@ public class PaymentController {
}
@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;
try {
paymentService.markBankTransferAsCaptured(id);
paymentService.markBankTransferAsCaptured(id, locale);
response = Map.of("success", true);
return ResponseEntity.ok(response);

View File

@ -15,6 +15,7 @@ import org.springframework.transaction.annotation.Transactional;
import com.imprimelibros.erp.payments.repo.WebhookEventRepository;
import java.time.LocalDateTime;
import java.util.Locale;
import java.util.Objects;
@Service
@ -36,7 +37,7 @@ public class PaymentService {
this.payRepo = payRepo;
this.txRepo = txRepo;
this.refundRepo = refundRepo;
this.redsysService = redsysService;
this.redsysService = redsysService;
this.webhookEventRepo = webhookEventRepo;
this.cartService = cartService;
}
@ -82,7 +83,7 @@ public class PaymentService {
}
@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
// y salimos.
@ -197,7 +198,7 @@ public class PaymentService {
}
if (authorized) {
processOrder(notif.cartId);
processOrder(notif.cartId, locale);
}
payRepo.save(p);
@ -317,7 +318,7 @@ public class PaymentService {
}
@Transactional
public void markBankTransferAsCaptured(Long paymentId) {
public void markBankTransferAsCaptured(Long paymentId, Locale locale) {
Payment p = payRepo.findById(paymentId)
.orElseThrow(() -> new IllegalArgumentException("Payment no encontrado: " + paymentId));
@ -354,7 +355,7 @@ public class PaymentService {
// 4) Procesar el pedido asociado al carrito (si existe)
if (p.getOrderId() != null) {
processOrder(p.getOrderId());
processOrder(p.getOrderId(), locale);
}
}
@ -450,17 +451,26 @@ public class PaymentService {
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);
if (cart != null) {
// Bloqueamos el carrito
this.cartService.lockCartById(cart.getId());
// order ID es generado dentro de createOrderFromCart donde se marcan los
// presupuestos como no editables
// Long orderId =
// this.cartService.pedidoService.createOrderFromCart(cart.getId(), p.getId());
// p.setOrderId(orderId);
// Creamos el pedido
Long orderId = this.cartService.crearPedido(cart.getId(), locale);
if(orderId == null){
return false;
}
else{
// envio de correo de confirmacion de pedido podria ir aqui
}
}
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.presupuesto.service.PresupuestoFormDataMapper;
import com.imprimelibros.erp.presupuesto.service.PresupuestoFormDataMapper.PresupuestoFormDataDto;
import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.common.web.IpUtils;
import jakarta.servlet.http.HttpServletRequest;
@ -561,6 +562,20 @@ public class PresupuestoController {
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)) {
// Añadir mensaje flash para mostrar alerta
redirectAttributes.addFlashAttribute("errorMessage",
@ -573,7 +588,7 @@ public class PresupuestoController {
String path = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes())
.getRequest().getRequestURI();
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");
} else {
model.addAttribute("cliente_id", presupuestoOpt.get().getUser().getId());

View File

@ -120,7 +120,7 @@ public class PresupuestoDatatableService {
String id = String.valueOf(p.getId());
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) ? "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
+ "\" class=\"link-danger btn-delete-"

View File

@ -99,23 +99,27 @@ public class Presupuesto extends AbstractAuditedEntity implements Cloneable {
modificado("presupuesto.estado.modificado");
private final String messageKey;
Estado(String messageKey) {
this.messageKey = messageKey;
}
public String getMessageKey() {
return messageKey;
}
}
public enum Entrega{
public enum Entrega {
peninsula("presupuesto.entrega.peninsula"),
canarias("presupuesto.entrega.canarias"),
paises_ue("presupuesto.entrega.paises-ue");
private final String messageKey;
Entrega(String messageKey) {
this.messageKey = messageKey;
}
public String getMessageKey() {
return messageKey;
}
@ -371,6 +375,18 @@ public class Presupuesto extends AbstractAuditedEntity implements Cloneable {
@Column(name = "alto_faja")
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 ======
public String resumenPresupuesto() {
@ -912,16 +928,48 @@ public class Presupuesto extends AbstractAuditedEntity implements Cloneable {
this.altoFaja = altoFaja;
}
public Long getId(){
public Long getId() {
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;
}
public Double getPeso(){
public Double getPeso() {
// get peso from first element of pricingSnapshotJson (need to parse JSON)
// pricingSnapshotJson = {"xxx":{"peso":0.5,...}} is a String
if (this.pricingSnapshotJson != null && !this.pricingSnapshotJson.isEmpty()) {

View File

@ -290,6 +290,10 @@ public class PresupuestoService {
}
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_PAGINAS_CUADERNILLO = 32;
@ -311,9 +315,16 @@ public class PresupuestoService {
Map<String, Object> body = new HashMap<>();
body.put("tipo_impresion_id", this.getTipoImpresionId(presupuesto));
body.put("tirada", Arrays.stream(presupuesto.getTiradas())
.filter(Objects::nonNull)
.collect(Collectors.toList()));
if (toSave) {
body.put("tirada", Arrays.stream(presupuesto.getTiradas())
.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("tipo", presupuesto.getTipoEncuadernacion());
body.put("clienteId", SK_CLIENTE_ID);
@ -343,9 +354,39 @@ public class PresupuestoService {
faja.put("alto", presupuesto.getAltoFaja());
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;
}
public Integer getTipoImpresionId(Presupuesto presupuesto) {
@ -599,11 +640,11 @@ public class PresupuestoService {
.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) {
precio = precio.add(precio.multiply(
BigDecimal.valueOf(presupuestoMaquetacion.getNumColumnas() - 1))
.multiply(BigDecimal.valueOf(price.apply("columnas"))) );
.multiply(BigDecimal.valueOf(price.apply("columnas"))));
}
precio = precio
@ -877,6 +918,7 @@ public class PresupuestoService {
/ Double.parseDouble(servicio.get("units").toString())
: servicio.get("price"));
servicioData.put("unidades", servicio.get("units"));
servicioData.put("id", servicio.get("id"));
serviciosExtras.add(servicioData);
}
}

View File

@ -1,9 +1,22 @@
package com.imprimelibros.erp.redsys;
import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.payments.PaymentService;
import com.imprimelibros.erp.payments.model.Payment;
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.http.MediaType;
import org.springframework.http.ResponseEntity;
@ -22,37 +35,51 @@ public class RedsysController {
private final PaymentService paymentService;
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.messageSource = messageSource;
this.templateEngine = templateEngine;
this.servletContext = servletContext;
}
@PostMapping(value = "/crear", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
@ResponseBody
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)) {
// 1) Creamos el Payment interno SIN orderId (null)
Payment p = paymentService.createBankTransferPayment(cartId, amountCents, "EUR");
// 2) Mostramos instrucciones de transferencia
String html = """
<html><head><meta charset="utf-8"><title>Pago por transferencia</title></head>
<body>
<h2>Pago por transferencia bancaria</h2>
<p>Hemos registrado tu intención de pedido.</p>
<p><strong>Importe:</strong> %s €</p>
<p><strong>IBAN:</strong> ES00 1234 5678 9012 3456 7890</p>
<p><strong>Concepto:</strong> TRANSF-%d</p>
<p>En cuanto recibamos la transferencia, procesaremos tu pedido.</p>
<p><a href="/checkout/resumen">Volver al resumen</a></p>
</body></html>
""".formatted(
String.format("%.2f", amountCents / 100.0),
p.getId() // usamos el ID del Payment como referencia
);
// 1⃣ Crear la "aplicación" web de Thymeleaf (Jakarta)
JakartaServletWebApplication app = JakartaServletWebApplication.buildApplication(servletContext);
// 2⃣ Construir el intercambio web desde request/response
response.setContentType("text/html;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
IWebExchange exchange = app.buildExchange(request, response);
// 3⃣ Crear el contexto WebContext con Locale
WebContext ctx = new WebContext(exchange, locale);
String importeFormateado = Utils.formatCurrency(amountCents / 100.0, locale);
ctx.setVariable("importe", importeFormateado);
ctx.setVariable("concepto", "TRANSF-" + p.getId());
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);
return ResponseEntity.ok()
@ -92,7 +119,8 @@ public class RedsysController {
// GET: cuando el usuario cae aquí sin parámetros, o Redsys redirige por GET
@GetMapping("/ok")
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);
redirectAttrs.addFlashAttribute("successPago", msg);
return "redirect:/cart";
@ -103,10 +131,10 @@ public class RedsysController {
@PostMapping(value = "/ok", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
@ResponseBody
public ResponseEntity<String> okPost(@RequestParam("Ds_Signature") String signature,
@RequestParam("Ds_MerchantParameters") String merchantParameters) {
@RequestParam("Ds_MerchantParameters") String merchantParameters, Locale locale) {
try {
// 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>");
} catch (Exception e) {
return ResponseEntity.badRequest()
@ -117,7 +145,9 @@ public class RedsysController {
@GetMapping("/ko")
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);
redirectAttrs.addFlashAttribute("errorPago", msg);
return "redirect:/cart";
@ -127,11 +157,11 @@ public class RedsysController {
@ResponseBody
public ResponseEntity<String> koPost(
@RequestParam("Ds_Signature") String signature,
@RequestParam("Ds_MerchantParameters") String merchantParameters) {
@RequestParam("Ds_MerchantParameters") String merchantParameters, Locale locale) {
try {
// 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)
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)
@ResponseBody
public String notifyRedsys(@RequestParam("Ds_Signature") String signature,
@RequestParam("Ds_MerchantParameters") String merchantParameters) {
@RequestParam("Ds_MerchantParameters") String merchantParameters, Locale locale) {
try {
paymentService.handleRedsysNotification(signature, merchantParameters);
paymentService.handleRedsysNotification(signature, merchantParameters, locale);
return "OK";
} catch (Exception e) {
e.printStackTrace(); // 👈 para ver el motivo del 500 en logs

View File

@ -1,29 +1,39 @@
databaseChangeLog:
- changeSet:
id: 0009-drop-unique-refund-gateway-id
id: 0010-drop-unique-tx-gateway
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:
tableName: refunds
indexName: idx_refunds_gateway_refund_id
tableName: payment_transactions
indexName: idx_payment_tx_gateway_txid
columns:
- column:
name: gateway_refund_id
name: gateway_transaction_id
rollback:
# 🔙 1) Eliminar el índice normal creado en este changeSet
- dropIndex:
indexName: idx_refunds_gateway_refund_id
tableName: refunds
tableName: payment_transactions
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
- addUniqueConstraint:
tableName: refunds
columnNames: gateway_refund_id
constraintName: uq_refund_gateway_id
tableName: payment_transactions
columnNames: gateway_transaction_id, type
constraintName: uq_tx_gateway_txid_type

View File

@ -2,14 +2,21 @@ databaseChangeLog:
- changeSet:
id: 0010-drop-unique-tx-gateway
author: JJO
changes:
# 1⃣ Eliminar la UNIQUE constraint sobre (gateway_transaction_id, type)
- dropUniqueConstraint:
constraintName: uq_tx_gateway_txid_type
# ✅ 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
# para poder seguir buscando rápido por este campo
- createIndex:
tableName: payment_transactions
indexName: idx_payment_tx_gateway_txid
@ -20,8 +27,8 @@ databaseChangeLog:
rollback:
# 🔙 1) Eliminar el índice normal creado en este changeSet
- dropIndex:
indexName: idx_payment_tx_gateway_txid
tableName: payment_transactions
indexName: idx_payment_tx_gateway_txid
# 🔙 2) Restaurar la UNIQUE constraint original
- 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:
file: db/changelog/changesets/0009-add-composite-unique-txid-type.yml
- 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.editar=Editar
app.add=Añadir
app.back=Volver
app.eliminar=Eliminar
app.imprimir=Imprimir
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.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.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.

View File

@ -12,6 +12,11 @@ $(() => {
// remove name from container . direccion-card
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) {
$(this).find('.direccion-id').attr('name', 'direcciones[' + i + '].id');
$(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 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"class="alert alert-info" role="alert" th:text="#{cart.empty}"></div>
</div>
<div id="alert-empty" th:class="'alert alert-info ' + ${items.isEmpty() ? '' : 'd-none'}" role="alert" th:text="#{cart.empty}"></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}"
th:with="isAuth=${#authorization.expression('isAuthenticated()')}"
<html th:lang="${#locale.country != '' ? #locale.language + '-' + #locale.country : #locale.language}" th:with="isAuth=${isAuth != null
? isAuth
: (#authorization == null ? false : #authorization.expression('isAuthenticated()'))}"
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"
xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="_csrf" th:content="${_csrf.token}" />
<meta name="_csrf_header" th:content="${_csrf.headerName}" />
<meta name="_csrf" th:content="${_csrf != null ? _csrf.token : ''}" />
<meta name="_csrf_header" th:content="${_csrf != null ? _csrf.headerName : ''}" />
<th:block layout:fragment="pagetitle" />
<th:block th:replace="~{imprimelibros/partials/head-css :: head-css}" />
@ -18,10 +19,11 @@
<body>
<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>
<section class="main-content">
<div class="page-content">
<div class="container-fluid">
@ -39,10 +41,11 @@
<th:block layout:fragment="pagejs" />
<script th:src="@{/assets/js/app.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>
</th:block>
</body>
</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 th:if="${#authorization.expression('isAuthenticated()')}"
<div th:if="${isAuth}"
class="ms-1 header-item d-none d-sm-flex">
<button type="button" id="btn_cart"
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">
<button type="button" class="btn" id="page-header-user-dropdown" data-bs-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
@ -114,9 +114,9 @@
</div>
</div>
<!-- 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">
<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>
</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
}
}