añadidos margenes presupuesto

This commit is contained in:
2025-10-02 00:07:42 +02:00
parent add4e43955
commit 460d2cfc01
17 changed files with 733 additions and 10 deletions

View File

@ -4,7 +4,6 @@ import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
@ -24,9 +23,6 @@ import org.springframework.security.web.util.matcher.OrRequestMatcher;
import org.springframework.security.web.util.matcher.RequestHeaderRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import com.imprimelibros.erp.users.User;
import com.imprimelibros.erp.users.UserDao;
import com.imprimelibros.erp.users.UserDetailsImpl;
import com.imprimelibros.erp.users.UserServiceImpl;
import jakarta.servlet.http.HttpServletRequest;

View File

@ -0,0 +1,182 @@
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;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoEncuadernacion;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoCubierta;
import com.imprimelibros.erp.shared.validation.NoRangeOverlap;
@NoRangeOverlap(
min = "tiradaMin",
max = "tiradaMax",
id = "id",
partitionBy = {"tipoEncuadernacion","tipoCubierta"},
deletedFlag = "deleted", // <- si usas soft delete
deletedActiveValue = false, // activo cuando deleted == false
message = "{validation.range.overlaps}",
invalidRangeMessage = "{validation.range.invalid}"
)
@Entity
@Table(name = "margenes_presupuesto")
@SQLDelete(sql = "UPDATE margenes_presupuesto SET deleted = TRUE, deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted = false")
public class MargenPresupuesto {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name="tipo_encuadernacion", nullable = false, length = 50)
@NotNull(message="{validation.required}")
@Enumerated(EnumType.STRING)
private TipoEncuadernacion tipoEncuadernacion;
@Column(name="tipo_cubierta", nullable = false, length = 50)
@NotNull(message="{validation.required}")
@Enumerated(EnumType.STRING)
private TipoCubierta tipoCubierta;
@Column(name="tirada_min", nullable = false)
@NotBlank(message="{validation.required}")
@Min(value=1, message="{validation.min}")
private Integer tiradaMin;
@Column(name="tirada_max", nullable = false)
@NotBlank(message="{validation.required}")
@Min(value=1, message="{validation.min}")
private Integer tiradaMax;
@Column(name="margen_max", nullable = false)
@NotBlank(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}")
@Min(value = 0, message="{validation.min}")
@Max(value = 200, message="{validation.max}")
private Integer margenMin;
@Column(name="created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name="updated_at")
private LocalDateTime updatedAt;
@Column(nullable = false)
private boolean deleted = false;
@Column(name="deleted_at")
private LocalDateTime deletedAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public TipoEncuadernacion getTipoEncuadernacion() {
return tipoEncuadernacion;
}
public void setTipoEncuadernacion(TipoEncuadernacion tipoEncuadernacion) {
this.tipoEncuadernacion = tipoEncuadernacion;
}
public TipoCubierta getTipoCubierta() {
return tipoCubierta;
}
public void setTipoCubierta(TipoCubierta tipoCubierta) {
this.tipoCubierta = tipoCubierta;
}
public Integer getTiradaMin() {
return tiradaMin;
}
public void setTiradaMin(Integer tiradaMin) {
this.tiradaMin = tiradaMin;
}
public Integer getTiradaMax() {
return tiradaMax;
}
public void setTiradaMax(Integer tiradaMax) {
this.tiradaMax = tiradaMax;
}
public Integer getMargenMax() {
return margenMax;
}
public void setMargenMax(Integer margenMax) {
this.margenMax = margenMax;
}
public Integer getMargenMin() {
return margenMin;
}
public void setMargenMin(Integer margenMin) {
this.margenMin = margenMin;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public boolean isDeleted() {
return deleted;
}
public void setDeleted(boolean deleted) {
this.deleted = deleted;
}
public LocalDateTime getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(LocalDateTime deletedAt) {
this.deletedAt = deletedAt;
}
@PrePersist
void onCreate() {
this.createdAt = LocalDateTime.now();
this.updatedAt = this.createdAt;
}
@PreUpdate
void onUpdate() {
this.updatedAt = LocalDateTime.now();
}
}

View File

@ -0,0 +1,123 @@
package com.imprimelibros.erp.configuracion.margenes_presupuestos;
import java.util.List;
import java.util.Map;
import org.springframework.context.MessageSource;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import com.imprimelibros.erp.datatables.DataTable;
import com.imprimelibros.erp.datatables.DataTablesParser;
import com.imprimelibros.erp.datatables.DataTablesRequest;
import com.imprimelibros.erp.datatables.DataTablesResponse;
import com.imprimelibros.erp.i18n.TranslationService;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Locale;
import org.springframework.ui.Model;
@Controller
@RequestMapping("/configuracion/margenes-presupuestos")
@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) {
this.repo = repo;
this.translationService = translationService;
this.messageSource = messageSource;
}
@GetMapping()
public String listView(Model model, Authentication authentication, Locale locale) {
List<String> keys = List.of();
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
return "imprimelibros/configuracion/margenes-presupuesto/margenes-presupuesto-list";
}
@GetMapping(value = "/datatable", produces = "application/json")
@ResponseBody
public DataTablesResponse<Map<String, Object>> datatable(HttpServletRequest request, Authentication authentication,
Locale locale) {
DataTablesRequest dt = DataTablesParser.from(request); //
List<String> searchable = List.of(
"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();
return DataTable
.of(repo, MargenPresupuesto.class, dt, searchable) // 'searchable' en DataTable.java
// edita columnas "reales":
.orderable(orderable)
.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"
+ " <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"
+ " </div>";
})
.edit("tipoEncuadernacion", (margen) -> {
return messageSource.getMessage("presupuesto." + margen.getTipoEncuadernacion().name(),null, locale);
})
.edit("tipoCubierta", (margen) -> {
return messageSource.getMessage("presupuesto." + margen.getTipoCubierta().name(), null, locale);
})
.where(base)
// Filtros custom:
.filter((builder, req) -> {
// 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));
}
// 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);
});
}*/
})
.toJson(total);
}
}

