terminado margenes presupuesto e incluido en la api

This commit is contained in:
2025-10-02 20:50:39 +02:00
parent 460d2cfc01
commit 1e24065fb7
18 changed files with 663 additions and 101 deletions

View File

@ -0,0 +1,24 @@
package com.imprimelibros.erp.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer;
import jakarta.validation.ValidatorFactory;
@Configuration
public class BeanValidationConfig {
// Asegura que usamos la factory de Spring (con SpringConstraintValidatorFactory)
@Bean
public LocalValidatorFactoryBean validator() {
return new LocalValidatorFactoryBean();
}
// Inserta esa factory en Hibernate/JPA
@Bean
public HibernatePropertiesCustomizer hibernateValidationCustomizer(ValidatorFactory vf) {
return props -> props.put("jakarta.persistence.validation.factory", vf);
}
}

View File

@ -3,7 +3,6 @@ package com.imprimelibros.erp.configuracion.margenes_presupuestos;
import jakarta.persistence.*;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDateTime;
@ -46,23 +45,23 @@ public class MargenPresupuesto {
private TipoCubierta tipoCubierta;
@Column(name="tirada_min", nullable = false)
@NotBlank(message="{validation.required}")
@NotNull(message="{validation.required}")
@Min(value=1, message="{validation.min}")
private Integer tiradaMin;
@Column(name="tirada_max", nullable = false)
@NotBlank(message="{validation.required}")
@NotNull(message="{validation.required}")
@Min(value=1, message="{validation.min}")
private Integer tiradaMax;
@Column(name="margen_max", nullable = false)
@NotBlank(message="{validation.required}")
@NotNull(message="{validation.required}")
@Min(value = 0, message="{validation.min}")
@Max(value = 200, message="{validation.max}")
private Integer margenMax;
@Column(name = "margen_min", nullable = false)
@NotBlank(message="{validation.required}")
@NotNull(message="{validation.required}")
@Min(value = 0, message="{validation.min}")
@Max(value = 200, message="{validation.max}")
private Integer margenMin;

View File

@ -1,16 +1,27 @@
package com.imprimelibros.erp.configuracion.margenes_presupuestos;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.context.MessageSource;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import com.imprimelibros.erp.datatables.DataTable;
@ -18,24 +29,28 @@ import com.imprimelibros.erp.datatables.DataTablesParser;
import com.imprimelibros.erp.datatables.DataTablesRequest;
import com.imprimelibros.erp.datatables.DataTablesResponse;
import com.imprimelibros.erp.i18n.TranslationService;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoCubierta;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoEncuadernacion;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.Locale;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
@Controller
@RequestMapping("/configuracion/margenes-presupuestos")
@RequestMapping("/configuracion/margenes-presupuesto")
@PreAuthorize("hasRole('SUPERADMIN')")
public class MargenPresupuestoController {
private final MargenPresupuestoDao repo;
private final TranslationService translationService;
private final MessageSource messageSource;
public MargenPresupuestoController(MargenPresupuestoDao repo, TranslationService translationService
, MessageSource messageSource) {
public MargenPresupuestoController(MargenPresupuestoDao repo, TranslationService translationService,
MessageSource messageSource) {
this.repo = repo;
this.translationService = translationService;
this.messageSource = messageSource;
@ -44,7 +59,15 @@ public class MargenPresupuestoController {
@GetMapping()
public String listView(Model model, Authentication authentication, Locale locale) {
List<String> keys = List.of();
List<String> keys = List.of(
"margenes-presupuesto.delete.title",
"margenes-presupuesto.delete.text",
"margenes-presupuesto.eliminar",
"margenes-presupuesto.delete.button",
"app.yes",
"app.cancelar",
"margenes-presupuesto.delete.ok.title",
"margenes-presupuesto.delete.ok.text");
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
@ -57,23 +80,21 @@ public class MargenPresupuestoController {
public DataTablesResponse<Map<String, Object>> datatable(HttpServletRequest request, Authentication authentication,
Locale locale) {
DataTablesRequest dt = DataTablesParser.from(request); //
DataTablesRequest dt = DataTablesParser.from(request);
List<String> searchable = List.of(
"tipoEncuadernacion",
"tipoCubierta",
"id",
"tiradaMin", "tiradaMax",
"margenMin", "margenMax");
List<String> orderable = List.of(
"id",
"tipoEncuadernacion",
"tipoCubierta",
"tiradaMin",
"tiradaMax",
"margenMin",
"margenMax");
List<String> orderable = List.of(
"id",
"tipoEncuadernacion",
"tipoCubierta",
"tiradaMin",
"tiradaMax",
"margenMin",
"margenMax");
Specification<MargenPresupuesto> base = (root, query, cb) -> cb.conjunction();
long total = repo.count();
@ -85,13 +106,14 @@ public class MargenPresupuestoController {
.add("actions", (margen) -> {
return "<div class=\"hstack gap-3 flex-wrap\">\n" +
" <a href=\"javascript:void(0);\" data-id=\"" + margen.getId()
+ "\" class=\"link-success btn-edit-user fs-15\"><i class=\"ri-edit-2-line\"></i></a>\n"
+ "\" class=\"link-success btn-edit-margen fs-15\"><i class=\"ri-edit-2-line\"></i></a>\n"
+ " <a href=\"javascript:void(0);\" data-id=\"" + margen.getId()
+ "\" class=\"link-danger btn-delete-user fs-15\"><i class=\"ri-delete-bin-5-line\"></i></a>\n"
+ "\" class=\"link-danger btn-delete-margen fs-15\"><i class=\"ri-delete-bin-5-line\"></i></a>\n"
+ " </div>";
})
.edit("tipoEncuadernacion", (margen) -> {
return messageSource.getMessage("presupuesto." + margen.getTipoEncuadernacion().name(),null, locale);
return messageSource.getMessage("presupuesto." + margen.getTipoEncuadernacion().name(), null,
locale);
})
.edit("tipoCubierta", (margen) -> {
return messageSource.getMessage("presupuesto." + margen.getTipoCubierta().name(), null, locale);
@ -99,25 +121,220 @@ public class MargenPresupuestoController {
.where(base)
// Filtros custom:
.filter((builder, req) -> {
// f_enabled: 'true' | 'false' | ''
/*String fEnabled = Optional.ofNullable(req.raw.get("f_enabled")).orElse("").trim();
if (!fEnabled.isEmpty()) {
boolean enabledVal = Boolean.parseBoolean(fEnabled);
builder.add((root, q, cb) -> cb.equal(root.get("enabled"), enabledVal));
String fEncuadernacion = Optional.ofNullable(req.raw.get("f_encuadernacion")).orElse("").trim();
if (!fEncuadernacion.isEmpty()) {
boolean added = false;
// 1) Si llega el nombre del enum (p.ej. "fresado", "cosido", ...)
try {
var encEnum = TipoEncuadernacion.valueOf(fEncuadernacion);
builder.add((root, q, cb) -> cb.equal(root.get("tipoEncuadernacion"), encEnum));
added = true;
} catch (IllegalArgumentException ignored) {
}
// 2) Si llega la clave i18n (p.ej. "presupuesto.fresado", ...)
if (!added) {
Arrays.stream(TipoEncuadernacion.values())
.filter(e -> e.getMessageKey().equals(fEncuadernacion))
.findFirst()
.ifPresent(encEnum -> builder
.add((root, q, cb) -> cb.equal(root.get("tipoEncuadernacion"), encEnum)));
}
}
// f_role: 'USER' | 'ADMIN' | 'SUPERADMIN' | ''
String fRole = Optional.ofNullable(req.raw.get("f_role")).orElse("").trim();
if (!fRole.isEmpty()) {
builder.add((root, q, cb) -> {
// join a roles; marca la query como distinct para evitar duplicados
var r = root.join("roles", jakarta.persistence.criteria.JoinType.LEFT);
q.distinct(true);
return cb.equal(r.get("name"), fRole);
});
}*/
// --- Cubierta ---
String fCubierta = Optional.ofNullable(req.raw.get("f_cubierta")).orElse("").trim();
if (!fCubierta.isEmpty()) {
boolean added = false;
// 1) Si llega el nombre del enum (p.ej. "tapaBlanda", "tapaDura",
// "tapaDuraLomoRedondo")
try {
var cubEnum = TipoCubierta.valueOf(fCubierta);
builder.add((root, q, cb) -> cb.equal(root.get("tipoCubierta"), cubEnum));
added = true;
} catch (IllegalArgumentException ignored) {
}
// 2) Si llega la clave i18n (p.ej. "presupuesto.tapa-blanda", ...)
if (!added) {
Arrays.stream(TipoCubierta.values())
.filter(e -> e.getMessageKey().equals(fCubierta))
.findFirst()
.ifPresent(cubEnum -> builder
.add((root, q, cb) -> cb.equal(root.get("tipoCubierta"), cubEnum)));
}
}
})
.toJson(total);
}
@GetMapping("form")
public String getForm(@RequestParam(required = false) Long id,
MargenPresupuesto margenPresupuesto,
BindingResult binding,
Model model,
HttpServletResponse response,
Locale locale) {
if (id != null) {
var opt = repo.findById(id);
if (opt.isEmpty()) {
binding.reject("usuarios.error.noEncontrado",
messageSource.getMessage("usuarios.error.noEncontrado", null, locale));
response.setStatus(404);
model.addAttribute("action", "/users/" + id);
return "imprimelibros/users/user-form :: userForm";
}
model.addAttribute("margenPresupuesto", opt.get());
model.addAttribute("action", "/configuracion/margenes-presupuesto/" + id);
} else {
// Crear: valores por defecto
model.addAttribute("margenPresupuesto", new MargenPresupuesto());
model.addAttribute("action", "/configuracion/margenes-presupuesto");
}
return "imprimelibros/configuracion/margenes-presupuesto/margenes-presupuesto-form :: margenesPresupuestoForm";
}
@PostMapping
public String create(
MargenPresupuesto margenPresupuesto,
BindingResult binding,
Model model,
HttpServletResponse response,
Locale locale) {
if (binding.hasErrors()) {
response.setStatus(422);
model.addAttribute("action", "/configuracion/margenes-presupuesto");
return "imprimelibros/configuracion/margenes-presupuesto/margenes-presupuesto-form :: margenesPresupuestoForm";
}
MargenPresupuesto data = new MargenPresupuesto();
data.setTipoEncuadernacion(margenPresupuesto.getTipoEncuadernacion());
data.setTipoCubierta(margenPresupuesto.getTipoCubierta());
data.setTiradaMin(margenPresupuesto.getTiradaMin());
data.setTiradaMax(margenPresupuesto.getTiradaMax());
data.setMargenMax(margenPresupuesto.getMargenMax());
data.setMargenMin(margenPresupuesto.getMargenMin());
try {
repo.save(data);
} catch (jakarta.validation.ConstraintViolationException vex) {
// Errores de Bean Validation disparados al flush (incluye tu @NoRangeOverlap)
vex.getConstraintViolations().forEach(v -> {
// intenta asignar al campo si existe, si no, error global
String path = v.getPropertyPath() != null ? v.getPropertyPath().toString() : null;
String code = v.getMessage() != null ? v.getMessage().trim() : "";
if (code.startsWith("{") && code.endsWith("}")) {
code = code.substring(1, code.length() - 1); // -> "validation.required"
}
if (path != null && binding.getFieldError(path) == null) {
binding.rejectValue(path, "validation", messageSource.getMessage(code, null, locale));
} else {
binding.reject("validation", messageSource.getMessage(code, null, locale));
}
});
response.setStatus(422);
model.addAttribute("action", "/configuracion/margenes-presupuesto");
return "imprimelibros/configuracion/margenes-presupuesto/margenes-presupuesto-form :: margenesPresupuestoForm";
}
response.setStatus(201);
return null;
}
@PutMapping("/{id}")
public String edit(
@PathVariable Long id,
MargenPresupuesto form,
BindingResult binding,
Model model,
HttpServletResponse response,
Locale locale) {
var uOpt = repo.findById(id);
if (uOpt.isEmpty()) {
binding.reject("usuarios.error.noEncontrado",
messageSource.getMessage("usuarios.error.noEncontrado", null, locale));
}
if (binding.hasErrors()) {
response.setStatus(422);
model.addAttribute("action", "/configuracion/margenes-presupuesto/" + id);
return "imprimelibros/configuracion/margenes-presupuesto/margenes-presupuesto-form :: margenesPresupuestoForm";
}
var entity = uOpt.get();
// 3) Copiar solamente campos editables
entity.setTipoEncuadernacion(form.getTipoEncuadernacion());
entity.setTipoCubierta(form.getTipoCubierta());
entity.setTiradaMin(form.getTiradaMin());
entity.setTiradaMax(form.getTiradaMax());
entity.setMargenMax(form.getMargenMax());
entity.setMargenMin(form.getMargenMin());
try {
repo.saveAndFlush(entity);
} catch (jakarta.validation.ConstraintViolationException vex) {
// Errores de Bean Validation disparados al flush (incluye tu @NoRangeOverlap)
vex.getConstraintViolations().forEach(v -> {
// intenta asignar al campo si existe, si no, error global
String path = v.getPropertyPath() != null ? v.getPropertyPath().toString() : null;
String code = v.getMessage() != null ? v.getMessage().trim() : "";
if (code.startsWith("{") && code.endsWith("}")) {
code = code.substring(1, code.length() - 1); // -> "validation.required"
}
if (path != null && binding.getFieldError(path) == null) {
binding.rejectValue(path, "validation", messageSource.getMessage(code, null, locale));
} else {
binding.reject("validation", messageSource.getMessage(code, null, locale));
}
});
response.setStatus(422);
model.addAttribute("action", "/configuracion/margenes-presupuesto/" + id);
return "imprimelibros/configuracion/margenes-presupuesto/margenes-presupuesto-form :: margenesPresupuestoForm";
} catch (org.springframework.dao.DataIntegrityViolationException dex) {
// Uniques, FKs, checks… mensajes de la BD
String msg = dex.getMostSpecificCause() != null ? dex.getMostSpecificCause().getMessage()
: dex.getMessage();
binding.reject("db.error", messageSource.getMessage(msg, null, locale));
response.setStatus(422);
model.addAttribute("action", "/configuracion/margenes-presupuesto/" + id);
return "imprimelibros/configuracion/margenes-presupuesto/margenes-presupuesto-form :: margenesPresupuestoForm";
}
response.setStatus(204);
return null;
}
@DeleteMapping("/{id}")
@Transactional
public ResponseEntity<?> delete(@PathVariable Long id, Authentication auth, Locale locale) {
return repo.findById(id).map(u -> {
try {
u.setDeleted(true);
u.setDeletedAt(LocalDateTime.now());
repo.save(u); // ← NO delete(); guardamos el soft delete con deleted_by relleno
return ResponseEntity.ok(Map.of("message",
messageSource.getMessage("margenes-presupuesto.exito.eliminado", null, locale)));
} catch (Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message",
messageSource.getMessage("margenes-presupuesto.error.delete-internal-error", null, locale)));
}
}).orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("message", messageSource.getMessage("margenes-presupuesto.error.not-found", null, locale))));
}
}

View File

@ -8,21 +8,33 @@ import org.springframework.data.repository.query.Param;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoEncuadernacion;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoCubierta;
public interface MargenPresupuestoDao extends JpaRepository<MargenPresupuesto, Long>, JpaSpecificationExecutor<MargenPresupuesto> {
public interface MargenPresupuestoDao
extends JpaRepository<MargenPresupuesto, Long>, JpaSpecificationExecutor<MargenPresupuesto> {
@Query("""
SELECT COUNT(m) FROM MargenPresupuesto m
WHERE m.deleted = false
AND m.tipoEncuadernacion = :enc
AND m.tipoCubierta = :cub
AND (:id IS NULL OR m.id <> :id)
AND NOT (m.tiradaMax < :min OR m.tiradaMin > :max)
""")
SELECT COUNT(m) FROM MargenPresupuesto m
WHERE m.deleted = false
AND m.tipoEncuadernacion = :enc
AND m.tipoCubierta = :cub
AND (:id IS NULL OR m.id <> :id)
AND NOT (m.tiradaMax < :min OR m.tiradaMin > :max)
""")
long countOverlaps(
@Param("enc") TipoEncuadernacion enc,
@Param("cub") TipoCubierta cub,
@Param("min") Integer min,
@Param("max") Integer max,
@Param("id") Long id
);
@Param("enc") TipoEncuadernacion enc,
@Param("cub") TipoCubierta cub,
@Param("min") Integer min,
@Param("max") Integer max,
@Param("id") Long id);
@Query("""
SELECT m FROM MargenPresupuesto m
WHERE m.deleted = false
AND m.tipoEncuadernacion = :enc
AND m.tipoCubierta = :cub
AND :tirada BETWEEN m.tiradaMin AND m.tiradaMax
""")
MargenPresupuesto findByTipoAndTirada(
@Param("enc") TipoEncuadernacion enc,
@Param("cub") TipoCubierta cub,
@Param("tirada") Integer tirada);
}

View File

@ -6,12 +6,19 @@ import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imprimelibros.erp.configuracion.margenes_presupuestos.MargenPresupuesto;
import com.imprimelibros.erp.configuracion.margenes_presupuestos.MargenPresupuestoDao;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoCubierta;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoEncuadernacion;
import java.util.Map;
import java.util.HashMap;
import java.util.List;
import java.util.function.Supplier;
@Service
@ -22,13 +29,16 @@ public class skApiClient {
private final AuthService authService;
private final RestTemplate restTemplate;
private final MargenPresupuestoDao margenPresupuestoDao;
public skApiClient(AuthService authService) {
public skApiClient(AuthService authService, MargenPresupuestoDao margenPresupuestoDao) {
this.authService = authService;
this.restTemplate = new RestTemplate();
this.margenPresupuestoDao = margenPresupuestoDao;
}
public String getPrice(Map<String, Object> requestBody) {
public String getPrice(Map<String, Object> requestBody, TipoEncuadernacion tipoEncuadernacion,
TipoCubierta tipoCubierta) {
return performWithRetry(() -> {
String url = this.skApiUrl + "api/calcular";
@ -45,14 +55,57 @@ public class skApiClient {
String.class);
try {
Map<String, Object> responseBody = new ObjectMapper().readValue(response.getBody(), Map.class);
Map<String, Object> responseBody = new ObjectMapper().readValue(
response.getBody(),
new TypeReference<Map<String, Object>>() {
});
ObjectMapper mapper = new ObjectMapper();
if (responseBody.get("error") == null) {
return new ObjectMapper().writeValueAsString(
Map.of("data", responseBody.get("data")));
Object dataObj = responseBody.get("data");
if (dataObj instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) dataObj;
List<Integer> tiradas = mapper.convertValue(
data.get("tiradas"), new TypeReference<List<Integer>>() {
});
List<Double> precios = mapper.convertValue(
data.get("precios"), new TypeReference<List<Double>>() {
});
for (int i = 0; i < tiradas.size(); i++) {
int tirada = tiradas.get(i);
MargenPresupuesto margen = margenPresupuestoDao.findByTipoAndTirada(
tipoEncuadernacion, tipoCubierta, tirada);
if (margen != null) {
double margenValue = calcularMargen(
tirada,
margen.getTiradaMin(),
margen.getTiradaMax(),
margen.getMargenMax(),
margen.getMargenMin());
double nuevoPrecio = precios.get(i) * (1 + margenValue / 100.0);
precios.set(i, nuevoPrecio);
} else {
System.out.println("No se encontró margen para tirada " + tirada);
}
}
// <-- Clave: sustituir la lista en el map que se devuelve
data.put("precios", precios);
// (tiradas no cambia, pero si la modificases: data.put("tiradas", tiradas);)
}
return mapper.writeValueAsString(Map.of("data", responseBody.get("data")));
} else {
return "{\"error\": 1}";
}
} catch (JsonProcessingException e) {
e.printStackTrace();
return "{\"error\": 1}";
@ -104,7 +157,11 @@ public class skApiClient {
} catch (JsonProcessingException e) {
// Fallback al 80% del ancho
Map<String, Object> tamanio = (Map<String, Object>) requestBody.get("tamanio");
Map<String, Object> tamanio = new ObjectMapper().convertValue(
requestBody.get("tamanio"),
new TypeReference<Map<String, Object>>() {
});
if (tamanio == null || tamanio.get("ancho") == null)
throw new RuntimeException("Tamaño no válido en la solicitud: " + requestBody);
else {
@ -132,7 +189,10 @@ public class skApiClient {
String.class);
try {
Map<String, Object> responseBody = new ObjectMapper().readValue(response.getBody(), Map.class);
Map<String, Object> responseBody = new ObjectMapper().readValue(
response.getBody(),
new TypeReference<Map<String, Object>>() {
});
return responseBody.get("data").toString();
} catch (JsonProcessingException e) {
e.printStackTrace();
@ -162,4 +222,14 @@ public class skApiClient {
}
}
}
}
private static double calcularMargen(
int tirada, int tiradaMin, int tiradaMax,
double margenMax, double margenMin) {
if (tirada <= tiradaMin)
return margenMax;
if (tirada >= tiradaMax)
return margenMin;
return margenMax - ((double) (tirada - tiradaMin) / (tiradaMax - tiradaMin)) * (margenMax - margenMin);
}
}

View File

@ -22,6 +22,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.http.MediaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.imprimelibros.erp.externalApi.skApiClient;
import com.imprimelibros.erp.presupuesto.classes.ImagenPresupuesto;
import com.imprimelibros.erp.presupuesto.classes.PresupuestoMaquetacion;
@ -45,7 +46,7 @@ public class PresupuestoController {
private final ObjectMapper objectMapper;
public PresupuestoController(ObjectMapper objectMapper){
public PresupuestoController(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@ -178,7 +179,10 @@ public class PresupuestoController {
// opciones gramaje interior
resultado.putAll(presupuestoService.obtenerOpcionesGramajeInterior(presupuesto));
List<String> opciones = (List<String>) resultado.get("opciones_gramaje_interior");
List<String> opciones = new ObjectMapper().convertValue(resultado.get("opciones_papel_interior"),
new TypeReference<List<String>>() {
});
if (opciones != null && !opciones.isEmpty()) {
String gramajeActual = presupuesto.getGramajeInterior().toString();
@ -206,7 +210,9 @@ public class PresupuestoController {
}
Map<String, Object> resultado = presupuestoService.obtenerOpcionesGramajeInterior(presupuesto);
List<String> opciones = (List<String>) resultado.get("opciones_gramaje_interior");
List<String> opciones = new ObjectMapper().convertValue(resultado.get("opciones_gramaje_interior"),
new TypeReference<List<String>>() {
});
if (opciones != null && !opciones.isEmpty()) {
String gramajeActual = presupuesto.getGramajeInterior().toString();
@ -244,9 +250,12 @@ public class PresupuestoController {
Map<String, Object> resultado = new HashMap<>();
Map<String, Object> papelesCubierta = presupuestoService.obtenerOpcionesPapelCubierta(presupuesto, locale);
List<ImagenPresupuesto> opciones = (List<ImagenPresupuesto>) presupuestoService
.obtenerOpcionesPapelCubierta(presupuesto, locale)
.get("opciones_papel_cubierta");
List<ImagenPresupuesto> opciones = new ObjectMapper().convertValue(
presupuestoService
.obtenerOpcionesPapelCubierta(presupuesto, locale)
.get("opciones_papel_cubierta"),
new TypeReference<List<ImagenPresupuesto>>() {
});
if (opciones != null && opciones.stream().noneMatch(
o -> o.getExtra_data().get("sk-id").equals(String.valueOf(presupuesto.getPapelCubiertaId())))) {
@ -255,7 +264,10 @@ public class PresupuestoController {
resultado.putAll(papelesCubierta);
resultado.putAll(presupuestoService.obtenerOpcionesGramajeCubierta(presupuesto));
List<String> gramajesCubierta = (List<String>) resultado.get("opciones_gramaje_cubierta");
List<String> gramajesCubierta = new ObjectMapper().convertValue(
resultado.get("opciones_gramaje_cubierta"),
new TypeReference<List<String>>() {
});
if (gramajesCubierta != null && !gramajesCubierta.isEmpty()) {
String gramajeActual = presupuesto.getGramajeCubierta().toString();
if (!gramajesCubierta.contains(gramajeActual)) {
@ -304,7 +316,8 @@ public class PresupuestoController {
if (!errores.isEmpty()) {
return ResponseEntity.badRequest().body(errores);
}
String price = apiClient.getPrice(presupuestoService.toSkApiRequest(presupuesto));
String price = apiClient.getPrice(presupuestoService.toSkApiRequest(presupuesto),
presupuesto.getTipoEncuadernacion(), presupuesto.getTipoCubierta());
if (price == null || price.isEmpty()) {
return ResponseEntity.badRequest().body("No se pudo obtener el precio. Intente nuevamente.");
}

View File

@ -575,7 +575,7 @@ public class PresupuestoService {
} else if (presupuestoTemp.getTipoImpresion() == Presupuesto.TipoImpresion.negro) {
presupuestoTemp.setTipoImpresion(Presupuesto.TipoImpresion.negrohq);
}
String priceStr = apiClient.getPrice(this.toSkApiRequest(presupuestoTemp));
String priceStr = apiClient.getPrice(this.toSkApiRequest(presupuestoTemp), presupuestoTemp.getTipoEncuadernacion(), presupuestoTemp.getTipoCubierta());
Double price_prototipo = 0.0;
try {
price = new ObjectMapper().readValue(priceStr, new TypeReference<>() {
@ -846,7 +846,7 @@ public class PresupuestoService {
public HashMap<String, Object> calcularPresupuesto(Presupuesto presupuesto, Locale locale) {
HashMap<String, Object> price = new HashMap<>();
String priceStr = apiClient.getPrice(this.toSkApiRequest(presupuesto));
String priceStr = apiClient.getPrice(this.toSkApiRequest(presupuesto), presupuesto.getTipoEncuadernacion(), presupuesto.getTipoCubierta());
try {
price = new ObjectMapper().readValue(priceStr, new TypeReference<>() {

View File

@ -5,15 +5,21 @@ import java.beans.PropertyDescriptor;
import java.lang.reflect.Method;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.FlushModeType;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.PersistenceUnit;
import jakarta.persistence.criteria.*;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.springframework.stereotype.Component;
@Component
public class NoRangeOverlapValidator implements ConstraintValidator<NoRangeOverlap, Object> {
@PersistenceContext
private EntityManager em;
@PersistenceUnit
private EntityManagerFactory emf;
private String minField;
private String maxField;
@ -38,22 +44,28 @@ public class NoRangeOverlapValidator implements ConstraintValidator<NoRangeOverl
@Override
public boolean isValid(Object bean, ConstraintValidatorContext ctx) {
if (bean == null) return true;
if (bean == null)
return true;
EntityManager em = null;
try {
// EM aislado para evitar auto-flush durante la validación
em = emf.createEntityManager();
em.setFlushMode(FlushModeType.COMMIT);
Class<?> entityClass = bean.getClass();
Number min = (Number) read(bean, minField);
Number max = (Number) read(bean, maxField);
Object id = safeRead(bean, idField); // puede ser null en INSERT
if (min == null || max == null) return true;
if (min == null || max == null)
return true;
if (min.longValue() > max.longValue()) {
ctx.disableDefaultConstraintViolation();
ctx.buildConstraintViolationWithTemplate(invalidRangeMessage)
.addPropertyNode(maxField)
.addConstraintViolation();
.addPropertyNode(maxField)
.addConstraintViolation();
return false;
}
@ -82,9 +94,8 @@ public class NoRangeOverlapValidator implements ConstraintValidator<NoRangeOverl
Expression<Number> eMax = root.get(maxField);
Predicate noOverlap = cb.or(
cb.lt(eMax.as(Long.class), min.longValue()),
cb.gt(eMin.as(Long.class), max.longValue())
);
cb.lt(eMax.as(Long.class), min.longValue()),
cb.gt(eMin.as(Long.class), max.longValue()));
Predicate overlap = cb.not(noOverlap);
cq.where(cb.and(pred, overlap));
@ -93,9 +104,9 @@ public class NoRangeOverlapValidator implements ConstraintValidator<NoRangeOverl
if (count != null && count > 0) {
ctx.disableDefaultConstraintViolation();
ctx.buildConstraintViolationWithTemplate(message)
.addPropertyNode(minField).addConstraintViolation();
.addPropertyNode(minField).addConstraintViolation();
ctx.buildConstraintViolationWithTemplate(message)
.addPropertyNode(maxField).addConstraintViolation();
.addPropertyNode(maxField).addConstraintViolation();
return false;
}

View File

@ -1,6 +1,9 @@
margenes-presupuesto.titulo=Márgenes de presupuesto
margenes-presupuesto.breadcrumb=Márgenes de presupuesto
margenes-presupuesto.add=Añadir
margenes-presupuesto.nuevo=Nuevo margen
margenes-presupuesto.editar=Editar margen
margenes-presupuesto.eliminar=Eliminar
margenes-presupuesto.tabla.id=ID
margenes-presupuesto.tabla.tipo_encuadernacion=Tipo encuadernación
@ -11,4 +14,22 @@ margenes-presupuesto.tabla.margen_minimo=Margen Mín.
margenes-presupuesto.tabla.margen_maximo=Margen Máx.
margenes-presupuesto.tabla.acciones=Acciones
margenes-presupuesto.todos=Todos
margenes-presupuesto.form.tipo_encuadernacion=Tipo de encuadernación
margenes-presupuesto.form.tipo_cubierta=Tipo de cubierta
margenes-presupuesto.form.tirada_minima=Tirada mínima
margenes-presupuesto.form.tirada_maxima=Tirada máxima
margenes-presupuesto.form.margen_minimo=Margen mínimo (%)
margenes-presupuesto.form.margen_maximo=Margen máximo (%)
margenes-presupuesto.todos=Todos
margenes-presupuesto.delete.title=Eliminar margen
margenes-presupuesto.delete.button=Si, ELIMINAR
margenes-presupuesto.delete.text=¿Está seguro de que desea eliminar este margen?<br>Esta acción no se puede deshacer.
margenes-presupuesto.delete.ok.title=Margen eliminado
margenes-presupuesto.delete.ok.text=El margen ha sido eliminado con éxito.
margenes-presupuesto.exito.eliminado=Margen eliminado con éxito.
margenes-presupuesto.error.delete-internal-error=No se puede eliminar: error interno.
margenes-presupuesto.error.delete-not-found=No se puede eliminar: margen no encontrado.

View File

@ -42,6 +42,7 @@ presupuesto.grapado-descripcion=Grapado (entre 12 y 40 páginas)
presupuesto.espiral=Espiral
presupuesto.espiral-descripcion=Espiral (a partir de 20 páginas)
presupuesto.wire-o=Wire-O
presupuesto.wireo=Wire-O
presupuesto.wire-o-descripcion=Wire-O (a partir de 20 páginas)
presupuesto.encuadernacion-descripcion=Seleccione la encuadernación del libro
presupuesto.continuar-interior=Continuar a diseño interior

View File

@ -8,4 +8,5 @@ validation.unique=El valor ya existe y debe ser único
validation.email=El correo electrónico no es válido
validation.range.overlaps=El rango se solapa con otro existente.
validation.range.invalid=El valor máximo debe ser mayor o igual que el mínimo.
validation.range.invalid2=Rango no válido.
validation.db=Error de base de datos: {message}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 925 KiB

View File

@ -18,18 +18,20 @@
return;
}
new DataTable('#margenes-datatable', {
const table = new DataTable('#margenes-datatable', {
processing: true,
serverSide: true,
orderCellsTop: true,
stateSave: true,
pageLength: 50,
language: { url: '/assets/libs/datatables/i18n/' + language + '.json' },
responsive: true,
ajax: {
url: '/configuracion/margenes-presupuestos/datatable',
url: '/configuracion/margenes-presupuesto/datatable',
method: 'GET',
data: function (d) {
// filtros si los necesitas
d.f_encuadernacion = $('#search-encuadernacion').val() || ''; // 'USER' | 'ADMIN' | 'SUPERADMIN' | ''
d.f_cubierta = $('#search-cubierta').val() || ''; // 'true' | 'false' | ''
}
},
order: [[0, 'asc']],
@ -45,4 +47,126 @@
],
columnDefs: [{ targets: -1, orderable: false, searchable: false }]
});
table.on("keyup", ".margenes-presupuesto-filter", function () {
const colName = $(this).data("col");
const colIndex = table.settings()[0].aoColumns.findIndex(c => c.name === colName);
if (colIndex >= 0) {
table.column(colIndex).search(this.value).draw();
}
});
table.on("change", ".margenes-presupuesto-select-filter", function () {
table.draw();
});
const modalEl = document.getElementById('margenesPresupuestoFormModal');
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
// Abrir "Crear"
$('#addButton').on('click', (e) => {
e.preventDefault();
$.get('/configuracion/margenes-presupuesto/form', function (html) {
$('#margenesPresupuestoModalBody').html(html);
const title = $('#margenesPresupuestoModalBody #margenesPresupuestoForm').data('add');
$('#margenesPresupuestoModal .modal-title').text(title);
modal.show();
});
});
// Abrir "Editar"
$(document).on('click', '.btn-edit-margen', function (e) {
e.preventDefault();
const id = $(this).data('id');
$.get('/configuracion/margenes-presupuesto/form', { id }, function (html) {
$('#margenesPresupuestoModalBody').html(html);
const title = $('#margenesPresupuestoModalBody #margenesPresupuestoForm').data('edit');
$('#margenesPresupuestoModal .modal-title').text(title);
modal.show();
});
});
// Botón "Eliminar"
$(document).on('click', '.btn-delete-margen', function (e) {
e.preventDefault();
const id = $(this).data('id');
Swal.fire({
title: window.languageBundle.get(['margenes-presupuesto.delete.title']) || 'Eliminar margen',
html: window.languageBundle.get(['margenes-presupuesto.delete.text']) || 'Esta acción no se puede deshacer.',
icon: 'warning',
showCancelButton: true,
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-danger w-xs mt-2',
cancelButton: 'btn btn-light w-xs mt-2'
},
confirmButtonText: window.languageBundle.get(['margenes-presupuesto.delete.button']) || 'Eliminar',
cancelButtonText: window.languageBundle.get(['app.cancelar']) || 'Cancelar',
}).then((result) => {
if (!result.isConfirmed) return;
$.ajax({
url: '/configuracion/margenes-presupuesto/' + id,
type: 'DELETE',
success: function () {
Swal.fire({
icon: 'success', title: window.languageBundle.get(['margenes-presupuesto.delete.ok.title']) || 'Eliminado',
text: window.languageBundle.get(['margenes-presupuesto.delete.ok.text']) || 'El margen ha sido eliminado con éxito.',
showConfirmButton: true,
customClass: {
confirmButton: 'btn btn-secondary w-xs mt-2',
},
});
$('#margenes-datatable').DataTable().ajax.reload(null, false);
},
error: function (xhr) {
// usa el mensaje del backend; fallback genérico por si no llega JSON
const msg = (xhr.responseJSON && xhr.responseJSON.message)
|| 'Error al eliminar el usuario.';
Swal.fire({ icon: 'error', title: 'No se pudo eliminar', text: msg });
}
});
});
});
// Submit del form en el modal
$(document).on('submit', '#margenesPresupuestoForm', function (e) {
e.preventDefault();
const $form = $(this);
$.ajax({
url: $form.attr('action'),
type: 'POST', // PUT simulado via _method
data: $form.serialize(),
dataType: 'html',
success: function (html) {
// Si por cualquier motivo llega 200 con fragmento, lo insertamos igual
if (typeof html === 'string' && html.indexOf('id="margenesPresupuestoForm"') !== -1 && html.indexOf('<html') === -1) {
$('#margenesPresupuestoModalBody').html(html);
const isEdit = $('#margenesPresupuestoModalBody #margenesPresupuestoForm input[name="_method"][value="PUT"]').length > 0;
const title = $('#margenesPresupuestoModalBody #margenesPresupuestoForm').data(isEdit ? 'edit' : 'add');
$('#margenesPresupuestoModal .modal-title').text(title);
return;
}
// Éxito real: cerrar y recargar tabla
modal.hide();
table.ajax.reload(null, false);
},
error: function (xhr) {
// Con 422 devolvemos el fragmento con errores aquí
if (xhr.status === 422 && xhr.responseText) {
$('#margenesPresupuestoModalBody').html(xhr.responseText);
const isEdit = $('#margenesPresupuestoModalBody #margenesPresupuestoForm input[name="_method"][value="PUT"]').length > 0;
const title = $('#margenesPresupuestoModalBody #margenesPresupuestoForm').data(isEdit ? 'edit' : 'add');
$('#margenesPresupuestoModal .modal-title').text(title);
return;
}
// Fallback
$('#margenesPresupuestoModalBody').html('<div class="p-3 text-danger">Error inesperado.</div>');
}
});
});
})();

View File

@ -39,7 +39,7 @@ $(() => {
columnDefs: [{ targets: -1, orderable: false, searchable: false }]
});
table.on("keyup", ".user-filter", function () {
table.on("keyup", ".user-filter", function() {
const colName = $(this).data("col");
const colIndex = table.settings()[0].aoColumns.findIndex(c => c.name === colName);
@ -48,7 +48,7 @@ $(() => {
}
});
table.on("change", ".user-filter-select", function () {
table.on("change", ".user-filter-select", function() {
table.draw();
});

View File

@ -0,0 +1,67 @@
<div th:fragment="margenesPresupuestoForm">
<form id="margenesPresupuestoForm" novalidate th:action="${action}" th:object="${margenPresupuesto}" method="post" th:data-add="#{margenesPresupuesto.add}"
th:data-edit="#{margenesPresupuesto.editar}">
<input type="hidden" name="_method" value="PUT" th:if="${margenPresupuesto.id != null}" />
<div th:if="${#fields.hasGlobalErrors()}" class="alert alert-danger">
<div th:each="e : ${#fields.globalErrors()}" th:text="${e}"></div>
</div>
<div class="form-group">
<label th:text="#{margenes-presupuesto.form.tipo_encuadernacion}" for="tipo_encuadernacion">Tipo de Encuadernación</label>
<select class="form-control" id="tipo_encuadernacion" th:field="*{tipoEncuadernacion}" required
th:classappend="${#fields.hasErrors('tipoEncuadernacion')} ? ' is-invalid'">
<option value="fresado" th:text="#{presupuesto.fresado}" selected>Fresado</option>
<option value="cosido" th:text="#{presupuesto.cosido}">Cosido</option>
<option value="espiral" th:text="#{presupuesto.espiral}">Espiral</option>
<option value="wireo" th:text="#{presupuesto.wireo}">Wire-O</option>
<option value="grapado" th:text="#{presupuesto.grapado}">Grapado</option>
</select>
<div class="invalid-feedback" th:if="${#fields.hasErrors('tipoEncuadernacion')}" th:errors="*{tipoEncuadernacion}">Error</div>
</div>
<div class="form-group">
<label th:text="#{margenes-presupuesto.form.tipo_cubierta}" for="tipo_cubierta">Tipo de Cubierta</label>
<select class="form-control" id="tipo_cubierta" th:field="*{tipoCubierta}" required
th:classappend="${#fields.hasErrors('tipoCubierta')} ? ' is-invalid'">
<option value="tapaBlanda" th:text="#{presupuesto.tapaBlanda}" selected>Tapa Blanda</option>
<option value="tapaDura" th:text="#{presupuesto.tapaDura}">Tapa Dura</option>
<option value="tapaDuraLomoRedondo" th:text="#{presupuesto.tapaDuraLomoRedondo}">Tapa Dura Lomo Redondo</option>
</select>
<div class="invalid-feedback" th:if="${#fields.hasErrors('tipoCubierta')}" th:errors="*{tipoCubierta}">Error</div>
</div>
<div class="form-group">
<label th:text="#{margenes-presupuesto.form.tirada_minima}" for="tirada_minima">Tirada Mínima</label>
<input type="number" class="form-control" id="tirada_minima" th:field="*{tiradaMin}" min="1"
th:classappend="${#fields.hasErrors('tiradaMin')} ? ' is-invalid'" required>
<div class="invalid-feedback" th:if="${#fields.hasErrors('tiradaMin')}" th:errors="*{tiradaMin}">Error</div>
</div>
<div class="form-group">
<label th:text="#{margenes-presupuesto.form.tirada_maxima}" for="tirada_maxima">Tirada Máxima</label>
<input type="number" class="form-control" id="tirada_maxima" th:field="*{tiradaMax}" min="1"
th:classappend="${#fields.hasErrors('tiradaMax')} ? ' is-invalid'" required>
<div class="invalid-feedback" th:if="${#fields.hasErrors('tiradaMax')}" th:errors="*{tiradaMax}">Error</div>
</div>
<div class="form-group">
<label th:text="#{margenes-presupuesto.form.margen_maximo}" for="margen_maximo">Margen Máximo (%)</label>
<input type="number" class="form-control" id="margen_maximo" th:field="*{margenMax}" min="0" max="100" step="0.01"
th:classappend="${#fields.hasErrors('margenMax')} ? ' is-invalid'" required>
<div class="invalid-feedback" th:if="${#fields.hasErrors('margenMax')}" th:errors="*{margenMax}">Error</div>
</div>
<div class="form-group">
<label th:text="#{margenes-presupuesto.form.margen_minimo}" for="margen_minimo">Margen Mínimo (%)</label>
<input type="number" class="form-control" id="margen_minimo" th:field="*{margenMin}" min="0" max="100" step="0.01"
th:classappend="${#fields.hasErrors('margenMin')} ? ' is-invalid'" required>
<div class="invalid-feedback" th:if="${#fields.hasErrors('margenMin')}" th:errors="*{margenMin}">Error</div>
</div>
<div class="row mt-3 justified-content-center d-flex">
<button type="submit" class="btn btn-secondary" th:text="#{usuarios.guardar}">Guardar</button>
</div>
</form>
</div>

View File

@ -23,7 +23,7 @@
<!-- Modales-->
<div
th:replace="imprimelibros/partials/modal-form :: modal('userFormModal', 'usuarios.add', 'modal-md', 'userModalBody')">
th:replace="imprimelibros/partials/modal-form :: modal('margenesPresupuestoFormModal', 'margenes-presupuesto.add', 'modal-md', 'margenesPresupuestoModalBody')">
</div>
<nav aria-label="breadcrumb">
@ -72,16 +72,16 @@
</select>
</th>
<th>
<input type="text" class="form-control form-control-sm margenes-presupuesto-filter" data-col="tirada_min" />
<input type="text" class="form-control form-control-sm margenes-presupuesto-filter" data-col="tiradaMin" />
</th>
<th>
<input type="text" class="form-control form-control-sm margenes-presupuesto-filter" data-col="tirada_max" />
<input type="text" class="form-control form-control-sm margenes-presupuesto-filter" data-col="tiradaMax" />
</th>
<th>
<input type="text" class="form-control form-control-sm margenes-presupuesto-filter" data-col="margen_min" />
<input type="text" class="form-control form-control-sm margenes-presupuesto-filter" data-col="margenMax" />
</th>
<th>
<input type="text" class="form-control form-control-sm margenes-presupuesto-filter" data-col="margen_max" />
<input type="text" class="form-control form-control-sm margenes-presupuesto-filter" data-col="margenMin" />
</th>
<th></th>
</tr>

View File

@ -52,7 +52,7 @@
<div class="collapse menu-dropdown" id="sidebarConfig">
<ul class="nav nav-sm flex-column">
<li class="nav-item">
<a href="/configuracion/margenes-presupuestos" class="nav-link" th:text="#{margenes-presupuesto.titulo}">Márgenes de presupuesto</a>
<a href="/configuracion/margenes-presupuesto" class="nav-link" th:text="#{margenes-presupuesto.titulo}">Márgenes de presupuesto</a>
</li>
</ul>
</li>

View File

@ -11,6 +11,8 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import com.imprimelibros.erp.externalApi.skApiClient;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoCubierta;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoEncuadernacion;
@SpringBootTest
class skApiClientTest {
@ -76,7 +78,7 @@ class skApiClientTest {
body.put("faja", false);
body.put("servicios", servicios);
return apiClient.getPrice(body);
return apiClient.getPrice(body, TipoEncuadernacion.fresado, TipoCubierta.tapaBlanda);
}
}