Compare commits

..

140 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
7f2db075a8 Merge branch 'fix/cesta_errores' into 'main'
arreglados problemas varios

See merge request jjimenez/erp-imprimelibros!20
2025-11-01 11:08:26 +00:00
6afa78df68 arreglados problemas varios 2025-11-01 12:07:28 +01:00
a01d74aeb2 Merge branch 'feat/checkout' into 'main'
Feat/checkout

See merge request jjimenez/erp-imprimelibros!19
2025-10-31 10:36:41 +00:00
40dc719e89 terminado carrito 2025-10-31 11:36:03 +01:00
90c191d8f8 falta mover la cesta a cliente 2025-10-31 08:25:13 +01:00
167c136dca falta actualizar bien el resumen 2025-10-30 19:48:26 +01:00
feff9ee94a cargando carrito desde backend 2025-10-29 23:30:33 +01:00
ae2904aa71 intentando meter los ficheros redsys 2025-10-29 14:10:10 +01:00
5e9631073e falta el update carrito del backend 2025-10-29 13:31:25 +01:00
c272fd7b9b trabajando en envios dentro del presupuesto 2025-10-28 22:28:27 +01:00
f770bd07d6 direccion unica añadida 2025-10-28 14:05:22 +01:00
cecc0c0ea0 modificando carro para añadir los nav tabs 2025-10-28 08:17:15 +01:00
9dad59fe16 modificando carrito 2025-10-27 20:30:38 +01:00
f6a683de81 modificando carrito 2025-10-27 20:30:11 +01:00
de7a392e07 Merge branch 'feat/direcciones_cliente' into 'main'
Feat/direcciones cliente

See merge request jjimenez/erp-imprimelibros!18
2025-10-26 14:45:28 +00:00
8590235709 direcciones terminadas 2025-10-26 15:44:54 +01:00
8e011e7fca falta borrar direcciones e implementar una vista de cliente 2025-10-25 15:18:17 +02:00
2ed032d7c6 trabajando en añadir 2025-10-24 16:15:05 +02:00
3517918afe quitado el servicio que estaba vacio 2025-10-23 21:39:16 +02:00
1526600ee1 añadido todo lo referente a paises 2025-10-23 21:38:35 +02:00
59f7d315d9 Merge branch 'feat/liquidbase' into 'main'
añadido sistema de migraciones

See merge request jjimenez/erp-imprimelibros!17
2025-10-23 10:41:07 +00:00
c35c0780c3 añadido sistema de migraciones 2025-10-23 12:39:17 +02:00
dbd3687b34 Merge branch 'fix/papel_interior_group' into 'main'
arreglado

See merge request jjimenez/erp-imprimelibros!16
2025-10-20 15:55:34 +00:00
af41410880 arreglado 2025-10-20 17:54:39 +02:00
8d53ac8957 Merge branch 'fix/papeles_interior_change' into 'main'
Fix/papeles interior change

See merge request jjimenez/erp-imprimelibros!15
2025-10-20 15:20:41 +00:00
d0c3bdc5a6 cambiados orden columnas en margenes 2025-10-20 17:19:57 +02:00
b2e5dcac9b arreglado el name del input en el evento 2025-10-20 17:18:33 +02:00
4e96514029 Merge branch 'fix/load_anonimos' into 'main'
solucionada carga de presupuestos anonimos

See merge request jjimenez/erp-imprimelibros!14
2025-10-20 15:06:12 +00:00
5b31eb4f32 solucionada carga de presupuestos anonimos 2025-10-20 17:05:32 +02:00
c43f3f098d Merge branch 'fix/pom_problems' into 'main'
quitadas dependencias duplicadas

See merge request jjimenez/erp-imprimelibros!13
2025-10-20 12:26:58 +00:00
dfe0a98964 quitadas dependencias duplicadas 2025-10-20 14:25:12 +02:00
3b832844f7 Merge branch 'fix/precio_maquetacion_columnas' into 'main'
arreglado el calculo del precio por columnas en el presupuesto maquetacion

See merge request jjimenez/erp-imprimelibros!12
2025-10-20 12:08:53 +00:00
d75b47b00a arreglado el calculo del precio por columnas en el presupuesto maquetacion 2025-10-20 14:07:39 +02:00
3468048de9 Merge branch 'mod/margenes-tirada' into 'main'
modificado los margenes por precio en lugar de por tirada y tipos

See merge request jjimenez/erp-imprimelibros!11
2025-10-20 07:39:02 +00:00
d3ef954ceb modificado los margenes por precio en lugar de por tirada y tipos 2025-10-20 09:37:04 +02:00
1888850a64 Merge branch 'feat/crud_presupuestos' into 'main'
Feat/crud presupuestos

See merge request jjimenez/erp-imprimelibros!10
2025-10-17 11:32:17 +00:00
06e03afa04 terminado. trabajando en el carrito. falta mensaje de ya añadido 2025-10-17 13:31:09 +02:00
46715d1017 primera version final del presupuesto 2025-10-17 09:21:31 +02:00
ea8a005cde acabando presupuesto 2025-10-16 21:46:02 +02:00
ff9c04afb6 trabajando en el resumen 2025-10-16 15:22:50 +02:00
060b435388 arreglados problemas presupuesto, botones css etc 2025-10-16 14:24:24 +02:00
f26f96a490 falta precio completo del item del carrito y devolver que ya existe el presupuesto en el carro 2025-10-15 20:05:56 +02:00
f20dd9068a corregidos varios fallos de presupuesto 2025-10-15 19:43:00 +02:00
70856edc12 añadido grapado al validados para tipos de paginas distintas 2025-10-15 11:13:45 +02:00
9f33db4055 trabajando en la vista del carro de la compra 2025-10-14 21:52:25 +02:00
a33ba3256b estructura inicial de carrito hecha 2025-10-14 15:16:02 +02:00
90376e61c8 modificado iva dependiendo de si es reducido o no y del lugar de la entrega 2025-10-14 14:04:21 +02:00
37ae61d6f7 modificado iva dependiendo de si es reducido o no y del lugar de la entrega 2025-10-14 13:21:35 +02:00
9b0a79e2cd modificado iva dependiendo de si es reducido o no y del lugar de la entrega 2025-10-14 12:31:33 +02:00
47344c94a9 modificado iva dependiendo de si es reducido o no y del lugar de la entrega 2025-10-14 12:30:39 +02:00
543ff9a079 modificado iva dependiendo de si es reducido o no y del lugar de la entrega 2025-10-14 12:30:23 +02:00
d99ef65268 draft de impresion presupuesto, añadiendo posibilidades de iva 2025-10-13 14:38:30 +02:00
9d88392a2b trabajando en el pdf 2025-10-12 23:28:55 +02:00
9ebe2a3419 trabajando en el pdf 2025-10-12 23:28:43 +02:00
c15fff73ee trabajando en el pdf 2025-10-12 23:28:33 +02:00
99d27cd3ed mejorados botones form presupuesto 2025-10-12 21:43:45 +02:00
26c2ca543a preparando el imprimir 2025-10-12 21:42:04 +02:00
6641c1f077 falta presupuesto marcapaginas y maquetacion y revision general en admin. revisar user 2025-10-12 14:48:20 +02:00
62dcff8869 falta ordenar por paginas y revisar la busqueda de los selects 2025-10-11 18:16:55 +02:00
a1359f37b0 guardando presupuestos anonimos 2025-10-11 14:14:47 +02:00
d4d83fe118 trabajando en el delete 2025-10-10 13:28:03 +02:00
6c4b63daa6 trabajando en leer datos de tipo cubierta 2025-10-09 21:59:59 +02:00
328ff509e3 trabajando en el wizard del presupuesto 2025-10-09 15:40:42 +02:00
2b53579a48 tabla anonimos terminado 2025-10-08 21:35:20 +02:00
389ac22b68 ya obtengo los datos de los anonimos, falta formatear esos datos y buscar 2025-10-07 10:44:32 +02:00
1e8f9cafb3 trabajando en el datatables de los presupuestos 2025-10-06 15:32:50 +02:00
718 changed files with 41289 additions and 161979 deletions

3
.gitignore vendored
View File

@ -31,3 +31,6 @@ build/
### VS Code ###
.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
ports:
- "8080:8080"
volumes:
- ./logs:/var/log/imprimelibros
restart: always
networks:
- imprimelibros-network

14
liquibase.properties Normal file
View File

@ -0,0 +1,14 @@
# liquibase.properties (RAÍZ DEL PROYECTO)
classpath=target/classes
# Conexión (ajusta DB, user y pass)
url=jdbc:mysql://localhost:3309/imprimelibros
#url=jdbc:mysql://localhost:3306/imprimelibros?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
username=imprimelibros_user
password=om91irrDctd
# Archivo que se generará con el snapshot (DEBE EXISTIR LA CARPETA)
#outputChangeLogFile=src/main/resources/db/changelog/changesets/0001-baseline.yml
# Para LEER los cambios en los demás comandos (sync, update, rollback…)
changeLogFile=src/main/resources/db/changelog/master.yml

12303
logs/erp.log Normal file

File diff suppressed because one or more lines are too long

87
pom.xml
View File

@ -6,7 +6,7 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.3</version>
<version>3.5.9</version>
<relativePath /> <!-- lookup parent from repository -->
</parent>
<groupId>com.imprimelibros</groupId>
@ -29,8 +29,15 @@
</scm>
<properties>
<java.version>21</java.version>
<liquibase.version>4.29.2</liquibase.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
@ -51,10 +58,6 @@
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
@ -63,10 +66,6 @@
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
@ -139,6 +138,60 @@
<scope>test</scope>
</dependency>
<!-- PDF generation -->
<dependency>
<groupId>com.openhtmltopdf</groupId>
<artifactId>openhtmltopdf-pdfbox</artifactId>
<version>1.0.10</version>
</dependency>
<dependency>
<groupId>com.openhtmltopdf</groupId>
<artifactId>openhtmltopdf-slf4j</artifactId>
<version>1.0.10</version>
</dependency>
<!-- Migraciones -->
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
<version>${liquibase.version}</version>
</dependency>
<!-- Redsys -->
<dependency>
<groupId>sis.redsys</groupId>
<artifactId>apiSha256</artifactId>
<version>1.0</version>
<scope>system</scope>
<systemPath>${project.basedir}/src/main/resources/lib/apiSha256.jar</systemPath>
</dependency>
<!-- Dependencias locales incluidas en el ZIP -->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
<version>1.47</version>
<scope>system</scope>
<systemPath>${project.basedir}/src/main/resources/lib/bcprov-jdk15on-1.4.7.jar</systemPath>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.3</version>
<scope>system</scope>
<systemPath>${project.basedir}/src/main/resources/lib/commons-codec-1.3.jar</systemPath>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>1.0</version>
<scope>system</scope>
<systemPath>${project.basedir}/src/main/resources/lib/org.json.jar</systemPath>
</dependency>
</dependencies>
<build>
@ -146,6 +199,22 @@
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<!-- IMPORTANTE: incluir dependencias con scope=system en el fat-jar -->
<configuration>
<!-- Esto hace que meta las dependencias con scope=system en BOOT-INF/lib -->
<includeSystemScope>true</includeSystemScope>
</configuration>
</plugin>
<!-- (Migraciones) Plugin Maven para generar/ejecutar changelogs -->
<plugin>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-maven-plugin</artifactId>
<version>4.29.2</version>
<configuration>
<!-- Usa variables de Maven/CI o un liquibase.properties -->
<propertyFile>liquibase.properties</propertyFile>
</configuration>
</plugin>
</plugins>
</build>

BIN
src.zip

Binary file not shown.

View File

