Compare commits

...

74 Commits

Author SHA1 Message Date
562dc2b231 Merge branch 'feat/log_save_presupuesto' into 'main'
añadido log en guardar presupuesto

See merge request jjimenez/erp-imprimelibros!33
2026-01-09 16:55:09 +00:00
9a49ccf6b8 añadido log en guardar presupuesto 2026-01-09 17:54:06 +01:00
b2026f1cab Merge branch 'mod/test_perfil' into 'main'
Modificaciones en el perfil de test

See merge request jjimenez/erp-imprimelibros!32
2026-01-08 11:31:12 +00:00
a5b6bf3a25 Modificaciones en el perfil de test 2026-01-08 12:30:43 +01:00
9a67c2e78f Merge branch 'feat/facturas' into 'main'
Feat/facturas

See merge request jjimenez/erp-imprimelibros!31
2026-01-07 20:22:12 +00:00
8263d97bf7 terminado (provisional) modulo de facturas 2026-01-07 21:21:33 +01:00
292aebcf65 Merge branch 'main' into feat/facturas 2026-01-05 13:00:00 +01:00
e50153205a Merge branch 'hotfix/add_presupuesto_comentarios' into 'main'
arreglado el comentario de administrador cuando no tiene id de presupuesto

See merge request jjimenez/erp-imprimelibros!30
2026-01-05 11:58:46 +00:00
5ecb38f474 arreglado el comentario de administrador cuando no tiene id de presupuesto 2026-01-05 12:56:55 +01:00
d7a85d9bfb Merge branch 'main' into feat/facturas 2026-01-05 11:06:57 +01:00
4343997eb1 Merge branch 'fix/baseline' into 'main'
modificado el baseline. actualmente falla porque hay que rellenar la tabla variables

See merge request jjimenez/erp-imprimelibros!29
2026-01-05 10:06:22 +00:00
6bfc60d158 modificado el baseline. actualmente falla porque hay que rellenar la tabla variables 2026-01-05 11:05:26 +01:00
aa8ecdf75c Merge branch 'mod/docker_compose-plesk' into 'main'
Añadido documento composer para entorno plesk

See merge request jjimenez/erp-imprimelibros!28
2026-01-04 17:44:50 +00:00
dc529ff055 Añadido documento composer para entorno plesk 2026-01-04 18:44:22 +01:00
4a535ab644 añadido seeder para series de facturacion 2026-01-04 13:11:47 +01:00
400251ac3d generación de factura pdf terminada 2026-01-04 12:11:45 +01:00
6bea279066 terminando pdf de facturas 2026-01-02 21:47:06 +01:00
bf823281a5 haciendo vista de facturas 2026-01-01 20:00:14 +01:00
9d4320db9a trabajando en el formulario de la factura 2025-12-31 18:07:17 +01:00
d7b5dedb38 series de facturación terminadas (vista en configuración) 2025-12-30 21:20:02 +01:00
089641b601 añadidos entidades, repos y facturacionservice 2025-12-30 19:51:04 +01:00
98a5fcaa0b Merge branch 'fix/presupuesto_user_id' into 'main'
arreglado

See merge request jjimenez/erp-imprimelibros!27
2025-12-30 08:37:02 +00:00
13a38fcdd5 arreglado 2025-12-30 09:36:30 +01:00
839301cf94 Merge branch 'mod/presupuesto_cliente_and_cancel_pedido' into 'main'
Mod/presupuesto cliente and cancel pedido

See merge request jjimenez/erp-imprimelibros!26
2025-12-29 20:29:39 +00:00
1ba1b28793 terminado 2025-12-29 21:28:48 +01:00
47866ddead Se puede seleccionar como admin el cliente del presupuesto como borrador 2025-12-29 20:05:35 +01:00
982423d766 Merge branch 'feat/pedidos_system' into 'main'
vista de pedidos casi terminada (a falta de acciones delos botones, cambio de...

See merge request jjimenez/erp-imprimelibros!25
2025-12-28 11:25:51 +00:00
5b5ce7ccd7 terminado consultas de pedidos a safekat 2025-12-28 12:01:40 +01:00
61be8d6d3b aceptando ferro 2025-12-27 19:19:17 +01:00
3a00702bb1 trbajando en las funciones de leer los estados 2025-12-27 10:50:09 +01:00
b94a099e01 sistema de pedidos pendientes de pago hechos 2025-12-23 17:41:05 +01:00
d4120bb486 haciendo pagos pendientes 2025-12-22 20:41:21 +01:00
4cc47b4249 añadidos limites cuando lomo interior es menor que 10 2025-12-12 18:27:24 +01:00
cf73801dbe reimpresiones hechas correctamente 2025-12-11 19:46:01 +01:00
3b9f446195 reimpresion a SK 2025-12-01 22:31:40 +01:00
c6e2322132 implementado duplicar en la lista 2025-11-29 23:30:22 +01:00
58fd4815c6 vista de pedidos casi terminada (a falta de acciones delos botones, cambio de estados, etc). Trabajando en el presupuesto para modificar las reimpresiones 2025-11-29 13:42:57 +01:00
9baf880022 terminando pedidos 2025-11-29 00:07:51 +01:00
25a7bcf0b8 trbajando en pedidos 2025-11-28 08:10:25 +01:00
58f0eee5d9 trabajando en la vista del pedido 2025-11-18 21:32:16 +01:00
997741c3c9 trabajando en la vista de peiddos 2025-11-17 22:52:35 +01:00
6ff5250d1b listado de pedidos admin hecho 2025-11-17 10:35:04 +01:00
73676f60b9 trabajando en la vista de pedidos 2025-11-16 22:36:47 +01:00
18a43ea121 trabajando en la vista de pedidos 2025-11-16 22:36:35 +01:00
d19cd1923c añadidas las direcciones de pedido 2025-11-16 21:56:03 +01:00
84a822db22 Merge branch 'hotfix/save_with_DL' into 'main'
arreglado problema pago bizum

See merge request jjimenez/erp-imprimelibros!24
2025-11-15 09:59:54 +00:00
69f27df98b arreglados varios temas además del DL (redsys, etc) 2025-11-15 10:59:05 +01:00
6bd36dbe8c arreglado problema pago bizum
A
2025-11-14 18:35:06 +01:00
3086a6de41 añadido test 2025-11-14 13:55:29 +01:00
4f1b3f2bb6 haciendo pruebas 2025-11-13 21:40:08 +01:00
d31a6e9e8e Merge branch 'feat/pago_success' into 'main'
Feat/pago success

See merge request jjimenez/erp-imprimelibros!23
2025-11-13 16:31:03 +00:00
51ea3b4120 terminado a falta de pruebas en servidor 2025-11-13 17:30:19 +01:00
32416d10f3 añadido fichero template pagos 2025-11-12 18:06:44 +01:00
69a4076e28 trabajando en respuesta ok tras el pago con tarjeta 2025-11-12 15:04:16 +01:00
b52f5e7d4f Merge branch 'feat/pedidos' into 'main'
Feat/pedidos

See merge request jjimenez/erp-imprimelibros!22
2025-11-10 20:07:18 +00:00
4ceb4fb8e4 primera versión de pedido realizada 2025-11-10 21:06:53 +01:00
cc696d7a99 falta la vista de los presupuestos aceptados 2025-11-09 20:43:57 +01:00
032e44b9c5 haciendo pruebas en SK 2025-11-09 14:46:41 +01:00
7254c8f11b trabajando en obtener las direcciones para guardar 2025-11-07 20:11:23 +01:00
90239be31e algun cambio en las migraciones 2025-11-06 15:49:28 +01:00
3ea1496861 Merge branch 'feat/checkout_pagos' into 'main'
Feat/checkout pagos

See merge request jjimenez/erp-imprimelibros!21
2025-11-06 12:50:38 +00:00
f13eeb940c terminado pagos 2025-11-06 13:49:15 +01:00
62396eb7a7 limpieza del proyecto. Transferencias falta devolución 2025-11-06 09:56:48 +01:00
c11c34011e trabajando en la tabla de transferencias 2025-11-05 21:46:54 +01:00
a4443763d8 trabajando en devoluciones 2025-11-05 15:09:26 +01:00
ed32f773a4 haciendo datatables de los pagos 2025-11-04 22:03:03 +01:00
dc64e40e38 haciendo vista de pagos 2025-11-04 15:29:29 +01:00
7516e9e91e falta vista de pagos 2025-11-04 14:40:18 +01:00
f528809c07 trabajando en el notify 2025-11-03 23:32:31 +01:00
725cff9b51 testeando el notify 2025-11-03 19:31:28 +01:00
88650fc5e8 añadidos ficheros a falta de modificar el servicio y el controlador redsys 2025-11-02 17:14:44 +01:00
dc8b67b937 añadidos ficheros a falta de modificar el servicio y el controlador redsys 2025-11-02 17:14:29 +01:00
4d451cc85e a falta del pago 2025-11-02 11:57:05 +01:00
51d22515e8 modificado el custom switch en el swal (en el css) 2025-11-01 12:23:36 +01:00
602 changed files with 28740 additions and 161516 deletions

3
.gitignore vendored
View File

@ -31,3 +31,6 @@ build/
### VS Code ### ### VS Code ###
.vscode/ .vscode/
### Logs ###
erp-*.log

45
docker-compose.plesk.yml Normal file
View File

@ -0,0 +1,45 @@
version: "3.8"
services:
imprimelibros-db:
image: mysql:8.0
container_name: imprimelibros-db
environment:
MYSQL_ROOT_PASSWORD: NrXz6DK6UoN
MYSQL_DATABASE: imprimelibros
MYSQL_USER: imprimelibros_user
MYSQL_PASSWORD: om91irrDctd
volumes:
- db_data:/var/lib/mysql
networks:
- imprimelibros-network
restart: always
ports:
- "3309:3306" # host:container
imprimelibros-app:
build:
context: .
dockerfile: Dockerfile
image: imprimelibros-app:latest
container_name: imprimelibros-app
depends_on:
- imprimelibros-db
environment:
SPRING_DATASOURCE_URL: jdbc:mysql://imprimelibros-db:3306/imprimelibros?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Europe/Madrid
SPRING_DATASOURCE_USERNAME: imprimelibros_user
SPRING_DATASOURCE_PASSWORD: om91irrDctd
ports:
- "127.0.0.1:8080:8080"
volumes:
- ./logs:/var/log/imprimelibros
restart: always
networks:
- imprimelibros-network
volumes:
db_data:
networks:
imprimelibros-network:
driver: bridge

View File

@ -31,6 +31,8 @@ services:
SPRING_DATASOURCE_PASSWORD: om91irrDctd SPRING_DATASOURCE_PASSWORD: om91irrDctd
ports: ports:
- "8080:8080" - "8080:8080"
volumes:
- ./logs:/var/log/imprimelibros
restart: always restart: always
networks: networks:
- imprimelibros-network - imprimelibros-network

12303
logs/erp.log Normal file

File diff suppressed because one or more lines are too long

13
pom.xml
View File

@ -6,7 +6,7 @@
<parent> <parent>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId> <artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.7</version> <version>3.5.9</version>
<relativePath /> <!-- lookup parent from repository --> <relativePath /> <!-- lookup parent from repository -->
</parent> </parent>
<groupId>com.imprimelibros</groupId> <groupId>com.imprimelibros</groupId>
@ -32,6 +32,12 @@
<liquibase.version>4.29.2</liquibase.version> <liquibase.version>4.29.2</liquibase.version>
</properties> </properties>
<dependencies> <dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId> <artifactId>spring-boot-starter-security</artifactId>
@ -193,6 +199,11 @@
<plugin> <plugin>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId> <artifactId>spring-boot-maven-plugin</artifactId>
<!-- IMPORTANTE: incluir dependencias con scope=system en el fat-jar -->
<configuration>
<!-- Esto hace que meta las dependencias con scope=system en BOOT-INF/lib -->
<includeSystemScope>true</includeSystemScope>
</configuration>
</plugin> </plugin>
<!-- (Migraciones) Plugin Maven para generar/ejecutar changelogs --> <!-- (Migraciones) Plugin Maven para generar/ejecutar changelogs -->

View File

@ -3,8 +3,10 @@ package com.imprimelibros.erp;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@EnableScheduling
@ConfigurationPropertiesScan(basePackages = "com.imprimelibros.erp") @ConfigurationPropertiesScan(basePackages = "com.imprimelibros.erp")
public class ErpApplication { public class ErpApplication {

View File

@ -0,0 +1,28 @@
package com.imprimelibros.erp.cart;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Service
public class CartCleanupService {
private final CartRepository cartRepository;
public CartCleanupService(CartRepository cartRepository) {
this.cartRepository = cartRepository;
}
/**
* Ejecuta cada noche a las 2:00 AM
*/
@Transactional
@Scheduled(cron = "0 0 2 * * *") // cada día a las 02:00
public void markAbandonedCarts() {
LocalDateTime limite = LocalDateTime.now().minusDays(7);
int updated = cartRepository.markOldCartsAsAbandoned(limite);
System.out.println("Carritos abandonados marcados: " + updated);
}
}

View File

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

View File

@ -1,9 +1,12 @@
package com.imprimelibros.erp.cart; package com.imprimelibros.erp.cart;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import java.util.List; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Optional; import java.util.Optional;
public interface CartRepository extends JpaRepository<Cart, Long> { public interface CartRepository extends JpaRepository<Cart, Long> {
@ -19,4 +22,14 @@ public interface CartRepository extends JpaRepository<Cart, Long> {
""") """)
Optional<Cart> findByIdFetchAll(@Param("id") Long id); Optional<Cart> findByIdFetchAll(@Param("id") Long id);
@Modifying
@Transactional
@Query("""
UPDATE Cart c
SET c.status = 'ABANDONED'
WHERE c.status = 'ACTIVE'
AND c.updatedAt < :limite
""")
int markOldCartsAsAbandoned(LocalDateTime limite);
} }

View File

@ -5,6 +5,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.context.MessageSource; import org.springframework.context.MessageSource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@ -14,43 +15,55 @@ import java.util.Objects;
import com.imprimelibros.erp.presupuesto.classes.PresupuestoFormatter; import com.imprimelibros.erp.presupuesto.classes.PresupuestoFormatter;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto; import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import com.imprimelibros.erp.presupuesto.service.PresupuestoService;
import com.imprimelibros.erp.users.UserService;
import com.imprimelibros.erp.cart.dto.CartDireccionRepository; import com.imprimelibros.erp.cart.dto.CartDireccionRepository;
import com.imprimelibros.erp.cart.dto.DireccionCardDTO; import com.imprimelibros.erp.cart.dto.DireccionCardDTO;
import com.imprimelibros.erp.cart.dto.DireccionShipment; import com.imprimelibros.erp.cart.dto.DireccionShipment;
import com.imprimelibros.erp.cart.dto.UpdateCartRequest; import com.imprimelibros.erp.cart.dto.UpdateCartRequest;
import com.imprimelibros.erp.common.Utils; import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.common.email.EmailService;
import com.imprimelibros.erp.direcciones.DireccionService; import com.imprimelibros.erp.direcciones.DireccionService;
import com.imprimelibros.erp.externalApi.skApiClient; import com.imprimelibros.erp.externalApi.skApiClient;
import com.imprimelibros.erp.pedido.PedidoService; import com.imprimelibros.erp.pedidos.PedidoRepository;
import com.imprimelibros.erp.presupuesto.PresupuestoRepository; import com.imprimelibros.erp.presupuesto.PresupuestoRepository;
@Service @Service
public class CartService { public class CartService {
private final EmailService emailService;
private final CartRepository cartRepo; private final CartRepository cartRepo;
private final CartDireccionRepository cartDireccionRepo; private final CartDireccionRepository cartDireccionRepo;
private final CartItemRepository itemRepo; private final CartItemRepository itemRepo;
private final MessageSource messageSource; private final MessageSource messageSource;
private final PresupuestoRepository presupuestoRepo; private final PresupuestoRepository presupuestoRepo;
private final Utils utils;
private final DireccionService direccionService; private final DireccionService direccionService;
private final skApiClient skApiClient; private final skApiClient skApiClient;
private final PedidoService pedidoService; private final PresupuestoService presupuestoService;
private final PedidoRepository pedidoRepository;
private final UserService userService;
public CartService(CartRepository cartRepo, CartItemRepository itemRepo, public CartService(CartRepository cartRepo, CartItemRepository itemRepo,
CartDireccionRepository cartDireccionRepo, MessageSource messageSource, CartDireccionRepository cartDireccionRepo, MessageSource messageSource,
PresupuestoFormatter presupuestoFormatter, PresupuestoRepository presupuestoRepo, PresupuestoFormatter presupuestoFormatter, PresupuestoRepository presupuestoRepo, PedidoRepository pedidoRepository,
Utils utils, DireccionService direccionService, skApiClient skApiClient, DireccionService direccionService, skApiClient skApiClient,PresupuestoService presupuestoService, EmailService emailService, UserService userService) {
PedidoService pedidoService) {
this.cartRepo = cartRepo; this.cartRepo = cartRepo;
this.itemRepo = itemRepo; this.itemRepo = itemRepo;
this.cartDireccionRepo = cartDireccionRepo; this.cartDireccionRepo = cartDireccionRepo;
this.messageSource = messageSource; this.messageSource = messageSource;
this.presupuestoRepo = presupuestoRepo; this.presupuestoRepo = presupuestoRepo;
this.utils = utils;
this.direccionService = direccionService; this.direccionService = direccionService;
this.skApiClient = skApiClient; this.skApiClient = skApiClient;
this.pedidoService = pedidoService; this.presupuestoService = presupuestoService;
this.emailService = emailService;
this.pedidoRepository = pedidoRepository;
this.userService = userService;
}
public Cart findById(Long cartId) {
return cartRepo.findById(cartId)
.orElseThrow(() -> new IllegalArgumentException("Carrito no encontrado"));
} }
/** Devuelve el carrito activo o lo crea si no existe. */ /** Devuelve el carrito activo o lo crea si no existe. */
@ -80,7 +93,7 @@ public class CartService {
Presupuesto p = item.getPresupuesto(); Presupuesto p = item.getPresupuesto();
Map<String, Object> elemento = getElementoCart(p, locale); Map<String, Object> elemento = presupuestoService.getPresupuestoInfoForCard(p, locale);
elemento.put("cartItemId", item.getId()); elemento.put("cartItemId", item.getId());
resultados.add(elemento); resultados.add(elemento);
} }
@ -136,52 +149,27 @@ public class CartService {
cartRepo.save(cart); cartRepo.save(cart);
} }
@Transactional
public void lockCartById(Long cartId) {
Cart cart = cartRepo.findById(cartId)
.orElseThrow(() -> new IllegalArgumentException("Carrito no encontrado"));
cart.setStatus(Cart.Status.LOCKED);
cartRepo.save(cart);
}
@Transactional @Transactional
public long countItems(Long userId) { public long countItems(Long userId) {
Cart cart = getOrCreateActiveCart(userId); Cart cart = getOrCreateActiveCart(userId);
return itemRepo.findByCartId(cart.getId()).size(); return itemRepo.findByCartId(cart.getId()).size();
} }
private Map<String, Object> getElementoCart(Presupuesto presupuesto, Locale locale) { public Map<String, Object> getCartSummaryRaw(Cart cart, Locale locale) {
Map<String, Object> resumen = new HashMap<>();
resumen.put("titulo", presupuesto.getTitulo());
resumen.put("imagen",
"/assets/images/imprimelibros/presupuestador/" + presupuesto.getTipoEncuadernacion() + ".png");
resumen.put("imagen_alt",
messageSource.getMessage("presupuesto." + presupuesto.getTipoEncuadernacion(), null, locale));
resumen.put("presupuestoId", presupuesto.getId());
if (presupuesto.getServiciosJson() != null && presupuesto.getServiciosJson().contains("ejemplar-prueba")) {
resumen.put("hasSample", true);
} else {
resumen.put("hasSample", false);
}
Map<String, Object> 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());
resumen.put("iva21", presupuesto.getIvaImporte21());
resumen.put("resumen", detalles);
return resumen;
}
public Map<String, Object> getCartSummary(Cart cart, Locale locale) {
double base = 0.0; double base = 0.0;
double iva4 = 0.0; double iva4 = 0.0;
double iva21 = 0.0; double iva21 = 0.0;
double shipment = 0.0; double shipment = 0.0;
boolean errorShipementCost = false;
Boolean errorShipementCost = false;
List<CartItem> items = cart.getItems(); List<CartItem> items = cart.getItems();
List<CartDireccion> direcciones = cart.getDirecciones(); List<CartDireccion> direcciones = cart.getDirecciones();
@ -192,55 +180,58 @@ public class CartService {
base += p.getBaseImponible().doubleValue(); base += p.getBaseImponible().doubleValue();
iva4 += p.getIvaImporte4().doubleValue(); iva4 += p.getIvaImporte4().doubleValue();
iva21 += p.getIvaImporte21().doubleValue(); iva21 += p.getIvaImporte21().doubleValue();
if (cart.getOnlyOneShipment() != null && cart.getOnlyOneShipment()) { if (cart.getOnlyOneShipment() != null && cart.getOnlyOneShipment()) {
// Si es envío único, que es a españa y no ha canarias if (direcciones != null && !direcciones.isEmpty()) {
if (direcciones != null && direcciones.size() > 0) {
CartDireccion cd = direcciones.get(0); CartDireccion cd = direcciones.get(0);
Boolean freeShipment = direccionService.checkFreeShipment(cd.getDireccion().getCp(), boolean freeShipment = direccionService.checkFreeShipment(
cd.getDireccion().getCp(),
cd.getDireccion().getPaisCode3()) && !cd.getIsPalets(); cd.getDireccion().getPaisCode3()) && !cd.getIsPalets();
if (!freeShipment) { if (!freeShipment) {
Integer unidades = p.getSelectedTirada(); Integer unidades = p.getSelectedTirada();
Map<String, Object> res = getShippingCost(cd, peso, unidades, locale); Map<String, Object> res = getShippingCost(cd, peso, unidades, locale);
if (res.get("success").equals(Boolean.FALSE)) { if (Boolean.FALSE.equals(res.get("success"))) {
errorShipementCost = true; errorShipementCost = true;
} } else {
else{
shipment += (Double) res.get("shipment"); shipment += (Double) res.get("shipment");
iva21 += (Double) res.get("iva21"); iva21 += (Double) res.get("iva21");
} }
} }
// si tiene prueba de envio, hay que añadir el coste
if (p.getServiciosJson() != null && p.getServiciosJson().contains("ejemplar-prueba")) {
// ejemplar de prueba
if (p.getServiciosJson() != null && p.getServiciosJson().contains("ejemplar-prueba")) {
Map<String, Object> res = getShippingCost(cd, peso, 1, locale); Map<String, Object> res = getShippingCost(cd, peso, 1, locale);
if (res.get("success").equals(Boolean.FALSE)) { if (Boolean.FALSE.equals(res.get("success"))) {
errorShipementCost = true; errorShipementCost = true;
} } else {
else{
shipment += (Double) res.get("shipment"); shipment += (Double) res.get("shipment");
iva21 += (Double) res.get("iva21"); iva21 += (Double) res.get("iva21");
} }
} }
} }
} else { } else {
// envio por cada presupuesto
// buscar la direccion asignada a este presupuesto
if (direcciones == null) if (direcciones == null)
continue; continue;
List<CartDireccion> cd_presupuesto = direcciones.stream() List<CartDireccion> cd_presupuesto = direcciones.stream()
.filter(d -> d.getPresupuesto() != null && d.getPresupuesto().getId().equals(p.getId()) .filter(d -> d.getPresupuesto() != null
&& d.getUnidades() != null && d.getUnidades() != null && d.getUnidades() > 0) && d.getPresupuesto().getId().equals(p.getId())
&& d.getUnidades() != null
&& d.getUnidades() > 0)
.toList(); .toList();
Boolean firstDirection = true;
boolean firstDirection = true;
for (CartDireccion cd : cd_presupuesto) { for (CartDireccion cd : cd_presupuesto) {
Integer unidades = cd.getUnidades(); Integer unidades = cd.getUnidades();
if (firstDirection) { if (firstDirection) {
Boolean freeShipment = direccionService.checkFreeShipment(cd.getDireccion().getCp(), boolean freeShipment = direccionService.checkFreeShipment(
cd.getDireccion().getCp(),
cd.getDireccion().getPaisCode3()) && !cd.getIsPalets(); cd.getDireccion().getPaisCode3()) && !cd.getIsPalets();
if (!freeShipment && unidades != null && unidades > 0) { if (!freeShipment && unidades != null && unidades > 0) {
Map<String, Object> res = getShippingCost(cd, peso, unidades, locale); Map<String, Object> res = getShippingCost(cd, peso, unidades, locale);
if (res.get("success").equals(Boolean.FALSE)) { if (Boolean.FALSE.equals(res.get("success"))) {
errorShipementCost = true; errorShipementCost = true;
} else { } else {
shipment += (Double) res.get("shipment"); shipment += (Double) res.get("shipment");
@ -250,7 +241,7 @@ public class CartService {
firstDirection = false; firstDirection = false;
} else { } else {
Map<String, Object> res = getShippingCost(cd, peso, unidades, locale); Map<String, Object> res = getShippingCost(cd, peso, unidades, locale);
if (res.get("success").equals(Boolean.FALSE)) { if (Boolean.FALSE.equals(res.get("success"))) {
errorShipementCost = true; errorShipementCost = true;
} else { } else {
shipment += (Double) res.get("shipment"); shipment += (Double) res.get("shipment");
@ -258,18 +249,19 @@ public class CartService {
} }
} }
} }
// ejemplar de prueba // ejemplar de prueba
CartDireccion cd_prueba = direcciones.stream() CartDireccion cd_prueba = direcciones.stream()
.filter(d -> d.getPresupuesto() != null && d.getPresupuesto().getId().equals(p.getId()) .filter(d -> d.getPresupuesto() != null
&& d.getPresupuesto().getId().equals(p.getId())
&& d.getUnidades() == null) && d.getUnidades() == null)
.findFirst().orElse(null); .findFirst().orElse(null);
if (cd_prueba != null) {
if (cd_prueba != null) {
Map<String, Object> res = getShippingCost(cd_prueba, peso, 1, locale); Map<String, Object> res = getShippingCost(cd_prueba, peso, 1, locale);
if (res.get("success").equals(Boolean.FALSE)) { if (Boolean.FALSE.equals(res.get("success"))) {
errorShipementCost = true; errorShipementCost = true;
} } else {
else{
shipment += (Double) res.get("shipment"); shipment += (Double) res.get("shipment");
iva21 += (Double) res.get("iva21"); iva21 += (Double) res.get("iva21");
} }
@ -277,11 +269,65 @@ public class CartService {
} }
} }
double total = base + iva4 + iva21 + shipment; double totalBeforeDiscount = base + iva4 + iva21 + shipment;
int fidelizacion = this.getDescuentoFidelizacion(cart.getUserId());
double descuento = totalBeforeDiscount * fidelizacion / 100.0;
double total = totalBeforeDiscount - descuento;
int fidelizacion = pedidoService.getDescuentoFidelizacion(); // Redondeo a 2 decimales
double descuento = (total) * fidelizacion / 100.0; base = Utils.round2(base);
total -= descuento; iva4 = Utils.round2(iva4);
iva21 = Utils.round2(iva21);
shipment = Utils.round2(shipment);
descuento = Utils.round2(descuento);
total = Utils.round2(total);
Map<String, Object> summary = new HashMap<>();
summary.put("base", base);
summary.put("iva4", iva4);
summary.put("iva21", iva21);
summary.put("shipment", shipment);
summary.put("fidelizacion", fidelizacion);
summary.put("descuento", descuento);
summary.put("total", total);
summary.put("amountCents", Math.round(total * 100));
summary.put("errorShipmentCost", errorShipementCost);
summary.put("cartId", cart.getId());
return summary;
}
public int getDescuentoFidelizacion(Long userId) {
// descuento entre el 1% y el 6% para clientes fidelidad (mas de 1500€ en el
// ultimo año)
Instant haceUnAno = Instant.now().minusSeconds(365 * 24 * 60 * 60);
double totalGastado = pedidoRepository.sumTotalByCreatedByAndCreatedAtAfter(userId, haceUnAno);
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;
}
public Map<String, Object> getCartSummary(Cart cart, Locale locale) {
Map<String, Object> raw = getCartSummaryRaw(cart, locale);
double base = (Double) raw.get("base");
double iva4 = (Double) raw.get("iva4");
double iva21 = (Double) raw.get("iva21");
double shipment = (Double) raw.get("shipment");
int fidelizacion = (Integer) raw.get("fidelizacion");
double descuento = (Double) raw.get("descuento");
double total = (Double) raw.get("total");
Map<String, Object> summary = new HashMap<>(); Map<String, Object> summary = new HashMap<>();
summary.put("base", Utils.formatCurrency(base, locale)); summary.put("base", Utils.formatCurrency(base, locale));
@ -289,9 +335,11 @@ public class CartService {
summary.put("iva21", Utils.formatCurrency(iva21, locale)); summary.put("iva21", Utils.formatCurrency(iva21, locale));
summary.put("shipment", Utils.formatCurrency(shipment, locale)); summary.put("shipment", Utils.formatCurrency(shipment, locale));
summary.put("fidelizacion", fidelizacion + "%"); summary.put("fidelizacion", fidelizacion + "%");
summary.put("descuento", Utils.formatCurrency(-descuento, locale)); summary.put("descuento", Utils.formatCurrency(-descuento, locale)); // negativo para mostrar
summary.put("total", Utils.formatCurrency(total, locale)); summary.put("total", Utils.formatCurrency(total, locale));
summary.put("errorShipmentCost", errorShipementCost); summary.put("amountCents", raw.get("amountCents"));
summary.put("errorShipmentCost", raw.get("errorShipmentCost"));
summary.put("cartId", raw.get("cartId"));
return summary; return summary;
} }
@ -375,6 +423,13 @@ public class CartService {
cart.setUserId(customerId); cart.setUserId(customerId);
cartRepo.save(cart); cartRepo.save(cart);
// Se mueven los presupuestos de cartitems a ese usuario
List<CartItem> items = itemRepo.findByCartId(cart.getId());
for (CartItem item : items) {
Presupuesto p = item.getPresupuesto();
p.setUser(userService.findById(customerId));
presupuestoRepo.save(p);
}
return true; return true;
} catch (Exception e) { } catch (Exception e) {
@ -386,13 +441,11 @@ public class CartService {
// delete cart directions by direccion id in ACTIVE carts // delete cart directions by direccion id in ACTIVE carts
@Transactional @Transactional
public void deleteCartDireccionesByDireccionId(Long direccionId) { public void deleteCartDireccionesByDireccionId(Long direccionId) {
/*List<CartDireccion> cartDirecciones = cartDireccionRepo.findByDireccion_IdAndCart_Status(direccionId, Cart.Status.ACTIVE);
for (CartDireccion cd : cartDirecciones) {
cartDireccionRepo.deleteById(cd.getId());
}*/
cartDireccionRepo.deleteByDireccionIdAndCartStatus(direccionId, Cart.Status.ACTIVE); cartDireccionRepo.deleteByDireccionIdAndCartStatus(direccionId, Cart.Status.ACTIVE);
} }
/*************************************** /***************************************
* MÉTODOS PRIVADOS * MÉTODOS PRIVADOS
***************************************/ ***************************************/

View File

@ -6,17 +6,20 @@ import java.util.Locale;
import java.util.Map; import java.util.Map;
import org.springframework.context.MessageSource; import org.springframework.context.MessageSource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.server.ResponseStatusException;
import com.imprimelibros.erp.common.Utils; import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.i18n.TranslationService; import com.imprimelibros.erp.i18n.TranslationService;
import com.imprimelibros.erp.paises.PaisesService; import com.imprimelibros.erp.paises.PaisesService;
import com.imprimelibros.erp.direcciones.Direccion;
import com.imprimelibros.erp.direcciones.DireccionService; import com.imprimelibros.erp.direcciones.DireccionService;
import com.imprimelibros.erp.cart.Cart;
import com.imprimelibros.erp.cart.CartService; import com.imprimelibros.erp.cart.CartService;
@Controller @Controller
@ -44,23 +47,29 @@ public class CheckoutController {
List<String> keys = List.of( List<String> keys = List.of(
"app.cancelar", "app.cancelar",
"app.seleccionar", "app.seleccionar",
"checkout.shipping.add.title",
"checkout.shipping.select-placeholder",
"checkout.shipping.new-address",
"app.yes", "app.yes",
"app.cancelar"); "checkout.billing-address.title",
"checkout.billing-address.new-address",
"checkout.billing-address.select-placeholder",
"checkout.billing-address.errors.noAddressSelected");
Map<String, String> translations = translationService.getTranslations(locale, keys); Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations); model.addAttribute("languageBundle", translations);
var items = this.cartService.listItems(Utils.currentUserId(principal), locale); Long userId = Utils.currentUserId(principal);
for (var item : items) { Cart cart = cartService.getOrCreateActiveCart(userId);
if (item.get("hasSample") != null && (Boolean) item.get("hasSample")) { model.addAttribute("summary", cartService.getCartSummary(cart, locale));
model.addAttribute("hasSample", true);
break;
}
}
model.addAttribute("items", items);
return "imprimelibros/checkout/checkout"; // crea esta vista si quieres (tabla simple) return "imprimelibros/checkout/checkout"; // crea esta vista si quieres (tabla simple)
} }
@GetMapping("/get-address/{id}")
public String getDireccionCard(@PathVariable Long id, 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("direccion", dir);
return "imprimelibros/direcciones/direccionBillingCard :: direccionBillingCard(direccion=${direccion}, pais=${pais})";
}
} }

View File

@ -4,12 +4,17 @@ import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.security.Principal; import java.security.Principal;
import java.text.NumberFormat; import java.text.NumberFormat;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.function.BiFunction; import java.util.function.BiFunction;
import org.springframework.context.MessageSource; import org.springframework.context.MessageSource;
@ -45,10 +50,37 @@ public class Utils {
this.messageSource = messageSource; this.messageSource = messageSource;
} }
public static List<Map<String, Object>> decodeJsonList(String json) {
ObjectMapper mapper = new ObjectMapper();
try {
return mapper.readValue(json, new TypeReference<List<Map<String, Object>>>() {
});
} catch (JsonProcessingException e) {
return new ArrayList<>();
}
}
public static Map<String, Object> decodeJsonMap(String json) {
ObjectMapper mapper = new ObjectMapper();
try {
return mapper.readValue(json, new TypeReference<Map<String, Object>>() {
});
} catch (JsonProcessingException e) {
return new HashMap<>();
}
}
public static double round2(double value) {
return BigDecimal.valueOf(value)
.setScale(2, RoundingMode.HALF_UP)
.doubleValue();
}
public static boolean isCurrentUserAdmin() { public static boolean isCurrentUserAdmin() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth.getAuthorities().stream() return auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN") || a.getAuthority().equals("ROLE_SUPERADMIN")); .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")
|| a.getAuthority().equals("ROLE_SUPERADMIN"));
} }
public static Long currentUserId(Principal principal) { public static Long currentUserId(Principal principal) {
@ -69,6 +101,24 @@ public class Utils {
throw new IllegalStateException("No se pudo obtener el ID del usuario actual"); throw new IllegalStateException("No se pudo obtener el ID del usuario actual");
} }
public static User currentUser(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.getUser();
} else if (principalObj instanceof User u && u.getId() != null) {
return u;
}
}
throw new IllegalStateException("No se pudo obtener el ID del usuario actual");
}
public static String formatCurrency(BigDecimal amount, Locale locale) { public static String formatCurrency(BigDecimal amount, Locale locale) {
NumberFormat currencyFormatter = NumberFormat.getCurrencyInstance(locale); NumberFormat currencyFormatter = NumberFormat.getCurrencyInstance(locale);
return currencyFormatter.format(amount); return currencyFormatter.format(amount);
@ -320,4 +370,70 @@ public class Utils {
resumen.put("servicios", serviciosExtras); resumen.put("servicios", serviciosExtras);
return resumen; return resumen;
} }
public static String formatDateTime(LocalDateTime dateTime, Locale locale) {
if (dateTime == null) {
return "";
}
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm", locale);
return dateTime.format(formatter);
}
public static String formatDate(LocalDateTime dateTime, Locale locale) {
if (dateTime == null) {
return "";
}
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy", locale);
return dateTime.format(formatter);
}
public static String formatInstant(Instant instant, Locale locale) {
if (instant == null) {
return "";
}
ZoneId zone = zoneIdForLocale(locale);
DateTimeFormatter formatter = DateTimeFormatter
.ofPattern("dd/MM/yyyy HH:mm", locale)
.withZone(zone);
return formatter.format(instant);
}
/*********************
* Metodos auxiliares
*/
private static ZoneId zoneIdForLocale(Locale locale) {
if (locale == null || locale.getCountry().isEmpty()) {
return ZoneId.of("UTC");
}
// Buscar timezones cuyo ID termine con el country code
// Ej: ES -> Europe/Madrid
String country = locale.getCountry();
Set<String> zoneIds = ZoneId.getAvailableZoneIds();
for (String id : zoneIds) {
// TimeZone# getID() no funciona por país, pero sí el prefijo + país
if (id.endsWith("/" + country) || id.contains("/" + country)) {
return ZoneId.of(id);
}
}
// fallback por regiones comunes (manual pero muy útil)
Map<String, String> fallback = Map.of(
"ES", "Europe/Madrid",
"MX", "America/Mexico_City",
"AR", "America/Argentina/Buenos_Aires",
"US", "America/New_York",
"GB", "Europe/London",
"FR", "Europe/Paris");
if (fallback.containsKey(country)) {
return ZoneId.of(fallback.get(country));
}
return ZoneId.systemDefault(); // último fallback
}
} }

View File

@ -0,0 +1,67 @@
package com.imprimelibros.erp.common.jpa;
import com.imprimelibros.erp.users.User;
import jakarta.persistence.*;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.Instant;
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AbstractAuditedEntitySoftTs {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@CreatedDate
@Column(name = "created_at", updatable = false)
private Instant createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private Instant updatedAt;
@CreatedBy
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "created_by")
private User createdBy;
@LastModifiedBy
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "updated_by")
private User updatedBy;
@Column(name = "deleted_at")
private Instant deletedAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "deleted_by")
private User deletedBy;
// Getters/Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
public User getCreatedBy() { return createdBy; }
public void setCreatedBy(User createdBy) { this.createdBy = createdBy; }
public User getUpdatedBy() { return updatedBy; }
public void setUpdatedBy(User updatedBy) { this.updatedBy = updatedBy; }
public Instant getDeletedAt() { return deletedAt; }
public void setDeletedAt(Instant deletedAt) { this.deletedAt = deletedAt; }
public User getDeletedBy() { return deletedBy; }
public void setDeletedBy(User deletedBy) { this.deletedBy = deletedBy; }
}

View File

@ -0,0 +1,22 @@
package com.imprimelibros.erp.common.web;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Entities;
public class HtmlToXhtml {
public static String toXhtml(String html) {
if (html == null || html.isBlank()) return "";
Document doc = Jsoup.parseBodyFragment(html);
doc.outputSettings()
.syntax(Document.OutputSettings.Syntax.xml) // => <br/>
.escapeMode(Entities.EscapeMode.xhtml) // entidades XHTML
.prettyPrint(false); // no metas saltos raros
// devolvemos sólo el contenido del body (sin <html><head>…)
return doc.body().html();
}
}

View File

@ -0,0 +1,9 @@
package com.imprimelibros.erp.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}

View File

@ -30,143 +30,151 @@ import jakarta.servlet.http.HttpServletRequest;
@Configuration @Configuration
public class SecurityConfig { public class SecurityConfig {
private final DataSource dataSource; private final DataSource dataSource;
public SecurityConfig(DataSource dataSource) { public SecurityConfig(DataSource dataSource) {
this.dataSource = dataSource; this.dataSource = dataSource;
} }
// ========== Beans base ========== // ========== Beans base ==========
@Bean @Bean
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); return new BCryptPasswordEncoder();
} }
// Remember-me (tabla persistent_logins) // Remember-me (tabla persistent_logins)
@Bean @Bean
public PersistentTokenRepository persistentTokenRepository() { public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl(); JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl();
repo.setDataSource(dataSource); repo.setDataSource(dataSource);
// repo.setCreateTableOnStartup(true); // solo 1ª vez si necesitas crear la // repo.setCreateTableOnStartup(true); // solo 1ª vez si necesitas crear la
// tabla // tabla
return repo; return repo;
} }
// Provider que soporta UsernamePasswordAuthenticationToken // Provider que soporta UsernamePasswordAuthenticationToken
private static RequestMatcher pathStartsWith(String... prefixes) { private static RequestMatcher pathStartsWith(String... prefixes) {
return new RequestMatcher() { return new RequestMatcher() {
@Override @Override
public boolean matches(HttpServletRequest request) { public boolean matches(HttpServletRequest request) {
String uri = request.getRequestURI(); String uri = request.getRequestURI();
if (uri == null) if (uri == null)
return false; return false;
for (String p : prefixes) { for (String p : prefixes) {
if (uri.startsWith(p)) if (uri.startsWith(p))
return true; return true;
} }
return false; return false;
} }
}; };
} }
@Bean @Bean
public SecurityFilterChain securityFilterChain( public SecurityFilterChain securityFilterChain(
HttpSecurity http, HttpSecurity http,
@Value("${security.rememberme.key}") String keyRememberMe, @Value("${security.rememberme.key}") String keyRememberMe,
UserDetailsService userDetailsService, UserDetailsService userDetailsService,
PersistentTokenRepository tokenRepo, PersistentTokenRepository tokenRepo,
PasswordEncoder passwordEncoder, UserServiceImpl userServiceImpl) throws Exception { PasswordEncoder passwordEncoder, UserServiceImpl userServiceImpl) throws Exception {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userServiceImpl); DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userServiceImpl);
provider.setPasswordEncoder(passwordEncoder); provider.setPasswordEncoder(passwordEncoder);
http.authenticationProvider(provider); http.authenticationProvider(provider);
http http
.authenticationProvider(provider) .authenticationProvider(provider)
.sessionManagement(session -> session .sessionManagement(session -> session
//.invalidSessionUrl("/login?expired") // .invalidSessionUrl("/login?expired")
.maximumSessions(1)) .maximumSessions(1))
// Ignora CSRF para tu recurso público (sin Ant/Mvc matchers) // Ignora CSRF para tu recurso público (sin Ant/Mvc matchers)
.csrf(csrf -> csrf .csrf(csrf -> csrf
.ignoringRequestMatchers(pathStartsWith("/presupuesto/public/"))) .ignoringRequestMatchers(pathStartsWith("/presupuesto/public/"),
// ====== RequestCache: sólo navegaciones HTML reales ====== pathStartsWith("/pagos/redsys/")))
.requestCache(rc -> { // ====== RequestCache: sólo navegaciones HTML reales ======
HttpSessionRequestCache cache = new HttpSessionRequestCache(); .requestCache(rc -> {
HttpSessionRequestCache cache = new HttpSessionRequestCache();
// Navegación HTML (por tipo o por cabecera Accept) // Navegación HTML (por tipo o por cabecera Accept)
RequestMatcher htmlPage = new OrRequestMatcher( RequestMatcher htmlPage = new OrRequestMatcher(
new MediaTypeRequestMatcher(MediaType.TEXT_HTML), new MediaTypeRequestMatcher(MediaType.TEXT_HTML),
new MediaTypeRequestMatcher(MediaType.APPLICATION_XHTML_XML), new MediaTypeRequestMatcher(MediaType.APPLICATION_XHTML_XML),
new RequestHeaderRequestMatcher("Accept", "text/html")); new RequestHeaderRequestMatcher("Accept", "text/html"));
// No AJAX // No AJAX
RequestMatcher nonAjax = new NegatedRequestMatcher( RequestMatcher nonAjax = new NegatedRequestMatcher(
new RequestHeaderRequestMatcher("X-Requested-With", "XMLHttpRequest")); new RequestHeaderRequestMatcher("X-Requested-With",
"XMLHttpRequest"));
// Excluir sondas .well-known // Excluir sondas .well-known
RequestMatcher notWellKnown = new NegatedRequestMatcher(pathStartsWith("/.well-known/")); RequestMatcher notWellKnown = new NegatedRequestMatcher(
pathStartsWith("/.well-known/"));
// Excluir estáticos: comunes + tu /assets/** // Excluir estáticos: comunes + tu /assets/**
RequestMatcher notStatic = new AndRequestMatcher( RequestMatcher notStatic = new AndRequestMatcher(
new NegatedRequestMatcher(PathRequest.toStaticResources().atCommonLocations()), new NegatedRequestMatcher(PathRequest.toStaticResources()
new NegatedRequestMatcher(pathStartsWith("/assets/"))); .atCommonLocations()),
new NegatedRequestMatcher(pathStartsWith("/assets/")));
RequestMatcher cartCount = new AndRequestMatcher( RequestMatcher cartCount = new AndRequestMatcher(
new NegatedRequestMatcher(PathRequest.toStaticResources().atCommonLocations()), new NegatedRequestMatcher(PathRequest.toStaticResources()
new NegatedRequestMatcher(pathStartsWith("/cart/count"))); .atCommonLocations()),
new NegatedRequestMatcher(pathStartsWith("/cart/count")));
cache.setRequestMatcher(new AndRequestMatcher(htmlPage, nonAjax, notStatic, notWellKnown, cartCount)); cache.setRequestMatcher(new AndRequestMatcher(htmlPage, nonAjax, notStatic,
rc.requestCache(cache); notWellKnown, cartCount));
}) rc.requestCache(cache);
// ======================================================== })
// ========================================================
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
// Aquí usa patrones String (no deprecados) // Aquí usa patrones String (no deprecados)
.requestMatchers( .requestMatchers(
"/", "/",
"/login", "/login",
"/signup", "/signup",
"/verify", "/verify",
"/auth/password/**", "/auth/password/**",
"/assets/**", "/assets/**",
"/css/**", "/css/**",
"/js/**", "/js/**",
"/images/**", "/images/**",
"/public/**", "/public/**",
"/presupuesto/public/**", "/presupuesto/public/**",
"/error", "/error",
"/favicon.ico", "/favicon.ico",
"/.well-known/**", // opcional "/.well-known/**", // opcional
"/api/pdf/presupuesto/**" "/api/pdf/presupuesto/**",
).permitAll() "/pagos/redsys/**"
.requestMatchers("/users/**").hasAnyRole("SUPERADMIN", "ADMIN") )
.anyRequest().authenticated()) .permitAll()
.requestMatchers("/users/**").hasAnyRole("SUPERADMIN", "ADMIN")
.anyRequest().authenticated())
.formLogin(login -> login .formLogin(login -> login
.loginPage("/login").permitAll() .loginPage("/login").permitAll()
.loginProcessingUrl("/login") .loginProcessingUrl("/login")
.usernameParameter("username") .usernameParameter("username")
.passwordParameter("password") .passwordParameter("password")
.defaultSuccessUrl("/", false) // respeta SavedRequest (ya filtrada) .defaultSuccessUrl("/", false) // respeta SavedRequest (ya filtrada)
.failureUrl("/login?error")) .failureUrl("/login?error"))
.rememberMe(rm -> rm .rememberMe(rm -> rm
.key(keyRememberMe) .key(keyRememberMe)
.rememberMeParameter("remember-me") .rememberMeParameter("remember-me")
.rememberMeCookieName("IMPRIMELIBROS_REMEMBER") .rememberMeCookieName("IMPRIMELIBROS_REMEMBER")
.tokenValiditySeconds(60 * 60 * 24 * 2) .tokenValiditySeconds(60 * 60 * 24 * 2)
.userDetailsService(userDetailsService) .userDetailsService(userDetailsService)
.tokenRepository(tokenRepo)) .tokenRepository(tokenRepo))
.logout(logout -> logout .logout(logout -> logout
.logoutUrl("/logout") .logoutUrl("/logout")
.logoutSuccessUrl("/") .logoutSuccessUrl("/")
.invalidateHttpSession(true) .invalidateHttpSession(true)
.deleteCookies("JSESSIONID", "IMPRIMELIBROS_REMEMBER") .deleteCookies("JSESSIONID", "IMPRIMELIBROS_REMEMBER")
.permitAll()); .permitAll());
return http.build(); return http.build();
} }
} }

View File

@ -0,0 +1,24 @@
package com.imprimelibros.erp.configurationERP;
import java.util.Locale;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
@RequestMapping("/configuracion/variables-sistema")
@PreAuthorize("hasRole('SUPERADMIN')")
public class VariablesController {
@GetMapping()
public String list(Model model, Locale locale) {
return new String();
}
}

View File

@ -506,6 +506,29 @@ public class DireccionController {
} }
@GetMapping(value = "/facturacion/select2", produces = "application/json")
@ResponseBody
public Map<String, Object> getSelect2Facturacion(
@RequestParam(value = "q", required = false) String q1,
@RequestParam(value = "term", required = false) String q2,
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.getForSelectFacturacion(q1, q2, isAdmin ? null : currentUserId);
}
private boolean isOwnerOrAdmin(Authentication auth, Long ownerId) { private boolean isOwnerOrAdmin(Authentication auth, Long ownerId) {
if (auth == null) { if (auth == null) {
return false; return false;

View File

@ -38,6 +38,10 @@ public interface DireccionRepository
// find by user_id // find by user_id
List<Direccion> findByUserId(Long userId); List<Direccion> findByUserId(Long userId);
// find by user_id and direccion_facturacion = true
@Query("SELECT d FROM Direccion d WHERE (:userId IS NULL OR d.user.id = :userId) AND d.direccionFacturacion = true")
List<Direccion> findByUserIdAndDireccionFacturacion(@Param("userId") Long userId);
// find by user_id with deleted // find by user_id with deleted
@Query(value = "SELECT * FROM direcciones WHERE user_id = :userId", nativeQuery = true) @Query(value = "SELECT * FROM direcciones WHERE user_id = :userId", nativeQuery = true)
List<Direccion> findByUserIdWithDeleted(@Param("userId") Long userId); List<Direccion> findByUserIdWithDeleted(@Param("userId") Long userId);

View File

@ -77,6 +77,65 @@ public class DireccionService {
} }
} }
public Map<String, Object> getForSelectFacturacion(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<Direccion> all = repo.findByUserIdAndDireccionFacturacion(userId);
// Mapear a opciones id/text con i18n y filtrar por búsqueda si llega
List<Map<String, String>> 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<String, String> 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<String, Object> resp = new HashMap<>();
resp.put("results", options);
return resp;
} catch (Exception e) {
e.printStackTrace();
return Map.of("results", List.of());
}
}
public Optional<Direccion> findById(Long id) { public Optional<Direccion> findById(Long id) {
return repo.findById(id); return repo.findById(id);
} }

View File

@ -128,7 +128,127 @@ public class skApiClient {
}); });
} }
public Integer getMaxSolapas(Map<String, Object> requestBody, Locale locale) { public Map<String, Object> savePresupuesto(Map<String, Object> requestBody) {
return performWithRetryMap(() -> {
String url = this.skApiUrl + "api/guardar";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(authService.getToken());
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
ResponseEntity<String> response = restTemplate.exchange(
url,
HttpMethod.POST,
entity,
String.class);
ObjectMapper mapper = new ObjectMapper();
try {
Map<String, Object> responseBody = mapper.readValue(
response.getBody(),
new TypeReference<Map<String, Object>>() {
});
// Si la API devuelve "error" a nivel raíz
if (responseBody.get("error") != null) {
// Devolvemos un mapa con sólo el error para que el caller decida
return Map.of("error", responseBody.get("error"));
}
Object dataObj = responseBody.get("data");
if (dataObj instanceof Map<?, ?> dataRaw) {
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) dataRaw;
Boolean success = (Boolean) (responseBody.get("success") != null ? responseBody.get("success")
: false);
Long id = ((Integer) data.get("id")).longValue();
String iskn = (String) data.get("iskn");
// OJO: aquí mantengo tu lógica tal cual (success == null o false => OK)
// Si tu API realmente usa success=true como éxito, esto habría que invertirlo.
if (success != null && success) {
if (id != null && iskn != null) {
data.put("id", Long.valueOf(id));
data.put("iskn", iskn);
}
} else {
// Tu lógica actual: si success es true u otra cosa → error 2
return Map.of("error", 2);
}
// Devolvemos sólo la parte interesante: el data ya enriquecido
return Map.of("data", data);
}
// Si data no es un Map, devolvemos error genérico
return Map.of("error", 1);
} catch (JsonProcessingException e) {
e.printStackTrace();
return Map.of("error", 1);
}
});
}
public Long crearPedido(Map<String, Object> requestBody) {
Map<String, Object> result = performWithRetryMap(() -> {
String url = this.skApiUrl + "api/crear-pedido";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(authService.getToken());
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
ResponseEntity<String> response = restTemplate.exchange(
url,
HttpMethod.POST,
entity,
String.class);
ObjectMapper mapper = new ObjectMapper();
try {
Map<String, Object> responseBody = mapper.readValue(
response.getBody(),
new TypeReference<Map<String, Object>>() {
});
// Si la API devuelve "error" a nivel raíz
if (responseBody.get("error") != null) {
// Devolvemos un mapa con sólo el error para que el caller decida
return Map.of("error", responseBody.get("error"));
}
Boolean success = (Boolean) (responseBody.get("success") != null ? responseBody.get("success") : false);
Long id = ((Integer) responseBody.get("id")).longValue();
if (success != null && id != null && success) {
return Map.of("data", id);
} else {
// Tu lógica actual: si success es true u otra cosa → error 2
return Map.of("error", 2);
}
} catch (JsonProcessingException e) {
e.printStackTrace();
return Map.of("error", 1);
}
});
if (result.get("error") != null) {
throw new RuntimeException("Error al crear el pedido: " + result.get("error"));
}
return (Long) result.get("data");
}
public Map<String, Object> getMaxSolapas(Map<String, Object> requestBody, Locale locale) {
try { try {
String jsonResponse = performWithRetry(() -> { String jsonResponse = performWithRetry(() -> {
String url = this.skApiUrl + "api/calcular-solapas"; String url = this.skApiUrl + "api/calcular-solapas";
@ -169,7 +289,11 @@ public class skApiClient {
messageSource.getMessage("presupuesto.errores.error-interior", new Object[] { 1 }, locale)); messageSource.getMessage("presupuesto.errores.error-interior", new Object[] { 1 }, locale));
} }
return root.get("data").asInt(); Integer maxSolapas = root.get("data").asInt();
Double lomo = root.get("lomo").asDouble();
return Map.of(
"maxSolapas", maxSolapas,
"lomo", lomo);
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
// Fallback al 80% del ancho // Fallback al 80% del ancho
@ -182,7 +306,9 @@ public class skApiClient {
throw new RuntimeException("Tamaño no válido en la solicitud: " + requestBody); throw new RuntimeException("Tamaño no válido en la solicitud: " + requestBody);
else { else {
int ancho = (int) tamanio.get("ancho"); int ancho = (int) tamanio.get("ancho");
return (int) Math.floor(ancho * 0.8); // 80% del ancho return Map.of(
"maxSolapas", (int) (ancho * 0.8),
"lomo", 0.0);
} }
} }
} }
@ -238,7 +364,6 @@ public class skApiClient {
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(authService.getToken()); headers.setBearerAuth(authService.getToken());
ResponseEntity<String> response = restTemplate.exchange( ResponseEntity<String> response = restTemplate.exchange(
uri, uri,
HttpMethod.GET, HttpMethod.GET,
@ -255,10 +380,10 @@ public class skApiClient {
return Map.of("error", messageSource.getMessage("direcciones.error.noShippingCost", null, locale)); return Map.of("error", messageSource.getMessage("direcciones.error.noShippingCost", null, locale));
} else { } else {
Double total = Optional.ofNullable(responseBody.get("data")) Double total = Optional.ofNullable(responseBody.get("data"))
.filter(Number.class::isInstance) .filter(Number.class::isInstance)
.map(Number.class::cast) .map(Number.class::cast)
.map(Number::doubleValue) .map(Number::doubleValue)
.orElse(0.0); .orElse(0.0);
return Map.of("data", total); return Map.of("data", total);
} }
@ -270,6 +395,223 @@ public class skApiClient {
} }
public Map<String, Object> checkPedidoEstado(Long presupuestoId, Locale locale) {
try {
String jsonResponse = performWithRetry(() -> {
String url = this.skApiUrl + "api/estado-pedido/" + presupuestoId;
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(authService.getToken()); // token actualizado
HttpEntity<Void> entity = new HttpEntity<>(headers);
ResponseEntity<String> response = restTemplate.exchange(
url,
HttpMethod.GET,
entity,
String.class);
return response.getBody();
});
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(jsonResponse);
if (root.get("data") == null) {
throw new RuntimeException(
"Sin respuesta desde el servidor del proveedor");
}
String estado = root.get("data").asText();
return Map.of(
"estado", estado);
} catch (JsonProcessingException e) {
// Fallback al 80% del ancho
return Map.of(
"estado", null);
}
}
public Map<String, Object> getFilesTypes(Long presupuestoId, Locale locale) {
try {
Map<String, Object> result = performWithRetryMap(() -> {
String url = this.skApiUrl + "api/files-presupuesto/" + presupuestoId;
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(authService.getToken()); // token actualizado
HttpEntity<Void> entity = new HttpEntity<>(headers);
ResponseEntity<String> response = restTemplate.exchange(
url,
HttpMethod.GET,
entity,
String.class);
ObjectMapper mapper = new ObjectMapper();
try {
Map<String, Object> responseBody = mapper.readValue(
response.getBody(),
new TypeReference<Map<String, Object>>() {
});
// Si la API devuelve "error" a nivel raíz
if (responseBody.get("error") != null) {
// Devolvemos un mapa con sólo el error para que el caller decida
return Map.of("error", responseBody.get("error"));
}
Boolean hasError = (Boolean) (responseBody.get("error") == null
|| responseBody.get("error") == "null" ? false : true);
Map<String, Boolean> files = (Map<String, Boolean>) responseBody.get("data");
if (files != null && !hasError) {
return Map.of("data", files);
} else {
// Tu lógica actual: si success es true u otra cosa → error 2
return Map.of("error", 2);
}
} catch (JsonProcessingException e) {
e.printStackTrace();
return Map.of("error", 1);
}
});
if (result.get("error") != null) {
throw new RuntimeException(
messageSource.getMessage("pedido.errors.connecting-server-error", null, locale));
}
Map<String, Object> data = (Map<String, Object>) result.get("data");
return data;
} catch (RuntimeException e) {
throw new RuntimeException(
messageSource.getMessage("pedido.errors.connecting-server-error", null, locale));
}
}
public byte[] downloadFile(Long presupuestoId, String fileType, Locale locale) {
return performWithRetryBytes(() -> {
String normalized = (fileType == null) ? "" : fileType.trim().toLowerCase();
String endpoint = switch (normalized) {
case "ferro" -> "api/get-ferro/" + presupuestoId;
case "cubierta" -> "api/get-cubierta/" + presupuestoId;
case "tapa" -> "api/get-tapa/" + presupuestoId;
default -> throw new IllegalArgumentException("Tipo de fichero no soportado: " + fileType);
};
// OJO: skApiUrl debería terminar en "/" para que concatene bien
String url = this.skApiUrl + endpoint;
HttpHeaders headers = new HttpHeaders();
// Si tu CI4 requiere Bearer, mantenlo. Si NO lo requiere, puedes quitar esta
// línea.
headers.setBearerAuth(authService.getToken());
headers.setAccept(List.of(MediaType.APPLICATION_PDF, MediaType.APPLICATION_OCTET_STREAM));
try {
ResponseEntity<byte[]> response = restTemplate.exchange(
url,
HttpMethod.GET,
new HttpEntity<>(headers),
byte[].class);
if (response.getStatusCode().is2xxSuccessful()) {
return response.getBody(); // bytes del PDF
}
return null;
} catch (HttpClientErrorException.NotFound e) {
// CI4 no tiene ese fichero
return null;
}
});
}
public Boolean aceptarFerro(Long presupuestoId, Locale locale) {
String result = performWithRetry(() -> {
String url = this.skApiUrl + "api/aceptar-ferro/" + presupuestoId;
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(authService.getToken());
HttpEntity<Void> entity = new HttpEntity<>(headers);
ResponseEntity<String> response = restTemplate.exchange(
url,
HttpMethod.POST,
entity,
String.class);
try {
Map<String, Object> responseBody = new ObjectMapper().readValue(
response.getBody(),
new TypeReference<Map<String, Object>>() {
});
Boolean success = (Boolean) (responseBody.get("success") != null ? responseBody.get("success") : false);
return success.toString();
} catch (JsonProcessingException e) {
e.printStackTrace();
return "false"; // Fallback en caso de error
}
});
return Boolean.parseBoolean(result);
}
public Boolean cancelarPedido(Long pedidoId) {
String result = performWithRetry(() -> {
String url = this.skApiUrl + "api/cancelar-pedido/" + pedidoId;
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(authService.getToken());
HttpEntity<Void> entity = new HttpEntity<>(headers);
ResponseEntity<String> response = restTemplate.exchange(
url,
HttpMethod.POST,
entity,
String.class);
try {
Map<String, Object> responseBody = new ObjectMapper().readValue(
response.getBody(),
new TypeReference<Map<String, Object>>() {
});
Boolean success = (Boolean) (responseBody.get("success") != null ? responseBody.get("success") : false);
return success.toString();
} catch (JsonProcessingException e) {
e.printStackTrace();
return "false"; // Fallback en caso de error
}
});
return Boolean.parseBoolean(result);
}
/****************** /******************
* PRIVATE METHODS * PRIVATE METHODS
******************/ ******************/
@ -301,6 +643,19 @@ public class skApiClient {
} }
} }
private byte[] performWithRetryBytes(Supplier<byte[]> request) {
try {
return request.get();
} catch (HttpClientErrorException.Unauthorized e) {
authService.invalidateToken();
try {
return request.get();
} catch (HttpClientErrorException ex) {
throw new RuntimeException("La autenticación ha fallado tras renovar el token.", ex);
}
}
}
private static BigDecimal calcularMargen( private static BigDecimal calcularMargen(
BigDecimal importe, BigDecimal importeMin, BigDecimal importeMax, BigDecimal importe, BigDecimal importeMin, BigDecimal importeMax,
BigDecimal margenMax, BigDecimal margenMin) { BigDecimal margenMax, BigDecimal margenMin) {

View File

@ -0,0 +1,6 @@
package com.imprimelibros.erp.facturacion;
public enum EstadoFactura {
borrador,
validada
}

View File

@ -0,0 +1,7 @@
package com.imprimelibros.erp.facturacion;
public enum EstadoPagoFactura {
pendiente,
pagada,
cancelada
}

View File

@ -0,0 +1,271 @@
package com.imprimelibros.erp.facturacion;
import com.imprimelibros.erp.common.jpa.AbstractAuditedEntitySoftTs;
import com.imprimelibros.erp.users.User;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import org.hibernate.annotations.Formula;
@Entity
@Table(name = "facturas", uniqueConstraints = {
@UniqueConstraint(name = "uq_facturas_numero_factura", columnNames = "numero_factura")
})
public class Factura extends AbstractAuditedEntitySoftTs {
@Column(name = "pedido_id")
private Long pedidoId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "factura_rectificada_id")
private Factura facturaRectificada;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "factura_rectificativa_id")
private Factura facturaRectificativa;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "cliente_id")
private User cliente;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "serie_id")
private SerieFactura serie;
@Column(name = "numero_factura", length = 50)
private String numeroFactura;
@Enumerated(EnumType.STRING)
@Column(name = "estado", nullable = false, length = 20)
private EstadoFactura estado = EstadoFactura.borrador;
@Enumerated(EnumType.STRING)
@Column(name = "estado_pago", nullable = false, length = 20)
private EstadoPagoFactura estadoPago = EstadoPagoFactura.pendiente;
@Enumerated(EnumType.STRING)
@Column(name = "tipo_pago", nullable = false, length = 30)
private TipoPago tipoPago = TipoPago.otros;
@Column(name = "fecha_emision")
private LocalDateTime fechaEmision;
@Column(name = "base_imponible", precision = 10, scale = 2)
private BigDecimal baseImponible;
@Column(name = "iva_4", precision = 10, scale = 2)
private BigDecimal iva4;
@Column(name = "iva_21", precision = 10, scale = 2)
private BigDecimal iva21;
@Column(name = "total_factura", precision = 10, scale = 2)
private BigDecimal totalFactura;
@Column(name = "total_pagado", precision = 10, scale = 2)
private BigDecimal totalPagado = new BigDecimal("0.00");
@Lob
@Column(name = "notas")
private String notas;
@OneToMany(mappedBy = "factura", cascade = CascadeType.ALL, orphanRemoval = true)
private List<FacturaLinea> lineas = new ArrayList<>();
@OneToMany(mappedBy = "factura", cascade = CascadeType.ALL, orphanRemoval = true)
private List<FacturaPago> pagos = new ArrayList<>();
@OneToMany(mappedBy = "factura", cascade = CascadeType.ALL, orphanRemoval = true)
private List<FacturaDireccion> direcciones = new ArrayList<>();
@Formula("(select u.fullname from users u where u.id = cliente_id)")
private String clienteNombre;
// Helpers
public void addLinea(FacturaLinea linea) {
linea.setFactura(this);
this.lineas.add(linea);
}
public void removeLinea(FacturaLinea linea) {
this.lineas.remove(linea);
linea.setFactura(null);
}
public void addPago(FacturaPago pago) {
pago.setFactura(this);
this.pagos.add(pago);
}
public void removePago(FacturaPago pago) {
this.pagos.remove(pago);
pago.setFactura(null);
}
// Getters/Setters
public Long getPedidoId() {
return pedidoId;
}
public void setPedidoId(Long pedidoId) {
this.pedidoId = pedidoId;
}
public Factura getFacturaRectificada() {
return facturaRectificada;
}
public void setFacturaRectificada(Factura facturaRectificada) {
this.facturaRectificada = facturaRectificada;
}
public Factura getFacturaRectificativa() {
return facturaRectificativa;
}
public void setFacturaRectificativa(Factura facturaRectificativa) {
this.facturaRectificativa = facturaRectificativa;
}
public User getCliente() {
return cliente;
}
public void setCliente(User cliente) {
this.cliente = cliente;
}
public SerieFactura getSerie() {
return serie;
}
public void setSerie(SerieFactura serie) {
this.serie = serie;
}
public String getNumeroFactura() {
return numeroFactura;
}
public void setNumeroFactura(String numeroFactura) {
this.numeroFactura = numeroFactura;
}
public EstadoFactura getEstado() {
return estado;
}
public void setEstado(EstadoFactura estado) {
this.estado = estado;
}
public EstadoPagoFactura getEstadoPago() {
return estadoPago;
}
public void setEstadoPago(EstadoPagoFactura estadoPago) {
this.estadoPago = estadoPago;
}
public TipoPago getTipoPago() {
return tipoPago;
}
public void setTipoPago(TipoPago tipoPago) {
this.tipoPago = tipoPago;
}
public LocalDateTime getFechaEmision() {
return fechaEmision;
}
public void setFechaEmision(LocalDateTime fechaEmision) {
this.fechaEmision = fechaEmision;
}
public BigDecimal getBaseImponible() {
return baseImponible;
}
public void setBaseImponible(BigDecimal baseImponible) {
this.baseImponible = baseImponible;
}
public BigDecimal getIva4() {
return iva4;
}
public void setIva4(BigDecimal iva4) {
this.iva4 = iva4;
}
public BigDecimal getIva21() {
return iva21;
}
public void setIva21(BigDecimal iva21) {
this.iva21 = iva21;
}
public BigDecimal getTotalFactura() {
return totalFactura;
}
public void setTotalFactura(BigDecimal totalFactura) {
this.totalFactura = totalFactura;
}
public BigDecimal getTotalPagado() {
return totalPagado;
}
public void setTotalPagado(BigDecimal totalPagado) {
this.totalPagado = totalPagado;
}
public String getNotas() {
return notas;
}
public void setNotas(String notas) {
this.notas = notas;
}
public List<FacturaLinea> getLineas() {
return lineas;
}
public void setLineas(List<FacturaLinea> lineas) {
this.lineas = lineas;
}
public List<FacturaPago> getPagos() {
return pagos;
}
public void setPagos(List<FacturaPago> pagos) {
this.pagos = pagos;
}
public List<FacturaDireccion> getDirecciones() {
return direcciones;
}
public void setDirecciones(List<FacturaDireccion> direcciones) {
this.direcciones = direcciones;
}
public FacturaDireccion getDireccionFacturacion() {
return (direcciones == null || direcciones.isEmpty()) ? null : direcciones.get(0);
}
public void addDireccion(FacturaDireccion direccion) {
direccion.setFactura(this);
this.direcciones.add(direccion);
}
}

View File

@ -0,0 +1,209 @@
package com.imprimelibros.erp.facturacion;
import com.imprimelibros.erp.direcciones.Direccion.TipoIdentificacionFiscal;
import com.imprimelibros.erp.paises.Paises;
import jakarta.persistence.*;
@Entity
@Table(name = "facturas_direcciones",
indexes = {
@Index(name = "idx_facturas_direcciones_factura_id", columnList = "factura_id")
}
)
public class FacturaDireccion {
@Column(name = "id")
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "factura_id", nullable = false,
foreignKey = @ForeignKey(name = "fk_facturas_direcciones_factura"))
private Factura factura;
@Column(name = "unidades")
private Integer unidades; // MEDIUMINT UNSIGNED
@Column(name = "email", length = 255)
private String email;
@Column(name = "att", length = 150, nullable = false)
private String att;
@Column(name = "direccion", length = 255, nullable = false)
private String direccion;
@Column(name = "cp", nullable = false)
private Integer cp; // MEDIUMINT UNSIGNED
@Column(name = "ciudad", length = 100, nullable = false)
private String ciudad;
@Column(name = "provincia", length = 100, nullable = false)
private String provincia;
@Column(name = "pais_code3", length = 3, nullable = false)
private String paisCode3 = "esp";
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "pais_code3", referencedColumnName = "code3", insertable = false, updatable = false)
private Paises pais;
@Column(name = "telefono", length = 30)
private String telefono;
@Column(name = "instrucciones", length = 255)
private String instrucciones;
@Column(name = "razon_social", length = 150)
private String razonSocial;
@Enumerated(EnumType.STRING)
@Column(name = "tipo_identificacion_fiscal", length = 20, nullable = false)
private TipoIdentificacionFiscal tipoIdentificacionFiscal = TipoIdentificacionFiscal.DNI;
@Column(name = "identificacion_fiscal", length = 50)
private String identificacionFiscal;
@Column(name = "created_at", nullable = false, updatable = false)
private java.time.Instant createdAt;
// Getters / Setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Factura getFactura() {
return factura;
}
public void setFactura(Factura factura) {
this.factura = factura;
}
public Integer getUnidades() {
return unidades;
}
public void setUnidades(Integer unidades) {
this.unidades = unidades;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getAtt() {
return att;
}
public void setAtt(String att) {
this.att = att;
}
public String getDireccion() {
return direccion;
}
public void setDireccion(String direccion) {
this.direccion = direccion;
}
public Integer getCp() {
return cp;
}
public void setCp(Integer cp) {
this.cp = cp;
}
public String getCiudad() {
return ciudad;
}
public void setCiudad(String ciudad) {
this.ciudad = ciudad;
}
public String getProvincia() {
return provincia;
}
public void setProvincia(String provincia) {
this.provincia = provincia;
}
public String getPaisCode3() {
return paisCode3;
}
public void setPaisCode3(String paisCode3) {
this.paisCode3 = paisCode3;
}
public Paises getPais() {
return pais;
}
public void setPais(Paises pais) {
this.pais = pais;
}
public String getTelefono() {
return telefono;
}
public void setTelefono(String telefono) {
this.telefono = telefono;
}
public String getInstrucciones() {
return instrucciones;
}
public void setInstrucciones(String instrucciones) {
this.instrucciones = instrucciones;
}
public String getRazonSocial() {
return razonSocial;
}
public void setRazonSocial(String razonSocial) {
this.razonSocial = razonSocial;
}
public TipoIdentificacionFiscal getTipoIdentificacionFiscal() {
return tipoIdentificacionFiscal;
}
public void setTipoIdentificacionFiscal(TipoIdentificacionFiscal tipoIdentificacionFiscal) {
this.tipoIdentificacionFiscal = tipoIdentificacionFiscal;
}
public String getIdentificacionFiscal() {
return identificacionFiscal;
}
public void setIdentificacionFiscal(String identificacionFiscal) {
this.identificacionFiscal = identificacionFiscal;
}
public java.time.Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(java.time.Instant createdAt) {
this.createdAt = createdAt;
}
}

View File

@ -0,0 +1,56 @@
package com.imprimelibros.erp.facturacion;
import com.imprimelibros.erp.common.jpa.AbstractAuditedEntitySoftTs;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "facturas_lineas")
public class FacturaLinea extends AbstractAuditedEntitySoftTs {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "factura_id")
private Factura factura;
@Lob
@Column(name = "descripcion")
private String descripcion;
@Column(name = "cantidad")
private Integer cantidad;
@Column(name = "base_linea", precision = 10, scale = 2)
private BigDecimal baseLinea;
@Column(name = "iva_4_linea", precision = 10, scale = 2)
private BigDecimal iva4Linea;
@Column(name = "iva_21_linea", precision = 10, scale = 2)
private BigDecimal iva21Linea;
@Column(name = "total_linea", precision = 10, scale = 2)
private BigDecimal totalLinea;
// Getters/Setters
public Factura getFactura() { return factura; }
public void setFactura(Factura factura) { this.factura = factura; }
public String getDescripcion() { return descripcion; }
public void setDescripcion(String descripcion) { this.descripcion = descripcion; }
public Integer getCantidad() { return cantidad; }
public void setCantidad(Integer cantidad) { this.cantidad = cantidad; }
public BigDecimal getBaseLinea() { return baseLinea; }
public void setBaseLinea(BigDecimal baseLinea) { this.baseLinea = baseLinea; }
public BigDecimal getIva4Linea() { return iva4Linea; }
public void setIva4Linea(BigDecimal iva4Linea) { this.iva4Linea = iva4Linea; }
public BigDecimal getIva21Linea() { return iva21Linea; }
public void setIva21Linea(BigDecimal iva21Linea) { this.iva21Linea = iva21Linea; }
public BigDecimal getTotalLinea() { return totalLinea; }
public void setTotalLinea(BigDecimal totalLinea) { this.totalLinea = totalLinea; }
}

View File

@ -0,0 +1,46 @@
package com.imprimelibros.erp.facturacion;
import com.imprimelibros.erp.common.jpa.AbstractAuditedEntitySoftTs;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "facturas_pagos")
public class FacturaPago extends AbstractAuditedEntitySoftTs {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "factura_id")
private Factura factura;
@Enumerated(EnumType.STRING)
@Column(name = "metodo_pago", nullable = false, length = 30)
private TipoPago metodoPago = TipoPago.otros;
@Column(name = "cantidad_pagada", precision = 10, scale = 2)
private BigDecimal cantidadPagada;
@Column(name = "fecha_pago")
private LocalDateTime fechaPago;
@Lob
@Column(name = "notas")
private String notas;
// Getters/Setters
public Factura getFactura() { return factura; }
public void setFactura(Factura factura) { this.factura = factura; }
public TipoPago getMetodoPago() { return metodoPago; }
public void setMetodoPago(TipoPago metodoPago) { this.metodoPago = metodoPago; }
public BigDecimal getCantidadPagada() { return cantidadPagada; }
public void setCantidadPagada(BigDecimal cantidadPagada) { this.cantidadPagada = cantidadPagada; }
public LocalDateTime getFechaPago() { return fechaPago; }
public void setFechaPago(LocalDateTime fechaPago) { this.fechaPago = fechaPago; }
public String getNotas() { return notas; }
public void setNotas(String notas) { this.notas = notas; }
}

View File

@ -0,0 +1,34 @@
package com.imprimelibros.erp.facturacion;
import com.imprimelibros.erp.common.jpa.AbstractAuditedEntitySoftTs;
import jakarta.persistence.*;
@Entity
@Table(name = "series_facturas")
public class SerieFactura extends AbstractAuditedEntitySoftTs {
@Column(name = "nombre_serie", nullable = false, length = 100)
private String nombreSerie;
@Column(name = "prefijo", nullable = false, length = 10)
private String prefijo;
@Enumerated(EnumType.STRING)
@Column(name = "tipo", nullable = false, length = 50)
private TipoSerieFactura tipo = TipoSerieFactura.facturacion;
@Column(name = "numero_actual", nullable = false)
private Long numeroActual = 1L;
public String getNombreSerie() { return nombreSerie; }
public void setNombreSerie(String nombreSerie) { this.nombreSerie = nombreSerie; }
public String getPrefijo() { return prefijo; }
public void setPrefijo(String prefijo) { this.prefijo = prefijo; }
public TipoSerieFactura getTipo() { return tipo; }
public void setTipo(TipoSerieFactura tipo) { this.tipo = tipo; }
public Long getNumeroActual() { return numeroActual; }
public void setNumeroActual(Long numeroActual) { this.numeroActual = numeroActual; }
}

View File

@ -0,0 +1,8 @@
package com.imprimelibros.erp.facturacion;
public enum TipoPago {
tpv_tarjeta,
tpv_bizum,
transferencia,
otros
}

View File

@ -0,0 +1,5 @@
package com.imprimelibros.erp.facturacion;
public enum TipoSerieFactura {
facturacion
}

View File

@ -0,0 +1,390 @@
package com.imprimelibros.erp.facturacion.controller;
import com.imprimelibros.erp.configurationERP.VariableService;
import com.imprimelibros.erp.datatables.DataTable;
import com.imprimelibros.erp.datatables.DataTablesParser;
import com.imprimelibros.erp.datatables.DataTablesRequest;
import com.imprimelibros.erp.datatables.DataTablesResponse;
import com.imprimelibros.erp.direcciones.DireccionService;
import com.imprimelibros.erp.facturacion.EstadoFactura;
import com.imprimelibros.erp.facturacion.Factura;
import com.imprimelibros.erp.facturacion.FacturaDireccion;
import com.imprimelibros.erp.facturacion.dto.FacturaAddRequestDto;
import com.imprimelibros.erp.facturacion.dto.FacturaGuardarDto;
import com.imprimelibros.erp.facturacion.dto.FacturaLineaUpsertDto;
import com.imprimelibros.erp.facturacion.dto.FacturaPagoUpsertDto;
import com.imprimelibros.erp.facturacion.repo.FacturaRepository;
import com.imprimelibros.erp.facturacion.service.FacturacionService;
import com.imprimelibros.erp.i18n.TranslationService;
import com.imprimelibros.erp.pedidos.PedidoDireccion;
import com.imprimelibros.erp.pedidos.PedidoService;
import jakarta.persistence.EntityNotFoundException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.context.MessageSource;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@Controller
@RequestMapping("/facturas")
@PreAuthorize("hasRole('SUPERADMIN') || hasRole('ADMIN')")
public class FacturasController {
private final FacturacionService facturacionService;
private final FacturaRepository repo;
private final TranslationService translationService;
private final MessageSource messageSource;
private final PedidoService pedidoService;
private final VariableService variableService;
private final DireccionService direccionService;
public FacturasController(
FacturaRepository repo,
TranslationService translationService,
MessageSource messageSource,
PedidoService pedidoService, FacturacionService facturacionService, VariableService variableService, DireccionService direccionService) {
this.repo = repo;
this.translationService = translationService;
this.messageSource = messageSource;
this.pedidoService = pedidoService;
this.facturacionService = facturacionService;
this.direccionService = direccionService;
this.variableService = variableService;
}
@GetMapping
public String facturasList(Model model, Locale locale) {
List<String> keys = List.of(
"app.eliminar",
"app.cancelar",
"facturas.delete.title",
"facturas.delete.text",
"facturas.delete.ok.title",
"facturas.delete.ok.text");
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
return "imprimelibros/facturas/facturas-list";
}
@GetMapping("/add")
public String facturaAdd(Model model, Locale locale) {
List<String> keys = List.of(
"facturas.form.cliente.placeholder",
"facturas.add.form.validation.title",
"facturas.add.form.validation",
"facturas.error.create"
);
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
model.addAttribute("defaultSerieRectificativa", variableService.getValorEntero("serie_facturacion_rect_default"));
return "imprimelibros/facturas/facturas-add-form";
}
@PostMapping("/add")
@ResponseBody
public Map<String, Object> facturaAddPost(
Model model,
@RequestBody FacturaAddRequestDto request,
Locale locale) {
Factura nuevaFactura = facturacionService.crearNuevaFactura(
request.getUser(),
request.getSerie(),
request.getDireccion(),
request.getFactura_rectificada()
);
Map<String, Object> result = new HashMap<>();
if(nuevaFactura == null){
result.put("success", false);
result.put("message", messageSource.getMessage("facturas.error.create", null, "No se ha podido crear la factura. Revise los datos e inténtelo de nuevo.", locale));
return result;
}
else{
result.put("success", true);
result.put("facturaId", nuevaFactura.getId());
return result;
}
}
@GetMapping("/{id}")
public String facturaDetail(@PathVariable Long id, Model model, Locale locale) {
Factura factura = repo.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Factura no encontrada con ID: " + id));
List<String> keys = List.of(
"facturas.lineas.error.base",
"facturas.lineas.delete.title",
"facturas.lineas.delete.text",
"facturas.pagos.delete.title",
"facturas.pagos.delete.text",
"facturas.pagos.error.cantidad",
"facturas.pagos.error.fecha",
"app.eliminar",
"app.cancelar");
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
FacturaDireccion direccionFacturacion = factura.getDireccionFacturacion();
model.addAttribute("direccionFacturacion", direccionFacturacion);
model.addAttribute("factura", factura);
return "imprimelibros/facturas/facturas-form";
}
@PostMapping("/{id}/guardar")
public ResponseEntity<?> guardarFacturaCabeceraYDireccion(
@PathVariable Long id,
@RequestBody @Valid FacturaGuardarDto payload) {
facturacionService.guardarCabeceraYDireccionFacturacion(id, payload);
return ResponseEntity.ok(Map.of("ok", true));
}
@GetMapping("/{id}/container")
public String facturaContainer(@PathVariable Long id, Model model, Locale locale) {
Factura factura = repo.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Factura no encontrada con ID: " + id));
FacturaDireccion direccionFacturacion = factura.getDireccionFacturacion();
model.addAttribute("direccionFacturacion", direccionFacturacion);
model.addAttribute("factura", factura);
return "imprimelibros/facturas/partials/factura-container :: factura-container";
}
@PostMapping("/{id}/validar")
public ResponseEntity<?> validarFactura(@PathVariable Long id) {
Factura factura = repo.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Factura no encontrada con ID: " + id));
if (factura.getEstado() != EstadoFactura.borrador) {
return ResponseEntity.badRequest().body("Solo se pueden validar facturas en estado 'borrador'.");
}
facturacionService.validarFactura(factura.getId());
repo.save(factura);
return ResponseEntity.ok().build();
}
@PostMapping("/{id}/borrador")
public ResponseEntity<?> marcarBorrador(@PathVariable Long id) {
Factura factura = repo.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Factura no encontrada con ID: " + id));
if (factura.getEstado() != EstadoFactura.validada) {
return ResponseEntity.badRequest()
.body("Solo se pueden marcar como borrador facturas en estado 'validada'.");
}
factura.setEstado(EstadoFactura.borrador);
repo.save(factura);
return ResponseEntity.ok().build();
}
@PostMapping("/{facturaId}/lineas")
public ResponseEntity<?> createLinea(@PathVariable Long facturaId,
@Valid @RequestBody FacturaLineaUpsertDto req) {
facturacionService.createLinea(facturaId, req);
return ResponseEntity.ok(Map.of("ok", true));
}
@PostMapping("/{facturaId}/lineas/{lineaId}")
public ResponseEntity<?> updateLinea(@PathVariable Long facturaId,
@PathVariable Long lineaId,
@Valid @RequestBody FacturaLineaUpsertDto req) {
facturacionService.upsertLinea(facturaId, req);
return ResponseEntity.ok(Map.of("ok", true));
}
@PostMapping("/{facturaId}/lineas/{lineaId}/delete")
public ResponseEntity<?> deleteLinea(@PathVariable Long facturaId,
@PathVariable Long lineaId) {
facturacionService.borrarLinea(facturaId, lineaId);
return ResponseEntity.ok(Map.of("ok", true));
}
/*
* -----------------------------
* Pagos
* --------------------------------
*/
@PostMapping("/{facturaId}/pagos")
public ResponseEntity<?> createPago(
@PathVariable Long facturaId,
@Valid @RequestBody FacturaPagoUpsertDto req, Principal principal) {
facturacionService.upsertPago(facturaId, req, principal);
return ResponseEntity.ok(Map.of("ok", true));
}
@PostMapping("/{facturaId}/pagos/{pagoId}")
public ResponseEntity<?> updatePago(
@PathVariable Long facturaId,
@PathVariable Long pagoId,
@Valid @RequestBody FacturaPagoUpsertDto req,
Principal principal) {
// opcional: fuerza consistencia
req.setId(pagoId);
facturacionService.upsertPago(facturaId, req, principal);
return ResponseEntity.ok(Map.of("ok", true));
}
@PostMapping("/{facturaId}/pagos/{pagoId}/delete")
public ResponseEntity<?> deletePago(
@PathVariable Long facturaId,
@PathVariable Long pagoId, Principal principal) {
facturacionService.borrarPago(facturaId, pagoId, principal);
return ResponseEntity.ok(Map.of("ok", true));
}
@PostMapping("/{id}/notas")
public ResponseEntity<?> setNotas(
@PathVariable Long id,
@RequestBody Map<String, String> payload,
Model model,
Locale locale) {
Factura factura = repo.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Factura no encontrada con ID: " + id));
String notas = payload.get("notas");
factura.setNotas(notas);
repo.save(factura);
return ResponseEntity.ok().build();
}
// -----------------------------
// API: DataTables (server-side)
// -----------------------------
@GetMapping("/api/datatables")
@ResponseBody
public DataTablesResponse<Map<String, Object>> datatables(HttpServletRequest request, Locale locale) {
DataTablesRequest dt = DataTablesParser.from(request);
Specification<Factura> notDeleted = (root, q, cb) -> cb.isNull(root.get("deletedAt"));
long total = repo.count(notDeleted);
return DataTable
.of(repo, Factura.class, dt, List.of("clienteNombre", "numeroFactura", "estado", "estadoPago"))
.where(notDeleted)
.orderable(List.of("id", "clienteNombre", "numeroFactura", "estado", "estadoPago"))
.onlyAddedColumns()
.add("id", Factura::getId)
.add("cliente", f -> {
var c = f.getCliente();
return c == null ? null : c.getFullName(); // o getNombre(), etc.
})
.add("numero_factura", Factura::getNumeroFactura)
.add("estado", Factura::getEstado)
.add("estado_label", f -> {
String key = "facturas.estado." + f.getEstado().name().toLowerCase();
return messageSource.getMessage(key, null, f.getEstado().name(), locale);
})
.add("estado_pago", Factura::getEstadoPago)
.add("estado_pago_label", f -> {
String key = "facturas.estado-pago." + f.getEstadoPago().name().toLowerCase();
return messageSource.getMessage(key, null, f.getEstadoPago().name(), locale);
})
.add("total", Factura::getTotalFactura)
.add("fecha_emision", f -> {
LocalDateTime fecha = f.getFechaEmision();
return fecha == null ? null : fecha.format(DateTimeFormatter.ofPattern("dd/MM/yyyy"));
})
.add("actions", f -> {
if (f.getEstado() == EstadoFactura.borrador) {
return """
<div class="hstack gap-3 flex-wrap">
<button type="button"
class="btn p-0 link-success btn-view-factura fs-15"
data-id="%d">
<i class="ri-eye-line"></i>
</button>
<button type="button"
class="btn p-0 link-danger btn-delete-factura fs-15"
data-id="%d">
<i class="ri-delete-bin-5-line"></i>
</button>
</div>
""".formatted(f.getId(), f.getId());
} else {
return """
<div class="hstack gap-3 flex-wrap">
<button type="button"
class="btn p-0 link-success btn-view-factura fs-15"
data-id="%d">
<i class="ri-eye-line"></i>
</button>
</div>
""".formatted(f.getId());
}
})
.toJson(total);
}
// -----------------------------
// API: select2 Direcciones
// -----------------------------
@GetMapping("/api/get-direcciones")
@ResponseBody
public Map<String, Object> getSelect2Facturacion(
@RequestParam(value = "q", required = false) String q1,
@RequestParam(value = "term", required = false) String q2,
@RequestParam(value = "user_id", required = true) Long userId,
Authentication auth) {
return direccionService.getForSelectFacturacion(q1, q2, userId);
}
// -----------------------------
// API: select2 facturas rectificables
// -----------------------------
@GetMapping("/api/get-facturas-rectificables")
@ResponseBody
public Map<String, Object> getSelect2FacturasRectificables(
@RequestParam(value = "q", required = false) String q1,
@RequestParam(value = "term", required = false) String q2,
@RequestParam(value = "user_id", required = true) Long userId,
Authentication auth) {
try {
} catch (Exception e) {
e.printStackTrace();
return Map.of("results", List.of());
}
return facturacionService.getForSelectFacturasRectificables(q1, q2, userId);
}
}

View File

@ -0,0 +1,226 @@
package com.imprimelibros.erp.facturacion.controller;
import com.imprimelibros.erp.datatables.DataTable;
import com.imprimelibros.erp.datatables.DataTablesParser;
import com.imprimelibros.erp.datatables.DataTablesRequest;
import com.imprimelibros.erp.datatables.DataTablesResponse;
import com.imprimelibros.erp.facturacion.SerieFactura;
import com.imprimelibros.erp.facturacion.TipoSerieFactura;
import com.imprimelibros.erp.facturacion.repo.SerieFacturaRepository;
import com.imprimelibros.erp.i18n.TranslationService;
import jakarta.persistence.EntityNotFoundException;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.context.MessageSource;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@Controller
@RequestMapping("/configuracion/series-facturacion")
@PreAuthorize("hasRole('SUPERADMIN')")
public class SeriesFacturacionController {
private final SerieFacturaRepository repo;
private final TranslationService translationService;
private final MessageSource messageSource;
public SeriesFacturacionController(SerieFacturaRepository repo, TranslationService translationService,
MessageSource messageSource) {
this.repo = repo;
this.translationService = translationService;
this.messageSource = messageSource;
}
// -----------------------------
// VISTA
// -----------------------------
@GetMapping
public String listView(Model model, Locale locale) {
List<String> keys = List.of(
"series-facturacion.modal.title.add",
"series-facturacion.modal.title.edit",
"app.guardar",
"app.cancelar",
"app.eliminar",
"series-facturacion.delete.title",
"series-facturacion.delete.text",
"series-facturacion.delete.ok.title",
"series-facturacion.delete.ok.text");
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
return "imprimelibros/configuracion/series-facturas/series-facturas-list";
}
// -----------------------------
// API: DataTables (server-side)
// -----------------------------
@GetMapping("/api/datatables")
@ResponseBody
public DataTablesResponse<Map<String, Object>> datatables(HttpServletRequest request, Locale locale) {
DataTablesRequest dt = DataTablesParser.from(request);
Specification<SerieFactura> notDeleted = (root, q, cb) -> cb.isNull(root.get("deletedAt"));
long total = repo.count(notDeleted);
return DataTable
.of(repo, SerieFactura.class, dt, List.of("nombreSerie", "prefijo"))
.where(notDeleted)
.orderable(List.of("id", "nombreSerie", "prefijo", "tipo", "numeroActual"))
.onlyAddedColumns()
.add("id", SerieFactura::getId)
.add("nombre_serie", SerieFactura::getNombreSerie)
.add("prefijo", SerieFactura::getPrefijo)
.add("tipo", s -> s.getTipo() != null ? s.getTipo().name() : null)
.add("tipo_label", s -> {
if (s.getTipo() == null)
return null;
return messageSource.getMessage(
"series-facturacion.tipo." + s.getTipo().name(),
null,
s.getTipo().name(),
locale);
})
.add("numero_actual", SerieFactura::getNumeroActual)
.add("actions", s -> """
<div class="hstack gap-3 flex-wrap">
<button type="button"
class="btn p-0 link-success btn-edit-serie fs-15"
data-id="%d">
<i class="ri-edit-2-line"></i>
</button>
<button type="button"
class="btn p-0 link-danger btn-delete-serie fs-15"
data-id="%d">
<i class="ri-delete-bin-5-line"></i>
</button>
</div>
""".formatted(s.getId(), s.getId()))
.toJson(total);
}
// -----------------------------
// API: CREATE
// -----------------------------
@PostMapping(value = "/api", consumes = "application/json")
@ResponseBody
public Map<String, Object> create(@RequestBody SerieFacturaPayload payload) {
validate(payload);
SerieFactura s = new SerieFactura();
s.setNombreSerie(payload.nombre_serie.trim());
s.setPrefijo(payload.prefijo.trim());
s.setTipo(TipoSerieFactura.facturacion); // fijo
s.setNumeroActual(payload.numero_actual);
repo.save(s);
return Map.of("ok", true, "id", s.getId());
}
// -----------------------------
// API: UPDATE
// -----------------------------
@PutMapping(value = "/api/{id}", consumes = "application/json")
@ResponseBody
public Map<String, Object> update(@PathVariable Long id, @RequestBody SerieFacturaPayload payload) {
validate(payload);
SerieFactura s = repo.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Serie no encontrada: " + id));
if (s.getDeletedAt() != null) {
throw new IllegalStateException("No se puede editar una serie eliminada.");
}
s.setNombreSerie(payload.nombre_serie.trim());
s.setPrefijo(payload.prefijo.trim());
s.setTipo(TipoSerieFactura.facturacion);
s.setNumeroActual(payload.numero_actual);
repo.save(s);
return Map.of("ok", true);
}
// -----------------------------
// API: DELETE (soft)
// -----------------------------
@DeleteMapping("/api/{id}")
@ResponseBody
public ResponseEntity<?> delete(@PathVariable Long id) {
SerieFactura s = repo.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Serie no encontrada: " + id));
if (s.getDeletedAt() == null) {
s.setDeletedAt(Instant.now());
s.setDeletedBy(null); // luego lo conectamos al usuario actual
repo.save(s);
}
return ResponseEntity.ok(Map.of("ok", true));
}
// -----------------------------
// API: GET for select2
// -----------------------------
@GetMapping("/api/get-series")
@ResponseBody
public Map<String, Object> getSeriesForSelect(
@RequestParam(value = "q", required = false) String q1,
@RequestParam(value = "term", required = false) String q2,
Locale locale) {
String query = (q1 != null && !q1.isBlank()) ? q1
: (q2 != null && !q2.isBlank()) ? q2
: "";
List<Map<String, Object>> results = repo.searchForSelectSeriesFacturacion(query).stream()
.map(s -> {
Map<String, Object> m = new HashMap<>();
m.put("id", s.getId());
m.put("text", s.getNombreSerie());
return m;
})
.toList();
return Map.of("results", results);
}
// -----------------------------
// Payload + validación
// -----------------------------
public static class SerieFacturaPayload {
public String nombre_serie;
public String prefijo;
public String tipo; // lo manda UI, pero en backend lo fijamos
public Long numero_actual;
}
private void validate(SerieFacturaPayload p) {
if (p == null)
throw new IllegalArgumentException("Body requerido.");
if (p.nombre_serie == null || p.nombre_serie.trim().isBlank()) {
throw new IllegalArgumentException("nombre_serie es obligatorio.");
}
if (p.prefijo == null || p.prefijo.trim().isBlank()) {
throw new IllegalArgumentException("prefijo es obligatorio.");
}
if (p.prefijo.trim().length() > 10) {
throw new IllegalArgumentException("prefijo máximo 10 caracteres.");
}
if (p.numero_actual == null || p.numero_actual < 1) {
throw new IllegalArgumentException("numero_actual debe ser >= 1.");
}
}
}

View File

@ -0,0 +1,147 @@
package com.imprimelibros.erp.facturacion.dto;
import java.time.Instant;
import com.imprimelibros.erp.facturacion.FacturaDireccion;
import com.imprimelibros.erp.pedidos.PedidoDireccion;
public class DireccionFacturacionDto {
private String razonSocial;
private String identificacionFiscal;
private String direccion;
private String cp;
private String ciudad;
private String provincia;
private String paisKeyword;
private String telefono;
public String getRazonSocial() {
return razonSocial;
}
public void setRazonSocial(String razonSocial) {
this.razonSocial = razonSocial;
}
public String getIdentificacionFiscal() {
return identificacionFiscal;
}
public void setIdentificacionFiscal(String identificacionFiscal) {
this.identificacionFiscal = identificacionFiscal;
}
public String getDireccion() {
return direccion;
}
public void setDireccion(String direccion) {
this.direccion = direccion;
}
public String getCp() {
return cp;
}
public void setCp(String cp) {
this.cp = cp;
}
public String getCiudad() {
return ciudad;
}
public void setCiudad(String ciudad) {
this.ciudad = ciudad;
}
public String getProvincia() {
return provincia;
}
public void setProvincia(String provincia) {
this.provincia = provincia;
}
public String getPaisKeyword() {
return paisKeyword;
}
public void setPaisKeyword(String paisKeyword) {
this.paisKeyword = paisKeyword;
}
public String getTelefono() {
return telefono;
}
public void setTelefono(String telefono) {
this.telefono = telefono;
}
public FacturaDireccion toFacturaDireccion() {
FacturaDireccion fd = new FacturaDireccion();
applyTo(fd);
return fd;
}
public PedidoDireccion toPedidoDireccion() {
PedidoDireccion pd = new PedidoDireccion();
applyTo(pd);
pd.setFacturacion(true);
return pd;
}
public void applyTo(PedidoDireccion pd) {
pd.setAtt("");
pd.setRazonSocial(this.razonSocial);
pd.setIdentificacionFiscal(this.identificacionFiscal);
pd.setDireccion(this.direccion);
// CP robusto
Integer cpInt = null;
if (this.cp != null && !this.cp.isBlank()) {
try {
cpInt = Integer.valueOf(this.cp.trim());
} catch (NumberFormatException ignored) {
// si quieres, lanza IllegalArgumentException para validarlo
}
}
pd.setCp(cpInt);
pd.setCiudad(this.ciudad);
pd.setProvincia(this.provincia);
pd.setPaisCode3(this.paisKeyword);
pd.setTelefono(this.telefono);
}
public void applyTo(FacturaDireccion fd ) {
fd.setAtt("");
fd.setRazonSocial(this.razonSocial);
fd.setIdentificacionFiscal(this.identificacionFiscal);
fd.setDireccion(this.direccion);
// CP robusto
Integer cpInt = null;
if (this.cp != null && !this.cp.isBlank()) {
try {
cpInt = Integer.valueOf(this.cp.trim());
} catch (NumberFormatException ignored) {
// si quieres, lanza IllegalArgumentException para validarlo
}
}
fd.setCp(cpInt);
fd.setCiudad(this.ciudad);
fd.setProvincia(this.provincia);
fd.setPaisCode3(this.paisKeyword);
fd.setTelefono(this.telefono);
fd.setCreatedAt(Instant.now());
}
}

View File

@ -0,0 +1,36 @@
package com.imprimelibros.erp.facturacion.dto;
public class FacturaAddRequestDto {
private Long user;
private Long serie;
private Long direccion;
private Long factura_rectificada;
// getters y setters
public Long getUser() {
return user;
}
public void setUser(Long user) {
this.user = user;
}
public Long getSerie() {
return serie;
}
public void setSerie(Long serie) {
this.serie = serie;
}
public Long getDireccion() {
return direccion;
}
public void setDireccion(Long direccion) {
this.direccion = direccion;
}
public Long getFactura_rectificada() {
return factura_rectificada;
}
public void setFactura_rectificada(Long factura_rectificada) {
this.factura_rectificada = factura_rectificada;
}
}

View File

@ -0,0 +1,33 @@
package com.imprimelibros.erp.facturacion.dto;
import java.time.LocalDateTime;
public class FacturaCabeceraDto {
private Long serieId;
private Long clienteId;
private LocalDateTime fechaEmision;
public Long getSerieId() {
return serieId;
}
public void setSerieId(Long serieId) {
this.serieId = serieId;
}
public Long getClienteId() {
return clienteId;
}
public void setClienteId(Long clienteId) {
this.clienteId = clienteId;
}
public LocalDateTime getFechaEmision() {
return fechaEmision;
}
public void setFechaEmision(LocalDateTime fechaEmision) {
this.fechaEmision = fechaEmision;
}
}

View File

@ -0,0 +1,67 @@
package com.imprimelibros.erp.facturacion.dto;
import com.imprimelibros.erp.pedidos.PedidoDireccion;
import com.imprimelibros.erp.facturacion.FacturaDireccion;
import java.time.Instant;
import com.imprimelibros.erp.direcciones.Direccion.TipoIdentificacionFiscal;
public final class FacturaDireccionMapper {
private FacturaDireccionMapper() {}
public static FacturaDireccion fromPedidoDireccion(PedidoDireccion src) {
if (src == null) return null;
FacturaDireccion dst = new FacturaDireccion();
dst.setUnidades(src.getUnidades());
dst.setEmail(src.getEmail());
dst.setAtt(src.getAtt());
dst.setDireccion(src.getDireccion());
dst.setCp(src.getCp());
dst.setCiudad(src.getCiudad());
dst.setProvincia(src.getProvincia());
dst.setPaisCode3(src.getPaisCode3());
dst.setTelefono(src.getTelefono());
dst.setInstrucciones(src.getInstrucciones());
dst.setRazonSocial(src.getRazonSocial());
dst.setCreatedAt(Instant.now());
// OJO: en PedidoDireccion usas Direccion.TipoIdentificacionFiscal
// En FacturaDireccion usa el enum que hayas definido/importado.
dst.setTipoIdentificacionFiscal(
TipoIdentificacionFiscal.valueOf(src.getTipoIdentificacionFiscal().name())
);
dst.setIdentificacionFiscal(src.getIdentificacionFiscal());
return dst;
}
public static FacturaDireccion fromDireccion(com.imprimelibros.erp.direcciones.Direccion src) {
if (src == null) return null;
FacturaDireccion dst = new FacturaDireccion();
dst.setUnidades(null);
dst.setEmail(src.getUser().getUserName());
dst.setAtt(src.getAtt());
dst.setDireccion(src.getDireccion());
dst.setCp(src.getCp());
dst.setCiudad(src.getCiudad());
dst.setProvincia(src.getProvincia());
dst.setPaisCode3(src.getPais().getCode3());
dst.setTelefono(src.getTelefono());
dst.setInstrucciones(src.getInstrucciones());
dst.setRazonSocial(src.getRazonSocial());
dst.setCreatedAt(Instant.now());
dst.setTipoIdentificacionFiscal(src.getTipoIdentificacionFiscal());
dst.setIdentificacionFiscal(src.getIdentificacionFiscal());
return dst;
}
}

View File

@ -0,0 +1,24 @@
package com.imprimelibros.erp.facturacion.dto;
import jakarta.validation.Valid;
public class FacturaGuardarDto {
@Valid private FacturaCabeceraDto cabecera;
@Valid private DireccionFacturacionDto direccionFacturacion;
// getters/setters
public FacturaCabeceraDto getCabecera() {
return cabecera;
}
public void setCabecera(FacturaCabeceraDto cabecera) {
this.cabecera = cabecera;
}
public DireccionFacturacionDto getDireccionFacturacion() {
return direccionFacturacion;
}
public void setDireccionFacturacion(DireccionFacturacionDto direccionFacturacion) {
this.direccionFacturacion = direccionFacturacion;
}
}

View File

@ -0,0 +1,34 @@
package com.imprimelibros.erp.facturacion.dto;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
public class FacturaLineaUpsertDto {
// Para update puedes mandarlo, pero realmente lo sacamos del path
private Long id;
@NotNull
private String descripcion; // HTML
@NotNull
private BigDecimal base;
private BigDecimal iva4;
private BigDecimal iva21;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getDescripcion() { return descripcion; }
public void setDescripcion(String descripcion) { this.descripcion = descripcion; }
public BigDecimal getBase() { return base; }
public void setBase(BigDecimal base) { this.base = base; }
public BigDecimal getIva4() { return iva4; }
public void setIva4(BigDecimal iva4) { this.iva4 = iva4; }
public BigDecimal getIva21() { return iva21; }
public void setIva21(BigDecimal iva21) { this.iva21 = iva21; }
}

View File

@ -0,0 +1,36 @@
package com.imprimelibros.erp.facturacion.dto;
import com.imprimelibros.erp.facturacion.TipoPago;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public class FacturaPagoUpsertDto {
private Long id; // null => nuevo pago
@NotNull
private TipoPago metodoPago;
@NotNull
private BigDecimal cantidadPagada;
private LocalDateTime fechaPago;
private String notas;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public TipoPago getMetodoPago() { return metodoPago; }
public void setMetodoPago(TipoPago metodoPago) { this.metodoPago = metodoPago; }
public BigDecimal getCantidadPagada() { return cantidadPagada; }
public void setCantidadPagada(BigDecimal cantidadPagada) { this.cantidadPagada = cantidadPagada; }
public LocalDateTime getFechaPago() { return fechaPago; }
public void setFechaPago(LocalDateTime fechaPago) { this.fechaPago = fechaPago; }
public String getNotas() { return notas; }
public void setNotas(String notas) { this.notas = notas; }
}

View File

@ -0,0 +1,28 @@
package com.imprimelibros.erp.facturacion.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public class SerieFacturaForm {
@NotBlank
@Size(max = 100)
private String nombreSerie;
@NotBlank
@Size(max = 10)
private String prefijo;
@NotNull
private Long numeroActual;
public String getNombreSerie() { return nombreSerie; }
public void setNombreSerie(String nombreSerie) { this.nombreSerie = nombreSerie; }
public String getPrefijo() { return prefijo; }
public void setPrefijo(String prefijo) { this.prefijo = prefijo; }
public Long getNumeroActual() { return numeroActual; }
public void setNumeroActual(Long numeroActual) { this.numeroActual = numeroActual; }
}

View File

@ -0,0 +1,14 @@
package com.imprimelibros.erp.facturacion.repo;
import org.springframework.data.jpa.repository.JpaRepository;
import com.imprimelibros.erp.facturacion.FacturaDireccion;
import java.util.List;
import java.util.Optional;
public interface FacturaDireccionRepository extends JpaRepository<FacturaDireccion, Long> {
List<FacturaDireccion> findByFacturaId(Long facturaId);
Optional<FacturaDireccion> findFirstByFacturaIdOrderByIdAsc(Long facturaId);
}

View File

@ -0,0 +1,12 @@
package com.imprimelibros.erp.facturacion.repo;
import com.imprimelibros.erp.facturacion.FacturaLinea;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface FacturaLineaRepository extends JpaRepository<FacturaLinea, Long> {
List<FacturaLinea> findByFacturaId(Long facturaId);
Optional<FacturaLinea> findByIdAndFacturaId(Long id, Long facturaId);
}

View File

@ -0,0 +1,13 @@
package com.imprimelibros.erp.facturacion.repo;
import com.imprimelibros.erp.facturacion.FacturaPago;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface FacturaPagoRepository extends JpaRepository<FacturaPago, Long> {
List<FacturaPago> findByFacturaIdAndDeletedAtIsNullOrderByFechaPagoDescIdDesc(Long facturaId);
Optional<FacturaPago> findByIdAndFacturaIdAndDeletedAtIsNull(Long id, Long facturaId);
}

View File

@ -0,0 +1,21 @@
package com.imprimelibros.erp.facturacion.repo;
import com.imprimelibros.erp.facturacion.EstadoFactura;
import com.imprimelibros.erp.facturacion.EstadoPagoFactura;
import com.imprimelibros.erp.facturacion.Factura;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.List;
import java.util.Optional;
public interface FacturaRepository extends JpaRepository<Factura, Long>, JpaSpecificationExecutor<Factura> {
Optional<Factura> findByNumeroFactura(String numeroFactura);
Factura findByPedidoId(Long pedidoId);
List<Factura> findByClienteIdAndEstadoAndEstadoPagoAndSerieId(
Long clienteId,
EstadoFactura estado,
EstadoPagoFactura estadoPago,
Long serieId);
}

View File

@ -0,0 +1,32 @@
package com.imprimelibros.erp.facturacion.repo;
import com.imprimelibros.erp.facturacion.SerieFactura;
import com.imprimelibros.erp.facturacion.TipoSerieFactura;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
import jakarta.persistence.LockModeType;
import java.util.List;
import java.util.Optional;
public interface SerieFacturaRepository
extends JpaRepository<SerieFactura, Long>, JpaSpecificationExecutor<SerieFactura> {
Optional<SerieFactura> findByTipo(TipoSerieFactura tipo);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from SerieFactura s where s.id = :id")
Optional<SerieFactura> findByIdForUpdate(@Param("id") Long id);
List<SerieFactura> findAllByDeletedAtIsNullOrderByNombreSerieAsc();
@Query("""
select s
from SerieFactura s
where s.deletedAt is null
and (:query is null or :query = '' or lower(s.nombreSerie) like lower(concat('%', :query, '%')))
order by s.nombreSerie
""")
List<SerieFactura> searchForSelectSeriesFacturacion(@Param("query") String query);
}

View File

@ -0,0 +1,725 @@
package com.imprimelibros.erp.facturacion.service;
import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.configurationERP.VariableService;
import com.imprimelibros.erp.facturacion.*;
import com.imprimelibros.erp.facturacion.dto.DireccionFacturacionDto;
import com.imprimelibros.erp.facturacion.dto.FacturaDireccionMapper;
import com.imprimelibros.erp.facturacion.dto.FacturaGuardarDto;
import com.imprimelibros.erp.facturacion.dto.FacturaLineaUpsertDto;
import com.imprimelibros.erp.facturacion.dto.FacturaPagoUpsertDto;
import com.imprimelibros.erp.facturacion.repo.FacturaDireccionRepository;
import com.imprimelibros.erp.facturacion.repo.FacturaLineaRepository;
import com.imprimelibros.erp.facturacion.repo.FacturaPagoRepository;
import com.imprimelibros.erp.facturacion.repo.FacturaRepository;
import com.imprimelibros.erp.facturacion.repo.SerieFacturaRepository;
import com.imprimelibros.erp.pedidos.Pedido;
import com.imprimelibros.erp.pedidos.PedidoDireccion;
import com.imprimelibros.erp.pedidos.PedidoLinea;
import com.imprimelibros.erp.pedidos.PedidoLineaRepository;
import com.imprimelibros.erp.pedidos.PedidoService;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import com.imprimelibros.erp.users.User;
import com.imprimelibros.erp.users.UserService;
import com.imprimelibros.erp.direcciones.Direccion;
import com.imprimelibros.erp.direcciones.DireccionRepository;
import jakarta.persistence.EntityNotFoundException;
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
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 java.util.Locale;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.security.Principal;
import java.text.Collator;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Service
public class FacturacionService {
private final FacturaRepository facturaRepo;
private final SerieFacturaRepository serieRepo;
private final FacturaPagoRepository pagoRepo;
private final FacturaLineaRepository lineaFacturaRepository;
private final DireccionRepository direccionRepo;
private final PedidoLineaRepository pedidoLineaRepo;
private final UserService userService;
private final Utils utils;
private final MessageSource messageSource;
private final PedidoService pedidoService;
private final VariableService variableService;
public FacturacionService(
FacturaRepository facturaRepo,
FacturaLineaRepository lineaFacturaRepository,
SerieFacturaRepository serieRepo,
FacturaPagoRepository pagoRepo,
DireccionRepository direccionRepo,
PedidoLineaRepository pedidoLineaRepo,
UserService userService,
Utils utils,
MessageSource messageSource,
PedidoService pedidoService,
VariableService variableService) {
this.facturaRepo = facturaRepo;
this.lineaFacturaRepository = lineaFacturaRepository;
this.serieRepo = serieRepo;
this.pagoRepo = pagoRepo;
this.direccionRepo = direccionRepo;
this.pedidoLineaRepo = pedidoLineaRepo;
this.userService = userService;
this.utils = utils;
this.messageSource = messageSource;
this.pedidoService = pedidoService;
this.variableService = variableService;
}
public SerieFactura getDefaultSerieFactura() {
Long defaultSerieId = variableService.getValorEntero("serie_facturacion_default").longValue();
SerieFactura serie = serieRepo.findById(defaultSerieId).orElse(null);
if (serie == null) {
throw new IllegalStateException("No hay ninguna serie de facturación configurada.");
}
return serie;
}
public Factura getFactura(Long facturaId) {
return facturaRepo.findById(facturaId)
.orElseThrow(() -> new EntityNotFoundException("Factura no encontrada: " + facturaId));
}
public Long getFacturaIdFromPedidoId(Long pedidoId) {
Factura factura = facturaRepo.findByPedidoId(pedidoId);
if (factura == null) {
throw new EntityNotFoundException("Factura no encontrada para el pedido: " + pedidoId);
}
return factura.getId();
}
// -----------------------
// Nueva factura
// -----------------------
@Transactional
public Factura crearNuevaFacturaAuto(Pedido pedido, SerieFactura serie, TipoPago tipoPago, Locale locale) {
Factura factura = new Factura();
factura.setCliente(pedido.getCreatedBy());
factura.setCreatedAt(Instant.now());
factura.setUpdatedAt(Instant.now());
Boolean pedidoPendientePago = false;
List<PedidoLinea> lineasPedido = pedidoLineaRepo.findByPedidoId(pedido.getId());
for (PedidoLinea lineaPedido : lineasPedido) {
if (lineaPedido.getEstado() == PedidoLinea.Estado.pendiente_pago) {
pedidoPendientePago = true;
break;
}
}
factura.setEstado(pedidoPendientePago ? EstadoFactura.borrador : EstadoFactura.validada);
factura.setEstadoPago(pedidoPendientePago ? EstadoPagoFactura.pendiente : EstadoPagoFactura.pagada);
factura.setTipoPago(pedidoPendientePago ? TipoPago.otros : tipoPago);
factura.setPedidoId(pedido.getId());
factura.setSerie(serie);
factura.setNumeroFactura(this.getNumberFactura(serie));
factura.setFechaEmision(LocalDateTime.now());
factura.setBaseImponible(BigDecimal.valueOf(pedido.getBase()).setScale(2, RoundingMode.HALF_UP));
factura.setIva4(BigDecimal.valueOf(pedido.getIva4()).setScale(2, RoundingMode.HALF_UP));
factura.setIva21(BigDecimal.valueOf(pedido.getIva21()).setScale(2, RoundingMode.HALF_UP));
factura.setTotalFactura(BigDecimal.valueOf(pedido.getTotal()).setScale(2, RoundingMode.HALF_UP));
factura.setTotalPagado(BigDecimal.valueOf(pedido.getTotal()).setScale(2, RoundingMode.HALF_UP));
// rellenar lineas
List<FacturaLinea> lineasFactura = new ArrayList<>();
for (PedidoLinea lineaPedido : lineasPedido) {
Presupuesto p = lineaPedido.getPresupuesto();
FacturaLinea lineaFactura = new FacturaLinea();
lineaFactura.setDescripcion(this.obtenerLineaFactura(lineaPedido, locale));
lineaFactura.setCantidad(p.getSelectedTirada());
lineaFactura.setBaseLinea(p.getBaseImponible());
lineaFactura.setIva4Linea(p.getIvaImporte4());
lineaFactura.setIva21Linea(p.getIvaImporte21());
lineaFactura.setTotalLinea(p.getTotalConIva());
lineaFactura.setCreatedBy(p.getUser());
lineaFactura.setFactura(factura);
lineasFactura.add(lineaFactura);
}
if(pedido.getEnvio() > 0){
FacturaLinea lineaEnvio = new FacturaLinea();
lineaEnvio.setDescripcion(messageSource.getMessage("facturas.lineas.gastos-envio", null, "Gastos de envío", locale));
lineaEnvio.setCantidad(1);
BigDecimal baseEnvio = BigDecimal.valueOf(pedido.getEnvio()).setScale(2, RoundingMode.HALF_UP);
lineaEnvio.setBaseLinea(baseEnvio);
BigDecimal iva21Envio = baseEnvio.multiply(BigDecimal.valueOf(0.21)).setScale(2, RoundingMode.HALF_UP);
lineaEnvio.setIva21Linea(iva21Envio);
lineaEnvio.setIva4Linea(BigDecimal.ZERO);
lineaEnvio.setTotalLinea(baseEnvio.add(iva21Envio));
lineaEnvio.setCreatedBy(pedido.getCreatedBy());
lineaEnvio.setCreatedAt(Instant.now());
lineaEnvio.setFactura(factura);
lineasFactura.add(lineaEnvio);
}
PedidoDireccion direccionPedido = pedidoService.getDireccionFacturacionPedido(pedido.getId());
if(direccionPedido == null){
throw new IllegalStateException("El pedido no tiene una dirección de facturación asociada.");
}
FacturaDireccion fd = FacturaDireccionMapper.fromPedidoDireccion(direccionPedido);
factura.addDireccion(fd);
factura.setLineas(lineasFactura);
factura = facturaRepo.save(factura);
if (pedidoPendientePago) {
return factura;
}
FacturaPago pago = new FacturaPago();
pago.setMetodoPago(tipoPago);
pago.setCantidadPagada(factura.getTotalFactura());
pago.setFechaPago(LocalDateTime.now());
pago.setFactura(factura);
pago.setCreatedBy(pedido.getCreatedBy());
pago.setCreatedAt(Instant.now());
pagoRepo.save(pago);
return factura;
}
@Transactional
public Factura crearNuevaFactura(Long userId, Long serieId, Long direccionId, Long facturaRectificadaId) {
User cliente = userService.findById(userId);
if (cliente == null) {
throw new EntityNotFoundException("Cliente no encontrado: " + userId);
}
SerieFactura serie = serieRepo.findById(serieId)
.orElseThrow(() -> new EntityNotFoundException("Serie no encontrada: " + serieId));
Factura factura = new Factura();
factura.setCliente(cliente);
factura.setPedidoId(null);
factura.setSerie(serie);
factura.setEstado(EstadoFactura.borrador);
factura.setEstadoPago(EstadoPagoFactura.pendiente);
factura.setFechaEmision(LocalDateTime.now());
factura.setCreatedAt(Instant.now());
factura.setUpdatedAt(Instant.now());
factura.setNumeroFactura(null);
factura.setBaseImponible(BigDecimal.ZERO);
factura.setIva4(BigDecimal.ZERO);
factura.setIva21(BigDecimal.ZERO);
factura.setTotalFactura(BigDecimal.ZERO);
factura.setTotalPagado(BigDecimal.ZERO);
factura.setLineas(new ArrayList<>());
factura.setPagos(new ArrayList<>());
Direccion direccion = direccionRepo.findById(direccionId)
.orElseThrow(() -> new EntityNotFoundException("Dirección de factura no encontrada: " + direccionId));
FacturaDireccion facturaDireccion = FacturaDireccionMapper.fromDireccion(direccion);
factura.addDireccion(facturaDireccion);
if(facturaRectificadaId != null){
Factura facturaRectificada = facturaRepo.findById(facturaRectificadaId)
.orElseThrow(() -> new EntityNotFoundException("Factura rectificada no encontrada: " + facturaRectificadaId));
factura.setFacturaRectificativa(facturaRectificada);
facturaRectificada.setFacturaRectificada(factura);
}
return facturaRepo.save(factura);
}
// -----------------------
// Estado / Numeración
// -----------------------
@Transactional
public String getNumberFactura(SerieFactura serie) {
try {
long next = (serie.getNumeroActual() == null) ? 1L : serie.getNumeroActual();
String numeroFactura = buildNumeroFactura(serie.getPrefijo(), next);
// Incrementar contador para la siguiente
serie.setNumeroActual(next + 1);
serieRepo.save(serie);
return numeroFactura;
} catch (Exception e) {
return null;
}
}
@Transactional
public void guardarCabeceraYDireccionFacturacion(Long facturaId, FacturaGuardarDto dto) {
Factura factura = getFactura(facturaId);
// ✅ Solo editable si borrador (tu regla actual para cabecera/dirección)
if (factura.getEstado() != EstadoFactura.borrador) {
throw new IllegalStateException("Solo se puede guardar cabecera/dirección en borrador.");
}
// 1) Cabecera
if (dto.getCabecera() != null) {
var c = dto.getCabecera();
if (c.getSerieId() != null) {
SerieFactura serie = serieRepo.findById(c.getSerieId())
.orElseThrow(() -> new EntityNotFoundException("Serie no encontrada: " + c.getSerieId()));
factura.setSerie(serie);
}
if (c.getClienteId() != null) {
User cliente = userService.findById(c.getClienteId());
if(cliente == null){
throw new EntityNotFoundException("Cliente no encontrado: " + c.getClienteId());
}
factura.setCliente(cliente);
}
if (c.getFechaEmision() != null) {
factura.setFechaEmision(c.getFechaEmision());
}
}
// 2) Dirección de facturación del pedido asociado
Long pedidoId = factura.getPedidoId();
if (pedidoId != null && dto.getDireccionFacturacion() != null) {
pedidoService.upsertDireccionFacturacion(pedidoId, dto.getDireccionFacturacion());
}
upsertDireccionFacturacion(facturaId, dto.getDireccionFacturacion());
facturaRepo.save(factura);
}
@Transactional
public Factura validarFactura(Long facturaId) {
Factura factura = facturaRepo.findById(facturaId)
.orElseThrow(() -> new EntityNotFoundException("Factura no encontrada: " + facturaId));
// Puedes permitir validar desde borrador solamente (lo normal)
if (factura.getEstado() == EstadoFactura.validada) {
return factura;
}
if (factura.getFechaEmision() == null) {
factura.setFechaEmision(LocalDateTime.now());
}
if (factura.getSerie() == null) {
throw new IllegalStateException("La factura no tiene serie asignada.");
}
// Si ya tiene numero_factura, no reservamos otro
if (factura.getNumeroFactura() == null || factura.getNumeroFactura().isBlank()) {
SerieFactura serieLocked = serieRepo.findByIdForUpdate(factura.getSerie().getId())
.orElseThrow(
() -> new EntityNotFoundException("Serie no encontrada: " + factura.getSerie().getId()));
long next = (serieLocked.getNumeroActual() == null) ? 1L : serieLocked.getNumeroActual();
String numeroFactura = buildNumeroFactura(serieLocked.getPrefijo(), next);
factura.setNumeroFactura(numeroFactura);
// Incrementar contador para la siguiente
serieLocked.setNumeroActual(next + 1);
serieRepo.save(serieLocked);
}
recalcularTotales(factura);
factura.setEstado(EstadoFactura.validada);
return facturaRepo.save(factura);
}
@Transactional
public Factura volverABorrador(Long facturaId) {
Factura factura = facturaRepo.findById(facturaId)
.orElseThrow(() -> new EntityNotFoundException("Factura no encontrada: " + facturaId));
factura.setEstado(EstadoFactura.borrador);
// No tocamos numero_factura (se conserva) -> evita duplicados y auditoría rara
recalcularTotales(factura);
return facturaRepo.save(factura);
}
@Transactional
public Boolean upsertDireccionFacturacion(Long facturaId, DireccionFacturacionDto direccionData) {
try {
Factura factura = facturaRepo.findById(facturaId)
.orElseThrow(() -> new EntityNotFoundException("Factura no encontrada: " + facturaId));
// ✅ Solo editable si borrador (tu regla actual para cabecera/dirección)
if (factura.getEstado() != EstadoFactura.borrador) {
throw new IllegalStateException("Solo se puede guardar dirección en borrador.");
}
factura.getDirecciones().clear();
factura.addDireccion(direccionData.toFacturaDireccion());
facturaRepo.save(factura);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
public Map<String, Object> getForSelectFacturasRectificables(String q1, String q2, Long userId) {
try {
String search = Optional.ofNullable(q1).orElse(q2);
if (search != null) {
search = search.trim();
}
final String q = (search == null || search.isEmpty())
? null
: search.toLowerCase();
List<Factura> all = facturaRepo.findByClienteIdAndEstadoAndEstadoPagoAndSerieId(
userId,
EstadoFactura.validada,
EstadoPagoFactura.pagada,
variableService.getValorEntero("serie_facturacion_default").longValue());
// Mapear a opciones id/text con i18n y filtrar por búsqueda si llega
List<Map<String, String>> options = all.stream()
.map(f -> {
String id = f.getId().toString();
String text = f.getNumeroFactura();
Map<String, String> m = new HashMap<>();
m.put("id", id); // lo normal en Select2: id = valor que guardarás (code3)
m.put("text", text); // texto mostrado, i18n con fallback a keyword
return m;
})
.filter(opt -> {
if (q == null || q.isEmpty())
return true;
String text = opt.get("text").toLowerCase();
return text.contains(q);
})
.sorted(Comparator.comparing(m -> m.get("text"), Collator.getInstance()))
.collect(Collectors.toList());
// Estructura Select2
Map<String, Object> resp = new HashMap<>();
resp.put("results", options);
return resp;
} catch (Exception e) {
e.printStackTrace();
return Map.of("results", List.of());
}
}
private String buildNumeroFactura(String prefijo, long numero) {
String pref = (prefijo == null) ? "" : prefijo.trim();
String num = String.format("%05d", numero);
return pref.isBlank() ? num : (pref + " " + num + "/" + LocalDate.now().getYear());
}
// -----------------------
// Líneas
// -----------------------
@Transactional
public void createLinea(Long facturaId, FacturaLineaUpsertDto req) {
Factura factura = this.getFactura(facturaId);
FacturaLinea lf = new FacturaLinea();
lf.setFactura(factura);
lf.setCantidad(1);
applyRequest(lf, req);
lineaFacturaRepository.save(lf);
this.recalcularTotales(factura);
}
@Transactional
public Factura upsertLinea(Long facturaId, FacturaLineaUpsertDto dto) {
Factura factura = facturaRepo.findById(facturaId)
.orElseThrow(() -> new EntityNotFoundException("Factura no encontrada: " + facturaId));
if (factura.getEstado() != EstadoFactura.borrador) {
throw new IllegalStateException("Solo se pueden editar líneas en facturas en borrador.");
}
FacturaLinea linea;
if (dto.getId() == null) {
linea = new FacturaLinea();
linea.setFactura(factura);
factura.getLineas().add(linea);
} else {
linea = factura.getLineas().stream()
.filter(l -> dto.getId().equals(l.getId()))
.findFirst()
.orElseThrow(() -> new EntityNotFoundException("Línea no encontrada: " + dto.getId()));
}
linea.setDescripcion(dto.getDescripcion());
linea.setBaseLinea(scale2(dto.getBase()));
linea.setIva4Linea(dto.getIva4());
linea.setIva21Linea(dto.getIva21());
linea.setTotalLinea(scale2(linea.getBaseLinea()
.add(nvl(linea.getIva4Linea()))
.add(nvl(linea.getIva21Linea()))));
recalcularTotales(factura);
return facturaRepo.save(factura);
}
@Transactional
public Factura borrarLinea(Long facturaId, Long lineaId) {
Factura factura = facturaRepo.findById(facturaId)
.orElseThrow(() -> new EntityNotFoundException("Factura no encontrada: " + facturaId));
if (factura.getEstado() != EstadoFactura.borrador) {
throw new IllegalStateException("Solo se pueden borrar líneas en facturas en borrador.");
}
boolean removed = factura.getLineas().removeIf(l -> lineaId.equals(l.getId()));
if (!removed) {
throw new EntityNotFoundException("Línea no encontrada: " + lineaId);
}
recalcularTotales(factura);
return facturaRepo.save(factura);
}
// -----------------------
// Pagos
// -----------------------
@Transactional
public Factura upsertPago(Long facturaId, FacturaPagoUpsertDto dto, Principal principal) {
Factura factura = facturaRepo.findById(facturaId)
.orElseThrow(() -> new EntityNotFoundException("Factura no encontrada: " + facturaId));
// Permitir añadir pagos tanto en borrador como validada (según tu regla)
FacturaPago pago;
if (dto.getId() == null) {
pago = new FacturaPago();
pago.setFactura(factura);
pago.setCreatedBy(Utils.currentUser(principal));
pago.setCreatedAt(Instant.now());
factura.getPagos().add(pago);
} else {
pago = factura.getPagos().stream()
.filter(p -> dto.getId().equals(p.getId()))
.findFirst()
.orElseThrow(() -> new EntityNotFoundException("Pago no encontrado: " + dto.getId()));
}
pago.setMetodoPago(dto.getMetodoPago());
pago.setCantidadPagada(scale2(dto.getCantidadPagada()));
pago.setFechaPago(dto.getFechaPago() != null ? dto.getFechaPago() : LocalDateTime.now());
pago.setNotas(dto.getNotas());
pago.setUpdatedAt(Instant.now());
pago.setUpdatedBy(Utils.currentUser(principal));
// El tipo_pago de la factura: si tiene un pago, lo reflejamos (último pago
// manda)
factura.setTipoPago(dto.getMetodoPago());
recalcularTotales(factura);
return facturaRepo.save(factura);
}
@Transactional
public Factura borrarPago(Long facturaId, Long pagoId, Principal principal) {
Factura factura = facturaRepo.findById(facturaId)
.orElseThrow(() -> new EntityNotFoundException("Factura no encontrada: " + facturaId));
FacturaPago pago = factura.getPagos().stream()
.filter(p -> pagoId.equals(p.getId()))
.findFirst()
.orElseThrow(() -> new EntityNotFoundException("Pago no encontrado: " + pagoId));
// soft delete
pago.setDeletedAt(Instant.now());
pago.setDeletedBy(Utils.currentUser(principal));
recalcularTotales(factura);
return facturaRepo.save(factura);
}
// -----------------------
// Recalcular totales
// -----------------------
@Transactional
public void recalcularTotales(Long facturaId) {
Factura factura = facturaRepo.findById(facturaId)
.orElseThrow(() -> new EntityNotFoundException("Factura no encontrada: " + facturaId));
recalcularTotales(factura);
facturaRepo.save(factura);
}
private void recalcularTotales(Factura factura) {
BigDecimal base = BigDecimal.ZERO;
BigDecimal iva4 = BigDecimal.ZERO;
BigDecimal iva21 = BigDecimal.ZERO;
BigDecimal total = BigDecimal.ZERO;
if (factura.getLineas() != null) {
for (FacturaLinea l : factura.getLineas()) {
base = base.add(nvl(l.getBaseLinea()));
iva4 = iva4.add(nvl(l.getIva4Linea()));
iva21 = iva21.add(nvl(l.getIva21Linea()));
total = total.add(nvl(l.getTotalLinea()));
}
}
factura.setBaseImponible(scale2(base));
factura.setIva4(scale2(iva4));
factura.setIva21(scale2(iva21));
factura.setTotalFactura(scale2(total));
// total_pagado
BigDecimal pagado = BigDecimal.ZERO;
if (factura.getPagos() != null) {
for (FacturaPago p : factura.getPagos()) {
if (p.getDeletedAt() != null)
continue;
pagado = pagado.add(nvl(p.getCantidadPagada()));
}
}
factura.setTotalPagado(scale2(pagado));
// estado_pago
// - cancelada: si la factura está marcada como cancelada manualmente (aquí NO
// lo hacemos automático)
// - pagada: si total_pagado >= total_factura y total_factura > 0
// - pendiente: resto
if (factura.getEstadoPago() == EstadoPagoFactura.cancelada) {
return;
}
BigDecimal totalFactura = nvl(factura.getTotalFactura());
if (totalFactura.compareTo(BigDecimal.ZERO) > 0 &&
factura.getTotalPagado().compareTo(totalFactura) >= 0) {
factura.setEstadoPago(EstadoPagoFactura.pagada);
} else {
factura.setEstadoPago(EstadoPagoFactura.pendiente);
}
}
private static BigDecimal nvl(BigDecimal v) {
return v == null ? BigDecimal.ZERO : v;
}
private static BigDecimal scale2(BigDecimal v) {
return (v == null ? BigDecimal.ZERO : v).setScale(2, RoundingMode.HALF_UP);
}
private String obtenerLineaFactura(PedidoLinea lineaPedido, Locale locale) {
Map<String, Object> specs = utils.getTextoPresupuesto(lineaPedido.getPresupuesto(), locale);
StringBuilder html = new StringBuilder();
html.append("<div class=\"specs-wrapper align-with-text \">")
.append("<div class=\"specs\">");
if (specs == null) {
return "<div></div>";
}
// 1) Líneas del presupuesto (HTML)
Object lineasObj = specs.get("lineas");
if (lineasObj instanceof List<?> lineasList) {
for (Object o : lineasList) {
if (!(o instanceof Map<?, ?> m))
continue;
Object descObj = m.get("descripcion");
String descripcionHtml = descObj != null ? descObj.toString() : "";
if (descripcionHtml.isBlank())
continue;
html.append("<div class=\"spec-row mb-1\">")
.append("<span class=\"spec-label\">")
.append(descripcionHtml) // OJO: esto es HTML (como th:utext)
.append("</span>")
.append("</div>");
}
}
// 2) Servicios adicionales (texto)
Object serviciosObj = specs.get("servicios");
String servicios = (serviciosObj != null) ? serviciosObj.toString().trim() : "";
if (!servicios.isBlank()) {
String label = messageSource.getMessage("pdf.servicios-adicionales", null, "Servicios adicionales", locale);
html.append("<div class=\"spec-row mb-1\">")
.append("<span>").append(escapeHtml(label)).append("</span>")
.append("<span class=\"spec-label\">").append(escapeHtml(servicios)).append("</span>")
.append("</div>");
}
// 3) Datos de maquetación (HTML)
Object datosMaqObj = specs.get("datosMaquetacion");
if (datosMaqObj != null && !datosMaqObj.toString().isBlank()) {
String label = messageSource.getMessage("pdf.datos-maquetacion", null, "Datos de maquetación:", locale);
html.append("<div class=\"spec-row mb-1\">")
.append("<span>").append(escapeHtml(label)).append("</span>")
.append("<span class=\"spec-label\">")
.append(datosMaqObj) // HTML (como th:utext)
.append("</span>")
.append("</div>");
}
// 4) Datos de marcapáginas (HTML)
Object datosMarcaObj = specs.get("datosMarcapaginas");
if (datosMarcaObj != null && !datosMarcaObj.toString().isBlank()) {
String label = messageSource.getMessage("pdf.datos-marcapaginas", null, "Datos de marcapáginas:", locale);
html.append("<div class=\"spec-row mb-1\">")
.append("<span>").append(escapeHtml(label)).append("</span>")
.append("<span class=\"spec-label\">")
.append(datosMarcaObj) // HTML (como th:utext)
.append("</span>")
.append("</div>");
}
html.append("</div></div>");
return html.toString();
}
/**
* Escape mínimo para texto plano (equivalente a th:text).
* No lo uses para fragmentos que ya son HTML (th:utext).
*/
private static String escapeHtml(String s) {
if (s == null)
return "";
return s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
private void applyRequest(FacturaLinea lf, FacturaLineaUpsertDto req) {
// HTML
lf.setDescripcion(req.getDescripcion() == null ? "" : req.getDescripcion());
BigDecimal base = nvl(req.getBase());
BigDecimal iva4 = nvl(req.getIva4());
BigDecimal iva21 = nvl(req.getIva21());
lf.setBaseLinea(base);
lf.setIva4Linea(iva4);
lf.setIva21Linea(iva21);
// total de línea (por ahora)
lf.setTotalLinea(base.add(iva4).add(iva21));
}
}

View File

@ -69,4 +69,18 @@ public class PaisesService {
} }
} }
public String getPaisNombrePorCode3(String code3, Locale locale) {
if (code3 == null || code3.isEmpty()) {
return "";
}
Optional<Paises> opt = repo.findByCode3(code3);
if (opt.isPresent()) {
Paises pais = opt.get();
String key = pais.getKeyword();
return messageSource.getMessage("paises." + key, null, key, locale);
} else {
return "";
}
}
} }

View File

@ -0,0 +1,332 @@
package com.imprimelibros.erp.payments;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import org.springframework.context.MessageSource;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.ResponseBody;
import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.datatables.DataTable;
import com.imprimelibros.erp.datatables.DataTablesParser;
import com.imprimelibros.erp.datatables.DataTablesRequest;
import com.imprimelibros.erp.datatables.DataTablesResponse;
import com.imprimelibros.erp.i18n.TranslationService;
import com.imprimelibros.erp.payments.model.Payment;
import com.imprimelibros.erp.payments.model.PaymentTransaction;
import com.imprimelibros.erp.payments.model.PaymentTransactionStatus;
import com.imprimelibros.erp.payments.model.PaymentTransactionType;
import com.imprimelibros.erp.payments.repo.PaymentTransactionRepository;
import com.imprimelibros.erp.users.User;
import com.imprimelibros.erp.users.UserDao;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.PostMapping;
@Controller
@RequestMapping("/pagos")
@PreAuthorize("hasRole('SUPERADMIN')")
public class PaymentController {
protected final PaymentService paymentService;
protected final MessageSource messageSource;
protected final TranslationService translationService;
protected final PaymentTransactionRepository repoPaymentTransaction;
protected final UserDao repoUser;
public PaymentController(PaymentTransactionRepository repoPaymentTransaction, UserDao repoUser,
MessageSource messageSource, TranslationService translationService, PaymentService paymentService) {
this.repoPaymentTransaction = repoPaymentTransaction;
this.repoUser = repoUser;
this.messageSource = messageSource;
this.translationService = translationService;
this.paymentService = paymentService;
}
@GetMapping()
public String index(Model model, Locale locale) {
List<String> keys = List.of(
"app.cancelar",
"app.aceptar",
"pagos.refund.title",
"pagos.refund.text",
"pagos.refund.success",
"pagos.refund.error.general",
"pagos.refund.error.invalid-number",
"pagos.transferencia.finalizar.title",
"pagos.transferencia.finalizar.text",
"pagos.transferencia.finalizar.success");
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
return "imprimelibros/pagos/gestion-pagos";
}
@GetMapping(value = "datatable/redsys", produces = "application/json")
@ResponseBody
public DataTablesResponse<Map<String, Object>> getDatatableRedsys(HttpServletRequest request, Locale locale) {
DataTablesRequest dt = DataTablesParser.from(request);
List<String> searchable = List.of(
"payment.gatewayOrderId",
"payment.orderId"
// "client" no, porque lo calculas a posteriori
);
// Campos ordenables
List<String> orderable = List.of(
"payment.gatewayOrderId",
"payment.orderId",
"amountCents",
"payment.amountRefundedCents",
"createdAt");
Specification<PaymentTransaction> base = Specification.allOf(
(root, query, cb) -> cb.equal(root.get("status"), PaymentTransactionStatus.succeeded));
base = base.and((root, query, cb) -> cb.equal(root.get("type"), PaymentTransactionType.CAPTURE));
base = base.and((root, query, cb) -> cb.notEqual(root.join("payment").get("gateway"), "bank_transfer"));
String clientSearch = dt.getColumnSearch("client");
// 2) Si hay filtro, traducirlo a userIds y añadirlo al Specification
if (clientSearch != null) {
List<Long> userIds = repoUser.findIdsByFullNameLike(clientSearch.trim());
if (userIds.isEmpty()) {
// Ningún usuario coincide → forzamos 0 resultados
base = base.and((root, query, cb) -> cb.disjunction());
} else {
base = base.and((root, query, cb) -> root.join("payment").get("userId").in(userIds));
}
}
Long total = repoPaymentTransaction.count(base);
return DataTable
.of(repoPaymentTransaction, PaymentTransaction.class, dt, searchable)
.orderable(orderable)
.add("created_at", pago -> Utils.formatDateTime(pago.getCreatedAt(), locale))
.add("client", pago -> {
if (pago.getPayment() != null && pago.getPayment().getUserId() != null) {
Payment payment = pago.getPayment();
if (payment.getUserId() != null) {
Optional<User> user = repoUser.findById(payment.getUserId().longValue());
return user.map(User::getFullName).orElse("");
}
}
return "";
})
.add("gateway_order_id", pago -> {
if (pago.getPayment() != null) {
return pago.getPayment().getGatewayOrderId();
} else {
return "";
}
})
.add("orderId", pago -> {
if (pago.getPayment() != null && pago.getPayment().getOrderId() != null) {
return pago.getPayment().getOrderId().toString();
} else {
return "";
}
})
.add("amount_cents", pago -> Utils.formatCurrency(pago.getAmountCents() / 100.0, locale))
.add("amount_cents_refund", pago -> {
Payment payment = pago.getPayment();
if (payment != null) {
return Utils.formatCurrency(payment.getAmountRefundedCents() / 100.0, locale);
}
return "";
})
.add("actions", pago -> {
Payment p = pago.getPayment();
if (p != null) {
if (pago.getAmountCents() - p.getAmountRefundedCents() > 0) {
return "<span class=\'badge bg-secondary btn-refund-payment \' data-dsOrderId=\'"
+ p.getGatewayOrderId()
+ "\' data-transactionId=\'" + pago.getPayment().getId()
+ "\' data-amount=\'" + (pago.getAmountCents() - p.getAmountRefundedCents())
+ "\' style=\'cursor: pointer;\'>"
+ messageSource.getMessage("pagos.table.devuelto", null, locale) + "</span>";
}
return "";
} else {
return "";
}
})
.where(base)
.toJson(total);
}
@GetMapping(value = "datatable/transferencias", produces = "application/json")
@ResponseBody
public DataTablesResponse<Map<String, Object>> getDatatableTransferencias(HttpServletRequest request,
Locale locale) {
DataTablesRequest dt = DataTablesParser.from(request);
List<String> searchable = List.of(
// "client" no, porque lo calculas a posteriori
);
// Campos ordenables
List<String> orderable = List.of(
"transferId",
"status",
"amountCents",
"payment.amountRefundedCents",
"createdAt", "updatedAt");
Specification<PaymentTransaction> base = (root, query, cb) -> cb.or(
cb.equal(root.get("status"), PaymentTransactionStatus.pending),
cb.equal(root.get("status"), PaymentTransactionStatus.succeeded));
base = base.and((root, query, cb) -> cb.equal(root.get("type"), PaymentTransactionType.CAPTURE));
base = base.and((root, query, cb) -> cb.equal(root.get("payment").get("gateway"), "bank_transfer"));
String clientSearch = dt.getColumnSearch("client");
// 2) Si hay filtro, traducirlo a userIds y añadirlo al Specification
if (clientSearch != null) {
List<Long> userIds = repoUser.findIdsByFullNameLike(clientSearch.trim());
if (userIds.isEmpty()) {
// Ningún usuario coincide → forzamos 0 resultados
base = base.and((root, query, cb) -> cb.disjunction());
} else {
base = base.and((root, query, cb) -> root.join("payment").get("userId").in(userIds));
}
}
Long total = repoPaymentTransaction.count(base);
return DataTable
.of(repoPaymentTransaction, PaymentTransaction.class, dt, searchable)
.orderable(orderable)
.add("created_at", pago -> Utils.formatDateTime(pago.getCreatedAt(), locale))
.add("processed_at", pago -> Utils.formatDateTime(pago.getProcessedAt(), locale))
.add("client", pago -> {
if (pago.getPayment() != null && pago.getPayment().getUserId() != null) {
Payment payment = pago.getPayment();
if (payment.getUserId() != null) {
Optional<User> user = repoUser.findById(payment.getUserId().longValue());
return user.map(User::getFullName).orElse("");
}
}
return "";
})
.add("transfer_id", pago -> {
if (pago.getPayment() != null) {
Long pedido = pago.getPayment().getOrderId();
if (pedido != null) {
return "TRANSF-" + pedido;
}
}
return "";
})
.add("order_id", pago -> {
if (pago.getStatus() != PaymentTransactionStatus.pending) {
if (pago.getPayment() != null && pago.getPayment().getOrderId() != null) {
return pago.getPayment().getOrderId().toString();
} else {
return "";
}
}
return messageSource.getMessage("pagos.transferencia.no-pedido", null, "Pendiente", locale);
}).add("amount_cents", pago -> Utils.formatCurrency(pago.getAmountCents() / 100.0, locale))
.add("amount_cents_refund", pago ->
{
Payment payment = pago.getPayment();
if (payment != null) {
return Utils.formatCurrency(payment.getAmountRefundedCents() / 100.0, locale);
}
return "";
}).add("status", pago -> {
switch (pago.getStatus()) {
case PaymentTransactionStatus.pending:
return messageSource.getMessage("pagos.table.estado.pending", null, "Pendiente", locale);
case PaymentTransactionStatus.succeeded:
return messageSource.getMessage("pagos.table.estado.succeeded", null, "Completada", locale);
case PaymentTransactionStatus.failed:
return messageSource.getMessage("pagos.table.estado.failed", null, "Fallido", locale);
default:
return pago.getStatus().name();
}
}).add("actions", pago -> {
Payment p = pago.getPayment();
if (p != null) {
String actions = "";
if (pago.getStatus() != PaymentTransactionStatus.succeeded) {
actions += "<span class=\'badge bg-success btn-mark-as-completed \' data-paymentId=\'"
+ p.getId()
+ "\' data-transactionId=\'" + pago.getPayment().getId()
+ "\' style=\'cursor: pointer;\'>"
+ messageSource.getMessage("pagos.table.finalizar", null, locale) + "</span> ";
}
if ((pago.getAmountCents() - p.getAmountRefundedCents() > 0)
&& pago.getStatus() == PaymentTransactionStatus.succeeded) {
actions += "<span class=\'badge bg-secondary btn-transfer-refund \' data-dsOrderId=\'"
+ p.getGatewayOrderId()
+ "\' data-transactionId=\'" + pago.getPayment().getId()
+ "\' data-amount=\'" + (pago.getAmountCents() - p.getAmountRefundedCents())
+ "\' style=\'cursor: pointer;\'>"
+ messageSource.getMessage("pagos.table.devuelto", null, locale) + "</span>";
}
return actions;
} else {
return "";
}
}).where(base).toJson(total);
}
@PostMapping(value = "/transfer/completed/{id}", produces = "application/json")
public ResponseEntity<Map<String, Object>> markTransferAsCaptured(@PathVariable Long id, Locale locale) {
Map<String, Object> response;
try {
paymentService.markBankTransferAsCaptured(id, locale);
response = Map.of("success", true);
return ResponseEntity.ok(response);
} catch (Exception e) {
e.printStackTrace();
response = Map.of("success", false);
response.put("error", e.getMessage());
return ResponseEntity.badRequest().body(response);
}
}
@PostMapping(value = "/transfer/refund/{id}", produces = "application/json")
public ResponseEntity<Map<String, Object>> refundTransfer(@PathVariable Long id,
@RequestParam("amountCents") Long amountCents) {
Map<String, Object> response;
try {
paymentService.refundBankTransfer(id, amountCents);
response = Map.of("success", true);
return ResponseEntity.ok(response);
} catch (Exception e) {
e.printStackTrace();
response = Map.of("success", false);
response.put("error", e.getMessage());
return ResponseEntity.badRequest().body(response);
}
}
}

View File

@ -0,0 +1,574 @@
package com.imprimelibros.erp.payments;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imprimelibros.erp.cart.Cart;
import com.imprimelibros.erp.cart.CartService;
import com.imprimelibros.erp.facturacion.SerieFactura;
import com.imprimelibros.erp.facturacion.TipoPago;
import com.imprimelibros.erp.facturacion.service.FacturacionService;
import com.imprimelibros.erp.payments.model.*;
import com.imprimelibros.erp.payments.repo.PaymentRepository;
import com.imprimelibros.erp.payments.repo.PaymentTransactionRepository;
import com.imprimelibros.erp.payments.repo.RefundRepository;
import com.imprimelibros.erp.redsys.RedsysService;
import com.imprimelibros.erp.redsys.RedsysService.FormPayload;
import com.imprimelibros.erp.redsys.RedsysService.RedsysNotification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.imprimelibros.erp.payments.repo.WebhookEventRepository;
import com.imprimelibros.erp.pedidos.Pedido;
import com.imprimelibros.erp.pedidos.PedidoService;
import java.time.LocalDateTime;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
@Service
public class PaymentService {
private final PaymentRepository payRepo;
private final PaymentTransactionRepository txRepo;
private final RefundRepository refundRepo;
private final RedsysService redsysService;
private final WebhookEventRepository webhookEventRepo;
private final ObjectMapper om = new ObjectMapper();
private final CartService cartService;
private final PedidoService pedidoService;
private final FacturacionService facturacionService;
public PaymentService(PaymentRepository payRepo,
PaymentTransactionRepository txRepo,
RefundRepository refundRepo,
RedsysService redsysService,
WebhookEventRepository webhookEventRepo,
CartService cartService,
PedidoService pedidoService,
FacturacionService facturacionService) {
this.payRepo = payRepo;
this.txRepo = txRepo;
this.refundRepo = refundRepo;
this.redsysService = redsysService;
this.webhookEventRepo = webhookEventRepo;
this.cartService = cartService;
this.pedidoService = pedidoService;
this.facturacionService = facturacionService;
}
public Payment findFailedPaymentByOrderId(Long orderId) {
return payRepo.findFirstByOrderIdAndStatusOrderByIdDesc(orderId, PaymentStatus.failed)
.orElse(null);
}
public Map<String, Long> getPaymentTransactionData(Long paymentId) {
PaymentTransaction tx = txRepo.findByPaymentIdAndType(
paymentId,
PaymentTransactionType.CAPTURE)
.orElse(null);
if (tx == null) {
return null;
}
String resp_payload = tx.getResponsePayload();
try {
ObjectMapper om = new ObjectMapper();
var node = om.readTree(resp_payload);
Long cartId = null;
Long dirFactId = null;
if (node.has("Ds_MerchantData")) {
// format: "Ds_MerchantData": "{&#34;dirFactId&#34;:3,&#34;cartId&#34;:90}"
String merchantData = node.get("Ds_MerchantData").asText();
merchantData = merchantData.replace("&#34;", "\"");
var mdNode = om.readTree(merchantData);
if (mdNode.has("cartId")) {
cartId = mdNode.get("cartId").asLong();
}
if (mdNode.has("dirFactId")) {
dirFactId = mdNode.get("dirFactId").asLong();
}
}
return Map.of(
"cartId", cartId,
"dirFactId", dirFactId);
} catch (Exception e) {
return null;
}
}
/**
* Crea el Payment en BD y construye el formulario de Redsys usando la API
* oficial (ApiMacSha256).
*/
@Transactional
public FormPayload createRedsysPayment(Long cartId, Long dirFactId, Long amountCents, String currency, String method, Long orderId)
throws Exception {
Payment p = new Payment();
p.setOrderId(orderId);
Cart cart = this.cartService.findById(cartId);
if (cart != null && cart.getUserId() != null) {
p.setUserId(cart.getUserId());
this.cartService.lockCartById(cartId);
}
p.setCurrency(currency);
p.setAmountTotalCents(amountCents);
p.setGateway("redsys");
p.setStatus(PaymentStatus.requires_payment_method);
p = payRepo.saveAndFlush(p);
long now = System.currentTimeMillis();
String dsOrder = String.format("%012d", now % 1_000_000_000_000L);
p.setGatewayOrderId(dsOrder);
payRepo.save(p);
RedsysService.PaymentRequest req = new RedsysService.PaymentRequest(dsOrder, amountCents,
"Compra en Imprimelibros", cartId, dirFactId);
if ("bizum".equalsIgnoreCase(method)) {
return redsysService.buildRedirectFormBizum(req);
} else {
return redsysService.buildRedirectForm(req);
}
}
@Transactional
public void handleRedsysNotification(String dsSignature, String dsMerchantParameters, Locale locale)
throws Exception {
// 0) Intentamos parsear la notificación. Si falla, registramos el webhook crudo
// y salimos.
RedsysNotification notif;
try {
notif = redsysService.validateAndParseNotification(dsSignature, dsMerchantParameters);
} catch (Exception ex) {
WebhookEvent e = new WebhookEvent();
e.setProvider("redsys");
e.setEventType("payment_notification_parse_error");
e.setEventId("PARSE_ERROR_" + System.currentTimeMillis());
e.setSignature(dsSignature);
e.setPayload(dsMerchantParameters);
e.setProcessed(false);
e.setAttempts(1);
e.setLastError("Error parsing/validating Redsys notification: " + ex.getMessage());
webhookEventRepo.save(e);
// IMPORTANTE: NO re-lanzamos la excepción
// Simplemente salimos. Así se hace commit de este insert.
return;
}
// 1) A partir de aquí, el parseo ha ido bien y tenemos notif.order,
// notif.amountCents, etc.
String provider = "redsys";
String eventType = "payment_notification";
String eventId = notif.order;
WebhookEvent ev = webhookEventRepo
.findByProviderAndEventId(provider, eventId)
.orElseGet(() -> {
WebhookEvent e = new WebhookEvent();
e.setProvider(provider);
e.setEventType(eventType);
e.setEventId(eventId);
e.setSignature(dsSignature);
try {
e.setPayload(om.writeValueAsString(notif.raw));
} catch (Exception ex) {
e.setPayload(dsMerchantParameters);
}
e.setProcessed(false);
e.setAttempts(0);
return webhookEventRepo.save(e);
});
if (Boolean.TRUE.equals(ev.getProcessed())) {
return;
}
Integer attempts = ev.getAttempts() == null ? 0 : ev.getAttempts();
ev.setAttempts(attempts + 1);
ev.setLastError(null);
webhookEventRepo.save(ev);
try {
Payment p = payRepo.findByGatewayAndGatewayOrderId("redsys", notif.order)
.orElseThrow(() -> new IllegalStateException("Payment no encontrado para Ds_Order " + notif.order));
if (!Objects.equals(p.getAmountTotalCents(), notif.amountCents)) {
throw new IllegalStateException("Importe inesperado: esperado=" +
p.getAmountTotalCents() + " recibido=" + notif.amountCents);
}
if (p.getStatus() == PaymentStatus.captured
|| p.getStatus() == PaymentStatus.partially_refunded
|| p.getStatus() == PaymentStatus.refunded) {
ev.setProcessed(true);
ev.setProcessedAt(LocalDateTime.now());
webhookEventRepo.save(ev);
return;
}
boolean authorized = isRedsysAuthorized(notif);
PaymentTransaction tx = new PaymentTransaction();
tx.setPayment(p);
tx.setType(PaymentTransactionType.CAPTURE);
tx.setCurrency(p.getCurrency()); // "EUR"
tx.setAmountCents(notif.amountCents);
tx.setStatus(authorized
? PaymentTransactionStatus.succeeded
: PaymentTransactionStatus.failed);
String gatewayTxId = null;
// 1) Si es Bizum y tenemos Ds_Bizum_IdOper, úsalo como ID único
if (notif.isBizum()
&& notif.bizumIdOper != null
&& !notif.bizumIdOper.isBlank()) {
gatewayTxId = notif.bizumIdOper.trim();
// 2) Si no es Bizum, intenta usar Ds_AuthorisationCode
} else if (notif.authorisationCode != null) {
String trimmed = notif.authorisationCode.trim();
// Redsys suele mandar "000000" para Bizum; por si acaso también lo filtramos
if (!trimmed.isEmpty() && !"000000".equals(trimmed)) {
gatewayTxId = trimmed;
}
}
// MySQL permite múltiples NULL en un índice UNIQUE, así que es seguro.
tx.setGatewayTransactionId(gatewayTxId);
tx.setGatewayResponseCode(notif.response);
tx.setResponsePayload(om.writeValueAsString(notif.raw));
tx.setProcessedAt(LocalDateTime.now());
txRepo.save(tx);
if (authorized) {
if (notif.isBizum()) {
p.setAuthorizationCode(null); // o "000000" si te interesa mostrarlo
} else if (notif.authorisationCode != null
&& !"000000".equals(notif.authorisationCode.trim())
&& !notif.authorisationCode.isBlank()) {
p.setAuthorizationCode(notif.authorisationCode.trim());
}
p.setStatus(PaymentStatus.captured);
p.setAmountCapturedCents(p.getAmountCapturedCents() + notif.amountCents);
p.setAuthorizedAt(LocalDateTime.now());
p.setCapturedAt(LocalDateTime.now());
pedidoService.setOrderAsPaid(p.getOrderId());
Pedido pedido = pedidoService.getPedidoById(p.getOrderId());
SerieFactura serie = facturacionService.getDefaultSerieFactura();
facturacionService.crearNuevaFacturaAuto(pedido, serie, notif.isBizum() ? TipoPago.tpv_bizum : TipoPago.tpv_tarjeta, locale);
} else {
p.setStatus(PaymentStatus.failed);
p.setFailedAt(LocalDateTime.now());
pedidoService.markPedidoAsPaymentDenied(p.getOrderId());
}
payRepo.save(p);
if (!authorized) {
ev.setLastError("Payment declined (Ds_Response=" + notif.response + ")");
}
ev.setProcessed(true);
ev.setProcessedAt(LocalDateTime.now());
webhookEventRepo.save(ev);
} catch (Exception e) {
ev.setProcessed(false);
ev.setLastError(e.getMessage());
ev.setProcessedAt(null);
webhookEventRepo.save(ev);
throw e; // aquí sí, porque queremos que si falla lógica de negocio el caller se entere
}
}
// ---- refundViaRedsys
// ----
@Transactional
public void refundViaRedsys(Long paymentId, long amountCents, String idempotencyKey) {
Payment p = payRepo.findById(paymentId)
.orElseThrow(() -> new IllegalArgumentException("Payment no encontrado"));
if (amountCents <= 0)
throw new IllegalArgumentException("Importe inválido");
long maxRefundable = p.getAmountCapturedCents() - p.getAmountRefundedCents();
if (amountCents > maxRefundable)
throw new IllegalStateException("Importe de devolución supera lo capturado");
txRepo.findByIdempotencyKey(idempotencyKey)
.ifPresent(t -> {
throw new IllegalStateException("Reembolso ya procesado");
});
Refund r = new Refund();
r.setPayment(p);
r.setAmountCents(amountCents);
r.setStatus(RefundStatus.pending);
r.setRequestedAt(LocalDateTime.now());
r = refundRepo.save(r);
String gatewayRefundId;
try {
// ⚠️ Usa aquí el mismo valor que mandaste en Ds_Merchant_Order al cobrar
// por ejemplo, p.getGatewayOrderId() o similar
String originalOrder = p.getGatewayOrderId(); // ajusta al nombre real del campo
gatewayRefundId = redsysService.requestRefund(originalOrder, amountCents);
} catch (Exception e) {
r.setStatus(RefundStatus.failed);
r.setProcessedAt(LocalDateTime.now());
refundRepo.save(r);
throw new IllegalStateException("Error al solicitar la devolución a Redsys", e);
}
// 🔧 NORMALIZAR ANTES DE GUARDAR
if (gatewayRefundId != null) {
gatewayRefundId = gatewayRefundId.trim();
if (gatewayRefundId.isEmpty() || "000000".equals(gatewayRefundId)) {
gatewayRefundId = null; // → múltiples NULL NO rompen el UNIQUE
}
}
PaymentTransaction tx = new PaymentTransaction();
tx.setPayment(p);
tx.setType(PaymentTransactionType.REFUND);
tx.setStatus(PaymentTransactionStatus.succeeded);
tx.setAmountCents(amountCents);
tx.setCurrency(p.getCurrency());
tx.setGatewayTransactionId(gatewayRefundId);
tx.setIdempotencyKey(idempotencyKey);
tx.setProcessedAt(LocalDateTime.now());
txRepo.save(tx);
r.setStatus(RefundStatus.succeeded);
r.setTransaction(tx);
r.setGatewayRefundId(gatewayRefundId);
r.setProcessedAt(LocalDateTime.now());
refundRepo.save(r);
p.setAmountRefundedCents(p.getAmountRefundedCents() + amountCents);
if (p.getAmountRefundedCents().equals(p.getAmountCapturedCents())) {
p.setStatus(PaymentStatus.refunded);
} else {
p.setStatus(PaymentStatus.partially_refunded);
}
payRepo.save(p);
}
@Transactional
public Payment createBankTransferPayment(Long cartId, Long dirFactId, long amountCents, String currency, Locale locale, Long orderId) {
Payment p = new Payment();
p.setOrderId(null);
Cart cart = this.cartService.findById(cartId);
if (cart != null && cart.getUserId() != null) {
p.setUserId(cart.getUserId());
// Se bloquea el carrito para evitar modificaciones mientras se procesa el pago
this.cartService.lockCartById(cartId);
}
p.setCurrency(currency);
p.setAmountTotalCents(amountCents);
p.setGateway("bank_transfer");
p.setStatus(PaymentStatus.requires_action); // pendiente de ingreso
if (orderId != null) {
p.setOrderId(orderId);
}
p = payRepo.save(p);
// Crear transacción pendiente
PaymentTransaction tx = new PaymentTransaction();
tx.setPayment(p);
tx.setType(PaymentTransactionType.CAPTURE); // o AUTH si prefieres
tx.setStatus(PaymentTransactionStatus.pending);
tx.setAmountCents(amountCents);
tx.setCurrency(currency);
String payload = "";
if (cartId != null) {
payload = "{\"cartId\":" + cartId + "}";
}
if (dirFactId != null) {
if (!payload.isEmpty()) {
payload = payload.substring(0, payload.length() - 1) + ",\"dirFactId\":" + dirFactId + "}";
} else {
payload = "{\"dirFactId\":" + dirFactId + "}";
}
}
tx.setResponsePayload(payload);
// tx.setProcessedAt(null); // la dejas nula hasta que se confirme
txRepo.save(tx);
return p;
}
@Transactional
public void markBankTransferAsCaptured(Long paymentId, Locale locale) {
Payment p = payRepo.findById(paymentId)
.orElseThrow(() -> new IllegalArgumentException("Payment no encontrado: " + paymentId));
if (!"bank_transfer".equals(p.getGateway())) {
throw new IllegalStateException("El Payment " + paymentId + " no es de tipo bank_transfer");
}
// Idempotencia simple: si ya está capturado no hacemos nada
if (p.getStatus() == PaymentStatus.captured
|| p.getStatus() == PaymentStatus.partially_refunded
|| p.getStatus() == PaymentStatus.refunded) {
return;
}
// 1) Buscar la transacción pendiente de captura
PaymentTransaction tx = txRepo
.findFirstByPaymentIdAndTypeAndStatusOrderByIdDesc(
paymentId,
PaymentTransactionType.CAPTURE,
PaymentTransactionStatus.pending)
.orElseThrow(() -> new IllegalStateException(
"No se ha encontrado transacción PENDING para la transferencia " + paymentId));
// 2) Actualizarla a SUCCEEDED y rellenar processedAt
tx.setStatus(PaymentTransactionStatus.succeeded);
tx.setProcessedAt(LocalDateTime.now());
txRepo.save(tx);
// 3) Actualizar el Payment
p.setAmountCapturedCents(p.getAmountTotalCents());
p.setCapturedAt(LocalDateTime.now());
p.setStatus(PaymentStatus.captured);
Long cartId = null;
Long dirFactId = null;
try {
// Intentar extraer cartId del payload de la transacción
if (tx.getResponsePayload() != null && !tx.getResponsePayload().isBlank()) {
ObjectMapper om = new ObjectMapper();
var node = om.readTree(tx.getResponsePayload());
if (node.has("cartId")) {
cartId = node.get("cartId").asLong();
}
if (node.has("dirFactId")) {
dirFactId = node.get("dirFactId").asLong();
}
}
} catch (Exception e) {
// ignorar
}
// 4) Procesar el pedido asociado al carrito (si existe) o marcar el pedido como pagado
if(p.getOrderId() != null) {
pedidoService.setOrderAsPaid(p.getOrderId());
Pedido pedido = pedidoService.getPedidoById(p.getOrderId());
SerieFactura serie = facturacionService.getDefaultSerieFactura();
facturacionService.crearNuevaFacturaAuto(pedido, serie, TipoPago.transferencia, locale);
}
/*else if (cartId != null) {
// Se procesa el pedido dejando el estado calculado en processOrder
Long orderId = processOrder(cartId, dirFactId, locale, null);
if (orderId != null) {
p.setOrderId(orderId);
}
}*/
payRepo.save(p);
}
/**
* Devuelve (total o parcialmente) un pago hecho por transferencia bancaria.
* - Solo permite gateway = "bank_transfer".
* - Crea un Refund + PaymentTransaction de tipo REFUND.
* - Actualiza amountRefundedCents y el estado del Payment.
*/
@Transactional
public Refund refundBankTransfer(Long paymentId, long amountCents) {
Payment p = payRepo.findById(paymentId)
.orElseThrow(() -> new IllegalArgumentException("Payment no encontrado: " + paymentId));
if (!"bank_transfer".equals(p.getGateway())) {
throw new IllegalStateException("El Payment " + paymentId + " no es de tipo bank_transfer");
}
if (amountCents <= 0) {
throw new IllegalArgumentException("El importe de devolución debe ser > 0");
}
// Solo tiene sentido devolver si está capturado o ya parcialmente devuelto
if (p.getStatus() != PaymentStatus.captured
&& p.getStatus() != PaymentStatus.partially_refunded) {
throw new IllegalStateException(
"El Payment " + paymentId + " no está capturado; estado actual: " + p.getStatus());
}
long maxRefundable = p.getAmountCapturedCents() - p.getAmountRefundedCents();
if (amountCents > maxRefundable) {
throw new IllegalStateException(
"Importe de devolución supera lo todavía reembolsable. " +
"maxRefundable=" + maxRefundable + " requested=" + amountCents);
}
LocalDateTime now = LocalDateTime.now();
// 1) Crear Refund (para transferencias lo marcamos como SUCCEEDED directamente)
Refund refund = new Refund();
refund.setPayment(p);
refund.setAmountCents(amountCents);
// reason usa el valor por defecto (customer_request); si quieres otro, cámbialo
// aquí
refund.setStatus(RefundStatus.succeeded);
refund.setRequestedAt(now);
refund.setProcessedAt(now);
// requestedByUserId, notes, metadata -> opcionales, déjalos en null si no los
// usas
refund = refundRepo.save(refund);
// 2) Crear transacción de tipo REFUND
PaymentTransaction tx = new PaymentTransaction();
tx.setPayment(p);
tx.setType(PaymentTransactionType.REFUND);
tx.setStatus(PaymentTransactionStatus.succeeded);
tx.setAmountCents(amountCents);
tx.setCurrency(p.getCurrency());
tx.setProcessedAt(now);
// gatewayTransactionId lo dejamos null → el índice UNIQUE permite múltiples
// NULL
tx = txRepo.save(tx);
// Vincular el Refund con la transacción
refund.setTransaction(tx);
refundRepo.save(refund);
// 3) Actualizar Payment: total devuelto y estado
p.setAmountRefundedCents(p.getAmountRefundedCents() + amountCents);
if (p.getAmountRefundedCents().equals(p.getAmountCapturedCents())) {
p.setStatus(PaymentStatus.refunded);
} else {
p.setStatus(PaymentStatus.partially_refunded);
}
payRepo.save(p);
return refund;
}
private boolean isRedsysAuthorized(RedsysService.RedsysNotification notif) {
if (notif.response == null) {
return false;
}
String r = notif.response.trim();
// Si no es numérico, lo tratamos como no autorizado
if (!r.matches("\\d+")) {
return false;
}
int code = Integer.parseInt(r);
// Redsys: 099 → autorizado; >=100 → denegado / error
return code >= 0 && code <= 99;
}
}

View File

@ -0,0 +1,5 @@
package com.imprimelibros.erp.payments.model;
public enum CaptureMethod { automatic, manual }

View File

@ -0,0 +1,173 @@
package com.imprimelibros.erp.payments.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "payments")
public class Payment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "order_id")
private Long orderId;
@Column(name = "user_id")
private Long userId;
@Column(nullable = false, length = 3)
private String currency;
@Column(name = "amount_total_cents", nullable = false)
private Long amountTotalCents;
@Column(name = "amount_captured_cents", nullable = false)
private Long amountCapturedCents = 0L;
@Column(name = "amount_refunded_cents", nullable = false)
private Long amountRefundedCents = 0L;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
private PaymentStatus status = PaymentStatus.requires_payment_method;
@Enumerated(EnumType.STRING)
@Column(name = "capture_method", nullable = false, length = 16)
private CaptureMethod captureMethod = CaptureMethod.automatic;
@Column(nullable = false, length = 32)
private String gateway;
@Column(name = "gateway_payment_id", length = 128)
private String gatewayPaymentId;
@Column(name = "gateway_order_id", length = 12)
private String gatewayOrderId;
@Column(name = "authorization_code", length = 32)
private String authorizationCode;
@Enumerated(EnumType.STRING)
@Column(name = "three_ds_status", nullable = false, length = 32)
private ThreeDSStatus threeDsStatus = ThreeDSStatus.not_applicable;
@Column(length = 22)
private String descriptor;
@Lob
@Column(name = "client_ip", columnDefinition = "varbinary(16)")
private byte[] clientIp;
@Column(name = "authorized_at")
private LocalDateTime authorizedAt;
@Column(name = "captured_at")
private LocalDateTime capturedAt;
@Column(name = "canceled_at")
private LocalDateTime canceledAt;
@Column(name = "failed_at")
private LocalDateTime failedAt;
@Column(columnDefinition = "json")
private String metadata;
@Column(name = "created_at", nullable = false,
columnDefinition = "datetime default current_timestamp")
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false,
columnDefinition = "datetime default current_timestamp on update current_timestamp")
private LocalDateTime updatedAt;
public Payment() {}
// Getters y setters ↓ (los típicos)
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getOrderId() { return orderId; }
public void setOrderId(Long orderId) { this.orderId = orderId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getCurrency() { return currency; }
public void setCurrency(String currency) { this.currency = currency; }
public Long getAmountTotalCents() { return amountTotalCents; }
public void setAmountTotalCents(Long amountTotalCents) { this.amountTotalCents = amountTotalCents; }
public Long getAmountCapturedCents() { return amountCapturedCents; }
public void setAmountCapturedCents(Long amountCapturedCents) { this.amountCapturedCents = amountCapturedCents; }
public Long getAmountRefundedCents() { return amountRefundedCents; }
public void setAmountRefundedCents(Long amountRefundedCents) { this.amountRefundedCents = amountRefundedCents; }
public PaymentStatus getStatus() { return status; }
public void setStatus(PaymentStatus status) { this.status = status; }
public CaptureMethod getCaptureMethod() { return captureMethod; }
public void setCaptureMethod(CaptureMethod captureMethod) { this.captureMethod = captureMethod; }
public String getGateway() { return gateway; }
public void setGateway(String gateway) { this.gateway = gateway; }
public String getGatewayPaymentId() { return gatewayPaymentId; }
public void setGatewayPaymentId(String gatewayPaymentId) { this.gatewayPaymentId = gatewayPaymentId; }
public String getGatewayOrderId() { return gatewayOrderId; }
public void setGatewayOrderId(String gatewayOrderId) { this.gatewayOrderId = gatewayOrderId; }
public String getAuthorizationCode() { return authorizationCode; }
public void setAuthorizationCode(String authorizationCode) { this.authorizationCode = authorizationCode; }
public ThreeDSStatus getThreeDsStatus() { return threeDsStatus; }
public void setThreeDsStatus(ThreeDSStatus threeDsStatus) { this.threeDsStatus = threeDsStatus; }
public String getDescriptor() { return descriptor; }
public void setDescriptor(String descriptor) { this.descriptor = descriptor; }
public byte[] getClientIp() { return clientIp; }
public void setClientIp(byte[] clientIp) { this.clientIp = clientIp; }
public LocalDateTime getAuthorizedAt() { return authorizedAt; }
public void setAuthorizedAt(LocalDateTime authorizedAt) { this.authorizedAt = authorizedAt; }
public LocalDateTime getCapturedAt() { return capturedAt; }
public void setCapturedAt(LocalDateTime capturedAt) { this.capturedAt = capturedAt; }
public LocalDateTime getCanceledAt() { return canceledAt; }
public void setCanceledAt(LocalDateTime canceledAt) { this.canceledAt = canceledAt; }
public LocalDateTime getFailedAt() { return failedAt; }
public void setFailedAt(LocalDateTime failedAt) { this.failedAt = failedAt; }
public String getMetadata() { return metadata; }
public void setMetadata(String metadata) { this.metadata = metadata; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
@PrePersist
public void prePersist() {
LocalDateTime now = LocalDateTime.now();
if (createdAt == null) {
createdAt = now;
}
if (updatedAt == null) {
updatedAt = now;
}
}
@PreUpdate
public void preUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@ -0,0 +1,8 @@
package com.imprimelibros.erp.payments.model;
public enum PaymentStatus {
requires_payment_method, requires_action, authorized,
captured, partially_refunded, refunded, canceled, failed
}

View File

@ -0,0 +1,129 @@
package com.imprimelibros.erp.payments.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(
name = "payment_transactions",
indexes = {
@Index(name = "idx_tx_pay", columnList = "payment_id"),
@Index(name = "idx_tx_type_status", columnList = "type,status"),
@Index(name = "idx_tx_idem", columnList = "idempotency_key")
}
)
public class PaymentTransaction {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "payment_id", nullable = false)
private Payment payment;
@Enumerated(EnumType.STRING)
@Column(name = "type", nullable = false, length = 16)
private PaymentTransactionType type;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 16)
private PaymentTransactionStatus status;
@Column(name = "amount_cents", nullable = false)
private Long amountCents;
@Column(name = "currency", nullable = false, length = 3)
private String currency;
@Column(name = "gateway_transaction_id", length = 128)
private String gatewayTransactionId;
@Column(name = "gateway_response_code", length = 64)
private String gatewayResponseCode;
@Column(name = "avs_result", length = 8)
private String avsResult;
@Column(name = "cvv_result", length = 8)
private String cvvResult;
@Column(name = "three_ds_version", length = 16)
private String threeDsVersion;
@Column(name = "idempotency_key", length = 128)
private String idempotencyKey;
@Column(name = "request_payload", columnDefinition = "json")
private String requestPayload;
@Column(name = "response_payload", columnDefinition = "json")
private String responsePayload;
@Column(name = "processed_at")
private LocalDateTime processedAt;
@Column(name = "created_at", nullable = false,
columnDefinition = "datetime default current_timestamp")
private LocalDateTime createdAt;
public PaymentTransaction() {}
// Getters & Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Payment getPayment() { return payment; }
public void setPayment(Payment payment) { this.payment = payment; }
public PaymentTransactionType getType() { return type; }
public void setType(PaymentTransactionType type) { this.type = type; }
public PaymentTransactionStatus getStatus() { return status; }
public void setStatus(PaymentTransactionStatus status) { this.status = status; }
public Long getAmountCents() { return amountCents; }
public void setAmountCents(Long amountCents) { this.amountCents = amountCents; }
public String getCurrency() { return currency; }
public void setCurrency(String currency) { this.currency = currency; }
public String getGatewayTransactionId() { return gatewayTransactionId; }
public void setGatewayTransactionId(String gatewayTransactionId) { this.gatewayTransactionId = gatewayTransactionId; }
public String getGatewayResponseCode() { return gatewayResponseCode; }
public void setGatewayResponseCode(String gatewayResponseCode) { this.gatewayResponseCode = gatewayResponseCode; }
public String getAvsResult() { return avsResult; }
public void setAvsResult(String avsResult) { this.avsResult = avsResult; }
public String getCvvResult() { return cvvResult; }
public void setCvvResult(String cvvResult) { this.cvvResult = cvvResult; }
public String getThreeDsVersion() { return threeDsVersion; }
public void setThreeDsVersion(String threeDsVersion) { this.threeDsVersion = threeDsVersion; }
public String getIdempotencyKey() { return idempotencyKey; }
public void setIdempotencyKey(String idempotencyKey) { this.idempotencyKey = idempotencyKey; }
public String getRequestPayload() { return requestPayload; }
public void setRequestPayload(String requestPayload) { this.requestPayload = requestPayload; }
public String getResponsePayload() { return responsePayload; }
public void setResponsePayload(String responsePayload) { this.responsePayload = responsePayload; }
public LocalDateTime getProcessedAt() { return processedAt; }
public void setProcessedAt(LocalDateTime processedAt) { this.processedAt = processedAt; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
@PrePersist
public void prePersist() {
LocalDateTime now = LocalDateTime.now();
if (createdAt == null) {
createdAt = now;
}
}
}

View File

@ -0,0 +1,4 @@
package com.imprimelibros.erp.payments.model;
public enum PaymentTransactionStatus { pending, succeeded, failed }

View File

@ -0,0 +1,4 @@
package com.imprimelibros.erp.payments.model;
public enum PaymentTransactionType { AUTH, CAPTURE, REFUND, VOID }

View File

@ -0,0 +1,99 @@
package com.imprimelibros.erp.payments.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(
name = "refunds",
uniqueConstraints = {
@UniqueConstraint(name = "uq_refund_gateway_id", columnNames = {"gateway_refund_id"})
},
indexes = {
@Index(name = "idx_ref_pay", columnList = "payment_id"),
@Index(name = "idx_ref_status", columnList = "status")
}
)
public class Refund {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "payment_id", nullable = false)
private Payment payment;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "transaction_id")
private PaymentTransaction transaction; // el REFUND en payment_transactions
@Column(name = "amount_cents", nullable = false)
private Long amountCents;
@Enumerated(EnumType.STRING)
@Column(name = "reason", nullable = false, length = 32)
private RefundReason reason = RefundReason.customer_request;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 16)
private RefundStatus status = RefundStatus.pending;
@Column(name = "requested_by_user_id")
private Long requestedByUserId;
@Column(name = "requested_at", nullable = false,
columnDefinition = "datetime default current_timestamp")
private LocalDateTime requestedAt;
@Column(name = "processed_at")
private LocalDateTime processedAt;
@Column(name = "gateway_refund_id", length = 128)
private String gatewayRefundId;
@Column(name = "notes", length = 500)
private String notes;
@Column(name = "metadata", columnDefinition = "json")
private String metadata;
public Refund() {}
// Getters & Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Payment getPayment() { return payment; }
public void setPayment(Payment payment) { this.payment = payment; }
public PaymentTransaction getTransaction() { return transaction; }
public void setTransaction(PaymentTransaction transaction) { this.transaction = transaction; }
public Long getAmountCents() { return amountCents; }
public void setAmountCents(Long amountCents) { this.amountCents = amountCents; }
public RefundReason getReason() { return reason; }
public void setReason(RefundReason reason) { this.reason = reason; }
public RefundStatus getStatus() { return status; }
public void setStatus(RefundStatus status) { this.status = status; }
public Long getRequestedByUserId() { return requestedByUserId; }
public void setRequestedByUserId(Long requestedByUserId) { this.requestedByUserId = requestedByUserId; }
public LocalDateTime getRequestedAt() { return requestedAt; }
public void setRequestedAt(LocalDateTime requestedAt) { this.requestedAt = requestedAt; }
public LocalDateTime getProcessedAt() { return processedAt; }
public void setProcessedAt(LocalDateTime processedAt) { this.processedAt = processedAt; }
public String getGatewayRefundId() { return gatewayRefundId; }
public void setGatewayRefundId(String gatewayRefundId) { this.gatewayRefundId = gatewayRefundId; }
public String getNotes() { return notes; }
public void setNotes(String notes) { this.notes = notes; }
public String getMetadata() { return metadata; }
public void setMetadata(String metadata) { this.metadata = metadata; }
}

View File

@ -0,0 +1,6 @@
package com.imprimelibros.erp.payments.model;
public enum RefundReason {
customer_request, partial_return, pricing_adjustment, duplicate, fraud, other
}

View File

@ -0,0 +1,4 @@
package com.imprimelibros.erp.payments.model;
public enum RefundStatus { pending, succeeded, failed, canceled }

View File

@ -0,0 +1,4 @@
package com.imprimelibros.erp.payments.model;
public enum ThreeDSStatus { not_applicable, attempted, challenge, succeeded, failed }

View File

@ -0,0 +1,96 @@
package com.imprimelibros.erp.payments.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(
name = "webhook_events",
uniqueConstraints = {
@UniqueConstraint(name = "uq_webhook_provider_event", columnNames = {"provider","event_id"})
},
indexes = {
@Index(name = "idx_webhook_processed", columnList = "processed")
}
)
public class WebhookEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "provider", nullable = false, length = 32)
private String provider; // "redsys", etc.
@Column(name = "event_type", nullable = false, length = 64)
private String eventType;
@Column(name = "event_id", length = 128)
private String eventId;
@Column(name = "signature", length = 512)
private String signature;
@Column(name = "payload", nullable = false, columnDefinition = "json")
private String payload;
@Column(name = "processed", nullable = false)
private Boolean processed = false;
@Column(name = "processed_at")
private LocalDateTime processedAt;
@Column(name = "attempts", nullable = false)
private Integer attempts = 0;
@Column(name = "last_error", length = 500)
private String lastError;
@Column(name = "created_at", nullable = false,
columnDefinition = "datetime default current_timestamp")
private LocalDateTime createdAt;
public WebhookEvent() {}
// Getters & Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getProvider() { return provider; }
public void setProvider(String provider) { this.provider = provider; }
public String getEventType() { return eventType; }
public void setEventType(String eventType) { this.eventType = eventType; }
public String getEventId() { return eventId; }
public void setEventId(String eventId) { this.eventId = eventId; }
public String getSignature() { return signature; }
public void setSignature(String signature) { this.signature = signature; }
public String getPayload() { return payload; }
public void setPayload(String payload) { this.payload = payload; }
public Boolean getProcessed() { return processed; }
public void setProcessed(Boolean processed) { this.processed = processed; }
public LocalDateTime getProcessedAt() { return processedAt; }
public void setProcessedAt(LocalDateTime processedAt) { this.processedAt = processedAt; }
public Integer getAttempts() { return attempts; }
public void setAttempts(Integer attempts) { this.attempts = attempts; }
public String getLastError() { return lastError; }
public void setLastError(String lastError) { this.lastError = lastError; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
@PrePersist
public void prePersist() {
LocalDateTime now = LocalDateTime.now();
if (createdAt == null) {
createdAt = now;
}
}
}

View File

@ -0,0 +1,14 @@
// PaymentRepository.java
package com.imprimelibros.erp.payments.repo;
import com.imprimelibros.erp.payments.model.Payment;
import com.imprimelibros.erp.payments.model.PaymentStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface PaymentRepository extends JpaRepository<Payment, Long> {
Optional<Payment> findByGatewayAndGatewayOrderId(String gateway, String gatewayOrderId);
Optional<Payment> findFirstByOrderIdAndStatusOrderByIdDesc(Long orderId, PaymentStatus status);
}

View File

@ -0,0 +1,26 @@
// PaymentTransactionRepository.java
package com.imprimelibros.erp.payments.repo;
import com.imprimelibros.erp.payments.model.PaymentTransaction;
import com.imprimelibros.erp.payments.model.PaymentTransactionStatus;
import com.imprimelibros.erp.payments.model.PaymentTransactionType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.List;
import java.util.Optional;
public interface PaymentTransactionRepository extends JpaRepository<PaymentTransaction, Long>, JpaSpecificationExecutor<PaymentTransaction> {
List<PaymentTransaction> findByGatewayTransactionId(String gatewayTransactionId);
Optional<PaymentTransaction> findByIdempotencyKey(String idempotencyKey);
Optional<PaymentTransaction> findByPaymentIdAndType(
Long paymentId,
PaymentTransactionType type
);
Optional<PaymentTransaction> findFirstByPaymentIdAndTypeAndStatusOrderByIdDesc(
Long paymentId,
PaymentTransactionType type,
PaymentTransactionStatus status
);
}

View File

@ -0,0 +1,12 @@
// RefundRepository.java
package com.imprimelibros.erp.payments.repo;
import com.imprimelibros.erp.payments.model.Refund;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface RefundRepository extends JpaRepository<Refund, Long> {
@Query("select coalesce(sum(r.amountCents),0) from Refund r where r.payment.id = :paymentId and r.status = com.imprimelibros.erp.payments.model.RefundStatus.succeeded")
long sumSucceededByPaymentId(@Param("paymentId") Long paymentId);
}

View File

@ -0,0 +1,12 @@
// WebhookEventRepository.java
package com.imprimelibros.erp.payments.repo;
import com.imprimelibros.erp.payments.model.WebhookEvent;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface WebhookEventRepository extends JpaRepository<WebhookEvent, Long> {
Optional<WebhookEvent> findByProviderAndEventId(String provider, String eventId);
}

View File

@ -25,8 +25,8 @@ public class PdfController {
@RequestParam(defaultValue = "inline") String mode, @RequestParam(defaultValue = "inline") String mode,
Locale locale) { Locale locale) {
if (type.equals(DocumentType.PRESUPUESTO.toString()) && id == null) { if (id == null) {
throw new IllegalArgumentException("Falta el ID del presupuesto para generar el PDF"); throw new IllegalArgumentException("Falta el ID para generar el PDF");
} }
if (type.equals(DocumentType.PRESUPUESTO.toString())) { if (type.equals(DocumentType.PRESUPUESTO.toString())) {
Long presupuestoId = Long.valueOf(id); Long presupuestoId = Long.valueOf(id);
@ -39,7 +39,22 @@ public class PdfController {
: ContentDisposition.inline()).filename("presupuesto-" + id + ".pdf").build()); : ContentDisposition.inline()).filename("presupuesto-" + id + ".pdf").build());
return new ResponseEntity<>(pdf, headers, HttpStatus.OK); return new ResponseEntity<>(pdf, headers, HttpStatus.OK);
} else { }/*else if(type.equals(DocumentType.PEDIDO.toString())) {
} */else if (type.equals(DocumentType.FACTURA.toString())) {
Long facturaId = Long.valueOf(id);
byte[] pdf = pdfService.generaFactura(facturaId, locale);
var headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF);
headers.setContentDisposition(
("download".equals(mode)
? ContentDisposition.attachment()
: ContentDisposition.inline()).filename("factura-" + id + ".pdf").build());
return new ResponseEntity<>(pdf, headers, HttpStatus.OK);
}
else {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null);
} }

View File

@ -16,6 +16,11 @@ import com.imprimelibros.erp.presupuesto.PresupuestoRepository;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto; import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import com.imprimelibros.erp.common.Utils; import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.common.web.HtmlToXhtml;
import com.imprimelibros.erp.facturacion.Factura;
import com.imprimelibros.erp.facturacion.service.FacturacionService;
import com.imprimelibros.erp.pedidos.PedidoDireccion;
import com.imprimelibros.erp.pedidos.PedidoService;
@Service @Service
public class PdfService { public class PdfService {
@ -24,6 +29,8 @@ public class PdfService {
private final PdfRenderer renderer; private final PdfRenderer renderer;
private final PresupuestoRepository presupuestoRepository; private final PresupuestoRepository presupuestoRepository;
private final Utils utils; private final Utils utils;
private final FacturacionService facturacionService;
private final PedidoService pedidoService;
private final Map<String, String> empresa = Map.of( private final Map<String, String> empresa = Map.of(
"nombre", "ImprimeLibros ERP", "nombre", "ImprimeLibros ERP",
@ -35,7 +42,6 @@ public class PdfService {
"poblacion", "Madrid", "poblacion", "Madrid",
"web", "www.imprimelibros.com"); "web", "www.imprimelibros.com");
private static class PrecioTirada { private static class PrecioTirada {
private Double peso; private Double peso;
@JsonProperty("iva_importe_4") @JsonProperty("iva_importe_4")
@ -88,12 +94,15 @@ public class PdfService {
} }
public PdfService(TemplateRegistry registry, PdfTemplateEngine engine, PdfRenderer renderer, public PdfService(TemplateRegistry registry, PdfTemplateEngine engine, PdfRenderer renderer,
PresupuestoRepository presupuestoRepository, Utils utils) { PresupuestoRepository presupuestoRepository, Utils utils, FacturacionService facturacionService,
PedidoService pedidoService) {
this.registry = registry; this.registry = registry;
this.engine = engine; this.engine = engine;
this.renderer = renderer; this.renderer = renderer;
this.presupuestoRepository = presupuestoRepository; this.presupuestoRepository = presupuestoRepository;
this.utils = utils; this.utils = utils;
this.pedidoService = pedidoService;
this.facturacionService = facturacionService;
} }
private byte[] generate(DocumentSpec spec) { private byte[] generate(DocumentSpec spec) {
@ -122,27 +131,6 @@ public class PdfService {
model.put("titulo", presupuesto.getTitulo()); model.put("titulo", presupuesto.getTitulo());
/*
* Map<String, Object> resumen = presupuestoService.getTextosResumen(
* presupuesto, null, model, model, null)
*/
model.put("lineas", List.of(
Map.of("descripcion", "Impresión interior B/N offset 80 g",
"meta", "300 páginas · tinta negra · papel 80 g",
"uds", 1000,
"precio", 2.15,
"dto", 0,
"importe", 2150.0),
Map.of("descripcion", "Cubierta color 300 g laminado mate",
"meta", "Lomo 15 mm · 4/0 · laminado mate",
"uds", 1000,
"precio", 0.38,
"dto", 5.0,
"importe", 361.0)));
model.put("servicios", List.of(
Map.of("descripcion", "Transporte península", "unidades", 1, "precio", 90.00)));
Map<String, Object> specs = utils.getTextoPresupuesto(presupuesto, locale); Map<String, Object> specs = utils.getTextoPresupuesto(presupuesto, locale);
model.put("specs", specs); model.put("specs", specs);
@ -202,4 +190,54 @@ public class PdfService {
throw new RuntimeException("Error generando presupuesto PDF", e); throw new RuntimeException("Error generando presupuesto PDF", e);
} }
} }
public byte[] generaFactura(Long facturaId, Locale locale) {
try {
Factura factura = facturacionService.getFactura(facturaId);
if (factura == null) {
throw new IllegalArgumentException("Factura no encontrada: " + facturaId);
}
factura.getLineas().forEach(l -> l.setDescripcion(HtmlToXhtml.toXhtml(l.getDescripcion())));
PedidoDireccion direccionFacturacion = pedidoService
.getPedidoDireccionFacturacionByPedidoId(factura.getPedidoId());
if (direccionFacturacion == null) {
throw new IllegalArgumentException(
"Dirección de facturación no encontrada para el pedido: " + factura.getPedidoId());
}
Map<String, Object> model = new HashMap<>();
model.put("factura", factura);
model.put("direccionFacturacion", direccionFacturacion);
var spec = new DocumentSpec(
DocumentType.FACTURA,
"factura-a4",
locale,
model);
byte[] pdf = this.generate(spec);
// HTML
// (Opcional) generar HTML de depuración con CSS incrustado
try {
String templateName = registry.resolve(DocumentType.FACTURA, "factura-a4");
String html = engine.render(templateName, locale, model);
String css = Files.readString(Path.of("src/main/resources/static/assets/css/facturapdf.css"));
String htmlWithCss = html.replaceFirst("(?i)</head>", "<style>\n" + css + "\n</style>\n</head>");
Path htmlPath = Path.of("target/factura-test.html");
Files.writeString(htmlPath, htmlWithCss, StandardCharsets.UTF_8);
} catch (Exception ignore) {
/* solo para depuración */ }
return pdf;
} catch (Exception e) {
throw new RuntimeException("Error generando factura PDF", e);
}
}
} }

View File

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

View File

@ -0,0 +1,120 @@
package com.imprimelibros.erp.pedidos;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
import com.imprimelibros.erp.common.jpa.AbstractAuditedEntity;
@Entity
@Table(name = "pedidos")
public class Pedido extends AbstractAuditedEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Campos económicos
@Column(name = "base", nullable = false)
private Double base;
@Column(name = "envio", nullable = false)
private Double envio = 0.0;
@Column(name = "iva4", nullable = false)
private Double iva4 = 0.0;
@Column(name = "iva21", nullable = false)
private Double iva21 = 0.0;
@Column(name = "descuento", nullable = false)
private Double descuento = 0.0;
@Column(name = "total", nullable = false)
private Double total = 0.0;
// Datos de proveedor
@Column(name = "proveedor", length = 100)
private String proveedor;
@Column(name = "proveedor_ref", length = 100)
private String proveedorRef;
@OneToMany(mappedBy = "pedido", cascade = CascadeType.ALL, orphanRemoval = false)
private List<PedidoLinea> lineas = new ArrayList<>();
// --- Getters y setters ---
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Double getBase() {
return base;
}
public void setBase(Double base) {
this.base = base;
}
public Double getEnvio() {
return envio;
}
public void setEnvio(Double envio) {
this.envio = envio;
}
public Double getIva4() {
return iva4;
}
public void setIva4(Double iva4) {
this.iva4 = iva4;
}
public Double getIva21() {
return iva21;
}
public void setIva21(Double iva21) {
this.iva21 = iva21;
}
public Double getDescuento() {
return descuento;
}
public void setDescuento(Double descuento) {
this.descuento = descuento;
}
public Double getTotal() {
return total;
}
public void setTotal(Double total) {
this.total = total;
}
public String getProveedor() {
return proveedor;
}
public void setProveedor(String proveedor) {
this.proveedor = proveedor;
}
public String getProveedorRef() {
return proveedorRef;
}
public void setProveedorRef(String proveedorRef) {
this.proveedorRef = proveedorRef;
}
}

View File

@ -0,0 +1,308 @@
package com.imprimelibros.erp.pedidos;
import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp;
import com.imprimelibros.erp.direcciones.Direccion.TipoIdentificacionFiscal;
import com.imprimelibros.erp.paises.Paises;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@Entity
@Table(name = "pedidos_direcciones")
public class PedidoDireccion {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// FK a pedidos_lineas.id (nullable, on delete set null)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "pedido_linea_id")
private PedidoLinea pedidoLinea;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "pedido_id")
private Pedido pedido;
@Column(name = "unidades")
private Integer unidades;
@Column(name = "is_facturacion", nullable = false)
private boolean facturacion = false;
@Column(name = "is_ejemplar_prueba", nullable = false)
private boolean ejemplarPrueba = false;
@Column(name = "email", length = 255)
private String email;
@Column(name = "att", nullable = false, length = 150)
private String att;
@Column(name = "direccion", nullable = false, length = 255)
private String direccion;
@Column(name = "cp", nullable = false)
private Integer cp;
@Column(name = "ciudad", nullable = false, length = 100)
private String ciudad;
@Column(name = "provincia", nullable = false, length = 100)
private String provincia;
@Column(name = "pais_code3", nullable = false, length = 3)
private String paisCode3 = "esp";
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "pais_code3", referencedColumnName = "code3", insertable = false, updatable = false)
private Paises pais;
@Transient
private String paisNombre;
@Column(name = "telefono", nullable = false, length = 30)
private String telefono;
@Column(name = "instrucciones", length = 255)
private String instrucciones;
@Column(name = "razon_social", length = 150)
private String razonSocial;
@Enumerated(EnumType.STRING)
@Column(name = "tipo_identificacion_fiscal", nullable = false, length = 20)
private TipoIdentificacionFiscal tipoIdentificacionFiscal = TipoIdentificacionFiscal.DNI;
@Column(name = "identificacion_fiscal", length = 50)
private String identificacionFiscal;
@Column(name = "is_palets", nullable = false)
private boolean palets = false;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
// ===== GETTERS & SETTERS =====
public Long getId() {
return id;
}
public PedidoLinea getPedidoLinea() {
return pedidoLinea;
}
public void setPedidoLinea(PedidoLinea pedidoLinea) {
this.pedidoLinea = pedidoLinea;
}
public Pedido getPedido() {
return pedido;
}
public void setPedido(Pedido pedido) {
this.pedido = pedido;
}
public Integer getUnidades() {
return unidades;
}
public void setUnidades(Integer unidades) {
this.unidades = unidades;
}
public boolean isFacturacion() {
return facturacion;
}
public void setFacturacion(boolean facturacion) {
this.facturacion = facturacion;
}
public boolean isEjemplarPrueba() {
return ejemplarPrueba;
}
public void setEjemplarPrueba(boolean ejemplarPrueba) {
this.ejemplarPrueba = ejemplarPrueba;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getAtt() {
return att;
}
public void setAtt(String att) {
this.att = att;
}
public String getDireccion() {
return direccion;
}
public void setDireccion(String direccion) {
this.direccion = direccion;
}
public Integer getCp() {
return cp;
}
public void setCp(Integer cp) {
this.cp = cp;
}
public String getCiudad() {
return ciudad;
}
public void setCiudad(String ciudad) {
this.ciudad = ciudad;
}
public String getProvincia() {
return provincia;
}
public void setProvincia(String provincia) {
this.provincia = provincia;
}
public String getPaisCode3() {
return paisCode3;
}
public void setPaisCode3(String paisCode3) {
this.paisCode3 = paisCode3;
}
public Paises getPais() {
return pais;
}
public void setPais(Paises pais) {
this.pais = pais;
}
public String getTelefono() {
return telefono;
}
public void setTelefono(String telefono) {
this.telefono = telefono;
}
public String getInstrucciones() {
return instrucciones;
}
public void setInstrucciones(String instrucciones) {
this.instrucciones = instrucciones;
}
public String getRazonSocial() {
return razonSocial;
}
public void setRazonSocial(String razonSocial) {
this.razonSocial = razonSocial;
}
public TipoIdentificacionFiscal getTipoIdentificacionFiscal() {
return tipoIdentificacionFiscal;
}
public void setTipoIdentificacionFiscal(TipoIdentificacionFiscal tipoIdentificacionFiscal) {
this.tipoIdentificacionFiscal = tipoIdentificacionFiscal;
}
public String getIdentificacionFiscal() {
return identificacionFiscal;
}
public void setIdentificacionFiscal(String identificacionFiscal) {
this.identificacionFiscal = identificacionFiscal;
}
public boolean isPalets() {
return palets;
}
public void setPalets(boolean palets) {
this.palets = palets;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public String getPaisNombre() {
return paisNombre;
}
public void setPaisNombre(String paisNombre) {
this.paisNombre = paisNombre;
}
public Map<String, Object> toSkMap(Double pesoKg) {
Map<String, Object> direccion = new HashMap<>();
direccion.put("cantidad", this.getUnidades());
direccion.put("peso", pesoKg);
direccion.put("att", this.getAtt());
direccion.put("email", this.getEmail());
direccion.put("direccion", this.getDireccion());
direccion.put("pais_code3", this.getPaisCode3());
direccion.put("cp", this.getCp());
direccion.put("municipio", this.getCiudad());
direccion.put("provincia", this.getProvincia());
direccion.put("telefono", this.getTelefono());
direccion.put("entregaPieCalle", this.isPalets() ? 1 : 0);
direccion.put("is_ferro_prototipo", this.isEjemplarPrueba() ? 1 : 0);
direccion.put("num_ferro_prototipo", this.isEjemplarPrueba() ? 1 : 0);
Map<String, Object> map = new HashMap<>();
map.put("direccion", direccion);
map.put("unidades", this.getUnidades());
map.put("entregaPalets", this.isPalets() ? 1 : 0);
return map;
}
public static Map<String, Object> toSkMapDepositoLegal() {
Map<String, Object> direccion = new HashMap<>();
direccion.put("cantidad", 4);
direccion.put("peso", 0);
direccion.put("att", "Unidades para Depósito Legal (sin envío)");
direccion.put("email", "");
direccion.put("direccion", "");
direccion.put("pais_code3", "esp");
direccion.put("cp", "");
direccion.put("municipio", "");
direccion.put("provincia", "");
direccion.put("telefono", "");
direccion.put("entregaPieCalle", 0);
direccion.put("is_ferro_prototipo", 0);
direccion.put("num_ferro_prototipo", 0);
Map<String, Object> map = new HashMap<>();
map.put("direccion", direccion);
map.put("unidades", 4);
map.put("entregaPalets", 0);
return map;
}
}

View File

@ -0,0 +1,26 @@
package com.imprimelibros.erp.pedidos;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface PedidoDireccionRepository extends JpaRepository<PedidoDireccion, Long> {
// Todas las direcciones de una línea de pedido
List<PedidoDireccion> findByPedidoLinea_Id(Long pedidoLineaId);
// Si en tu código sueles trabajar con el objeto:
List<PedidoDireccion> findByPedidoLinea(PedidoLinea pedidoLinea);
PedidoDireccion findByPedidoIdAndFacturacionTrue(Long pedidoId);
@Query("""
select distinct d
from PedidoDireccion d
join d.pedidoLinea pl
where d.pedidoLinea.id = :pedidoLineaId
""")
List<PedidoDireccion> findByPedidoLineaId(Long pedidoLineaId);
}

View File

@ -0,0 +1,55 @@
package com.imprimelibros.erp.pedidos;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@Service
public class PedidoEstadoService {
private static final Logger log = LoggerFactory.getLogger(PedidoEstadoService.class);
private final PedidoLineaRepository pedidoLineaRepository;
private final PedidoService pedidoService;
public PedidoEstadoService(PedidoLineaRepository pedidoLineaRepository, PedidoService pedidoService) {
this.pedidoLineaRepository = pedidoLineaRepository;
this.pedidoService = pedidoService;
}
/**
* Ejecuta cada noche a las 4:00 AM
*/
@Scheduled(cron = "0 0 4 * * *")
public void actualizarEstadosPedidos() {
List<PedidoLinea> pedidosLineas = pedidoLineaRepository.findPedidosLineasParaActualizarEstado();
for (PedidoLinea linea : pedidosLineas) {
try {
Map<String, Object> resultado = pedidoService.actualizarEstado(linea.getId(), Locale.getDefault());
if (!Boolean.TRUE.equals(resultado.get("success"))) {
log.error("Error al actualizar estado. pedidoLineaId={} message={}",
linea.getId(), resultado.get("message"));
}
} catch (Exception ex) {
log.error("Excepción actualizando estado. pedidoLineaId={}", linea.getId(), ex);
}
// rate limit / delay
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("Job interrumpido mientras dormía (rate limit).");
return;
}
}
}
}

View File

@ -0,0 +1,136 @@
package com.imprimelibros.erp.pedidos;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
@Entity
@Table(name = "pedidos_lineas")
public class PedidoLinea {
public enum Estado {
pendiente_pago("pedido.estado.pendiente_pago", 1),
procesando_pago("pedido.estado.procesando_pago", 2),
denegado_pago("pedido.estado.denegado_pago", 3),
aprobado("pedido.estado.aprobado", 4),
maquetacion("pedido.estado.maquetacion", 5),
haciendo_ferro("pedido.estado.haciendo_ferro", 6),
esperando_aceptacion_ferro("pedido.estado.esperando_aceptacion_ferro", 7),
ferro_cliente("pedido.estado.ferro_cliente", 8),
produccion("pedido.estado.produccion", 9),
terminado("pedido.estado.terminado", 10),
enviado("pedido.estado.enviado", 11),
cancelado("pedido.estado.cancelado", 12);
private final String messageKey;
private final int priority;
Estado(String messageKey, int priority) {
this.messageKey = messageKey;
this.priority = priority;
}
public String getMessageKey() {
return messageKey;
}
public int getPriority() {
return priority;
}
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "pedido_id", nullable = false)
private Pedido pedido;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "presupuesto_id", nullable = false)
private Presupuesto presupuesto;
@Enumerated(EnumType.STRING)
@Column(name = "estado", nullable = false)
private Estado estado = Estado.aprobado;
@Column(name = "estado_manual", nullable = false)
private Boolean estadoManual;
@Column(name = "fecha_entrega")
private LocalDateTime fechaEntrega;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "created_by", nullable = false)
private Long createdBy;
// --- Getters y setters ---
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Pedido getPedido() {
return pedido;
}
public void setPedido(Pedido pedido) {
this.pedido = pedido;
}
public Presupuesto getPresupuesto() {
return presupuesto;
}
public void setPresupuesto(Presupuesto presupuesto) {
this.presupuesto = presupuesto;
}
public Estado getEstado() {
return estado;
}
public void setEstado(Estado estado) {
this.estado = estado;
}
public Boolean getEstadoManual() {
return estadoManual;
}
public void setEstadoManual(Boolean estadoManual) {
this.estadoManual = estadoManual;
}
public LocalDateTime getFechaEntrega() {
return fechaEntrega;
}
public void setFechaEntrega(LocalDateTime fechaEntrega) {
this.fechaEntrega = fechaEntrega;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public Long getCreatedBy() {
return createdBy;
}
public void setCreatedBy(Long createdBy) {
this.createdBy = createdBy;
}
}

View File

@ -0,0 +1,34 @@
package com.imprimelibros.erp.pedidos;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface PedidoLineaRepository extends JpaRepository<PedidoLinea, Long> {
List<PedidoLinea> findByPedidoId(Long pedidoId);
List<PedidoLinea> findByPedidoIdOrderByIdAsc(Long pedidoId);
List<PedidoLinea> findByPresupuestoId(Long presupuestoId);
@Query("""
SELECT pl
FROM PedidoLinea pl
JOIN pl.presupuesto p
WHERE pl.estadoManual = false
AND pl.estado IN (
'haciendo_ferro',
'esperando_aceptacion_ferro',
'produccion',
'terminado'
)
AND p.proveedor = 'Safekat'
AND p.proveedorRef1 IS NOT NULL
AND p.proveedorRef2 IS NOT NULL
""")
List<PedidoLinea> findPedidosLineasParaActualizarEstado();
}

View File

@ -0,0 +1,23 @@
package com.imprimelibros.erp.pedidos;
import java.time.Instant;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@Repository
public interface PedidoRepository extends JpaRepository<Pedido, Long>, JpaSpecificationExecutor<Pedido> {
// Suma de "total" para un "createdBy" desde una fecha dada
@Query("""
SELECT COALESCE(SUM(p.total), 0)
FROM Pedido p
WHERE p.createdBy.id = :userId
AND p.createdAt >= :afterDate
""")
Double sumTotalByCreatedByAndCreatedAtAfter(@Param("userId") Long userId,
@Param("afterDate") Instant afterDate);
}

View File

@ -0,0 +1,765 @@
package com.imprimelibros.erp.pedidos;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.imprimelibros.erp.cart.Cart;
import com.imprimelibros.erp.cart.CartDireccion;
import com.imprimelibros.erp.cart.CartItem;
import com.imprimelibros.erp.cart.CartService;
import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.direcciones.Direccion;
import com.imprimelibros.erp.presupuesto.PresupuestoRepository;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import com.imprimelibros.erp.presupuesto.service.PresupuestoService;
import com.imprimelibros.erp.users.UserService;
import com.imprimelibros.erp.direcciones.DireccionService;
import com.imprimelibros.erp.externalApi.skApiClient;
import com.imprimelibros.erp.facturacion.FacturaDireccion;
import com.imprimelibros.erp.facturacion.dto.DireccionFacturacionDto;
import com.imprimelibros.erp.pedidos.PedidoLinea.Estado;
@Service
public class PedidoService {
private final PedidoRepository pedidoRepository;
private final PedidoLineaRepository pedidoLineaRepository;
private final PresupuestoRepository presupuestoRepository;
private final PedidoDireccionRepository pedidoDireccionRepository;
private final DireccionService direccionService;
private final UserService userService;
private final PresupuestoService presupuestoService;
private final CartService cartService;
private final skApiClient skApiClient;
private final PresupuestoRepository presupuestoRepo;
private final MessageSource messageSource;
public PedidoService(PedidoRepository pedidoRepository, PedidoLineaRepository pedidoLineaRepository,
PresupuestoRepository presupuestoRepository, PedidoDireccionRepository pedidoDireccionRepository,
DireccionService direccionService, UserService userService, PresupuestoService presupuestoService,
CartService cartService, skApiClient skApiClient, PresupuestoRepository presupuestoRepo,
MessageSource messageSource) {
this.pedidoRepository = pedidoRepository;
this.pedidoLineaRepository = pedidoLineaRepository;
this.presupuestoRepository = presupuestoRepository;
this.pedidoDireccionRepository = pedidoDireccionRepository;
this.direccionService = direccionService;
this.userService = userService;
this.presupuestoService = presupuestoService;
this.cartService = cartService;
this.skApiClient = skApiClient;
this.presupuestoRepo = presupuestoRepo;
this.messageSource = messageSource;
}
public Pedido getPedidoById(Long pedidoId) {
return pedidoRepository.findById(pedidoId).orElse(null);
}
public PedidoDireccion getPedidoDireccionFacturacionByPedidoId(Long pedidoId) {
return pedidoDireccionRepository.findByPedidoIdAndFacturacionTrue(pedidoId);
}
@Transactional
public Pedido crearPedido(
Long cartId,
Long direccionFacturacionId,
String proveedor,
String proveedorRef) {
Pedido pedido = new Pedido();
Cart cart = cartService.getCartById(cartId);
Map<String, Object> cartSummaryRaw = cartService.getCartSummaryRaw(cart, Locale.getDefault());
// Datos económicos (ojo con las claves, son las del summaryRaw)
pedido.setBase((Double) cartSummaryRaw.getOrDefault("base", 0.0d));
pedido.setEnvio((Double) cartSummaryRaw.getOrDefault("shipment", 0.0d));
pedido.setIva4((Double) cartSummaryRaw.getOrDefault("iva4", 0.0d));
pedido.setIva21((Double) cartSummaryRaw.getOrDefault("iva21", 0.0d));
pedido.setDescuento((Double) cartSummaryRaw.getOrDefault("descuento", 0.0d));
pedido.setTotal((Double) cartSummaryRaw.getOrDefault("total", 0.0d));
// Proveedor
if (proveedor != null && proveedorRef != null) {
pedido.setProveedor(proveedor);
pedido.setProveedorRef(proveedorRef);
}
// Auditoría mínima
/*
* Long userId = cart.getUserId();
* pedido.setCreatedBy(userService.findById(userId));
* pedido.setUpdatedBy(userService.findById(userId));
*/
// Se obtiene el usuario del primer presupuesto del carrito
Long userId = null;
List<CartItem> cartItems = cart.getItems();
if (!cartItems.isEmpty()) {
Presupuesto firstPresupuesto = cartItems.get(0).getPresupuesto();
if (firstPresupuesto != null) {
userId = firstPresupuesto.getUser().getId();
}
}
if (userId == null) {
userId = cart.getUserId();
}
pedido.setCreatedBy(userService.findById(userId));
pedido.setUpdatedBy(userService.findById(userId));
pedido.setCreatedAt(Instant.now());
pedido.setDeleted(false);
pedido.setUpdatedAt(Instant.now());
// Guardamos el pedido
Pedido pedidoGuardado = pedidoRepository.save(pedido);
pedidoGuardado.setCreatedBy(userService.findById(userId));
pedidoGuardado.setUpdatedBy(userService.findById(userId));
pedidoRepository.save(pedidoGuardado);
List<CartItem> items = cart.getItems();
for (Integer i = 0; i < items.size(); i++) {
CartItem item = items.get(i);
Presupuesto pCart = item.getPresupuesto();
// Asegurarnos de trabajar con la entidad gestionada por JPA
Presupuesto p = presupuestoRepository.findById(pCart.getId())
.orElseThrow(() -> new IllegalStateException("Presupuesto no encontrado: " + pCart.getId()));
p.setEstado(Presupuesto.Estado.aceptado);
presupuestoRepository.save(p);
PedidoLinea linea = new PedidoLinea();
linea.setPedido(pedidoGuardado);
linea.setPresupuesto(p);
linea.setCreatedBy(userId);
linea.setCreatedAt(LocalDateTime.now());
linea.setEstado(PedidoLinea.Estado.pendiente_pago);
linea.setEstadoManual(false);
pedidoLineaRepository.save(linea);
// Guardar las direcciones asociadas a la línea del pedido
Map<String, Object> direcciones_presupuesto = this.getDireccionesPresupuesto(cart, p);
saveDireccionesPedidoLinea(direcciones_presupuesto, pedidoGuardado, linea, direccionFacturacionId);
}
return pedidoGuardado;
}
public Boolean markPedidoAsProcesingPayment(Long pedidoId) {
Pedido pedido = pedidoRepository.findById(pedidoId).orElse(null);
if (pedido == null) {
return false;
}
List<PedidoLinea> lineas = pedidoLineaRepository.findByPedidoId(pedidoId);
for (PedidoLinea linea : lineas) {
linea.setEstado(PedidoLinea.Estado.procesando_pago);
pedidoLineaRepository.save(linea);
}
return true;
}
public Boolean markPedidoAsPaymentDenied(Long pedidoId) {
Pedido pedido = pedidoRepository.findById(pedidoId).orElse(null);
if (pedido == null) {
return false;
}
List<PedidoLinea> lineas = pedidoLineaRepository.findByPedidoId(pedidoId);
for (PedidoLinea linea : lineas) {
linea.setEstado(PedidoLinea.Estado.denegado_pago);
pedidoLineaRepository.save(linea);
}
return true;
}
public Pedido findById(Long pedidoId) {
return pedidoRepository.findById(pedidoId).orElse(null);
}
@Transactional
public Boolean upsertDireccionFacturacion(Long pedidoId, DireccionFacturacionDto direccionData) {
try {
Pedido pedido = pedidoRepository.findById(pedidoId).orElse(null);
if (pedido != null) {
PedidoDireccion direccionPedido = pedidoDireccionRepository.findByPedidoIdAndFacturacionTrue(pedidoId);
if (direccionPedido == null) {
// crear
direccionPedido = direccionData.toPedidoDireccion();
direccionPedido.setPedido(pedido);
} else {
// actualizar en la existente (NO crees una nueva, para conservar ID)
direccionData.applyTo(direccionPedido); // si implementas applyTo()
direccionPedido.setFacturacion(true); // por si acaso
}
pedidoDireccionRepository.save(direccionPedido);
}
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/** Lista de los items del pedido preparados para la vista */
@Transactional
public List<Map<String, Object>> getLineas(Long pedidoId, Locale locale) {
Pedido p = pedidoRepository.findById(pedidoId).orElse(null);
if (p == null) {
return new ArrayList<>();
}
List<Map<String, Object>> resultados = new ArrayList<>();
List<PedidoLinea> items = pedidoLineaRepository.findByPedidoIdOrderByIdAsc(p.getId());
for (PedidoLinea item : items) {
Presupuesto presupuesto = item.getPresupuesto();
Map<String, Object> elemento = presupuestoService.getPresupuestoInfoForCard(presupuesto, locale);
elemento.put("estado", item.getEstado());
elemento.put("fechaEntrega",
item.getFechaEntrega() != null ? Utils.formatDate(item.getFechaEntrega(), locale) : "");
elemento.put("lineaId", item.getId());
resultados.add(elemento);
}
return resultados;
}
public PedidoDireccion getDireccionFacturacionPedido(Long pedidoId) {
return pedidoDireccionRepository.findByPedidoIdAndFacturacionTrue(pedidoId);
}
public List<PedidoDireccion> getDireccionesEntregaPedidoLinea(Long pedidoLineaId) {
return pedidoDireccionRepository.findByPedidoLinea_Id(pedidoLineaId);
}
public Boolean setOrderAsPaid(Long pedidoId) {
Pedido pedido = pedidoRepository.findById(pedidoId).orElse(null);
if (pedido == null) {
return false;
}
List<PedidoLinea> lineas = pedidoLineaRepository.findByPedidoId(pedidoId);
List<Map<String, Object>> referenciasProveedor = new ArrayList<>();
Integer total = lineas.size();
Integer counter = 1;
for (PedidoLinea linea : lineas) {
if (linea.getEstado() == Estado.pendiente_pago
|| linea.getEstado() == Estado.denegado_pago) {
Presupuesto presupuesto = linea.getPresupuesto();
linea.setEstado(getEstadoInicial(presupuesto));
pedidoLineaRepository.save(linea);
// Save presupuesto in SK
Map<String, Object> result = savePresupuestoSK(linea.getId(), presupuesto, counter, total);
if (result == null) {
return false;
}
referenciasProveedor.add(result);
counter++;
}
}
if (referenciasProveedor.isEmpty()) {
return false;
}
// Save pedido in SK
ArrayList<Long> presupuestoSkIds = new ArrayList<>();
for (Map<String, Object> presData : referenciasProveedor) {
Long presId = ((Number) presData.get("id")).longValue();
presupuestoSkIds.add(presId);
}
Map<String, Object> ids = new HashMap<>();
ids.put("presupuesto_ids", presupuestoSkIds);
Long skPedidoId = skApiClient.crearPedido(ids);
if (skPedidoId == null) {
System.out.println("No se pudo crear el pedido en SK.");
return false;
}
pedido.setProveedor("Safekat");
pedido.setProveedorRef(skPedidoId.toString());
pedidoRepository.save(pedido);
return true;
}
public Map<String, Object> actualizarEstado(Long pedidoLineaId, Locale locale) {
PedidoLinea pedidoLinea = pedidoLineaRepository.findById(pedidoLineaId).orElse(null);
if (pedidoLinea == null) {
return Map.of(
"success", false,
"message", messageSource.getMessage("pedido.errors.linea-not-found", null, locale));
}
PedidoLinea.Estado estadoOld = pedidoLinea.getEstado();
if (estadoOld == null) {
return Map.of(
"success", false,
"message", messageSource.getMessage("pedido.errors.cannot-update", null, locale));
}
// Rango: >= haciendo_ferro y < enviado
if (estadoOld.getPriority() < PedidoLinea.Estado.haciendo_ferro.getPriority()
|| estadoOld.getPriority() >= PedidoLinea.Estado.enviado.getPriority()) {
return Map.of(
"success", false,
"message", messageSource.getMessage("pedido.errors.cannot-update", null, locale));
}
var presupuesto = pedidoLinea.getPresupuesto();
if (presupuesto == null || presupuesto.getProveedorRef2() == null) {
return Map.of(
"success", false,
"message", messageSource.getMessage("pedido.errors.update-server-error", null, locale));
}
Long refExterna;
try {
refExterna = Long.valueOf(presupuesto.getProveedorRef2().toString());
} catch (Exception ex) {
return Map.of(
"success", false,
"message", messageSource.getMessage("pedido.errors.update-server-error", null, locale));
}
Map<String, Object> result = skApiClient.checkPedidoEstado(refExterna, locale);
if (result == null || result.get("estado") == null) {
return Map.of(
"success", false,
"message", messageSource.getMessage("pedido.errors.update-server-error", null, locale));
}
String estadoStr = String.valueOf(result.get("estado"));
PedidoLinea.Estado estadoSk;
try {
// si la API devuelve minúsculas tipo "produccion", esto funciona
estadoSk = PedidoLinea.Estado.valueOf(estadoStr.trim().toLowerCase());
} catch (Exception ex) {
return Map.of(
"success", false,
"message", messageSource.getMessage("pedido.errors.update-server-error", null, locale));
}
if (estadoOld == estadoSk) {
return Map.of(
"success", true,
"state", messageSource.getMessage("pedido.estado." + estadoSk.name(), null, locale),
"stateKey", estadoSk.name(),
"message", messageSource.getMessage("pedido.success.same-estado", null, locale));
}
pedidoLinea.setEstado(estadoSk);
pedidoLineaRepository.save(pedidoLinea);
return Map.of(
"success", true,
"state", messageSource.getMessage("pedido.estado." + estadoSk.name(), null, locale),
"stateKey", estadoSk.name(),
"message", messageSource.getMessage("pedido.success.estado-actualizado", null, locale));
}
public Boolean markPedidoAsMaquetacionDone(Long pedidoId) {
Pedido pedido = pedidoRepository.findById(pedidoId).orElse(null);
if (pedido == null) {
return false;
}
List<PedidoLinea> lineas = pedidoLineaRepository.findByPedidoId(pedidoId);
for (PedidoLinea linea : lineas) {
if (linea.getEstado() == Estado.maquetacion) {
linea.setEstado(Estado.haciendo_ferro);
pedidoLineaRepository.save(linea);
}
}
return true;
}
public Map<String, Object> getFilesType(Long pedidoLineaId, Locale locale) {
PedidoLinea pedidoLinea = pedidoLineaRepository.findById(pedidoLineaId).orElse(null);
if (pedidoLinea == null) {
return Map.of("success", false, "message", "Línea de pedido no encontrada.");
}
Map<String, Object> files = skApiClient.getFilesTypes(
Long.valueOf(pedidoLinea.getPresupuesto().getProveedorRef2().toString()), locale);
return files;
}
public byte[] getFerroFileContent(Long pedidoLineaId, Locale locale) {
return downloadFile(pedidoLineaId, "ferro", locale);
}
public byte[] getCubiertaFileContent(Long pedidoLineaId, Locale locale) {
return downloadFile(pedidoLineaId, "cubierta", locale);
}
public byte[] getTapaFileContent(Long pedidoLineaId, Locale locale) {
return downloadFile(pedidoLineaId, "tapa", locale);
}
public Boolean aceptarFerro(Long pedidoLineaId, Locale locale) {
PedidoLinea pedidoLinea = pedidoLineaRepository.findById(pedidoLineaId).orElse(null);
if (pedidoLinea == null) {
return false;
}
if (pedidoLinea.getEstado() != PedidoLinea.Estado.esperando_aceptacion_ferro) {
return false;
}
Boolean result = skApiClient.aceptarFerro(
Long.valueOf(pedidoLinea.getPresupuesto().getProveedorRef2().toString()), locale);
if (!result) {
return false;
}
pedidoLinea.setEstado(PedidoLinea.Estado.produccion);
pedidoLineaRepository.save(pedidoLinea);
return true;
}
public Boolean cancelarPedido(Long pedidoId) {
Pedido pedido = pedidoRepository.findById(pedidoId).orElse(null);
if (pedido == null) {
return false;
}
Boolean result = skApiClient.cancelarPedido(Long.valueOf(pedido.getProveedorRef()));
if (!result) {
return false;
}
List<PedidoLinea> lineas = pedidoLineaRepository.findByPedidoId(pedidoId);
for (PedidoLinea linea : lineas) {
if (linea.getEstado() != PedidoLinea.Estado.terminado && linea.getEstado() != PedidoLinea.Estado.enviado) {
linea.setEstado(PedidoLinea.Estado.cancelado);
pedidoLineaRepository.save(linea);
}
}
return true;
}
/***************************
* MÉTODOS PRIVADOS
***************************/
private byte[] downloadFile(Long pedidoLineaId, String fileType, Locale locale) {
PedidoLinea pedidoLinea = pedidoLineaRepository.findById(pedidoLineaId).orElse(null);
if (pedidoLinea == null) {
return null;
}
byte[] fileData = skApiClient.downloadFile(
Long.valueOf(pedidoLinea.getPresupuesto().getProveedorRef2().toString()),
fileType,
locale);
return fileData;
}
@Transactional
private Map<String, Object> savePresupuestoSK(Long pedidoLineaId, Presupuesto presupuesto, Integer counter,
Integer total) {
Map<String, Object> data_to_send = presupuestoService.toSkApiRequest(presupuesto, true);
data_to_send.put("createPedido", 0);
// Recuperar el mapa anidado datosCabecera
@SuppressWarnings("unchecked")
Map<String, Object> datosCabecera = (Map<String, Object>) data_to_send.get("datosCabecera");
if (datosCabecera != null) {
Object tituloOriginal = datosCabecera.get("titulo");
datosCabecera.put(
"titulo",
"[" + (counter) + "/" + total + "] " + (tituloOriginal != null ? tituloOriginal : ""));
}
List<PedidoDireccion> direccionesPedidoLinea = pedidoDireccionRepository
.findByPedidoLineaId(pedidoLineaId);
List<Map<String, Object>> direccionesPresupuesto = new ArrayList<>();
List<Map<String, Object>> direccionEjemplarPrueba = new ArrayList<>();
for (PedidoDireccion pd : direccionesPedidoLinea) {
if (pd.isEjemplarPrueba()) {
direccionEjemplarPrueba.add(
pd.toSkMap(presupuesto.getPeso()));
} else {
direccionesPresupuesto.add(
pd.toSkMap(presupuesto.getPeso() * pd.getUnidades()));
}
}
if (presupuesto.getServiciosJson() != null && presupuesto.getServiciosJson().contains("deposito-legal")) {
direccionesPresupuesto.add(
PedidoDireccion.toSkMapDepositoLegal());
}
data_to_send.put("direcciones", direccionesPresupuesto);
if (direccionEjemplarPrueba.size() > 0)
data_to_send.put("direccionesFP1", direccionEjemplarPrueba.get(0));
else {
data_to_send.put("direccionesFP1", new ArrayList<>());
}
Map<String, Object> result = skApiClient.savePresupuesto(data_to_send);
if (result.containsKey("error")) {
System.out.println("Error al guardar presupuesto en SK");
System.out.println("-------------------------");
System.out.println(result.get("error"));
// decide si seguir con otros items o abortar:
// continue; o bien throw ...
return null;
}
Object dataObj = result.get("data");
if (!(dataObj instanceof Map<?, ?> dataRaw)) {
System.out.println("Formato inesperado de 'data' en savePresupuesto: " + result);
return null;
}
@SuppressWarnings("unchecked")
Map<String, Object> dataMap = (Map<String, Object>) dataRaw;
Long presId = ((Number) dataMap.get("id")).longValue();
String skin = ((String) dataMap.get("iskn")).toString();
presupuesto.setProveedor("Safekat");
presupuesto.setProveedorRef1(skin);
presupuesto.setProveedorRef2(presId);
presupuesto.setEstado(Presupuesto.Estado.aceptado);
presupuestoRepo.save(presupuesto);
return dataMap;
}
// Obtener las direcciones de envío asociadas a un presupuesto en el carrito
private Map<String, Object> getDireccionesPresupuesto(Cart cart, Presupuesto presupuesto) {
List<Map<String, Object>> direccionesPresupuesto = new ArrayList<>();
List<Map<String, Object>> direccionesPrueba = new ArrayList<>();
if (cart.getOnlyOneShipment()) {
List<CartDireccion> direcciones = cart.getDirecciones().stream().limit(1).toList();
if (!direcciones.isEmpty()) {
if (presupuesto.getServiciosJson() != null
&& presupuesto.getServiciosJson().contains("deposito-legal")) {
direccionesPresupuesto.add(direcciones.get(0).toSkMap(
presupuesto.getSelectedTirada(),
presupuesto.getPeso(),
direcciones.get(0).getIsPalets(),
false));
direccionesPresupuesto.add(direcciones.get(0).toSkMapDepositoLegal());
} else {
direccionesPresupuesto.add(direcciones.get(0).toSkMap(
presupuesto.getSelectedTirada(),
presupuesto.getPeso(),
direcciones.get(0).getIsPalets(),
false));
}
if (presupuesto.getServiciosJson() != null
&& presupuesto.getServiciosJson().contains("ejemplar-prueba")) {
direccionesPrueba.add(direcciones.get(0).toSkMap(
1,
presupuesto.getPeso(),
false,
true));
}
Map<String, Object> direccionesRet = new HashMap<>();
direccionesRet.put("direcciones", direccionesPresupuesto);
if (!direccionesPrueba.isEmpty())
direccionesRet.put("direccionesFP1", direccionesPrueba.get(0));
else {
direccionesRet.put("direccionesFP1", new ArrayList<>());
}
return direccionesRet;
}
} else {
List<CartDireccion> direcciones = cart.getDirecciones().stream()
.filter(d -> d.getPresupuesto() != null && d.getPresupuesto().getId().equals(presupuesto.getId()))
.toList();
for (CartDireccion cd : direcciones) {
// direccion de ejemplar de prueba
if (cd.getPresupuesto() == null || !cd.getPresupuesto().getId().equals(presupuesto.getId())) {
continue;
}
if (cd.getUnidades() == null || cd.getUnidades() <= 0) {
direccionesPrueba.add(cd.toSkMap(
1,
presupuesto.getPeso(),
false,
true));
} else {
direccionesPresupuesto.add(cd.toSkMap(
cd.getUnidades(),
presupuesto.getPeso(),
cd.getIsPalets(),
false));
}
}
if (presupuesto.getServiciosJson() != null
&& presupuesto.getServiciosJson().contains("deposito-legal")) {
CartDireccion cd = new CartDireccion();
direccionesPresupuesto.add(cd.toSkMapDepositoLegal());
}
}
Map<String, Object> direccionesRet = new HashMap<>();
direccionesRet.put("direcciones", direccionesPresupuesto);
if (!direccionesPrueba.isEmpty())
direccionesRet.put("direccionesFP1", direccionesPrueba.get(0));
else {
direccionesRet.put("direccionesFP1", new ArrayList<>());
}
return direccionesRet;
}
@Transactional
private void saveDireccionesPedidoLinea(
Map<String, Object> direcciones,
Pedido pedido,
PedidoLinea linea, Long direccionFacturacionId) {
String email = pedido.getCreatedBy().getUserName();
// direccion prueba
if (direcciones.containsKey("direccionesFP1")) {
try {
@SuppressWarnings("unchecked")
Map<String, Object> fp1 = (Map<String, Object>) direcciones.get("direccionesFP1");
@SuppressWarnings("unchecked")
PedidoDireccion direccion = saveDireccion(
email,
false,
(HashMap<String, Object>) fp1.get("direccion"),
pedido,
linea,
true,
false);
pedidoDireccionRepository.save(direccion);
} catch (Exception e) {
// Viene vacio
}
}
if (direcciones.containsKey("direcciones")) {
List<?> dirs = (List<?>) direcciones.get("direcciones");
for (Object dir : dirs) {
@SuppressWarnings("unchecked")
HashMap<String, Object> direccionEnvio = (HashMap<String, Object>) ((HashMap<String, Object>) dir)
.get("direccion");
if (direccionEnvio.get("cantidad") != null && (Integer) direccionEnvio.get("cantidad") == 4
&& direccionEnvio.get("att").toString().contains("Depósito Legal")) {
continue; // Saltar la dirección de depósito legal
}
@SuppressWarnings("unchecked")
PedidoDireccion direccion = saveDireccion(
email,
((Number) ((HashMap<String, Object>) dir)
.getOrDefault("entregaPalets", 0))
.intValue() == 1,
(HashMap<String, Object>) ((HashMap<String, Object>) dir).get("direccion"),
pedido,
linea, false,
false);
pedidoDireccionRepository.save(direccion);
}
}
if (direccionFacturacionId != null) {
Direccion dirFact = direccionService.findById(direccionFacturacionId).orElse(null);
if (dirFact != null) {
HashMap<String, Object> dirFactMap = new HashMap<>();
dirFactMap.put("att", dirFact.getAtt());
dirFactMap.put("direccion", dirFact.getDireccion());
dirFactMap.put("cp", dirFact.getCp());
dirFactMap.put("municipio", dirFact.getCiudad());
dirFactMap.put("provincia", dirFact.getProvincia());
dirFactMap.put("pais_code3", dirFact.getPaisCode3());
dirFactMap.put("telefono", dirFact.getTelefono());
dirFactMap.put("instrucciones", dirFact.getInstrucciones());
dirFactMap.put("razon_social", dirFact.getRazonSocial());
dirFactMap.put("tipo_identificacion_fiscal", dirFact.getTipoIdentificacionFiscal().name());
dirFactMap.put("identificacion_fiscal", dirFact.getIdentificacionFiscal());
PedidoDireccion direccion = saveDireccion(
email,
false,
dirFactMap,
pedido,
linea,
false,
true);
pedidoDireccionRepository.save(direccion);
}
}
}
private PedidoDireccion saveDireccion(
String email,
Boolean palets,
HashMap<String, Object> dir,
Pedido pedido,
PedidoLinea linea,
Boolean isEjemplarPrueba,
Boolean isFacturacion) {
PedidoDireccion direccion = new PedidoDireccion();
direccion.setEmail(email);
direccion.setPalets(isEjemplarPrueba || isFacturacion ? false : palets);
direccion.setPedidoLinea(isFacturacion ? null : linea);
if (isFacturacion) {
direccion.setUnidades(null);
direccion.setFacturacion(true);
direccion.setPedido(pedido);
} else {
if (isEjemplarPrueba) {
direccion.setUnidades(1);
direccion.setEjemplarPrueba(true);
} else {
direccion.setUnidades((Integer) dir.getOrDefault("cantidad", 1));
direccion.setEjemplarPrueba(false);
}
direccion.setFacturacion(false);
}
direccion.setAtt((String) dir.getOrDefault("att", ""));
direccion.setDireccion((String) dir.getOrDefault("direccion", ""));
direccion.setCp((Integer) dir.getOrDefault("cp", 0));
direccion.setCiudad((String) dir.getOrDefault("municipio", ""));
direccion.setProvincia((String) dir.getOrDefault("provincia", ""));
direccion.setPaisCode3((String) dir.getOrDefault("pais_code3", "esp"));
direccion.setTelefono((String) dir.getOrDefault("telefono", ""));
direccion.setInstrucciones((String) dir.getOrDefault("instrucciones", ""));
direccion.setRazonSocial((String) dir.getOrDefault("razon_social", ""));
direccion.setTipoIdentificacionFiscal(Direccion.TipoIdentificacionFiscal
.valueOf((String) dir.getOrDefault("tipo_identificacion_fiscal",
Direccion.TipoIdentificacionFiscal.DNI.name())));
direccion.setIdentificacionFiscal((String) dir.getOrDefault("identificacion_fiscal", ""));
return direccion;
}
private Estado getEstadoInicial(Presupuesto p) {
if (presupuestoService.hasMaquetacion(p)) {
return Estado.maquetacion;
} else {
return Estado.haciendo_ferro;
}
}
}

View File

@ -0,0 +1,454 @@
package com.imprimelibros.erp.pedidos;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import java.security.Principal;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.context.MessageSource;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.datatables.DataTable;
import com.imprimelibros.erp.datatables.DataTablesParser;
import com.imprimelibros.erp.datatables.DataTablesRequest;
import com.imprimelibros.erp.datatables.DataTablesResponse;
import com.imprimelibros.erp.facturacion.service.FacturacionService;
import com.imprimelibros.erp.i18n.TranslationService;
import com.imprimelibros.erp.paises.PaisesService;
import com.imprimelibros.erp.presupuesto.service.PresupuestoService;
import com.imprimelibros.erp.users.UserDao;
import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.JoinType;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@Controller
@RequestMapping("/pedidos")
public class PedidosController {
private final PresupuestoService presupuestoService;
private final PedidoRepository repoPedido;
private final PedidoService pedidoService;
private final UserDao repoUser;
private final MessageSource messageSource;
private final PedidoLineaRepository repoPedidoLinea;
private final PaisesService paisesService;
private final TranslationService translationService;
private final FacturacionService facturacionService;
public PedidosController(PedidoRepository repoPedido, PedidoService pedidoService, UserDao repoUser,
MessageSource messageSource, TranslationService translationService,
PedidoLineaRepository repoPedidoLinea, PaisesService paisesService,
FacturacionService facturacionService, PresupuestoService presupuestoService) {
this.repoPedido = repoPedido;
this.pedidoService = pedidoService;
this.repoUser = repoUser;
this.messageSource = messageSource;
this.translationService = translationService;
this.repoPedidoLinea = repoPedidoLinea;
this.paisesService = paisesService;
this.facturacionService = facturacionService;
this.presupuestoService = presupuestoService;
}
@GetMapping
public String listarPedidos(Model model, Locale locale) {
List<String> keys = List.of(
"app.cancelar",
"app.seleccionar",
"app.yes",
"checkout.payment.card",
"checkout.payment.bizum",
"checkout.payment.bank-transfer",
"checkout.error.select-method");
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
if (Utils.isCurrentUserAdmin()) {
return "imprimelibros/pedidos/pedidos-list";
}
return "imprimelibros/pedidos/pedidos-list-cliente";
}
@GetMapping(value = "datatable", produces = "application/json")
@ResponseBody
public DataTablesResponse<Map<String, Object>> getDatatable(
HttpServletRequest request,
Principal principal,
Locale locale) {
DataTablesRequest dt = DataTablesParser.from(request);
Boolean isAdmin = Utils.isCurrentUserAdmin();
Long currentUserId = Utils.currentUserId(principal);
List<String> searchable = List.of(
"id");
// Campos ordenables
List<String> orderable = List.of(
"id",
"createdBy.fullName",
"createdAt",
"total",
"estado");
Specification<Pedido> base = (root, query, cb) -> cb.conjunction();
if (!isAdmin) {
base = base.and((root, query, cb) -> cb.equal(root.get("createdBy").get("id"), currentUserId));
}
String clientSearch = dt.getColumnSearch("cliente");
String estadoSearch = dt.getColumnSearch("estado");
// 2) Si hay filtro, traducirlo a userIds y añadirlo al Specification
if (clientSearch != null) {
List<Long> userIds = repoUser.findIdsByFullNameLike(clientSearch.trim());
if (userIds.isEmpty()) {
// Ningún usuario coincide → forzamos 0 resultados
base = base.and((root, query, cb) -> cb.disjunction());
} else {
base = base.and((root, query, cb) -> root.get("createdBy").in(userIds));
}
}
if (estadoSearch != null && !estadoSearch.isBlank()) {
try {
PedidoLinea.Estado estadoEnum = PedidoLinea.Estado.valueOf(estadoSearch.trim());
base = base.and((root, query, cb) -> {
// Evitar duplicados de pedidos si el provider usa joins
if (Pedido.class.equals(query.getResultType())) {
query.distinct(true);
}
Join<Pedido, PedidoLinea> lineas = root.join("lineas", JoinType.INNER);
return cb.equal(lineas.get("estado"), estadoEnum);
});
} catch (IllegalArgumentException ex) {
// Valor de estado no válido → forzamos 0 resultados
base = base.and((root, query, cb) -> cb.disjunction());
}
}
Long total = repoPedido.count(base);
return DataTable
.of(repoPedido, Pedido.class, dt, searchable)
.orderable(orderable)
.add("id", Pedido::getId)
.add("created_at", pedido -> Utils.formatInstant(pedido.getCreatedAt(), locale))
.add("cliente", pedido -> {
if (pedido.getCreatedBy() != null) {
return pedido.getCreatedBy().getFullName();
}
return "";
})
.add("total", pedido -> {
if (pedido.getTotal() != null) {
return Utils.formatCurrency(pedido.getTotal(), locale);
} else {
return "";
}
})
.add("estado", pedido -> {
List<PedidoLinea> lineas = repoPedidoLinea.findByPedidoId(pedido.getId());
if (lineas.isEmpty()) {
return "";
}
// concatenar los estados de las líneas, ordenados por prioridad
StringBuilder sb = new StringBuilder();
lineas.stream()
.map(PedidoLinea::getEstado)
.distinct()
.sorted(Comparator.comparingInt(PedidoLinea.Estado::getPriority))
.forEach(estado -> {
if (sb.length() > 0) {
sb.append(", ");
}
sb.append(messageSource.getMessage(estado.getMessageKey(), null, locale));
});
String text = sb.toString();
return text;
})
.add("actions", pedido -> {
String data = "<span class=\'badge bg-success btn-view \' data-id=\'" + pedido.getId()
+ "\' style=\'cursor: pointer;\'>"
+ messageSource.getMessage("app.view", null, locale) + "</span>";
List<PedidoLinea> lineas = repoPedidoLinea.findByPedidoId(pedido.getId());
boolean hasDenegadoPago = lineas.stream()
.anyMatch(linea -> PedidoLinea.Estado.denegado_pago.equals(linea.getEstado()));
if (hasDenegadoPago) {
data += " <span class='badge bg-danger btn-pay' data-amount='" + (int) (pedido.getTotal() * 100)
+ "' data-id='" + pedido.getId() + "' style='cursor: pointer;'>"
+ messageSource.getMessage("app.pay", null, locale) + "</span>";
}
return data;
})
.where(base)
.toJson(total);
}
@GetMapping("/view/{id}")
public String verPedido(
@PathVariable(name = "id", required = true) Long id,
Model model, Locale locale) {
List<String> keys = List.of(
"app.cancelar",
"app.yes",
"pedido.view.cancel-title",
"pedido.view.cancel-text");
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
Boolean isAdmin = Utils.isCurrentUserAdmin();
if (isAdmin) {
model.addAttribute("isAdmin", true);
} else {
model.addAttribute("isAdmin", false);
}
PedidoDireccion direccionFacturacion = pedidoService.getDireccionFacturacionPedido(id);
if (direccionFacturacion != null) {
String paisNombre = paisesService.getPaisNombrePorCode3(direccionFacturacion.getPaisCode3(), locale);
direccionFacturacion.setPaisNombre(paisNombre);
}
model.addAttribute("direccionFacturacion", direccionFacturacion);
Boolean showCancel = false;
Boolean showDownloadFactura = true;
List<Map<String, Object>> lineas = pedidoService.getLineas(id, locale);
for (Map<String, Object> linea : lineas) {
PedidoLinea pedidoLinea = repoPedidoLinea.findById(
((Number) linea.get("lineaId")).longValue()).orElse(null);
if (pedidoLinea != null) {
Map<String, Boolean> buttons = new HashMap<>();
if (pedidoLinea.getEstado() != PedidoLinea.Estado.enviado) {
showDownloadFactura = false;
}
if (pedidoLinea.getEstado().getPriority() >= PedidoLinea.Estado.esperando_aceptacion_ferro.getPriority()
&& pedidoLinea.getEstado().getPriority() <= PedidoLinea.Estado.produccion.getPriority()) {
if (pedidoLinea.getEstado() == PedidoLinea.Estado.esperando_aceptacion_ferro) {
buttons.put("aceptar_ferro", true);
} else {
buttons.put("aceptar_ferro", false);
}
Map<String, Object> filesType = pedidoService.getFilesType(pedidoLinea.getId(), locale);
if (filesType == null || filesType.get("error") != null) {
throw new RuntimeException(
messageSource.getMessage("pedido.errors.update-server-error", null, locale));
}
for (String key : filesType.keySet()) {
buttons.put(key, (Integer) filesType.get(key) == 1 ? true : false);
}
linea.put("buttons", buttons);
}
if (pedidoLinea.getEstado() != PedidoLinea.Estado.cancelado
&& pedidoLinea.getEstado() != PedidoLinea.Estado.terminado
&& pedidoLinea.getEstado() != PedidoLinea.Estado.enviado) {
showCancel = true;
}
}
List<PedidoDireccion> dirEntrega = pedidoService.getDireccionesEntregaPedidoLinea(
((Number) linea.get("lineaId")).longValue());
if (dirEntrega != null && !dirEntrega.isEmpty()) {
for (PedidoDireccion direccion : dirEntrega) {
String paisNombre = paisesService.getPaisNombrePorCode3(direccion.getPaisCode3(), locale);
direccion.setPaisNombre(paisNombre);
}
}
linea.put("direccionesEntrega", dirEntrega);
}
Long facturaId = null;
if (showDownloadFactura) {
facturaId = facturacionService.getFacturaIdFromPedidoId(id);
}
model.addAttribute("lineas", lineas);
model.addAttribute("showCancel", showCancel);
if (showDownloadFactura && facturaId != null) {
model.addAttribute("facturaId", facturaId);
model.addAttribute("showDownloadFactura", showDownloadFactura);
}
model.addAttribute("id", id);
return "imprimelibros/pedidos/pedidos-view";
}
@PostMapping("/cancel/{id}")
@ResponseBody
public Map<String, Object> cancelPedido(
@PathVariable(name = "id", required = true) Long id,
Locale locale) {
Boolean result = pedidoService.cancelarPedido(id);
if (result) {
String successMsg = messageSource.getMessage("pedido.success.pedido-cancelado", null, locale);
return Map.of(
"success", true,
"message", successMsg);
} else {
String errorMsg = messageSource.getMessage("pedido.errors.cancel-pedido", null, locale);
return Map.of(
"success", false,
"message", errorMsg);
}
}
// -------------------------------------
// Acciones sobre las lineas de pedido
// -------------------------------------
@PostMapping("/linea/{id}/update-status")
@ResponseBody
public Map<String, Object> updateStatus(
@PathVariable(name = "id", required = true) Long id, Locale locale) {
Map<String, Object> result = pedidoService.actualizarEstado(id, locale);
return result;
}
@PostMapping("/linea/{id}/update-maquetacion")
@ResponseBody
public Map<String, Object> updateMaquetacion(
@PathVariable(name = "id", required = true) Long id,
Locale locale) {
PedidoLinea entity = repoPedidoLinea.findById(id).orElse(null);
if (entity == null) {
String errorMsg = messageSource.getMessage("pedido.errors.linea-not-found", null, locale);
return Map.of(
"success", false,
"message", errorMsg);
}
if (entity.getEstado() != PedidoLinea.Estado.maquetacion) {
String errorMsg = messageSource.getMessage("pedido.errors.state-error", null, locale);
return Map.of(
"success", false,
"message", errorMsg);
}
entity.setEstado(PedidoLinea.Estado.haciendo_ferro);
repoPedidoLinea.save(entity);
String successMsg = messageSource.getMessage("pedido.success.estado-actualizado", null, locale);
return Map.of(
"success", true,
"message", successMsg,
"state", messageSource.getMessage(entity.getEstado().getMessageKey(), null, locale));
}
@GetMapping("/linea/{id}/download-ferro")
public ResponseEntity<Resource> downloadFerro(@PathVariable(name = "id", required = true) Long id, Locale locale) {
byte[] ferroFileContent = pedidoService.getFerroFileContent(id, locale);
if (ferroFileContent == null) {
return ResponseEntity.notFound().build();
}
ByteArrayResource resource = new ByteArrayResource(ferroFileContent);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=ferro_" + id + ".pdf")
.contentType(MediaType.APPLICATION_PDF)
.body(resource);
}
@GetMapping("/linea/{id}/download-cub")
public ResponseEntity<Resource> downloadCubierta(@PathVariable(name = "id", required = true) Long id,
Locale locale) {
byte[] cubFileContent = pedidoService.getCubiertaFileContent(id, locale);
if (cubFileContent == null) {
return ResponseEntity.notFound().build();
}
ByteArrayResource resource = new ByteArrayResource(cubFileContent);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=cubierta_" + id + ".pdf")
.contentType(MediaType.APPLICATION_PDF)
.body(resource);
}
@GetMapping("/linea/{id}/download-tapa")
public ResponseEntity<Resource> downloadTapa(@PathVariable(name = "id", required = true) Long id, Locale locale) {
byte[] tapaFileContent = pedidoService.getTapaFileContent(id, locale);
if (tapaFileContent == null) {
return ResponseEntity.notFound().build();
}
ByteArrayResource resource = new ByteArrayResource(tapaFileContent);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=tapa_" + id + ".pdf")
.contentType(MediaType.APPLICATION_PDF)
.body(resource);
}
@PostMapping("/linea/{id}/aceptar-ferro")
@ResponseBody
public Map<String, Object> aceptarFerro(@PathVariable(name = "id", required = true) Long id,
Locale locale) {
PedidoLinea entity = repoPedidoLinea.findById(id).orElse(null);
if (entity == null) {
String errorMsg = messageSource.getMessage("pedido.errors.linea-not-found", null, locale);
return Map.of(
"success", false,
"message", errorMsg);
}
if (entity.getEstado() != PedidoLinea.Estado.esperando_aceptacion_ferro) {
String errorMsg = messageSource.getMessage("pedido.errors.state-error", null, locale);
return Map.of(
"success", false,
"message", errorMsg);
}
Boolean result = pedidoService.aceptarFerro(id, locale);
if (result) {
String successMsg = messageSource.getMessage("pedido.success.estado-actualizado", null, locale);
return Map.of(
"success", true,
"message", successMsg,
"state", messageSource.getMessage(entity.getEstado().getMessageKey(), null, locale));
} else {
String errorMsg = messageSource.getMessage("pedido.errors.update-server-error", null, locale);
return Map.of(
"success", false,
"message", errorMsg);
}
}
}

View File

@ -14,6 +14,8 @@ import java.util.Optional;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource; import org.springframework.context.MessageSource;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@ -52,6 +54,7 @@ import com.imprimelibros.erp.users.UserDao;
import com.imprimelibros.erp.users.UserDetailsImpl; import com.imprimelibros.erp.users.UserDetailsImpl;
import com.imprimelibros.erp.presupuesto.service.PresupuestoFormDataMapper; import com.imprimelibros.erp.presupuesto.service.PresupuestoFormDataMapper;
import com.imprimelibros.erp.presupuesto.service.PresupuestoFormDataMapper.PresupuestoFormDataDto; import com.imprimelibros.erp.presupuesto.service.PresupuestoFormDataMapper.PresupuestoFormDataDto;
import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.common.web.IpUtils; import com.imprimelibros.erp.common.web.IpUtils;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@ -62,6 +65,8 @@ import jakarta.validation.Valid;
@RequestMapping("/presupuesto") @RequestMapping("/presupuesto")
public class PresupuestoController { public class PresupuestoController {
private static final Logger log = LoggerFactory.getLogger(PresupuestoController.class);
private final PresupuestoRepository presupuestoRepository; private final PresupuestoRepository presupuestoRepository;
@Autowired @Autowired
@ -146,7 +151,9 @@ public class PresupuestoController {
return ResponseEntity.badRequest().body(errores); return ResponseEntity.badRequest().body(errores);
} }
Map<String, Object> resultado = new HashMap<>(); Map<String, Object> resultado = new HashMap<>();
resultado.put("solapas", apiClient.getMaxSolapas(presupuestoService.toSkApiRequest(presupuesto), locale)); Map<String , Object> datosInterior = apiClient.getMaxSolapas(presupuestoService.toSkApiRequest(presupuesto), locale);
resultado.put("solapas", datosInterior.get("maxSolapas"));
resultado.put("lomo", datosInterior.get("lomo"));
resultado.putAll(presupuestoService.obtenerOpcionesAcabadosCubierta(presupuesto, locale)); resultado.putAll(presupuestoService.obtenerOpcionesAcabadosCubierta(presupuesto, locale));
return ResponseEntity.ok(resultado); return ResponseEntity.ok(resultado);
} }
@ -266,7 +273,10 @@ public class PresupuestoController {
} }
} }
resultado.put("solapas", apiClient.getMaxSolapas(presupuestoService.toSkApiRequest(presupuesto), locale)); Map<String , Object> datosInterior = apiClient.getMaxSolapas(presupuestoService.toSkApiRequest(presupuesto), locale);
resultado.put("solapas", datosInterior.get("maxSolapas"));
resultado.put("lomo", datosInterior.get("lomo"));
return ResponseEntity.ok(resultado); return ResponseEntity.ok(resultado);
} }
@ -299,7 +309,10 @@ public class PresupuestoController {
presupuesto.setGramajeInterior(Integer.parseInt(opciones.get(0))); // Asignar primera opción presupuesto.setGramajeInterior(Integer.parseInt(opciones.get(0))); // Asignar primera opción
} }
} }
resultado.put("solapas", apiClient.getMaxSolapas(presupuestoService.toSkApiRequest(presupuesto), locale)); Map<String , Object> datosInterior = apiClient.getMaxSolapas(presupuestoService.toSkApiRequest(presupuesto), locale);
resultado.put("solapas", datosInterior.get("maxSolapas"));
resultado.put("lomo", datosInterior.get("lomo"));
return ResponseEntity.ok(resultado); return ResponseEntity.ok(resultado);
} }
@ -322,7 +335,10 @@ public class PresupuestoController {
} }
Map<String, Object> resultado = new HashMap<>(); Map<String, Object> resultado = new HashMap<>();
resultado.put("solapas", apiClient.getMaxSolapas(presupuestoService.toSkApiRequest(presupuesto), locale)); Map<String , Object> datosInterior = apiClient.getMaxSolapas(presupuestoService.toSkApiRequest(presupuesto), locale);
resultado.put("solapas", datosInterior.get("maxSolapas"));
resultado.put("lomo", datosInterior.get("lomo"));
return ResponseEntity.ok(resultado); return ResponseEntity.ok(resultado);
} }
@ -491,7 +507,8 @@ public class PresupuestoController {
String sessionId = request.getSession(true).getId(); String sessionId = request.getSession(true).getId();
String ip = IpUtils.getClientIp(request); String ip = IpUtils.getClientIp(request);
var resumen = presupuestoService.getResumen(p, serviciosList, datosMaquetacion, datosMarcapaginas, save, mode, locale, sessionId, ip); var resumen = presupuestoService.getResumen(p, serviciosList, datosMaquetacion, datosMarcapaginas, save, mode,
locale, sessionId, ip);
return ResponseEntity.ok(resumen); return ResponseEntity.ok(resumen);
} }
@ -518,7 +535,27 @@ public class PresupuestoController {
"presupuesto.add.cancel", "presupuesto.add.cancel",
"presupuesto.add.select-client", "presupuesto.add.select-client",
"presupuesto.add.error.options", "presupuesto.add.error.options",
"presupuesto.add.error.options-client"); "presupuesto.add.error.options-client",
"presupuesto.duplicar.title",
"presupuesto.duplicar.text",
"presupuesto.duplicar.confirm",
"presupuesto.duplicar.cancelar",
"presupuesto.duplicar.aceptar",
"presupuesto.duplicar.required",
"presupuesto.duplicar.success.title",
"presupuesto.duplicar.success.text",
"presupuesto.duplicar.error.title",
"presupuesto.duplicar.error.internal",
"presupuesto.reimprimir.title",
"presupuesto.reimprimir.text",
"presupuesto.reimprimir.confirm",
"presupuesto.reimprimir.cancelar",
"presupuesto.reimprimir.aceptar",
"presupuesto.reimprimir.success.title",
"presupuesto.reimprimir.success.text",
"presupuesto.reimprimir.error.title",
"presupuesto.reimprimir.error.internal"
);
Map<String, String> translations = translationService.getTranslations(locale, keys); Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations); model.addAttribute("languageBundle", translations);
@ -542,7 +579,26 @@ public class PresupuestoController {
"presupuesto.exito.guardado", "presupuesto.exito.guardado",
"presupuesto.add.error.save.title", "presupuesto.add.error.save.title",
"presupuesto.iva-reducido", "presupuesto.iva-reducido",
"presupuesto.iva-reducido-descripcion"); "presupuesto.iva-reducido-descripcion",
"presupuesto.duplicar.title",
"presupuesto.duplicar.text",
"presupuesto.duplicar.confirm",
"presupuesto.duplicar.cancelar",
"presupuesto.duplicar.aceptar",
"presupuesto.duplicar.required",
"presupuesto.duplicar.success.title",
"presupuesto.duplicar.success.text",
"presupuesto.duplicar.error.title",
"presupuesto.duplicar.error.internal",
"presupuesto.reimprimir.title",
"presupuesto.reimprimir.text",
"presupuesto.reimprimir.confirm",
"presupuesto.reimprimir.cancelar",
"presupuesto.reimprimir.aceptar",
"presupuesto.reimprimir.success.title",
"presupuesto.reimprimir.success.text",
"presupuesto.reimprimir.error.title",
"presupuesto.reimprimir.error.internal");
Map<String, String> translations = translationService.getTranslations(locale, keys); Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations); model.addAttribute("languageBundle", translations);
@ -561,6 +617,20 @@ public class PresupuestoController {
return "redirect:/presupuesto"; return "redirect:/presupuesto";
} }
if (presupuestoOpt.get().getEstado() == Presupuesto.Estado.aceptado) {
Map<String, Object> resumen = presupuestoService.getTextosResumen(
presupuestoOpt.get(),
Utils.decodeJsonList(presupuestoOpt.get().getServiciosJson()),
Utils.decodeJsonMap(presupuestoOpt.get().getDatosMaquetacionJson()),
Utils.decodeJsonMap(presupuestoOpt.get().getDatosMarcapaginasJson()),
locale);
model.addAttribute("resumen", resumen);
model.addAttribute("presupuesto", presupuestoOpt.get());
return "imprimelibros/presupuestos/presupuestador-view";
}
if (!presupuestoService.canAccessPresupuesto(presupuestoOpt.get(), authentication)) { if (!presupuestoService.canAccessPresupuesto(presupuestoOpt.get(), authentication)) {
// Añadir mensaje flash para mostrar alerta // Añadir mensaje flash para mostrar alerta
redirectAttributes.addFlashAttribute("errorMessage", redirectAttributes.addFlashAttribute("errorMessage",
@ -573,13 +643,14 @@ public class PresupuestoController {
String path = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()) String path = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes())
.getRequest().getRequestURI(); .getRequest().getRequestURI();
String mode = path.contains("/view/") ? "view" : "edit"; String mode = path.contains("/view/") ? "view" : "edit";
if (mode.equals("view")) { if (mode.equals("view") || presupuestoOpt.get().getEstado() != Presupuesto.Estado.borrador) {
model.addAttribute("appMode", "view"); model.addAttribute("appMode", "view");
} else { } else {
model.addAttribute("cliente_id", presupuestoOpt.get().getUser().getId()); model.addAttribute("cliente_id", presupuestoOpt.get().getUser().getId());
model.addAttribute("appMode", "edit"); model.addAttribute("appMode", "edit");
} }
model.addAttribute("id", presupuestoOpt.get().getId()); model.addAttribute("id", presupuestoOpt.get().getId());
model.addAttribute("presupuesto", presupuestoOpt.get());
return "imprimelibros/presupuestos/presupuesto-form"; return "imprimelibros/presupuestos/presupuesto-form";
} }
@ -757,6 +828,7 @@ public class PresupuestoController {
return ResponseEntity.ok(Map.of("id", saveResult.get("presupuesto_id"), return ResponseEntity.ok(Map.of("id", saveResult.get("presupuesto_id"),
"message", messageSource.getMessage("presupuesto.exito.guardado", null, locale))); "message", messageSource.getMessage("presupuesto.exito.guardado", null, locale)));
} catch (Exception ex) { } catch (Exception ex) {
log.error("Error al guardar el presupuesto", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", .body(Map.of("message",
messageSource.getMessage("presupuesto.error.save-internal-error", null, locale), messageSource.getMessage("presupuesto.error.save-internal-error", null, locale),
@ -765,4 +837,30 @@ public class PresupuestoController {
} }
} }
@PostMapping("/{id}/comentario")
@ResponseBody
public String actualizarComentario(@PathVariable Long id,
@RequestParam String comentario) {
presupuestoService.updateComentario(id, comentario);
return "OK";
}
@PostMapping("/api/duplicar/{id}")
@ResponseBody
public Map<String, Object> duplicarPresupuesto(
@PathVariable Long id,
@RequestParam(name = "titulo", defaultValue = "") String titulo) {
Long entity = presupuestoService.duplicarPresupuesto(id, titulo);
return Map.of("id", entity);
}
@PostMapping("/api/reimprimir/{id}")
@ResponseBody
public Map<String, Object> reimprimirPresupuesto(@PathVariable Long id) {
Long entity = presupuestoService.reimprimirPresupuesto(id);
return Map.of("id", entity);
}
} }

View File

@ -86,7 +86,7 @@ public class PresupuestoDatatableService {
.addIf(publico, "ciudad", Presupuesto::getCiudad) .addIf(publico, "ciudad", Presupuesto::getCiudad)
.add("updatedAt", p -> formatDate(p.getUpdatedAt(), locale)) .add("updatedAt", p -> formatDate(p.getUpdatedAt(), locale))
.addIf(!publico, "user", p -> p.getUser() != null ? p.getUser().getFullName() : "") .addIf(!publico, "user", p -> p.getUser() != null ? p.getUser().getFullName() : "")
.add("actions", this::generarBotones) .add("actions", p -> generarBotones(p, locale))
.where(base) .where(base)
.toJson(count); .toJson(count);
} }
@ -115,18 +115,27 @@ public class PresupuestoDatatableService {
return df.format(instant); return df.format(instant);
} }
private String generarBotones(Presupuesto p) { private String generarBotones(Presupuesto p, Locale locale) {
boolean borrador = p.getEstado() == Presupuesto.Estado.borrador; boolean borrador = p.getEstado() == Presupuesto.Estado.borrador;
String id = String.valueOf(p.getId()); String id = String.valueOf(p.getId());
String editBtn = "<a href=\"javascript:void(0);\" data-id=\"" + id + "\" class=\"link-success btn-edit-" + String editBtn = "<a href=\"javascript:void(0);\" data-id=\"" + id + "\" class=\"link-success btn-edit-" +
(p.getOrigen().equals(Presupuesto.Origen.publico) ? "anonimo" : "privado") + " fs-15\"><i class=\"ri-" + (p.getOrigen().equals(Presupuesto.Origen.publico) ? "anonimo" : "privado") + " fs-15\"><i class=\"ri-" +
(p.getOrigen().equals(Presupuesto.Origen.publico) ? "eye" : "pencil") + "-line\"></i></a>"; (p.getOrigen().equals(Presupuesto.Origen.publico) || p.getEstado() == Presupuesto.Estado.aceptado ? "eye" : "pencil") + "-line\" " +
"data-bs-toggle=\"tooltip\" data-bs-placement=\"top\" title=\"" +
msg(p.getEstado() == Presupuesto.Estado.aceptado ? "presupuesto.ver" : "presupuesto.editar", locale) + "\"></i></a>";
String duplicarBtn = !p.getOrigen().equals(Presupuesto.Origen.publico) ? "<a href=\"javascript:void(0);\" data-id=\"" + id
+ "\" class=\"link-success btn-duplicate-privado fs-15\"><i class=\"ri-file-copy-2-line\" data-bs-toggle=\"tooltip\" data-bs-placement=\"top\" title=\"" +
msg("presupuesto.duplicar", locale) + "\"></i></a>" : "";
String reimprimirBtn = p.getEstado() == Presupuesto.Estado.aceptado && !p.getOrigen().equals(Presupuesto.Origen.publico) ? "<a href=\"javascript:void(0);\" data-id=\"" + id
+ "\" class=\"link-success btn-reprint-privado fs-15\"><i class=\"ri-printer-line\" data-bs-toggle=\"tooltip\" data-bs-placement=\"top\" title=\"" +
msg("presupuesto.reimprimir", locale) + "\"></i></a>" : "";
String deleteBtn = borrador ? "<a href=\"javascript:void(0);\" data-id=\"" + id String deleteBtn = borrador ? "<a href=\"javascript:void(0);\" data-id=\"" + id
+ "\" class=\"link-danger btn-delete-" + "\" class=\"link-danger btn-delete-"
+ (p.getOrigen().equals(Presupuesto.Origen.publico) ? "anonimo" : "privado") + (p.getOrigen().equals(Presupuesto.Origen.publico) ? "anonimo" : "privado")
+ " fs-15\"><i class=\"ri-delete-bin-5-line\"></i></a>" : ""; + " fs-15\"><i class=\"ri-delete-bin-5-line\" data-bs-toggle=\"tooltip\" data-bs-placement=\"top\" title=\"" +
msg("presupuesto.borrar", locale) + "\"></i></a>" : "";
return "<div class=\"hstack gap-3 flex-wrap\">" + editBtn + deleteBtn + "</div>"; return "<div class=\"hstack gap-3 flex-wrap\">" + editBtn + duplicarBtn + reimprimirBtn + deleteBtn + "</div>";
} }
} }

View File

@ -25,6 +25,7 @@ public class PresupuestoPapeles {
); );
private static final Map<String, String> CABEZADA_COLOR_KEYS = Map.of( private static final Map<String, String> CABEZADA_COLOR_KEYS = Map.of(
"NOCABE", "presupuesto.cabezada-sin-cabezada",
"WHI", "presupuesto.cabezada-blanca", "WHI", "presupuesto.cabezada-blanca",
"GRE", "presupuesto.cabezada-verde", "GRE", "presupuesto.cabezada-verde",
"BLUE", "presupuesto.cabezada-azul", "BLUE", "presupuesto.cabezada-azul",

View File

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

View File

@ -22,6 +22,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.common.web.IpUtils; import com.imprimelibros.erp.common.web.IpUtils;
import com.imprimelibros.erp.configurationERP.VariableService; import com.imprimelibros.erp.configurationERP.VariableService;
import com.imprimelibros.erp.presupuesto.GeoIpService; import com.imprimelibros.erp.presupuesto.GeoIpService;
@ -71,14 +72,16 @@ public class PresupuestoService {
private final skApiClient apiClient; private final skApiClient apiClient;
private final GeoIpService geoIpService; private final GeoIpService geoIpService;
private final UserDao userRepo; private final UserDao userRepo;
private final Utils utils;
public PresupuestoService(PresupuestadorItems presupuestadorItems, PresupuestoFormatter presupuestoFormatter, public PresupuestoService(PresupuestadorItems presupuestadorItems, PresupuestoFormatter presupuestoFormatter,
skApiClient apiClient, GeoIpService geoIpService, UserDao userRepo) { skApiClient apiClient, GeoIpService geoIpService, UserDao userRepo, Utils utils) {
this.presupuestadorItems = presupuestadorItems; this.presupuestadorItems = presupuestadorItems;
this.presupuestoFormatter = presupuestoFormatter; this.presupuestoFormatter = presupuestoFormatter;
this.apiClient = apiClient; this.apiClient = apiClient;
this.geoIpService = geoIpService; this.geoIpService = geoIpService;
this.userRepo = userRepo; this.userRepo = userRepo;
this.utils = utils;
} }
public boolean validateDatosGenerales(int[] tiradas) { public boolean validateDatosGenerales(int[] tiradas) {
@ -290,6 +293,10 @@ public class PresupuestoService {
} }
public Map<String, Object> toSkApiRequest(Presupuesto presupuesto) { public Map<String, Object> toSkApiRequest(Presupuesto presupuesto) {
return toSkApiRequest(presupuesto, false);
}
public Map<String, Object> toSkApiRequest(Presupuesto presupuesto, Boolean toSave) {
final int SK_CLIENTE_ID = 1284; final int SK_CLIENTE_ID = 1284;
final int SK_PAGINAS_CUADERNILLO = 32; final int SK_PAGINAS_CUADERNILLO = 32;
@ -311,9 +318,28 @@ public class PresupuestoService {
Map<String, Object> body = new HashMap<>(); Map<String, Object> body = new HashMap<>();
body.put("tipo_impresion_id", this.getTipoImpresionId(presupuesto)); body.put("tipo_impresion_id", this.getTipoImpresionId(presupuesto));
body.put("tirada", Arrays.stream(presupuesto.getTiradas()) Boolean hasDepositoLegal = false;
.filter(Objects::nonNull) if (presupuesto.getServiciosJson() != null
.collect(Collectors.toList())); && presupuesto.getServiciosJson().contains("deposito-legal")) {
hasDepositoLegal = true;
}
if (toSave && hasDepositoLegal) {
body.put("tirada", Arrays.stream(presupuesto.getTiradas())
.filter(Objects::nonNull)
.map(tirada -> tirada + 4)
.collect(Collectors.toList()));
if(presupuesto.getSelectedTirada() != null) {
presupuesto.setSelectedTirada(presupuesto.getSelectedTirada());
}
} else {
body.put("tirada", Arrays.stream(presupuesto.getTiradas())
.filter(Objects::nonNull)
.collect(Collectors.toList()));
}
body.put("selectedTirada",
presupuesto.getSelectedTirada() != null ? presupuesto.getSelectedTirada() : presupuesto.getTirada1());
body.put("tamanio", tamanio); body.put("tamanio", tamanio);
body.put("tipo", presupuesto.getTipoEncuadernacion()); body.put("tipo", presupuesto.getTipoEncuadernacion());
body.put("clienteId", SK_CLIENTE_ID); body.put("clienteId", SK_CLIENTE_ID);
@ -325,6 +351,11 @@ public class PresupuestoService {
body.put("interior", interior); body.put("interior", interior);
body.put("cubierta", cubierta); body.put("cubierta", cubierta);
body.put("guardas", null); body.put("guardas", null);
// Para las reimpresiones
if(presupuesto.getIsReimpresion() != null && presupuesto.getIsReimpresion()) {
body.put("reimpresion", 1);
body.put("iskn", presupuesto.getProveedorRef1());
}
if (presupuesto.getSobrecubierta()) { if (presupuesto.getSobrecubierta()) {
Map<String, Object> sobrecubierta = new HashMap<>(); Map<String, Object> sobrecubierta = new HashMap<>();
sobrecubierta.put("papel", presupuesto.getPapelSobrecubiertaId()); sobrecubierta.put("papel", presupuesto.getPapelSobrecubiertaId());
@ -343,9 +374,43 @@ public class PresupuestoService {
faja.put("alto", presupuesto.getAltoFaja()); faja.put("alto", presupuesto.getAltoFaja());
body.put("faja", faja); body.put("faja", faja);
} }
// body.put("servicios", servicios);
if (toSave) {
Map<String, Object> servicios = new HashMap<>();
Map<String, Object> data = new HashMap<>();
data.put("input_data", body);
data.put("ferroDigital", 1);
data.put("ferro", 0);
data.put("marcapaginas", 0);
data.put("retractilado5", 0);
if (presupuesto.getServiciosJson() != null
&& presupuesto.getServiciosJson().indexOf("ejemplar-prueba") > 0) {
data.put("prototipo", 1);
servicios.put("prototipo", "1");
} else {
data.put("prototipo", 0);
}
if (presupuesto.getServiciosJson() != null && presupuesto.getServiciosJson().indexOf("retractilado") > 0) {
data.put("retractilado", 1);
servicios.put("retractilado", "1");
} else {
data.put("retractilado", 0);
}
data.put("ivaReducido", presupuesto.getIvaReducido() ? 1 : 0);
data.put("confirmar", 1);
Map<String, Object> datosCabecera = new HashMap<>();
datosCabecera.put("titulo", presupuesto.getTitulo());
datosCabecera.put("autor", presupuesto.getAutor());
datosCabecera.put("isbn", presupuesto.getIsbn());
datosCabecera.put("coleccion", "");
datosCabecera.put("referenciaCliente", presupuesto.getId());
data.put("datosCabecera", datosCabecera);
body.put("servicios", servicios);
return data;
}
return body; return body;
} }
public Integer getTipoImpresionId(Presupuesto presupuesto) { public Integer getTipoImpresionId(Presupuesto presupuesto) {
@ -603,7 +668,7 @@ public class PresupuestoService {
if (presupuestoMaquetacion.getNumColumnas() > 1) { if (presupuestoMaquetacion.getNumColumnas() > 1) {
precio = precio.add(precio.multiply( precio = precio.add(precio.multiply(
BigDecimal.valueOf(presupuestoMaquetacion.getNumColumnas() - 1)) BigDecimal.valueOf(presupuestoMaquetacion.getNumColumnas() - 1))
.multiply(BigDecimal.valueOf(price.apply("columnas"))) ); .multiply(BigDecimal.valueOf(price.apply("columnas"))));
} }
precio = precio precio = precio
@ -877,6 +942,7 @@ public class PresupuestoService {
/ Double.parseDouble(servicio.get("units").toString()) / Double.parseDouble(servicio.get("units").toString())
: servicio.get("price")); : servicio.get("price"));
servicioData.put("unidades", servicio.get("units")); servicioData.put("unidades", servicio.get("units"));
servicioData.put("id", servicio.get("id"));
serviciosExtras.add(servicioData); serviciosExtras.add(servicioData);
} }
} }
@ -962,6 +1028,7 @@ public class PresupuestoService {
resumen.put("iva_importe_4", presupuesto.getIvaImporte4()); resumen.put("iva_importe_4", presupuesto.getIvaImporte4());
resumen.put("iva_importe_21", presupuesto.getIvaImporte21()); resumen.put("iva_importe_21", presupuesto.getIvaImporte21());
resumen.put("total_con_iva", presupuesto.getTotalConIva()); resumen.put("total_con_iva", presupuesto.getTotalConIva());
resumen.put("isReimpresion", presupuesto.getIsReimpresion());
return resumen; return resumen;
} }
@ -1057,10 +1124,14 @@ public class PresupuestoService {
try { try {
// retractilado: recalcular precio // retractilado: recalcular precio
if (s.get("id").equals("retractilado")) { if (s.get("id").equals("retractilado")) {
double precio_retractilado = obtenerPrecioRetractilado(cantidad) != null
? Double.parseDouble(obtenerPrecioRetractilado(cantidad)) String p = obtenerPrecioRetractilado(cantidad);
: 0.0; if(p != null){
s.put("price", precio_retractilado); double precio_retractilado = Double.parseDouble(p);
s.put("price", precio_retractilado);
} else {
s.put("price", 0.0);
}
} }
// si tiene protitipo, guardamos el valor para el IVA al 4% // si tiene protitipo, guardamos el valor para el IVA al 4%
else if (s.get("id").equals("ejemplar-prueba")) { else if (s.get("id").equals("ejemplar-prueba")) {
@ -1078,8 +1149,10 @@ public class PresupuestoService {
} }
} }
try { try {
presupuesto.setServiciosJson(new ObjectMapper().writeValueAsString(servicios)); if(presupuesto.getSelectedTirada() != null && presupuesto.getSelectedTirada().equals(tirada))
presupuesto.setServiciosJson(new ObjectMapper().writeValueAsString(servicios));
} catch (Exception ignore) { } catch (Exception ignore) {
System.out.println("Error guardando servicios JSON: " + ignore.getMessage());
} }
} }
@ -1159,6 +1232,18 @@ public class PresupuestoService {
HashMap<String, Object> result = new HashMap<>(); HashMap<String, Object> result = new HashMap<>();
try { try {
Presupuesto presupuestoExistente = null;
if (id != null) {
presupuestoExistente = presupuestoRepository.findById(id).orElse(null);
}
if (presupuestoExistente != null) {
// merge de datos que no están en el formulario
presupuesto.setIsReimpresion(presupuestoExistente.getIsReimpresion());
presupuesto.setProveedor(presupuestoExistente.getProveedor());
presupuesto.setProveedorRef1(presupuestoExistente.getProveedorRef1());
presupuesto.setProveedorRef2(presupuestoExistente.getProveedorRef2());
}
presupuesto.setDatosMaquetacionJson( presupuesto.setDatosMaquetacionJson(
datosMaquetacion != null ? new ObjectMapper().writeValueAsString(datosMaquetacion) : null); datosMaquetacion != null ? new ObjectMapper().writeValueAsString(datosMaquetacion) : null);
presupuesto.setDatosMarcapaginasJson( presupuesto.setDatosMarcapaginasJson(
@ -1248,6 +1333,93 @@ public class PresupuestoService {
return true; return true;
} }
public Boolean hasMaquetacion(Presupuesto presupuesto) {
if (presupuesto.getServiciosJson() != null && !presupuesto.getServiciosJson().isEmpty()) {
if(presupuesto.getServiciosJson().contains("maquetacion")) {
return true;
}
}
return false;
}
public Map<String, Object> getPresupuestoInfoForCard(Presupuesto presupuesto, Locale locale) {
Map<String, Object> resumen = new HashMap<>();
resumen.put("titulo", presupuesto.getTitulo());
resumen.put("imagen",
"/assets/images/imprimelibros/presupuestador/" + presupuesto.getTipoEncuadernacion() + ".png");
resumen.put("imagen_alt",
messageSource.getMessage("presupuesto." + presupuesto.getTipoEncuadernacion(), null, locale));
resumen.put("presupuestoId", presupuesto.getId());
if (presupuesto.getServiciosJson() != null && presupuesto.getServiciosJson().contains("ejemplar-prueba")) {
resumen.put("hasSample", true);
} else {
resumen.put("hasSample", false);
}
Map<String, Object> 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());
resumen.put("iva21", presupuesto.getIvaImporte21());
resumen.put("total", Utils.formatCurrency(presupuesto.getTotalConIva(), locale));
resumen.put("resumen", detalles);
return resumen;
}
public Presupuesto findPresupuestoById(Long id) {
return presupuestoRepository.findById(id).orElse(null);
}
public void updateComentario(Long presupuestoId, String comentario) {
Presupuesto presupuesto = presupuestoRepository.findById(presupuestoId).orElse(null);
if (presupuesto != null) {
presupuesto.setComentario(comentario);
presupuestoRepository.saveAndFlush(presupuesto);
}
}
public long duplicarPresupuesto(Long presupuestoId, String titulo) {
Presupuesto presupuesto = presupuestoRepository.findById(presupuestoId).orElse(null);
if (presupuesto != null) {
Presupuesto nuevo = presupuesto.clone();
nuevo.setId(null); // para que se genere uno nuevo
nuevo.setEstado(Presupuesto.Estado.borrador);
nuevo.setTitulo(titulo != null && !titulo.isEmpty() ? titulo : "[D] " + presupuesto.getTitulo());
nuevo.setIsReimpresion(false);
nuevo.setProveedor(null);
nuevo.setProveedorRef1(null);
nuevo.setProveedorRef2(null);
presupuestoRepository.saveAndFlush(nuevo);
return nuevo.getId();
}
return -1;
}
public long reimprimirPresupuesto(Long presupuestoId) {
Presupuesto presupuesto = presupuestoRepository.findById(presupuestoId).orElse(null);
if (presupuesto != null) {
Presupuesto nuevo = presupuesto.clone();
nuevo.setId(null); // para que se genere uno nuevo
nuevo.setEstado(Presupuesto.Estado.borrador);
nuevo.setTitulo("[R] " + presupuesto.getTitulo());
nuevo.setIsReimpresion(true);
presupuestoRepository.saveAndFlush(nuevo);
return nuevo.getId();
}
return -1;
}
// ======================================================================= // =======================================================================
// Métodos privados // Métodos privados
// ======================================================================= // =======================================================================

View File

@ -1,83 +1,308 @@
package com.imprimelibros.erp.redsys; package com.imprimelibros.erp.redsys;
import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.payments.PaymentService;
import com.imprimelibros.erp.payments.model.Payment;
import com.imprimelibros.erp.pedidos.Pedido;
import com.imprimelibros.erp.pedidos.PedidoService;
import com.imprimelibros.erp.redsys.RedsysService.FormPayload;
import jakarta.servlet.ServletContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.thymeleaf.context.WebContext;
import org.thymeleaf.web.IWebExchange;
import org.thymeleaf.web.servlet.JakartaServletWebApplication;
import org.thymeleaf.spring6.SpringTemplateEngine;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.context.MessageSource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
@Controller @Controller
@RequestMapping("/pagos/redsys") @RequestMapping("/pagos/redsys")
public class RedsysController { public class RedsysController {
private final RedsysService service; private final PaymentService paymentService;
private final MessageSource messageSource;
private final SpringTemplateEngine templateEngine;
private final ServletContext servletContext;
private final PedidoService pedidoService;
public RedsysController(RedsysService service) { public RedsysController(PaymentService paymentService, MessageSource messageSource,
this.service = service; SpringTemplateEngine templateEngine, ServletContext servletContext,
PedidoService pedidoService) {
this.paymentService = paymentService;
this.messageSource = messageSource;
this.templateEngine = templateEngine;
this.servletContext = servletContext;
this.pedidoService = pedidoService;
} }
@PostMapping("/crear") @PostMapping(value = "/crear", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
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 @ResponseBody
public ResponseEntity<String> notifyRedsys( public ResponseEntity<byte[]> crearPago(@RequestParam("amountCents") Long amountCents,
@RequestParam("Ds_Signature") String dsSignature, @RequestParam("method") String method, @RequestParam("cartId") Long cartId,
@RequestParam("Ds_MerchantParameters") String dsMerchantParameters) { @RequestParam(value = "dirFactId", required = false) Long dirFactId,
HttpServletRequest request,
HttpServletResponse response, Locale locale)
throws Exception {
// Creamos el pedido inteno
Pedido order = pedidoService.crearPedido(cartId, dirFactId, null, null);
if ("bank-transfer".equalsIgnoreCase(method)) {
// 1) Creamos el Payment interno SIN orderId (null)
Payment p = paymentService.createBankTransferPayment(cartId, dirFactId, amountCents, "EUR", locale,
order.getId());
pedidoService.markPedidoAsProcesingPayment(order.getId());
// 1⃣ Crear la "aplicación" web de Thymeleaf (Jakarta)
JakartaServletWebApplication app = JakartaServletWebApplication.buildApplication(servletContext);
// 2⃣ Construir el intercambio web desde request/response
response.setContentType("text/html;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
IWebExchange exchange = app.buildExchange(request, response);
// 3⃣ Crear el contexto WebContext con Locale
WebContext ctx = new WebContext(exchange, locale);
String importeFormateado = Utils.formatCurrency(amountCents / 100.0, locale);
ctx.setVariable("importe", importeFormateado);
ctx.setVariable("concepto", "TRANSF-" + p.getOrderId());
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
boolean isAuth = auth != null
&& auth.isAuthenticated()
&& !(auth instanceof AnonymousAuthenticationToken);
ctx.setVariable("isAuth", isAuth);
// 3) Renderizamos la plantilla a HTML
String html = templateEngine.process("imprimelibros/pagos/transfer", ctx);
byte[] body = html.getBytes(StandardCharsets.UTF_8);
return ResponseEntity.ok()
.contentType(MediaType.TEXT_HTML)
.body(body);
}
// Tarjeta o Bizum (Redsys)
FormPayload form = paymentService.createRedsysPayment(cartId, dirFactId, amountCents, "EUR", method,
order.getId());
String html = """
<html><head><meta charset="utf-8"><title>Redirigiendo a Redsys…</title></head>
<body onload="document.forms[0].submit()">
<form action="%s" method="post">
<input type="hidden" name="Ds_SignatureVersion" value="%s"/>
<input type="hidden" name="Ds_MerchantParameters" value="%s"/>
<input type="hidden" name="Ds_Signature" value="%s"/>
<input type="hidden" name="cartId" value="%d"/>
<noscript>
<p>Haz clic en pagar para continuar</p>
<button type="submit">Pagar</button>
</noscript>
</form>
</body></html>
""".formatted(
form.action(),
form.signatureVersion(),
form.merchantParameters(),
form.signature(), cartId);
byte[] body = html.getBytes(StandardCharsets.UTF_8);
return ResponseEntity.ok()
.contentType(MediaType.TEXT_HTML)
.body(body);
}
@PostMapping(value = "/reintentar", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
@ResponseBody
public ResponseEntity<byte[]> reintentarPago(@RequestParam("amountCents") Long amountCents,
@RequestParam("method") String method, @RequestParam("orderId") Long orderId,
HttpServletRequest request,
HttpServletResponse response, Locale locale)
throws Exception {
// Creamos el pedido inteno
Pedido order = pedidoService.findById(orderId);
// Find the payment with orderId = order.getId() and status = failed
Payment failedPayment = paymentService.findFailedPaymentByOrderId(order.getId());
if (failedPayment == null) {
throw new Exception("No se encontró un pago fallido para el pedido " + order.getId());
}
Long cartId = null;
Long dirFactId = null;
// Find payment transaction details from failedPayment if needed
try { try {
RedsysService.RedsysNotification notif = service.validateAndParseNotification(dsSignature, Map<String, Long> transactionDetails = paymentService.getPaymentTransactionData(failedPayment.getId());
dsMerchantParameters); cartId = transactionDetails.get("cartId");
dirFactId = transactionDetails.get("dirFactId");
// 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) { } catch (Exception e) {
return ResponseEntity.status(500).body("ERROR"); throw new Exception(
"No se pudieron obtener los detalles de la transacción para el pago " + failedPayment.getId());
}
if ("bank-transfer".equalsIgnoreCase(method)) {
// 1) Creamos el Payment interno SIN orderId (null)
Payment p = paymentService.createBankTransferPayment(cartId, dirFactId, amountCents, "EUR", locale,
order.getId());
pedidoService.markPedidoAsProcesingPayment(order.getId());
// 1⃣ Crear la "aplicación" web de Thymeleaf (Jakarta)
JakartaServletWebApplication app = JakartaServletWebApplication.buildApplication(servletContext);
// 2⃣ Construir el intercambio web desde request/response
response.setContentType("text/html;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
IWebExchange exchange = app.buildExchange(request, response);
// 3⃣ Crear el contexto WebContext con Locale
WebContext ctx = new WebContext(exchange, locale);
String importeFormateado = Utils.formatCurrency(amountCents / 100.0, locale);
ctx.setVariable("importe", importeFormateado);
ctx.setVariable("concepto", "TRANSF-" + p.getOrderId());
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
boolean isAuth = auth != null
&& auth.isAuthenticated()
&& !(auth instanceof AnonymousAuthenticationToken);
ctx.setVariable("isAuth", isAuth);
// 3) Renderizamos la plantilla a HTML
String html = templateEngine.process("imprimelibros/pagos/transfer", ctx);
byte[] body = html.getBytes(StandardCharsets.UTF_8);
return ResponseEntity.ok()
.contentType(MediaType.TEXT_HTML)
.body(body);
}
// Tarjeta o Bizum (Redsys)
FormPayload form = paymentService.createRedsysPayment(cartId, dirFactId, amountCents, "EUR", method,
order.getId());
String html = """
<html><head><meta charset="utf-8"><title>Redirigiendo a Redsys…</title></head>
<body onload="document.forms[0].submit()">
<form action="%s" method="post">
<input type="hidden" name="Ds_SignatureVersion" value="%s"/>
<input type="hidden" name="Ds_MerchantParameters" value="%s"/>
<input type="hidden" name="Ds_Signature" value="%s"/>
<input type="hidden" name="cartId" value="%d"/>
<noscript>
<p>Haz clic en pagar para continuar</p>
<button type="submit">Pagar</button>
</noscript>
</form>
</body></html>
""".formatted(
form.action(),
form.signatureVersion(),
form.merchantParameters(),
form.signature(), cartId);
byte[] body = html.getBytes(StandardCharsets.UTF_8);
return ResponseEntity.ok()
.contentType(MediaType.TEXT_HTML)
.body(body);
}
// GET: cuando el usuario cae aquí sin parámetros, o Redsys redirige por GET
@GetMapping("/ok")
public String okGet(RedirectAttributes redirectAttrs, Model model, Locale locale) {
return "imprimelibros/pagos/pago-ok";
}
// POST: si Redsys envía Ds_Signature y Ds_MerchantParameters (muchas
// integraciones ni lo usan)
@PostMapping(value = "/ok", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
@ResponseBody
public ResponseEntity<String> okPost(@RequestParam("Ds_Signature") String signature,
@RequestParam("Ds_MerchantParameters") String merchantParameters, Locale locale) {
try {
// opcional: idempotente, si /notify ya ha hecho el trabajo no pasa nada
paymentService.handleRedsysNotification(signature, merchantParameters, locale);
return ResponseEntity.ok("<h2>Pago realizado correctamente</h2><a href=\"/cart\">Volver</a>");
} catch (Exception e) {
return ResponseEntity.badRequest()
.body("<h2>Error validando pago</h2><pre>" + e.getMessage() + "</pre>");
} }
} }
@PostMapping("/ok") @GetMapping("/ko")
public String okReturn(@RequestParam("Ds_Signature") String dsSignature, public String koGet(RedirectAttributes redirectAttrs, Model model, Locale locale) {
@RequestParam("Ds_MerchantParameters") String dsMerchantParameters,
Model model) { String msg = messageSource.getMessage("checkout.error.payment", null,
"Error al procesar el pago: el pago ha sido cancelado o rechazado Por favor, inténtelo de nuevo.",
locale);
model.addAttribute("errorPago", msg);
redirectAttrs.addFlashAttribute("errorPago", msg);
return "redirect:/cart";
}
@PostMapping(value = "/ko", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
@ResponseBody
public ResponseEntity<String> koPost(
@RequestParam("Ds_Signature") String signature,
@RequestParam("Ds_MerchantParameters") String merchantParameters, Locale locale) {
try { try {
RedsysService.RedsysNotification notif = service.validateAndParseNotification(dsSignature, dsMerchantParameters); // Procesamos la notificación IGUAL que en /ok y /notify
// Aquí puedes validar importe/pedido/moneda con tu base de datos y marcar como paymentService.handleRedsysNotification(signature, merchantParameters, locale);
// pagado
model.addAttribute("authorized", notif.authorized()); // Mensaje para el usuario (pago cancelado/rechazado)
//model.addAttribute("order", notif.order()); String html = "<h2>Pago cancelado o rechazado</h2><a href=\"/checkout\">Volver</a>";
//model.addAttribute("amountCents", notif.amountCents()); return ResponseEntity.ok(html);
return "imprimelibros/payments/redsys-ok";
} catch (Exception e) { } catch (Exception e) {
model.addAttribute("error", "No se pudo validar la respuesta de Redsys."); // Si algo falla al validar/procesar, lo mostramos (útil en entorno de pruebas)
return "imprimelibros/payments/redsys-ko"; String html = "<h2>Error procesando notificación KO</h2><pre>" + e.getMessage() + "</pre>";
return ResponseEntity.badRequest().body(html);
} }
} }
@PostMapping("/ko") @PostMapping(value = "/notify", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public String koReturn(@RequestParam(value = "Ds_Signature", required = false) String dsSignature, @ResponseBody
@RequestParam(value = "Ds_MerchantParameters", required = false) String dsMerchantParameters, public String notifyRedsys(@RequestParam("Ds_Signature") String signature,
Model model) { @RequestParam("Ds_MerchantParameters") String merchantParameters, Locale locale) {
// Suele venir cuando el usuario cancela o hay error try {
model.addAttribute("error", "Operación cancelada o rechazada."); paymentService.handleRedsysNotification(signature, merchantParameters, locale);
return "imprimelibros/payments/redsys-ko"; return "OK";
} catch (Exception e) {
e.printStackTrace(); // 👈 para ver el motivo del 500 en logs
return "ERROR";
}
} }
@PostMapping(value = "/refund/{paymentId}", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
@ResponseBody
public ResponseEntity<String> refund(@PathVariable Long paymentId,
@RequestParam("amountCents") Long amountCents) {
try {
String idem = "refund-" + paymentId + "-" + amountCents + "-" + UUID.randomUUID();
paymentService.refundViaRedsys(paymentId, amountCents, idem);
return ResponseEntity.ok("{\"success\":true}");
} catch (Exception e) {
return ResponseEntity.badRequest().body("{\"success\":false, \"error\": \"" + e.getMessage() + "\"}");
}
}
} }

View File

@ -1,13 +1,17 @@
package com.imprimelibros.erp.redsys; package com.imprimelibros.erp.redsys;
import org.json.JSONObject;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import sis.redsys.api.ApiMacSha256; import sis.redsys.api.ApiMacSha256;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.util.Base64; import java.util.Base64;
@ -18,6 +22,10 @@ import java.util.Objects;
public class RedsysService { public class RedsysService {
// ---------- CONFIG ---------- // ---------- CONFIG ----------
@Value("${redsys.url}")
private String url;
@Value("${redsys.refund.url}")
private String urlRefund;
@Value("${redsys.merchant-code}") @Value("${redsys.merchant-code}")
private String merchantCode; private String merchantCode;
@Value("${redsys.terminal}") @Value("${redsys.terminal}")
@ -37,19 +45,33 @@ public class RedsysService {
@Value("${redsys.environment}") @Value("${redsys.environment}")
private String env; private String env;
private final HttpClient httpClient = HttpClient.newHttpClient();
// ---------- RECORDS ---------- // ---------- RECORDS ----------
public record PaymentRequest(String order, long amountCents, String description) { // Pedido a Redsys
public record PaymentRequest(String order, long amountCents, String description, Long cartId, Long dirFactId) {
} }
// Payload para el formulario
public record FormPayload(String action, String signatureVersion, String merchantParameters, String signature) { public record FormPayload(String action, String signatureVersion, String merchantParameters, String signature) {
} }
// ---------- MÉTODO PRINCIPAL ---------- // ---------- MÉTODO PRINCIPAL (TARJETA) ----------
public FormPayload buildRedirectForm(PaymentRequest req) throws Exception { public FormPayload buildRedirectForm(PaymentRequest req) throws Exception {
return buildRedirectFormInternal(req, false); // false = tarjeta (sin PAYMETHODS)
}
// ---------- NUEVO: MÉTODO PARA BIZUM ----------
public FormPayload buildRedirectFormBizum(PaymentRequest req) throws Exception {
return buildRedirectFormInternal(req, true); // true = Bizum (PAYMETHODS = z)
}
// ---------- LÓGICA COMÚN ----------
private FormPayload buildRedirectFormInternal(PaymentRequest req, boolean bizum) throws Exception {
ApiMacSha256 api = new ApiMacSha256(); ApiMacSha256 api = new ApiMacSha256();
api.setParameter("DS_MERCHANT_AMOUNT", String.valueOf(req.amountCents())); 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_ORDER", req.order()); // Usa 12 dígitos con ceros
api.setParameter("DS_MERCHANT_MERCHANTCODE", merchantCode); api.setParameter("DS_MERCHANT_MERCHANTCODE", merchantCode);
api.setParameter("DS_MERCHANT_CURRENCY", currency); api.setParameter("DS_MERCHANT_CURRENCY", currency);
api.setParameter("DS_MERCHANT_TRANSACTIONTYPE", txType); api.setParameter("DS_MERCHANT_TRANSACTIONTYPE", txType);
@ -58,12 +80,33 @@ public class RedsysService {
api.setParameter("DS_MERCHANT_URLOK", urlOk); api.setParameter("DS_MERCHANT_URLOK", urlOk);
api.setParameter("DS_MERCHANT_URLKO", urlKo); api.setParameter("DS_MERCHANT_URLKO", urlKo);
// ✅ Añadir contexto adicional (por ejemplo, cartId)
// Si tu PaymentRequest no lo lleva todavía, puedes pasarlo en description o
// crear otro campo.
JSONObject ctx = new JSONObject();
ctx.put("cartId", req.cartId());
if (req.dirFactId() != null) {
ctx.put("dirFactId", req.dirFactId());
}
api.setParameter("DS_MERCHANT_MERCHANTDATA", ctx.toString());
if (req.description() != null && !req.description().isBlank()) {
api.setParameter("DS_MERCHANT_PRODUCTDESCRIPTION", req.description());
}
// 🔹 Bizum: PAYMETHODS = "z" según Redsys
if (bizum) {
api.setParameter("DS_MERCHANT_PAYMETHODS", "z");
}
String merchantParameters = api.createMerchantParameters(); String merchantParameters = api.createMerchantParameters();
String signature = api.createMerchantSignature(secretKeyBase64); String signature = api.createMerchantSignature(secretKeyBase64);
String action = "test".equalsIgnoreCase(env) String action = url;
? "https://sis-t.redsys.es:25443/sis/realizarPago" /*
: "https://sis.redsys.es/sis/realizarPago"; * ? "https://sis-t.redsys.es:25443/sis/realizarPago"
* : "https://sis.redsys.es/sis/realizarPago";
*/
return new FormPayload(action, "HMAC_SHA256_V1", merchantParameters, signature); return new FormPayload(action, "HMAC_SHA256_V1", merchantParameters, signature);
} }
@ -84,27 +127,40 @@ public class RedsysService {
// ---------- STEP 4: Validar notificación ---------- // ---------- STEP 4: Validar notificación ----------
public RedsysNotification validateAndParseNotification(String dsSignature, String dsMerchantParametersB64) public RedsysNotification validateAndParseNotification(String dsSignature, String dsMerchantParametersB64)
throws Exception { throws Exception {
Map<String, Object> mp = decodeMerchantParametersToMap(dsMerchantParametersB64);
RedsysNotification notif = new RedsysNotification(mp);
if (notif.order == null || notif.order.isBlank()) { ApiMacSha256 api = new ApiMacSha256();
throw new IllegalArgumentException("Falta Ds_Order en Ds_MerchantParameters");
// 1) Decodificar Ds_MerchantParameters usando la librería oficial
String json = api.decodeMerchantParameters(dsMerchantParametersB64);
// 2) Convertir a Map para tu modelo
Map<String, Object> mp = MAPPER.readValue(json, new TypeReference<>() {
});
RedsysNotification notif = new RedsysNotification(mp);
if (notif.order == null || notif.order.isBlank()) {
System.out.println("### ATENCIÓN: Ds_Order no viene en MerchantParameters");
throw new IllegalArgumentException("Falta Ds_Order en Ds_MerchantParameters");
}
// 3) Calcular firma esperada: clave comercio + MerchantParameters en B64
String expected = api.createMerchantSignatureNotif(
secretKeyBase64, // 👈 La misma que usas para crear la firma del pago
dsMerchantParametersB64 // 👈 SIEMPRE el B64 tal cual llega de Redsys, sin tocar
);
// 4) Comparar firma Redsys vs firma calculada
if (!safeEqualsB64(dsSignature, expected)) {
System.out.println("### Firma Redsys no válida");
System.out.println("Ds_Signature (Redsys) = " + dsSignature);
System.out.println("Expected (local) = " + expected);
throw new SecurityException("Firma Redsys no válida");
}
return notif;
} }
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 ---------- // ---------- HELPERS ----------
private static boolean safeEqualsB64(String a, String b) { private static boolean safeEqualsB64(String a, String b) {
if (Objects.equals(a, b)) if (Objects.equals(a, b))
@ -141,6 +197,11 @@ public class RedsysService {
public final String response; public final String response;
public final long amountCents; public final long amountCents;
public final String currency; public final String currency;
public final Long cartId;
public final Long dirFactId;
public final String processedPayMethod; // Ds_ProcessedPayMethod
public final String bizumIdOper; // Ds_Bizum_IdOper
public final String authorisationCode; // Ds_AuthorisationCode
public RedsysNotification(Map<String, Object> raw) { public RedsysNotification(Map<String, Object> raw) {
this.raw = raw; this.raw = raw;
@ -148,6 +209,46 @@ public class RedsysService {
this.response = str(raw.get("Ds_Response")); this.response = str(raw.get("Ds_Response"));
this.currency = str(raw.get("Ds_Currency")); this.currency = str(raw.get("Ds_Currency"));
this.amountCents = parseLongSafe(raw.get("Ds_Amount")); this.amountCents = parseLongSafe(raw.get("Ds_Amount"));
this.cartId = extractCartId(raw.get("Ds_MerchantData"));
this.dirFactId = extractDirFactId(raw.get("Ds_MerchantData"));
this.processedPayMethod = str(raw.get("Ds_ProcessedPayMethod"));
this.bizumIdOper = str(raw.get("Ds_Bizum_IdOper"));
this.authorisationCode = str(raw.get("Ds_AuthorisationCode"));
}
private static Long extractCartId(Object merchantDataObj) {
if (merchantDataObj == null)
return null;
try {
String json = String.valueOf(merchantDataObj);
// 👇 DES-ESCAPAR las comillas HTML que vienen de Redsys
json = json.replace("&#34;", "\"");
org.json.JSONObject ctx = new org.json.JSONObject(json);
return ctx.optLong("cartId", 0L);
} catch (Exception e) {
e.printStackTrace(); // te ayudará si vuelve a fallar
return null;
}
}
private static Long extractDirFactId(Object merchantDataObj) {
if (merchantDataObj == null)
return null;
try {
String json = String.valueOf(merchantDataObj);
// 👇 DES-ESCAPAR las comillas HTML que vienen de Redsys
json = json.replace("&#34;", "\"");
org.json.JSONObject ctx = new org.json.JSONObject(json);
long v = ctx.optLong("dirFactId", 0L);
return v != 0L ? v : null;
} catch (Exception e) {
e.printStackTrace(); // te ayudará si vuelve a fallar
return null;
}
} }
public boolean authorized() { public boolean authorized() {
@ -159,6 +260,11 @@ public class RedsysService {
} }
} }
public boolean isBizum() {
// Redsys suele usar 68 para Bizum; ajustable si tu banco usa otro código.
return "68".equals(processedPayMethod);
}
private static String str(Object o) { private static String str(Object o) {
return o == null ? null : String.valueOf(o); return o == null ? null : String.valueOf(o);
} }
@ -171,4 +277,100 @@ public class RedsysService {
} }
} }
} }
/**
* Solicita a Redsys una devolución (TransactionType = 3)
*
* @param order El mismo Ds_Merchant_Order que se usó en el cobro.
* @param amountCents Importe en céntimos a devolver.
* @return gatewayRefundId (p.ej. Ds_AuthorisationCode o Ds_Order)
*/
public String requestRefund(String order, long amountCents) throws Exception {
ApiMacSha256 api = new ApiMacSha256();
// Montar parámetros para el refund
api.setParameter("DS_MERCHANT_MERCHANTCODE", merchantCode);
api.setParameter("DS_MERCHANT_TERMINAL", terminal);
api.setParameter("DS_MERCHANT_ORDER", order);
api.setParameter("DS_MERCHANT_AMOUNT", String.valueOf(amountCents));
api.setParameter("DS_MERCHANT_CURRENCY", currency);
api.setParameter("DS_MERCHANT_TRANSACTIONTYPE", "3"); // 3 = devolución
api.setParameter("DS_MERCHANT_MERCHANTURL", "");
api.setParameter("DS_MERCHANT_URLOK", "");
api.setParameter("DS_MERCHANT_URLKO", "");
// Crear parámetros y firma (como en tu PHP)
String merchantParameters = api.createMerchantParameters();
String signature = api.createMerchantSignature(secretKeyBase64);
// Montar el JSON para Redsys REST
String json = """
{
"Ds_MerchantParameters": "%s",
"Ds_Signature": "%s",
"Ds_SignatureVersion": "HMAC_SHA256_V1"
}
""".formatted(merchantParameters, signature);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(urlRefund))
.header("Content-Type", "application/json; charset=UTF-8")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() / 100 != 2)
throw new IllegalStateException("HTTP error Redsys refund: " + response.statusCode());
if (response.body() == null || response.body().isBlank())
throw new IllegalStateException("Respuesta vacía de Redsys refund REST");
// Parsear la respuesta JSON
Map<String, Object> respMap = MAPPER.readValue(response.body(), new TypeReference<>() {
});
// Redsys puede devolver "Ds_MerchantParameters" o "errorCode"
if (respMap.containsKey("errorCode")) {
throw new IllegalStateException("Error Redsys refund: " + respMap.get("errorCode"));
}
String dsMerchantParametersResp = (String) respMap.get("Ds_MerchantParameters");
if (dsMerchantParametersResp == null) {
throw new IllegalStateException("Respuesta Redsys refund sin Ds_MerchantParameters");
}
// Decodificar MerchantParameters de la respuesta
Map<String, Object> decoded = decodeMerchantParametersToMap(dsMerchantParametersResp);
String dsResponse = String.valueOf(decoded.get("Ds_Response"));
if (dsResponse == null) {
throw new IllegalStateException("Respuesta Redsys refund sin Ds_Response");
}
int code;
try {
code = Integer.parseInt(dsResponse);
} catch (NumberFormatException e) {
throw new IllegalStateException("Código Ds_Response no numérico en refund: " + dsResponse, e);
}
// ✅ Consideramos OK: 099 (éxito típico) o 900 (0900)
boolean ok = (code >= 0 && code <= 99) || code == 900;
if (!ok) {
throw new IllegalStateException("Devolución rechazada, Ds_Response=" + dsResponse);
}
// Devolvemos algún identificador razonable para la transacción de refund
Object authCodeObj = decoded.get("Ds_AuthorisationCode");
String authCode = authCodeObj != null ? String.valueOf(authCodeObj).trim() : null;
if (authCode == null || authCode.isEmpty()) {
// Fallback: usa el Ds_Order original como ID de refund
return order;
}
return authCode;
}
} }

View File

@ -359,6 +359,7 @@ public class UserController {
@GetMapping(value = "api/get-users", produces = MediaType.APPLICATION_JSON_VALUE) @GetMapping(value = "api/get-users", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> getUsers( public Map<String, Object> getUsers(
@RequestParam(required = false) String role, // puede venir ausente @RequestParam(required = false) String role, // puede venir ausente
@RequestParam(required = false) Boolean showUsername,
@RequestParam(required = false) String q, @RequestParam(required = false) String q,
@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) { @RequestParam(defaultValue = "10") int size) {
@ -373,9 +374,15 @@ public class UserController {
.map(u -> { .map(u -> {
Map<String, Object> m = new HashMap<>(); Map<String, Object> m = new HashMap<>();
m.put("id", u.getId()); m.put("id", u.getId());
m.put("text", (u.getFullName() != null && !u.getFullName().isBlank()) if (showUsername != null && Boolean.TRUE.equals(showUsername)) {
? u.getFullName() m.put("text", (u.getFullName() != null && !u.getFullName().isBlank())
: u.getUserName()); ? u.getFullName() + " (" + u.getUserName() + ")"
: u.getUserName());
} else {
m.put("text", (u.getFullName() != null && !u.getFullName().isBlank())
? u.getFullName()
: u.getUserName());
}
return m; return m;
}) })
.collect(Collectors.toList()); .collect(Collectors.toList());
@ -385,4 +392,20 @@ public class UserController {
"pagination", Map.of("more", more)); "pagination", Map.of("more", more));
} }
@ResponseBody
@GetMapping(value = "api/get-user/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> getUser(@PathVariable Long id) {
User u = userService.findById(id);
if (u == null) {
return Map.of();
}
Map<String, Object> m = new HashMap<>();
m.put("id", u.getId());
m.put("userName", u.getUserName());
m.put("fullName", u.getFullName());
return m;
}
} }

View File

@ -19,60 +19,63 @@ import org.springframework.lang.Nullable;
@Repository @Repository
public interface UserDao extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> { public interface UserDao extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
// Aplicamos EntityGraph a la versión con Specification+Pageable // Aplicamos EntityGraph a la versión con Specification+Pageable
@Override @Override
@EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" }) @EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" })
@NonNull @NonNull
Page<User> findAll(@Nullable Specification<User> spec, @NonNull Pageable pageable); Page<User> findAll(@Nullable Specification<User> spec, @NonNull Pageable pageable);
Optional<User> findByUserNameIgnoreCase(String userName); Optional<User> findByUserNameIgnoreCase(String userName);
boolean existsByUserNameIgnoreCase(String userName); boolean existsByUserNameIgnoreCase(String userName);
// Para comprobar si existe al hacer signup // Para comprobar si existe al hacer signup
@Query(value = """ @Query(value = """
SELECT id, deleted, enabled SELECT id, deleted, enabled
FROM users FROM users
WHERE LOWER(username) = LOWER(:userName) WHERE LOWER(username) = LOWER(:userName)
LIMIT 1 LIMIT 1
""", nativeQuery = true) """, nativeQuery = true)
Optional<UserLite> findLiteByUserNameIgnoreCase(@Param("userName") String userName); Optional<UserLite> findLiteByUserNameIgnoreCase(@Param("userName") String userName);
boolean existsByUserNameIgnoreCaseAndIdNot(String userName, Long id); boolean existsByUserNameIgnoreCaseAndIdNot(String userName, Long id);
// Nuevo: para login/negocio "activo" // Nuevo: para login/negocio "activo"
@EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" }) @EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" })
Optional<User> findByUserNameIgnoreCaseAndEnabledTrueAndDeletedFalse(String userName); Optional<User> findByUserNameIgnoreCaseAndEnabledTrueAndDeletedFalse(String userName);
// Para poder restaurar, necesitas leer ignorando @Where (native): // Para poder restaurar, necesitas leer ignorando @Where (native):
@Query(value = "SELECT * FROM users WHERE id = :id", nativeQuery = true) @Query(value = "SELECT * FROM users WHERE id = :id", nativeQuery = true)
Optional<User> findByIdIncludingDeleted(@Param("id") Long id); Optional<User> findByIdIncludingDeleted(@Param("id") Long id);
@Query(value = "SELECT * FROM users WHERE deleted = TRUE", nativeQuery = true) @Query(value = "SELECT * FROM users WHERE deleted = TRUE", nativeQuery = true)
List<User> findAllDeleted(); List<User> findAllDeleted();
@Query("select u.id from User u where lower(u.userName) = lower(:userName)") @Query("select u.id from User u where lower(u.userName) = lower(:userName)")
Optional<Long> findIdByUserNameIgnoreCase(@Param("userName") String userName); Optional<Long> findIdByUserNameIgnoreCase(@Param("userName") String userName);
@Query(value = """ @Query(value = """
SELECT DISTINCT u SELECT DISTINCT u
FROM User u FROM User u
JOIN u.rolesLink rl JOIN u.rolesLink rl
JOIN rl.role r JOIN rl.role r
WHERE (:role IS NULL OR r.name = :role) WHERE (:role IS NULL OR r.name = :role)
AND (:q IS NULL OR LOWER(u.fullName) LIKE LOWER(CONCAT('%', :q, '%')) AND (:q IS NULL OR LOWER(u.fullName) LIKE LOWER(CONCAT('%', :q, '%'))
OR LOWER(u.userName) LIKE LOWER(CONCAT('%', :q, '%'))) OR LOWER(u.userName) LIKE LOWER(CONCAT('%', :q, '%')))
""", countQuery = """ """, countQuery = """
SELECT COUNT(DISTINCT u.id) SELECT COUNT(DISTINCT u.id)
FROM User u FROM User u
JOIN u.rolesLink rl JOIN u.rolesLink rl
JOIN rl.role r JOIN rl.role r
WHERE (:role IS NULL OR r.name = :role) WHERE (:role IS NULL OR r.name = :role)
AND (:q IS NULL OR LOWER(u.fullName) LIKE LOWER(CONCAT('%', :q, '%')) AND (:q IS NULL OR LOWER(u.fullName) LIKE LOWER(CONCAT('%', :q, '%'))
OR LOWER(u.userName) LIKE LOWER(CONCAT('%', :q, '%'))) OR LOWER(u.userName) LIKE LOWER(CONCAT('%', :q, '%')))
""") """)
Page<User> searchUsers(@Param("role") String role, Page<User> searchUsers(@Param("role") String role,
@Param("q") String q, @Param("q") String q,
Pageable pageable); Pageable pageable);
@Query("select u.id from User u where lower(u.fullName) like lower(concat('%', :name, '%'))")
List<Long> findIdsByFullNameLike(@Param("name") String name);
} }

View File

@ -17,4 +17,5 @@ public interface UserService extends UserDetailsService {
* @return página de usuarios * @return página de usuarios
*/ */
Page<User> findByRoleAndSearch(String role, String query, Pageable pageable); Page<User> findByRoleAndSearch(String role, String query, Pageable pageable);
User findById(Long id);
} }

View File

@ -31,4 +31,8 @@ public class UserServiceImpl implements UserService {
if (query == null || query.isBlank()) query = null; if (query == null || query.isBlank()) query = null;
return userDao.searchUsers(role, query, pageable); return userDao.searchUsers(role, query, pageable);
} }
public User findById(Long id) {
return userDao.findById(id).orElse(null);
}
} }

View File

@ -3,16 +3,35 @@
# #
# Logging # Logging
# #
logging.level.root=INFO
logging.level.org.springframework.security=ERROR logging.level.org.springframework.security=ERROR
logging.level.root=ERROR
logging.level.org.springframework=ERROR logging.level.org.springframework=ERROR
logging.level.org.springframework.web=ERROR
logging.level.org.thymeleaf=ERROR
logging.level.org.apache.catalina.core=ERROR
# Debug JPA / Hibernate # Debug JPA / Hibernate
#logging.level.org.hibernate.SQL=DEBUG #logging.level.org.hibernate.SQL=DEBUG
#logging.level.org.hibernate.orm.jdbc.bind=TRACE #logging.level.org.hibernate.orm.jdbc.bind=TRACE
#spring.jpa.properties.hibernate.format_sql=true #spring.jpa.properties.hibernate.format_sql=true
server.error.include-message=always
server.error.include-stacktrace=on_param
server.error.include-binding-errors=on_param
# Archivo relativo a tu proyecto (asegúrate de que exista el directorio ./logs)
logging.file.name=logs/erp.log
# Rotación tiempo+tamaño (mismo patrón, pero en ./logs)
logging.logback.rollingpolicy.file-name-pattern=logs/erp-%d{yyyy-MM-dd}.%i.log
logging.logback.rollingpolicy.max-file-size=10MB
logging.logback.rollingpolicy.max-history=10
logging.logback.rollingpolicy.total-size-cap=1GB
# Formatos con timestamp
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} %-5level [%thread] %logger{36} - %msg%n
logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss} %-5level [%thread] %logger{36} - %msg%n
# Datos de la API de Safekat # Datos de la API de Safekat
safekat.api.url=http://localhost:8000/ safekat.api.url=http://localhost:8000/
safekat.api.email=imnavajas@coit.es safekat.api.email=imnavajas@coit.es
@ -20,6 +39,8 @@ safekat.api.password=Safekat2024
# Configuración Redsys # Configuración Redsys
redsys.environment=test redsys.environment=test
redsys.url=https://sis-t.redsys.es:25443/sis/realizarPago
redsys.refund.url=https://sis-t.redsys.es:25443/sis/rest/trataPeticionREST
redsys.urls.ok=http://localhost:8080/pagos/redsys/ok redsys.urls.ok=http://localhost:8080/pagos/redsys/ok
redsys.urls.ko=http://localhost:8080/pagos/redsys/ko redsys.urls.ko=http://localhost:8080/pagos/redsys/ko
redsys.urls.notify=http://localhost:8080/pagos/redsys/notify redsys.urls.notify=https://orological-sacrilegiously-lucille.ngrok-free.dev/pagos/redsys/notify

View File

@ -3,14 +3,36 @@
# #
# Logging # Logging
# #
logging.level.org.springframework.security=ERROR
# Niveles
logging.level.root=ERROR logging.level.root=ERROR
logging.level.org.springframework=ERROR logging.level.org.springframework=ERROR
# Debug JPA / Hibernate logging.level.org.springframework.security=ERROR
#logging.level.org.hibernate.SQL=DEBUG logging.level.org.springframework.web=ERROR
#logging.level.org.hibernate.orm.jdbc.bind=TRACE logging.level.org.thymeleaf=ERROR
#spring.jpa.properties.hibernate.format_sql=true logging.level.org.apache.catalina.core=ERROR
server.error.include-message=never
server.error.include-stacktrace=never
server.error.include-binding-errors=never
# Opcional: desactivar Whitelabel y servir tu propia página de error
server.error.whitelabel.enabled=false
# Servelet options
server.servlet.context-path=/intranet
# Archivo principal dentro del contenedor (monta /var/log/imprimelibros como volumen)
logging.file.name=/var/log/imprimelibros/erp.log
# Rotación tiempo+tamaño -> requiere %d y %i
logging.logback.rollingpolicy.file-name-pattern=/var/log/imprimelibros/erp-%d{yyyy-MM-dd}.%i.log
logging.logback.rollingpolicy.max-file-size=10MB
logging.logback.rollingpolicy.max-history=10
logging.logback.rollingpolicy.total-size-cap=1GB
# Formatos con timestamp
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} %-5level [%thread] %logger{36} - %msg%n
logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss} %-5level [%thread] %logger{36} - %msg%n
# Datos de la API de Safekat # Datos de la API de Safekat
@ -20,6 +42,8 @@ safekat.api.password=Safekat2024
# Configuración Redsys # Configuración Redsys
redsys.environment=test redsys.environment=test
redsys.urls.ok=https://imprimelibros.jjimenez.eu/pagos/redsys/ok redsys.url=https://sis-t.redsys.es:25443/sis/realizarPago
redsys.urls.ko=https://imprimelibros.jjimenez.eu/pagos/redsys/ko redsys.refund.url=https://sis-t.redsys.es:25443/sis/rest/trataPeticionREST
redsys.urls.notify=https://imprimelibros.jjimenez.eu/pagos/redsys/notify redsys.urls.ok=https://app.imprimelibros.com/intranet/pagos/redsys/ok
redsys.urls.ko=https://app.imprimelibros.com/intranet/pagos/redsys/ko
redsys.urls.notify=https://app.imprimelibros.com/intranet/pagos/redsys/notify

View File

@ -1,7 +1,7 @@
spring.application.name=erp spring.application.name=erp
# Active profile # Active profile
spring.profiles.active=dev #spring.profiles.active=dev
#spring.profiles.active=test spring.profiles.active=test
#spring.profiles.active=prod #spring.profiles.active=prod
@ -19,15 +19,6 @@ spring.jpa.show-sql=false
# Hibernate Timezone # Hibernate Timezone
spring.jpa.properties.hibernate.jdbc.time_zone=UTC spring.jpa.properties.hibernate.jdbc.time_zone=UTC
# 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
# #
# Resource chain # Resource chain
# Activa el resource chain y versionado por contenido # Activa el resource chain y versionado por contenido
@ -106,5 +97,3 @@ redsys.currency=978
redsys.transaction-type=0 redsys.transaction-type=0
redsys.secret-key=sq7HjrUOBfKmC576ILgskD5srU870gJ7 redsys.secret-key=sq7HjrUOBfKmC576ILgskD5srU870gJ7

View File

@ -539,13 +539,13 @@ databaseChangeLog:
- column: - column:
constraints: constraints:
nullable: false nullable: false
defaultValueComputed: CURRENT_TIMESTAMP(3) defaultValueComputed: CURRENT_TIMESTAMP
name: created_at name: created_at
type: datetime type: datetime
- column: - column:
constraints: constraints:
nullable: false nullable: false
defaultValueComputed: CURRENT_TIMESTAMP(3) on update CURRENT_TIMESTAMP(3) defaultValueComputed: CURRENT_TIMESTAMP on update CURRENT_TIMESTAMP
name: updated_at name: updated_at
type: datetime type: datetime
- column: - column:
@ -573,6 +573,7 @@ databaseChangeLog:
name: iva_reducido name: iva_reducido
type: BIT(1) type: BIT(1)
tableName: presupuesto tableName: presupuesto
- changeSet: - changeSet:
id: 1761213112413-10 id: 1761213112413-10
author: jjimenez (generated) author: jjimenez (generated)
@ -840,7 +841,7 @@ databaseChangeLog:
associatedWith: '' associatedWith: ''
columns: columns:
- column: - column:
defaultValueNumeric: !!float '0' defaultValueNumeric: 0
name: deleted name: deleted
indexName: idx_presupuesto_deleted indexName: idx_presupuesto_deleted
tableName: presupuesto tableName: presupuesto

View File

@ -0,0 +1,418 @@
databaseChangeLog:
- changeSet:
id: 0007-payments-core
author: jjo
changes:
# 2) payments
- createTable:
tableName: payments
columns:
- column:
name: id
type: BIGINT AUTO_INCREMENT
constraints:
primaryKey: true
nullable: false
- column:
name: order_id
type: BIGINT
- column:
name: user_id
type: BIGINT
- column:
name: currency
type: CHAR(3)
constraints:
nullable: false
- column:
name: amount_total_cents
type: BIGINT
constraints:
nullable: false
- column:
name: amount_captured_cents
type: BIGINT
defaultValueNumeric: 0
constraints:
nullable: false
- column:
name: amount_refunded_cents
type: BIGINT
defaultValueNumeric: 0
constraints:
nullable: false
- column:
name: status
type: "ENUM('requires_payment_method','requires_action','authorized','captured','partially_refunded','refunded','canceled','failed')"
defaultValue: "requires_payment_method"
constraints:
nullable: false
- column:
name: capture_method
type: "ENUM('automatic','manual')"
defaultValue: "automatic"
constraints:
nullable: false
- column:
name: gateway
type: VARCHAR(32)
constraints:
nullable: false
- column:
name: gateway_payment_id
type: VARCHAR(128)
- column:
name: gateway_order_id
type: VARCHAR(12)
- column:
name: authorization_code
type: VARCHAR(32)
- column:
name: three_ds_status
type: "ENUM('not_applicable','attempted','challenge','succeeded','failed')"
defaultValue: "not_applicable"
constraints:
nullable: false
- column:
name: descriptor
type: VARCHAR(22)
- column:
name: client_ip
type: VARBINARY(16)
- column:
name: authorized_at
type: DATETIME
- column:
name: captured_at
type: DATETIME
- column:
name: canceled_at
type: DATETIME
- column:
name: failed_at
type: DATETIME
- column:
name: metadata
type: JSON
- column:
name: created_at
type: DATETIME
defaultValueComputed: CURRENT_TIMESTAMP
constraints:
nullable: false
- column:
name: updated_at
type: DATETIME
defaultValueComputed: "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"
constraints:
nullable: false
- createIndex:
tableName: payments
indexName: idx_payments_order
columns:
- column:
name: order_id
- createIndex:
tableName: payments
indexName: idx_payments_gateway
columns:
- column:
name: gateway
- column:
name: gateway_payment_id
- createIndex:
tableName: payments
indexName: idx_payments_status
columns:
- column:
name: status
- addUniqueConstraint:
tableName: payments
columnNames: gateway, gateway_order_id
constraintName: uq_payments_gateway_order
# 3) payment_transactions
- createTable:
tableName: payment_transactions
columns:
- column:
name: id
type: BIGINT AUTO_INCREMENT
constraints:
primaryKey: true
nullable: false
- column:
name: payment_id
type: BIGINT
constraints:
nullable: false
- column:
name: type
type: "ENUM('AUTH','CAPTURE','REFUND','VOID')"
constraints:
nullable: false
- column:
name: status
type: "ENUM('pending','succeeded','failed')"
constraints:
nullable: false
- column:
name: amount_cents
type: BIGINT
constraints:
nullable: false
- column:
name: currency
type: CHAR(3)
constraints:
nullable: false
- column:
name: gateway_transaction_id
type: VARCHAR(128)
- column:
name: gateway_response_code
type: VARCHAR(64)
- column:
name: avs_result
type: VARCHAR(8)
- column:
name: cvv_result
type: VARCHAR(8)
- column:
name: three_ds_version
type: VARCHAR(16)
- column:
name: idempotency_key
type: VARCHAR(128)
- column:
name: request_payload
type: JSON
- column:
name: response_payload
type: JSON
- column:
name: processed_at
type: DATETIME
- column:
name: created_at
type: DATETIME
defaultValueComputed: CURRENT_TIMESTAMP
constraints:
nullable: false
- addForeignKeyConstraint:
baseTableName: payment_transactions
baseColumnNames: payment_id
referencedTableName: payments
referencedColumnNames: id
constraintName: fk_tx_payment
onDelete: CASCADE
- addUniqueConstraint:
tableName: payment_transactions
columnNames: gateway_transaction_id
constraintName: uq_tx_gateway_txid
- createIndex:
tableName: payment_transactions
indexName: idx_tx_pay
columns:
- column:
name: payment_id
- createIndex:
tableName: payment_transactions
indexName: idx_tx_type_status
columns:
- column:
name: type
- column:
name: status
- createIndex:
tableName: payment_transactions
indexName: idx_tx_idem
columns:
- column:
name: idempotency_key
# 4) refunds
- createTable:
tableName: refunds
columns:
- column:
name: id
type: BIGINT AUTO_INCREMENT
constraints:
primaryKey: true
nullable: false
- column:
name: payment_id
type: BIGINT
constraints:
nullable: false
- column:
name: transaction_id
type: BIGINT
- column:
name: amount_cents
type: BIGINT
constraints:
nullable: false
- column:
name: reason
type: "ENUM('customer_request','partial_return','pricing_adjustment','duplicate','fraud','other')"
defaultValue: "customer_request"
constraints:
nullable: false
- column:
name: status
type: "ENUM('pending','succeeded','failed','canceled')"
defaultValue: "pending"
constraints:
nullable: false
- column:
name: requested_by_user_id
type: BIGINT
- column:
name: requested_at
type: DATETIME
defaultValueComputed: CURRENT_TIMESTAMP
constraints:
nullable: false
- column:
name: processed_at
type: DATETIME
- column:
name: gateway_refund_id
type: VARCHAR(128)
- column:
name: notes
type: VARCHAR(500)
- column:
name: metadata
type: JSON
- addForeignKeyConstraint:
baseTableName: refunds
baseColumnNames: payment_id
referencedTableName: payments
referencedColumnNames: id
constraintName: fk_ref_payment
onDelete: CASCADE
- addForeignKeyConstraint:
baseTableName: refunds
baseColumnNames: transaction_id
referencedTableName: payment_transactions
referencedColumnNames: id
constraintName: fk_ref_tx
onDelete: SET NULL
- addUniqueConstraint:
tableName: refunds
columnNames: gateway_refund_id
constraintName: uq_refund_gateway_id
- createIndex:
tableName: refunds
indexName: idx_ref_pay
columns:
- column:
name: payment_id
- createIndex:
tableName: refunds
indexName: idx_ref_status
columns:
- column:
name: status
# 5) webhook_events
- createTable:
tableName: webhook_events
columns:
- column:
name: id
type: BIGINT AUTO_INCREMENT
constraints:
primaryKey: true
nullable: false
- column:
name: provider
type: VARCHAR(32)
constraints:
nullable: false
- column:
name: event_type
type: VARCHAR(64)
constraints:
nullable: false
- column:
name: event_id
type: VARCHAR(128)
- column:
name: signature
type: VARCHAR(512)
- column:
name: payload
type: JSON
constraints:
nullable: false
- column:
name: processed
type: TINYINT(1)
defaultValueNumeric: 0
constraints:
nullable: false
- column:
name: processed_at
type: DATETIME
- column:
name: attempts
type: INT
defaultValueNumeric: 0
constraints:
nullable: false
- column:
name: last_error
type: VARCHAR(500)
- column:
name: created_at
type: DATETIME
defaultValueComputed: CURRENT_TIMESTAMP
constraints:
nullable: false
- addUniqueConstraint:
tableName: webhook_events
columnNames: provider, event_id
constraintName: uq_webhook_provider_event
- createIndex:
tableName: webhook_events
indexName: idx_webhook_processed
columns:
- column:
name: processed
rollback:
# Se borran las tablas en orden inverso de dependencias
- dropTable:
tableName: webhook_events
- dropTable:
tableName: refunds
- dropTable:
tableName: payment_transactions
- dropTable:
tableName: payments

View File

@ -0,0 +1,47 @@
databaseChangeLog:
- changeSet:
id: 0008-update-cart-status-constraint
author: jjo
changes:
# 1) Eliminar el índice único antiguo (user_id, status)
- sql:
sql: |
ALTER TABLE carts
DROP INDEX uq_carts_user_active;
# 2) Añadir columna generada 'active_flag'
# Será 1 si status = 'ACTIVE', y NULL en cualquier otro caso
- sql:
sql: |
ALTER TABLE carts
ADD COLUMN active_flag TINYINT(1)
GENERATED ALWAYS AS (
CASE WHEN status = 'ACTIVE' THEN 1 ELSE NULL END
);
# 3) Crear el nuevo índice único:
# solo limita (user_id, active_flag=1),
# se permiten muchos registros con active_flag NULL (LOCKED, COMPLETED, etc.)
- sql:
sql: |
CREATE UNIQUE INDEX uq_carts_user_active
ON carts (user_id, active_flag);
rollback:
# 🔙 1) Eliminar el índice nuevo basado en active_flag
- sql:
sql: |
ALTER TABLE carts
DROP INDEX uq_carts_user_active;
# 🔙 2) Eliminar la columna generada active_flag
- sql:
sql: |
ALTER TABLE carts
DROP COLUMN active_flag;
# 🔙 3) Restaurar el índice único original (user_id, status)
- sql:
sql: |
CREATE UNIQUE INDEX uq_carts_user_active
ON carts (user_id, status);

View File

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

View File

@ -0,0 +1,37 @@
databaseChangeLog:
- changeSet:
id: 0010-drop-unique-tx-gateway
author: JJO
# ✅ Solo ejecuta el changeSet si existe la UNIQUE constraint
preConditions:
- onFail: MARK_RAN
- uniqueConstraintExists:
tableName: payment_transactions
constraintName: uq_tx_gateway_txid_type
changes:
# 1⃣ Eliminar la UNIQUE constraint si existe
- dropUniqueConstraint:
tableName: payment_transactions
constraintName: uq_tx_gateway_txid_type
# 2⃣ Crear un índice normal (no único) sobre gateway_transaction_id
- createIndex:
tableName: payment_transactions
indexName: idx_payment_tx_gateway_txid
columns:
- column:
name: gateway_transaction_id
rollback:
# 🔙 1) Eliminar el índice normal creado en este changeSet
- dropIndex:
tableName: payment_transactions
indexName: idx_payment_tx_gateway_txid
# 🔙 2) Restaurar la UNIQUE constraint original
- addUniqueConstraint:
tableName: payment_transactions
columnNames: gateway_transaction_id, type
constraintName: uq_tx_gateway_txid_type

View File

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

View File

@ -0,0 +1,31 @@
databaseChangeLog:
- changeSet:
id: 0012--drop-unique-gateway-txid-2
author: jjo
changes:
# 1) Eliminar el índice UNIQUE actual
- dropIndex:
indexName: uq_tx_gateway_txid
tableName: payment_transactions
# 2) Crear un índice normal (no único) sobre gateway_transaction_id
- createIndex:
indexName: idx_tx_gateway_txid
tableName: payment_transactions
columns:
- column:
name: gateway_transaction_id
rollback:
# Rollback: volver al índice UNIQUE como estaba antes
- dropIndex:
indexName: idx_tx_gateway_txid
tableName: payment_transactions
- createIndex:
indexName: uq_tx_gateway_txid
tableName: payment_transactions
unique: true
columns:
- column:
name: gateway_transaction_id

View File

@ -0,0 +1,33 @@
databaseChangeLog:
- changeSet:
id: 0013-drop-unique-refund-gateway-id
author: jjo
changes:
# 1) Eliminar el índice UNIQUE actual sobre gateway_refund_id
- dropIndex:
indexName: uq_refund_gateway_id
tableName: refunds
# 2) Crear un índice normal (no único) sobre gateway_refund_id
- createIndex:
indexName: idx_refund_gateway_id
tableName: refunds
columns:
- column:
name: gateway_refund_id
rollback:
# Rollback: quitar el índice normal
- dropIndex:
indexName: idx_refund_gateway_id
tableName: refunds
# y restaurar el UNIQUE como estaba antes
- createIndex:
indexName: uq_refund_gateway_id
tableName: refunds
unique: true
columns:
- column:
name: gateway_refund_id

View File

@ -0,0 +1,151 @@
databaseChangeLog:
- changeSet:
id: 0014-create-pedidos-direcciones
author: jjo
changes:
- createTable:
tableName: pedidos_direcciones
columns:
- column:
name: id
type: BIGINT AUTO_INCREMENT
constraints:
primaryKey: true
nullable: false
- column:
name: pedido_linea_id
type: BIGINT
constraints:
nullable: true
- column:
name: pedido_id
type: BIGINT
constraints:
nullable: true
- column:
name: unidades
type: MEDIUMINT UNSIGNED
constraints:
nullable: true
- column:
name: is_facturacion
type: TINYINT(1)
defaultValueNumeric: 0
constraints:
nullable: false
- column:
name: is_ejemplar_prueba
type: TINYINT(1)
defaultValueNumeric: 0
constraints:
nullable: false
- column:
name: att
type: VARCHAR(150)
constraints:
nullable: false
- column:
name: direccion
type: VARCHAR(255)
constraints:
nullable: false
- column:
name: cp
type: MEDIUMINT UNSIGNED
constraints:
nullable: false
- column:
name: ciudad
type: VARCHAR(100)
constraints:
nullable: false
- column:
name: provincia
type: VARCHAR(100)
constraints:
nullable: false
- column:
name: pais_code3
type: CHAR(3)
defaultValue: esp
constraints:
nullable: false
- column:
name: telefono
type: VARCHAR(30)
constraints:
nullable: false
- column:
name: instrucciones
type: VARCHAR(255)
constraints:
nullable: true
- column:
name: razon_social
type: VARCHAR(150)
constraints:
nullable: true
- column:
name: tipo_identificacion_fiscal
type: ENUM('DNI','NIE','CIF','Pasaporte','VAT_ID')
defaultValue: DNI
constraints:
nullable: false
- column:
name: identificacion_fiscal
type: VARCHAR(50)
constraints:
nullable: true
- column:
name: created_at
type: TIMESTAMP
defaultValueComputed: CURRENT_TIMESTAMP
constraints:
nullable: false
- addForeignKeyConstraint:
baseTableName: pedidos_direcciones
baseColumnNames: pedido_linea_id
referencedTableName: pedidos_lineas
referencedColumnNames: id
constraintName: fk_pedidos_direcciones_pedido_linea
onDelete: SET NULL
onUpdate: CASCADE
- addForeignKeyConstraint:
baseTableName: pedidos_direcciones
baseColumnNames: pedido_id
referencedTableName: pedidos
referencedColumnNames: id
constraintName: fk_pedidos_direcciones_pedidos
onDelete: SET NULL
onUpdate: CASCADE
- createIndex:
tableName: pedidos_direcciones
indexName: idx_pedidos_direcciones_pedido_linea_id
columns:
- column:
name: pedido_linea_id
rollback:
- dropTable:
tableName: pedidos_direcciones
cascadeConstraints: true

View File

@ -0,0 +1,48 @@
databaseChangeLog:
- changeSet:
id: 0015-alter-pedidos-lineas-and-presupuesto-estados
author: jjo
changes:
# Añadir columnas a pedidos_lineas
- addColumn:
tableName: pedidos_lineas
columns:
- column:
name: estado
type: "ENUM('aprobado','maquetación','haciendo_ferro','producción','terminado','cancelado')"
defaultValue: aprobado
constraints:
nullable: false
afterColumn: presupuesto_id
- column:
name: estado_manual
type: TINYINT(1)
defaultValueNumeric: 0
constraints:
nullable: false
afterColumn: estado
# Añadir columna a presupuesto
- addColumn:
tableName: presupuesto
columns:
- column:
name: is_reimpresion
type: TINYINT(1)
defaultValueNumeric: 0
constraints:
nullable: false
rollback:
- dropColumn:
tableName: pedidos_lineas
columnName: estado
- dropColumn:
tableName: pedidos_lineas
columnName: estado_manual
- dropColumn:
tableName: presupuesto
columnName: is_reimpresion

Some files were not shown because too many files have changed in this diff Show More