View File

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

View File

@ -0,0 +1,43 @@
package com.imprimelibros.erp.configuracion.margenes_presupuestos;
import java.util.List;
import java.util.Optional;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoEncuadernacion;
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoCubierta;
@Service
@Transactional
public class MargenPresupuestoService {
private final MargenPresupuestoDao dao;
public MargenPresupuestoService(MargenPresupuestoDao dao) {
this.dao = dao;
}
public List<MargenPresupuesto> findAll() {
return dao.findAll();
}
public Optional<MargenPresupuesto> findById(Long id) {
return dao.findById(id);
}
public MargenPresupuesto save(MargenPresupuesto entity) {
return dao.save(entity);
}
public void delete(Long id) {
dao.deleteById(id);
}
public boolean hasOverlap(TipoEncuadernacion enc, TipoCubierta cub, Integer min, Integer max, Long excludeId) {
long count = dao.countOverlaps(enc, cub, min, max, excludeId);
return count > 0;
}
}

View File

@ -1,5 +1,7 @@
package com.imprimelibros.erp.datatables;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.domain.*;
import org.springframework.data.jpa.domain.Specification;
@ -26,7 +28,9 @@ public class DataTable<T> {
private final List<Function<Map<String, Object>, Map<String, Object>>> editors = new ArrayList<>();
private final List<FilterHook<T>> filters = new ArrayList<>();
private Specification<T> baseSpec = (root, q, cb) -> cb.conjunction();
private final ObjectMapper om = new ObjectMapper();
private final ObjectMapper om = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
private List<String> orderable = null;
private DataTable(JpaSpecificationExecutor<T> repo, Class<T> entityClass, DataTablesRequest dt,

View File

@ -0,0 +1,35 @@
package com.imprimelibros.erp.shared.validation;
import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import java.lang.annotation.*;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
@Documented
@Target(TYPE)
@Retention(RUNTIME)
@Constraint(validatedBy = NoRangeOverlapValidator.class)
public @interface NoRangeOverlap {
// Campos obligatorios
String min(); // nombre del campo min (Integer/Long/etc.)
String max(); // nombre del campo max
// Campos opcionales
String id() default "id"; // nombre del campo ID (para excluir self en update)
String[] partitionBy() default {}; // ej. {"tipoEncuadernacion","tipoCubierta"}
// Soft delete opcional
String deletedFlag() default ""; // ej. "deleted" (si vacío, no se aplica filtro)
boolean deletedActiveValue() default false; // qué valor significa "activo" (normalmente false)
// Mensajes
String message() default "{validation.range.overlaps}";
String invalidRangeMessage() default "{validation.range.invalid}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

View File

@ -0,0 +1,127 @@
package com.imprimelibros.erp.shared.validation;
import java.beans.IntrospectionException;
import java.beans.PropertyDescriptor;
import java.lang.reflect.Method;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.criteria.*;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class NoRangeOverlapValidator implements ConstraintValidator<NoRangeOverlap, Object> {
@PersistenceContext
private EntityManager em;
private String minField;
private String maxField;
private String idField;
private String[] partitionFields;
private String deletedFlag;
private boolean deletedActiveValue;
private String message;
private String invalidRangeMessage;
@Override
public void initialize(NoRangeOverlap ann) {
this.minField = ann.min();
this.maxField = ann.max();
this.idField = ann.id();
this.partitionFields = ann.partitionBy();
this.deletedFlag = ann.deletedFlag();
this.deletedActiveValue = ann.deletedActiveValue();
this.message = ann.message();
this.invalidRangeMessage = ann.invalidRangeMessage();
}
@Override
public boolean isValid(Object bean, ConstraintValidatorContext ctx) {
if (bean == null) return true;
try {
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.longValue() > max.longValue()) {
ctx.disableDefaultConstraintViolation();
ctx.buildConstraintViolationWithTemplate(invalidRangeMessage)
.addPropertyNode(maxField)
.addConstraintViolation();
return false;
}
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Long> cq = cb.createQuery(Long.class);
Root<?> root = cq.from(entityClass);
cq.select(cb.count(root));
Predicate pred = cb.conjunction();
if (id != null) {
pred = cb.and(pred, cb.notEqual(root.get(idField), id));
}
for (String pf : partitionFields) {
Object val = read(bean, pf);
pred = cb.and(pred, cb.equal(root.get(pf), val));
}
if (!deletedFlag.isEmpty()) {
pred = cb.and(pred, cb.equal(root.get(deletedFlag), deletedActiveValue));
}
Expression<Number> eMin = root.get(minField);
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())
);
Predicate overlap = cb.not(noOverlap);
cq.where(cb.and(pred, overlap));
Long count = em.createQuery(cq).getSingleResult();
if (count != null && count > 0) {
ctx.disableDefaultConstraintViolation();
ctx.buildConstraintViolationWithTemplate(message)
.addPropertyNode(minField).addConstraintViolation();
ctx.buildConstraintViolationWithTemplate(message)
.addPropertyNode(maxField).addConstraintViolation();
return false;
}
return true;
} catch (Exception ex) {
// En caso de error inesperado, puedes loguear aquí
return true;
}
}
private Object read(Object bean, String name) throws Exception {
PropertyDescriptor pd = getPropertyDescriptor(bean.getClass(), name);
Method getter = pd.getReadMethod();
return getter.invoke(bean);
}
private Object safeRead(Object bean, String name) {
try {
return read(bean, name);
} catch (Exception ignore) {
return null;
}
}
private PropertyDescriptor getPropertyDescriptor(Class<?> clazz, String name) throws IntrospectionException {
return new PropertyDescriptor(name, clazz);
}
}

View File

@ -8,7 +8,6 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.MessageSource;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;