@ -2,8 +2,12 @@ package com.imprimelibros.erp;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
@ConfigurationPropertiesScan(basePackages = "com.imprimelibros.erp")
public class ErpApplication {
public static void main(String[] args) {

View File

@ -0,0 +1,134 @@
package com.imprimelibros.erp.cart;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "carts", uniqueConstraints = @UniqueConstraint(name = "uq_carts_user_active", columnNames = { "user_id",
"status" }))
public class Cart {
public enum Status {
ACTIVE, LOCKED, ABANDONED
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private Long userId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private Status status = Status.ACTIVE;
@Column(nullable = false, length = 3)
private String currency = "EUR";
@Column(name = "only_one_shipment", nullable = false)
private Boolean onlyOneShipment = true;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt = LocalDateTime.now();
@OneToMany(mappedBy = "cart", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<CartItem> items = new ArrayList<>();
@OneToMany(mappedBy = "cart", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<CartDireccion> direcciones = new ArrayList<>();
@Column(name = "total", nullable = false)
private BigDecimal total = BigDecimal.ZERO;
@PreUpdate
public void preUpdate() {
this.updatedAt = LocalDateTime.now();
}
// Getters & Setters
public Long getId() {
return id;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public Status getStatus() {
return status;
}
public void setStatus(Status status) {
this.status = status;
}
public String getCurrency() {
return currency;
}
public void setCurrency(String currency) {
this.currency = currency;
}
public Boolean getOnlyOneShipment() {
return onlyOneShipment;
}
public void setOnlyOneShipment(Boolean onlyOneShipment) {
this.onlyOneShipment = onlyOneShipment;
}
public BigDecimal getTotal() {
return total;
}
public void setTotal(BigDecimal total) {
this.total = total;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public List<CartItem> getItems() {
return items;
}
public void setItems(List<CartItem> items) {
this.items = items;
}
public List<CartDireccion> getDirecciones() {
return direcciones;
}
public void setDirecciones(List<CartDireccion> direcciones) {
this.direcciones = direcciones;
}
public void addDireccion(CartDireccion d) {
direcciones.add(d);
d.setCart(this);
}
public void removeDireccion(CartDireccion d) {
direcciones.remove(d);
d.setCart(null);
}
}

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

@ -0,0 +1,202 @@
package com.imprimelibros.erp.cart;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.context.MessageSource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.direcciones.Direccion;
import com.imprimelibros.erp.direcciones.DireccionService;
import com.imprimelibros.erp.i18n.TranslationService;
import java.security.Principal;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import com.imprimelibros.erp.cart.dto.UpdateCartRequest;
@Controller
@RequestMapping("/cart")
public class CartController {
protected final CartService service;
protected DireccionService direccionService;
protected MessageSource messageSource;
protected TranslationService translationService;
public CartController(CartService service, DireccionService direccionService, MessageSource messageSource,
TranslationService translationService) {
this.service = service;
this.direccionService = direccionService;
this.messageSource = messageSource;
this.translationService = translationService;
}
/** Vista del carrito */
@GetMapping
public String viewCart(Model model, Principal principal, Locale locale) {
List<String> keys = List.of(
"app.cancelar",
"app.seleccionar",
"cart.shipping.add.title",
"cart.shipping.select-placeholder",
"cart.shipping.new-address",
"cart.shipping.errors.noAddressSelected",
"cart.shipping.enter-units",
"cart.shipping.units-label",
"cart.shipping.errors.units-error",
"cart.shipping.ud",
"cart.shipping.uds",
"cart.shipping.send-in-palets",
"cart.shipping.send-in-palets.info",
"cart.shipping.tipo-envio",
"cart.pass-to.customer.error",
"cart.pass-to.customer.error-move",
"app.yes",
"app.aceptar",
"app.cancelar");
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
Long userId = Utils.currentUserId(principal);
Cart cart = service.getOrCreateActiveCart(userId);
var items = service.listItems(userId, locale);
model.addAttribute("items", items);
Map<String, Object> direcciones = service.getCartDirecciones(cart.getId(), locale);
if (direcciones != null && direcciones.containsKey("mainDir"))
model.addAttribute("mainDir", direcciones.get("mainDir"));
else if (direcciones != null && direcciones.containsKey("direcciones"))
model.addAttribute("direcciones", direcciones.get("direcciones"));
var summary = service.getCartSummary(cart, locale);
model.addAttribute("cartSummary", summary);
if (summary.get("errorShipmentCost") != null && (Boolean) summary.get("errorShipmentCost"))
model.addAttribute("errorEnvio", true);
else
model.addAttribute("errorEnvio", false);
model.addAttribute("cart", cart);
return "imprimelibros/cart/cart"; // crea esta vista si quieres (tabla simple)
}
/** Añadir presupuesto via POST form */
@PostMapping("/add")
public String add(@PathVariable(name = "presupuestoId", required = true) Long presupuestoId, Principal principal) {
service.addPresupuesto(Utils.currentUserId(principal), presupuestoId);
return "redirect:/cart";
}
/** Añadir presupuesto con ruta REST (opcional) */
@PostMapping("/add/{presupuestoId}")
public Object addPath(@PathVariable Long presupuestoId, Principal principal, HttpServletRequest request) {
service.addPresupuesto(Utils.currentUserId(principal), presupuestoId);
boolean isAjax = "XMLHttpRequest".equals(request.getHeader("X-Requested-With"));
if (isAjax) {
// Responder 200 con la URL a la que quieres ir
return ResponseEntity.ok(
Map.of("redirect", "/cart"));
}
// Navegación normal: redirección server-side
return "redirect:/cart";
}
@GetMapping("/count")
@ResponseBody
public long getCount(Principal principal) {
if (principal == null)
return 0;
return service.countItems(Utils.currentUserId(principal));
}
/** Eliminar línea por ID de item */
@DeleteMapping("/{itemId}/remove")
public String remove(@PathVariable Long itemId, Principal principal) {
service.removeItem(Utils.currentUserId(principal), itemId);
return "redirect:/cart";
}
/** Eliminar línea por presupuesto_id (opcional) */
@DeleteMapping("/delete/item/{presupuestoId}")
@ResponseBody
public String removeByPresupuesto(@PathVariable Long presupuestoId, Principal principal) {
service.removeByPresupuesto(Utils.currentUserId(principal), presupuestoId);
return "redirect:/cart";
}
/** Vaciar carrito completo */
@DeleteMapping("/clear")
public String clear(Principal principal) {
service.clear(Utils.currentUserId(principal));
return "redirect:/cart";
}
@GetMapping("/get-address/{id}")
public String getDireccionCard(@PathVariable Long id, @RequestParam(required = false) Long presupuestoId,
@RequestParam(required = false) Integer unidades,
@RequestParam(required = false) Integer isPalets,
Model model, Locale locale) {
Direccion dir = direccionService.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
model.addAttribute("pais", messageSource.getMessage("paises." + dir.getPais().getKeyword(), null,
dir.getPais().getKeyword(), locale));
model.addAttribute("presupuestoId", presupuestoId);
model.addAttribute("unidades", unidades);
model.addAttribute("isPalets", isPalets);
model.addAttribute("direccion", dir);
return "imprimelibros/direcciones/direccionCard :: direccionCard(direccion=${direccion})";
}
@PostMapping(value = "/update/{id}", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public String updateCart(@PathVariable Long id, UpdateCartRequest updateRequest, Model model, Locale locale,
Principal principal) {
try {
service.updateCart(id, updateRequest);
var cartSummary = service.getCartSummary(service.getCartById(id), locale);
model.addAttribute("cartSummary", cartSummary);
return "imprimelibros/cart/_cartSummary :: cartSummary(summary=${cartSummary})";
} catch (Exception e) {
// redirect to cart with error message
String errorMessage = messageSource.getMessage("cart.update.error", null, "Error updating cart", locale);
model.addAttribute("errorMessage", errorMessage);
return "redirect:/cart";
}
}
@PostMapping(value = "/pass-to-customer/{customerId}")
public ResponseEntity<?> moveToCustomer(
@PathVariable Long customerId,
Principal principal) {
if(!Utils.isCurrentUserAdmin()) {
return ResponseEntity.status(403).body(Map.of("error", "Forbidden"));
}
Long userId = Utils.currentUserId(principal);
Cart cart = service.getOrCreateActiveCart(userId);
boolean ok = service.moveCartToCustomer(cart.getId(), customerId);
if (ok)
return ResponseEntity.ok().build();
return ResponseEntity.status(400).body(Map.of("error", "cart.errors.move-cart"));
}
}

View File

@ -0,0 +1,152 @@
package com.imprimelibros.erp.cart;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import org.springframework.context.MessageSource;
import com.imprimelibros.erp.cart.dto.DireccionCardDTO;
import com.imprimelibros.erp.direcciones.Direccion;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import jakarta.persistence.*;
@Entity
@Table(name = "cart_direcciones")
public class CartDireccion {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "cart_id", nullable = false)
private Cart cart;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "direccion_id", nullable = false)
private Direccion direccion;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "presupuesto_id")
private Presupuesto presupuesto;
@Column(name = "unidades")
private Integer unidades;
@Column(name = "isPalets", nullable = false)
private Boolean isPalets;
// --- Getters & Setters ---
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Cart getCart() {
return cart;
}
public void setCart(Cart cart) {
this.cart = cart;
}
public Direccion getDireccion() {
return direccion;
}
public void setDireccion(Direccion direccion) {
this.direccion = direccion;
}
public Presupuesto getPresupuesto() {
return presupuesto;
}
public void setPresupuesto(Presupuesto presupuesto) {
this.presupuesto = presupuesto;
}
public Integer getUnidades() {
return unidades;
}
public void setUnidades(Integer unidades) {
this.unidades = unidades;
}
public Boolean getIsPalets() {
return isPalets;
}
public void setIsPalets(Boolean isPalets) {
this.isPalets = isPalets;
}
public DireccionCardDTO toDireccionCard(MessageSource messageSource, Locale locale) {
String pais = messageSource.getMessage("paises." + this.direccion.getPais().getKeyword(), null,
this.direccion.getPais().getKeyword(), locale);
return new DireccionCardDTO(
this.direccion,
this.presupuesto != null ? this.presupuesto.getId() : null,
this.unidades,
this.isPalets,
pais
);
}
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

@ -0,0 +1,40 @@
package com.imprimelibros.erp.cart;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
@Entity
@Table(
name = "cart_items",
uniqueConstraints = @UniqueConstraint(name="uq_cartitem_unique", columnNames={"cart_id","presupuesto_id"})
)
public class CartItem {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "cart_id", nullable = false)
private Cart cart;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "presupuesto_id", nullable = false)
private Presupuesto presupuesto;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
// Getters & Setters
public Long getId() { return id; }
public Cart getCart() { return cart; }
public void setCart(Cart cart) { this.cart = cart; }
public Presupuesto getPresupuesto() { return presupuesto; }
public void setPresupuesto(Presupuesto presupuesto) { this.presupuesto = presupuesto; }
public LocalDateTime getCreatedAt() { return createdAt; }
}

View File

@ -0,0 +1,17 @@
package com.imprimelibros.erp.cart;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface CartItemRepository extends JpaRepository<CartItem, Long> {
List<CartItem> findByCartId(Long cartId);
Optional<CartItem> findByCartIdAndPresupuestoId(Long cartId, Long presupuestoId);
boolean existsByCartIdAndPresupuestoId(Long cartId, Long presupuestoId);
long deleteByCartId(Long cartId);
}

View File

@ -0,0 +1,35 @@
package com.imprimelibros.erp.cart;
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.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Optional;
public interface CartRepository extends JpaRepository<Cart, Long> {
Optional<Cart> findByUserIdAndStatus(Long userId, Cart.Status status);
@Query("""
select distinct c from Cart c
left join fetch c.direcciones cd
left join fetch cd.direccion d
left join fetch d.pais p
left join fetch cd.presupuesto pr
where c.id = :id
""")
Optional<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

@ -0,0 +1,492 @@
package com.imprimelibros.erp.cart;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import com.imprimelibros.erp.presupuesto.classes.PresupuestoFormatter;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import com.imprimelibros.erp.presupuesto.service.PresupuestoService;
import com.imprimelibros.erp.users.UserService;
import com.imprimelibros.erp.cart.dto.CartDireccionRepository;
import com.imprimelibros.erp.cart.dto.DireccionCardDTO;
import com.imprimelibros.erp.cart.dto.DireccionShipment;
import com.imprimelibros.erp.cart.dto.UpdateCartRequest;
import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.common.email.EmailService;
import com.imprimelibros.erp.direcciones.DireccionService;
import com.imprimelibros.erp.externalApi.skApiClient;
import com.imprimelibros.erp.pedidos.PedidoRepository;
import com.imprimelibros.erp.presupuesto.PresupuestoRepository;
@Service
public class CartService {
private final EmailService emailService;
private final CartRepository cartRepo;
private final CartDireccionRepository cartDireccionRepo;
private final CartItemRepository itemRepo;
private final MessageSource messageSource;
private final PresupuestoRepository presupuestoRepo;
private final DireccionService direccionService;
private final skApiClient skApiClient;
private final PresupuestoService presupuestoService;
private final PedidoRepository pedidoRepository;
private final UserService userService;
public CartService(CartRepository cartRepo, CartItemRepository itemRepo,
CartDireccionRepository cartDireccionRepo, MessageSource messageSource,
PresupuestoFormatter presupuestoFormatter, PresupuestoRepository presupuestoRepo, PedidoRepository pedidoRepository,
DireccionService direccionService, skApiClient skApiClient,PresupuestoService presupuestoService, EmailService emailService, UserService userService) {
this.cartRepo = cartRepo;
this.itemRepo = itemRepo;
this.cartDireccionRepo = cartDireccionRepo;
this.messageSource = messageSource;
this.presupuestoRepo = presupuestoRepo;
this.direccionService = direccionService;
this.skApiClient = skApiClient;
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. */
@Transactional
public Cart getOrCreateActiveCart(Long userId) {
return cartRepo.findByUserIdAndStatus(userId, Cart.Status.ACTIVE)
.orElseGet(() -> {
Cart c = new Cart();
c.setUserId(userId);
c.setStatus(Cart.Status.ACTIVE);
return cartRepo.save(c);
});
}
public Cart getCartById(Long cartId) {
return cartRepo.findById(cartId)
.orElseThrow(() -> new IllegalArgumentException("Carrito no encontrado"));
}
/** Lista items (presupuestos) del carrito activo del usuario. */
@Transactional
public List<Map<String, Object>> listItems(Long userId, Locale locale) {
Cart cart = getOrCreateActiveCart(userId);
List<Map<String, Object>> resultados = new ArrayList<>();
List<CartItem> items = itemRepo.findByCartId(cart.getId());
for (CartItem item : items) {
Presupuesto p = item.getPresupuesto();
Map<String, Object> elemento = presupuestoService.getPresupuestoInfoForCard(p, locale);
elemento.put("cartItemId", item.getId());
resultados.add(elemento);
}
// System.out.println("Cart items: " + resultados);
return resultados;
}
/** Añade un presupuesto al carrito. Si ya está, no hace nada (idempotente). */
@Transactional
public void addPresupuesto(Long userId, Long presupuestoId) {
Cart cart = getOrCreateActiveCart(userId);
boolean exists = itemRepo.existsByCartIdAndPresupuestoId(cart.getId(), presupuestoId);
if (!exists) {
CartItem ci = new CartItem();
ci.setCart(cart);
ci.setPresupuesto(presupuestoRepo.findById(presupuestoId)
.orElseThrow(() -> new IllegalArgumentException("Presupuesto no encontrado")));
itemRepo.save(ci);
}
}
/** Elimina una línea del carrito por ID de item (validando pertenencia). */
@Transactional
public void removeItem(Long userId, Long itemId) {
Cart cart = getOrCreateActiveCart(userId);
CartItem item = itemRepo.findById(itemId).orElseThrow(() -> new IllegalArgumentException("Item no existe"));
if (!item.getCart().getId().equals(cart.getId()))
throw new IllegalStateException("El item no pertenece a tu carrito");
itemRepo.delete(item);
}
/** Elimina una línea del carrito buscando por presupuesto_id. */
@Transactional
public void removeByPresupuesto(Long userId, Long presupuestoId) {
Cart cart = getOrCreateActiveCart(userId);
CartItem item = itemRepo.findByCartIdAndPresupuestoId(cart.getId(), presupuestoId)
.orElseThrow(() -> new IllegalArgumentException("Item no encontrado"));
itemRepo.deleteById(item.getId());
}
/** Vacía todo el carrito activo. */
@Transactional
public void clear(Long userId) {
Cart cart = getOrCreateActiveCart(userId);
itemRepo.deleteByCartId(cart.getId());
}
/** Marca el carrito como bloqueado (por ejemplo, antes de crear un pedido). */
@Transactional
public void lockCart(Long userId) {
Cart cart = getOrCreateActiveCart(userId);
cart.setStatus(Cart.Status.LOCKED);
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
public long countItems(Long userId) {
Cart cart = getOrCreateActiveCart(userId);
return itemRepo.findByCartId(cart.getId()).size();
}
public Map<String, Object> getCartSummaryRaw(Cart cart, Locale locale) {
double base = 0.0;
double iva4 = 0.0;
double iva21 = 0.0;
double shipment = 0.0;
boolean errorShipementCost = false;
List<CartItem> items = cart.getItems();
List<CartDireccion> direcciones = cart.getDirecciones();
for (CartItem item : items) {
Presupuesto p = item.getPresupuesto();
Double peso = p.getPeso() != null ? p.getPeso().doubleValue() : 0.0;
base += p.getBaseImponible().doubleValue();
iva4 += p.getIvaImporte4().doubleValue();
iva21 += p.getIvaImporte21().doubleValue();
if (cart.getOnlyOneShipment() != null && cart.getOnlyOneShipment()) {
if (direcciones != null && !direcciones.isEmpty()) {
CartDireccion cd = direcciones.get(0);
boolean freeShipment = direccionService.checkFreeShipment(
cd.getDireccion().getCp(),
cd.getDireccion().getPaisCode3()) && !cd.getIsPalets();
if (!freeShipment) {
Integer unidades = p.getSelectedTirada();
Map<String, Object> res = getShippingCost(cd, peso, unidades, locale);
if (Boolean.FALSE.equals(res.get("success"))) {
errorShipementCost = true;
} else {
shipment += (Double) res.get("shipment");
iva21 += (Double) res.get("iva21");
}
}
// ejemplar de prueba
if (p.getServiciosJson() != null && p.getServiciosJson().contains("ejemplar-prueba")) {
Map<String, Object> res = getShippingCost(cd, peso, 1, locale);
if (Boolean.FALSE.equals(res.get("success"))) {
errorShipementCost = true;
} else {
shipment += (Double) res.get("shipment");
iva21 += (Double) res.get("iva21");
}
}
}
} else {
if (direcciones == null)
continue;
List<CartDireccion> cd_presupuesto = direcciones.stream()
.filter(d -> d.getPresupuesto() != null
&& d.getPresupuesto().getId().equals(p.getId())
&& d.getUnidades() != null
&& d.getUnidades() > 0)
.toList();
boolean firstDirection = true;
for (CartDireccion cd : cd_presupuesto) {
Integer unidades = cd.getUnidades();
if (firstDirection) {
boolean freeShipment = direccionService.checkFreeShipment(
cd.getDireccion().getCp(),
cd.getDireccion().getPaisCode3()) && !cd.getIsPalets();
if (!freeShipment && unidades != null && unidades > 0) {
Map<String, Object> res = getShippingCost(cd, peso, unidades, locale);
if (Boolean.FALSE.equals(res.get("success"))) {
errorShipementCost = true;
} else {
shipment += (Double) res.get("shipment");
iva21 += (Double) res.get("iva21");
}
}
firstDirection = false;
} else {
Map<String, Object> res = getShippingCost(cd, peso, unidades, locale);
if (Boolean.FALSE.equals(res.get("success"))) {
errorShipementCost = true;
} else {
shipment += (Double) res.get("shipment");
iva21 += (Double) res.get("iva21");
}
}
}
// ejemplar de prueba
CartDireccion cd_prueba = direcciones.stream()
.filter(d -> d.getPresupuesto() != null
&& d.getPresupuesto().getId().equals(p.getId())
&& d.getUnidades() == null)
.findFirst().orElse(null);
if (cd_prueba != null) {
Map<String, Object> res = getShippingCost(cd_prueba, peso, 1, locale);
if (Boolean.FALSE.equals(res.get("success"))) {
errorShipementCost = true;
} else {
shipment += (Double) res.get("shipment");
iva21 += (Double) res.get("iva21");
}
}
}
}
double totalBeforeDiscount = base + iva4 + iva21 + shipment;
int fidelizacion = this.getDescuentoFidelizacion(cart.getUserId());
double descuento = totalBeforeDiscount * fidelizacion / 100.0;
double total = totalBeforeDiscount - descuento;
// Redondeo a 2 decimales
base = Utils.round2(base);
iva4 = Utils.round2(iva4);
iva21 = Utils.round2(iva21);
shipment = Utils.round2(shipment);
descuento = Utils.round2(descuento);
total = Utils.round2(total);
Map<String, Object> summary = new HashMap<>();
summary.put("base", base);
summary.put("iva4", iva4);
summary.put("iva21", iva21);
summary.put("shipment", shipment);
summary.put("fidelizacion", fidelizacion);
summary.put("descuento", descuento);
summary.put("total", total);
summary.put("amountCents", Math.round(total * 100));
summary.put("errorShipmentCost", errorShipementCost);
summary.put("cartId", cart.getId());
return summary;
}
public 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<>();
summary.put("base", Utils.formatCurrency(base, locale));
summary.put("iva4", Utils.formatCurrency(iva4, locale));
summary.put("iva21", Utils.formatCurrency(iva21, locale));
summary.put("shipment", Utils.formatCurrency(shipment, locale));
summary.put("fidelizacion", fidelizacion + "%");
summary.put("descuento", Utils.formatCurrency(-descuento, locale)); // negativo para mostrar
summary.put("total", Utils.formatCurrency(total, locale));
summary.put("amountCents", raw.get("amountCents"));
summary.put("errorShipmentCost", raw.get("errorShipmentCost"));
summary.put("cartId", raw.get("cartId"));
return summary;
}
@Transactional(readOnly = true)
public Map<String, Object> getCartDirecciones(Long cartId, Locale locale) {
Cart cart = cartRepo.findByIdFetchAll(cartId)
.orElseThrow(() -> new IllegalArgumentException("Carrito no encontrado"));
Map<String, Object> result = new HashMap<>();
List<CartDireccion> direcciones = cart.getDirecciones();
if (cart.getOnlyOneShipment() && !direcciones.isEmpty()) {
result.put("mainDir", direcciones.get(0).toDireccionCard(messageSource, locale));
} else {
List<DireccionCardDTO> dirCards = cart.getDirecciones().stream()
.filter(Objects::nonNull)
.map(cd -> cd.toDireccionCard(messageSource, locale))
.filter(Objects::nonNull)
.toList();
result.put("direcciones", dirCards);
}
return result;
}
@Transactional
public Boolean updateCart(Long cartId, UpdateCartRequest request) {
try {
Cart cart = cartRepo.findById(cartId)
.orElseThrow(() -> new IllegalArgumentException("Carrito no encontrado"));
cart.setOnlyOneShipment(request.isOnlyOneShipment());
// Borramos todas las direcciones actuales de la bbdd
// Opcional (limpieza): romper backref antes de clear
for (CartDireccion d : cart.getDirecciones()) {
d.setCart(null);
}
cart.getDirecciones().clear();
// Guardamos las direcciones
List<DireccionShipment> direcciones = request.getDirecciones();
if (direcciones != null && direcciones.size() > 0) {
for (DireccionShipment dir : direcciones) {
// Crear una nueva CartDireccion por cada item
CartDireccion cd = new CartDireccion();
cd.setCart(cart);
cd.setDireccion(dir.getId() != null ? direccionService.findById(dir.getId())
.orElseThrow(() -> new IllegalArgumentException("Dirección no encontrada")) : null);
cd.setIsPalets(dir.getIsPalets() != null ? dir.getIsPalets() : false);
cd.setUnidades(dir.getUnidades() != null ? dir.getUnidades() : null);
if (dir.getPresupuestoId() != null) {
Presupuesto p = presupuestoRepo.findById(dir.getPresupuestoId())
.orElse(null);
cd.setPresupuesto(p);
}
cart.addDireccion(cd);
}
} else {
}
cartRepo.save(cart);
return true;
} catch (Exception e) {
// Manejo de excepciones
return false;
}
}
public Boolean moveCartToCustomer(Long cartId, Long customerId) {
try {
// Remove the cart from the customer if they have one
Cart existingCart = cartRepo.findByUserIdAndStatus(customerId, Cart.Status.ACTIVE)
.orElse(null);
if (existingCart != null) {
cartRepo.delete(existingCart);
}
Cart cart = cartRepo.findById(cartId)
.orElseThrow(() -> new IllegalArgumentException("Carrito no encontrado"));
cart.setUserId(customerId);
cartRepo.save(cart);
// 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;
} catch (Exception e) {
// Manejo de excepciones
return false;
}
}
// delete cart directions by direccion id in ACTIVE carts
@Transactional
public void deleteCartDireccionesByDireccionId(Long direccionId) {
cartDireccionRepo.deleteByDireccionIdAndCartStatus(direccionId, Cart.Status.ACTIVE);
}
/***************************************
* MÉTODOS PRIVADOS
***************************************/
private Map<String, Object> getShippingCost(
CartDireccion cd,
Double peso,
Integer unidades,
Locale locale) {
Map<String, Object> result = new HashMap<>();
try {
Map<String, Object> data = Map.of(
"cp", cd.getDireccion().getCp(),
"pais_code3", cd.getDireccion().getPaisCode3(),
"peso", peso != null ? peso : 0.0,
"unidades", unidades,
"palets", Boolean.TRUE.equals(cd.getIsPalets()) ? 1 : 0);
var shipmentCost = skApiClient.getCosteEnvio(data, locale);
if (shipmentCost != null && shipmentCost.get("data") != null) {
Number n = (Number) shipmentCost.get("data");
double cost = n.doubleValue();
result.put("success", true);
result.put("shipment", cost);
result.put("iva21", cost * 0.21);
} else {
result.put("success", false);
result.put("shipment", 0.0);
result.put("iva21", 0.0);
}
} catch (Exception e) {
result.put("success", false);
result.put("shipment", 0.0);
result.put("iva21", 0.0);
}
return result;
}
}

View File

@ -0,0 +1,31 @@
package com.imprimelibros.erp.cart.dto;
import com.imprimelibros.erp.cart.Cart;
import com.imprimelibros.erp.cart.CartDireccion;
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.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
public interface CartDireccionRepository extends JpaRepository<CartDireccion, Long> {
// Borrado masivo por cart_id
void deleteByCartId(Long cartId);
// Lectura por cart_id (útil para componer respuestas)
List<CartDireccion> findByCartId(Long cartId);
@Modifying
@Transactional
@Query("""
delete from CartDireccion cd
where cd.direccion.id = :direccionId
and cd.cart.status = :status
""")
int deleteByDireccionIdAndCartStatus(@Param("direccionId") Long direccionId,
@Param("status") Cart.Status status);
}

View File

@ -0,0 +1,39 @@
package com.imprimelibros.erp.cart.dto;
import com.imprimelibros.erp.direcciones.Direccion;
public class DireccionCardDTO {
private final Direccion direccion;
private final Long presupuestoId;
private final Integer unidades;
private final Boolean isPalets;
private final String pais;
public DireccionCardDTO(Direccion direccion, Long presupuestoId, Integer unidades, Boolean isPalets, String pais) {
this.direccion = direccion;
this.presupuestoId = presupuestoId;
this.unidades = unidades;
this.isPalets = isPalets;
this.pais = pais;
}
public Direccion getDireccion() {
return direccion;
}
public Long getPresupuestoId() {
return presupuestoId;
}
public Integer getUnidades() {
return unidades;
}
public Boolean getIsPalets() {
return isPalets;
}
public String getPais() {
return pais;
}
}

View File

@ -0,0 +1,60 @@
package com.imprimelibros.erp.cart.dto;
public class DireccionShipment {
private Long id; // puede no venir → null
private String cp; // puede no venir → null
private String paisCode3; // puede no venir → null
private Long presupuestoId; // puede no venir → null
private Integer unidades; // puede no venir → null
private Boolean isPalets; // puede no venir → null
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getCp() {
return cp;
}
public void setCp(String cp) {
this.cp = cp;
}
public String getPaisCode3() {
return paisCode3;
}
public void setPaisCode3(String paisCode3) {
this.paisCode3 = paisCode3;
}
public Long getPresupuestoId() {
return presupuestoId;
}
public void setPresupuestoId(Long presupuestoId) {
this.presupuestoId = presupuestoId;
}
public Integer getUnidades() {
return unidades;
}
public void setUnidades(Integer unidades) {
this.unidades = unidades;
}
public Boolean getIsPalets() {
return isPalets;
}
public void setIsPalets(Boolean isPalets) {
this.isPalets = isPalets;
}
}

View File

@ -0,0 +1,26 @@
package com.imprimelibros.erp.cart.dto;
import java.util.ArrayList;
import java.util.List;
public class UpdateCartRequest {
private Boolean onlyOneShipment = Boolean.FALSE; // default: false
private List<DireccionShipment> direcciones = new ArrayList<>();
public boolean isOnlyOneShipment() { // boolean-style getter
return Boolean.TRUE.equals(onlyOneShipment);
}
public void setOnlyOneShipment(Boolean onlyOneShipment) {
this.onlyOneShipment = onlyOneShipment;
}
public List<DireccionShipment> getDirecciones() {
return direcciones;
}
public void setDirecciones(List<DireccionShipment> direcciones) {
this.direcciones = (direcciones != null) ? direcciones : new ArrayList<>();
}
}

View File

@ -0,0 +1,75 @@
package com.imprimelibros.erp.checkout;
import java.security.Principal;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.context.MessageSource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
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.server.ResponseStatusException;
import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.i18n.TranslationService;
import com.imprimelibros.erp.paises.PaisesService;
import com.imprimelibros.erp.direcciones.Direccion;
import com.imprimelibros.erp.direcciones.DireccionService;
import com.imprimelibros.erp.cart.Cart;
import com.imprimelibros.erp.cart.CartService;
@Controller
@RequestMapping("/checkout")
public class CheckoutController {
protected CartService cartService;
protected TranslationService translationService;
protected PaisesService paisesService;
protected DireccionService direccionService;
protected MessageSource messageSource;
public CheckoutController(CartService cartService, TranslationService translationService,
PaisesService paisesService, DireccionService direccionService, MessageSource messageSource) {
this.cartService = cartService;
this.translationService = translationService;
this.paisesService = paisesService;
this.direccionService = direccionService;
this.messageSource = messageSource;
}
@GetMapping
public String view(Model model, Principal principal, Locale locale) {
List<String> keys = List.of(
"app.cancelar",
"app.seleccionar",
"app.yes",
"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);
model.addAttribute("languageBundle", translations);
Long userId = Utils.currentUserId(principal);
Cart cart = cartService.getOrCreateActiveCart(userId);
model.addAttribute("summary", cartService.getCartSummary(cart, locale));
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

@ -0,0 +1,439 @@
package com.imprimelibros.erp.common;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.security.Principal;
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.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.BiFunction;
import org.springframework.context.MessageSource;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imprimelibros.erp.datatables.DataTablesRequest;
import com.imprimelibros.erp.presupuesto.classes.PresupuestoFormatter;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import com.imprimelibros.erp.presupuesto.maquetacion.MaquetacionMatrices;
import com.imprimelibros.erp.presupuesto.marcapaginas.Marcapaginas;
import com.imprimelibros.erp.users.User;
import com.imprimelibros.erp.users.UserDetailsImpl;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.Path;
import jakarta.persistence.criteria.Predicate;
import java.util.function.Function;
@Component
public class Utils {
private final PresupuestoFormatter presupuestoFormatter;
private final MessageSource messageSource;
public Utils(PresupuestoFormatter presupuestoFormatter,
MessageSource messageSource) {
this.presupuestoFormatter = presupuestoFormatter;
this.messageSource = messageSource;
}
public static List<Map<String, Object>> decodeJsonList(String json) {
ObjectMapper mapper = new ObjectMapper();
try {
return mapper.readValue(json, new TypeReference<List<Map<String, Object>>>() {
});
} catch (JsonProcessingException e) {
return new ArrayList<>();
}
}
public static Map<String, Object> decodeJsonMap(String json) {
ObjectMapper mapper = new ObjectMapper();
try {
return mapper.readValue(json, new TypeReference<Map<String, Object>>() {
});
} catch (JsonProcessingException e) {
return new HashMap<>();
}
}
public static double round2(double value) {
return BigDecimal.valueOf(value)
.setScale(2, RoundingMode.HALF_UP)
.doubleValue();
}
public static boolean isCurrentUserAdmin() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")
|| a.getAuthority().equals("ROLE_SUPERADMIN"));
}
public static Long currentUserId(Principal principal) {
if (principal == null) {
throw new IllegalStateException("Usuario no autenticado");
}
if (principal instanceof Authentication auth) {
Object principalObj = auth.getPrincipal();
if (principalObj instanceof UserDetailsImpl udi) {
return udi.getId();
} else if (principalObj instanceof User u && u.getId() != null) {
return u.getId();
}
}
throw new IllegalStateException("No se pudo obtener el ID del usuario actual");
}
public static 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) {
NumberFormat currencyFormatter = NumberFormat.getCurrencyInstance(locale);
return currencyFormatter.format(amount);
}
public static String formatCurrency(Double amount, Locale locale) {
NumberFormat currencyFormatter = NumberFormat.getCurrencyInstance(locale);
return currencyFormatter.format(amount);
}
public static String formatNumber(BigDecimal amount, Locale locale) {
NumberFormat numberFormatter = NumberFormat.getNumberInstance(locale);
return numberFormatter.format(amount);
}
public static String formatNumber(Double amount, Locale locale) {
NumberFormat numberFormatter = NumberFormat.getNumberInstance(locale);
return numberFormatter.format(amount);
}
public static Optional<BiFunction<Path<BigDecimal>, CriteriaBuilder, Predicate>> parseNumericFilter(
DataTablesRequest dt, String colName, Locale locale) {
String raw = dt.getColumnSearch(colName); // usa el "name" del DataTable (snake_case)
if (raw == null || raw.isBlank())
return Optional.empty();
String s = raw.trim();
// normaliza número con coma o punto
Function<String, BigDecimal> toBig = x -> {
String t = x.replace(".", "").replace(",", "."); // 1.234,56 -> 1234.56
return new BigDecimal(t);
};
try {
if (s.matches("(?i)^>=?\\s*[-\\d.,]+$")) {
BigDecimal v = toBig.apply(s.replace(">=", "").replace(">", "").trim());
return Optional.of((path, cb) -> cb.greaterThanOrEqualTo(path, v));
}
if (s.matches("(?i)^<=?\\s*[-\\d.,]+$")) {
BigDecimal v = toBig.apply(s.replace("<=", "").replace("<", "").trim());
return Optional.of((path, cb) -> cb.lessThanOrEqualTo(path, v));
}
if (s.contains("-")) { // rango "a-b"
String[] p = s.split("-");
if (p.length == 2) {
BigDecimal a = toBig.apply(p[0].trim());
BigDecimal b = toBig.apply(p[1].trim());
BigDecimal min = a.min(b), max = a.max(b);
return Optional.of((path, cb) -> cb.between(path, min, max));
}
}
// exacto/like numérico
BigDecimal v = toBig.apply(s);
return Optional.of((path, cb) -> cb.equal(path, v));
} catch (Exception ignore) {
return Optional.empty();
}
}
public Map<String, Object> getTextoPresupuesto(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());
ObjectMapper mapper = new ObjectMapper();
List<Map<String, Object>> servicios = new ArrayList<>();
if (presupuesto.getServiciosJson() != null && !presupuesto.getServiciosJson().isBlank())
try {
servicios = mapper.readValue(presupuesto.getServiciosJson(), new TypeReference<>() {
});
} catch (JsonProcessingException e) {
// Manejar la excepción
}
boolean hayDepositoLegal = servicios != null && servicios.stream()
.map(m -> java.util.Objects.toString(m.get("id"), ""))
.map(String::trim)
.anyMatch("deposito-legal"::equals);
List<HashMap<String, Object>> lineas = new ArrayList<>();
HashMap<String, Object> linea = new HashMap<>();
Double precio_unitario = 0.0;
Double precio_total = 0.0;
BigDecimal total = BigDecimal.ZERO;
linea.put("descripcion", presupuestoFormatter.resumen(presupuesto, servicios, locale));
linea.put("cantidad", presupuesto.getSelectedTirada() != null ? presupuesto.getSelectedTirada() : 0);
precio_unitario = (presupuesto.getPrecioUnitario() != null
? presupuesto.getPrecioUnitario().doubleValue()
: 0.0);
precio_total = (presupuesto.getPrecioTotalTirada() != null
? presupuesto.getPrecioTotalTirada().doubleValue()
: 0.0);
linea.put("precio_unitario", precio_unitario);
linea.put("precio_total", BigDecimal.valueOf(precio_total).setScale(2, RoundingMode.HALF_UP));
total = total.add(BigDecimal.valueOf(precio_total));
lineas.add(linea);
if (hayDepositoLegal) {
linea = new HashMap<>();
linea.put("descripcion",
messageSource.getMessage("pdf.ejemplares-deposito-legal", new Object[] { 4 },
locale));
lineas.add(linea);
}
String serviciosExtras = "";
if (servicios != null) {
for (Map<String, Object> servicio : servicios) {
if ("deposito-legal".equals(servicio.get("id")) ||
"service-isbn".equals(servicio.get("id"))) {
serviciosExtras += messageSource.getMessage(
"presupuesto.extras-" + servicio.get("id"), null, locale)
+ ", ";
} else {
serviciosExtras += messageSource.getMessage(
"presupuesto.extras-" + servicio.get("id"), null, locale)
.toLowerCase() + ", ";
}
}
if (!serviciosExtras.isEmpty()) {
serviciosExtras = serviciosExtras.substring(0, serviciosExtras.length() - 2);
;
}
if (servicios.stream().anyMatch(service -> "marcapaginas".equals(service.get("id")))) {
ObjectMapper mapperServicio = new ObjectMapper();
Object raw = presupuesto.getDatosMarcapaginasJson();
Map<String, Object> datosMarcapaginas;
String descripcion = "";
try {
if (raw instanceof String s) {
datosMarcapaginas = mapperServicio.readValue(s,
new TypeReference<Map<String, Object>>() {
});
} else if (raw instanceof Map<?, ?> m) {
datosMarcapaginas = mapperServicio.convertValue(m,
new TypeReference<Map<String, Object>>() {
});
} else {
throw new IllegalArgumentException(
"Tipo no soportado para datosMarcapaginas: "
+ raw);
}
} catch (JsonProcessingException e) {
throw new RuntimeException("Error parsing datosMarcapaginasJson", e);
}
descripcion += "<br/><ul><li>";
descripcion += Marcapaginas.Tamanios
.valueOf(datosMarcapaginas.get("tamanio").toString()).getLabel()
+ ", ";
descripcion += Marcapaginas.Caras_Impresion
.valueOf(datosMarcapaginas.get("carasImpresion").toString())
.getMessageKey() + ", ";
descripcion += messageSource
.getMessage(Marcapaginas.Papeles
.valueOf(datosMarcapaginas.get("papel")
.toString())
.getMessageKey(), null, locale)
+ " - " +
datosMarcapaginas.get("gramaje").toString() + " gr, ";
descripcion += messageSource.getMessage(
Marcapaginas.Acabado.valueOf(
datosMarcapaginas.get("acabado").toString())
.getMessageKey(),
null, locale);
descripcion += "</li></ul>";
resumen.put("datosMarcapaginas", descripcion);
}
if (servicios.stream().anyMatch(service -> "maquetacion".equals(service.get("id")))) {
ObjectMapper mapperServicio = new ObjectMapper();
Object raw = presupuesto.getDatosMaquetacionJson();
Map<String, Object> datosMaquetacion;
String descripcion = "";
try {
if (raw instanceof String s) {
datosMaquetacion = mapperServicio.readValue(s,
new TypeReference<Map<String, Object>>() {
});
} else if (raw instanceof Map<?, ?> m) {
datosMaquetacion = mapperServicio.convertValue(m,
new TypeReference<Map<String, Object>>() {
});
} else {
throw new IllegalArgumentException(
"Tipo no soportado para datosMaquetacion: "
+ raw);
}
} catch (JsonProcessingException e) {
throw new RuntimeException("Error parsing datosMaquetacionJson", e);
}
descripcion += "<br/><ul><li>";
descripcion += (datosMaquetacion.get("num_caracteres") + " "
+ messageSource.getMessage("presupuesto.maquetacion.caracteres",
null, locale))
+ ", ";
descripcion += MaquetacionMatrices.Formato
.valueOf(datosMaquetacion.get("formato_maquetacion").toString())
.getLabel() + ", ";
descripcion += messageSource.getMessage(MaquetacionMatrices.FontSize
.valueOf(datosMaquetacion.get("cuerpo_texto").toString())
.getMessageKey(), null, locale)
+ ", ";
descripcion += messageSource.getMessage("presupuesto.maquetacion.num-columnas",
null, locale) + ": "
+ datosMaquetacion.get("num_columnas").toString() + ", ";
descripcion += messageSource.getMessage("presupuesto.maquetacion.num-tablas",
null, locale) + ": "
+ datosMaquetacion.get("num_tablas").toString() + ", ";
descripcion += messageSource.getMessage("presupuesto.maquetacion.num-fotos",
null, locale) + ": "
+ datosMaquetacion.get("num_fotos").toString();
if ((boolean) datosMaquetacion.get("correccion_ortotipografica")) {
descripcion += ", " + messageSource
.getMessage("presupuesto.maquetacion.correccion-ortotipografica",
null, locale);
}
if ((boolean) datosMaquetacion.get("texto_mecanografiado")) {
descripcion += ", " + messageSource.getMessage(
"presupuesto.maquetacion.texto-mecanografiado",
null, locale);
}
if ((boolean) datosMaquetacion.get("disenio_portada")) {
descripcion += ", "
+ messageSource.getMessage(
"presupuesto.maquetacion.diseno-portada",
null, locale);
}
if ((boolean) datosMaquetacion.get("epub")) {
descripcion += ", " + messageSource.getMessage(
"presupuesto.maquetacion.epub", null, locale);
}
descripcion += "</li></ul>";
resumen.put("datosMaquetacion", descripcion);
}
}
NumberFormat currencyFormat = NumberFormat.getCurrencyInstance(locale);
String formattedString = currencyFormat.format(total.setScale(2, RoundingMode.HALF_UP).doubleValue());
resumen.put("total", formattedString);
resumen.put("lineas", lineas);
resumen.put("servicios", serviciosExtras);
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

@ -13,7 +13,7 @@ import com.imprimelibros.erp.users.User;
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AbstractAuditedSoftDeleteEntity {
public abstract class AbstractAuditedEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@ -46,6 +46,10 @@ public abstract class AbstractAuditedSoftDeleteEntity {
@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; }
@ -67,4 +71,7 @@ public abstract class AbstractAuditedSoftDeleteEntity {
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,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

@ -2,32 +2,36 @@ package com.imprimelibros.erp.common.web;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Arrays;
import java.util.List;
public final class IpUtils {
private IpUtils() {}
private static final List<String> HEADERS = Arrays.asList(
"X-Forwarded-For",
"X-Real-IP",
"CF-Connecting-IP",
"True-Client-IP",
"X-Client-IP",
"X-Forwarded",
"Forwarded-For",
"Forwarded"
);
public class IpUtils {
public static String getClientIp(HttpServletRequest request) {
for (String h : HEADERS) {
String v = request.getHeader(h);
if (v != null && !v.isBlank() && !"unknown".equalsIgnoreCase(v)) {
// X-Forwarded-For puede traer lista: "client, proxy1, proxy2"
String first = v.split(",")[0].trim();
if (!first.isBlank()) return first;
String[] headers = {
"X-Forwarded-For",
"Proxy-Client-IP",
"WL-Proxy-Client-IP",
"HTTP_X_FORWARDED_FOR",
"HTTP_X_FORWARDED",
"HTTP_X_CLUSTER_CLIENT_IP",
"HTTP_CLIENT_IP",
"HTTP_FORWARDED_FOR",
"HTTP_FORWARDED",
"HTTP_VIA",
"REMOTE_ADDR"
};
for (String header : headers) {
String ip = request.getHeader(header);
if (ip != null && ip.length() != 0 && !"unknown".equalsIgnoreCase(ip)) {
// Si hay varios (X-Forwarded-For), toma el primero
return ip.split(",")[0];
}
}
return request.getRemoteAddr();
// Fallback
String ip = request.getRemoteAddr();
if ("0:0:0:0:0:0:0:1".equals(ip) || "::1".equals(ip)) {
return "127.0.0.1";
}
return ip;
}
}

View File

@ -1,5 +1,6 @@
package com.imprimelibros.erp.config;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
@ -10,13 +11,16 @@ import jakarta.validation.ValidatorFactory;
@Configuration
public class BeanValidationConfig {
// Asegura que usamos la factory de Spring (con SpringConstraintValidatorFactory)
// Usa el MessageSource (messages*.properties) para resolver {códigos}
@Bean
public LocalValidatorFactoryBean validator() {
return new LocalValidatorFactoryBean();
public LocalValidatorFactoryBean validator(MessageSource messageSource) {
LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean();
bean.setValidationMessageSource(messageSource); // <-- CLAVE
return bean;
}
// Inserta esa factory en Hibernate/JPA
// Inserta esa factory en Hibernate/JPA (opcional pero correcto)
@Bean
public HibernatePropertiesCustomizer hibernateValidationCustomizer(ValidatorFactory vf) {
return props -> props.put("jakarta.persistence.validation.factory", vf);

View File

@ -3,41 +3,45 @@ package com.imprimelibros.erp.config;
import java.util.Optional;
import jakarta.persistence.EntityManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import com.imprimelibros.erp.users.User;
import com.imprimelibros.erp.users.UserDao;
import com.imprimelibros.erp.users.UserDetailsImpl; // tu implementación
@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorAware")
public class JpaAuditConfig {
@Bean
public AuditorAware<User> auditorAware(UserDao userDao) {
public AuditorAware<User> auditorAware(EntityManager em) {
return () -> {
var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated())
return Optional.empty();
var ctx = SecurityContextHolder.getContext();
if (ctx == null) return Optional.empty();
var auth = ctx.getAuthentication();
if (auth == null || !auth.isAuthenticated()) return Optional.empty();
Object principal = auth.getPrincipal();
Long userId = null;
if (principal instanceof User u)
return Optional.of(u);
if (principal instanceof UserDetails ud) {
return userDao.findByUserNameIgnoreCase(ud.getUsername());
// Tu UserDetailsImpl ya tiene el id
if (principal instanceof UserDetailsImpl udi) {
userId = udi.getId();
}
if (principal instanceof String username && !"anonymousUser".equals(username)) {
return userDao.findByUserNameIgnoreCase(username);
// Si a veces pones el propio User como principal:
else if (principal instanceof User u && u.getId() != null) {
userId = u.getId();
}
// ⚠️ NO hagas consultas aquí (nada de userDao.findBy...).
if (userId == null) return Optional.empty();
return Optional.empty();
// Devuelve una referencia gestionada (NO hace SELECT ni fuerza flush)
return Optional.of(em.getReference(User.class, userId));
};
}
}

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

View File

@ -1,181 +1,126 @@
package com.imprimelibros.erp.configuracion.margenes_presupuestos;
import jakarta.persistence.*;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoEncuadernacion;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoCubierta;
import com.imprimelibros.erp.shared.validation.NoRangeOverlap;
@Entity
@Table(name = "margenes_presupuesto")
@SQLDelete(sql = "UPDATE margenes_presupuesto SET deleted = true WHERE id=?")
@SQLRestriction("deleted = false")
@NoRangeOverlap(
min = "tiradaMin",
max = "tiradaMax",
id = "id",
partitionBy = {"tipoEncuadernacion","tipoCubierta"},
partitionBy = {},
deletedFlag = "deleted", // <- si usas soft delete
deletedActiveValue = false, // activo cuando deleted == false
message = "{validation.range.overlaps}",
invalidRangeMessage = "{validation.range.invalid}"
)
@Entity
@Table(name = "margenes_presupuesto")
@SQLDelete(sql = "UPDATE margenes_presupuesto SET deleted = TRUE, deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted = false")
public class MargenPresupuesto {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name="tipo_encuadernacion", nullable = false, length = 50)
@Column(name="importe_min", nullable=false, precision=12, scale=2)
@NotNull(message="{validation.required}")
@Enumerated(EnumType.STRING)
private TipoEncuadernacion tipoEncuadernacion;
private BigDecimal importeMin;
@Column(name="tipo_cubierta", nullable = false, length = 50)
@Column(name="importe_max", nullable=false, precision=12, scale=2)
@NotNull(message="{validation.required}")
@Enumerated(EnumType.STRING)
private TipoCubierta tipoCubierta;
private BigDecimal importeMax;
@Column(name="tirada_min", nullable = false)
@Column(name="margen_min", nullable=false, precision=6, scale=2)
@NotNull(message="{validation.required}")
@Min(value=1, message="{validation.min}")
private Integer tiradaMin;
private BigDecimal margenMin;
@Column(name="tirada_max", nullable = false)
@Column(name="margen_max", nullable=false, precision=6, scale=2)
@NotNull(message="{validation.required}")
@Min(value=1, message="{validation.min}")
private Integer tiradaMax;
private BigDecimal margenMax;
@Column(name="margen_max", nullable = false)
@NotNull(message="{validation.required}")
@Min(value = 0, message="{validation.min}")
@Max(value = 200, message="{validation.max}")
private Integer margenMax;
@Column(nullable=false)
private boolean deleted = false;
@Column(name = "margen_min", nullable = false)
@NotNull(message="{validation.required}")
@Min(value = 0, message="{validation.min}")
@Max(value = 200, message="{validation.max}")
private Integer margenMin;
@Column(name="created_at", nullable = false, updatable = false)
@Column(name="created_at", nullable=false)
private LocalDateTime createdAt;
@Column(name="updated_at")
@Column(name="updated_at", nullable=false)
private LocalDateTime updatedAt;
@Column(nullable = false)
private boolean deleted = false;
@Column(name="deleted_at")
private LocalDateTime deletedAt;
@PrePersist
void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = createdAt;
}
@PreUpdate
void onUpdate() {
updatedAt = LocalDateTime.now();
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public TipoEncuadernacion getTipoEncuadernacion() {
return tipoEncuadernacion;
public BigDecimal getImporteMin() {
return importeMin;
}
public void setTipoEncuadernacion(TipoEncuadernacion tipoEncuadernacion) {
this.tipoEncuadernacion = tipoEncuadernacion;
public void setImporteMin(BigDecimal importeMin) {
this.importeMin = importeMin;
}
public TipoCubierta getTipoCubierta() {
return tipoCubierta;
public BigDecimal getImporteMax() {
return importeMax;
}
public void setTipoCubierta(TipoCubierta tipoCubierta) {
this.tipoCubierta = tipoCubierta;
public void setImporteMax(BigDecimal importeMax) {
this.importeMax = importeMax;
}
public Integer getTiradaMin() {
return tiradaMin;
}
public void setTiradaMin(Integer tiradaMin) {
this.tiradaMin = tiradaMin;
}
public Integer getTiradaMax() {
return tiradaMax;
}
public void setTiradaMax(Integer tiradaMax) {
this.tiradaMax = tiradaMax;
}
public Integer getMargenMax() {
return margenMax;
}
public void setMargenMax(Integer margenMax) {
this.margenMax = margenMax;
}
public Integer getMargenMin() {
public BigDecimal getMargenMin() {
return margenMin;
}
public void setMargenMin(Integer margenMin) {
public void setMargenMin(BigDecimal margenMin) {
this.margenMin = margenMin;
}
public LocalDateTime getCreatedAt() {
return createdAt;
public BigDecimal getMargenMax() {
return margenMax;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
public void setMargenMax(BigDecimal margenMax) {
this.margenMax = margenMax;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public boolean isDeleted() {
return deleted;
}
public void setDeleted(boolean deleted) {
this.deleted = deleted;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public LocalDateTime getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(LocalDateTime deletedAt) {
this.deletedAt = deletedAt;
}
@PrePersist
void onCreate() {
this.createdAt = LocalDateTime.now();
this.updatedAt = this.createdAt;
}
@PreUpdate
void onUpdate() {
this.updatedAt = LocalDateTime.now();
}
}

View File

@ -1,10 +1,9 @@
package com.imprimelibros.erp.configuracion.margenes_presupuestos;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.context.MessageSource;
import org.springframework.data.jpa.domain.Specification;
@ -16,7 +15,6 @@ import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
@ -24,13 +22,13 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
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.presupuesto.Presupuesto.TipoCubierta;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoEncuadernacion;
import jakarta.persistence.criteria.Predicate;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@ -84,25 +82,46 @@ public class MargenPresupuestoController {
List<String> searchable = List.of(
"id",
"tiradaMin", "tiradaMax",
"importeMin", "importeMax",
"margenMin", "margenMax");
List<String> orderable = List.of(
"id",
"tipoEncuadernacion",
"tipoCubierta",
"tiradaMin",
"tiradaMax",
"importeMin",
"importeMax",
"margenMin",
"margenMax");
Specification<MargenPresupuesto> base = (root, query, cb) -> cb.conjunction();
Specification<MargenPresupuesto> filtros = (root, query, cb) -> {
List<Predicate> ps = new ArrayList<>();
Utils.parseNumericFilter(dt, "importe_min", locale)
.ifPresent(f -> ps.add(f.apply(root.get("importeMin"), cb)));
Utils.parseNumericFilter(dt, "importe_max", locale)
.ifPresent(f -> ps.add(f.apply(root.get("importeMax"), cb)));
Utils.parseNumericFilter(dt, "margen_min", locale)
.ifPresent(f -> ps.add(f.apply(root.get("margenMin"), cb)));
Utils.parseNumericFilter(dt, "margen_max", locale)
.ifPresent(f -> ps.add(f.apply(root.get("margenMax"), cb)));
return ps.isEmpty() ? cb.conjunction() : cb.and(ps.toArray(new Predicate[0]));
};
long total = repo.count();
return DataTable
.of(repo, MargenPresupuesto.class, dt, searchable) // 'searchable' en DataTable.java
// edita columnas "reales":
.orderable(orderable)
.edit("importeMin", (margen) -> Utils.formatCurrency(margen.getImporteMin(), locale))
.edit("importeMax", (margen) -> Utils.formatCurrency(margen.getImporteMax(), locale))
.edit("margenMin", (margen) -> Utils.formatNumber(margen.getMargenMin(), locale))
.edit("margenMax", (margen) -> Utils.formatNumber(margen.getMargenMax(), locale))
.add("actions", (margen) -> {
return "<div class=\"hstack gap-3 flex-wrap\">\n" +
" <a href=\"javascript:void(0);\" data-id=\"" + margen.getId()
@ -111,58 +130,8 @@ public class MargenPresupuestoController {
+ "\" class=\"link-danger btn-delete-margen fs-15\"><i class=\"ri-delete-bin-5-line\"></i></a>\n"
+ " </div>";
})
.edit("tipoEncuadernacion", (margen) -> {
return messageSource.getMessage("presupuesto." + margen.getTipoEncuadernacion().name(), null,
locale);
})
.edit("tipoCubierta", (margen) -> {
return messageSource.getMessage("presupuesto." + margen.getTipoCubierta().name(), null, locale);
})
.where(base)
// Filtros custom:
.filter((builder, req) -> {
String fEncuadernacion = Optional.ofNullable(req.raw.get("f_encuadernacion")).orElse("").trim();
if (!fEncuadernacion.isEmpty()) {
boolean added = false;
// 1) Si llega el nombre del enum (p.ej. "fresado", "cosido", ...)
try {
var encEnum = TipoEncuadernacion.valueOf(fEncuadernacion);
builder.add((root, q, cb) -> cb.equal(root.get("tipoEncuadernacion"), encEnum));
added = true;
} catch (IllegalArgumentException ignored) {
}
// 2) Si llega la clave i18n (p.ej. "presupuesto.fresado", ...)
if (!added) {
Arrays.stream(TipoEncuadernacion.values())
.filter(e -> e.getMessageKey().equals(fEncuadernacion))
.findFirst()
.ifPresent(encEnum -> builder
.add((root, q, cb) -> cb.equal(root.get("tipoEncuadernacion"), encEnum)));
}
}
// --- Cubierta ---
String fCubierta = Optional.ofNullable(req.raw.get("f_cubierta")).orElse("").trim();
if (!fCubierta.isEmpty()) {
boolean added = false;
// 1) Si llega el nombre del enum (p.ej. "tapaBlanda", "tapaDura",
// "tapaDuraLomoRedondo")
try {
var cubEnum = TipoCubierta.valueOf(fCubierta);
builder.add((root, q, cb) -> cb.equal(root.get("tipoCubierta"), cubEnum));
added = true;
} catch (IllegalArgumentException ignored) {
}
// 2) Si llega la clave i18n (p.ej. "presupuesto.tapa-blanda", ...)
if (!added) {
Arrays.stream(TipoCubierta.values())
.filter(e -> e.getMessageKey().equals(fCubierta))
.findFirst()
.ifPresent(cubEnum -> builder
.add((root, q, cb) -> cb.equal(root.get("tipoCubierta"), cubEnum)));
}
}
})
.toJson(total);
}
@ -177,11 +146,11 @@ public class MargenPresupuestoController {
if (id != null) {
var opt = repo.findById(id);
if (opt.isEmpty()) {
binding.reject("usuarios.error.noEncontrado",
messageSource.getMessage("usuarios.error.noEncontrado", null, locale));
binding.reject("margenes-presupuesto.error.noEncontrado",
messageSource.getMessage("margenes-presupuesto.error.noEncontrado", null, locale));
response.setStatus(404);
model.addAttribute("action", "/users/" + id);
return "imprimelibros/users/user-form :: userForm";
model.addAttribute("action", "/configuracion/margenes-presupuesto/" + id);
return "imprimelibros/configuracion/margenes-presupuesto/margenes-presupuesto-form :: margenesPresupuestoForm";
}
model.addAttribute("margenPresupuesto", opt.get());
@ -203,16 +172,14 @@ public class MargenPresupuestoController {
Locale locale) {
if (binding.hasErrors()) {
response.setStatus(422);
response.setStatus(422);
model.addAttribute("action", "/configuracion/margenes-presupuesto");
return "imprimelibros/configuracion/margenes-presupuesto/margenes-presupuesto-form :: margenesPresupuestoForm";
}
MargenPresupuesto data = new MargenPresupuesto();
data.setTipoEncuadernacion(margenPresupuesto.getTipoEncuadernacion());
data.setTipoCubierta(margenPresupuesto.getTipoCubierta());
data.setTiradaMin(margenPresupuesto.getTiradaMin());
data.setTiradaMax(margenPresupuesto.getTiradaMax());
data.setImporteMin(margenPresupuesto.getImporteMin());
data.setImporteMax(margenPresupuesto.getImporteMax());
data.setMargenMax(margenPresupuesto.getMargenMax());
data.setMargenMin(margenPresupuesto.getMargenMin());
@ -244,7 +211,6 @@ public class MargenPresupuestoController {
return null;
}
@PutMapping("/{id}")
public String edit(
@PathVariable Long id,
@ -269,10 +235,8 @@ public class MargenPresupuestoController {
var entity = uOpt.get();
// 3) Copiar solamente campos editables
entity.setTipoEncuadernacion(form.getTipoEncuadernacion());
entity.setTipoCubierta(form.getTipoCubierta());
entity.setTiradaMin(form.getTiradaMin());
entity.setTiradaMax(form.getTiradaMax());
entity.setImporteMin(form.getImporteMin());
entity.setImporteMax(form.getImporteMax());
entity.setMargenMax(form.getMargenMax());
entity.setMargenMin(form.getMargenMin());
@ -318,23 +282,23 @@ public class MargenPresupuestoController {
@DeleteMapping("/{id}")
@Transactional
public ResponseEntity<?> delete(@PathVariable Long id, Authentication auth, Locale locale) {
return repo.findById(id).map(u -> {
try {
u.setDeleted(true);
u.setDeletedAt(LocalDateTime.now());
repo.save(u); // ← NO delete(); guardamos el soft delete con deleted_by relleno
return ResponseEntity.ok(Map.of("message",
messageSource.getMessage("margenes-presupuesto.exito.eliminado", null, locale)));
} catch (Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message",
messageSource.getMessage("margenes-presupuesto.error.delete-internal-error", null, locale)));
messageSource.getMessage("margenes-presupuesto.error.delete-internal-error", null,
locale)));
}
}).orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("message", messageSource.getMessage("margenes-presupuesto.error.not-found", null, locale))));
.body(Map.of("message",
messageSource.getMessage("margenes-presupuesto.error.not-found", null, locale))));
}
}

View File

@ -1,40 +1,36 @@
package com.imprimelibros.erp.configuracion.margenes_presupuestos;
import java.math.BigDecimal;
import java.util.Optional;
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 com.imprimelibros.erp.presupuesto.Presupuesto.TipoEncuadernacion;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoCubierta;
public interface MargenPresupuestoDao
extends JpaRepository<MargenPresupuesto, Long>, JpaSpecificationExecutor<MargenPresupuesto> {
@Query("""
SELECT COUNT(m) FROM MargenPresupuesto m
WHERE m.deleted = false
AND m.tipoEncuadernacion = :enc
AND m.tipoCubierta = :cub
AND (:id IS NULL OR m.id <> :id)
AND NOT (m.tiradaMax < :min OR m.tiradaMin > :max)
""")
SELECT COUNT(m) FROM MargenPresupuesto m
WHERE m.deleted = false
AND ( ( :min BETWEEN m.importeMin AND m.importeMax )
OR ( :max BETWEEN m.importeMin AND m.importeMax )
OR ( m.importeMin BETWEEN :min AND :max )
OR ( m.importeMax BETWEEN :min AND :max ) )
AND ( :excludeId IS NULL OR m.id <> :excludeId )
""")
long countOverlaps(
@Param("enc") TipoEncuadernacion enc,
@Param("cub") TipoCubierta cub,
@Param("min") Integer min,
@Param("max") Integer max,
@Param("id") Long id);
@Param("min") BigDecimal min,
@Param("max") BigDecimal max,
@Param("excludeId") Long excludeId);
@Query("""
SELECT m FROM MargenPresupuesto m
WHERE m.deleted = false
AND m.tipoEncuadernacion = :enc
AND m.tipoCubierta = :cub
AND :tirada BETWEEN m.tiradaMin AND m.tiradaMax
""")
MargenPresupuesto findByTipoAndTirada(
@Param("enc") TipoEncuadernacion enc,
@Param("cub") TipoCubierta cub,
@Param("tirada") Integer tirada);
SELECT m FROM MargenPresupuesto m
WHERE m.deleted = false
AND :importe BETWEEN m.importeMin AND m.importeMax
""")
Optional<MargenPresupuesto> findByImporte(@Param("importe") BigDecimal importe);
}

View File

@ -1,14 +1,12 @@
package com.imprimelibros.erp.configuracion.margenes_presupuestos;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoEncuadernacion;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoCubierta;
@Service
@Transactional
public class MargenPresupuestoService {
@ -27,17 +25,19 @@ public class MargenPresupuestoService {
return dao.findById(id);
}
public MargenPresupuesto save(MargenPresupuesto entity) {
return dao.save(entity);
public Optional<MargenPresupuesto> findByImporte(BigDecimal importe){
return dao.findByImporte(importe);
}
public MargenPresupuesto save(MargenPresupuesto e) {
return dao.save(e);
}
public void delete(Long id) {
dao.deleteById(id);
}
public boolean hasOverlap(TipoEncuadernacion enc, TipoCubierta cub, Integer min, Integer max, Long excludeId) {
long count = dao.countOverlaps(enc, cub, min, max, excludeId);
return count > 0;
public boolean hasOverlap(BigDecimal min, BigDecimal max, Long excludeId) {
return dao.countOverlaps(min, max, excludeId) > 0;
}
}

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

@ -7,12 +7,14 @@ import org.springframework.data.domain.*;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import jakarta.persistence.criteria.*;
import java.util.*;
import java.util.function.BiConsumer;
import java.util.function.Function;
public class DataTable<T> {
/* ===== Tipos funcionales ===== */
public interface FilterHook<T> extends BiConsumer<SpecBuilder<T>, DataTablesRequest> {
}
@ -20,25 +22,55 @@ public class DataTable<T> {
void add(Specification<T> extra);
}
/**
* Filtro custom por campo virtual: te doy (root, query, cb, value) y me
* devuelves un Predicate
*/
@FunctionalInterface
public interface FieldFilter<T> {
Predicate apply(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb, String value);
}
/**
* Orden custom por campo virtual: te doy (root, query, cb) y me devuelves la
* Expression<?> para orderBy
*/
@FunctionalInterface
public interface FieldOrder<T> {
Expression<?> apply(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);
}
/* ===== Estado ===== */
private final JpaSpecificationExecutor<T> repo;
private final Class<T> entityClass;
private final DataTablesRequest dt;
private final List<String> searchable;
private final List<Function<T, Map<String, Object>>> adders = new ArrayList<>();
private final List<Function<Map<String, Object>, Map<String, Object>>> editors = new ArrayList<>();
private final List<FilterHook<T>> filters = new ArrayList<>();
private Specification<T> baseSpec = (root, q, cb) -> cb.conjunction();
private final ObjectMapper om = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
/** whitelist de campos ordenables “simples” (por nombre) */
private List<String> orderable = null;
/** mapas de comportamiento custom por campo */
private final Map<String, FieldOrder<T>> orderCustom = new HashMap<>();
private final Map<String, FieldFilter<T>> filterCustom = new HashMap<>();
private boolean onlyAdded = false;
/* ===== Ctor / factory ===== */
private DataTable(JpaSpecificationExecutor<T> repo, Class<T> entityClass, DataTablesRequest dt,
List<String> searchable) {
this.repo = repo;
this.entityClass = entityClass;
this.dt = dt;
this.searchable = searchable;
this.searchable = searchable != null ? searchable : List.of();
}
public static <T> DataTable<T> of(JpaSpecificationExecutor<T> repo, Class<T> entityClass, DataTablesRequest dt,
@ -46,13 +78,19 @@ public class DataTable<T> {
return new DataTable<>(repo, entityClass, dt, searchable);
}
/** Equivalente a tu $q->where(...): establece condición base */
/* ===== Fluent API ===== */
public DataTable<T> onlyAddedColumns() {
this.onlyAdded = true;
return this;
}
/** WHERE base reusable */
public DataTable<T> where(Specification<T> spec) {
this.baseSpec = this.baseSpec.and(spec);
return this;
}
/** add("campo", fn(entity)->valor|Map) */
/** Campos renderizados */
public DataTable<T> add(String field, Function<T, Object> fn) {
adders.add(entity -> {
Map<String, Object> m = new HashMap<>();
@ -62,19 +100,19 @@ public class DataTable<T> {
return this;
}
/**
* add(fn(entity)->Map<String,Object>) para devolver objetos anidados como tu
* "logo"
*/
public DataTable<T> addIf(boolean condition, String field, Function<T, Object> fn) {
if (condition)
return add(field, fn);
return this;
}
public DataTable<T> add(Function<T, Map<String, Object>> fn) {
adders.add(fn);
return this;
}
/**
* edit("campo", fn(entity)->valor) sobreescribe un campo existente o lo crea si
* no existe
*/
/** Edita/inyecta valor usando la entidad original (guardada como __entity) */
@SuppressWarnings("unchecked")
public DataTable<T> edit(String field, Function<T, Object> fn) {
editors.add(row -> {
row.put(field, fn.apply((T) row.get("__entity")));
@ -83,73 +121,132 @@ public class DataTable<T> {
return this;
}
/** Whitelist de campos simples ordenables (por nombre) */
public DataTable<T> orderable(List<String> fields) {
this.orderable = fields;
return this;
}
private List<String> getOrderable() {
return (orderable == null || orderable.isEmpty()) ? this.searchable : this.orderable;
/** Orden custom por campo virtual (expresiones) */
public DataTable<T> orderable(String field, FieldOrder<T> orderFn) {
this.orderCustom.put(field, orderFn);
return this;
}
/** filter((builder, req) -> builder.add(miExtraSpec(req))) */
/** Filtro custom por campo virtual (LIKE, rangos, etc.) */
public DataTable<T> filter(String field, FieldFilter<T> filterFn) {
this.filterCustom.put(field, filterFn);
return this;
}
/** Hook para añadir Specifications extra programáticamente */
public DataTable<T> filter(FilterHook<T> hook) {
filters.add(hook);
return this;
}
/* ===== Helpers ===== */
private List<String> getOrderable() {
return (orderable == null || orderable.isEmpty()) ? this.searchable : this.orderable;
}
/* ===== Core ===== */
public DataTablesResponse<Map<String, Object>> toJson(long totalCount) {
// Construye spec con búsqueda global + base + filtros custom
// 1) Spec base + búsqueda (global/columnas) + hooks programáticos
Specification<T> spec = baseSpec.and(DataTablesSpecification.build(dt, searchable));
final Specification<T>[] holder = new Specification[] { spec };
filters.forEach(h -> h.accept(extra -> holder[0] = holder[0].and(extra), dt));
spec = holder[0];
// Sort
// Sort
// Hooks externos
filters.forEach(h -> h.accept(extra -> holder[0] = holder[0].and(extra), dt));
// 2) Filtros por columna “custom” (virtuales)
for (var col : dt.columns) {
if (col == null || !col.searchable)
continue;
if (col.name == null || col.name.isBlank())
continue;
if (!filterCustom.containsKey(col.name))
continue;
if (col.search == null || col.search.value == null || col.search.value.isBlank())
continue;
var value = col.search.value;
var filterFn = filterCustom.get(col.name);
holder[0] = holder[0].and((root, query, cb) -> {
Predicate p = filterFn.apply(root, query, cb, value);
return p != null ? p : cb.conjunction();
});
}
// 3) Orden:
// - Para campos “simples” (no custom): con Sort (Spring)
// - Para campos “custom” (virtuales/expresiones): query.orderBy(...) dentro de
// una spec
Sort sort = Sort.unsorted();
List<Sort.Order> simpleOrders = new ArrayList<>();
boolean customApplied = false;
if (!dt.order.isEmpty() && !dt.columns.isEmpty()) {
List<Sort.Order> orders = new ArrayList<>();
for (var o : dt.order) {
var col = dt.columns.get(o.column);
String field = col != null ? col.name : null;
if (col == null)
continue;
String field = col.name;
if (field == null || field.isBlank())
continue;
if (!col.orderable)
continue;
if (!getOrderable().contains(field))
continue; // << usa tu whitelist
continue;
orders.add(new Sort.Order(
"desc".equalsIgnoreCase(o.dir) ? Sort.Direction.DESC : Sort.Direction.ASC,
field));
}
if (!orders.isEmpty()) {
sort = Sort.by(orders);
} else {
for (var c : dt.columns) {
if (c != null && c.orderable && c.name != null && !c.name.isBlank()
&& getOrderable().contains(c.name)) {
sort = Sort.by(c.name);
break;
}
if (orderCustom.containsKey(field)) {
final boolean asc = !"desc".equalsIgnoreCase(o.dir);
final FieldOrder<T> orderFn = orderCustom.get(field);
// aplica el ORDER BY custom dentro de la Specification (con Criteria)
holder[0] = holder[0].and((root, query, cb) -> {
Expression<?> expr = orderFn.apply(root, query, cb);
if (expr != null) {
query.orderBy(asc ? cb.asc(expr) : cb.desc(expr));
}
return cb.conjunction();
});
customApplied = true;
} else {
// orden simple por nombre de propiedad real
simpleOrders.add(new Sort.Order(
"desc".equalsIgnoreCase(o.dir) ? Sort.Direction.DESC : Sort.Direction.ASC,
field));
}
}
}
// Page
if (!simpleOrders.isEmpty()) {
sort = Sort.by(simpleOrders);
}
// 4) Paginación (Sort para simples; custom order ya va dentro de la spec)
int page = dt.length > 0 ? dt.start / dt.length : 0;
Pageable pageable = dt.length > 0 ? PageRequest.of(page, dt.length, sort) : Pageable.unpaged();
var p = repo.findAll(holder[0], pageable);
long filtered = p.getTotalElements();
// Mapear entidad -> Map base (via Jackson) + add/edit
// 5) Mapeo a Map + add/edit
List<Map<String, Object>> data = new ArrayList<>();
for (T e : p.getContent()) {
Map<String, Object> row = om.convertValue(e, Map.class);
row.put("__entity", e); // para editores que necesiten la entidad
Map<String, Object> row;
if (onlyAdded) {
row = new HashMap<>();
} else {
try {
row = om.convertValue(e, Map.class);
} catch (IllegalArgumentException ex) {
row = new HashMap<>();
}
}
row.put("__entity", e);
for (var ad : adders)
row.putAll(ad.apply(e));
for (var ed : editors)
@ -157,6 +254,12 @@ public class DataTable<T> {
row.remove("__entity");
data.add(row);
}
return new DataTablesResponse<>(dt.draw, totalCount, filtered, data);
}
}
private Predicate nullSafePredicate(CriteriaBuilder cb) {
// Devuelve conjunción para no interferir con los demás predicados
return cb.conjunction();
}
}

View File

@ -9,15 +9,34 @@ public class DataTablesRequest {
public Search search = new Search();
public List<Order> order = new ArrayList<>();
public List<Column> columns = new ArrayList<>();
public Map<String,String> raw = new HashMap<>(); // <- params extra
public Map<String, String> raw = new HashMap<>(); // <- params extra
public static class Search {
public String value = "";
public boolean regex;
}
public static class Order {
public int column;
public String dir;
}
public static class Search { public String value=""; public boolean regex; }
public static class Order { public int column; public String dir; }
public static class Column {
public String data;
public String name;
public boolean searchable=true;
public boolean orderable=true;
public Search search=new Search();
public boolean searchable = true;
public boolean orderable = true;
public Search search = new Search();
}
public String getColumnSearch(String columnName) {
if (columnName == null || columns == null)
return null;
for (Column col : columns) {
if (col != null && col.name != null && col.name.equalsIgnoreCase(columnName)) {
return col.search != null ? col.search.value : null;
}
}
return null;
}
}

View File

@ -23,9 +23,15 @@ public class DataTablesSpecification {
DataTablesRequest.Column col = dt.columns.get(i);
if (col.searchable && col.search != null && col.search.value != null && !col.search.value.isEmpty()) {
try {
ands.add(like(cb, root.get(col.name), col.search.value));
Path<?> path = root;
String[] parts = col.name.split("\\.");
for (String part : parts) {
path = path.get(part);
}
ands.add(like(cb, path, col.search.value));
} catch (IllegalArgumentException ex) {
// columna no mapeada o relación: la ignoramos
//System.out.println("[DT] columna no mapeada o relación: " + col.name);
}
}
}

View File

@ -0,0 +1,237 @@
package com.imprimelibros.erp.direcciones;
import jakarta.persistence.*;
import java.io.Serializable;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import com.imprimelibros.erp.common.jpa.AbstractAuditedEntity;
import com.imprimelibros.erp.paises.Paises;
import com.imprimelibros.erp.users.User;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.NotBlank;
@Entity
@Table(name = "direcciones", indexes = {
@Index(name = "idx_direcciones_user", columnList = "user_id"),
@Index(name = "idx_direcciones_pais_code3", columnList = "pais_code3")
})
@SQLDelete(sql = "UPDATE direcciones SET deleted = 1, deleted_at = NOW(3) WHERE id = ?")
@SQLRestriction("deleted = 0")
public class Direccion extends AbstractAuditedEntity implements Serializable {
public enum TipoIdentificacionFiscal {
DNI("direcciones.dni"),
NIE("direcciones.nie"),
CIF("direcciones.cif"),
Pasaporte("direcciones.pasaporte"),
VAT_ID("direcciones.vat_id");
private String key;
TipoIdentificacionFiscal(String key) {
this.key = key;
}
public String getKey() {
return key;
}
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// --- FK a users(id)
@NotNull(message = "{direcciones.form.error.required}")
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@NotBlank(message = "{direcciones.form.error.required}")
@Column(name = "alias", length = 100, nullable = false)
private String alias;
@NotBlank(message = "{direcciones.form.error.required}")
@Column(name = "att", length = 150, nullable = false)
private String att;
@NotBlank(message = "{direcciones.form.error.required}")
@Column(name = "direccion", length = 255, nullable = false)
private String direccion;
@NotNull(message = "{direcciones.form.error.required}")
@Column(name = "cp", length = 20, nullable = false)
private Integer cp;
@NotBlank(message = "{direcciones.form.error.required}")
@Column(name = "ciudad", length = 100, nullable = false)
private String ciudad;
@NotBlank(message = "{direcciones.form.error.required}")
@Column(name = "provincia", length = 100, nullable = false)
private String provincia;
// Usamos el code3 del país como FK lógica (String)
@NotBlank(message = "{direcciones.form.error.required}")
@Column(name = "pais_code3", length = 3, nullable = false)
private String paisCode3 = "esp";
@NotBlank(message = "{direcciones.form.error.required}")
@Column(name = "telefono", length = 30, nullable = false)
private String telefono;
@Column(name = "instrucciones", length = 255)
private String instrucciones;
@Column(name = "is_facturacion", nullable = false)
private boolean direccionFacturacion = false;
@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;
// --- Asociación opcional (read-only) a Pais por code3, si tienes la entidad
// Pais
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "pais_code3", referencedColumnName = "code3", insertable = false, updatable = false)
private Paises pais;
// --- Getters & Setters ---
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public String getAlias() {
return alias;
}
public void setAlias(String alias) {
this.alias = alias;
}
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 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 boolean isDireccionFacturacion() {
return direccionFacturacion;
}
public void setDireccionFacturacion(boolean direccionFacturacion) {
this.direccionFacturacion = direccionFacturacion;
}
public String getRazonSocial() {
return razonSocial;
}
public void setRazonSocial(String razonSocial) {
this.razonSocial = razonSocial;
}
public TipoIdentificacionFiscal getTipoIdentificacionFiscal() {
return tipoIdentificacionFiscal;
}
public void setTipoIdentificacionFiscal(TipoIdentificacionFiscal tipo) {
this.tipoIdentificacionFiscal = tipo;
}
public String getIdentificacionFiscal() {
return identificacionFiscal;
}
public void setIdentificacionFiscal(String identificacionFiscal) {
this.identificacionFiscal = identificacionFiscal;
}
public Paises getPais() {
return pais;
}
public void setPais(Paises pais) {
this.pais = pais;
}
}

View File

@ -0,0 +1,551 @@
package com.imprimelibros.erp.direcciones;
import java.security.Principal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.context.MessageSource;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import com.imprimelibros.erp.cart.CartService;
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.paises.PaisesService;
import com.imprimelibros.erp.users.User;
import com.imprimelibros.erp.users.UserDao;
import com.imprimelibros.erp.users.UserDetailsImpl;
import jakarta.persistence.criteria.Predicate;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
@Controller
@RequestMapping("/direcciones")
public class DireccionController {
private final DireccionService direccionService;
protected final DireccionRepository repo;
protected final PaisesService paisesService;
protected final MessageSource messageSource;
protected final UserDao userRepo;
protected final TranslationService translationService;
protected final CartService cartService;
public DireccionController(DireccionRepository repo, PaisesService paisesService,
MessageSource messageSource, UserDao userRepo, TranslationService translationService,
DireccionService direccionService, CartService cartService) {
this.repo = repo;
this.paisesService = paisesService;
this.messageSource = messageSource;
this.userRepo = userRepo;
this.translationService = translationService;
this.direccionService = direccionService;
this.cartService = cartService;
}
@GetMapping()
public String viewDirecciones(Model model, Authentication auth, Locale locale) {
boolean isUser = auth != null && auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_USER"));
model.addAttribute("isUser", isUser ? 1 : 0);
List<String> keys = List.of(
"direcciones.delete.title",
"direcciones.delete.text",
"direcciones.eliminar",
"direcciones.delete.button",
"app.yes",
"app.cancelar",
"direcciones.delete.ok.title",
"direcciones.delete.ok.text",
"direcciones.btn.edit",
"direcciones.btn.delete",
"direcciones.telefono", "direcciones.isFacturacionShort");
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
if (isUser)
return "imprimelibros/direcciones/direccion-list-cliente";
else
return "imprimelibros/direcciones/direccion-list";
}
@GetMapping(value = "/datatable", produces = "application/json")
@ResponseBody
public DataTablesResponse<Map<String, Object>> datatable(
HttpServletRequest request,
Authentication authentication,
Locale locale) {
DataTablesRequest dt = DataTablesParser.from(request);
// Columnas visibles / lógicas para el DataTable en el frontend:
// id, cliente (nombre de usuario), alias, att, direccion, cp, ciudad,
// provincia, pais
List<String> searchable = List.of(
"id",
"cliente", "alias",
"att", "direccion", "cp", "ciudad", "provincia", "pais");
List<String> orderable = List.of(
"id",
"cliente", "alias",
"att", "direccion", "cp", "ciudad", "provincia", "pais");
// Filtro base por rol (ROLE_USER solo ve sus direcciones)
Specification<Direccion> base = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (authentication != null && authentication.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_USER"))) {
String username = authentication.getName();
predicates.add(cb.equal(root.get("user").get("userName"), username));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
long total = repo.count(base);
// Construcción del datatable con entity + spec
return DataTable
.of(repo, Direccion.class, dt, searchable)
.orderable(orderable)
// Columnas "crudas" (las que existen tal cual):
.edit("id", d -> d.getId())
.edit("alias", d -> d.getAlias())
.edit("att", d -> d.getAtt())
.edit("direccion", d -> d.getDireccion())
.edit("cp", d -> d.getCp())
.edit("ciudad", d -> d.getCiudad())
.edit("provincia", d -> d.getProvincia())
// Columnas calculadas:
// cliente = nombre del usuario (o username si no tienes name)
.add("cliente", d -> {
var u = d.getUser();
return (u != null && u.getFullName() != null && !u.getFullName().isBlank())
? u.getFullName()
: "";
})
// pais = nombre localizado desde MessageSource usando el keyword del país
.add("pais", d -> {
// si tienes la relación read-only a Pais (d.getPais()) con .getKeyword()
String keyword = (d.getPais() != null) ? d.getPais().getKeyword() : null;
if (keyword == null || keyword.isBlank())
return d.getPaisCode3();
return messageSource.getMessage("paises." + keyword, null, keyword, locale);
})
// Ejemplo de columna de acciones:
.add("actions", d -> """
<div class="hstack gap-3 flex-wrap">
<a href="javascript:void(0);" data-id="%d" class="link-success btn-edit-direccion fs-15">
<i class="ri-edit-2-line"></i>
</a>
<a href="javascript:void(0);" data-id="%d" class="link-danger btn-delete-direccion fs-15">
<i class="ri-delete-bin-5-line"></i>
</a>
</div>
""".formatted(d.getId(), d.getId()))
// WHERE dinámico (spec base)
.where(base)
// Si tu DataTable helper soporta “join/alias” para buscar/ordenar por campos
// relacionados:
// .searchAlias("cliente", (root, cb) -> root.join("user").get("name"))
// .orderAlias("cliente", (root) -> root.join("user").get("name"))
// .searchAlias("pais", (root, cb) -> root.join("pais",
// JoinType.LEFT).get("keyword"))
// .orderAlias("pais", (root) -> root.join("pais",
// JoinType.LEFT).get("keyword"))
.toJson(total);
}
@GetMapping(value = "/datatableDirecciones", produces = "application/json")
@ResponseBody
public DataTablesResponse<Map<String, Object>> datatableCliente(
HttpServletRequest request,
Authentication authentication,
Locale locale) {
DataTablesRequest dt = DataTablesParser.from(request);
// Columnas visibles / lógicas para el DataTable en el frontend:
// id, cliente (nombre de usuario), alias, att, direccion, cp, ciudad,
// provincia, pais
List<String> searchable = List.of(
"id",
"alias",
"att", "direccion", "cp", "ciudad", "provincia", "pais", "telefono", "is_facturacion", "razonSocial",
"identificacionFiscal");
List<String> orderable = List.of(
"id",
"cliente", "alias",
"att", "direccion", "cp", "ciudad", "provincia", "pais", "telefono");
// Filtro base por rol (ROLE_USER solo ve sus direcciones)
Specification<Direccion> base = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (authentication != null && authentication.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_USER"))) {
String username = authentication.getName();
predicates.add(cb.equal(root.get("user").get("userName"), username));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
long total = repo.count(base);
// Construcción del datatable con entity + spec
return DataTable
.of(repo, Direccion.class, dt, searchable)
.orderable(orderable)
// Columnas "crudas" (las que existen tal cual):
.edit("id", d -> d.getId())
.edit("alias", d -> d.getAlias())
.edit("att", d -> d.getAtt())
.edit("direccion", d -> d.getDireccion())
.edit("cp", d -> d.getCp())
.edit("ciudad", d -> d.getCiudad())
.edit("provincia", d -> d.getProvincia())
.edit("telefono", d -> d.getTelefono())
.edit("is_facturacion", d -> d.isDireccionFacturacion())
.edit("razon_social", d -> d.getRazonSocial())
.edit("tipo_identificacion_fiscal", d -> d.getTipoIdentificacionFiscal())
.edit("identificacion_fiscal", d -> d.getIdentificacionFiscal())
// pais = nombre localizado desde MessageSource usando el keyword del país
.add("pais", d -> {
// si tienes la relación read-only a Pais (d.getPais()) con .getKeyword()
String keyword = (d.getPais() != null) ? d.getPais().getKeyword() : null;
if (keyword == null || keyword.isBlank())
return d.getPaisCode3();
return messageSource.getMessage("paises." + keyword, null, keyword, locale);
})
// WHERE dinámico (spec base)
.where(base)
.toJson(total);
}
@GetMapping("form")
public String getForm(@RequestParam(required = false) Long id,
Direccion direccion,
BindingResult binding,
Model model,
HttpServletResponse response,
Authentication auth,
Locale locale) {
model.addAttribute("paises", paisesService.getForSelect("", "", locale).get("results"));
if (id != null) {
var opt = repo.findByIdWithPaisAndUser(id);
if (opt == null) {
binding.reject("direcciones.error.noEncontrado",
messageSource.getMessage("direcciones.error.noEncontrado", null, locale));
response.setStatus(404);
model.addAttribute("action", "/direcciones/" + id);
return "imprimelibros/direcciones/direccion-form :: direccionForm";
}
model.addAttribute("dirForm", opt.get());
model.addAttribute("action", "/direcciones/" + id);
} else {
Direccion newDireccion = new Direccion();
boolean isUser = auth != null && auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_USER"));
if (isUser) {
User user = direccion.getUser() != null ? direccion.getUser() : null;
if (user != null) {
newDireccion.setUser(user);
}
}
model.addAttribute("dirForm", newDireccion);
model.addAttribute("action", "/direcciones");
}
return "imprimelibros/direcciones/direccion-form :: direccionForm";
}
@GetMapping("direction-form")
public String getForm(@RequestParam(required = false) Long id,
Direccion direccion,
BindingResult binding,
Model model,
HttpServletResponse response,
Principal principal,
Locale locale) {
model.addAttribute("paises", paisesService.getForSelect("", "", locale).get("results"));
Direccion newDireccion = new Direccion();
User user = null;
if (principal instanceof UserDetailsImpl udi) {
user = new User();
user.setId(udi.getId());
} else if (principal instanceof User u && u.getId() != null) {
user = u;
}
newDireccion.setUser(user);
model.addAttribute("dirForm", newDireccion);
model.addAttribute("action", "/direcciones/add");
return "imprimelibros/direcciones/direccion-form-fixed-user :: direccionForm";
}
@PostMapping
public String create(
@Valid @ModelAttribute("dirForm") Direccion direccion,
BindingResult binding,
Model model,
HttpServletResponse response,
Authentication auth,
Locale locale) {
boolean isUser = auth != null && auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_USER"));
if (isUser) {
User current = userRepo.findByUserNameIgnoreCaseAndEnabledTrueAndDeletedFalse(auth.getName()).orElse(null);
direccion.setUser(current); // ignora lo que venga del hidden
}
if (binding.hasErrors()) {
response.setStatus(422);
model.addAttribute("paises", paisesService.getForSelect("", "", locale).get("results"));
model.addAttribute("action", "/direcciones");
model.addAttribute("dirForm", direccion);
return "imprimelibros/direcciones/direccion-form :: direccionForm";
}
var data = direccion;
repo.save(data);
response.setStatus(201);
return null;
}
// para el formulario modal en checkout
@PostMapping("/add")
public String create2(
@Valid @ModelAttribute("dirForm") Direccion direccion,
BindingResult binding,
Model model,
HttpServletResponse response,
Authentication auth,
Locale locale) {
User current = userRepo.findByUserNameIgnoreCaseAndEnabledTrueAndDeletedFalse(auth.getName()).orElse(null);
direccion.setUser(current);
if (binding.hasErrors()) {
response.setStatus(422);
model.addAttribute("paises", paisesService.getForSelect("", "", locale).get("results"));
model.addAttribute("action", "/direcciones/add");
model.addAttribute("dirForm", direccion);
return "imprimelibros/direcciones/direccion-form-fixed-user :: direccionForm";
}
var data = direccion;
repo.save(data);
response.setStatus(201);
return null;
}
@PostMapping("/{id}")
public String update(
@PathVariable Long id,
@Valid @ModelAttribute("dirForm") Direccion direccion, // <- nombre distinto
BindingResult binding,
Model model,
Authentication auth,
HttpServletResponse response,
Locale locale) {
var opt = repo.findById(id);
if (opt.isEmpty()) {
binding.reject("direcciones.error.noEncontrado",
messageSource.getMessage("direcciones.error.noEncontrado", null, locale));
response.setStatus(404);
model.addAttribute("dirForm", direccion); // por si re-renderiza
model.addAttribute("action", "/direcciones/" + id);
return "imprimelibros/direcciones/direccion-form :: direccionForm";
}
Long ownerId = opt.get().getUser() != null ? opt.get().getUser().getId() : null;
if (!isOwnerOrAdmin(auth, ownerId)) {
binding.reject("direcciones.error.sinPermiso",
messageSource.getMessage("direcciones.error.sinPermiso", null, locale));
response.setStatus(403);
model.addAttribute("dirForm", direccion); // por si re-renderiza
model.addAttribute("action", "/direcciones/" + id);
return "imprimelibros/direcciones/direccion-form :: direccionForm";
}
if (binding.hasErrors()) {
response.setStatus(422);
model.addAttribute("dirForm", direccion); // <- importante
model.addAttribute("paises", paisesService.getForSelect("", "", locale).get("results"));
model.addAttribute("action", "/direcciones/" + id);
return "imprimelibros/direcciones/direccion-form :: direccionForm";
}
repo.save(direccion);
response.setStatus(200);
return null;
}
@DeleteMapping("/{id}")
@Transactional
public ResponseEntity<?> delete(@PathVariable Long id, Authentication auth, Locale locale) {
Direccion direccion = repo.findById(id).orElse(null);
if (direccion == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("message", messageSource.getMessage("direcciones.error.noEncontrado", null, locale)));
}
boolean isUser = auth != null && auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_USER"));
Long ownerId = direccion.getUser() != null ? direccion.getUser().getId() : null;
Boolean isOwner = this.isOwnerOrAdmin(auth, ownerId);
if (isUser && !isOwner) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("message",
messageSource.getMessage("direcciones.error.sinPermiso", null, locale)));
}
try {
direccion.setDeleted(true);
direccion.setDeletedAt(Instant.now());
if (auth != null && auth.getPrincipal() instanceof UserDetailsImpl udi) {
direccion.setDeletedBy(userRepo.getReferenceById(udi.getId()));
} else if (auth != null) {
userRepo.findByUserNameIgnoreCase(auth.getName()).ifPresent(direccion::setDeletedBy);
}
repo.saveAndFlush(direccion);
// eliminar referencias en carritos activos
cartService.deleteCartDireccionesByDireccionId(direccion.getId());
return ResponseEntity.ok(Map.of("message",
messageSource.getMessage("direcciones.exito.eliminado", null, locale)));
} catch (Exception ex) {
// Devuelve SIEMPRE algo en el catch
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message",
messageSource.getMessage("direcciones.error.delete-internal-error", null, locale),
"detail",
ex.getClass().getSimpleName() + ": " + (ex.getMessage() != null ? ex.getMessage() : "")));
}
}
@GetMapping(value = "/select2", produces = "application/json")
@ResponseBody
public Map<String, Object> getSelect2(
@RequestParam(value = "q", required = false) String q1,
@RequestParam(value = "term", required = false) String q2,
@RequestParam(value = "presupuestoId", required = false) Long presupuestoId,
Authentication auth) {
boolean isAdmin = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN") || a.getAuthority().equals("ROLE_SUPERADMIN"));
Long currentUserId = null;
if (!isAdmin) {
if (auth != null && auth.getPrincipal() instanceof UserDetailsImpl udi) {
currentUserId = udi.getId();
} else if (auth != null) {
currentUserId = userRepo.findIdByUserNameIgnoreCase(auth.getName()).orElse(null);
}
}
return direccionService.getForSelect(q1, q2, isAdmin ? null : currentUserId);
}
@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) {
if (auth == null) {
return false;
}
boolean isAdmin = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN") || a.getAuthority().equals("ROLE_SUPERADMIN"));
if (isAdmin) {
return true;
}
// Aquí deberías obtener el ID del usuario actual desde tu servicio de usuarios
Long currentUserId = null;
if (auth != null && auth.getPrincipal() instanceof UserDetailsImpl udi) {
currentUserId = udi.getId();
} else if (auth != null) {
currentUserId = userRepo.findIdByUserNameIgnoreCase(auth.getName()).orElse(null);
}
return currentUserId != null && currentUserId.equals(ownerId);
}
}

View File

@ -0,0 +1,49 @@
package com.imprimelibros.erp.direcciones;
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 java.util.List;
import java.util.Optional;
public interface DireccionRepository
extends JpaRepository<Direccion, Long>,
JpaSpecificationExecutor<Direccion> {
@Query("""
select d from Direccion d
left join fetch d.user
left join fetch d.pais
where d.user.id = :id
""")
List<DireccionView> findAllWithPaisAndUser(@Param("userId") Long userId);
@Query("""
select d from Direccion d
left join fetch d.user
left join fetch d.pais
where d.id = :id
""")
Optional<Direccion> findByIdWithPaisAndUser(@Param("id") Long id);
@Query(value = "SELECT * FROM direcciones", nativeQuery = true)
List<DireccionView> findAllWithDeleted();
// find by user_id
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
@Query(value = "SELECT * FROM direcciones WHERE user_id = :userId", nativeQuery = true)
List<Direccion> findByUserIdWithDeleted(@Param("userId") Long userId);
}

View File

@ -0,0 +1,156 @@
package com.imprimelibros.erp.direcciones;
import java.text.Collator;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
@Service
public class DireccionService {
protected DireccionRepository repo;
public DireccionService(DireccionRepository repo) {
this.repo = repo;
}
public Map<String, Object> getForSelect(String q1, String q2, Long userId) {
try {
// Termino de búsqueda (Select2 usa 'q' o 'term' según versión/config)
String search = Optional.ofNullable(q1).orElse(q2);
if (search != null) {
search = search.trim();
}
final String q = (search == null || search.isEmpty())
? null
: search.toLowerCase();
List<Direccion> all = userId != null ? repo.findByUserId(userId) : repo.findAll();
// 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 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) {
return repo.findById(id);
}
public Boolean checkFreeShipment(Integer cp, String paisCode3) {
if (paisCode3 != null && paisCode3.toLowerCase().equals("esp") && cp != null) {
// Excluir Canarias (35xxx y 38xxx), Baleares (07xxx), Ceuta (51xxx), Melilla
// (52xxx)
int provincia = cp / 1000;
if (provincia != 7 && provincia != 35 && provincia != 38 && provincia != 51 && provincia != 52) {
return true; // España peninsular
}
}
return false;
}
}

View File

@ -0,0 +1,28 @@
package com.imprimelibros.erp.direcciones;
public interface DireccionView {
Long getId();
UserView getUser();
String getAlias();
String getAtt();
String getDireccion();
String getCp();
String getCiudad();
String getProvincia();
PaisView getPais();
String getPaisKeyword();
String getTelefono();
Boolean getIsFacturacion();
String getRazonSocial();
String getTipoIdentificacionFiscal();
String getIdentificacionFiscal();
String getCliente();
interface UserView {
Long getId();
String getFullName();
}
interface PaisView {
String getCode3();
String getKeyword();
}
}

View File

@ -1,10 +1,12 @@
package com.imprimelibros.erp.externalApi;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.MessageSource;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
@ -13,13 +15,18 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.imprimelibros.erp.configuracion.margenes_presupuestos.MargenPresupuesto;
import com.imprimelibros.erp.configuracion.margenes_presupuestos.MargenPresupuestoDao;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoCubierta;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoEncuadernacion;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto.TipoCubierta;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto.TipoEncuadernacion;
import java.util.Map;
import java.util.Optional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.net.URI;
import java.util.HashMap;
import java.util.List;
import java.util.function.Supplier;
import java.util.Locale;
@Service
public class skApiClient {
@ -30,11 +37,14 @@ public class skApiClient {
private final AuthService authService;
private final RestTemplate restTemplate;
private final MargenPresupuestoDao margenPresupuestoDao;
private final MessageSource messageSource;
public skApiClient(AuthService authService, MargenPresupuestoDao margenPresupuestoDao) {
public skApiClient(AuthService authService, MargenPresupuestoDao margenPresupuestoDao,
MessageSource messageSource) {
this.authService = authService;
this.restTemplate = new RestTemplate();
this.margenPresupuestoDao = margenPresupuestoDao;
this.messageSource = messageSource;
}
public String getPrice(Map<String, Object> requestBody, TipoEncuadernacion tipoEncuadernacion,
@ -76,23 +86,28 @@ public class skApiClient {
data.get("precios"), new TypeReference<List<Double>>() {
});
for (int i = 0; i < tiradas.size(); i++) {
int tirada = tiradas.get(i);
for (int i = 0; i < precios.size(); i++) {
BigDecimal importe = new BigDecimal(precios.get(i));
MargenPresupuesto margen = margenPresupuestoDao.findByTipoAndTirada(
tipoEncuadernacion, tipoCubierta, tirada);
BigDecimal importeTotal = importe.multiply(BigDecimal.valueOf(tiradas.get(i)));
MargenPresupuesto margen = margenPresupuestoDao
.findByImporte(importeTotal).orElse(null);
if (margen != null) {
double margenValue = calcularMargen(
tirada,
margen.getTiradaMin(),
margen.getTiradaMax(),
BigDecimal margenValue = calcularMargen(
importeTotal,
margen.getImporteMin(),
margen.getImporteMax(),
margen.getMargenMax(),
margen.getMargenMin());
double nuevoPrecio = precios.get(i) * (1 + margenValue / 100.0);
precios.set(i, nuevoPrecio);
BigDecimal nuevoPrecio = new BigDecimal(precios.get(i)).multiply(BigDecimal.ONE
.add(margenValue.divide(BigDecimal.valueOf(100), RoundingMode.HALF_UP)));
precios.set(i, nuevoPrecio.setScale(4, RoundingMode.HALF_UP).doubleValue()); // redondear
// a 4
// decimales
} else {
System.out.println("No se encontró margen para tirada " + tirada);
System.out.println("No se encontró margen para importe " + importe);
}
}
@ -113,7 +128,127 @@ public class skApiClient {
});
}
public Integer getMaxSolapas(Map<String, Object> requestBody) {
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 {
String jsonResponse = performWithRetry(() -> {
String url = this.skApiUrl + "api/calcular-solapas";
@ -150,10 +285,15 @@ public class skApiClient {
JsonNode root = mapper.readTree(jsonResponse);
if (root.get("data") == null || !root.get("data").isInt()) {
throw new RuntimeException("Respuesta inesperada de calcular-solapas: " + jsonResponse);
throw new RuntimeException(
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) {
// Fallback al 80% del ancho
@ -166,7 +306,9 @@ public class skApiClient {
throw new RuntimeException("Tamaño no válido en la solicitud: " + requestBody);
else {
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);
}
}
}
@ -206,6 +348,270 @@ public class skApiClient {
}
}
public Map<String, Object> getCosteEnvio(Map<String, Object> data, Locale locale) {
return performWithRetryMap(() -> {
String url = this.skApiUrl + "api/calcular-envio";
URI uri = UriComponentsBuilder.fromUriString(url)
.queryParam("pais_code3", data.get("pais_code3"))
.queryParam("cp", data.get("cp"))
.queryParam("peso", data.get("peso"))
.queryParam("unidades", data.get("unidades"))
.queryParam("palets", data.get("palets"))
.build(true) // no re-encode []
.toUri();
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(authService.getToken());
ResponseEntity<String> response = restTemplate.exchange(
uri,
HttpMethod.GET,
new HttpEntity<>(headers),
String.class);
try {
Map<String, Object> responseBody = new ObjectMapper().readValue(
response.getBody(),
new TypeReference<Map<String, Object>>() {
});
Boolean error = (Boolean) responseBody.get("error");
if (error != null && error) {
return Map.of("error", messageSource.getMessage("direcciones.error.noShippingCost", null, locale));
} else {
Double total = Optional.ofNullable(responseBody.get("data"))
.filter(Number.class::isInstance)
.map(Number.class::cast)
.map(Number::doubleValue)
.orElse(0.0);
return Map.of("data", total);
}
} catch (JsonProcessingException e) {
e.printStackTrace();
return Map.of("error", "Internal Server Error: 1"); // Fallback en caso de error
}
});
}
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
******************/
@ -223,13 +629,42 @@ public class skApiClient {
}
}
private static double calcularMargen(
int tirada, int tiradaMin, int tiradaMax,
double margenMax, double margenMin) {
if (tirada <= tiradaMin)
private Map<String, Object> performWithRetryMap(Supplier<Map<String, Object>> request) {
try {
return request.get();
} catch (HttpClientErrorException.Unauthorized e) {
// Token expirado, renovar y reintentar
authService.invalidateToken();
try {
return request.get(); // segundo intento
} catch (HttpClientErrorException ex) {
throw new RuntimeException("La autenticación ha fallado tras renovar el token.", ex);
}
}
}
private 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(
BigDecimal importe, BigDecimal importeMin, BigDecimal importeMax,
BigDecimal margenMax, BigDecimal margenMin) {
if (importe.compareTo(importeMin) <= 0)
return margenMax;
if (tirada >= tiradaMax)
if (importe.compareTo(importeMax) >= 0)
return margenMin;
return margenMax - ((double) (tirada - tiradaMin) / (tiradaMax - tiradaMin)) * (margenMax - margenMin);
return margenMax.subtract(margenMax.subtract(margenMin)
.multiply(importe.subtract(importeMin)
.divide(importeMax.subtract(importeMin), RoundingMode.HALF_UP)));
}
}

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

@ -35,19 +35,23 @@ public class HomeController {
"presupuesto.plantilla-cubierta",
"presupuesto.plantilla-cubierta-text",
"presupuesto.impresion-cubierta",
"presupuesto.impresion-cubierta-help");
"presupuesto.impresion-cubierta-help",
"presupuesto.iva-reducido",
"presupuesto.iva-reducido-descripcion");
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
model.addAttribute("pod", variableService.getValorEntero("POD"));
model.addAttribute("ancho_alto_min", variableService.getValorEntero("ancho_alto_min"));
model.addAttribute("ancho_alto_max", variableService.getValorEntero("ancho_alto_max"));
model.addAttribute("appMode", "public");
}
else{
// empty translations for authenticated users
Map<String, String> translations = Map.of();
model.addAttribute("languageBundle", translations);
}
return "imprimelibros/home";
return "imprimelibros/home/home";
}
}

View File

@ -4,9 +4,9 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Component;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoCubierta;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoEncuadernacion;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoImpresion;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto.TipoCubierta;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto.TipoEncuadernacion;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto.TipoImpresion;
import java.util.*;

View File

@ -0,0 +1,82 @@
package com.imprimelibros.erp.paises;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "paises")
public class Paises {
@Id
@Column(name = "keyword", length = 64, nullable = false)
private String keyword;
@Column(name = "code", length = 2, nullable = false, unique = true)
private String code;
@Column(name = "code3", length = 3, nullable = false, unique = true)
private String code3;
@Column(name = "currency", length = 3, nullable = false)
private String currency;
// --- Getters & Setters ---
public String getKeyword() {
return keyword;
}
public void setKeyword(String keyword) {
this.keyword = keyword;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getCode3() {
return code3;
}
public void setCode3(String code3) {
this.code3 = code3;
}
public String getCurrency() {
return currency;
}
public void setCurrency(String currency) {
this.currency = currency;
}
// --- toString, equals & hashCode ---
@Override
public String toString() {
return "Paises{" +
"keyword='" + keyword + '\'' +
", code='" + code + '\'' +
", code3='" + code3 + '\'' +
", currency='" + currency + '\'' +
'}';
}
@Override
public int hashCode() {
return keyword != null ? keyword.hashCode() : 0;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof Paises other)) return false;
return keyword != null && keyword.equals(other.keyword);
}
}

View File

@ -0,0 +1,36 @@
package com.imprimelibros.erp.paises;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping("/api/paises")
public class PaisesController {
private final PaisesService paisesService;
public PaisesController(PaisesService paisesService) {
this.paisesService = paisesService;
}
/**
* Compatible con Select2 (AJAX):
* - Soporta parámetros opcionales:
* - q / term : texto a buscar
* - lang : ej. "es", "en" (si se omite usa Locale actual)
*
* Respuesta:
* { "results": [ { "id": "espania", "text": "España" }, ... ] }
*/
@GetMapping
public Map<String, Object> getPaises(
@RequestParam(value = "q", required = false) String q1,
@RequestParam(value = "term", required = false) String q2,
Locale locale) {
return paisesService.getForSelect(q1, q2, locale);
}
}

View File

@ -0,0 +1,16 @@
package com.imprimelibros.erp.paises;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface PaisesRepository extends JpaRepository<Paises, String> {
Optional<Paises> findByCode(String code);
Optional<Paises> findByCode3(String code3);
Optional<Paises> findByCurrency(String currency);
}

View File

@ -0,0 +1,86 @@
package com.imprimelibros.erp.paises;
import java.text.Collator;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Service;
@Service
public class PaisesService {
protected final PaisesRepository repo;
protected final MessageSource messageSource;
public PaisesService(PaisesRepository repo, MessageSource messageSource) {
this.repo = repo;
this.messageSource = messageSource;
}
public Map<String, Object> getForSelect(String q1, String q2, Locale locale) {
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(locale);
List<Paises> all = repo.findAll();
// Mapear a opciones id/text con i18n y filtrar por búsqueda si llega
List<Map<String, String>> options = all.stream()
.map(cc -> {
String key = cc.getKeyword();
String id = cc.getCode3();
String text = messageSource.getMessage("paises." + key, null, key, locale);
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(locale);
String id = opt.get("id").toLowerCase(locale);
return text.contains(q) || id.contains(q);
})
.sorted(Comparator.comparing(m -> m.get("text"), Collator.getInstance(locale)))
.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 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

@ -0,0 +1,11 @@
package com.imprimelibros.erp.pdf;
import java.util.Locale;
import java.util.Map;
public record DocumentSpec(
DocumentType type,
String templateId, // p.ej. "presupuesto-a4"
Locale locale,
Map<String, Object> model // data del documento
) {}

View File

@ -0,0 +1,10 @@
package com.imprimelibros.erp.pdf;
public enum DocumentType {
PRESUPUESTO, PEDIDO, FACTURA;
@Override
public String toString() {
return name().toLowerCase();
}
}

View File

@ -0,0 +1,62 @@
package com.imprimelibros.erp.pdf;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ContentDisposition;
import java.util.Locale;
@RestController
@RequestMapping("/api/pdf")
public class PdfController {
private final PdfService pdfService;
public PdfController(PdfService pdfService) {
this.pdfService = pdfService;
}
@GetMapping(value = "/{type}/{id}", produces = "application/pdf")
public ResponseEntity<byte[]> generate(
@PathVariable("type") String type,
@PathVariable String id,
@RequestParam(defaultValue = "inline") String mode,
Locale locale) {
if (id == null) {
throw new IllegalArgumentException("Falta el ID para generar el PDF");
}
if (type.equals(DocumentType.PRESUPUESTO.toString())) {
Long presupuestoId = Long.valueOf(id);
byte[] pdf = pdfService.generaPresupuesto(presupuestoId, locale);
var headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF);
headers.setContentDisposition(
("download".equals(mode)
? ContentDisposition.attachment()
: ContentDisposition.inline()).filename("presupuesto-" + id + ".pdf").build());
return new ResponseEntity<>(pdf, headers, HttpStatus.OK);
}/*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);
}
}
}

View File

@ -0,0 +1,15 @@
// com.imprimelibros.erp.pdf.PdfModuleConfig.java
package com.imprimelibros.erp.pdf;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.HashMap;
import java.util.Map;
@ConfigurationProperties(prefix = "imprimelibros.pdf")
public class PdfModuleConfig {
private Map<String, String> templates = new HashMap<>();
public Map<String, String> getTemplates() { return templates; }
public void setTemplates(Map<String, String> templates) { this.templates = templates; }
}

View File

@ -0,0 +1,42 @@
package com.imprimelibros.erp.pdf;
import com.openhtmltopdf.outputdevice.helper.BaseRendererBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
@Service
public class PdfRenderer {
@Value("classpath:/static/")
private org.springframework.core.io.Resource staticRoot;
public byte[] renderHtmlToPdf(String html) {
try (var baos = new ByteArrayOutputStream()) {
var builder = new com.openhtmltopdf.pdfboxout.PdfRendererBuilder();
builder.useFastMode();
// 👇 Base URL para que pueda resolver /assets/css/ y /img/
builder.withHtmlContent(html, staticRoot.getURL().toString()); // .../target/classes/static/
// (Opcional) Registrar fuentes TTF
builder.useFont(() -> getClass().getResourceAsStream("/static/assets/fonts/OpenSans-Regular.ttf"),
"Open Sans", 400, BaseRendererBuilder.FontStyle.NORMAL, true);
builder.useFont(() -> getClass().getResourceAsStream("/static/assets/fonts/OpenSans-SemiBold.ttf"),
"Open Sans", 600, BaseRendererBuilder.FontStyle.NORMAL, true);
builder.useFont(() -> getClass().getResourceAsStream("/static/assets/fonts/OpenSans-Bold.ttf"),
"Open Sans", 700, BaseRendererBuilder.FontStyle.NORMAL, true);
builder.toStream(baos);
builder.run();
return baos.toByteArray();
} catch (Exception e) {
throw new IllegalStateException("Error generando PDF", e);
}
}
}

View File

@ -0,0 +1,243 @@
package com.imprimelibros.erp.pdf;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imprimelibros.erp.presupuesto.PresupuestoRepository;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
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
public class PdfService {
private final TemplateRegistry registry;
private final PdfTemplateEngine engine;
private final PdfRenderer renderer;
private final PresupuestoRepository presupuestoRepository;
private final Utils utils;
private final FacturacionService facturacionService;
private final PedidoService pedidoService;
private final Map<String, String> empresa = Map.of(
"nombre", "ImprimeLibros ERP",
"direccion", "C/ Dirección 123, 28000 Madrid",
"telefono", "+34 600 000 000",
"email", "info@imprimelibros.com",
"cif", "B-12345678",
"cp", "28000",
"poblacion", "Madrid",
"web", "www.imprimelibros.com");
private static class PrecioTirada {
private Double peso;
@JsonProperty("iva_importe_4")
private Double ivaImporte4;
@JsonProperty("total_con_iva")
private Double totalConIva;
@JsonProperty("base_imponible")
private Double baseImponible;
@JsonProperty("iva_importe_21")
private Double ivaImporte21;
@JsonProperty("precio_unitario")
private Double precioUnitario;
@JsonProperty("servicios_total")
private Double serviciosTotal;
@JsonProperty("precio_total_tirada")
private Double precioTotalTirada;
public Double getPeso() {
return peso;
}
public Double getIvaImporte4() {
return ivaImporte4;
}
public Double getTotalConIva() {
return totalConIva;
}
public Double getBaseImponible() {
return baseImponible;
}
public Double getIvaImporte21() {
return ivaImporte21;
}
public Double getPrecioUnitario() {
return precioUnitario;
}
public Double getServiciosTotal() {
return serviciosTotal;
}
public Double getPrecioTotalTirada() {
return precioTotalTirada;
}
}
public PdfService(TemplateRegistry registry, PdfTemplateEngine engine, PdfRenderer renderer,
PresupuestoRepository presupuestoRepository, Utils utils, FacturacionService facturacionService,
PedidoService pedidoService) {
this.registry = registry;
this.engine = engine;
this.renderer = renderer;
this.presupuestoRepository = presupuestoRepository;
this.utils = utils;
this.pedidoService = pedidoService;
this.facturacionService = facturacionService;
}
private byte[] generate(DocumentSpec spec) {
var template = registry.resolve(spec.type(), spec.templateId());
if (template == null) {
throw new IllegalArgumentException("Plantilla no registrada: " + spec.type() + ":" + spec.templateId());
}
var html = engine.render(template, spec.locale(), spec.model());
return renderer.renderHtmlToPdf(html);
}
public byte[] generaPresupuesto(Long presupuestoId, Locale locale) {
try {
Presupuesto presupuesto = presupuestoRepository.findById(presupuestoId)
.orElseThrow(() -> new IllegalArgumentException("Presupuesto no encontrado: " + presupuestoId));
Map<String, Object> model = new HashMap<>();
model.put("numero", presupuesto.getId());
model.put("fecha", presupuesto.getUpdatedAt());
model.put("empresa", empresa);
model.put("cliente", Map.of(
"nombre", presupuesto.getUser() != null ? presupuesto.getUser().getFullName() : ""));
model.put("titulo", presupuesto.getTitulo());
Map<String, Object> specs = utils.getTextoPresupuesto(presupuesto, locale);
model.put("specs", specs);
Map<String, Object> pricing = new HashMap<>();
ObjectMapper mapper = new ObjectMapper();
// Si quieres parsear directamente a un Map:
Map<Integer, PrecioTirada> snapshot = mapper.readValue(presupuesto.getPricingSnapshotJson(),
mapper.getTypeFactory().constructMapType(Map.class, Integer.class, PrecioTirada.class));
List<Integer> tiradas = snapshot.keySet().stream().toList();
pricing.put("tiradas", tiradas);
pricing.put("impresion", snapshot.values().stream()
.map(p -> Utils.formatCurrency(p.getPrecioTotalTirada(), locale))
.toList());
pricing.put("servicios", snapshot.values().stream()
.map(p -> Utils.formatCurrency(p.getServiciosTotal(), locale))
.toList());
pricing.put("peso", snapshot.values().stream()
.map(p -> Utils.formatCurrency(p.getPeso(), locale))
.toList());
pricing.put("iva_4", snapshot.values().stream()
.map(p -> Utils.formatCurrency(p.getIvaImporte4(), locale))
.toList());
pricing.put("iva_21", snapshot.values().stream()
.map(p -> Utils.formatCurrency(p.getIvaImporte21(), locale))
.toList());
pricing.put("total", snapshot.values().stream()
.map(p -> Utils.formatCurrency(p.getTotalConIva(), locale))
.toList());
pricing.put("show_iva_4", presupuesto.getIvaImporte4().floatValue() > 0);
pricing.put("show_iva_21", presupuesto.getIvaImporte21().floatValue() > 0);
model.put("pricing", pricing);
var spec = new DocumentSpec(
DocumentType.PRESUPUESTO,
"presupuesto-a4",
Locale.forLanguageTag("es-ES"),
model);
byte[] pdf = this.generate(spec);
// HTML
// (Opcional) generar HTML de depuración con CSS incrustado
try {
String templateName = registry.resolve(DocumentType.PRESUPUESTO, "presupuesto-a4");
String html = engine.render(templateName, Locale.forLanguageTag("es-ES"), model);
String css = Files.readString(Path.of("src/main/resources/static/assets/css/presupuestopdf.css"));
String htmlWithCss = html.replaceFirst("(?i)</head>", "<style>\n" + css + "\n</style>\n</head>");
Path htmlPath = Path.of("target/presupuesto-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 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

@ -0,0 +1,22 @@
package com.imprimelibros.erp.pdf;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring6.SpringTemplateEngine;
import org.springframework.stereotype.Service;
import java.util.Locale;
import java.util.Map;
@Service
public class PdfTemplateEngine {
private final SpringTemplateEngine thymeleaf;
public PdfTemplateEngine(SpringTemplateEngine thymeleaf) {
this.thymeleaf = thymeleaf;
}
public String render(String templateName, Locale locale, Map<String,Object> model) {
Context ctx = new Context(locale);
if (model != null) model.forEach(ctx::setVariable);
return thymeleaf.process(templateName, ctx);
}
}

View File

@ -0,0 +1,21 @@
package com.imprimelibros.erp.pdf;
import org.springframework.stereotype.Service;
@Service
public class TemplateRegistry {
private final PdfModuleConfig config;
public TemplateRegistry(PdfModuleConfig config) {
this.config = config;
}
public String resolve(DocumentType type, String templateId) {
String key = type.name() + ":" + templateId;
String keyAlt = type.name() + "_" + templateId; // compatibilidad con properties
if (config.getTemplates() == null) return null;
String value = config.getTemplates().get(key);
if (value == null) value = config.getTemplates().get(keyAlt);
return value;
}
}

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