diff --git a/pom.xml b/pom.xml index 2e5351a..404dffd 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ org.springframework.boot spring-boot-starter-parent - 3.5.6 + 3.5.7 com.imprimelibros @@ -151,6 +151,41 @@ ${liquibase.version} + + + sis.redsys + apiSha256 + 1.0 + system + ${project.basedir}/src/main/resources/lib/apiSha256.jar + + + + + org.bouncycastle + bcprov-jdk15on + 1.47 + system + ${project.basedir}/src/main/resources/lib/bcprov-jdk15on-1.4.7.jar + + + + commons-codec + commons-codec + 1.3 + system + ${project.basedir}/src/main/resources/lib/commons-codec-1.3.jar + + + + org.json + json + 1.0 + system + ${project.basedir}/src/main/resources/lib/org.json.jar + + + diff --git a/src/main/java/com/imprimelibros/erp/cart/Cart.java b/src/main/java/com/imprimelibros/erp/cart/Cart.java index ff3b6ec..557636f 100644 --- a/src/main/java/com/imprimelibros/erp/cart/Cart.java +++ b/src/main/java/com/imprimelibros/erp/cart/Cart.java @@ -1,18 +1,23 @@ package com.imprimelibros.erp.cart; import jakarta.persistence.*; + +import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @Entity -@Table(name = "carts", - uniqueConstraints = @UniqueConstraint(name="uq_carts_user_active", columnNames={"user_id","status"})) +@Table(name = "carts", uniqueConstraints = @UniqueConstraint(name = "uq_carts_user_active", columnNames = { "user_id", + "status" })) public class Cart { - public enum Status { ACTIVE, LOCKED, ABANDONED } + public enum Status { + ACTIVE, LOCKED, ABANDONED + } - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "user_id", nullable = false) @@ -25,6 +30,9 @@ public class Cart { @Column(nullable = false, length = 3) private String currency = "EUR"; + @Column(name = "only_one_shipment", nullable = false) + private Boolean onlyOneShipment = true; + @Column(name = "created_at", nullable = false) private LocalDateTime createdAt = LocalDateTime.now(); @@ -34,23 +42,93 @@ public class Cart { @OneToMany(mappedBy = "cart", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) private List items = new ArrayList<>(); + @OneToMany(mappedBy = "cart", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List direcciones = new ArrayList<>(); + + @Column(name = "total", nullable = false) + private BigDecimal total = BigDecimal.ZERO; + @PreUpdate - public void preUpdate() { this.updatedAt = LocalDateTime.now(); } + public void preUpdate() { + this.updatedAt = LocalDateTime.now(); + } // Getters & Setters - public Long getId() { return id; } - public Long getUserId() { return userId; } - public void setUserId(Long userId) { this.userId = userId; } + public Long getId() { + return id; + } - public Status getStatus() { return status; } - public void setStatus(Status status) { this.status = status; } + public Long getUserId() { + return userId; + } - public String getCurrency() { return currency; } - public void setCurrency(String currency) { this.currency = currency; } + public void setUserId(Long userId) { + this.userId = userId; + } - public LocalDateTime getCreatedAt() { return createdAt; } - public LocalDateTime getUpdatedAt() { return updatedAt; } + public Status getStatus() { + return status; + } - public List getItems() { return items; } - public void setItems(List items) { this.items = items; } + public void setStatus(Status status) { + this.status = status; + } + + public String getCurrency() { + return currency; + } + + public void setCurrency(String currency) { + this.currency = currency; + } + + public Boolean getOnlyOneShipment() { + return onlyOneShipment; + } + + public void setOnlyOneShipment(Boolean onlyOneShipment) { + this.onlyOneShipment = onlyOneShipment; + } + + public BigDecimal getTotal() { + return total; + } + + public void setTotal(BigDecimal total) { + this.total = total; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public List getItems() { + return items; + } + + public void setItems(List items) { + this.items = items; + } + + public List getDirecciones() { + return direcciones; + } + + public void setDirecciones(List direcciones) { + this.direcciones = direcciones; + } + + public void addDireccion(CartDireccion d) { + direcciones.add(d); + d.setCart(this); + } + + public void removeDireccion(CartDireccion d) { + direcciones.remove(d); + d.setCart(null); + } } diff --git a/src/main/java/com/imprimelibros/erp/cart/CartController.java b/src/main/java/com/imprimelibros/erp/cart/CartController.java index 4bb4f78..5fa7504 100644 --- a/src/main/java/com/imprimelibros/erp/cart/CartController.java +++ b/src/main/java/com/imprimelibros/erp/cart/CartController.java @@ -3,71 +3,105 @@ package com.imprimelibros.erp.cart; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; - -import com.imprimelibros.erp.users.UserDetailsImpl; +import org.springframework.web.server.ResponseStatusException; import jakarta.servlet.http.HttpServletRequest; -import com.imprimelibros.erp.users.User; - +import org.springframework.context.MessageSource; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; +import com.imprimelibros.erp.common.Utils; +import com.imprimelibros.erp.direcciones.Direccion; +import com.imprimelibros.erp.direcciones.DireccionService; +import com.imprimelibros.erp.i18n.TranslationService; import java.security.Principal; +import java.util.List; import java.util.Locale; import java.util.Map; +import com.imprimelibros.erp.cart.dto.UpdateCartRequest; + @Controller @RequestMapping("/cart") public class CartController { - private final CartService service; + protected final CartService service; + protected DireccionService direccionService; + protected MessageSource messageSource; + protected TranslationService translationService; - public CartController(CartService service) { + public CartController(CartService service, DireccionService direccionService, MessageSource messageSource, + TranslationService translationService) { this.service = service; - } - - /** - * Obtiene el ID de usuario desde tu seguridad. - * Adáptalo a tu UserDetails (e.g., SecurityContext con getId()) - */ - private Long currentUserId(Principal principal) { - if (principal == null) { - throw new IllegalStateException("Usuario no autenticado"); - } - - if (principal instanceof Authentication auth) { - Object principalObj = auth.getPrincipal(); - - if (principalObj instanceof UserDetailsImpl udi) { - return udi.getId(); - } else if (principalObj instanceof User u && u.getId() != null) { - return u.getId(); - } - } - - throw new IllegalStateException("No se pudo obtener el ID del usuario actual"); + this.direccionService = direccionService; + this.messageSource = messageSource; + this.translationService = translationService; } /** Vista del carrito */ @GetMapping public String viewCart(Model model, Principal principal, Locale locale) { - var items = service.listItems(currentUserId(principal), locale); + + List keys = List.of( + "app.cancelar", + "app.seleccionar", + "cart.shipping.add.title", + "cart.shipping.select-placeholder", + "cart.shipping.new-address", + "cart.shipping.errors.noAddressSelected", + "cart.shipping.enter-units", + "cart.shipping.units-label", + "cart.shipping.errors.units-error", + "cart.shipping.ud", + "cart.shipping.uds", + "cart.shipping.send-in-palets", + "cart.shipping.send-in-palets.info", + "cart.shipping.tipo-envio", + "cart.pass-to.customer.error", + "cart.pass-to.customer.error-move", + "app.yes", + "app.aceptar", + "app.cancelar"); + + Map translations = translationService.getTranslations(locale, keys); + model.addAttribute("languageBundle", translations); + + Long userId = Utils.currentUserId(principal); + Cart cart = service.getOrCreateActiveCart(userId); + + var items = service.listItems(userId, locale); model.addAttribute("items", items); + + Map direcciones = service.getCartDirecciones(cart.getId(), locale); + if (direcciones != null && direcciones.containsKey("mainDir")) + model.addAttribute("mainDir", direcciones.get("mainDir")); + else if (direcciones != null && direcciones.containsKey("direcciones")) + model.addAttribute("direcciones", direcciones.get("direcciones")); + + var summary = service.getCartSummary(cart, locale); + model.addAttribute("cartSummary", summary); + if (summary.get("errorShipmentCost") != null && (Boolean) summary.get("errorShipmentCost")) + model.addAttribute("errorEnvio", true); + else + model.addAttribute("errorEnvio", false); + + model.addAttribute("cart", cart); return "imprimelibros/cart/cart"; // crea esta vista si quieres (tabla simple) } /** Añadir presupuesto via POST form */ @PostMapping("/add") public String add(@PathVariable(name = "presupuestoId", required = true) Long presupuestoId, Principal principal) { - service.addPresupuesto(currentUserId(principal), presupuestoId); + service.addPresupuesto(Utils.currentUserId(principal), presupuestoId); return "redirect:/cart"; } /** Añadir presupuesto con ruta REST (opcional) */ @PostMapping("/add/{presupuestoId}") public Object addPath(@PathVariable Long presupuestoId, Principal principal, HttpServletRequest request) { - service.addPresupuesto(currentUserId(principal), presupuestoId); + service.addPresupuesto(Utils.currentUserId(principal), presupuestoId); boolean isAjax = "XMLHttpRequest".equals(request.getHeader("X-Requested-With")); if (isAjax) { // Responder 200 con la URL a la que quieres ir @@ -83,13 +117,13 @@ public class CartController { public long getCount(Principal principal) { if (principal == null) return 0; - return service.countItems(currentUserId(principal)); + return service.countItems(Utils.currentUserId(principal)); } /** Eliminar línea por ID de item */ @DeleteMapping("/{itemId}/remove") public String remove(@PathVariable Long itemId, Principal principal) { - service.removeItem(currentUserId(principal), itemId); + service.removeItem(Utils.currentUserId(principal), itemId); return "redirect:/cart"; } @@ -97,14 +131,72 @@ public class CartController { @DeleteMapping("/delete/item/{presupuestoId}") @ResponseBody public String removeByPresupuesto(@PathVariable Long presupuestoId, Principal principal) { - service.removeByPresupuesto(currentUserId(principal), presupuestoId); + service.removeByPresupuesto(Utils.currentUserId(principal), presupuestoId); return "redirect:/cart"; } /** Vaciar carrito completo */ @DeleteMapping("/clear") public String clear(Principal principal) { - service.clear(currentUserId(principal)); + service.clear(Utils.currentUserId(principal)); return "redirect:/cart"; } + + @GetMapping("/get-address/{id}") + public String getDireccionCard(@PathVariable Long id, @RequestParam(required = false) Long presupuestoId, + @RequestParam(required = false) Integer unidades, + @RequestParam(required = false) Integer isPalets, + Model model, Locale locale) { + Direccion dir = direccionService.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + model.addAttribute("pais", messageSource.getMessage("paises." + dir.getPais().getKeyword(), null, + dir.getPais().getKeyword(), locale)); + model.addAttribute("presupuestoId", presupuestoId); + model.addAttribute("unidades", unidades); + model.addAttribute("isPalets", isPalets); + model.addAttribute("direccion", dir); + + return "imprimelibros/direcciones/direccionCard :: direccionCard(direccion=${direccion})"; + } + + @PostMapping(value = "/update/{id}", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + public String updateCart(@PathVariable Long id, UpdateCartRequest updateRequest, Model model, Locale locale, + Principal principal) { + + try { + service.updateCart(id, updateRequest); + var cartSummary = service.getCartSummary(service.getCartById(id), locale); + model.addAttribute("cartSummary", cartSummary); + + return "imprimelibros/cart/_cartSummary :: cartSummary(summary=${cartSummary})"; + + } catch (Exception e) { + + // redirect to cart with error message + String errorMessage = messageSource.getMessage("cart.update.error", null, "Error updating cart", locale); + model.addAttribute("errorMessage", errorMessage); + return "redirect:/cart"; + } + } + + @PostMapping(value = "/pass-to-customer/{customerId}") + public ResponseEntity moveToCustomer( + @PathVariable Long customerId, + Principal principal) { + + if(!Utils.isCurrentUserAdmin()) { + return ResponseEntity.status(403).body(Map.of("error", "Forbidden")); + } + + Long userId = Utils.currentUserId(principal); + Cart cart = service.getOrCreateActiveCart(userId); + + boolean ok = service.moveCartToCustomer(cart.getId(), customerId); + + if (ok) + return ResponseEntity.ok().build(); + return ResponseEntity.status(400).body(Map.of("error", "cart.errors.move-cart")); + } + + } diff --git a/src/main/java/com/imprimelibros/erp/cart/CartDireccion.java b/src/main/java/com/imprimelibros/erp/cart/CartDireccion.java new file mode 100644 index 0000000..dd94623 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/cart/CartDireccion.java @@ -0,0 +1,101 @@ +package com.imprimelibros.erp.cart; + +import java.util.Locale; + +import org.springframework.context.MessageSource; +import com.imprimelibros.erp.cart.dto.DireccionCardDTO; +import com.imprimelibros.erp.direcciones.Direccion; +import com.imprimelibros.erp.presupuesto.dto.Presupuesto; + +import jakarta.persistence.*; + +@Entity +@Table(name = "cart_direcciones") +public class CartDireccion { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "cart_id", nullable = false) + private Cart cart; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "direccion_id", nullable = false) + private Direccion direccion; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "presupuesto_id") + private Presupuesto presupuesto; + + @Column(name = "unidades") + private Integer unidades; + + @Column(name = "isPalets", nullable = false) + private Boolean isPalets; + + // --- Getters & Setters --- + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Cart getCart() { + return cart; + } + + public void setCart(Cart cart) { + this.cart = cart; + } + + public Direccion getDireccion() { + return direccion; + } + + public void setDireccion(Direccion direccion) { + this.direccion = direccion; + } + + public Presupuesto getPresupuesto() { + return presupuesto; + } + + public void setPresupuesto(Presupuesto presupuesto) { + this.presupuesto = presupuesto; + } + + public Integer getUnidades() { + return unidades; + } + + public void setUnidades(Integer unidades) { + this.unidades = unidades; + } + + public Boolean getIsPalets() { + return isPalets; + } + + public void setIsPalets(Boolean isPalets) { + this.isPalets = isPalets; + } + + public DireccionCardDTO toDireccionCard(MessageSource messageSource, Locale locale) { + + String pais = messageSource.getMessage("paises." + this.direccion.getPais().getKeyword(), null, + this.direccion.getPais().getKeyword(), locale); + + return new DireccionCardDTO( + this.direccion, + this.presupuesto != null ? this.presupuesto.getId() : null, + this.unidades, + this.isPalets, + pais + ); + } + +} diff --git a/src/main/java/com/imprimelibros/erp/cart/CartItem.java b/src/main/java/com/imprimelibros/erp/cart/CartItem.java index 853214c..7579201 100644 --- a/src/main/java/com/imprimelibros/erp/cart/CartItem.java +++ b/src/main/java/com/imprimelibros/erp/cart/CartItem.java @@ -3,6 +3,8 @@ package com.imprimelibros.erp.cart; import jakarta.persistence.*; import java.time.LocalDateTime; +import com.imprimelibros.erp.presupuesto.dto.Presupuesto; + @Entity @Table( name = "cart_items", @@ -17,8 +19,9 @@ public class CartItem { @JoinColumn(name = "cart_id", nullable = false) private Cart cart; - @Column(name = "presupuesto_id", nullable = false) - private Long presupuestoId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "presupuesto_id", nullable = false) + private Presupuesto presupuesto; @Column(name = "created_at", nullable = false) private LocalDateTime createdAt = LocalDateTime.now(); @@ -29,8 +32,8 @@ public class CartItem { public Cart getCart() { return cart; } public void setCart(Cart cart) { this.cart = cart; } - public Long getPresupuestoId() { return presupuestoId; } - public void setPresupuestoId(Long presupuestoId) { this.presupuestoId = presupuestoId; } + public Presupuesto getPresupuesto() { return presupuesto; } + public void setPresupuesto(Presupuesto presupuesto) { this.presupuesto = presupuesto; } public LocalDateTime getCreatedAt() { return createdAt; } } diff --git a/src/main/java/com/imprimelibros/erp/cart/CartRepository.java b/src/main/java/com/imprimelibros/erp/cart/CartRepository.java index d23d60a..2f1ad0c 100644 --- a/src/main/java/com/imprimelibros/erp/cart/CartRepository.java +++ b/src/main/java/com/imprimelibros/erp/cart/CartRepository.java @@ -1,9 +1,22 @@ package com.imprimelibros.erp.cart; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; public interface CartRepository extends JpaRepository { Optional findByUserIdAndStatus(Long userId, Cart.Status status); + + @Query(""" + select distinct c from Cart c + left join fetch c.direcciones cd + left join fetch cd.direccion d + left join fetch d.pais p + left join fetch cd.presupuesto pr + where c.id = :id + """) + Optional findByIdFetchAll(@Param("id") Long id); + } diff --git a/src/main/java/com/imprimelibros/erp/cart/CartService.java b/src/main/java/com/imprimelibros/erp/cart/CartService.java index d04b349..1980f4d 100644 --- a/src/main/java/com/imprimelibros/erp/cart/CartService.java +++ b/src/main/java/com/imprimelibros/erp/cart/CartService.java @@ -1,6 +1,6 @@ package com.imprimelibros.erp.cart; -import jakarta.transaction.Transactional; +import org.springframework.transaction.annotation.Transactional; import org.springframework.context.MessageSource; import org.springframework.stereotype.Service; @@ -10,32 +10,43 @@ import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import com.imprimelibros.erp.presupuesto.classes.PresupuestoFormatter; import com.imprimelibros.erp.presupuesto.dto.Presupuesto; +import com.imprimelibros.erp.cart.dto.DireccionCardDTO; +import com.imprimelibros.erp.cart.dto.DireccionShipment; +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.presupuesto.PresupuestoRepository; - @Service public class CartService { private final CartRepository cartRepo; private final CartItemRepository itemRepo; private final MessageSource messageSource; - private final PresupuestoFormatter presupuestoFormatter; private final PresupuestoRepository presupuestoRepo; private final Utils utils; + private final DireccionService direccionService; + private final skApiClient skApiClient; + private final PedidoService pedidoService; public CartService(CartRepository cartRepo, CartItemRepository itemRepo, MessageSource messageSource, PresupuestoFormatter presupuestoFormatter, - PresupuestoRepository presupuestoRepo, Utils utils) { + PresupuestoRepository presupuestoRepo, Utils utils, DireccionService direccionService, + skApiClient skApiClient, PedidoService pedidoService) { this.cartRepo = cartRepo; this.itemRepo = itemRepo; this.messageSource = messageSource; - this.presupuestoFormatter = presupuestoFormatter; this.presupuestoRepo = presupuestoRepo; this.utils = utils; + this.direccionService = direccionService; + this.skApiClient = skApiClient; + this.pedidoService = pedidoService; } /** Devuelve el carrito activo o lo crea si no existe. */ @@ -50,6 +61,11 @@ public class CartService { }); } + public Cart getCartById(Long cartId) { + return cartRepo.findById(cartId) + .orElseThrow(() -> new IllegalArgumentException("Carrito no encontrado")); + } + /** Lista items (presupuestos) del carrito activo del usuario. */ @Transactional public List> listItems(Long userId, Locale locale) { @@ -58,14 +74,13 @@ public class CartService { List items = itemRepo.findByCartId(cart.getId()); for (CartItem item : items) { - Presupuesto p = presupuestoRepo.findById(item.getPresupuestoId()) - .orElseThrow(() -> new IllegalStateException("Presupuesto no encontrado: " + item.getPresupuestoId())); + Presupuesto p = item.getPresupuesto(); Map elemento = getElementoCart(p, locale); elemento.put("cartItemId", item.getId()); resultados.add(elemento); } - //System.out.println("Cart items: " + resultados); + // System.out.println("Cart items: " + resultados); return resultados; } @@ -77,7 +92,8 @@ public class CartService { if (!exists) { CartItem ci = new CartItem(); ci.setCart(cart); - ci.setPresupuestoId(presupuestoId); + ci.setPresupuesto(presupuestoRepo.findById(presupuestoId) + .orElseThrow(() -> new IllegalArgumentException("Presupuesto no encontrado"))); itemRepo.save(ci); } } @@ -96,8 +112,9 @@ public class CartService { @Transactional public void removeByPresupuesto(Long userId, Long presupuestoId) { Cart cart = getOrCreateActiveCart(userId); - itemRepo.findByCartIdAndPresupuestoId(cart.getId(), presupuestoId) - .ifPresent(itemRepo::delete); + CartItem item = itemRepo.findByCartIdAndPresupuestoId(cart.getId(), presupuestoId) + .orElseThrow(() -> new IllegalArgumentException("Item no encontrado")); + itemRepo.deleteById(item.getId()); } /** Vacía todo el carrito activo. */ @@ -122,9 +139,9 @@ public class CartService { } private Map getElementoCart(Presupuesto presupuesto, Locale locale) { - + Map resumen = new HashMap<>(); - + resumen.put("titulo", presupuesto.getTitulo()); resumen.put("imagen", @@ -134,8 +151,15 @@ public class CartService { resumen.put("presupuestoId", presupuesto.getId()); + if (presupuesto.getServiciosJson() != null && presupuesto.getServiciosJson().contains("ejemplar-prueba")) { + resumen.put("hasSample", true); + } else { + resumen.put("hasSample", false); + } Map detalles = utils.getTextoPresupuesto(presupuesto, locale); + resumen.put("tirada", presupuesto.getSelectedTirada()); + resumen.put("baseTotal", Utils.formatCurrency(presupuesto.getBaseImponible(), locale)); resumen.put("base", presupuesto.getBaseImponible()); resumen.put("iva4", presupuesto.getIvaImporte4()); @@ -145,4 +169,257 @@ public class CartService { return resumen; } + + public Map getCartSummary(Cart cart, Locale locale) { + + double base = 0.0; + double iva4 = 0.0; + double iva21 = 0.0; + double shipment = 0.0; + + Boolean errorShipementCost = false; + + List items = cart.getItems(); + List direcciones = cart.getDirecciones(); + + for (CartItem item : items) { + Presupuesto p = item.getPresupuesto(); + Double peso = p.getPeso() != null ? p.getPeso().doubleValue() : 0.0; + 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) { + CartDireccion cd = direcciones.get(0); + Boolean freeShipment = direccionService.checkFreeShipment(cd.getDireccion().getCp(), + cd.getDireccion().getPaisCode3()) && !cd.getIsPalets(); + if (!freeShipment) { + Integer unidades = p.getSelectedTirada(); + Map res = getShippingCost(cd, peso, unidades, locale); + if (res.get("success").equals(Boolean.FALSE)) { + errorShipementCost = true; + } + 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")) { + + Map res = getShippingCost(cd, peso, 1, locale); + if (res.get("success").equals(Boolean.FALSE)) { + errorShipementCost = true; + } + 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 cd_presupuesto = direcciones.stream() + .filter(d -> d.getPresupuesto() != null && d.getPresupuesto().getId().equals(p.getId()) + && d.getUnidades() != null && d.getUnidades() != null && d.getUnidades() > 0) + .toList(); + Boolean firstDirection = true; + for (CartDireccion cd : cd_presupuesto) { + Integer unidades = cd.getUnidades(); + if (firstDirection) { + Boolean freeShipment = direccionService.checkFreeShipment(cd.getDireccion().getCp(), + cd.getDireccion().getPaisCode3()) && !cd.getIsPalets(); + if (!freeShipment && unidades != null && unidades > 0) { + Map res = getShippingCost(cd, peso, unidades, locale); + if (res.get("success").equals(Boolean.FALSE)) { + errorShipementCost = true; + } else { + shipment += (Double) res.get("shipment"); + iva21 += (Double) res.get("iva21"); + } + } + firstDirection = false; + } else { + Map res = getShippingCost(cd, peso, unidades, locale); + if (res.get("success").equals(Boolean.FALSE)) { + errorShipementCost = true; + } else { + shipment += (Double) res.get("shipment"); + iva21 += (Double) res.get("iva21"); + } + } + } + // ejemplar de prueba + CartDireccion cd_prueba = direcciones.stream() + .filter(d -> d.getPresupuesto() != null && d.getPresupuesto().getId().equals(p.getId()) + && d.getUnidades() == null) + .findFirst().orElse(null); + if (cd_prueba != null) { + + Map res = getShippingCost(cd_prueba, peso, 1, locale); + if (res.get("success").equals(Boolean.FALSE)) { + errorShipementCost = true; + } + else{ + shipment += (Double) res.get("shipment"); + iva21 += (Double) res.get("iva21"); + } + } + } + } + + double total = base + iva4 + iva21 + shipment; + + int fidelizacion = pedidoService.getDescuentoFidelizacion(); + double descuento = (total) * fidelizacion / 100.0; + total -= descuento; + + Map summary = new HashMap<>(); + summary.put("base", Utils.formatCurrency(base, locale)); + summary.put("iva4", Utils.formatCurrency(iva4, locale)); + 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("total", Utils.formatCurrency(total, locale)); + summary.put("errorShipmentCost", errorShipementCost); + + return summary; + } + + @Transactional(readOnly = true) + public Map getCartDirecciones(Long cartId, Locale locale) { + Cart cart = cartRepo.findByIdFetchAll(cartId) + .orElseThrow(() -> new IllegalArgumentException("Carrito no encontrado")); + + Map result = new HashMap<>(); + List direcciones = cart.getDirecciones(); + + if (cart.getOnlyOneShipment() && !direcciones.isEmpty()) { + result.put("mainDir", direcciones.get(0).toDireccionCard(messageSource, locale)); + } else { + List dirCards = cart.getDirecciones().stream() + .filter(Objects::nonNull) + .map(cd -> cd.toDireccionCard(messageSource, locale)) + .filter(Objects::nonNull) + .toList(); + result.put("direcciones", dirCards); + } + + return result; + } + + @Transactional + public Boolean updateCart(Long cartId, UpdateCartRequest request) { + + try { + Cart cart = cartRepo.findById(cartId) + .orElseThrow(() -> new IllegalArgumentException("Carrito no encontrado")); + cart.setOnlyOneShipment(request.isOnlyOneShipment()); + // Borramos todas las direcciones actuales de la bbdd + // Opcional (limpieza): romper backref antes de clear + for (CartDireccion d : cart.getDirecciones()) { + d.setCart(null); + } + cart.getDirecciones().clear(); + // Guardamos las direcciones + List direcciones = request.getDirecciones(); + if (direcciones != null && direcciones.size() > 0) { + for (DireccionShipment dir : direcciones) { + // Crear una nueva CartDireccion por cada item + CartDireccion cd = new CartDireccion(); + cd.setCart(cart); + cd.setDireccion(dir.getId() != null ? direccionService.findById(dir.getId()) + .orElseThrow(() -> new IllegalArgumentException("Dirección no encontrada")) : null); + cd.setIsPalets(dir.getIsPalets() != null ? dir.getIsPalets() : false); + cd.setUnidades(dir.getUnidades() != null ? dir.getUnidades() : null); + if (dir.getPresupuestoId() != null) { + Presupuesto p = presupuestoRepo.findById(dir.getPresupuestoId()) + .orElse(null); + cd.setPresupuesto(p); + } + cart.addDireccion(cd); + } + } else { + + } + cartRepo.save(cart); + return true; + } catch (Exception e) { + // Manejo de excepciones + return false; + } + } + + public Boolean moveCartToCustomer(Long cartId, Long customerId) { + try { + + // Remove the cart from the customer if they have one + Cart existingCart = cartRepo.findByUserIdAndStatus(customerId, Cart.Status.ACTIVE) + .orElse(null); + if (existingCart != null) { + cartRepo.delete(existingCart); + } + + Cart cart = cartRepo.findById(cartId) + .orElseThrow(() -> new IllegalArgumentException("Carrito no encontrado")); + + cart.setUserId(customerId); + cartRepo.save(cart); + return true; + + } catch (Exception e) { + // Manejo de excepciones + return false; + } + } + + /*************************************** + * MÉTODOS PRIVADOS + ***************************************/ + + private Map getShippingCost( + CartDireccion cd, + Double peso, + Integer unidades, + Locale locale) { + + Map result = new HashMap<>(); + + try { + Map data = Map.of( + "cp", cd.getDireccion().getCp(), + "pais_code3", cd.getDireccion().getPaisCode3(), + "peso", peso != null ? peso : 0.0, + "unidades", unidades, + "palets", Boolean.TRUE.equals(cd.getIsPalets()) ? 1 : 0); + + var shipmentCost = skApiClient.getCosteEnvio(data, locale); + + if (shipmentCost != null && shipmentCost.get("data") != null) { + Number n = (Number) shipmentCost.get("data"); + double cost = n.doubleValue(); + + result.put("success", true); + result.put("shipment", cost); + result.put("iva21", cost * 0.21); + } else { + result.put("success", false); + result.put("shipment", 0.0); + result.put("iva21", 0.0); + } + } catch (Exception e) { + result.put("success", false); + result.put("shipment", 0.0); + result.put("iva21", 0.0); + } + + return result; + } + } diff --git a/src/main/java/com/imprimelibros/erp/cart/dto/CartDireccionRepository.java b/src/main/java/com/imprimelibros/erp/cart/dto/CartDireccionRepository.java new file mode 100644 index 0000000..ba1e740 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/cart/dto/CartDireccionRepository.java @@ -0,0 +1,15 @@ +package com.imprimelibros.erp.cart.dto; + +import com.imprimelibros.erp.cart.CartDireccion; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CartDireccionRepository extends JpaRepository { + + // Borrado masivo por cart_id + void deleteByCartId(Long cartId); + + // Lectura por cart_id (útil para componer respuestas) + List findByCartId(Long cartId); +} diff --git a/src/main/java/com/imprimelibros/erp/cart/dto/DireccionCardDTO.java b/src/main/java/com/imprimelibros/erp/cart/dto/DireccionCardDTO.java new file mode 100644 index 0000000..fb3da47 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/cart/dto/DireccionCardDTO.java @@ -0,0 +1,39 @@ +package com.imprimelibros.erp.cart.dto; + +import com.imprimelibros.erp.direcciones.Direccion; + +public class DireccionCardDTO { + private final Direccion direccion; + private final Long presupuestoId; + private final Integer unidades; + private final Boolean isPalets; + private final String pais; + + public DireccionCardDTO(Direccion direccion, Long presupuestoId, Integer unidades, Boolean isPalets, String pais) { + this.direccion = direccion; + this.presupuestoId = presupuestoId; + this.unidades = unidades; + this.isPalets = isPalets; + this.pais = pais; + } + + public Direccion getDireccion() { + return direccion; + } + + public Long getPresupuestoId() { + return presupuestoId; + } + + public Integer getUnidades() { + return unidades; + } + + public Boolean getIsPalets() { + return isPalets; + } + + public String getPais() { + return pais; + } +} diff --git a/src/main/java/com/imprimelibros/erp/cart/dto/DireccionShipment.java b/src/main/java/com/imprimelibros/erp/cart/dto/DireccionShipment.java new file mode 100644 index 0000000..a6c5fea --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/cart/dto/DireccionShipment.java @@ -0,0 +1,60 @@ +package com.imprimelibros.erp.cart.dto; + +public class DireccionShipment { + + private Long id; // puede no venir → null + private String cp; // puede no venir → null + private String paisCode3; // puede no venir → null + private Long presupuestoId; // puede no venir → null + private Integer unidades; // puede no venir → null + private Boolean isPalets; // puede no venir → null + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getCp() { + return cp; + } + + public void setCp(String cp) { + this.cp = cp; + } + + public String getPaisCode3() { + return paisCode3; + } + + public void setPaisCode3(String paisCode3) { + this.paisCode3 = paisCode3; + } + + public Long getPresupuestoId() { + return presupuestoId; + } + + public void setPresupuestoId(Long presupuestoId) { + this.presupuestoId = presupuestoId; + } + + public Integer getUnidades() { + return unidades; + } + + public void setUnidades(Integer unidades) { + this.unidades = unidades; + } + + public Boolean getIsPalets() { + return isPalets; + } + + public void setIsPalets(Boolean isPalets) { + this.isPalets = isPalets; + } +} + diff --git a/src/main/java/com/imprimelibros/erp/cart/dto/UpdateCartRequest.java b/src/main/java/com/imprimelibros/erp/cart/dto/UpdateCartRequest.java new file mode 100644 index 0000000..515afc9 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/cart/dto/UpdateCartRequest.java @@ -0,0 +1,26 @@ +package com.imprimelibros.erp.cart.dto; + +import java.util.ArrayList; +import java.util.List; + +public class UpdateCartRequest { + private Boolean onlyOneShipment = Boolean.FALSE; // default: false + private List direcciones = new ArrayList<>(); + + public boolean isOnlyOneShipment() { // boolean-style getter + return Boolean.TRUE.equals(onlyOneShipment); + } + + public void setOnlyOneShipment(Boolean onlyOneShipment) { + this.onlyOneShipment = onlyOneShipment; + } + + public List getDirecciones() { + return direcciones; + } + + public void setDirecciones(List direcciones) { + this.direcciones = (direcciones != null) ? direcciones : new ArrayList<>(); + } +} + diff --git a/src/main/java/com/imprimelibros/erp/checkout/CheckoutController.java b/src/main/java/com/imprimelibros/erp/checkout/CheckoutController.java new file mode 100644 index 0000000..00474fc --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/checkout/CheckoutController.java @@ -0,0 +1,66 @@ +package com.imprimelibros.erp.checkout; + +import java.security.Principal; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.springframework.context.MessageSource; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +import com.imprimelibros.erp.common.Utils; +import com.imprimelibros.erp.i18n.TranslationService; +import com.imprimelibros.erp.paises.PaisesService; + +import com.imprimelibros.erp.direcciones.DireccionService; + +import com.imprimelibros.erp.cart.CartService; + +@Controller +@RequestMapping("/checkout") +public class CheckoutController { + + protected CartService cartService; + protected TranslationService translationService; + protected PaisesService paisesService; + protected DireccionService direccionService; + protected MessageSource messageSource; + + public CheckoutController(CartService cartService, TranslationService translationService, + PaisesService paisesService, DireccionService direccionService, MessageSource messageSource) { + this.cartService = cartService; + this.translationService = translationService; + this.paisesService = paisesService; + this.direccionService = direccionService; + this.messageSource = messageSource; + } + + @GetMapping + public String view(Model model, Principal principal, Locale locale) { + + List keys = List.of( + "app.cancelar", + "app.seleccionar", + "checkout.shipping.add.title", + "checkout.shipping.select-placeholder", + "checkout.shipping.new-address", + "app.yes", + "app.cancelar"); + + Map translations = translationService.getTranslations(locale, keys); + model.addAttribute("languageBundle", translations); + + var items = this.cartService.listItems(Utils.currentUserId(principal), locale); + for (var item : items) { + if (item.get("hasSample") != null && (Boolean) item.get("hasSample")) { + model.addAttribute("hasSample", true); + break; + } + } + model.addAttribute("items", items); + return "imprimelibros/checkout/checkout"; // crea esta vista si quieres (tabla simple) + } +} diff --git a/src/main/java/com/imprimelibros/erp/common/Utils.java b/src/main/java/com/imprimelibros/erp/common/Utils.java index b6b2a79..a5262b7 100644 --- a/src/main/java/com/imprimelibros/erp/common/Utils.java +++ b/src/main/java/com/imprimelibros/erp/common/Utils.java @@ -2,6 +2,7 @@ package com.imprimelibros.erp.common; import java.math.BigDecimal; import java.math.RoundingMode; +import java.security.Principal; import java.text.NumberFormat; import java.util.ArrayList; import java.util.HashMap; @@ -12,6 +13,8 @@ import java.util.Optional; import java.util.function.BiFunction; import org.springframework.context.MessageSource; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import com.fasterxml.jackson.core.JsonProcessingException; @@ -22,6 +25,8 @@ import com.imprimelibros.erp.presupuesto.classes.PresupuestoFormatter; import com.imprimelibros.erp.presupuesto.dto.Presupuesto; import com.imprimelibros.erp.presupuesto.maquetacion.MaquetacionMatrices; import com.imprimelibros.erp.presupuesto.marcapaginas.Marcapaginas; +import com.imprimelibros.erp.users.User; +import com.imprimelibros.erp.users.UserDetailsImpl; import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.Path; @@ -40,6 +45,30 @@ public class Utils { this.messageSource = messageSource; } + 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")); + } + + public static Long currentUserId(Principal principal) { + + if (principal == null) { + throw new IllegalStateException("Usuario no autenticado"); + } + + if (principal instanceof Authentication auth) { + Object principalObj = auth.getPrincipal(); + + if (principalObj instanceof UserDetailsImpl udi) { + return udi.getId(); + } else if (principalObj instanceof User u && u.getId() != null) { + return u.getId(); + } + } + throw new IllegalStateException("No se pudo obtener el ID del usuario actual"); + } + public static String formatCurrency(BigDecimal amount, Locale locale) { NumberFormat currencyFormatter = NumberFormat.getCurrencyInstance(locale); return currencyFormatter.format(amount); diff --git a/src/main/java/com/imprimelibros/erp/config/SecurityConfig.java b/src/main/java/com/imprimelibros/erp/config/SecurityConfig.java index 392b416..096488d 100644 --- a/src/main/java/com/imprimelibros/erp/config/SecurityConfig.java +++ b/src/main/java/com/imprimelibros/erp/config/SecurityConfig.java @@ -91,7 +91,6 @@ public class SecurityConfig { // Ignora CSRF para tu recurso público (sin Ant/Mvc matchers) .csrf(csrf -> csrf .ignoringRequestMatchers(pathStartsWith("/presupuesto/public/"))) - // ====== RequestCache: sólo navegaciones HTML reales ====== .requestCache(rc -> { HttpSessionRequestCache cache = new HttpSessionRequestCache(); diff --git a/src/main/java/com/imprimelibros/erp/direcciones/DireccionController.java b/src/main/java/com/imprimelibros/erp/direcciones/DireccionController.java index 8993f40..28d2bad 100644 --- a/src/main/java/com/imprimelibros/erp/direcciones/DireccionController.java +++ b/src/main/java/com/imprimelibros/erp/direcciones/DireccionController.java @@ -1,5 +1,6 @@ package com.imprimelibros.erp.direcciones; +import java.security.Principal; import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -43,20 +44,23 @@ import jakarta.validation.Valid; @RequestMapping("/direcciones") public class DireccionController { + private final DireccionService direccionService; + protected final DireccionRepository repo; protected final PaisesService paisesService; protected final MessageSource messageSource; protected final UserDao userRepo; protected final TranslationService translationService; - public DireccionController(DireccionRepository repo, PaisesService paisesService, - MessageSource messageSource, UserDao userRepo, TranslationService translationService) { + MessageSource messageSource, UserDao userRepo, TranslationService translationService, + DireccionService direccionService) { this.repo = repo; this.paisesService = paisesService; this.messageSource = messageSource; this.userRepo = userRepo; this.translationService = translationService; + this.direccionService = direccionService; } @GetMapping() @@ -295,6 +299,33 @@ public class DireccionController { return "imprimelibros/direcciones/direccion-form :: direccionForm"; } + @GetMapping("direction-form") + public String getForm(@RequestParam(required = false) Long id, + Direccion direccion, + BindingResult binding, + Model model, + HttpServletResponse response, + Principal principal, + Locale locale) { + + model.addAttribute("paises", paisesService.getForSelect("", "", locale).get("results")); + + Direccion newDireccion = new Direccion(); + + User user = null; + if (principal instanceof UserDetailsImpl udi) { + user = new User(); + user.setId(udi.getId()); + } else if (principal instanceof User u && u.getId() != null) { + user = u; + } + newDireccion.setUser(user); + model.addAttribute("dirForm", newDireccion); + model.addAttribute("action", "/direcciones/add"); + + return "imprimelibros/direcciones/direccion-form-fixed-user :: direccionForm"; + } + @PostMapping public String create( @Valid @ModelAttribute("dirForm") Direccion direccion, @@ -327,6 +358,34 @@ public class DireccionController { return null; } + // para el formulario modal en checkout + @PostMapping("/add") + public String create2( + @Valid @ModelAttribute("dirForm") Direccion direccion, + BindingResult binding, + Model model, + HttpServletResponse response, + Authentication auth, + Locale locale) { + + User current = userRepo.findByUserNameIgnoreCaseAndEnabledTrueAndDeletedFalse(auth.getName()).orElse(null); + direccion.setUser(current); + + if (binding.hasErrors()) { + response.setStatus(422); + model.addAttribute("paises", paisesService.getForSelect("", "", locale).get("results")); + model.addAttribute("action", "/direcciones/add"); + model.addAttribute("dirForm", direccion); + return "imprimelibros/direcciones/direccion-form-fixed-user :: direccionForm"; + } + + var data = direccion; + + repo.save(data); + response.setStatus(201); + return null; + } + @PostMapping("/{id}") public String update( @PathVariable Long id, @@ -416,12 +475,36 @@ public class DireccionController { } } + @GetMapping(value = "/select2", produces = "application/json") + @ResponseBody + public Map getSelect2( + @RequestParam(value = "q", required = false) String q1, + @RequestParam(value = "term", required = false) String q2, + @RequestParam(value = "presupuestoId", required = false) Long presupuestoId, + Authentication auth) { + + boolean isAdmin = auth.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN") || a.getAuthority().equals("ROLE_SUPERADMIN")); + + Long currentUserId = null; + if (!isAdmin) { + if (auth != null && auth.getPrincipal() instanceof UserDetailsImpl udi) { + currentUserId = udi.getId(); + } else if (auth != null) { + currentUserId = userRepo.findIdByUserNameIgnoreCase(auth.getName()).orElse(null); + } + } + + return direccionService.getForSelect(q1, q2, isAdmin ? null : currentUserId); + + } + private boolean isOwnerOrAdmin(Authentication auth, Long ownerId) { if (auth == null) { return false; } boolean isAdmin = auth.getAuthorities().stream() - .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")); + .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN") || a.getAuthority().equals("ROLE_SUPERADMIN")); if (isAdmin) { return true; } @@ -434,4 +517,5 @@ public class DireccionController { } return currentUserId != null && currentUserId.equals(ownerId); } + } diff --git a/src/main/java/com/imprimelibros/erp/direcciones/DireccionService.java b/src/main/java/com/imprimelibros/erp/direcciones/DireccionService.java new file mode 100644 index 0000000..1f1d4d5 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/direcciones/DireccionService.java @@ -0,0 +1,97 @@ +package com.imprimelibros.erp.direcciones; + +import java.text.Collator; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; + +@Service +public class DireccionService { + + protected DireccionRepository repo; + + public DireccionService(DireccionRepository repo) { + this.repo = repo; + } + + public Map getForSelect(String q1, String q2, Long userId) { + try { + + // Termino de búsqueda (Select2 usa 'q' o 'term' según versión/config) + String search = Optional.ofNullable(q1).orElse(q2); + if (search != null) { + search = search.trim(); + } + final String q = (search == null || search.isEmpty()) + ? null + : search.toLowerCase(); + + List all = userId != null ? repo.findByUserId(userId) : repo.findAll(); + + // Mapear a opciones id/text con i18n y filtrar por búsqueda si llega + List> options = all.stream() + .map(cc -> { + String id = cc.getId().toString(); + String alias = cc.getAlias(); + String direccion = cc.getDireccion(); + String cp = String.valueOf(cc.getCp()); + String ciudad = cc.getCiudad(); + String att = cc.getAtt(); + Map m = new HashMap<>(); + m.put("id", id); // lo normal en Select2: id = valor que guardarás (code3) + m.put("text", alias); // texto mostrado, i18n con fallback a keyword + m.put("cp", cp); + m.put("ciudad", ciudad); + m.put("att", att); + m.put("alias", alias); + m.put("direccion", direccion); + return m; + }) + .filter(opt -> { + if (q == null || q.isEmpty()) + return true; + String cp = opt.get("cp"); + String ciudad = opt.get("ciudad").toLowerCase(); + String att = opt.get("att").toLowerCase(); + String alias = opt.get("alias").toLowerCase(); + String text = opt.get("text").toLowerCase(); + String direccion = opt.get("direccion").toLowerCase(); + return text.contains(q) || cp.contains(q) || ciudad.contains(q) || att.contains(q) + || alias.contains(q) || direccion.contains(q); + }) + .sorted(Comparator.comparing(m -> m.get("text"), Collator.getInstance())) + .collect(Collectors.toList()); + + // Estructura Select2 + Map resp = new HashMap<>(); + resp.put("results", options); + return resp; + } catch (Exception e) { + e.printStackTrace(); + return Map.of("results", List.of()); + } + } + + public Optional findById(Long id) { + return repo.findById(id); + } + + public Boolean checkFreeShipment(Integer cp, String paisCode3) { + if (paisCode3 != null && paisCode3.toLowerCase().equals("esp") && cp != null) { + // Excluir Canarias (35xxx y 38xxx), Baleares (07xxx), Ceuta (51xxx), Melilla + // (52xxx) + int provincia = cp / 1000; + + if (provincia != 7 && provincia != 35 && provincia != 38 && provincia != 51 && provincia != 52) { + return true; // España peninsular + } + } + return false; + } + +} diff --git a/src/main/java/com/imprimelibros/erp/externalApi/skApiClient.java b/src/main/java/com/imprimelibros/erp/externalApi/skApiClient.java index 4e8fcb3..e586f1f 100644 --- a/src/main/java/com/imprimelibros/erp/externalApi/skApiClient.java +++ b/src/main/java/com/imprimelibros/erp/externalApi/skApiClient.java @@ -6,6 +6,7 @@ import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -18,8 +19,10 @@ import com.imprimelibros.erp.presupuesto.dto.Presupuesto.TipoCubierta; import com.imprimelibros.erp.presupuesto.dto.Presupuesto.TipoEncuadernacion; import java.util.Map; +import java.util.Optional; import java.math.BigDecimal; import java.math.RoundingMode; +import java.net.URI; import java.util.HashMap; import java.util.List; import java.util.function.Supplier; @@ -219,6 +222,54 @@ public class skApiClient { } } + public Map getCosteEnvio(Map data, Locale locale) { + + return performWithRetryMap(() -> { + String url = this.skApiUrl + "api/calcular-envio"; + + URI uri = UriComponentsBuilder.fromUriString(url) + .queryParam("pais_code3", data.get("pais_code3")) + .queryParam("cp", data.get("cp")) + .queryParam("peso", data.get("peso")) + .queryParam("unidades", data.get("unidades")) + .queryParam("palets", data.get("palets")) + .build(true) // no re-encode [] + .toUri(); + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(authService.getToken()); + + + ResponseEntity response = restTemplate.exchange( + uri, + HttpMethod.GET, + new HttpEntity<>(headers), + String.class); + + try { + Map responseBody = new ObjectMapper().readValue( + response.getBody(), + new TypeReference>() { + }); + Boolean error = (Boolean) responseBody.get("error"); + if (error != null && error) { + 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); + + return Map.of("data", total); + } + } catch (JsonProcessingException e) { + e.printStackTrace(); + return Map.of("error", "Internal Server Error: 1"); // Fallback en caso de error + } + }); + + } + /****************** * PRIVATE METHODS ******************/ @@ -236,6 +287,20 @@ public class skApiClient { } } + private Map performWithRetryMap(Supplier> request) { + try { + return request.get(); + } catch (HttpClientErrorException.Unauthorized e) { + // Token expirado, renovar y reintentar + authService.invalidateToken(); + try { + return request.get(); // segundo intento + } catch (HttpClientErrorException ex) { + throw new RuntimeException("La autenticación ha fallado tras renovar el token.", ex); + } + } + } + private static BigDecimal calcularMargen( BigDecimal importe, BigDecimal importeMin, BigDecimal importeMax, BigDecimal margenMax, BigDecimal margenMin) { diff --git a/src/main/java/com/imprimelibros/erp/pdf/PdfRenderer.java b/src/main/java/com/imprimelibros/erp/pdf/PdfRenderer.java index 79c2db5..079bab3 100644 --- a/src/main/java/com/imprimelibros/erp/pdf/PdfRenderer.java +++ b/src/main/java/com/imprimelibros/erp/pdf/PdfRenderer.java @@ -1,8 +1,6 @@ package com.imprimelibros.erp.pdf; import com.openhtmltopdf.outputdevice.helper.BaseRendererBuilder; -import com.openhtmltopdf.pdfboxout.PdfRendererBuilder; -import org.springframework.core.io.Resource; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/imprimelibros/erp/pedido/PedidoService.java b/src/main/java/com/imprimelibros/erp/pedido/PedidoService.java new file mode 100644 index 0000000..5655b27 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/pedido/PedidoService.java @@ -0,0 +1,26 @@ +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; + } +} diff --git a/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoController.java b/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoController.java index f0ed404..73b89a8 100644 --- a/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoController.java +++ b/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoController.java @@ -4,6 +4,7 @@ import org.springframework.stereotype.Controller; import org.springframework.transaction.annotation.Transactional; import org.springframework.ui.Model; +import java.security.Principal; import java.time.Instant; import java.util.HashMap; import java.util.Locale; @@ -621,14 +622,14 @@ public class PresupuestoController { @ResponseBody public DataTablesResponse> datatable( HttpServletRequest request, Authentication auth, Locale locale, - @PathVariable("tipo") String tipo) { + @PathVariable("tipo") String tipo, Principal principal) { DataTablesRequest dt = DataTablesParser.from(request); if ("anonimos".equals(tipo)) { - return dtService.datatablePublicos(dt, locale); + return dtService.datatablePublicos(dt, locale, principal); } else if ("clientes".equals(tipo)) { - return dtService.datatablePrivados(dt, locale); + return dtService.datatablePrivados(dt, locale, principal); } else { throw new IllegalArgumentException("Tipo de datatable no válido"); } diff --git a/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoDatatableService.java b/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoDatatableService.java index 0555968..d81756a 100644 --- a/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoDatatableService.java +++ b/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoDatatableService.java @@ -1,15 +1,18 @@ package com.imprimelibros.erp.presupuesto; import com.imprimelibros.erp.common.Utils; +import com.imprimelibros.erp.configuracion.margenes_presupuestos.MargenPresupuesto; import com.imprimelibros.erp.datatables.*; import com.imprimelibros.erp.presupuesto.dto.Presupuesto; import jakarta.persistence.criteria.Expression; import org.springframework.context.MessageSource; +import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.security.Principal; import java.time.*; import java.time.format.DateTimeFormatter; import java.util.*; @@ -26,18 +29,29 @@ public class PresupuestoDatatableService { } @Transactional(readOnly = true) - public DataTablesResponse> datatablePublicos(DataTablesRequest dt, Locale locale) { - return commonDataTable(dt, locale, "publico", true); + public DataTablesResponse> datatablePublicos(DataTablesRequest dt, Locale locale, + Principal principal) { + return commonDataTable(dt, locale, "publico", true, principal); } @Transactional(readOnly = true) - public DataTablesResponse> datatablePrivados(DataTablesRequest dt, Locale locale) { - return commonDataTable(dt, locale, "privado", false); + public DataTablesResponse> datatablePrivados(DataTablesRequest dt, Locale locale, + Principal principal) { + return commonDataTable(dt, locale, "privado", false, principal); } private DataTablesResponse> commonDataTable(DataTablesRequest dt, Locale locale, String origen, - boolean publico) { - Long count = repo.findAllByOrigen(Presupuesto.Origen.valueOf(origen)).stream().count(); + boolean publico, Principal principal) { + + Specification base = Specification.allOf( + (root, query, cb) -> cb.equal(root.get("origen"), Presupuesto.Origen.valueOf(origen))); + + Boolean isAdmin = Utils.isCurrentUserAdmin(); + if (!isAdmin) { + base = base.and((root, query, cb) -> cb.equal(root.get("user").get("id"), Utils.currentUserId(principal))); + } + + Long count = repo.count(base); List orderable = List.of( "id", "titulo", "user.fullName", "tipoEncuadernacion", "tipoCubierta", "tipoImpresion", @@ -74,6 +88,7 @@ public class PresupuestoDatatableService { .add("updatedAt", p -> formatDate(p.getUpdatedAt(), locale)) .addIf(!publico, "user", p -> p.getUser() != null ? p.getUser().getFullName() : "") .add("actions", this::generarBotones) + .where(base) .toJson(count); } diff --git a/src/main/java/com/imprimelibros/erp/presupuesto/dto/Presupuesto.java b/src/main/java/com/imprimelibros/erp/presupuesto/dto/Presupuesto.java index 65942fc..12db785 100644 --- a/src/main/java/com/imprimelibros/erp/presupuesto/dto/Presupuesto.java +++ b/src/main/java/com/imprimelibros/erp/presupuesto/dto/Presupuesto.java @@ -920,4 +920,28 @@ public class Presupuesto extends AbstractAuditedEntity implements Cloneable { public void setId(Long id){ this.id = id; } + + 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()) { + try { + String json = this.pricingSnapshotJson.trim(); + int pesoIndex = json.indexOf("\"peso\":"); + if (pesoIndex != -1) { + int startIndex = pesoIndex + 7; + int endIndex = json.indexOf(",", startIndex); + if (endIndex == -1) { + endIndex = json.indexOf("}", startIndex); + } + String pesoStr = json.substring(startIndex, endIndex).trim(); + return Double.parseDouble(pesoStr); + } + } catch (Exception e) { + // log error + e.printStackTrace(); + } + } + return null; + } } diff --git a/src/main/java/com/imprimelibros/erp/redsys/RedsysController.java b/src/main/java/com/imprimelibros/erp/redsys/RedsysController.java new file mode 100644 index 0000000..921b224 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/redsys/RedsysController.java @@ -0,0 +1,83 @@ +package com.imprimelibros.erp.redsys; + + +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; + +@Controller +@RequestMapping("/pagos/redsys") +public class RedsysController { + + private final RedsysService service; + + public RedsysController(RedsysService service) { + this.service = service; + } + + @PostMapping("/crear") + public String crearPago(@RequestParam String order, + @RequestParam long amountCents, + Model model) throws Exception { + + var req = new RedsysService.PaymentRequest(order, amountCents, "Compra en ImprimeLibros"); + var form = service.buildRedirectForm(req); + model.addAttribute("action", form.action()); + model.addAttribute("signatureVersion", form.signatureVersion()); + model.addAttribute("merchantParameters", form.merchantParameters()); + model.addAttribute("signature", form.signature()); + return "imprimelibros/payments/redsys-redirect"; + } + + @PostMapping("/notify") + @ResponseBody + public ResponseEntity notifyRedsys( + @RequestParam("Ds_Signature") String dsSignature, + @RequestParam("Ds_MerchantParameters") String dsMerchantParameters) { + + try { + RedsysService.RedsysNotification notif = service.validateAndParseNotification(dsSignature, + dsMerchantParameters); + + // 1) Idempotencia: comprueba si el pedido ya fue procesado + // 2) Valida que importe/moneda/pedido coincidan con lo que esperabas + // 3) Marca como pagado si notif.authorized() == true + + return ResponseEntity.ok("OK"); // Redsys espera "OK" + } catch (SecurityException se) { + // Firma incorrecta: NO procesar + return ResponseEntity.status(400).body("BAD SIGNATURE"); + } catch (Exception e) { + return ResponseEntity.status(500).body("ERROR"); + } + } + + @PostMapping("/ok") + public String okReturn(@RequestParam("Ds_Signature") String dsSignature, + @RequestParam("Ds_MerchantParameters") String dsMerchantParameters, + Model model) { + try { + RedsysService.RedsysNotification notif = service.validateAndParseNotification(dsSignature, dsMerchantParameters); + // Aquí puedes validar importe/pedido/moneda con tu base de datos y marcar como + // pagado + model.addAttribute("authorized", notif.authorized()); + //model.addAttribute("order", notif.order()); + //model.addAttribute("amountCents", notif.amountCents()); + return "imprimelibros/payments/redsys-ok"; + } catch (Exception e) { + model.addAttribute("error", "No se pudo validar la respuesta de Redsys."); + return "imprimelibros/payments/redsys-ko"; + } + } + + @PostMapping("/ko") + public String koReturn(@RequestParam(value = "Ds_Signature", required = false) String dsSignature, + @RequestParam(value = "Ds_MerchantParameters", required = false) String dsMerchantParameters, + Model model) { + // Suele venir cuando el usuario cancela o hay error + model.addAttribute("error", "Operación cancelada o rechazada."); + return "imprimelibros/payments/redsys-ko"; + } + +} diff --git a/src/main/java/com/imprimelibros/erp/redsys/RedsysService.java b/src/main/java/com/imprimelibros/erp/redsys/RedsysService.java new file mode 100644 index 0000000..df8c484 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/redsys/RedsysService.java @@ -0,0 +1,174 @@ +package com.imprimelibros.erp.redsys; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import sis.redsys.api.ApiMacSha256; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.Map; +import java.util.Objects; + +@Service +public class RedsysService { + + // ---------- CONFIG ---------- + @Value("${redsys.merchant-code}") + private String merchantCode; + @Value("${redsys.terminal}") + private String terminal; + @Value("${redsys.currency}") + private String currency; + @Value("${redsys.transaction-type}") + private String txType; + @Value("${redsys.secret-key}") + private String secretKeyBase64; + @Value("${redsys.urls.ok}") + private String urlOk; + @Value("${redsys.urls.ko}") + private String urlKo; + @Value("${redsys.urls.notify}") + private String urlNotify; + @Value("${redsys.environment}") + private String env; + + // ---------- RECORDS ---------- + public record PaymentRequest(String order, long amountCents, String description) { + } + + public record FormPayload(String action, String signatureVersion, String merchantParameters, String signature) { + } + + // ---------- MÉTODO PRINCIPAL ---------- + public FormPayload buildRedirectForm(PaymentRequest req) throws Exception { + ApiMacSha256 api = new ApiMacSha256(); + + api.setParameter("DS_MERCHANT_AMOUNT", String.valueOf(req.amountCents())); + api.setParameter("DS_MERCHANT_ORDER", req.order()); // Usa 12 dígitos con ceros si puedes + api.setParameter("DS_MERCHANT_MERCHANTCODE", merchantCode); + api.setParameter("DS_MERCHANT_CURRENCY", currency); + api.setParameter("DS_MERCHANT_TRANSACTIONTYPE", txType); + api.setParameter("DS_MERCHANT_TERMINAL", terminal); + api.setParameter("DS_MERCHANT_MERCHANTURL", urlNotify); + api.setParameter("DS_MERCHANT_URLOK", urlOk); + api.setParameter("DS_MERCHANT_URLKO", urlKo); + + String merchantParameters = api.createMerchantParameters(); + String signature = api.createMerchantSignature(secretKeyBase64); + + String action = "test".equalsIgnoreCase(env) + ? "https://sis-t.redsys.es:25443/sis/realizarPago" + : "https://sis.redsys.es/sis/realizarPago"; + + return new FormPayload(action, "HMAC_SHA256_V1", merchantParameters, signature); + } + + // ---------- STEP 3: Decodificar Ds_MerchantParameters ---------- + private static final ObjectMapper MAPPER = new ObjectMapper(); + + public Map decodeMerchantParametersToMap(String dsMerchantParametersB64) throws Exception { + try { + byte[] decoded = Base64.getDecoder().decode(dsMerchantParametersB64); + String json = new String(decoded, StandardCharsets.UTF_8); + return MAPPER.readValue(json, new TypeReference<>() { + }); + } catch (Exception e) { + throw new IllegalArgumentException("No se pudo decodificar Ds_MerchantParameters", e); + } + } + + // ---------- STEP 4: Validar notificación ---------- + public RedsysNotification validateAndParseNotification(String dsSignature, String dsMerchantParametersB64) + throws Exception { + Map mp = decodeMerchantParametersToMap(dsMerchantParametersB64); + RedsysNotification notif = new RedsysNotification(mp); + + if (notif.order == null || notif.order.isBlank()) { + throw new IllegalArgumentException("Falta Ds_Order en Ds_MerchantParameters"); + } + + ApiMacSha256 api = new ApiMacSha256(); + api.setParameter("Ds_MerchantParameters", dsMerchantParametersB64); + + String expected = api.createMerchantSignatureNotif(secretKeyBase64, api.decodeMerchantParameters(dsMerchantParametersB64)); // ✅ SOLO UN PARÁMETRO + + if (!safeEqualsB64(dsSignature, expected)) { + throw new SecurityException("Firma Redsys no válida"); + } + + return notif; +} + + + // ---------- HELPERS ---------- + private static boolean safeEqualsB64(String a, String b) { + if (Objects.equals(a, b)) + return true; + try { + String na = normalizeB64(a); + String nb = normalizeB64(b); + byte[] da = Base64.getDecoder().decode(na); + byte[] db = Base64.getDecoder().decode(nb); + return MessageDigest.isEqual(da, db); + } catch (Exception e) { + return false; + } + } + + private static String normalizeB64(String s) { + if (s == null) + return ""; + String n = s.replace('-', '+').replace('_', '/'); + int mod = n.length() % 4; + if (mod == 2) + n += "=="; + else if (mod == 3) + n += "="; + else if (mod == 1) + n += "==="; + return n; + } + + // ---------- MODELO DE NOTIFICACIÓN ---------- + public static final class RedsysNotification { + public final Map raw; + public final String order; + public final String response; + public final long amountCents; + public final String currency; + + public RedsysNotification(Map raw) { + this.raw = raw; + this.order = str(raw.get("Ds_Order")); + this.response = str(raw.get("Ds_Response")); + this.currency = str(raw.get("Ds_Currency")); + this.amountCents = parseLongSafe(raw.get("Ds_Amount")); + } + + public boolean authorized() { + try { + int r = Integer.parseInt(response); + return r >= 0 && r <= 99; + } catch (Exception e) { + return false; + } + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } + + private static long parseLongSafe(Object o) { + try { + return Long.parseLong(String.valueOf(o)); + } catch (Exception e) { + return 0L; + } + } + } +} diff --git a/src/main/java/com/imprimelibros/erp/users/UserService.java b/src/main/java/com/imprimelibros/erp/users/UserService.java index 6856390..228e2cd 100644 --- a/src/main/java/com/imprimelibros/erp/users/UserService.java +++ b/src/main/java/com/imprimelibros/erp/users/UserService.java @@ -1,6 +1,9 @@ package com.imprimelibros.erp.users; import org.springframework.security.core.userdetails.UserDetailsService; + +import java.util.Map; + import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; diff --git a/src/main/java/com/imprimelibros/erp/users/UserServiceImpl.java b/src/main/java/com/imprimelibros/erp/users/UserServiceImpl.java index 107c88b..76b6843 100644 --- a/src/main/java/com/imprimelibros/erp/users/UserServiceImpl.java +++ b/src/main/java/com/imprimelibros/erp/users/UserServiceImpl.java @@ -2,10 +2,21 @@ package com.imprimelibros.erp.users; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UsernameNotFoundException; + +import java.text.Collator; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import com.imprimelibros.erp.direcciones.Direccion; + @Service public class UserServiceImpl implements UserService { @@ -29,5 +40,4 @@ public class UserServiceImpl implements UserService { if (query == null || query.isBlank()) query = null; return userDao.searchUsers(role, query, pageable); } - } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index d56bf95..d280e5d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,5 +1,9 @@ spring.application.name=erp +server.forward-headers-strategy=framework +server.servlet.session.cookie.secure=true + + # # Logging # @@ -11,8 +15,8 @@ logging.level.org.springframework=ERROR # # Database Configuration # -spring.datasource.url=jdbc:mysql://localhost:3309/imprimelibros -#spring.datasource.url=jdbc:mysql://127.0.0.1:3309/imprimelibros?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Europe/Madrid&characterEncoding=utf8 +#spring.datasource.url=jdbc:mysql://localhost:3309/imprimelibros +spring.datasource.url=jdbc:mysql://127.0.0.1:3309/imprimelibros?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Europe/Madrid&characterEncoding=utf8 spring.datasource.username=imprimelibros_user spring.datasource.password=om91irrDctd spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver @@ -101,3 +105,22 @@ spring.liquibase.change-log=classpath:db/changelog/master.yml # spring.liquibase.url=jdbc:mysql://localhost:3306/imprimelibros # spring.liquibase.user=tu_user # spring.liquibase.password=tu_pass + + +# Redsys +redsys.environment=test +redsys.merchant-code=124760810 +redsys.terminal=1 +redsys.currency=978 +redsys.transaction-type=0 +redsys.secret-key=sq7HjrUOBfKmC576ILgskD5srU870gJ7 +redsys.urls.ok=http://localhost:8080/pagos/redsys/ok +redsys.urls.ko=http://localhost:8080/pagos/redsys/ko +redsys.urls.notify=http://localhost:8080/pagos/redsys/notify + + +# Mensajes de error mas cortos +# Oculta el stack trace en los errores del servidor +server.error.include-stacktrace=never +# No mostrar el mensaje completo de excepción en la respuesta +server.error.include-message=always \ No newline at end of file diff --git a/src/main/resources/db/changelog/changesets/0005-add-carts-onlyoneshipment.yml b/src/main/resources/db/changelog/changesets/0005-add-carts-onlyoneshipment.yml new file mode 100644 index 0000000..4dae461 --- /dev/null +++ b/src/main/resources/db/changelog/changesets/0005-add-carts-onlyoneshipment.yml @@ -0,0 +1,37 @@ +databaseChangeLog: + - changeSet: + id: 0005-add-carts-onlyoneshipment + author: jjo + preConditions: + onFail: MARK_RAN + not: + columnExists: + tableName: carts + columnName: only_one_shipment + + changes: + - addColumn: + tableName: carts + columns: + - column: + name: only_one_shipment + type: TINYINT(1) + defaultValueNumeric: 1 + remarks: "Si 1, el carrito tiene la misma direccion para todos los items" + afterColumn: currency + constraints: + nullable: false + + - column: + name: total + type: DECIMAL(19,6) + defaultValueNumeric: 0 + remarks: "Total del carrito" + afterColumn: only_one_shipment + constraints: + nullable: false + + rollback: + - dropColumn: + tableName: carts + columnName: only_one_shipment diff --git a/src/main/resources/db/changelog/changesets/0006-add-cart-direcciones.yml b/src/main/resources/db/changelog/changesets/0006-add-cart-direcciones.yml new file mode 100644 index 0000000..41a1177 --- /dev/null +++ b/src/main/resources/db/changelog/changesets/0006-add-cart-direcciones.yml @@ -0,0 +1,94 @@ +databaseChangeLog: + - changeSet: + id: 0006-add-cart-direcciones + author: jjo + preConditions: + onFail: MARK_RAN + not: + tableExists: + tableName: cart_direcciones + + changes: + - createTable: + tableName: cart_direcciones + remarks: "Relación de direcciones y unidades por carrito/direcciones_envio" + columns: + - column: + name: id + type: BIGINT UNSIGNED AUTO_INCREMENT + constraints: + primaryKey: true + primaryKeyName: pk_cart_direcciones + + - column: + name: cart_id + type: BIGINT + constraints: + nullable: false + + - column: + name: direccion_id + type: BIGINT + constraints: + nullable: false + + - column: + name: presupuesto_id + type: BIGINT + constraints: + nullable: true + + - column: + name: unidades + type: INT + constraints: + nullable: true + + - column: + name: is_palets + type: TINYINT(1) + constraints: + nullable: false + defaultValue: false + + - createIndex: + indexName: idx_cart_dir_direccion_id + tableName: cart_direcciones + columns: + - column: + name: direccion_id + + - createIndex: + indexName: idx_cart_dir_presupuesto_id + tableName: cart_direcciones + columns: + - column: + name: presupuesto_id + + - addForeignKeyConstraint: + baseTableName: cart_direcciones + baseColumnNames: direccion_id + constraintName: fk_cart_dir_direccion + referencedTableName: direcciones + referencedColumnNames: id + onDelete: CASCADE + onUpdate: CASCADE + + - addForeignKeyConstraint: + baseTableName: cart_direcciones + baseColumnNames: presupuesto_id + constraintName: fk_cart_dir_presupuesto + referencedTableName: presupuesto + referencedColumnNames: id + onDelete: SET NULL + onUpdate: CASCADE + + rollback: + - dropForeignKeyConstraint: + baseTableName: cart_direcciones + constraintName: fk_cart_dir_direccion + - dropForeignKeyConstraint: + baseTableName: cart_direcciones + constraintName: fk_cart_dir_presupuesto + - dropTable: + tableName: cart_direcciones diff --git a/src/main/resources/db/changelog/master.yml b/src/main/resources/db/changelog/master.yml index 90f38f5..95eb18c 100644 --- a/src/main/resources/db/changelog/master.yml +++ b/src/main/resources/db/changelog/master.yml @@ -6,4 +6,8 @@ databaseChangeLog: - include: file: db/changelog/changesets/0003-create-paises.yml - include: - file: db/changelog/changesets/0004-create-direcciones.yml \ No newline at end of file + file: db/changelog/changesets/0004-create-direcciones.yml + - include: + file: db/changelog/changesets/0005-add-carts-onlyoneshipment.yml + - include: + file: db/changelog/changesets/0006-add-cart-direcciones.yml \ No newline at end of file diff --git a/src/main/resources/i18n/app_es.properties b/src/main/resources/i18n/app_es.properties index 8e2de87..4ce7940 100644 --- a/src/main/resources/i18n/app_es.properties +++ b/src/main/resources/i18n/app_es.properties @@ -3,6 +3,7 @@ app.yes=Sí app.no=No app.aceptar=Aceptar app.cancelar=Cancelar +app.seleccionar=Seleccionar app.guardar=Guardar app.editar=Editar app.add=Añadir diff --git a/src/main/resources/i18n/cart_es.properties b/src/main/resources/i18n/cart_es.properties index 4fff994..f6e9f46 100644 --- a/src/main/resources/i18n/cart_es.properties +++ b/src/main/resources/i18n/cart_es.properties @@ -4,11 +4,47 @@ cart.empty=Tu cesta de la compra está vacía. cart.item.presupuesto-numero=Presupuesto # cart.precio=Precio +cart.tabs.details=Detalles +cart.tabs.envio=Envío + +cart.shipping.add=Añadir dirección +cart.shipping.add.title=Seleccione una dirección +cart.shipping.select-placeholder=Buscar en direcciones... +cart.shipping.new-address=Nueva dirección +cart.shipping.info=Todos los pedidos incluyen un envío gratuito a la Península y Baleares por línea de pedido. +cart.shipping.order=Envío del pedido +cart.shipping.samples=Envío de prueba +cart.shipping.onlyOneShipment=Todo el pedido se envía a una única dirección. +cart.shipping.tirada=Tirada: +cart.shipping.unidades=unidades +cart.shipping.ud=ud. +cart.shipping.uds=uds. +cart.shipping.enter-units=Introduzca el número de unidades para esta dirección: +cart.shipping.units-label=Número de unidades (máximo {max}) +cart.shipping.send-in-palets=Enviar en palets +cart.shipping.send-in-palets.info=Marque esta opción si desea que el envío se realice en palets (sólo para tiradas grandes). La entrega se realizará a pie de calle. +cart.shipping.tipo-envio=Tipo de envío: + +cart.shipping.errors.units-error=Por favor, introduzca un número válido entre 1 y {max}. +cart.shipping.errors.noAddressSelected=Debe seleccionar una dirección de envío para el pedido. +cart.shipping.errors.fillAddressesItems=Debe seleccionar una dirección de envío para cada artículo de la cesta. + cart.resumen.title=Resumen de la cesta -cart.resumen.base=Base imponible: -cart.resumen.iva-4=IVA 4%: -cart.resumen.iva-21=IVA 21%: -cart.resumen.total=Total cesta: +cart.resumen.base=Base imponible +cart.resumen.envio=Coste de envío +cart.resumen.iva-4=IVA 4% +cart.resumen.iva-21=IVA 21% +cart.resumen.descuento=Descuento fidelización +cart.resumen.total=Total cesta cart.resumen.tramitar=Tramitar pedido -cart.resumen.fidelizacion=Si tiene descuento por fidelización, se aplicará al tramitar el pedido. \ No newline at end of file +cart.pass-to.customer=Mover cesta a cliente +cart.pass-to.customer.info=Puede mover la cesta actual al cliente seleccionado. Esto eliminará la cesta del usuario actual y la asociará al cliente seleccionado. +cart.pass-to.customer.warning=Advertencia: Esta acción no se puede deshacer y sobrescribirá la cesta del cliente seleccionado. Asegúrese de que el cliente seleccionado es correcto. +cart.pass-to.select-customer=Seleccione un cliente +cart.pass-to.button=Mover cesta +cart.pass-to.success=Cesta movida correctamente al cliente {0}. +cart.pass-to.customer.error=Debe seleccionar un cliente para mover la cesta. +cart.pass-to.customer.error-move=Error al mover la cesta de la compra +cart.errors.update-cart=Error al actualizar la cesta de la compra: {0} +cart.errors.shipping=No se puede calcular el coste del envío para alguna de las direcciones seleccionadas. Por favor, póngase en contacto con el servicio de atención al cliente. \ No newline at end of file diff --git a/src/main/resources/i18n/direcciones_es.properties b/src/main/resources/i18n/direcciones_es.properties index bc30637..6e3ee47 100644 --- a/src/main/resources/i18n/direcciones_es.properties +++ b/src/main/resources/i18n/direcciones_es.properties @@ -53,5 +53,7 @@ direcciones.error.delete-internal-error=Error interno al eliminar la dirección. direcciones.error.noEncontrado=Dirección no encontrada. direcciones.error.sinPermiso=No tiene permiso para realizar esta acción. +direcciones.error.noShippingCost=No se pudo calcular el coste de envío para la dirección proporcionada. + direcciones.form.error.required=Campo obligatorio. diff --git a/src/main/resources/i18n/pedidos_en.properties b/src/main/resources/i18n/pedidos_en.properties new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/i18n/pedidos_es.properties b/src/main/resources/i18n/pedidos_es.properties new file mode 100644 index 0000000..a56dd01 --- /dev/null +++ b/src/main/resources/i18n/pedidos_es.properties @@ -0,0 +1,17 @@ +checkout.title=Finalizar compra +checkout.summay=Resumen de la compra +checkout.shipping=Envío +checkout.payment=Método de pago + +checkout.shipping.info=Todos los pedidos incluyen un envío gratuito a la Península y Baleares por línea de pedido. +checkout.shipping.order=Envío del pedido +checkout.shipping.samples=Envío de pruebas +checkout.shipping.onlyOneShipment=Todo el pedido se envía a una única dirección. + + +checkout.summary.presupuesto=#Presupuesto +checkout.summary.titulo=Título +checkout.summary.base=Base +checkout.summary.iva-4=IVA 4% +checkout.summary.iva-21=IVA 21% +checkout.summary.envio=Envío \ No newline at end of file diff --git a/src/main/resources/lib/apiSha256.jar b/src/main/resources/lib/apiSha256.jar new file mode 100644 index 0000000..83fbf6c Binary files /dev/null and b/src/main/resources/lib/apiSha256.jar differ diff --git a/src/main/resources/lib/bcprov-jdk15on-1.4.7.jar b/src/main/resources/lib/bcprov-jdk15on-1.4.7.jar new file mode 100644 index 0000000..0b80922 Binary files /dev/null and b/src/main/resources/lib/bcprov-jdk15on-1.4.7.jar differ diff --git a/src/main/resources/lib/commons-codec-1.3.jar b/src/main/resources/lib/commons-codec-1.3.jar new file mode 100644 index 0000000..957b675 Binary files /dev/null and b/src/main/resources/lib/commons-codec-1.3.jar differ diff --git a/src/main/resources/lib/org.json.jar b/src/main/resources/lib/org.json.jar new file mode 100644 index 0000000..5372cb0 Binary files /dev/null and b/src/main/resources/lib/org.json.jar differ diff --git a/src/main/resources/static/assets/css/cart.css b/src/main/resources/static/assets/css/cart.css new file mode 100644 index 0000000..f927b98 --- /dev/null +++ b/src/main/resources/static/assets/css/cart.css @@ -0,0 +1,38 @@ +.nav-pills .nav-link.active, .nav-pills .show > .nav-link { + background-color: #4c5c6366 !important; +} + +.step-arrow-nav .nav { + background-color: #4c5c6333 !important; +} + +.nav-link.active .bg-soft-primary { + background-color: #ffffff33 !important; + /* #4c5c63 al 20% */ +} + +.nav-link.active .text-primary { + color: #ffffff !important; + /* #4c5c63 al 20% */ +} + +.nav-link:not(.active) .bg-soft-primary { + background-color: #4c5c6366 !important; + /* #4c5c63 al 20% */ +} + +.nav-link:not(.active) .text-primary { + color: #000000 !important; + /* #4c5c63 al 20% */ +} + +.direccion-card { + flex: 1 1 350px; /* ancho mínimo 350px, crece si hay espacio */ + max-width: 350px; /* opcional, para que no se estiren demasiado */ + min-width: 340px; /* protege el ancho mínimo */ +} + +.shipping-addresses-item { align-items: stretch; justify-content: center;} +.shipping-addresses-sample { align-items: stretch; justify-content: center;} +.direccion-card { display: flex; flex-direction: column; } +.direccion-card .card-body { display: flex; flex-direction: column; } \ No newline at end of file diff --git a/src/main/resources/static/assets/css/imprimelibros.css b/src/main/resources/static/assets/css/imprimelibros.css index aafd10a..b426ded 100644 --- a/src/main/resources/static/assets/css/imprimelibros.css +++ b/src/main/resources/static/assets/css/imprimelibros.css @@ -29,3 +29,13 @@ body { color: #fff; border-color: #92b2a7; } + +/* Solo dentro del modal */ +.swal2-popup .form-switch-custom { + font-size: 1rem; /* clave: fija el tamaño base del switch */ + line-height: 1.5; +} +.swal2-popup .form-switch-custom .form-check-input { + float: none; /* por si acaso */ + margin: 0; +} diff --git a/src/main/resources/static/assets/js/pages/imprimelibros/cart/cart.js b/src/main/resources/static/assets/js/pages/imprimelibros/cart/cart.js index 707a93a..aae1d3a 100644 --- a/src/main/resources/static/assets/js/pages/imprimelibros/cart/cart.js +++ b/src/main/resources/static/assets/js/pages/imprimelibros/cart/cart.js @@ -1,44 +1,111 @@ import { formateaMoneda } from '../../imprimelibros/utils.js'; +import { showLoader, hideLoader } from '../loader.js'; $(() => { - updateTotal(); + $(document).ajaxStart(showLoader).ajaxStop(hideLoader); - function updateTotal() { - const items = $(".product"); - let iva4 = 0; - let iva21 = 0; - let base = 0; - for (let i = 0; i < items.length; i++) { - const item = $(items[i]); - const b = item.data("base"); - const i4 = item.data("iva-4"); - const i21 = item.data("iva-21"); - base += parseFloat(b) || 0; - iva4 += parseFloat(i4) || 0; - iva21 += parseFloat(i21) || 0; + $(document).on('updateCart', () => { + // get form and submit + const form = $('#cartForm'); + const container = $("#onlyOneShipment").is(':checked') ? $('#shippingAddressesContainer') : $('.product'); + // remove name from container . direccion-card + container.find('.direccion-card input[type="hidden"]').removeAttr('name'); + + 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'); + $(this).find('.direccion-pais-code3').attr('name', 'direcciones[' + i + '].paisCode3'); + if ($(this).find('.presupuesto-id').length > 0 && $(this).find('.presupuesto-id').val() !== null + && $(this).find('.presupuesto-id').val() !== "") + $(this).find('.presupuesto-id').attr('name', 'direcciones[' + i + '].presupuestoId'); + if ($(this).find('.item-tirada').length > 0 && $(this).find('.item-tirada').val() !== null + && $(this).find('.item-tirada').val() !== "") + $(this).find('.item-tirada').attr('name', 'direcciones[' + i + '].unidades'); + }); + $.post(form.attr('action'), form.serialize(), (response) => { + // if success and received html, replace container summary + if (response) { + $('.cart-summary-container').replaceWith(response); + } + }).always(() => { + hideLoader(); + }); + + checkAddressesForItems(); + }); + + checkAddressesForItems(); + + function checkAddressesForItems() { + if ($('.product').length === 0) { + $("#alert-empty").removeClass("d-none"); + $('.cart-content').addClass('d-none'); + return; } - $("#base-cesta").text(formateaMoneda(base)); - if (iva4 > 0) { - $("#iva-4-cesta").text(formateaMoneda(iva4)); - $("#tr-iva-4").show(); - } else { - $("#tr-iva-4").hide(); + else { + $('.cart-content').removeClass('d-none'); + $("#alert-empty").addClass("d-none"); + // check if select2 is initialized + if ($('#select-customer').length && !$('#select-customer').hasClass('select2-hidden-accessible')) { + initMoveCartToCustomer(); + } } - if (iva21 > 0) { - $("#iva-21-cesta").text(formateaMoneda(iva21)); - $("#tr-iva-21").show(); - } else { - $("#tr-iva-21").hide(); + if ($('#onlyOneShipment').is(':checked')) { + if ($("#shippingAddressesContainer .direccion-card").length === 0) { + $(".alert-shipment").removeClass("d-none"); + $('#btn-checkout').prop('disabled', true); + return; + } + $(".alert-shipment").addClass("d-none"); + $('#btn-checkout').prop('disabled', false); + } + else { + const items = $(".product"); + let errorFound = false; + for (let i = 0; i < items.length; i++) { + let errorFoundItem = false; + const item = $(items[i]); + const tirada = parseInt(item.find(".item-tirada").val()) || 0; + const direcciones = item.find(".direccion-card"); + let totalUnidades = 0; + direcciones.each(function () { + const unidades = parseInt($(this).find(".item-tirada").val()) || 0; + totalUnidades += unidades; + }); + if (totalUnidades < tirada) { + errorFoundItem = true; + } + + if (item.find(".shipping-addresses-sample")) { + const container = item.find(".shipping-addresses-sample"); + if (container.find('.direccion-card').toArray().length === 0) { + errorFoundItem = true; + } + } + if (errorFoundItem) { + errorFound = true; + item.find(".alert-icon-shipment").removeClass("d-none"); + } + else { + item.find(".alert-icon-shipment").addClass("d-none"); + } + } + if (errorFound) { + $(".alert-shipment").removeClass("d-none"); + $('#btn-checkout').prop('disabled', true); + } + else { + $(".alert-shipment").addClass("d-none"); + $('#btn-checkout').prop('disabled', false); + } } - const total = base + iva4 + iva21; - $("#total-cesta").text(formateaMoneda(total)); } $(document).on("click", ".delete-item", async function (event) { event.preventDefault(); - const cartItemId = $(this).data("cart-item-id"); + const presupuestoId = $(this).data("cart-item-id"); const card = $(this).closest('.card.product'); // CSRF (Spring Security) @@ -46,7 +113,7 @@ $(() => { const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.content || 'X-CSRF-TOKEN'; try { - const res = await fetch(`/cart/delete/item/${cartItemId}`, { + const res = await fetch(`/cart/delete/item/${presupuestoId}`, { method: 'DELETE', headers: { [csrfHeader]: csrfToken } }); @@ -55,13 +122,81 @@ $(() => { console.error('Error al eliminar. Status:', res.status); return; } - else{ + else { card?.remove(); - updateTotal(); + $(document).trigger('updateCart'); } } catch (err) { console.error('Error en la solicitud:', err); } }); + + function initMoveCartToCustomer() { + if ($('#select-customer').length) { + + $('#moveCart').on('click', async function (e) { + e.preventDefault(); + const customerId = $('#select-customer').val(); + if (!customerId) { + // set text and show alert + $('#alert-select-customer').text(window.languageBundle['cart.pass-to.customer.error'] || 'Debe seleccionar un cliente para mover la cesta.'); + $('#alert-select-customer').removeClass('d-none').hide().fadeIn(); + setTimeout(() => { + $('#alert-select-customer').fadeOut(function () { + $(this).addClass('d-none'); + }); + }, 5000); + return; + } + + // CSRF (Spring Security) + const csrfToken = document.querySelector('meta[name="_csrf"]')?.content || ''; + const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.content || 'X-CSRF-TOKEN'; + + try { + const res = await fetch(`/cart/pass-to-customer/${customerId}`, { + method: 'POST', + headers: { [csrfHeader]: csrfToken } + }); + + if (!res.ok) { + $('#alert-select-customer').text(window.languageBundle['cart.pass-to.customer.move.error'] || 'Error al mover la cesta de la compra'); + $('#alert-select-customer').removeClass('d-none').hide().fadeIn(); + setTimeout(() => { + $('#alert-select-customer').fadeOut(function () { + $(this).addClass('d-none'); + }); + }, 5000); + return; + } + else { + window.location.href = '/cart'; + } + + } catch (err) { + console.error('Error en la solicitud:', err); + $('#alert-select-customer').text(window.languageBundle['cart.errors.move-cart'] || 'Error al mover la cesta de la compra'); + $('#alert-select-customer').removeClass('d-none').hide().fadeIn(); + setTimeout(() => { + $('#alert-select-customer').fadeOut(function () { + $(this).addClass('d-none'); + }); + }, 5000); + } + }); + + $('#select-customer').select2({ + width: '100%', + ajax: { + url: 'users/api/get-users', + dataType: 'json', + delay: 250, + }, + allowClear: true + }); + } + } + initMoveCartToCustomer(); + }); \ No newline at end of file diff --git a/src/main/resources/static/assets/js/pages/imprimelibros/cart/shipping-cart.js b/src/main/resources/static/assets/js/pages/imprimelibros/cart/shipping-cart.js new file mode 100644 index 0000000..ab7edfe --- /dev/null +++ b/src/main/resources/static/assets/js/pages/imprimelibros/cart/shipping-cart.js @@ -0,0 +1,443 @@ +import { showLoader, hideLoader } from '../loader.js'; + +$(() => { + + // Si usas jQuery AJAX: + $(document).ajaxStart(showLoader).ajaxStop(hideLoader); + + $("#onlyOneShipment").on('change', function () { + if ($(this).is(':checked')) { + $('.nav-product').addClass('d-none'); + document.querySelectorAll('.card.product').forEach(card => { + const detailsBtn = card.querySelector('.nav-link[id^="pills-details-"][id$="-tab"]'); + if (detailsBtn) new bootstrap.Tab(detailsBtn).show(); + }); + $('#shippingAddressesContainer').empty().removeClass('d-none'); + $('.shipping-addresses-item').toArray().forEach(element => { + $(element).empty().addClass('d-none'); + }); + $('#addOrderAddress').removeClass('d-none'); + } else { + $('.nav-product').removeClass('d-none'); + $('#shippingAddressesContainer').empty().addClass('d-none'); + $('.shipping-addresses-item').toArray().forEach(element => { + $(element).empty().removeClass('d-none'); + }); + $('#addOrderAddress').addClass('d-none'); + } + $(document).trigger('updateCart'); + }); + + $(document).on('click', '.btn-delete-direccion', function (e) { + e.preventDefault(); + const $card = $(this).closest('.direccion-card'); + const $div = $card.parent(); + $card.remove(); + if ($div.hasClass('shipping-order-address')) { + $('#addOrderAddress').removeClass('d-none'); + } + else { + $div.trigger('direcciones:actualizadas'); + } + $(document).trigger('updateCart'); + }); + + //btn-edit-direccion + $(document).on('click', '.btn-edit-direccion', function (e) { + e.preventDefault(); + const $card = $(this).closest('.direccion-card'); + const container = $(this).closest('.product').find('.shipping-addresses-item'); + const tirada = $(this).closest('.product').find('.item-tirada').val(); + const totalTirada = container.find('.item-tirada').toArray().reduce((acc, el) => acc + parseInt($(el).val() || 0), 0); + const remainingTirada = parseInt(tirada) - parseInt(totalTirada) + parseInt($card.find('.item-tirada').val() || 0); + const data = getUnitsFromUser(remainingTirada); + data.then(data => { + if (data.unidades) { + $card.find('.item-tirada').val(data.unidades); + $card.find('#units-text').each(function () { + if (data.unidades == 1) { + $(this).text(`${data.unidades} ${window.languageBundle['cart.shipping.ud'] || 'unidad'}`); + } else { + $(this).text(`${data.unidades} ${window.languageBundle['cart.shipping.uds'] || 'unidades'}`); + } + }); + container.trigger('direcciones:actualizadas'); + $(document).trigger('updateCart'); + $card.find('.is-palets').val(data.isPalets ? 'true' : 'false'); + $card.find('.icon-shipment').each(function () { + if (data.isPalets) { + $(this).removeClass('la-box').addClass('la-pallet'); + } else { + $(this).removeClass('la-pallet').addClass('la-box'); + } + }); + } + }); + + }); + + const language = document.documentElement.lang || 'es-ES'; + const modalEl = document.getElementById('direccionFormModal'); + const modal = bootstrap.Modal.getOrCreateInstance(modalEl); + + $(document).on("change", ".direccionFacturacion", function () { + const isChecked = $(this).is(':checked'); + if (isChecked) { + $('.direccionFacturacionItems').removeClass('d-none'); + } else { + $('.direccionFacturacionItems').addClass('d-none'); + $('#razonSocial').val(''); + $('#tipoIdentificacionFiscal').val('DNI'); + $('#identificacionFiscal').val(''); + } + }); + + $('#addOrderAddress').on('click', async () => { + if ($('#onlyOneShipment').is(':checked')) { + if (await seleccionarDireccionEnvio()) { + $('#addOrderAddress').addClass('d-none'); + } + } + }); + + $(document).on('click', '.btn-add-shipping', function () { + const presupuestoId = $(this).closest('.product').find('.item-presupuesto-id').val(); + const container = $(this).closest('.product').find('.shipping-addresses-item'); + const tirada = $(this).closest('.product').find('.item-tirada').val(); + const totalTirada = container.find('.item-tirada').toArray().reduce((acc, el) => acc + parseInt($(el).val() || 0), 0); + const remainingTirada = parseInt(tirada) - parseInt(totalTirada); + seleccionarDireccionEnvio(presupuestoId, remainingTirada, container); + }); + + $(document).on('click', '.btn-add-shipping-sample', function () { + const presupuestoId = $(this).closest('.product').find('.item-presupuesto-id').val(); + const container = $(this).closest('.product').find('.shipping-addresses-sample'); + seleccionarDireccionEnvio(presupuestoId, null, container); + }); + + + + async function seleccionarDireccionEnvio(presupuestoId = null, tirada = null, container = null) { + + const { value: direccionId, isDenied } = await Swal.fire({ + title: window.languageBundle['cart.shipping.add.title'] || 'Seleccione una dirección', + html: ` + + `, + showCancelButton: true, + showDenyButton: true, + buttonsStyling: false, + confirmButtonText: window.languageBundle['app.seleccionar'] || 'Seleccionar', + cancelButtonText: window.languageBundle['app.cancelar'] || 'Cancelar', + denyButtonText: window.languageBundle['cart.shipping.new-address'] || 'Nueva dirección', + customClass: { + confirmButton: 'btn btn-secondary me-2', + cancelButton: 'btn btn-light', + denyButton: 'btn btn-secondary me-2' + }, + focusConfirm: false, + + // Inicializa cuando el DOM del modal ya existe + didOpen: () => { + const $select = $('#direccionSelect'); + $select.empty(); // limpia placeholder estático + + $select.select2({ + width: '100%', + dropdownParent: $('.swal2-container'), + ajax: { + url: '/direcciones/select2', + dataType: 'json', + delay: 250, + data: params => ({ q: params.term || '' }), + processResults: (data) => { + const items = Array.isArray(data) ? data : (data.results || []); + return { + results: items.map(item => ({ + id: item.id, + text: item.text, // ← Select2 necesita 'id' y 'text' + alias: item.alias || 'Sin alias', + att: item.att || '', + direccion: item.direccion || '', + cp: item.cp || '', + ciudad: item.ciudad || '', + html: ` +
+ ${item.alias || 'Sin alias'}
+ ${item.att ? `${item.att}
` : ''} + ${item.direccion || ''}${item.cp ? ', ' + item.cp : ''}${item.ciudad ? ', ' + item.ciudad : ''} +
+ ` + })), + pagination: { more: false } // opcional, evita que espere más páginas + }; + } + + }, + placeholder: window.languageBundle['cart.shipping.select-placeholder'] || 'Buscar en direcciones...', + language: language, + + templateResult: data => { + if (data.loading) return data.text; + return $(data.html || data.text); + }, + // Selección más compacta (solo alias + ciudad) + templateSelection: data => { + if (!data.id) return data.text; + const alias = data.alias || data.text; + const ciudad = data.ciudad ? ` — ${data.ciudad}` : ''; + return $(`${alias}${ciudad}`); + }, + escapeMarkup: m => m + }); + }, + + preConfirm: () => { + const $select = $('#direccionSelect'); + const val = $select.val(); + if (!val) { + Swal.showValidationMessage( + window.languageBundle['cart.shipping.errors.noAddressSelected'] || 'Por favor, seleccione una dirección.' + ); + return false; + } + return val; + }, + + didClose: () => { + // Limpieza: destruir select2 para evitar fugas + const $select = $('#direccionSelect'); + if ($select.data('select2')) { + $select.select2('destroy'); + } + } + }); + + if (isDenied) { + $.get('/direcciones/direction-form', function (html) { + $('#direccionFormModalBody').html(html); + const title = $('#direccionFormModalBody #direccionForm').data('add'); + $('#direccionFormModal .modal-title').text(title); + modal.removeClass('d-none'); + }); + } + + let unidades = null; + let isPalets = 0; + if (tirada !== null && tirada >= 1 && direccionId) { + + const data = await getUnitsFromUser(tirada); + if (data && data.unidades) { + unidades = parseInt(data.unidades); + isPalets = data.isPalets ? 1 : 0; + } else { + // Si el usuario cancela, salir de la función + return false; + } + } + else if (presupuestoId == null && direccionId) { // caso para todas los envios a la misma direccion + const isPaletsValue = await getTipoEnvio(); + if (isPaletsValue !== null) { + isPalets = isPaletsValue ? 1 : 0; + } else { + // Si el usuario cancela, salir de la función + return false; + } + } + if (direccionId) { + // Obtén el objeto completo seleccionado + showLoader(); + let uri = `/cart/get-address/${direccionId}?isPalets=${isPalets}`; + if (presupuestoId !== null) { + uri += `&presupuestoId=${presupuestoId}`; + if (tirada !== null) { + uri += `&unidades=${unidades}`; + } + } + const response = await fetch(uri); + if (response.ok) { + const html = await response.text(); + if (presupuestoId !== null) { + container.append(html).trigger('direcciones:actualizadas'); + } + else { + $('#shippingAddressesContainer').append(html); + } + $(document).trigger('updateCart'); + return true; + } + hideLoader(); + return false; + } + hideLoader(); + return false; + } + + async function getUnitsFromUser(tirada) { + const { value: formValues } = await Swal.fire({ + title: window.languageBundle['cart.shipping.enter-units'] || 'Introduzca el número de unidades para esta dirección', + html: ` +
+ + +
+
+ + +
+ + ${window.languageBundle['cart.shipping.send-in-palets.info'] || 'En palets la entrega se realizará a pie de calle.'} + + `, + focusConfirm: false, + showCancelButton: true, + buttonsStyling: false, + customClass: { + confirmButton: 'btn btn-secondary me-2', + cancelButton: 'btn btn-light', + }, + confirmButtonText: window.languageBundle['app.aceptar'] || 'Aceptar', + cancelButtonText: window.languageBundle['app.cancelar'] || 'Cancelar', + preConfirm: () => { + const unidades = parseInt(document.getElementById('swal-input-unidades').value, 10); + const isPalets = document.getElementById('swal-input-palets').checked; + + if (!unidades || isNaN(unidades) || unidades < 1 || unidades > tirada) { + Swal.showValidationMessage( + window.languageBundle['cart.shipping.errors.units-error']?.replace('{max}', tirada) + || `Por favor, introduzca un número válido entre 1 y ${tirada}.` + ); + return false; + } + return { unidades, isPalets }; + } + }); + + if (formValues) { + return formValues; // { unidades: number, isPalets: boolean } + } + return null; // Si se cancela el Swal + } + + async function getTipoEnvio() { + const { isConfirmed, value } = await Swal.fire({ + title: window.languageBundle['cart.shipping.tipo-envio'] || 'Tipo de envío', + html: ` +
+ + +
+ + ${window.languageBundle['cart.shipping.send-in-palets.info'] || 'En palets la entrega se realizará a pie de calle.'} + + `, + focusConfirm: false, + showCancelButton: true, + buttonsStyling: false, + customClass: { + confirmButton: 'btn btn-secondary me-2', + cancelButton: 'btn btn-light', + }, + confirmButtonText: window.languageBundle['app.aceptar'] || 'Aceptar', + cancelButtonText: window.languageBundle['app.cancelar'] || 'Cancelar', + preConfirm: () => { + const popup = Swal.getPopup(); + const chk = popup.querySelector('#swal-input-palets'); + // Devuelve un OBJETO (siempre truthy) con el booleano dentro + return { isPalets: !!chk?.checked }; + } + }); + + if (!isConfirmed) return null; // cancelado + return value.isPalets; // true / false + } + + + function checkTotalUnits(container, tirada) { + + const totalUnits = container.find('.direccion-card').toArray().reduce((acc, el) => { + const unidades = parseInt($(el).find('.item-tirada').val()) || 0; + return acc + unidades; + }, 0); + if (totalUnits < tirada) { + return false; + } + if (container.find('.product').closest('.shipping-addresses-sample')) { + if (container.find('.direccion-card').toArray().length === 0) { + return false; + } + } + return true; + } + + $(document).on('direcciones:actualizadas', '.shipping-addresses-item', function (e) { + + const tirada = $(this).closest('.product').find('.item-tirada').val(); + const container = $(this); + + if (!checkTotalUnits(container, tirada)) { + container.closest('.px-2').find('.btn-add-shipping').removeClass('d-none'); + } else { + container.closest('.px-2').find('.btn-add-shipping').addClass('d-none'); + } + }); + + $(document).on('direcciones:actualizadas', '.shipping-addresses-sample', function (e) { + + const container = $(this); + + if (container.find('.direccion-card').toArray().length === 0) { + container.closest('.px-2').find('.btn-add-shipping-sample').removeClass('d-none'); + } + else { + container.closest('.px-2').find('.btn-add-shipping-sample').addClass('d-none'); + } + }); + + $(document).on('submit', '#direccionForm', function (e) { + e.preventDefault(); + const $form = $(this); + + $.ajax({ + url: $form.attr('action'), + type: 'POST', // PUT simulado via _method + data: $form.serialize(), + dataType: 'html', + success: function (html) { + // Si por cualquier motivo llega 200 con fragmento, lo insertamos igual + if (typeof html === 'string' && html.indexOf('id="direccionForm"') !== -1 && html.indexOf(' 0; + const title = $('#direccionFormModalBody #direccionForm').data(isEdit ? 'edit' : 'add'); + $('#direccionModal .modal-title').text(title); + return; + } + // Éxito real: cerrar y recargar tabla + modal.addClass('d-none'); + seleccionarDireccionEnvio(); + }, + error: function (xhr) { + // Con 422 devolvemos el fragmento con errores aquí + if (xhr.status === 422 && xhr.responseText) { + $('#direccionFormModalBody').html(xhr.responseText); + const isEdit = $('#direccionFormModalBody #direccionForm input[name="_method"][value="PUT"]').length > 0; + const title = $('#direccionFormModalBody #direccionForm').data(isEdit ? 'edit' : 'add'); + $('#direccionModal .modal-title').text(title); + initSelect2Cliente(true); + return; + } + // Fallback + $('#direccionFormModalBody').html('
Error inesperado.
'); + } + }); + }); +}); \ No newline at end of file diff --git a/src/main/resources/static/assets/js/pages/imprimelibros/checkout/checkout.js b/src/main/resources/static/assets/js/pages/imprimelibros/checkout/checkout.js new file mode 100644 index 0000000..3aa8e86 --- /dev/null +++ b/src/main/resources/static/assets/js/pages/imprimelibros/checkout/checkout.js @@ -0,0 +1,21 @@ +$(() => { + + const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content'); + const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content'); + if (window.$ && csrfToken && csrfHeader) { + $.ajaxSetup({ + beforeSend: function (xhr) { + xhr.setRequestHeader(csrfHeader, csrfToken); + } + }); + } + + const language = document.documentElement.lang || 'es-ES'; + const modalEl = document.getElementById('direccionFormModal'); + const modal = bootstrap.Modal.getOrCreateInstance(modalEl); + + + + + +}); diff --git a/src/main/resources/static/assets/js/pages/imprimelibros/loader.js b/src/main/resources/static/assets/js/pages/imprimelibros/loader.js new file mode 100644 index 0000000..11152b3 --- /dev/null +++ b/src/main/resources/static/assets/js/pages/imprimelibros/loader.js @@ -0,0 +1,11 @@ +export function showLoader() { + const el = document.getElementById('sectionLoader'); + el.classList.remove('d-none'); + el.classList.add('d-flex'); +} + +export function hideLoader() { + const el = document.getElementById('sectionLoader'); + el.classList.remove('d-flex'); + el.classList.add('d-none'); +} \ No newline at end of file diff --git a/src/main/resources/templates/imprimelibros/cart/_cartContent.html b/src/main/resources/templates/imprimelibros/cart/_cartContent.html new file mode 100644 index 0000000..378648c --- /dev/null +++ b/src/main/resources/templates/imprimelibros/cart/_cartContent.html @@ -0,0 +1,61 @@ +
+ +
+
+ Cargando… +
+
+ + + + + + +
+ + + +
+
+

+
+ + + +
+ + +
+
+
+ +
+ +
+
+ +
+
+
+ +
+ +
\ No newline at end of file diff --git a/src/main/resources/templates/imprimelibros/cart/_cartItem.html b/src/main/resources/templates/imprimelibros/cart/_cartItem.html index 5ee3a2c..01de3dc 100644 --- a/src/main/resources/templates/imprimelibros/cart/_cartItem.html +++ b/src/main/resources/templates/imprimelibros/cart/_cartItem.html @@ -1,71 +1,192 @@ -
-
-
-
- portada + + + +
+
    + + +
+
+
+
+ +
+ +
+
+ portada +
+
+ + +
+
+ Presupuesto +
+
+ Presupuesto # + # +
+ +
    +
  • + +
  • +
+ +
    +
  • + Servicios adicionales: + +
  • +
+ +
    +
  • + Datos de maquetación: + +
  • +
+ +
    +
  • + Datos de marcapáginas: + +
  • +
+
+ + +
+

Precio

+
+ 0,00 +
+
+
- -
- -
- - Presupuesto - -
-
- Presupuesto # - # -
+
+
- -
    -
    -
  • -
    -
  • +
    + +
    + + Presupuesto + +
    +
    + Presupuesto # + # +
    +
    + + +
    + +
    +
    +
    Envio + del pedido +
    +
    +
    +
    + + +
    + + +
    + +
    +
    +
    +
    +
    +
    -
-
    - Servicios adicionales - -
+
+
+
Envio + de pruebas +
+
-
    -
  • - Datos de maquetación: - -
  • -
+
+
+ -
    -
  • - Datos de marcapáginas: - -
  • -
+
+ + +
-
- - -
-

Precio

-
- 0,00 -
+
+
+
+
+
+
+
+
+
+
@@ -77,7 +198,7 @@ diff --git a/src/main/resources/templates/imprimelibros/cart/_cartSummary.html b/src/main/resources/templates/imprimelibros/cart/_cartSummary.html new file mode 100644 index 0000000..516e328 --- /dev/null +++ b/src/main/resources/templates/imprimelibros/cart/_cartSummary.html @@ -0,0 +1,76 @@ +
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
:
:
+ :
: + +
+
+ + + +
+
+ +
+
+ +
+
+
+
+
+ + + +
+ + +
+
+ +
+
+
+ +
+ + +
\ No newline at end of file diff --git a/src/main/resources/templates/imprimelibros/cart/cart.html b/src/main/resources/templates/imprimelibros/cart/cart.html index d54ce93..7498643 100644 --- a/src/main/resources/templates/imprimelibros/cart/cart.html +++ b/src/main/resources/templates/imprimelibros/cart/cart.html @@ -9,7 +9,7 @@ - + @@ -22,6 +22,10 @@
+
+
+
-
-
- -
-
-
-
-
- -
-
-
-
-
-
-
-
- - - - - - - - - - - - - - - - - - - -
:
:
: - - - -
- -
- -
-
- - -
- - -
+
+
+
+ @@ -102,6 +51,7 @@ + diff --git a/src/main/resources/templates/imprimelibros/checkout/_envio.html b/src/main/resources/templates/imprimelibros/checkout/_envio.html new file mode 100644 index 0000000..893721c --- /dev/null +++ b/src/main/resources/templates/imprimelibros/checkout/_envio.html @@ -0,0 +1,43 @@ +
+
+
+
+
+
Envio del pedido +
+
+
+
+

+
+ + +
+ + +
+
+ +
+
+
+ +
+
+
Envio de pruebas +
+
+ +
+ +
+
+ + +
\ No newline at end of file diff --git a/src/main/resources/templates/imprimelibros/checkout/_pago.html b/src/main/resources/templates/imprimelibros/checkout/_pago.html new file mode 100644 index 0000000..42eb8c5 --- /dev/null +++ b/src/main/resources/templates/imprimelibros/checkout/_pago.html @@ -0,0 +1,3 @@ +
+ +
\ No newline at end of file diff --git a/src/main/resources/templates/imprimelibros/checkout/checkout.html b/src/main/resources/templates/imprimelibros/checkout/checkout.html new file mode 100644 index 0000000..5763197 --- /dev/null +++ b/src/main/resources/templates/imprimelibros/checkout/checkout.html @@ -0,0 +1,175 @@ + + + + + + + + + + + + + + + + +
+
+ + +
+ +
+ +
+ +
+
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PresupuestoTítulo + Base
+ PRESUPUESTO-001 + + Título del presupuesto + + + 0,00 + + +
:
:
: + + +
+ +
+ +
+
+ + +
+ + +
+
+ + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/imprimelibros/direcciones/direccion-form-fixed-user.html b/src/main/resources/templates/imprimelibros/direcciones/direccion-form-fixed-user.html new file mode 100644 index 0000000..6980f89 --- /dev/null +++ b/src/main/resources/templates/imprimelibros/direcciones/direccion-form-fixed-user.html @@ -0,0 +1,169 @@ +
+
+ +
+ Error +
+ + + +
+ + +
+ +
+ +
+ + +
+
+ +
+ + +
+
+ +
+
+ + +
+
+ +
+ + +
+
+
+ +
+
+ + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+ +
+ + +
+
+
+ +
+ + +
+ +
+ + +
+
+ +
+
+ + +
+
+ +
+ + +
+
+
+ +
+ +
+ +
+
\ No newline at end of file diff --git a/src/main/resources/templates/imprimelibros/direcciones/direccion-form.html b/src/main/resources/templates/imprimelibros/direcciones/direccion-form.html index d124b90..79e2969 100644 --- a/src/main/resources/templates/imprimelibros/direcciones/direccion-form.html +++ b/src/main/resources/templates/imprimelibros/direcciones/direccion-form.html @@ -103,6 +103,7 @@
diff --git a/src/main/resources/templates/imprimelibros/direcciones/direccionCard.html b/src/main/resources/templates/imprimelibros/direcciones/direccionCard.html new file mode 100644 index 0000000..329b10b --- /dev/null +++ b/src/main/resources/templates/imprimelibros/direcciones/direccionCard.html @@ -0,0 +1,52 @@ +
+
+ + + + + + + + +
+
+ + + + + +
+
+
+ + + + +
+
+ +
+
+ +
+
+
+ +
+ +
\ No newline at end of file diff --git a/src/main/resources/templates/imprimelibros/login/login.html b/src/main/resources/templates/imprimelibros/login/login.html index e3f3cda..1b2f168 100644 --- a/src/main/resources/templates/imprimelibros/login/login.html +++ b/src/main/resources/templates/imprimelibros/login/login.html @@ -6,6 +6,9 @@ + + + diff --git a/src/main/resources/templates/imprimelibros/payments/redsys-redirect.html b/src/main/resources/templates/imprimelibros/payments/redsys-redirect.html new file mode 100644 index 0000000..cc983cd --- /dev/null +++ b/src/main/resources/templates/imprimelibros/payments/redsys-redirect.html @@ -0,0 +1,7 @@ +
+ + + + +
+ diff --git a/src/test/java/com/imprimelibros/erp/calcularEnvios.java b/src/test/java/com/imprimelibros/erp/calcularEnvios.java new file mode 100644 index 0000000..c3364b1 --- /dev/null +++ b/src/test/java/com/imprimelibros/erp/calcularEnvios.java @@ -0,0 +1,44 @@ +package com.imprimelibros.erp; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import java.util.Locale; + +import com.imprimelibros.erp.externalApi.skApiClient; + +@SpringBootTest +public class calcularEnvios { + @Autowired + private skApiClient apiClient; + + @Test + void testPrecioCalculadoDevuelveJson() { + + Map data = new HashMap<>(); + data.put("pais_code3", "esp"); + data.put("cp", 18200); + data.put("peso", 7.82); + data.put("unidades", 10); + data.put("palets", 0); + + + // get locale + Locale locale = Locale.forLanguageTag("es-ES"); + + + Map resultado = apiClient.getCosteEnvio(data, locale); + + System.out.println("📦 Resultado de la API:"); + System.out.println(resultado); + + assertNotNull(resultado, "El resultado no debe ser null"); + /*assertTrue(resultado.trim().startsWith("{"), "El resultado debe comenzar con { (JSON)"); + assertTrue(resultado.trim().endsWith("}"), "El resultado debe terminar con } (JSON)");*/ + } +}