Compare commits

...

65 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
571 changed files with 26595 additions and 161644 deletions

3
.gitignore vendored
View File

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

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

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

View File

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

12303
logs/erp.log Normal file

File diff suppressed because one or more lines are too long

13
pom.xml
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,121 +4,329 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import org.springframework.context.MessageSource;
import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam; 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 org.springframework.web.bind.annotation.ResponseBody;
import com.imprimelibros.erp.payments.model.PaymentTransactionStatus.*;
import com.imprimelibros.erp.common.Utils; import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.configuracion.margenes_presupuestos.MargenPresupuesto;
import com.imprimelibros.erp.datatables.DataTable; import com.imprimelibros.erp.datatables.DataTable;
import com.imprimelibros.erp.datatables.DataTablesParser; import com.imprimelibros.erp.datatables.DataTablesParser;
import com.imprimelibros.erp.datatables.DataTablesRequest; import com.imprimelibros.erp.datatables.DataTablesRequest;
import com.imprimelibros.erp.datatables.DataTablesResponse; 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.Payment;
import com.imprimelibros.erp.payments.model.PaymentTransaction; import com.imprimelibros.erp.payments.model.PaymentTransaction;
import com.imprimelibros.erp.payments.model.PaymentTransactionStatus; 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.payments.repo.PaymentTransactionRepository;
import com.imprimelibros.erp.users.User; import com.imprimelibros.erp.users.User;
import com.imprimelibros.erp.users.UserDao; import com.imprimelibros.erp.users.UserDao;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.PostMapping;
@Controller @Controller
@RequestMapping("/pagos") @RequestMapping("/pagos")
@PreAuthorize("hasRole('SUPERADMIN')") @PreAuthorize("hasRole('SUPERADMIN')")
public class PaymentController { public class PaymentController {
protected final PaymentService paymentService;
protected final MessageSource messageSource;
protected final TranslationService translationService;
protected final PaymentTransactionRepository repoPaymentTransaction; protected final PaymentTransactionRepository repoPaymentTransaction;
protected final UserDao repoUser; protected final UserDao repoUser;
public PaymentController(PaymentTransactionRepository repoPaymentTransaction, UserDao repoUser) { public PaymentController(PaymentTransactionRepository repoPaymentTransaction, UserDao repoUser,
MessageSource messageSource, TranslationService translationService, PaymentService paymentService) {
this.repoPaymentTransaction = repoPaymentTransaction; this.repoPaymentTransaction = repoPaymentTransaction;
this.repoUser = repoUser; this.repoUser = repoUser;
this.messageSource = messageSource;
this.translationService = translationService;
this.paymentService = paymentService;
} }
@GetMapping() @GetMapping()
public String index() { 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"; return "imprimelibros/pagos/gestion-pagos";
} }
@GetMapping(value = "datatable/redsys", produces = "application/json") @GetMapping(value = "datatable/redsys", produces = "application/json")
@ResponseBody @ResponseBody
public DataTablesResponse<Map<String, Object>> getDatatableRedsys(HttpServletRequest request,Locale locale) { public DataTablesResponse<Map<String, Object>> getDatatableRedsys(HttpServletRequest request, Locale locale) {
DataTablesRequest dt = DataTablesParser.from(request); DataTablesRequest dt = DataTablesParser.from(request);
List<String> searchable = List.of( List<String> searchable = List.of(
); "payment.gatewayOrderId",
"payment.orderId"
// "client" no, porque lo calculas a posteriori
);
// Campos ordenables
List<String> orderable = List.of( List<String> orderable = List.of(
"payment.gatewayOrderId",
); "payment.orderId",
"amountCents",
"payment.amountRefundedCents",
"createdAt");
Specification<PaymentTransaction> base = Specification.allOf( Specification<PaymentTransaction> base = Specification.allOf(
(root, query, cb) -> cb.equal(root.get("status"), PaymentTransactionStatus.succeeded)); (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); Long total = repoPaymentTransaction.count(base);
return DataTable return DataTable
.of(repoPaymentTransaction, PaymentTransaction.class, dt, searchable) // 'searchable' en DataTable.java .of(repoPaymentTransaction, PaymentTransaction.class, dt, searchable)
// edita columnas "reales":
.orderable(orderable) .orderable(orderable)
.add("created_at", (pago) -> { .add("created_at", pago -> Utils.formatDateTime(pago.getCreatedAt(), locale))
return Utils.formatDateTime(pago.getCreatedAt(), locale); .add("client", pago -> {
})
.add("client", (pago) -> {
if (pago.getPayment() != null && pago.getPayment().getUserId() != null) { if (pago.getPayment() != null && pago.getPayment().getUserId() != null) {
Payment payment = pago.getPayment(); Payment payment = pago.getPayment();
if(payment.getUserId() != null) { if (payment.getUserId() != null) {
Optional<User> user = repoUser.findById(payment.getUserId()); Optional<User> user = repoUser.findById(payment.getUserId().longValue());
return user.map(User::getFullName).orElse(""); return user.map(User::getFullName).orElse("");
} }
return "";
} else {
return "";
} }
return "";
}) })
.add("gateway_order_id", (pago) -> { .add("gateway_order_id", pago -> {
if (pago.getPayment() != null) { if (pago.getPayment() != null) {
return pago.getPayment().getGatewayOrderId(); return pago.getPayment().getGatewayOrderId();
} else { } else {
return ""; return "";
} }
}) })
.add("orderId", (pago) -> { .add("orderId", pago -> {
if (pago.getPayment() != null && pago.getPayment().getOrderId() != null) { if (pago.getPayment() != null && pago.getPayment().getOrderId() != null) {
return pago.getPayment().getOrderId().toString(); return pago.getPayment().getOrderId().toString();
} else { } else {
return ""; return "";
} }
}) })
.add("amount_cents", (pago) -> { .add("amount_cents", pago -> Utils.formatCurrency(pago.getAmountCents() / 100.0, locale))
return 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) -> { .add("actions", pago -> {
return "<div class=\"hstack gap-3 flex-wrap\">\n" + Payment p = pago.getPayment();
" <a href=\"javascript:void(0);\" data-id=\"" + pago.getId() if (p != null) {
+ "\" class=\"link-success btn-edit-pago fs-15\"><i class=\"ri-edit-2-line\"></i></a>\n" if (pago.getAmountCents() - p.getAmountRefundedCents() > 0) {
+ " <a href=\"javascript:void(0);\" data-id=\"" + pago.getId() return "<span class=\'badge bg-secondary btn-refund-payment \' data-dsOrderId=\'"
+ "\" class=\"link-danger btn-delete-pago fs-15\"><i class=\"ri-delete-bin-5-line\"></i></a>\n" + p.getGatewayOrderId()
+ " </div>"; + "\' 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) .where(base)
// Filtros custom:
.toJson(total); .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

@ -3,6 +3,9 @@ package com.imprimelibros.erp.payments;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.imprimelibros.erp.cart.Cart; import com.imprimelibros.erp.cart.Cart;
import com.imprimelibros.erp.cart.CartService; 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.model.*;
import com.imprimelibros.erp.payments.repo.PaymentRepository; import com.imprimelibros.erp.payments.repo.PaymentRepository;
import com.imprimelibros.erp.payments.repo.PaymentTransactionRepository; import com.imprimelibros.erp.payments.repo.PaymentTransactionRepository;
@ -13,10 +16,13 @@ import com.imprimelibros.erp.redsys.RedsysService.RedsysNotification;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import com.imprimelibros.erp.payments.repo.WebhookEventRepository; 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.time.LocalDateTime;
import java.util.Locale;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.UUID;
@Service @Service
public class PaymentService { public class PaymentService {
@ -28,34 +34,80 @@ public class PaymentService {
private final WebhookEventRepository webhookEventRepo; private final WebhookEventRepository webhookEventRepo;
private final ObjectMapper om = new ObjectMapper(); private final ObjectMapper om = new ObjectMapper();
private final CartService cartService; private final CartService cartService;
private final PedidoService pedidoService;
private final FacturacionService facturacionService;
public PaymentService(PaymentRepository payRepo, public PaymentService(PaymentRepository payRepo,
PaymentTransactionRepository txRepo, PaymentTransactionRepository txRepo,
RefundRepository refundRepo, RefundRepository refundRepo,
RedsysService redsysService, RedsysService redsysService,
WebhookEventRepository webhookEventRepo, CartService cartService) { WebhookEventRepository webhookEventRepo,
CartService cartService,
PedidoService pedidoService,
FacturacionService facturacionService) {
this.payRepo = payRepo; this.payRepo = payRepo;
this.txRepo = txRepo; this.txRepo = txRepo;
this.refundRepo = refundRepo; this.refundRepo = refundRepo;
this.redsysService = redsysService; this.redsysService = redsysService;
this.webhookEventRepo = webhookEventRepo; this.webhookEventRepo = webhookEventRepo;
this.cartService = cartService; this.cartService = cartService;
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 * Crea el Payment en BD y construye el formulario de Redsys usando la API
* oficial (ApiMacSha256). * oficial (ApiMacSha256).
*/ */
@Transactional @Transactional
public FormPayload createRedsysPayment(Long cartId, long amountCents, String currency, String method) public FormPayload createRedsysPayment(Long cartId, Long dirFactId, Long amountCents, String currency, String method, Long orderId)
throws Exception { throws Exception {
Payment p = new Payment(); Payment p = new Payment();
p.setOrderId(null); p.setOrderId(orderId);
Cart cart = this.cartService.findById(cartId); Cart cart = this.cartService.findById(cartId);
if(cart != null && cart.getUserId() != null) { if (cart != null && cart.getUserId() != null) {
p.setUserId(cart.getUserId()); p.setUserId(cart.getUserId());
this.cartService.lockCartById(cartId);
} }
p.setCurrency(currency); p.setCurrency(currency);
p.setAmountTotalCents(amountCents); p.setAmountTotalCents(amountCents);
@ -63,10 +115,6 @@ public class PaymentService {
p.setStatus(PaymentStatus.requires_payment_method); p.setStatus(PaymentStatus.requires_payment_method);
p = payRepo.saveAndFlush(p); p = payRepo.saveAndFlush(p);
// ANTES:
// String dsOrder = String.format("%012d", p.getId());
// AHORA: timestamp
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
String dsOrder = String.format("%012d", now % 1_000_000_000_000L); String dsOrder = String.format("%012d", now % 1_000_000_000_000L);
@ -74,7 +122,7 @@ public class PaymentService {
payRepo.save(p); payRepo.save(p);
RedsysService.PaymentRequest req = new RedsysService.PaymentRequest(dsOrder, amountCents, RedsysService.PaymentRequest req = new RedsysService.PaymentRequest(dsOrder, amountCents,
"Compra en Imprimelibros", cartId); "Compra en Imprimelibros", cartId, dirFactId);
if ("bizum".equalsIgnoreCase(method)) { if ("bizum".equalsIgnoreCase(method)) {
return redsysService.buildRedirectFormBizum(req); return redsysService.buildRedirectFormBizum(req);
@ -84,7 +132,8 @@ public class PaymentService {
} }
@Transactional @Transactional
public void handleRedsysNotification(String dsSignature, String dsMerchantParameters) throws Exception { public void handleRedsysNotification(String dsSignature, String dsMerchantParameters, Locale locale)
throws Exception {
// 0) Intentamos parsear la notificación. Si falla, registramos el webhook crudo // 0) Intentamos parsear la notificación. Si falla, registramos el webhook crudo
// y salimos. // y salimos.
@ -170,13 +219,20 @@ public class PaymentService {
? PaymentTransactionStatus.succeeded ? PaymentTransactionStatus.succeeded
: PaymentTransactionStatus.failed); : PaymentTransactionStatus.failed);
Object authCode = notif.raw.get("Ds_AuthorisationCode");
String gatewayTxId = null; String gatewayTxId = null;
if (authCode != null) { // 1) Si es Bizum y tenemos Ds_Bizum_IdOper, úsalo como ID único
String trimmed = String.valueOf(authCode).trim(); if (notif.isBizum()
// Redsys devuelve " " (espacios) cuando NO hay código de autorización. && notif.bizumIdOper != null
// Eso lo consideramos "sin ID" → null, para no chocar con el índice único. && !notif.bizumIdOper.isBlank()) {
if (!trimmed.isEmpty()) {
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; gatewayTxId = trimmed;
} }
} }
@ -188,32 +244,33 @@ public class PaymentService {
txRepo.save(tx); txRepo.save(tx);
if (authorized) { if (authorized) {
p.setAuthorizationCode(tx.getGatewayTransactionId()); 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.setStatus(PaymentStatus.captured);
p.setAmountCapturedCents(p.getAmountCapturedCents() + notif.amountCents); p.setAmountCapturedCents(p.getAmountCapturedCents() + notif.amountCents);
p.setAuthorizedAt(LocalDateTime.now()); p.setAuthorizedAt(LocalDateTime.now());
p.setCapturedAt(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 { } else {
p.setStatus(PaymentStatus.failed); p.setStatus(PaymentStatus.failed);
p.setFailedAt(LocalDateTime.now()); p.setFailedAt(LocalDateTime.now());
} pedidoService.markPedidoAsPaymentDenied(p.getOrderId());
if(authorized) {
// GENERAR PEDIDO A PARTIR DEL CARRITO
Cart cart = this.cartService.findById(notif.cartId);
if(cart != null) {
// Bloqueamos el carrito
this.cartService.lockCartById(cart.getId());
// order ID es generado dentro de createOrderFromCart donde se marcan los presupuestos como no editables
// Long orderId = this.cartService.pedidoService.createOrderFromCart(cart.getId(), p.getId());
// p.setOrderId(orderId);
}
} }
payRepo.save(p); payRepo.save(p);
if (!authorized) { if (!authorized) {
ev.setLastError("Payment declined (Ds_Response=" + notif.response + ")"); ev.setLastError("Payment declined (Ds_Response=" + notif.response + ")");
} }
@ -230,9 +287,8 @@ public class PaymentService {
} }
} }
// ---- refundViaRedsys y bank_transfer igual que antes, no tocan RedsysService // ---- refundViaRedsys
// ---- // ----
@Transactional @Transactional
public void refundViaRedsys(Long paymentId, long amountCents, String idempotencyKey) { public void refundViaRedsys(Long paymentId, long amountCents, String idempotencyKey) {
Payment p = payRepo.findById(paymentId) Payment p = payRepo.findById(paymentId)
@ -240,6 +296,7 @@ public class PaymentService {
if (amountCents <= 0) if (amountCents <= 0)
throw new IllegalArgumentException("Importe inválido"); throw new IllegalArgumentException("Importe inválido");
long maxRefundable = p.getAmountCapturedCents() - p.getAmountRefundedCents(); long maxRefundable = p.getAmountCapturedCents() - p.getAmountRefundedCents();
if (amountCents > maxRefundable) if (amountCents > maxRefundable)
throw new IllegalStateException("Importe de devolución supera lo capturado"); throw new IllegalStateException("Importe de devolución supera lo capturado");
@ -256,8 +313,26 @@ public class PaymentService {
r.setRequestedAt(LocalDateTime.now()); r.setRequestedAt(LocalDateTime.now());
r = refundRepo.save(r); r = refundRepo.save(r);
String gatewayRefundId = "REF-" + UUID.randomUUID(); // aquí iría el ID real si alguna vez llamas a un API de String gatewayRefundId;
// devoluciones 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(); PaymentTransaction tx = new PaymentTransaction();
tx.setPayment(p); tx.setPayment(p);
@ -286,19 +361,24 @@ public class PaymentService {
} }
@Transactional @Transactional
public Payment createBankTransferPayment(Long cartId, long amountCents, String currency) { public Payment createBankTransferPayment(Long cartId, Long dirFactId, long amountCents, String currency, Locale locale, Long orderId) {
Payment p = new Payment(); Payment p = new Payment();
p.setOrderId(null); p.setOrderId(null);
Cart cart = this.cartService.findById(cartId); Cart cart = this.cartService.findById(cartId);
if(cart != null && cart.getUserId() != null) { if (cart != null && cart.getUserId() != null) {
p.setUserId(cart.getUserId()); p.setUserId(cart.getUserId());
// Se bloquea el carrito para evitar modificaciones mientras se procesa el pago
this.cartService.lockCartById(cartId);
} }
p.setCurrency(currency); p.setCurrency(currency);
p.setAmountTotalCents(amountCents); p.setAmountTotalCents(amountCents);
p.setGateway("bank_transfer"); p.setGateway("bank_transfer");
p.setStatus(PaymentStatus.requires_action); // pendiente de ingreso p.setStatus(PaymentStatus.requires_action); // pendiente de ingreso
if (orderId != null) {
p.setOrderId(orderId);
}
p = payRepo.save(p); p = payRepo.save(p);
// Crear transacción pendiente // Crear transacción pendiente
@ -308,6 +388,18 @@ public class PaymentService {
tx.setStatus(PaymentTransactionStatus.pending); tx.setStatus(PaymentTransactionStatus.pending);
tx.setAmountCents(amountCents); tx.setAmountCents(amountCents);
tx.setCurrency(currency); 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 // tx.setProcessedAt(null); // la dejas nula hasta que se confirme
txRepo.save(tx); txRepo.save(tx);
@ -315,7 +407,7 @@ public class PaymentService {
} }
@Transactional @Transactional
public void markBankTransferAsCaptured(Long paymentId) { public void markBankTransferAsCaptured(Long paymentId, Locale locale) {
Payment p = payRepo.findById(paymentId) Payment p = payRepo.findById(paymentId)
.orElseThrow(() -> new IllegalArgumentException("Payment no encontrado: " + paymentId)); .orElseThrow(() -> new IllegalArgumentException("Payment no encontrado: " + paymentId));
@ -348,9 +440,122 @@ public class PaymentService {
p.setAmountCapturedCents(p.getAmountTotalCents()); p.setAmountCapturedCents(p.getAmountTotalCents());
p.setCapturedAt(LocalDateTime.now()); p.setCapturedAt(LocalDateTime.now());
p.setStatus(PaymentStatus.captured); 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); 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) { private boolean isRedsysAuthorized(RedsysService.RedsysNotification notif) {
if (notif.response == null) { if (notif.response == null) {
return false; return false;
@ -365,4 +570,5 @@ public class PaymentService {
return code >= 0 && code <= 99; return code >= 0 && code <= 99;
} }
} }

View File

@ -6,9 +6,6 @@ import java.time.LocalDateTime;
@Entity @Entity
@Table( @Table(
name = "payment_transactions", name = "payment_transactions",
uniqueConstraints = {
@UniqueConstraint(name = "uq_tx_gateway_txid", columnNames = {"gateway_transaction_id"})
},
indexes = { indexes = {
@Index(name = "idx_tx_pay", columnList = "payment_id"), @Index(name = "idx_tx_pay", columnList = "payment_id"),
@Index(name = "idx_tx_type_status", columnList = "type,status"), @Index(name = "idx_tx_type_status", columnList = "type,status"),

View File

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

View File

@ -8,11 +8,16 @@ import com.imprimelibros.erp.payments.model.PaymentTransactionType;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface PaymentTransactionRepository extends JpaRepository<PaymentTransaction, Long>, JpaSpecificationExecutor<PaymentTransaction> { public interface PaymentTransactionRepository extends JpaRepository<PaymentTransaction, Long>, JpaSpecificationExecutor<PaymentTransaction> {
Optional<PaymentTransaction> findByGatewayTransactionId(String gatewayTransactionId); List<PaymentTransaction> findByGatewayTransactionId(String gatewayTransactionId);
Optional<PaymentTransaction> findByIdempotencyKey(String idempotencyKey); Optional<PaymentTransaction> findByIdempotencyKey(String idempotencyKey);
Optional<PaymentTransaction> findByPaymentIdAndType(
Long paymentId,
PaymentTransactionType type
);
Optional<PaymentTransaction> findFirstByPaymentIdAndTypeAndStatusOrderByIdDesc( Optional<PaymentTransaction> findFirstByPaymentIdAndTypeAndStatusOrderByIdDesc(
Long paymentId, Long paymentId,
PaymentTransactionType type, PaymentTransactionType type,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,24 @@
package com.imprimelibros.erp.redsys; package com.imprimelibros.erp.redsys;
import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.payments.PaymentService; import com.imprimelibros.erp.payments.PaymentService;
import com.imprimelibros.erp.payments.model.Payment; import com.imprimelibros.erp.payments.model.Payment;
import com.imprimelibros.erp.pedidos.Pedido;
import com.imprimelibros.erp.pedidos.PedidoService;
import com.imprimelibros.erp.redsys.RedsysService.FormPayload; import com.imprimelibros.erp.redsys.RedsysService.FormPayload;
import jakarta.servlet.ServletContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.thymeleaf.context.WebContext;
import org.thymeleaf.web.IWebExchange;
import org.thymeleaf.web.servlet.JakartaServletWebApplication;
import org.thymeleaf.spring6.SpringTemplateEngine;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.context.MessageSource; import org.springframework.context.MessageSource;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@ -14,6 +29,7 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Locale; import java.util.Locale;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
@Controller @Controller
@ -22,37 +38,62 @@ public class RedsysController {
private final PaymentService paymentService; private final PaymentService paymentService;
private final MessageSource messageSource; private final MessageSource messageSource;
private final SpringTemplateEngine templateEngine;
private final ServletContext servletContext;
private final PedidoService pedidoService;
public RedsysController(PaymentService paymentService, MessageSource messageSource) { public RedsysController(PaymentService paymentService, MessageSource messageSource,
SpringTemplateEngine templateEngine, ServletContext servletContext,
PedidoService pedidoService) {
this.paymentService = paymentService; this.paymentService = paymentService;
this.messageSource = messageSource; this.messageSource = messageSource;
this.templateEngine = templateEngine;
this.servletContext = servletContext;
this.pedidoService = pedidoService;
} }
@PostMapping(value = "/crear", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) @PostMapping(value = "/crear", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
@ResponseBody @ResponseBody
public ResponseEntity<byte[]> crearPago(@RequestParam("amountCents") Long amountCents, public ResponseEntity<byte[]> crearPago(@RequestParam("amountCents") Long amountCents,
@RequestParam("method") String method, @RequestParam("cartId") Long cartId) throws Exception { @RequestParam("method") String method, @RequestParam("cartId") Long cartId,
@RequestParam(value = "dirFactId", required = false) Long dirFactId,
HttpServletRequest request,
HttpServletResponse response, Locale locale)
throws Exception {
// Creamos el pedido inteno
Pedido order = pedidoService.crearPedido(cartId, dirFactId, null, null);
if ("bank-transfer".equalsIgnoreCase(method)) { if ("bank-transfer".equalsIgnoreCase(method)) {
// 1) Creamos el Payment interno SIN orderId (null)
Payment p = paymentService.createBankTransferPayment(cartId, amountCents, "EUR");
// 2) Mostramos instrucciones de transferencia // 1) Creamos el Payment interno SIN orderId (null)
String html = """ Payment p = paymentService.createBankTransferPayment(cartId, dirFactId, amountCents, "EUR", locale,
<html><head><meta charset="utf-8"><title>Pago por transferencia</title></head> order.getId());
<body>
<h2>Pago por transferencia bancaria</h2> pedidoService.markPedidoAsProcesingPayment(order.getId());
<p>Hemos registrado tu intención de pedido.</p>
<p><strong>Importe:</strong> %s €</p> // 1⃣ Crear la "aplicación" web de Thymeleaf (Jakarta)
<p><strong>IBAN:</strong> ES00 1234 5678 9012 3456 7890</p> JakartaServletWebApplication app = JakartaServletWebApplication.buildApplication(servletContext);
<p><strong>Concepto:</strong> TRANSF-%d</p>
<p>En cuanto recibamos la transferencia, procesaremos tu pedido.</p> // 2⃣ Construir el intercambio web desde request/response
<p><a href="/checkout/resumen">Volver al resumen</a></p> response.setContentType("text/html;charset=UTF-8");
</body></html> response.setCharacterEncoding("UTF-8");
""".formatted( IWebExchange exchange = app.buildExchange(request, response);
String.format("%.2f", amountCents / 100.0),
p.getId() // usamos el ID del Payment como referencia // 3⃣ Crear el contexto WebContext con Locale
); WebContext ctx = new WebContext(exchange, locale);
String importeFormateado = Utils.formatCurrency(amountCents / 100.0, locale);
ctx.setVariable("importe", importeFormateado);
ctx.setVariable("concepto", "TRANSF-" + p.getOrderId());
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
boolean isAuth = auth != null
&& auth.isAuthenticated()
&& !(auth instanceof AnonymousAuthenticationToken);
ctx.setVariable("isAuth", isAuth);
// 3) Renderizamos la plantilla a HTML
String html = templateEngine.process("imprimelibros/pagos/transfer", ctx);
byte[] body = html.getBytes(StandardCharsets.UTF_8); byte[] body = html.getBytes(StandardCharsets.UTF_8);
return ResponseEntity.ok() return ResponseEntity.ok()
@ -61,7 +102,104 @@ public class RedsysController {
} }
// Tarjeta o Bizum (Redsys) // Tarjeta o Bizum (Redsys)
FormPayload form = paymentService.createRedsysPayment(cartId, amountCents, "EUR", method); FormPayload form = paymentService.createRedsysPayment(cartId, dirFactId, amountCents, "EUR", method,
order.getId());
String html = """
<html><head><meta charset="utf-8"><title>Redirigiendo a Redsys…</title></head>
<body onload="document.forms[0].submit()">
<form action="%s" method="post">
<input type="hidden" name="Ds_SignatureVersion" value="%s"/>
<input type="hidden" name="Ds_MerchantParameters" value="%s"/>
<input type="hidden" name="Ds_Signature" value="%s"/>
<input type="hidden" name="cartId" value="%d"/>
<noscript>
<p>Haz clic en pagar para continuar</p>
<button type="submit">Pagar</button>
</noscript>
</form>
</body></html>
""".formatted(
form.action(),
form.signatureVersion(),
form.merchantParameters(),
form.signature(), cartId);
byte[] body = html.getBytes(StandardCharsets.UTF_8);
return ResponseEntity.ok()
.contentType(MediaType.TEXT_HTML)
.body(body);
}
@PostMapping(value = "/reintentar", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
@ResponseBody
public ResponseEntity<byte[]> reintentarPago(@RequestParam("amountCents") Long amountCents,
@RequestParam("method") String method, @RequestParam("orderId") Long orderId,
HttpServletRequest request,
HttpServletResponse response, Locale locale)
throws Exception {
// Creamos el pedido inteno
Pedido order = pedidoService.findById(orderId);
// Find the payment with orderId = order.getId() and status = failed
Payment failedPayment = paymentService.findFailedPaymentByOrderId(order.getId());
if (failedPayment == null) {
throw new Exception("No se encontró un pago fallido para el pedido " + order.getId());
}
Long cartId = null;
Long dirFactId = null;
// Find payment transaction details from failedPayment if needed
try {
Map<String, Long> transactionDetails = paymentService.getPaymentTransactionData(failedPayment.getId());
cartId = transactionDetails.get("cartId");
dirFactId = transactionDetails.get("dirFactId");
} catch (Exception e) {
throw new Exception(
"No se pudieron obtener los detalles de la transacción para el pago " + failedPayment.getId());
}
if ("bank-transfer".equalsIgnoreCase(method)) {
// 1) Creamos el Payment interno SIN orderId (null)
Payment p = paymentService.createBankTransferPayment(cartId, dirFactId, amountCents, "EUR", locale,
order.getId());
pedidoService.markPedidoAsProcesingPayment(order.getId());
// 1⃣ Crear la "aplicación" web de Thymeleaf (Jakarta)
JakartaServletWebApplication app = JakartaServletWebApplication.buildApplication(servletContext);
// 2⃣ Construir el intercambio web desde request/response
response.setContentType("text/html;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
IWebExchange exchange = app.buildExchange(request, response);
// 3⃣ Crear el contexto WebContext con Locale
WebContext ctx = new WebContext(exchange, locale);
String importeFormateado = Utils.formatCurrency(amountCents / 100.0, locale);
ctx.setVariable("importe", importeFormateado);
ctx.setVariable("concepto", "TRANSF-" + p.getOrderId());
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
boolean isAuth = auth != null
&& auth.isAuthenticated()
&& !(auth instanceof AnonymousAuthenticationToken);
ctx.setVariable("isAuth", isAuth);
// 3) Renderizamos la plantilla a HTML
String html = templateEngine.process("imprimelibros/pagos/transfer", ctx);
byte[] body = html.getBytes(StandardCharsets.UTF_8);
return ResponseEntity.ok()
.contentType(MediaType.TEXT_HTML)
.body(body);
}
// Tarjeta o Bizum (Redsys)
FormPayload form = paymentService.createRedsysPayment(cartId, dirFactId, amountCents, "EUR", method,
order.getId());
String html = """ String html = """
<html><head><meta charset="utf-8"><title>Redirigiendo a Redsys…</title></head> <html><head><meta charset="utf-8"><title>Redirigiendo a Redsys…</title></head>
@ -92,10 +230,7 @@ public class RedsysController {
// GET: cuando el usuario cae aquí sin parámetros, o Redsys redirige por GET // GET: cuando el usuario cae aquí sin parámetros, o Redsys redirige por GET
@GetMapping("/ok") @GetMapping("/ok")
public String okGet(RedirectAttributes redirectAttrs, Model model, Locale locale) { public String okGet(RedirectAttributes redirectAttrs, Model model, Locale locale) {
String msg = messageSource.getMessage("checkout.success.payment", null, "Pago realizado con éxito. Gracias por su compra.", locale); return "imprimelibros/pagos/pago-ok";
model.addAttribute("successPago", msg);
redirectAttrs.addFlashAttribute("successPago", msg);
return "redirect:/cart";
} }
// POST: si Redsys envía Ds_Signature y Ds_MerchantParameters (muchas // POST: si Redsys envía Ds_Signature y Ds_MerchantParameters (muchas
@ -103,10 +238,10 @@ public class RedsysController {
@PostMapping(value = "/ok", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) @PostMapping(value = "/ok", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
@ResponseBody @ResponseBody
public ResponseEntity<String> okPost(@RequestParam("Ds_Signature") String signature, public ResponseEntity<String> okPost(@RequestParam("Ds_Signature") String signature,
@RequestParam("Ds_MerchantParameters") String merchantParameters) { @RequestParam("Ds_MerchantParameters") String merchantParameters, Locale locale) {
try { try {
// opcional: idempotente, si /notify ya ha hecho el trabajo no pasa nada // opcional: idempotente, si /notify ya ha hecho el trabajo no pasa nada
paymentService.handleRedsysNotification(signature, merchantParameters); paymentService.handleRedsysNotification(signature, merchantParameters, locale);
return ResponseEntity.ok("<h2>Pago realizado correctamente</h2><a href=\"/cart\">Volver</a>"); return ResponseEntity.ok("<h2>Pago realizado correctamente</h2><a href=\"/cart\">Volver</a>");
} catch (Exception e) { } catch (Exception e) {
return ResponseEntity.badRequest() return ResponseEntity.badRequest()
@ -117,7 +252,9 @@ public class RedsysController {
@GetMapping("/ko") @GetMapping("/ko")
public String koGet(RedirectAttributes redirectAttrs, Model model, Locale locale) { public String koGet(RedirectAttributes redirectAttrs, Model model, Locale locale) {
String msg = messageSource.getMessage("checkout.error.payment", null, "Error al procesar el pago: el pago ha sido cancelado o rechazado Por favor, inténtelo de nuevo.", locale); String msg = messageSource.getMessage("checkout.error.payment", null,
"Error al procesar el pago: el pago ha sido cancelado o rechazado Por favor, inténtelo de nuevo.",
locale);
model.addAttribute("errorPago", msg); model.addAttribute("errorPago", msg);
redirectAttrs.addFlashAttribute("errorPago", msg); redirectAttrs.addFlashAttribute("errorPago", msg);
return "redirect:/cart"; return "redirect:/cart";
@ -127,11 +264,11 @@ public class RedsysController {
@ResponseBody @ResponseBody
public ResponseEntity<String> koPost( public ResponseEntity<String> koPost(
@RequestParam("Ds_Signature") String signature, @RequestParam("Ds_Signature") String signature,
@RequestParam("Ds_MerchantParameters") String merchantParameters) { @RequestParam("Ds_MerchantParameters") String merchantParameters, Locale locale) {
try { try {
// Procesamos la notificación IGUAL que en /ok y /notify // Procesamos la notificación IGUAL que en /ok y /notify
paymentService.handleRedsysNotification(signature, merchantParameters); paymentService.handleRedsysNotification(signature, merchantParameters, locale);
// Mensaje para el usuario (pago cancelado/rechazado) // Mensaje para el usuario (pago cancelado/rechazado)
String html = "<h2>Pago cancelado o rechazado</h2><a href=\"/checkout\">Volver</a>"; String html = "<h2>Pago cancelado o rechazado</h2><a href=\"/checkout\">Volver</a>";
@ -146,9 +283,9 @@ public class RedsysController {
@PostMapping(value = "/notify", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) @PostMapping(value = "/notify", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
@ResponseBody @ResponseBody
public String notifyRedsys(@RequestParam("Ds_Signature") String signature, public String notifyRedsys(@RequestParam("Ds_Signature") String signature,
@RequestParam("Ds_MerchantParameters") String merchantParameters) { @RequestParam("Ds_MerchantParameters") String merchantParameters, Locale locale) {
try { try {
paymentService.handleRedsysNotification(signature, merchantParameters); paymentService.handleRedsysNotification(signature, merchantParameters, locale);
return "OK"; return "OK";
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); // 👈 para ver el motivo del 500 en logs e.printStackTrace(); // 👈 para ver el motivo del 500 en logs
@ -163,9 +300,9 @@ public class RedsysController {
try { try {
String idem = "refund-" + paymentId + "-" + amountCents + "-" + UUID.randomUUID(); String idem = "refund-" + paymentId + "-" + amountCents + "-" + UUID.randomUUID();
paymentService.refundViaRedsys(paymentId, amountCents, idem); paymentService.refundViaRedsys(paymentId, amountCents, idem);
return ResponseEntity.ok("Refund solicitado"); return ResponseEntity.ok("{\"success\":true}");
} catch (Exception e) { } catch (Exception e) {
return ResponseEntity.badRequest().body("Error refund: " + e.getMessage()); return ResponseEntity.badRequest().body("{\"success\":false, \"error\": \"" + e.getMessage() + "\"}");
} }
} }
} }

View File

@ -8,6 +8,10 @@ import sis.redsys.api.ApiMacSha256;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.util.Base64; import java.util.Base64;
@ -18,6 +22,10 @@ import java.util.Objects;
public class RedsysService { public class RedsysService {
// ---------- CONFIG ---------- // ---------- CONFIG ----------
@Value("${redsys.url}")
private String url;
@Value("${redsys.refund.url}")
private String urlRefund;
@Value("${redsys.merchant-code}") @Value("${redsys.merchant-code}")
private String merchantCode; private String merchantCode;
@Value("${redsys.terminal}") @Value("${redsys.terminal}")
@ -37,9 +45,11 @@ public class RedsysService {
@Value("${redsys.environment}") @Value("${redsys.environment}")
private String env; private String env;
private final HttpClient httpClient = HttpClient.newHttpClient();
// ---------- RECORDS ---------- // ---------- RECORDS ----------
// Pedido a Redsys // Pedido a Redsys
public record PaymentRequest(String order, long amountCents, String description, Long cartId) { public record PaymentRequest(String order, long amountCents, String description, Long cartId, Long dirFactId) {
} }
// Payload para el formulario // Payload para el formulario
@ -74,7 +84,10 @@ public class RedsysService {
// Si tu PaymentRequest no lo lleva todavía, puedes pasarlo en description o // Si tu PaymentRequest no lo lleva todavía, puedes pasarlo en description o
// crear otro campo. // crear otro campo.
JSONObject ctx = new JSONObject(); JSONObject ctx = new JSONObject();
ctx.put("cartId", req.cartId()); // o req.cartId() si decides añadirlo al record ctx.put("cartId", req.cartId());
if (req.dirFactId() != null) {
ctx.put("dirFactId", req.dirFactId());
}
api.setParameter("DS_MERCHANT_MERCHANTDATA", ctx.toString()); api.setParameter("DS_MERCHANT_MERCHANTDATA", ctx.toString());
if (req.description() != null && !req.description().isBlank()) { if (req.description() != null && !req.description().isBlank()) {
@ -89,9 +102,11 @@ public class RedsysService {
String merchantParameters = api.createMerchantParameters(); String merchantParameters = api.createMerchantParameters();
String signature = api.createMerchantSignature(secretKeyBase64); String signature = api.createMerchantSignature(secretKeyBase64);
String action = "test".equalsIgnoreCase(env) String action = url;
? "https://sis-t.redsys.es:25443/sis/realizarPago" /*
: "https://sis.redsys.es/sis/realizarPago"; * ? "https://sis-t.redsys.es:25443/sis/realizarPago"
* : "https://sis.redsys.es/sis/realizarPago";
*/
return new FormPayload(action, "HMAC_SHA256_V1", merchantParameters, signature); return new FormPayload(action, "HMAC_SHA256_V1", merchantParameters, signature);
} }
@ -183,6 +198,10 @@ public class RedsysService {
public final long amountCents; public final long amountCents;
public final String currency; public final String currency;
public final Long cartId; public final Long cartId;
public final Long dirFactId;
public final String processedPayMethod; // Ds_ProcessedPayMethod
public final String bizumIdOper; // Ds_Bizum_IdOper
public final String authorisationCode; // Ds_AuthorisationCode
public RedsysNotification(Map<String, Object> raw) { public RedsysNotification(Map<String, Object> raw) {
this.raw = raw; this.raw = raw;
@ -191,6 +210,10 @@ public class RedsysService {
this.currency = str(raw.get("Ds_Currency")); this.currency = str(raw.get("Ds_Currency"));
this.amountCents = parseLongSafe(raw.get("Ds_Amount")); this.amountCents = parseLongSafe(raw.get("Ds_Amount"));
this.cartId = extractCartId(raw.get("Ds_MerchantData")); this.cartId = extractCartId(raw.get("Ds_MerchantData"));
this.dirFactId = extractDirFactId(raw.get("Ds_MerchantData"));
this.processedPayMethod = str(raw.get("Ds_ProcessedPayMethod"));
this.bizumIdOper = str(raw.get("Ds_Bizum_IdOper"));
this.authorisationCode = str(raw.get("Ds_AuthorisationCode"));
} }
private static Long extractCartId(Object merchantDataObj) { private static Long extractCartId(Object merchantDataObj) {
@ -210,6 +233,24 @@ public class RedsysService {
} }
} }
private static Long extractDirFactId(Object merchantDataObj) {
if (merchantDataObj == null)
return null;
try {
String json = String.valueOf(merchantDataObj);
// 👇 DES-ESCAPAR las comillas HTML que vienen de Redsys
json = json.replace("&#34;", "\"");
org.json.JSONObject ctx = new org.json.JSONObject(json);
long v = ctx.optLong("dirFactId", 0L);
return v != 0L ? v : null;
} catch (Exception e) {
e.printStackTrace(); // te ayudará si vuelve a fallar
return null;
}
}
public boolean authorized() { public boolean authorized() {
try { try {
int r = Integer.parseInt(response); int r = Integer.parseInt(response);
@ -219,6 +260,11 @@ public class RedsysService {
} }
} }
public boolean isBizum() {
// Redsys suele usar 68 para Bizum; ajustable si tu banco usa otro código.
return "68".equals(processedPayMethod);
}
private static String str(Object o) { private static String str(Object o) {
return o == null ? null : String.valueOf(o); return o == null ? null : String.valueOf(o);
} }
@ -231,4 +277,100 @@ public class RedsysService {
} }
} }
} }
/**
* Solicita a Redsys una devolución (TransactionType = 3)
*
* @param order El mismo Ds_Merchant_Order que se usó en el cobro.
* @param amountCents Importe en céntimos a devolver.
* @return gatewayRefundId (p.ej. Ds_AuthorisationCode o Ds_Order)
*/
public String requestRefund(String order, long amountCents) throws Exception {
ApiMacSha256 api = new ApiMacSha256();
// Montar parámetros para el refund
api.setParameter("DS_MERCHANT_MERCHANTCODE", merchantCode);
api.setParameter("DS_MERCHANT_TERMINAL", terminal);
api.setParameter("DS_MERCHANT_ORDER", order);
api.setParameter("DS_MERCHANT_AMOUNT", String.valueOf(amountCents));
api.setParameter("DS_MERCHANT_CURRENCY", currency);
api.setParameter("DS_MERCHANT_TRANSACTIONTYPE", "3"); // 3 = devolución
api.setParameter("DS_MERCHANT_MERCHANTURL", "");
api.setParameter("DS_MERCHANT_URLOK", "");
api.setParameter("DS_MERCHANT_URLKO", "");
// Crear parámetros y firma (como en tu PHP)
String merchantParameters = api.createMerchantParameters();
String signature = api.createMerchantSignature(secretKeyBase64);
// Montar el JSON para Redsys REST
String json = """
{
"Ds_MerchantParameters": "%s",
"Ds_Signature": "%s",
"Ds_SignatureVersion": "HMAC_SHA256_V1"
}
""".formatted(merchantParameters, signature);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(urlRefund))
.header("Content-Type", "application/json; charset=UTF-8")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() / 100 != 2)
throw new IllegalStateException("HTTP error Redsys refund: " + response.statusCode());
if (response.body() == null || response.body().isBlank())
throw new IllegalStateException("Respuesta vacía de Redsys refund REST");
// Parsear la respuesta JSON
Map<String, Object> respMap = MAPPER.readValue(response.body(), new TypeReference<>() {
});
// Redsys puede devolver "Ds_MerchantParameters" o "errorCode"
if (respMap.containsKey("errorCode")) {
throw new IllegalStateException("Error Redsys refund: " + respMap.get("errorCode"));
}
String dsMerchantParametersResp = (String) respMap.get("Ds_MerchantParameters");
if (dsMerchantParametersResp == null) {
throw new IllegalStateException("Respuesta Redsys refund sin Ds_MerchantParameters");
}
// Decodificar MerchantParameters de la respuesta
Map<String, Object> decoded = decodeMerchantParametersToMap(dsMerchantParametersResp);
String dsResponse = String.valueOf(decoded.get("Ds_Response"));
if (dsResponse == null) {
throw new IllegalStateException("Respuesta Redsys refund sin Ds_Response");
}
int code;
try {
code = Integer.parseInt(dsResponse);
} catch (NumberFormatException e) {
throw new IllegalStateException("Código Ds_Response no numérico en refund: " + dsResponse, e);
}
// ✅ Consideramos OK: 099 (éxito típico) o 900 (0900)
boolean ok = (code >= 0 && code <= 99) || code == 900;
if (!ok) {
throw new IllegalStateException("Devolución rechazada, Ds_Response=" + dsResponse);
}
// Devolvemos algún identificador razonable para la transacción de refund
Object authCodeObj = decoded.get("Ds_AuthorisationCode");
String authCode = authCodeObj != null ? String.valueOf(authCodeObj).trim() : null;
if (authCode == null || authCode.isEmpty()) {
// Fallback: usa el Ds_Order original como ID de refund
return order;
}
return authCode;
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
spring.application.name=erp spring.application.name=erp
# Active profile # Active profile
spring.profiles.active=dev #spring.profiles.active=dev
#spring.profiles.active=test spring.profiles.active=test
#spring.profiles.active=prod #spring.profiles.active=prod
@ -19,15 +19,6 @@ spring.jpa.show-sql=false
# Hibernate Timezone # Hibernate Timezone
spring.jpa.properties.hibernate.jdbc.time_zone=UTC spring.jpa.properties.hibernate.jdbc.time_zone=UTC
# Mensajes de error mas cortos
# Oculta el stack trace en los errores del servidor
server.error.include-stacktrace=never
# No mostrar el mensaje completo de excepción en la respuesta
server.error.include-message=always
# #
# Resource chain # Resource chain
# Activa el resource chain y versionado por contenido # Activa el resource chain y versionado por contenido
@ -106,5 +97,3 @@ redsys.currency=978
redsys.transaction-type=0 redsys.transaction-type=0
redsys.secret-key=sq7HjrUOBfKmC576ILgskD5srU870gJ7 redsys.secret-key=sq7HjrUOBfKmC576ILgskD5srU870gJ7

View File

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

View File

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

View File

@ -26,3 +26,22 @@ databaseChangeLog:
sql: | sql: |
CREATE UNIQUE INDEX uq_carts_user_active CREATE UNIQUE INDEX uq_carts_user_active
ON carts (user_id, active_flag); ON carts (user_id, active_flag);
rollback:
# 🔙 1) Eliminar el índice nuevo basado en active_flag
- sql:
sql: |
ALTER TABLE carts
DROP INDEX uq_carts_user_active;
# 🔙 2) Eliminar la columna generada active_flag
- sql:
sql: |
ALTER TABLE carts
DROP COLUMN active_flag;
# 🔙 3) Restaurar el índice único original (user_id, status)
- sql:
sql: |
CREATE UNIQUE INDEX uq_carts_user_active
ON carts (user_id, status);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,37 @@
databaseChangeLog:
- changeSet:
id: 0016-fix-enum-estado-pedidos-lineas
author: jjo
changes:
# 1) Convertir valores existentes "maquetación" → "maquetacion"
- update:
tableName: pedidos_lineas
columns:
- column:
name: estado
value: "maquetacion"
where: "estado = 'maquetación'"
# 2) Cambiar ENUM quitando tilde
- modifyDataType:
tableName: pedidos_lineas
columnName: estado
newDataType: "ENUM('aprobado','maquetacion','haciendo_ferro','producción','terminado','cancelado')"
rollback:
# 1) Volver a convertir "maquetacion" → "maquetación"
- update:
tableName: pedidos_lineas
columns:
- column:
name: estado
value: "maquetación"
where: "estado = 'maquetacion'"
# 2) Restaurar ENUM original
- modifyDataType:
tableName: pedidos_lineas
columnName: estado
newDataType: "ENUM('aprobado','maquetación','haciendo_ferro','producción','terminado','cancelado')"

View File

@ -0,0 +1,19 @@
databaseChangeLog:
- changeSet:
id: add-fecha-entrega-to-pedidos-lineas
author: jjo
changes:
- addColumn:
tableName: pedidos_lineas
columns:
- column:
name: fecha_entrega
type: datetime
constraints:
nullable: true
afterColumn: estado_manual
rollback:
- dropColumn:
tableName: pedidos_lineas
columnName: fecha_entrega

View File

@ -0,0 +1,38 @@
databaseChangeLog:
- changeSet:
id: 0018-change-presupuesto-ch-3
author: jjo
preConditions:
- onFail: MARK_RAN
- onError: HALT
- dbms:
type: mysql
- sqlCheck:
expectedResult: 1
sql: |
SELECT CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END
FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE()
AND TABLE_NAME = 'presupuesto'
AND CONSTRAINT_NAME = 'presupuesto_chk_3'
AND CONSTRAINT_TYPE = 'CHECK';
changes:
- sql:
dbms: mysql
splitStatements: false
stripComments: true
sql: |
ALTER TABLE presupuesto
DROP CHECK presupuesto_chk_3;
rollback:
- sql:
dbms: mysql
splitStatements: false
stripComments: true
sql: |
ALTER TABLE presupuesto
ADD CONSTRAINT presupuesto_chk_3
CHECK (tipo_cubierta BETWEEN 0 AND 2);

View File

@ -0,0 +1,32 @@
databaseChangeLog:
- changeSet:
id: 0019-add-estados-pago-to-pedidos-lineas
author: jjo
changes:
- modifyDataType:
tableName: pedidos_lineas
columnName: estado
newDataType: >
enum(
'pendiente_pago',
'procesando_pago',
'aprobado',
'maquetacion',
'haciendo_ferro',
'produccion',
'terminado',
'cancelado'
)
rollback:
- modifyDataType:
tableName: pedidos_lineas
columnName: estado
newDataType: >
enum(
'aprobado',
'maquetacion',
'haciendo_ferro',
'produccion',
'terminado',
'cancelado'
)

View File

@ -0,0 +1,35 @@
databaseChangeLog:
- changeSet:
id: 0020-add-estados-pago-to-pedidos-lineas-2
author: jjo
changes:
- modifyDataType:
tableName: pedidos_lineas
columnName: estado
newDataType: >
enum(
'pendiente_pago',
'procesando_pago',
'denegado_pago',
'aprobado',
'maquetacion',
'haciendo_ferro',
'produccion',
'terminado',
'cancelado'
)
rollback:
- modifyDataType:
tableName: pedidos_lineas
columnName: estado
newDataType: >
enum(
'pendiente_pago',
'procesando_pago',
'aprobado',
'maquetacion',
'haciendo_ferro',
'produccion',
'terminado',
'cancelado'
)

View File

@ -0,0 +1,23 @@
databaseChangeLog:
- changeSet:
id: 0021-add-email-and-is-palets-to-pedidos-direcciones
author: jjo
changes:
- sql:
dbms: mysql
splitStatements: false
stripComments: true
sql: >
ALTER TABLE pedidos_direcciones
ADD COLUMN is_palets TINYINT(1) NOT NULL DEFAULT 0 AFTER identificacion_fiscal,
ADD COLUMN email VARCHAR(255) NULL AFTER is_ejemplar_prueba;
rollback:
- sql:
dbms: mysql
splitStatements: false
stripComments: true
sql: >
ALTER TABLE pedidos_direcciones
DROP COLUMN is_palets,
DROP COLUMN email;

View File

@ -0,0 +1,37 @@
databaseChangeLog:
- changeSet:
id: 0022-add-estados-pago-to-pedidos-lineas-3
author: jjo
changes:
- modifyDataType:
tableName: pedidos_lineas
columnName: estado
newDataType: >
enum(
'pendiente_pago',
'procesando_pago',
'denegado_pago',
'aprobado',
'maquetacion',
'haciendo_ferro',
'esperando_aceptacion_ferro',
'produccion',
'terminado',
'cancelado'
)
rollback:
- modifyDataType:
tableName: pedidos_lineas
columnName: estado
newDataType: >
enum(
'pendiente_pago',
'procesando_pago',
'denegado_pago',
'aprobado',
'maquetacion',
'haciendo_ferro',
'produccion',
'terminado',
'cancelado'
)

View File

@ -0,0 +1,407 @@
databaseChangeLog:
- changeSet:
id: 20251230-01-pedidos-lineas-enviado
author: jjo
changes:
- modifyDataType:
tableName: pedidos_lineas
columnName: estado
newDataType: >
ENUM(
'pendiente_pago',
'procesando_pago',
'denegado_pago',
'aprobado',
'maquetacion',
'haciendo_ferro',
'esperando_aceptacion_ferro',
'produccion',
'terminado',
'enviado',
'cancelado'
)
rollback:
- modifyDataType:
tableName: pedidos_lineas
columnName: estado
newDataType: >
ENUM(
'pendiente_pago',
'procesando_pago',
'denegado_pago',
'aprobado',
'maquetacion',
'haciendo_ferro',
'esperando_aceptacion_ferro',
'produccion',
'terminado',
'cancelado'
)
# -------------------------------------------------
- changeSet:
id: 20251230-02-series-facturas
author: jjo
changes:
- createTable:
tableName: series_facturas
columns:
- column:
name: id
type: BIGINT
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: nombre_serie
type: VARCHAR(100)
constraints:
nullable: false
- column:
name: prefijo
type: VARCHAR(10)
constraints:
nullable: false
- column:
name: tipo
type: ENUM('facturacion')
defaultValue: facturacion
- column:
name: numero_actual
type: BIGINT
defaultValueNumeric: 1
- column:
name: created_at
type: TIMESTAMP
- column:
name: updated_at
type: TIMESTAMP
- column:
name: deleted_at
type: TIMESTAMP
- column:
name: created_by
type: BIGINT
- column:
name: updated_by
type: BIGINT
- column:
name: deleted_by
type: BIGINT
- addForeignKeyConstraint:
constraintName: fk_series_facturas_created_by
baseTableName: series_facturas
baseColumnNames: created_by
referencedTableName: users
referencedColumnNames: id
onDelete: SET NULL
- addForeignKeyConstraint:
constraintName: fk_series_facturas_updated_by
baseTableName: series_facturas
baseColumnNames: updated_by
referencedTableName: users
referencedColumnNames: id
onDelete: SET NULL
- addForeignKeyConstraint:
constraintName: fk_series_facturas_deleted_by
baseTableName: series_facturas
baseColumnNames: deleted_by
referencedTableName: users
referencedColumnNames: id
onDelete: SET NULL
rollback:
- dropTable:
tableName: series_facturas
# -------------------------------------------------
- changeSet:
id: 20251230-03-facturas
author: jjo
changes:
- createTable:
tableName: facturas
columns:
- column:
name: id
type: BIGINT
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: pedido_id
type: BIGINT
- column:
name: factura_rectificada_id
type: BIGINT
- column:
name: factura_rectificativa_id
type: BIGINT
- column:
name: cliente_id
type: BIGINT
- column:
name: serie_id
type: BIGINT
- column:
name: numero_factura
type: VARCHAR(50)
- column:
name: estado
type: ENUM('borrador','validada')
defaultValue: borrador
- column:
name: estado_pago
type: ENUM('pendiente','pagada','cancelada')
defaultValue: pendiente
- column:
name: tipo_pago
type: ENUM('tpv_tarjeta','tpv_bizum','transferencia','otros')
defaultValue: otros
- column:
name: fecha_emision
type: DATETIME
- column:
name: base_imponible
type: DECIMAL(10,2)
- column:
name: iva_4
type: DECIMAL(10,2)
- column:
name: iva_21
type: DECIMAL(10,2)
- column:
name: total_factura
type: DECIMAL(10,2)
- column:
name: total_pagado
type: DECIMAL(10,2)
defaultValueNumeric: 0.00
- column:
name: notas
type: TEXT
- column:
name: created_at
type: TIMESTAMP
- column:
name: updated_at
type: TIMESTAMP
- column:
name: deleted_at
type: TIMESTAMP
- column:
name: created_by
type: BIGINT
- column:
name: updated_by
type: BIGINT
- column:
name: deleted_by
type: BIGINT
- addUniqueConstraint:
constraintName: uq_facturas_numero_factura
tableName: facturas
columnNames: numero_factura
- addForeignKeyConstraint:
constraintName: fk_facturas_pedido
baseTableName: facturas
baseColumnNames: pedido_id
referencedTableName: pedidos
referencedColumnNames: id
- addForeignKeyConstraint:
constraintName: fk_facturas_cliente
baseTableName: facturas
baseColumnNames: cliente_id
referencedTableName: users
referencedColumnNames: id
- addForeignKeyConstraint:
constraintName: fk_facturas_serie
baseTableName: facturas
baseColumnNames: serie_id
referencedTableName: series_facturas
referencedColumnNames: id
- addForeignKeyConstraint:
constraintName: fk_facturas_rectificada
baseTableName: facturas
baseColumnNames: factura_rectificada_id
referencedTableName: facturas
referencedColumnNames: id
- addForeignKeyConstraint:
constraintName: fk_facturas_rectificativa
baseTableName: facturas
baseColumnNames: factura_rectificativa_id
referencedTableName: facturas
referencedColumnNames: id
rollback:
- dropTable:
tableName: facturas
# -------------------------------------------------
- changeSet:
id: 20251230-04-facturas-lineas
author: jjo
changes:
- createTable:
tableName: facturas_lineas
columns:
- column:
name: id
type: BIGINT
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: factura_id
type: BIGINT
- column:
name: descripcion
type: TEXT
- column:
name: cantidad
type: INT
- column:
name: base_linea
type: DECIMAL(10,2)
- column:
name: iva_4_linea
type: DECIMAL(10,2)
- column:
name: iva_21_linea
type: DECIMAL(10,2)
- column:
name: total_linea
type: DECIMAL(10,2)
- column:
name: created_at
type: TIMESTAMP
- column:
name: updated_at
type: TIMESTAMP
- column:
name: deleted_at
type: TIMESTAMP
- column:
name: created_by
type: BIGINT
- column:
name: updated_by
type: BIGINT
- column:
name: deleted_by
type: BIGINT
- addForeignKeyConstraint:
constraintName: fk_facturas_lineas_factura
baseTableName: facturas_lineas
baseColumnNames: factura_id
referencedTableName: facturas
referencedColumnNames: id
rollback:
- dropTable:
tableName: facturas_lineas
# -------------------------------------------------
- changeSet:
id: 20251230-05-facturas-pagos
author: jjo
changes:
- createTable:
tableName: facturas_pagos
columns:
- column:
name: id
type: BIGINT
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: factura_id
type: BIGINT
- column:
name: metodo_pago
type: ENUM('tpv_tarjeta','tpv_bizum','transferencia','otros')
defaultValue: otros
- column:
name: cantidad_pagada
type: DECIMAL(10,2)
- column:
name: fecha_pago
type: DATETIME
- column:
name: notas
type: TEXT
- column:
name: created_at
type: TIMESTAMP
- column:
name: updated_at
type: TIMESTAMP
- column:
name: deleted_at
type: TIMESTAMP
- column:
name: created_by
type: BIGINT
- column:
name: updated_by
type: BIGINT
- column:
name: deleted_by
type: BIGINT
- addForeignKeyConstraint:
constraintName: fk_facturas_pagos_factura
baseTableName: facturas_pagos
baseColumnNames: factura_id
referencedTableName: facturas
referencedColumnNames: id
rollback:
- dropTable:
tableName: facturas_pagos

View File

@ -0,0 +1,67 @@
databaseChangeLog:
- changeSet:
id: 0024-series-facturacion-seeder
author: jjo
context: demo
changes:
# --- SERIES ---
- sql:
splitStatements: true
stripComments: true
sql: |
INSERT INTO series_facturas
(nombre_serie, prefijo, tipo, numero_actual, created_at, updated_at, created_by, updated_by)
SELECT
'IMPRESIÓN DIGITAL', 'IMPR', 'facturacion', 1, NOW(), NOW(), 1, 1
WHERE NOT EXISTS (
SELECT 1 FROM series_facturas WHERE prefijo = 'IMPR'
);
INSERT INTO series_facturas
(nombre_serie, prefijo, tipo, numero_actual, created_at, updated_at, created_by, updated_by)
SELECT
'RECT. IMPRESIÓN DIGITAL', 'REC IL', 'facturacion', 1, NOW(), NOW(), 1, 1
WHERE NOT EXISTS (
SELECT 1 FROM series_facturas WHERE prefijo = 'REC IL'
);
# --- VARIABLES (con el id real de la serie) ---
# serie_facturacion_default -> id de la serie con prefijo IMPR
- sql:
splitStatements: true
stripComments: true
sql: |
INSERT INTO variables (clave, valor)
SELECT
'serie_facturacion_default',
CAST(sf.id AS CHAR)
FROM series_facturas sf
WHERE sf.prefijo = 'IMPR'
LIMIT 1
ON DUPLICATE KEY UPDATE valor = VALUES(valor);
# sere_facturacion_rect_default -> id de la serie con prefijo REC IL
- sql:
splitStatements: true
stripComments: true
sql: |
INSERT INTO variables (clave, valor)
SELECT
'serie_facturacion_rect_default',
CAST(sf.id AS CHAR)
FROM series_facturas sf
WHERE sf.prefijo = 'REC IL'
LIMIT 1
ON DUPLICATE KEY UPDATE valor = VALUES(valor);
rollback:
- sql:
splitStatements: true
stripComments: true
sql: |
DELETE FROM variables
WHERE clave IN ('serie_facturacion_default', 'sere_facturacion_rect_default');
DELETE FROM series_facturas
WHERE prefijo IN ('IMPR', 'REC IL');

View File

@ -0,0 +1,114 @@
databaseChangeLog:
- changeSet:
id: create-facturas-direcciones
author: jjo
changes:
- createTable:
tableName: facturas_direcciones
columns:
- column:
name: id
type: BIGINT
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: factura_id
type: BIGINT
constraints:
nullable: false
- column:
name: unidades
type: MEDIUMINT UNSIGNED
- column:
name: email
type: VARCHAR(255)
- column:
name: att
type: VARCHAR(150)
constraints:
nullable: false
- column:
name: direccion
type: VARCHAR(255)
constraints:
nullable: false
- column:
name: cp
type: MEDIUMINT UNSIGNED
constraints:
nullable: false
- column:
name: ciudad
type: VARCHAR(100)
constraints:
nullable: false
- column:
name: provincia
type: VARCHAR(100)
constraints:
nullable: false
- column:
name: pais_code3
type: CHAR(3)
defaultValue: esp
constraints:
nullable: false
- column:
name: telefono
type: VARCHAR(30)
- column:
name: instrucciones
type: VARCHAR(255)
- column:
name: razon_social
type: VARCHAR(150)
- column:
name: tipo_identificacion_fiscal
type: ENUM('DNI','NIE','CIF','Pasaporte','VAT_ID')
defaultValue: DNI
constraints:
nullable: false
- column:
name: identificacion_fiscal
type: VARCHAR(50)
- column:
name: created_at
type: TIMESTAMP
defaultValueComputed: CURRENT_TIMESTAMP
constraints:
nullable: false
- addForeignKeyConstraint:
constraintName: fk_facturas_direcciones_factura
baseTableName: facturas_direcciones
baseColumnNames: factura_id
referencedTableName: facturas
referencedColumnNames: id
onDelete: CASCADE
onUpdate: RESTRICT
rollback:
- dropForeignKeyConstraint:
baseTableName: facturas_direcciones
constraintName: fk_facturas_direcciones_factura
- dropTable:
tableName: facturas_direcciones

View File

@ -15,3 +15,37 @@ databaseChangeLog:
file: db/changelog/changesets/0007-payments-core.yml file: db/changelog/changesets/0007-payments-core.yml
- include: - include:
file: db/changelog/changesets/0008-update-cart-status-constraint.yml file: db/changelog/changesets/0008-update-cart-status-constraint.yml
- include:
file: db/changelog/changesets/0009-add-composite-unique-txid-type.yml
- include:
file: db/changelog/changesets/0010-drop-unique-tx-gateway.yml
- include:
file: db/changelog/changesets/0011-update-pedidos-presupuesto.yml
- include:
file: db/changelog/changesets/0012--drop-unique-gateway-txid-2.yml
- include:
file: db/changelog/changesets/0013-drop-unique-refund-gateway-id.yml
- include:
file: db/changelog/changesets/0014-create-pedidos-direcciones.yml
- include:
file: db/changelog/changesets/0015-alter-pedidos-lineas-and-presupuesto-estados.yml
- include:
file: db/changelog/changesets/0016-fix-enum-estado-pedidos-lineas.yml
- include:
file: db/changelog/changesets/0017-add-fecha-entrega-to-pedidos-lineas.yml
- include:
file: db/changelog/changesets/0018-change-presupuesto-ch-3.yml
- include:
file: db/changelog/changesets/0019-add-estados-pago-to-pedidos-lineas.yml
- include:
file: db/changelog/changesets/0020-add-estados-pago-to-pedidos-lineas-2.yml
- include:
file: db/changelog/changesets/0021-add-email-and-is-palets-to-pedidos-direcciones.yml
- include:
file: db/changelog/changesets/0022-add-estados-pago-to-pedidos-lineas-3.yml
- include:
file: db/changelog/changesets/0023-facturacion.yml
- include:
file: db/changelog/changesets/0024-series-facturacion-seeder.yml
- include:
file: db/changelog/changesets/0025-create-facturas-direcciones.yml

View File

@ -7,8 +7,11 @@ app.seleccionar=Seleccionar
app.guardar=Guardar app.guardar=Guardar
app.editar=Editar app.editar=Editar
app.add=Añadir app.add=Añadir
app.back=Volver
app.eliminar=Eliminar app.eliminar=Eliminar
app.imprimir=Imprimir app.imprimir=Imprimir
app.view=Ver
app.pay=Pagar
app.acciones.siguiente=Siguiente app.acciones.siguiente=Siguiente
app.acciones.anterior=Anterior app.acciones.anterior=Anterior
@ -19,6 +22,8 @@ app.logout=Cerrar sesión
app.sidebar.inicio=Inicio app.sidebar.inicio=Inicio
app.sidebar.presupuestos=Presupuestos app.sidebar.presupuestos=Presupuestos
app.sidebar.pedidos=Pedidos
app.sidebar.facturas=Facturas
app.sidebar.configuracion=Configuración app.sidebar.configuracion=Configuración
app.sidebar.usuarios=Usuarios app.sidebar.usuarios=Usuarios
app.sidebar.direcciones=Mis Direcciones app.sidebar.direcciones=Mis Direcciones
@ -26,3 +31,5 @@ app.sidebar.direcciones-admin=Administrar Direcciones
app.sidebar.gestion-pagos=Gestión de Pagos app.sidebar.gestion-pagos=Gestión de Pagos
app.errors.403=No tienes permiso para acceder a esta página. app.errors.403=No tienes permiso para acceder a esta página.
app.validation.required=Campo obligatorio

View File

@ -35,6 +35,8 @@ direcciones.pasaporte=Pasaporte
direcciones.cif=C.I.F. direcciones.cif=C.I.F.
direcciones.vat_id=VAT ID direcciones.vat_id=VAT ID
direcciones.direccionFacturacion=Dirección de facturación
direcciones.delete.title=Eliminar dirección direcciones.delete.title=Eliminar dirección
direcciones.delete.button=Si, ELIMINAR direcciones.delete.button=Si, ELIMINAR
direcciones.delete.text=¿Está seguro de que desea eliminar esta dirección?<br>Esta acción no se puede deshacer. direcciones.delete.text=¿Está seguro de que desea eliminar esta dirección?<br>Esta acción no se puede deshacer.

View File

@ -0,0 +1,100 @@
facturas.title=Facturas
facturas.breadcrumb=Facturas
facturas.breadcrumb.ver=Ver Factura
facturas.breadcrumb.nueva=Nueva Factura
facturas.tabla.id=ID
facturas.tabla.cliente=Cliente
facturas.tabla.num-factura=Número de Factura
facturas.tabla.estado=Estado
facturas.tabla.estado-pago=Estado de Pago
facturas.tabla.total=Total
facturas.tabla.fecha-emision=Fecha de Emisión
facturas.tabla.acciones=Acciones
facturas.estado-pago.pendiente=Pendiente
facturas.estado-pago.pagada=Pagada
facturas.estado-pago.cancelada=Cancelada
facturas.estado.borrador=Borrador
facturas.estado.validada=Validada
facturas.form.numero-factura=Número de Factura
facturas.form.id=ID de la Factura
facturas.form.factura-rectificada=Factura rectificada
facturas.form.serie=Serie de facturación
facturas.form.serie.placeholder=Seleccione una serie...
facturas.form.fecha-emision=Fecha de Emisión
facturas.form.cliente=Cliente
facturas.form.direccion-facturacion=Dirección de Facturación
facturas.form.direccion-facturacion.placeholder=Seleccione una dirección...
facturas.form.cliente.placeholder=Seleccione un cliente...
facturas.form.notas=Notas
facturas.form.factura-rectificada=Factura rectificada
facturas.form.btn.validar=Validar Factura
facturas.form.btn.borrador=Pasar a Borrador
facturas.form.btn.guardar=Guardar
facturas.form.btn.imprimir=Imprimir Factura
facturas.lineas.acciones=Acciones
facturas.lineas.acciones.editar=Editar
facturas.lineas.acciones.eliminar=Eliminar
facturas.lineas.acciones.agregar=Agregar línea
facturas.lineas.descripcion=Descripción
facturas.lineas.base=Base Imponible
facturas.lineas.iva_4=I.V.A. 4%
facturas.lineas.iva_21=I.V.A. 21%
facturas.lineas.total=Total
facturas.lineas.titulo=Líneas de la Factura
facturas.lineas.iva_4.help=Introduce el importe del I.V.A. (no el %).
facturas.lineas.iva_21.help=Introduce el importe del I.V.A. (no el %).
facturas.lineas.delete.title=¿Eliminar línea de factura?
facturas.lineas.delete.text=Esta acción no se puede deshacer.
facturas.lineas.error.base=La base imponible no es válida.
facturas.lineas.gastos-envio=Gastos de envío
facturas.direccion.titulo=Dirección de Facturación
facturas.direccion.razon-social=Razón Social
facturas.direccion.identificacion-fiscal=Identificación Fiscal
facturas.direccion.direccion=Dirección
facturas.direccion.codigo-postal=Código Postal
facturas.direccion.ciudad=Ciudad
facturas.direccion.provincia=Provincia
facturas.direccion.pais=País
facturas.direccion.telefono=Teléfono
facturas.pagos.titulo=Pago de factura
facturas.pagos.acciones=Acciones
facturas.pagos.acciones.agregar=Agregar pago
facturas.pagos.acciones.editar=Editar
facturas.pagos.acciones.eliminar=Eliminar
facturas.pagos.metodo=Método de pago
facturas.pagos.notas=Notas
facturas.pagos.cantidad=Cantidad pagada
facturas.pagos.fecha=Fecha de pago
facturas.pagos.tipo=Tipo de pago
facturas.pagos.tipo.tpv_tarjeta=TPV/Tarjeta
facturas.pagos.tipo.tpv_bizum=TPV/Bizum
facturas.pagos.tipo.transferencia=Transferencia
facturas.pagos.tipo.otros=Otros
facturas.pagos.total_pagado=Total pagado
facturas.pagos.delete.title=Eliminar pago
facturas.pagos.delete.text=Esta acción no se puede deshacer.
facturas.pagos.error.cantidad=La cantidad no es válida.
facturas.pagos.error.fecha=La fecha no es válida.
facturas.delete.title=¿Estás seguro de que deseas eliminar esta factura?
facturas.delete.text=Esta acción no se puede deshacer.
facturas.delete.ok.title=Factura eliminada
facturas.delete.ok.text=La factura ha sido eliminada correctamente.
facturas.add.form.validation.title=Error al crear la factura
facturas.add.form.validation=Revise que todos los campos están rellenos
facturas.error.create=No se ha podido crear la factura. Revise los datos e inténtelo de nuevo.

View File

@ -7,6 +7,35 @@ pagos.table.cliente.nombre=Nombre Cliente
pagos.table.redsys.id=Cod. Redsys pagos.table.redsys.id=Cod. Redsys
pagos.table.pedido.id=Pedido pagos.table.pedido.id=Pedido
pagos.table.cantidad=Cantidad pagos.table.cantidad=Cantidad
pagos.table.devuelto=Devolución
pagos.table.fecha=Fecha pagos.table.fecha=Fecha
pagos.table.estado=Estado pagos.table.estado=Estado
pagos.table.acciones=Acciones pagos.table.acciones=Acciones
pagos.table.concepto-transferencia=Concepto
pagos.table.estado-transferencia=Estado
pagos.table.fecha-created=Fecha creación
pagos.table.fecha-procesed=Fecha procesada
pagos.table.estado.pending=Pendiente
pagos.table.estado.succeeded=Completada
pagos.table.estado.failed=Fallido
pagos.table.finalizar=Finalizar
pagos.transferencia.no-pedido=No disponible
pagos.transferencia.finalizar.title=Finalizar Transferencia Bancaria
pagos.transferencia.finalizar.text=¿Estás seguro de que deseas marcar esta transferencia bancaria como completada? Esta acción no se puede deshacer.
pagos.transferencia.finalizar.success=Transferencia bancaria marcada como completada con éxito.
pagos.transferencia.finalizar.error.general=Error al finalizar la transferencia bancaria
pagos.transferencia.ok.title=Pago por transferencia bancaria
pagos.transferencia.ok.text=Ha realizado su pedido correctamente. Para completar el pago, realice una transferencia bancaria con los siguientes datos:<br>Titular de la cuenta: Impresión Imprime Libros SL<br>IBAN: ES00 1234 5678 9012 3456 7890<br>Importe: {0}<br>Concepto: {1}<br>Le rogamos que nos envíe el justificante de la transferencia respondiendo al correo de confirmación de pedido que le acabamos de enviar.<br>Si no encuentra el mensaje, por favor revise la carpeta de correo no deseado y añada <a href="mailto:contacto@imprimelibros.com">contacto@imprimelibros.com</a>
pagos.tarjeta-bizum.ok.title=Pago realizado correctamente
pagos.tarjeta-bizum.ok.text=Gracias por confiar en nosotros.<br> Su pago se ha procesado correctamente. En breve recibirá un correo electrónico con los detalles de su pedido.
pagos.refund.title=Devolución
pagos.refund.text=Introduce la cantidad a devolver (en euros):
pagos.refund.success=Devolución solicitada con éxito. Si no se refleja inmediatamente, espere unos minutos y actualiza la página.
pagos.refund.error.general=Error al procesar la devolución
pagos.refund.error.invalid-number=Cantidad inválida para la devolución

View File

@ -4,6 +4,8 @@ pdf.company.postalcode=28028
pdf.company.city=Madrid pdf.company.city=Madrid
pdf.company.phone=+34 910052574 pdf.company.phone=+34 910052574
pdf.page=Página
pdf.presupuesto=PRESUPUESTO pdf.presupuesto=PRESUPUESTO
pdf.factura=FACTURA pdf.factura=FACTURA
pdf.pedido=PEDIDO pdf.pedido=PEDIDO
@ -27,6 +29,27 @@ pdf.datos-maquetacion=Datos de maquetación:
pdf.datos-marcapaginas=Datos de marcapáginas: pdf.datos-marcapaginas=Datos de marcapáginas:
pdf.incluye-envio=El presupuesto incluye el envío a una dirección de la península. pdf.incluye-envio=El presupuesto incluye el envío a una dirección de la península.
pdf.presupuesto-validez=Validez del presupuesto: 30 días desde la fecha de emisión.
# Factura
pdf.factura.number=FACTURA Nº:
pdf.factura.razon-social=RAZÓN SOCIAL:
pdf.factura.identificacion-fiscal=IDENTIFICACIÓN FISCAL:
pdf.factura.direccion=DIRECCIÓN:
pdf.factura.codigo-postal=CÓDIGO POSTAL:
pdf.factura.ciudad=CIUDAD:
pdf.factura.provincia=PROVINCIA:
pdf.factura.pais=PAÍS:
pdf.factura.lineas.descripcion=DESCRIPCIÓN
pdf.factura.lineas.base=BASE IMPONIBLE
pdf.factura.lineas.iva_4=IVA 4%
pdf.factura.lineas.iva_21=IVA 21%
pdf.factura.lineas.total=TOTAL
pdf.factura.total-base=TOTAL BASE IMPONIBLE
pdf.factura.total-iva_4=TOTAL IVA 4%
pdf.factura.total-iva_21=TOTAL IVA 21%
pdf.factura.total-general=TOTAL GENERAL
pdf.politica-privacidad=Política de privacidad pdf.politica-privacidad=Política de privacidad
pdf.politica-privacidad.responsable=Responsable: Impresión Imprime Libros - CIF: B04998886 - Teléfono de contacto: 910052574 pdf.politica-privacidad.responsable=Responsable: Impresión Imprime Libros - CIF: B04998886 - Teléfono de contacto: 910052574

View File

@ -3,7 +3,6 @@ checkout.summary=Resumen de la compra
checkout.billing-address=Dirección de facturación checkout.billing-address=Dirección de facturación
checkout.payment=Método de pago checkout.payment=Método de pago
checkout.billing-address.title=Seleccione una dirección checkout.billing-address.title=Seleccione una dirección
checkout.billing-address.new-address=Nueva dirección checkout.billing-address.new-address=Nueva dirección
checkout.billing-address.select-placeholder=Buscar en direcciones... checkout.billing-address.select-placeholder=Buscar en direcciones...
@ -14,5 +13,62 @@ checkout.payment.bizum=Bizum
checkout.payment.bank-transfer=Transferencia bancaria checkout.payment.bank-transfer=Transferencia bancaria
checkout.error.payment=Error al procesar el pago: el pago ha sido cancelado o rechazado Por favor, inténtelo de nuevo. checkout.error.payment=Error al procesar el pago: el pago ha sido cancelado o rechazado Por favor, inténtelo de nuevo.
checkout.success.payment=Pago realizado con éxito. Gracias por su compra. checkout.success.payment=Pago realizado con éxito. Gracias por su compra.
checkout.error.select-method=Por favor, seleccione un método de pago.
checkout.make-payment=Realizar el pago checkout.make-payment=Realizar el pago
checkout.authorization-required=Certifico que tengo los derechos para imprimir los archivos incluidos en mi pedido y me hago responsable en caso de reclamación de los mismos
pedido.estado.pendiente_pago=Pendiente de pago
pedido.estado.procesando_pago=Procesando pago
pedido.estado.denegado_pago=Pago denegado
pedido.estado.aprobado=Aprobado
pedido.estado.maquetacion=Maquetación
pedido.estado.haciendo_ferro=Haciendo ferro
pedido.estado.esperando_aceptacion_ferro=Esperando aceptación de ferro
pedido.estado.ferro_cliente=Esperando aprobación de ferro
pedido.estado.produccion=Producción
pedido.estado.terminado=Terminado
pedido.estado.enviado=Enviado
pedido.estado.cancelado=Cancelado
pedido.module-title=Pedidos
pedido.pedido=Pedido
pedido.fecha-entrega=Fecha de entrega
pedido.cancelar=Cancelar pedido
pedido.update-estado=Actualizar estado
pedido.maquetacion_finalizada=Maquetación finalizada
pedido.ferro=Ferro
pedido.cubierta=Cubierta
pedido.tapa=Tapa
pedido.aceptar_ferro=Aceptar ferro
pedido.shipping-addresses=Direcciones de envío
pedido.prueba=Prueba
pedido.table.id=Num. Pedido
pedido.table.cliente=Cliente
pedido.table.fecha=Fecha
pedido.table.importe=Importe
pedido.table.estado=Estado
pedido.table.acciones=Acciones
pedido.view.tirada=Tirada
pedido.view.view-presupuesto=Ver presupuesto
pedido.view.aceptar-ferro=Aceptar ferro
pedido.view.ferro-download=Descargar ferro
pedido.view.cub-download=Descargar cubierta
pedido.view.tapa-download=Descargar tapa
pedido.view.descargar-factura=Descargar factura
pedido.view.admin-actions=Acciones de administrador
pedido.view.actions=Acciones
pedido.view.cancel-title=¿Estás seguro de que deseas cancelar este pedido?
pedido.view.cancel-text=Esta acción no se puede deshacer.
pedido.errors.linea-not-found=No se ha encontrado la línea de pedido.
pedido.errors.cancel-pedido=Error al cancelar el pedido
pedido.errors.state-error=Estado de línea no válido.
pedido.errors.update-server-error=Error al actualizar el estado desde el servidor externo.
pedido.errors.connecting-server-error=Error al conectar con el servidor externo.
pedido.errors.cannot-update=No se puede actualizar el estado de una línea con ese estado inicial.
pedido.success.estado-actualizado=Estado del pedido actualizado correctamente.
pedido.success.same-estado=Sin cambios en el estado.
pedido.success.pedido-cancelado=Pedido cancelado correctamente.

View File

@ -11,6 +11,12 @@ presupuesto.add-to-presupuesto=Añadir al presupuesto
presupuesto.calcular=Calcular presupuesto.calcular=Calcular
presupuesto.add=Añadir presupuesto presupuesto.add=Añadir presupuesto
presupuesto.guardar=Guardar presupuesto.guardar=Guardar
presupuesto.duplicar=Duplicar
presupuesto.reimprimir=Reimprimir
presupuesto.reimpresion=Reimpresión
presupuesto.editar=Editar
presupuesto.ver=Ver
presupuesto.borrar=Eliminar
presupuesto.add-to-cart=Añadir a la cesta presupuesto.add-to-cart=Añadir a la cesta
presupuesto.nav.presupuestos-cliente=Presupuestos cliente presupuesto.nav.presupuestos-cliente=Presupuestos cliente
@ -37,10 +43,13 @@ presupuesto.tabla.region=Región
presupuesto.tabla.ciudad=Ciudad presupuesto.tabla.ciudad=Ciudad
presupuesto.tabla.acciones=Acciones presupuesto.tabla.acciones=Acciones
presupuesto.comentario-administrador=Comentarios
# Pestaña datos generales de presupuesto # Pestaña datos generales de presupuesto
presupuesto.informacion-libro=Información del libro presupuesto.informacion-libro=Información del libro
presupuesto.datos-generales-descripcion=Datos generales del presupuesto presupuesto.datos-generales-descripcion=Datos generales del presupuesto
presupuesto.titulo=Título* presupuesto.titulo=Título*
presupuesto.cliente=Cliente*
presupuesto.autor=Autor presupuesto.autor=Autor
presupuesto.isbn=ISBN presupuesto.isbn=ISBN
presupuesto.tirada=Tirada presupuesto.tirada=Tirada
@ -130,6 +139,7 @@ presupuesto.papel-guardas=Papel de guardas
presupuesto.guardas-impresas=Guardas impresas presupuesto.guardas-impresas=Guardas impresas
presupuesto.no=No presupuesto.no=No
presupuesto.cabezada=Cabezada presupuesto.cabezada=Cabezada
presupuesto.cabezada-sin-cabezada=Sin cabezada
presupuesto.cabezada-blanca=Blanca presupuesto.cabezada-blanca=Blanca
presupuesto.cabezada-verde=Verde presupuesto.cabezada-verde=Verde
presupuesto.cabezada-azul=Azul presupuesto.cabezada-azul=Azul
@ -294,6 +304,30 @@ presupuesto.error.delete-permission-denied=No se puede eliminar: permiso denegad
presupuesto.error.delete-not-found=No se puede eliminar: presupuesto no encontrado. presupuesto.error.delete-not-found=No se puede eliminar: presupuesto no encontrado.
presupuesto.error.delete-not-draft=Solo se pueden eliminar presupuestos en estado Borrador. presupuesto.error.delete-not-draft=Solo se pueden eliminar presupuestos en estado Borrador.
# Mensajes de duplicar presupuesto
presupuesto.duplicar.title=Duplicar presupuesto
presupuesto.duplicar.confirm=Si, DUPLICAR
presupuesto.duplicar.cancelar=Cancelar
presupuesto.duplicar.text=¿Está seguro de que desea duplicar este presupuesto?<br>Se creará una copia exacta del mismo en estado Borrador con el título introducido a continuación.
presupuesto.duplicar.required=El título es obligatorio.
presupuesto.duplicar.success.title=Presupuesto duplicado
presupuesto.duplicar.success.text=El presupuesto ha sido duplicado con éxito.
presupuesto.duplicar.aceptar=Aceptar
presupuesto.duplicar.error.title=Error al duplicar presupuesto
presupuesto.duplicar.error.internal=No se puede duplicar: error interno.
# Mensajes de reimprimir presupuesto
presupuesto.reimprimir.title=Reimprimir presupuesto
presupuesto.reimprimir.confirm=Si, REIMPRIMIR
presupuesto.reimprimir.cancelar=Cancelar
presupuesto.reimprimir.text=¿Está seguro de que desea reimprimir este presupuesto?<br>Se generará una nuevo presupuesto usando los mismos ficheros para su impresión.
presupuesto.reimprimir.success.title=Presupuesto generado
presupuesto.reimprimir.success.text=El presupuesto ha sido generado con éxito.
presupuesto.reimprimir.aceptar=Aceptar
presupuesto.reimprimir.error.title=Error al generar el presupuesto
presupuesto.reimprimir.error.internal=No se puede generar el nuevo presupuesto: error interno.
# Añadir presupuesto # Añadir presupuesto
presupuesto.add.tipo=Tipo de presupuesto presupuesto.add.tipo=Tipo de presupuesto
presupuesto.add.anonimo=Anónimo presupuesto.add.anonimo=Anónimo

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