Merge branch 'feat/facturas' into 'main'

Feat/facturas

See merge request jjimenez/erp-imprimelibros!31
This commit is contained in:
2026-01-07 20:22:12 +00:00
80 changed files with 18153 additions and 7062 deletions

18316
logs/erp.log

File diff suppressed because one or more lines are too long

View File

@ -101,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);

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

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

@ -16,8 +16,6 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseBody;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imprimelibros.erp.common.Utils; import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.datatables.DataTable; import com.imprimelibros.erp.datatables.DataTable;
import com.imprimelibros.erp.datatables.DataTablesParser; import com.imprimelibros.erp.datatables.DataTablesParser;

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;
@ -14,7 +17,6 @@ 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.Pedido;
import com.imprimelibros.erp.pedidos.PedidoLinea;
import com.imprimelibros.erp.pedidos.PedidoService; import com.imprimelibros.erp.pedidos.PedidoService;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@ -33,6 +35,7 @@ public class PaymentService {
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 PedidoService pedidoService;
private final FacturacionService facturacionService;
public PaymentService(PaymentRepository payRepo, public PaymentService(PaymentRepository payRepo,
PaymentTransactionRepository txRepo, PaymentTransactionRepository txRepo,
@ -40,7 +43,8 @@ public class PaymentService {
RedsysService redsysService, RedsysService redsysService,
WebhookEventRepository webhookEventRepo, WebhookEventRepository webhookEventRepo,
CartService cartService, CartService cartService,
PedidoService pedidoService) { PedidoService pedidoService,
FacturacionService facturacionService) {
this.payRepo = payRepo; this.payRepo = payRepo;
this.txRepo = txRepo; this.txRepo = txRepo;
this.refundRepo = refundRepo; this.refundRepo = refundRepo;
@ -48,6 +52,7 @@ public class PaymentService {
this.webhookEventRepo = webhookEventRepo; this.webhookEventRepo = webhookEventRepo;
this.cartService = cartService; this.cartService = cartService;
this.pedidoService = pedidoService; this.pedidoService = pedidoService;
this.facturacionService = facturacionService;
} }
public Payment findFailedPaymentByOrderId(Long orderId) { public Payment findFailedPaymentByOrderId(Long orderId) {
@ -253,6 +258,11 @@ public class PaymentService {
p.setCapturedAt(LocalDateTime.now()); p.setCapturedAt(LocalDateTime.now());
pedidoService.setOrderAsPaid(p.getOrderId()); 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());
@ -452,6 +462,11 @@ public class PaymentService {
// 4) Procesar el pedido asociado al carrito (si existe) o marcar el pedido como pagado // 4) Procesar el pedido asociado al carrito (si existe) o marcar el pedido como pagado
if(p.getOrderId() != null) { if(p.getOrderId() != null) {
pedidoService.setOrderAsPaid(p.getOrderId()); 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) { /*else if (cartId != null) {
// Se procesa el pedido dejando el estado calculado en processOrder // Se procesa el pedido dejando el estado calculado en processOrder

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

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

@ -20,7 +20,8 @@ public class PedidoLinea {
ferro_cliente("pedido.estado.ferro_cliente", 8), ferro_cliente("pedido.estado.ferro_cliente", 8),
produccion("pedido.estado.produccion", 9), produccion("pedido.estado.produccion", 9),
terminado("pedido.estado.terminado", 10), terminado("pedido.estado.terminado", 10),
cancelado("pedido.estado.cancelado", 11); enviado("pedido.estado.enviado", 11),
cancelado("pedido.estado.cancelado", 12);
private final String messageKey; private final String messageKey;
private final int priority; private final int priority;

View File

@ -1,6 +1,7 @@
package com.imprimelibros.erp.pedidos; package com.imprimelibros.erp.pedidos;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
@ -9,7 +10,25 @@ import java.util.List;
public interface PedidoLineaRepository extends JpaRepository<PedidoLinea, Long> { public interface PedidoLineaRepository extends JpaRepository<PedidoLinea, Long> {
List<PedidoLinea> findByPedidoId(Long pedidoId); List<PedidoLinea> findByPedidoId(Long pedidoId);
List<PedidoLinea> findByPedidoIdOrderByIdAsc(Long pedidoId); List<PedidoLinea> findByPedidoIdOrderByIdAsc(Long pedidoId);
List<PedidoLinea> findByPresupuestoId(Long presupuestoId); 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

@ -24,6 +24,8 @@ import com.imprimelibros.erp.presupuesto.service.PresupuestoService;
import com.imprimelibros.erp.users.UserService; import com.imprimelibros.erp.users.UserService;
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.facturacion.FacturaDireccion;
import com.imprimelibros.erp.facturacion.dto.DireccionFacturacionDto;
import com.imprimelibros.erp.pedidos.PedidoLinea.Estado; import com.imprimelibros.erp.pedidos.PedidoLinea.Estado;
@Service @Service
@ -59,6 +61,14 @@ public class PedidoService {
this.messageSource = messageSource; this.messageSource = messageSource;
} }
public Pedido getPedidoById(Long pedidoId) {
return pedidoRepository.findById(pedidoId).orElse(null);
}
public PedidoDireccion getPedidoDireccionFacturacionByPedidoId(Long pedidoId) {
return pedidoDireccionRepository.findByPedidoIdAndFacturacionTrue(pedidoId);
}
@Transactional @Transactional
public Pedido crearPedido( public Pedido crearPedido(
Long cartId, Long cartId,
@ -86,10 +96,11 @@ public class PedidoService {
} }
// Auditoría mínima // Auditoría mínima
/*Long userId = cart.getUserId(); /*
pedido.setCreatedBy(userService.findById(userId)); * Long userId = cart.getUserId();
pedido.setUpdatedBy(userService.findById(userId)); * pedido.setCreatedBy(userService.findById(userId));
*/ * pedido.setUpdatedBy(userService.findById(userId));
*/
// Se obtiene el usuario del primer presupuesto del carrito // Se obtiene el usuario del primer presupuesto del carrito
Long userId = null; Long userId = null;
List<CartItem> cartItems = cart.getItems(); List<CartItem> cartItems = cart.getItems();
@ -99,7 +110,7 @@ public class PedidoService {
userId = firstPresupuesto.getUser().getId(); userId = firstPresupuesto.getUser().getId();
} }
} }
if(userId == null){ if (userId == null) {
userId = cart.getUserId(); userId = cart.getUserId();
} }
pedido.setCreatedBy(userService.findById(userId)); pedido.setCreatedBy(userService.findById(userId));
@ -107,7 +118,6 @@ public class PedidoService {
pedido.setCreatedAt(Instant.now()); pedido.setCreatedAt(Instant.now());
pedido.setDeleted(false); pedido.setDeleted(false);
pedido.setUpdatedAt(Instant.now()); pedido.setUpdatedAt(Instant.now());
// Guardamos el pedido // Guardamos el pedido
Pedido pedidoGuardado = pedidoRepository.save(pedido); Pedido pedidoGuardado = pedidoRepository.save(pedido);
@ -177,6 +187,36 @@ public class PedidoService {
return pedidoRepository.findById(pedidoId).orElse(null); 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 */ /** Lista de los items del pedido preparados para la vista */
@Transactional @Transactional
public List<Map<String, Object>> getLineas(Long pedidoId, Locale locale) { public List<Map<String, Object>> getLineas(Long pedidoId, Locale locale) {
@ -260,43 +300,81 @@ public class PedidoService {
} }
public Map<String, Object> actualizarEstado(Long pedidoLineaId, Locale locale) { public Map<String, Object> actualizarEstado(Long pedidoLineaId, Locale locale) {
PedidoLinea pedidoLinea = pedidoLineaRepository.findById(pedidoLineaId).orElse(null); PedidoLinea pedidoLinea = pedidoLineaRepository.findById(pedidoLineaId).orElse(null);
if (pedidoLinea == null) { if (pedidoLinea == null) {
return Map.of("success", false, return Map.of(
"success", false,
"message", messageSource.getMessage("pedido.errors.linea-not-found", null, locale)); "message", messageSource.getMessage("pedido.errors.linea-not-found", null, locale));
} }
if (pedidoLinea.getEstado().getPriority() >= PedidoLinea.Estado.haciendo_ferro.getPriority() && PedidoLinea.Estado estadoOld = pedidoLinea.getEstado();
pedidoLinea.getEstado().getPriority() < PedidoLinea.Estado.terminado.getPriority()) { if (estadoOld == null) {
PedidoLinea.Estado estadoOld = pedidoLinea.getEstado(); return Map.of(
Map<String, Object> result = skApiClient.checkPedidoEstado( "success", false,
Long.valueOf(pedidoLinea.getPresupuesto().getProveedorRef2().toString()), locale); "message", messageSource.getMessage("pedido.errors.cannot-update", null, locale));
if (result == null || !result.containsKey("estado")) { }
return Map.of(
"success", false,
"message", messageSource.getMessage("pedido.errors.update-server-error", null, locale));
}
PedidoLinea.Estado estadoSk = PedidoLinea.Estado.valueOf((String) result.get("estado"));
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); // Rango: >= haciendo_ferro y < enviado
pedidoLineaRepository.save(pedidoLinea); 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( return Map.of(
"success", true, "success", true,
"state", messageSource.getMessage("pedido.estado." + estadoSk.name(), null, locale), "state", messageSource.getMessage("pedido.estado." + estadoSk.name(), null, locale),
"stateKey", estadoSk.name(), "stateKey", estadoSk.name(),
"message", messageSource.getMessage("pedido.success.estado-actualizado", null, locale)); "message", messageSource.getMessage("pedido.success.same-estado", null, locale));
} }
pedidoLinea.setEstado(estadoSk);
pedidoLineaRepository.save(pedidoLinea);
return Map.of( return Map.of(
"success", false, "success", true,
"message", messageSource.getMessage("pedido.errors.cannot-update", null, locale)); "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) { public Boolean markPedidoAsMaquetacionDone(Long pedidoId) {
@ -325,7 +403,6 @@ public class PedidoService {
return files; return files;
} }
public byte[] getFerroFileContent(Long pedidoLineaId, Locale locale) { public byte[] getFerroFileContent(Long pedidoLineaId, Locale locale) {
return downloadFile(pedidoLineaId, "ferro", locale); return downloadFile(pedidoLineaId, "ferro", locale);
} }
@ -356,7 +433,6 @@ public class PedidoService {
return true; return true;
} }
public Boolean cancelarPedido(Long pedidoId) { public Boolean cancelarPedido(Long pedidoId) {
Pedido pedido = pedidoRepository.findById(pedidoId).orElse(null); Pedido pedido = pedidoRepository.findById(pedidoId).orElse(null);
@ -369,7 +445,7 @@ public class PedidoService {
} }
List<PedidoLinea> lineas = pedidoLineaRepository.findByPedidoId(pedidoId); List<PedidoLinea> lineas = pedidoLineaRepository.findByPedidoId(pedidoId);
for (PedidoLinea linea : lineas) { for (PedidoLinea linea : lineas) {
if (linea.getEstado() != PedidoLinea.Estado.terminado) { if (linea.getEstado() != PedidoLinea.Estado.terminado && linea.getEstado() != PedidoLinea.Estado.enviado) {
linea.setEstado(PedidoLinea.Estado.cancelado); linea.setEstado(PedidoLinea.Estado.cancelado);
pedidoLineaRepository.save(linea); pedidoLineaRepository.save(linea);
} }
@ -378,8 +454,6 @@ public class PedidoService {
return true; return true;
} }
/*************************** /***************************
* MÉTODOS PRIVADOS * MÉTODOS PRIVADOS
***************************/ ***************************/

View File

@ -26,6 +26,7 @@ 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.facturacion.service.FacturacionService;
import com.imprimelibros.erp.i18n.TranslationService; import com.imprimelibros.erp.i18n.TranslationService;
import com.imprimelibros.erp.paises.PaisesService; import com.imprimelibros.erp.paises.PaisesService;
import com.imprimelibros.erp.presupuesto.service.PresupuestoService; import com.imprimelibros.erp.presupuesto.service.PresupuestoService;
@ -52,10 +53,12 @@ public class PedidosController {
private final PedidoLineaRepository repoPedidoLinea; private final PedidoLineaRepository repoPedidoLinea;
private final PaisesService paisesService; private final PaisesService paisesService;
private final TranslationService translationService; private final TranslationService translationService;
private final FacturacionService facturacionService;
public PedidosController(PedidoRepository repoPedido, PedidoService pedidoService, UserDao repoUser, public PedidosController(PedidoRepository repoPedido, PedidoService pedidoService, UserDao repoUser,
MessageSource messageSource, TranslationService translationService, MessageSource messageSource, TranslationService translationService,
PedidoLineaRepository repoPedidoLinea, PaisesService paisesService, PresupuestoService presupuestoService) { PedidoLineaRepository repoPedidoLinea, PaisesService paisesService,
FacturacionService facturacionService, PresupuestoService presupuestoService) {
this.repoPedido = repoPedido; this.repoPedido = repoPedido;
this.pedidoService = pedidoService; this.pedidoService = pedidoService;
this.repoUser = repoUser; this.repoUser = repoUser;
@ -63,6 +66,7 @@ public class PedidosController {
this.translationService = translationService; this.translationService = translationService;
this.repoPedidoLinea = repoPedidoLinea; this.repoPedidoLinea = repoPedidoLinea;
this.paisesService = paisesService; this.paisesService = paisesService;
this.facturacionService = facturacionService;
this.presupuestoService = presupuestoService; this.presupuestoService = presupuestoService;
} }
@ -236,6 +240,7 @@ public class PedidosController {
model.addAttribute("direccionFacturacion", direccionFacturacion); model.addAttribute("direccionFacturacion", direccionFacturacion);
Boolean showCancel = false; Boolean showCancel = false;
Boolean showDownloadFactura = true;
List<Map<String, Object>> lineas = pedidoService.getLineas(id, locale); List<Map<String, Object>> lineas = pedidoService.getLineas(id, locale);
for (Map<String, Object> linea : lineas) { for (Map<String, Object> linea : lineas) {
@ -243,6 +248,9 @@ public class PedidosController {
((Number) linea.get("lineaId")).longValue()).orElse(null); ((Number) linea.get("lineaId")).longValue()).orElse(null);
if (pedidoLinea != null) { if (pedidoLinea != null) {
Map<String, Boolean> buttons = new HashMap<>(); Map<String, Boolean> buttons = new HashMap<>();
if (pedidoLinea.getEstado() != PedidoLinea.Estado.enviado) {
showDownloadFactura = false;
}
if (pedidoLinea.getEstado().getPriority() >= PedidoLinea.Estado.esperando_aceptacion_ferro.getPriority() if (pedidoLinea.getEstado().getPriority() >= PedidoLinea.Estado.esperando_aceptacion_ferro.getPriority()
&& pedidoLinea.getEstado().getPriority() <= PedidoLinea.Estado.produccion.getPriority()) { && pedidoLinea.getEstado().getPriority() <= PedidoLinea.Estado.produccion.getPriority()) {
@ -263,8 +271,10 @@ public class PedidosController {
linea.put("buttons", buttons); linea.put("buttons", buttons);
} }
if(pedidoLinea.getEstado() != PedidoLinea.Estado.cancelado && pedidoLinea.getEstado() != PedidoLinea.Estado.terminado) { if (pedidoLinea.getEstado() != PedidoLinea.Estado.cancelado
showCancel = true; && pedidoLinea.getEstado() != PedidoLinea.Estado.terminado
&& pedidoLinea.getEstado() != PedidoLinea.Estado.enviado) {
showCancel = true;
} }
} }
@ -280,8 +290,16 @@ public class PedidosController {
linea.put("direccionesEntrega", dirEntrega); linea.put("direccionesEntrega", dirEntrega);
} }
Long facturaId = null;
if (showDownloadFactura) {
facturaId = facturacionService.getFacturaIdFromPedidoId(id);
}
model.addAttribute("lineas", lineas); model.addAttribute("lineas", lineas);
model.addAttribute("showCancel", showCancel); model.addAttribute("showCancel", showCancel);
if (showDownloadFactura && facturaId != null) {
model.addAttribute("facturaId", facturaId);
model.addAttribute("showDownloadFactura", showDownloadFactura);
}
model.addAttribute("id", id); model.addAttribute("id", id);
return "imprimelibros/pedidos/pedidos-view"; return "imprimelibros/pedidos/pedidos-view";
} }

View File

@ -1,15 +1,12 @@
package com.imprimelibros.erp.redsys; package com.imprimelibros.erp.redsys;
import com.imprimelibros.erp.cart.Cart;
import com.imprimelibros.erp.common.Utils; 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.payments.repo.PaymentTransactionRepository;
import com.imprimelibros.erp.pedidos.Pedido; import com.imprimelibros.erp.pedidos.Pedido;
import com.imprimelibros.erp.pedidos.PedidoService; import com.imprimelibros.erp.pedidos.PedidoService;
import com.imprimelibros.erp.redsys.RedsysService.FormPayload; import com.imprimelibros.erp.redsys.RedsysService.FormPayload;
import groovy.util.logging.Log;
import jakarta.servlet.ServletContext; import jakarta.servlet.ServletContext;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;

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

@ -42,4 +42,10 @@ databaseChangeLog:
- include: - include:
file: db/changelog/changesets/0021-add-email-and-is-palets-to-pedidos-direcciones.yml file: db/changelog/changesets/0021-add-email-and-is-palets-to-pedidos-direcciones.yml
- include: - include:
file: db/changelog/changesets/0022-add-estados-pago-to-pedidos-lineas-3.yml 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

@ -23,10 +23,13 @@ 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.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
app.sidebar.direcciones-admin=Administrar Direcciones 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

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

@ -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
@ -29,6 +31,26 @@ 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. 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
pdf.politica-privacidad.correo-direccion=Correo electrónico: info@imprimelibros.com - Dirección postal: Calle José Picón, Nº 28 Local A, 28028, Madrid pdf.politica-privacidad.correo-direccion=Correo electrónico: info@imprimelibros.com - Dirección postal: Calle José Picón, Nº 28 Local A, 28028, Madrid

View File

@ -28,6 +28,7 @@ pedido.estado.esperando_aceptacion_ferro=Esperando aceptación de ferro
pedido.estado.ferro_cliente=Esperando aprobación de ferro pedido.estado.ferro_cliente=Esperando aprobación de ferro
pedido.estado.produccion=Producción pedido.estado.produccion=Producción
pedido.estado.terminado=Terminado pedido.estado.terminado=Terminado
pedido.estado.enviado=Enviado
pedido.estado.cancelado=Cancelado pedido.estado.cancelado=Cancelado
pedido.module-title=Pedidos pedido.module-title=Pedidos
@ -56,7 +57,9 @@ pedido.view.aceptar-ferro=Aceptar ferro
pedido.view.ferro-download=Descargar ferro pedido.view.ferro-download=Descargar ferro
pedido.view.cub-download=Descargar cubierta pedido.view.cub-download=Descargar cubierta
pedido.view.tapa-download=Descargar tapa pedido.view.tapa-download=Descargar tapa
pedido.view.descargar-factura=Descargar factura
pedido.view.admin-actions=Acciones de administrador 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-title=¿Estás seguro de que deseas cancelar este pedido?
pedido.view.cancel-text=Esta acción no se puede deshacer. pedido.view.cancel-text=Esta acción no se puede deshacer.

View File

@ -0,0 +1,26 @@
series-facturacion.title=Series de Facturación
series-facturacion.breadcrumb=Series de Facturación
series-facturacion.tabla.id=ID
series-facturacion.tabla.nombre=Nombre
series-facturacion.tabla.prefijo=Prefijo
series-facturacion.tabla.tipo=Tipo
series-facturacion.tabla.numero-actual=Número Actual
series-facturacion.tabla.acciones=Acciones
series-facturacion.delete.title=¿Estás seguro de que deseas eliminar esta serie de facturación?
series-facturacion.delete.text=Esta acción no se puede deshacer.
series-facturacion.delete.ok.title=Serie de facturación eliminada
series-facturacion.delete.ok.text=La serie de facturación ha sido eliminada correctamente.
series-facturacion.tipo.facturacion=Facturación
series-facturacion.form.nombre=Nombre
series-facturacion.form.prefijo=Prefijo
series-facturacion.form.prefijo.help=Ej: FAC, DIG, REC...
series-facturacion.form.tipo=Tipo
series-facturacion.tipo.facturacion=Facturación
series-facturacion.form.numero-actual=Número actual
series-facturacion.modal.title.add=Nueva Serie de Facturación
series-facturacion.modal.title.edit=Editar Serie de Facturación

View File

@ -2914,6 +2914,19 @@ File: Main Css File
background-color: #0ac7fb !important; background-color: #0ac7fb !important;
} }
.accordion-fill-imprimelibros .accordion-item .accordion-button {
-webkit-box-shadow: none;
box-shadow: none;
}
.accordion-fill-imprimelibros .accordion-item .accordion-button:not(.collapsed) {
color: #fff;
background-color: #92b2a7 !important;
}
.accordion-fill-imprimelibros .accordion-item .accordion-button:is(.collapsed) {
color: #fff;
background-color: #4c5c63 !important;
}
.accordion-warning .accordion-item { .accordion-warning .accordion-item {
border-color: rgba(239, 174, 78, 0.6); border-color: rgba(239, 174, 78, 0.6);
} }
@ -11985,19 +11998,19 @@ div.dtr-modal div.dtr-modal-close:hover {
bottom: 100%; bottom: 100%;
} }
.flatpickr-calendar.arrowTop::before { .flatpickr-calendar.arrowTop::before {
border-bottom-color: #687cfe; border-bottom-color: #92b2a7;
} }
.flatpickr-calendar.arrowTop::after { .flatpickr-calendar.arrowTop::after {
border-bottom-color: #687cfe; border-bottom-color: #92b2a7;
} }
.flatpickr-calendar.arrowBottom::before, .flatpickr-calendar.arrowBottom::after { .flatpickr-calendar.arrowBottom::before, .flatpickr-calendar.arrowBottom::after {
top: 100%; top: 100%;
} }
.flatpickr-calendar.arrowBottom::before { .flatpickr-calendar.arrowBottom::before {
border-top-color: #687cfe; border-top-color: #92b2a7;
} }
.flatpickr-calendar.arrowBottom::after { .flatpickr-calendar.arrowBottom::after {
border-top-color: #687cfe; border-top-color: #92b2a7;
} }
.flatpickr-calendar:focus { .flatpickr-calendar:focus {
outline: 0; outline: 0;
@ -12012,7 +12025,7 @@ div.dtr-modal div.dtr-modal-close:hover {
display: -webkit-box; display: -webkit-box;
display: -ms-flexbox; display: -ms-flexbox;
display: flex; display: flex;
background-color: #687cfe; background-color: #92b2a7;
border-radius: 5px 5px 0px 0px; border-radius: 5px 5px 0px 0px;
} }
.flatpickr-months .flatpickr-month { .flatpickr-months .flatpickr-month {
@ -12284,7 +12297,7 @@ div.dtr-modal div.dtr-modal-close:hover {
} }
.flatpickr-weekdays { .flatpickr-weekdays {
background-color: #687cfe; background-color: #92b2a7;
text-align: center; text-align: center;
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;
@ -12309,7 +12322,7 @@ div.dtr-modal div.dtr-modal-close:hover {
span.flatpickr-weekday { span.flatpickr-weekday {
cursor: default; cursor: default;
font-size: 90%; font-size: 90%;
background: #687cfe; background: #92b2a7;
color: #fff; color: #fff;
line-height: 1; line-height: 1;
margin: 0; margin: 0;
@ -12411,11 +12424,11 @@ span.flatpickr-weekday {
color: var(--vz-dark); color: var(--vz-dark);
} }
.flatpickr-day.selected, .flatpickr-day.startRange, .flatpickr-day.endRange, .flatpickr-day.selected.inRange, .flatpickr-day.startRange.inRange, .flatpickr-day.endRange.inRange, .flatpickr-day.selected:focus, .flatpickr-day.startRange:focus, .flatpickr-day.endRange:focus, .flatpickr-day.selected:hover, .flatpickr-day.startRange:hover, .flatpickr-day.endRange:hover, .flatpickr-day.selected.prevMonthDay, .flatpickr-day.startRange.prevMonthDay, .flatpickr-day.endRange.prevMonthDay, .flatpickr-day.selected.nextMonthDay, .flatpickr-day.startRange.nextMonthDay, .flatpickr-day.endRange.nextMonthDay { .flatpickr-day.selected, .flatpickr-day.startRange, .flatpickr-day.endRange, .flatpickr-day.selected.inRange, .flatpickr-day.startRange.inRange, .flatpickr-day.endRange.inRange, .flatpickr-day.selected:focus, .flatpickr-day.startRange:focus, .flatpickr-day.endRange:focus, .flatpickr-day.selected:hover, .flatpickr-day.startRange:hover, .flatpickr-day.endRange:hover, .flatpickr-day.selected.prevMonthDay, .flatpickr-day.startRange.prevMonthDay, .flatpickr-day.endRange.prevMonthDay, .flatpickr-day.selected.nextMonthDay, .flatpickr-day.startRange.nextMonthDay, .flatpickr-day.endRange.nextMonthDay {
background: #687cfe; background: #92b2a7;
-webkit-box-shadow: none; -webkit-box-shadow: none;
box-shadow: none; box-shadow: none;
color: #fff; color: #fff;
border-color: #687cfe; border-color: #92b2a7;
} }
.flatpickr-day.selected.startRange, .flatpickr-day.startRange.startRange, .flatpickr-day.endRange.startRange { .flatpickr-day.selected.startRange, .flatpickr-day.startRange.startRange, .flatpickr-day.endRange.startRange {
border-radius: 50px 0 0 50px; border-radius: 50px 0 0 50px;

View File

@ -0,0 +1,476 @@
:root {
--verde: #92b2a7;
--letterspace: 8px;
/* ← puedes ajustar este valor en el root */
-ink: #1b1e28;
--muted: #5b6472;
--accent: #0ea5e9;
/* azul tira a cyan */
--line: #e6e8ef;
--bg-tag: #f4f7fb;
}
/* Open Sans (rutas relativas desde css → fonts) */
@font-face {
font-family: "Open Sans";
src: url("../fonts/OpenSans-Regular.ttf") format("truetype");
font-weight: 400;
}
@font-face {
font-family: "Open Sans";
src: url("../fonts/OpenSans-SemiBold.ttf") format("truetype");
font-weight: 600;
}
@font-face {
font-family: "Open Sans";
src: url("../fonts/OpenSans-Bold.ttf") format("truetype");
font-weight: 700;
}
@page {
size: A4;
margin: 15mm 14mm 47mm 14mm;
@bottom-center {
content: element(footer);
/* llamamos al elemento “footer” */
}
}
html,
body {
font-family: "Open Sans" !important;
color: var(--ink);
font-size: 11pt;
line-height: 1.35;
}
.page-content {
padding: 0;
/* ↑ deja 10mm extra para no pisar el footer */
box-sizing: border-box;
/* para que el padding no desborde */
}
body.has-watermark {
background-image: none !important;
}
/* ====== HEADER (tabla) ====== */
.il-header {
width: 100%;
border-collapse: collapse;
margin: 0 0 8mm 0;
/* ↓ espacio bajo el header */
}
.il-left,
.il-right {
vertical-align: middle;
}
.il-left {
width: 50%;
}
.il-right {
width: 50%;
text-align: right;
}
.il-logo {
height: 70px;
}
/* ← tamaño logo */
/* Caja superior derecha con esquinas */
.il-company-box {
display: inline-block;
align-items: end;
/* para alinear a la derecha sin ocupar todo */
position: relative;
padding: 4mm 4mm;
/* ← espacio texto ↔ esquinas */
color: #000;
font-size: 10.5pt;
/* ← tamaño de letra */
line-height: 1;
/* ← separación entre líneas */
max-width: 75mm;
/* ← ancho máximo de la caja */
text-align: center;
}
/* Esquinas */
.il-company-box .corner {
position: absolute;
width: 20px;
/* ← anchura esquina */
height: 20px;
/* ← altura esquina */
border-color: #92b2a7;
/* ← color esquina */
}
.corner.tl {
top: 0;
left: 0;
border-top: 2px solid #92b2a7;
border-left: 2px solid #92b2a7;
}
.corner.tr {
top: 0;
right: 0;
border-top: 2px solid #92b2a7;
border-right: 2px solid #92b2a7;
}
.corner.bl {
bottom: 0;
left: 0;
border-bottom: 2px solid #92b2a7;
border-left: 2px solid #92b2a7;
}
.corner.br {
bottom: 0;
right: 0;
border-bottom: 2px solid #92b2a7;
border-right: 2px solid #92b2a7;
}
.company-line {
margin: 1.5mm 0;
}
/* Nueva banda verde PRESUPUESTO */
.doc-banner {
width: 100%;
background-color: #92b2a7 !important;
/* ← tu verde corporativo */
color: white;
text-align: center;
padding: 2mm 0;
margin-bottom: 4mm;
display: block;
/* evita conflictos */
}
.banner-text {
font-family: "Open Sans", Arial, sans-serif !important;
font-weight: 400;
font-size: 20pt;
letter-spacing: 8px;
/* ← configurable */
}
/* ficha superior */
.sheet-info {
width: 100%;
border-collapse: collapse;
margin: 4mm 0 6mm 0;
font-size: 10.5pt;
}
.sheet-info td {
border: 1px solid var(--line);
padding: 4px 6px;
}
.sheet-info .lbl {
color: var(--muted);
margin-right: 4px;
}
/*.sheet-info .val {
}*/
/* Línea título libro */
.line-title {
font-family: "Open Sans", Arial, sans-serif !important;
margin: 3mm 0 5mm 0;
padding: 2px 4px;
font-size: 10.5pt;
font-weight: 600;
color: #5c5c5c;
}
.line-title .lbl {
margin-right: 6px;
font-weight: 600;
}
/* Specs 2 columnas */
.specs-wrapper {
width: 180mm;
margin-left: 15mm;
/* ← margen izquierdo real del A4 */
margin-right: auto;
/* opcional */
color: #5c5c5c;
font-size: 9pt;
}
.align-with-text {
margin-left: 1mm;
margin-right: 0;
width: auto;
}
.specs {
display: table;
width: 100%;
table-layout: fixed;
margin-bottom: 6mm;
}
.specs .col {
display: table-cell;
width: 50%;
padding-right: 6mm;
vertical-align: top;
}
.specs .col:last-child {
padding-right: 0;
}
/* Listas sin margen superior por defecto */
ul,
ol {
margin-top: 0;
margin-bottom: 0rem;
/* si quieres algo abajo */
padding-left: 1.25rem;
/* sangría */
}
/* Párrafos con menos margen inferior */
p {
margin: 0 0 .5rem;
}
/* Si una lista va justo después de un texto o título, que no tenga hueco arriba */
p+ul,
p+ol,
h1+ul,
h2+ul,
h3+ul,
h4+ul,
h5+ul,
h6+ul,
div+ul,
div+ol {
margin-top: 0;
}
.block-title {
text-transform: uppercase;
font-weight: 700;
color: var(--accent);
font-size: 8pt;
margin: 2mm 0 1mm 0;
}
.kv {
margin: 1mm 0;
}
.kv span {
color: var(--muted);
display: inline-block;
min-width: 55%;
}
.kv b {
font-weight: 600;
}
.subblock {
margin-top: 3mm;
}
.services {
margin: 0;
padding-left: 14px;
}
.services li {
margin: 1mm 0;
}
/* Bloque marcapáginas */
.bookmark {
margin-top: 4mm;
border: 1px dashed var(--line);
padding: 3mm;
background: #fff;
}
.bookmark .bk-title {
font-weight: 700;
margin-bottom: 2mm;
}
/* Tabla de precios (tiradas) */
.prices {
width: 100%;
border-collapse: collapse;
margin-top: 6mm;
font-size: 10.5pt;
}
.prices thead th {
text-align: left;
padding: 3px;
border-bottom: 2px solid var(--accent);
background: #eef8fe;
font-weight: 500;
}
.prices tbody td {
border-bottom: 1px solid var(--line);
padding: 3px;
}
.prices .col-tirada {
width: 22%;
font-weight: 500;
}
/* Footer */
.footer {
position: fixed;
left: 14mm;
right: 14mm;
bottom: 18mm;
border-top: 1px solid var(--line);
padding-top: 4mm;
font-size: 7.5pt;
color: var(--muted);
z-index: 10;
/* sobre la marca */
background: transparent;
}
.footer .address {
display: table-cell;
width: 45%;
}
.footer .privacy {
display: table-cell;
width: 55%;
}
.pv-title {
font-weight: 700;
margin-bottom: 1mm;
color: var(--ink);
}
.pv-text {
line-height: 1.25;
}
.page-count {
margin-top: 2mm;
text-align: right;
font-size: 9pt;
color: var(--muted);
}
.page::after {
content: counter(page);
}
.pages::after {
content: counter(pages);
}
.items-table {
width: 100%;
border-color: #92b2a7;
border-collapse: collapse;
}
.items-table thead th {
background-color: #f3f6f9;
font-size: small;
}
.items-table tbody td {
font-size: small;
color: #000
}
.items-table td.desc{
font-family: "Open Sans" !important;
color: #5c5c5c !important;
font-size: 9pt !important;
line-height: 1.25 !important;
}
/* TODO lo que esté dentro (p, span, ul, li, b, i, etc. del HTML manual) */
.items-table td.desc *{
font-family: "Open Sans" !important;
color: #5c5c5c !important;
font-size: 9pt !important;
line-height: 1.25 !important;
}
.items-table thead {
display: table-header-group;
}
.items-table tfoot {
display: table-footer-group;
}
.items-table tr,
.items-table td,
.items-table th {
break-inside: avoid;
page-break-inside: avoid;
}
.page-number {
position: absolute;
bottom: -3mm;
right: 0;
font-size: small;
}
.page-number .pn:before {
content: counter(page) " / " counter(pages);
}
.pdf-footer-running {
position: running(footer);
/* lo registra como elemento repetible */
font-size: 7.5pt;
color: var(--muted);
width: 100%;
border-top: 1px solid var(--line);
padding-top: 4mm;
/* el resto de tus estilos internos (address, privacy, etc.) */
}

View File

@ -0,0 +1,222 @@
/* global $, bootstrap, window */
$(() => {
// si jQuery está cargado, añade CSRF a AJAX
const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content');
if (window.$ && csrfToken && csrfHeader) {
$.ajaxSetup({
beforeSend: function (xhr) {
xhr.setRequestHeader(csrfHeader, csrfToken);
}
});
}
const language = document.documentElement.lang || 'es-ES';
const $table = $('#series-datatable'); // en tu HTML está así, aunque el id sea raro
const $addBtn = $('#addButton');
const $modal = $('#serieFacturacionModal');
const modal = new bootstrap.Modal($modal[0]);
const $form = $('#serieFacturacionForm');
const $alert = $('#serieFacturacionAlert');
const $saveBtn = $('#serieFacturacionSaveBtn');
function showError(msg) {
$alert.removeClass('d-none').text(msg || 'Error');
}
function clearError() {
$alert.addClass('d-none').text('');
}
function resetForm() {
clearError();
$form[0].reset();
$form.removeClass('was-validated');
$('#serie_id').val('');
$('#numero_actual').val('1');
$('#tipo').val('facturacion');
}
function openAddModal() {
resetForm();
$('#serieFacturacionModalTitle').text(window.languageBundle?.['series-facturacion.modal.title.add'] || 'Añadir serie');
modal.show();
}
function openEditModal(row) {
resetForm();
$('#serieFacturacionModalTitle').text(window.languageBundle?.['series-facturacion.modal.title.edit'] || 'Editar serie');
$('#serie_id').val(row.id);
$('#nombre_serie').val(row.nombre_serie);
$('#prefijo').val(row.prefijo);
$('#tipo').val(row.tipo || 'facturacion');
$('#numero_actual').val(row.numero_actual);
modal.show();
}
// -----------------------------
// DataTable server-side
// -----------------------------
const dt = $table.DataTable({
processing: true,
serverSide: true,
searching: true,
orderMulti: false,
pageLength: 10,
lengthMenu: [10, 25, 50, 100],
language: { url: '/assets/libs/datatables/i18n/' + language + '.json' },
ajax: {
url: '/configuracion/series-facturacion/api/datatables',
type: 'GET',
dataSrc: function (json) {
// DataTables espera {draw, recordsTotal, recordsFiltered, data}
return json.data || [];
},
error: function (xhr) {
console.error('DataTables error', xhr);
}
},
columns: [
{ data: 'id' },
{ data: 'nombre_serie' },
{ data: 'prefijo' },
{ data: 'tipo_label', name: 'tipo' },
{ data: 'numero_actual' },
{
data: 'actions',
orderable: false,
searchable: false
}
],
order: [[0, 'desc']]
});
// -----------------------------
// Add
// -----------------------------
$addBtn.on('click', () => openAddModal());
// -----------------------------
// Edit click
// -----------------------------
$table.on('click', '.btn-edit-serie', function () {
const row = dt.row($(this).closest('tr')).data();
openEditModal(row);
});
// -----------------------------
// Delete click
// -----------------------------
$table.on('click', '.btn-delete-serie', function () {
const row = dt.row($(this).closest('tr')).data();
Swal.fire({
title: window.languageBundle.get(['series-facturacion.delete.title']) || 'Eliminar serie',
html: window.languageBundle.get(['series-facturacion.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(['app.eliminar']) || 'Eliminar',
cancelButtonText: window.languageBundle.get(['app.cancelar']) || 'Cancelar',
}).then((result) => {
if (!result.isConfirmed) return;
$.ajax({
url: `/configuracion/series-facturacion/api/${row.id}`,
method: 'DELETE',
success: function () {
Swal.fire({
icon: 'success', title: window.languageBundle.get(['series-facturacion.delete.ok.title']) || 'Eliminado',
text: window.languageBundle.get(['series-facturacion.delete.ok.text']) || 'La serie de facturación ha sido eliminada correctamente.',
showConfirmButton: false,
timer: 1800,
customClass: {
confirmButton: 'btn btn-secondary w-xs mt-2',
},
});
dt.ajax.reload(null, false);
},
error: function (xhr) {
const msg = (xhr.responseJSON && xhr.responseJSON.message)
|| 'Error al eliminar la serie de facturación.';
Swal.fire({
icon: 'error',
title: 'No se pudo eliminar',
text: msg,
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-secondary me-2', // clases para el botón confirmar
cancelButton: 'btn btn-light' // clases para cancelar
},
});
}
});
});
});
// -----------------------------
// Save (create/update)
// -----------------------------
$saveBtn.on('click', function () {
clearError();
// Validación Bootstrap
const formEl = $form[0];
if (!formEl.checkValidity()) {
$form.addClass('was-validated');
return;
}
const id = $('#serie_id').val();
const payload = {
nombre_serie: $('#nombre_serie').val().trim(),
prefijo: $('#prefijo').val().trim(),
tipo: $('#tipo').val(),
numero_actual: Number($('#numero_actual').val())
};
const isEdit = !!id;
const url = isEdit
? `/configuracion/series-facturacion/api/${id}`
: `/configuracion/series-facturacion/api`;
const method = isEdit ? 'PUT' : 'POST';
$saveBtn.prop('disabled', true);
$.ajax({
url,
method,
contentType: 'application/json; charset=utf-8',
data: JSON.stringify(payload),
success: function () {
modal.hide();
dt.ajax.reload(null, false);
},
error: function (xhr) {
const msg = xhr.responseJSON?.message || xhr.responseText || 'No se pudo guardar.';
showError(msg);
},
complete: function () {
$saveBtn.prop('disabled', false);
}
});
});
// limpiar estado al cerrar
$modal.on('hidden.bs.modal', () => resetForm());
});

View File

@ -0,0 +1,216 @@
$(() => {
const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content');
if (window.$ && csrfToken && csrfHeader) {
$.ajaxSetup({
beforeSend: function (xhr) {
xhr.setRequestHeader(csrfHeader, csrfToken);
}
});
}
let $addBtn = $('#save-btn');
let $cancelBtn = $('#cancel-btn');
let $selectCliente = $('#clienteSelect');
let $serieInput = $('#serieInput');
let $direccionFacturacion = $('#direccionFacturacion');
let $facturaRectificada = $('#facturaRectificada');
let $divFacturaRectificada = $('#div-factura-rectificada');
// -----------------------------
// Initialize select2 for cliente selection
// -----------------------------
$selectCliente.select2({
placeholder: languageBundle['facturas.form.cliente.placeholder'],
width: '100%',
ajax: {
url: '/users/api/get-users',
dataType: 'json',
delay: 250,
data: function (params) {
return {
showUsername: true,
q: params.term,
page: params.page || 1
};
}
},
minimumInputLength: 0
});
$selectCliente.on('select2:select', function (e) {
const data = e.params.data;
$serieInput.val(null).trigger('change');
$direccionFacturacion.val(null).trigger('change');
$facturaRectificada.val(null).trigger('change');
if (data && data.id) {
$serieInput.prop('disabled', false);
$direccionFacturacion.prop('disabled', false);
}
else {
$serieInput.prop('disabled', true);
$direccionFacturacion.prop('disabled', true);
}
});
$serieInput.select2({
placeholder: languageBundle['facturas.form.serie.placeholder'],
width: '100%',
ajax: {
url: '/configuracion/series-facturacion/api/get-series',
dataType: 'json',
delay: 250,
data: function (params) {
return {
q: params.term,
page: params.page || 1
};
}
},
minimumInputLength: 0
});
$serieInput.on('select2:select', function (e) {
const data = e.params.data;
const defaultRectSerieId = $serieInput.data('default-serie-rect');
if (data && data.id) {
if (data.id === defaultRectSerieId) {
$divFacturaRectificada.removeClass('d-none');
$facturaRectificada.val(null).trigger('change');
}
else {
$divFacturaRectificada.addClass('d-none');
$facturaRectificada.val(null).trigger('change');
}
}
});
$direccionFacturacion.select2({
placeholder: languageBundle['facturas.form.direccion-facturacion.placeholder'],
width: '100%',
ajax: {
url: '/facturas/api/get-direcciones',
dataType: 'json',
delay: 250,
data: function (params) {
const clienteId = $selectCliente.val();
return {
user_id: clienteId,
q: params.term,
page: params.page || 1
};
},
processResults: (data) => {
const items = Array.isArray(data) ? data : (data.results || []);
return {
results: items.map(item => ({
id: item.id,
text: item.text, // ← Select2 necesita 'id' y 'text'
alias: item.alias || 'Sin alias',
att: item.att || '',
direccion: item.direccion || '',
cp: item.cp || '',
ciudad: item.ciudad || '',
html: `
<div>
<strong>${item.alias || 'Sin alias'}</strong><br>
${item.att ? `<small>${item.att}</small><br>` : ''}
<small>${item.direccion || ''}${item.cp ? ', ' + item.cp : ''}${item.ciudad ? ', ' + item.ciudad : ''}</small>
</div>
`
})),
pagination: { more: false } // opcional, evita que espere más páginas
};
}
},
minimumInputLength: 0,
templateResult: data => {
if (data.loading) return data.text;
return $(data.html || data.text);
},
// Selección más compacta (solo alias + ciudad)
templateSelection: data => {
if (!data.id) return data.text;
const alias = data.alias || data.text;
const ciudad = data.ciudad ? `${data.ciudad}` : '';
return $(`<span>${alias}${ciudad}</span>`);
},
escapeMarkup: m => m
});
$facturaRectificada.select2({
placeholder: languageBundle['facturas.form.factura-rectificada.placeholder'],
width: '100%',
ajax: {
url: '/facturas/api/get-facturas-rectificables',
dataType: 'json',
delay: 250,
data: function (params) {
const clienteId = $selectCliente.val();
return {
user_id: clienteId,
q: params.term,
page: params.page || 1
};
}
},
minimumInputLength: 0
});
// -----------------------------
// Cancel button click
// -----------------------------
$cancelBtn.on('click', () => {
window.location.href = '/facturas';
});
// -----------------------------
// Save button click
// -----------------------------
$addBtn.on('click', () => {
const clienteId = $selectCliente.val();
const serieId = $serieInput.val();
const direccionId = $direccionFacturacion.val();
const facturaRectificadaId = $facturaRectificada.val();
if (!clienteId && !serieId && !direccionId) {
Swal.fire({
icon: 'error',
title: languageBundle['facturas.add.form.validation.title'],
text: languageBundle['facturas.add.form.validation']
});
return;
}
$.ajax({
url: '/facturas/add',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({
user: clienteId,
serie: serieId,
direccion: direccionId,
factura_rectificada: facturaRectificadaId
}),
success: function (response) {
if (response.success) {
window.location.href = '/facturas/' + response.facturaId;
} else {
Swal.fire({
icon: 'error',
title: 'Error',
text: response.message
});
}
},
error: function () {
Swal.fire({
icon: 'error',
title: 'Error',
text: languageBundle['facturas.error.create']
});
}
});
});
});

View File

@ -0,0 +1,131 @@
/* global $, bootstrap, window */
$(() => {
// si jQuery está cargado, añade CSRF a AJAX
const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content');
if (window.$ && csrfToken && csrfHeader) {
$.ajaxSetup({
beforeSend: function (xhr) {
xhr.setRequestHeader(csrfHeader, csrfToken);
}
});
}
const language = document.documentElement.lang || 'es-ES';
const $table = $('#facturas-datatable'); // en tu HTML está así, aunque el id sea raro
const $addBtn = $('#addButton');
// -----------------------------
// DataTable server-side
// -----------------------------
const dt = $table.DataTable({
processing: true,
serverSide: true,
searching: true,
orderMulti: false,
pageLength: 10,
lengthMenu: [10, 25, 50, 100],
language: { url: '/assets/libs/datatables/i18n/' + language + '.json' },
ajax: {
url: '/facturas/api/datatables',
type: 'GET',
dataSrc: function (json) {
// DataTables espera {draw, recordsTotal, recordsFiltered, data}
return json.data || [];
},
error: function (xhr) {
console.error('DataTables error', xhr);
}
},
columns: [
{ data: 'id' },
{ data: 'cliente' },
{ data: 'numero_factura' },
{ data: 'estado_label', name: 'estado' },
{ data: 'estado_pago_label', name: 'estado_pago' },
{ data: 'total' },
{ data: 'fecha_emision' },
{
data: 'actions',
orderable: false,
searchable: false
}
],
order: [[0, 'desc']]
});
// -----------------------------
// Add
// -----------------------------
$addBtn.on('click', () => {
window.location.href = '/facturas/add';
});
// -----------------------------
// Edit click
// -----------------------------
$table.on('click', '.btn-view-factura', function () {
const row = dt.row($(this).closest('tr')).data();
window.location.href = `/facturas/${row.id}`;
});
// -----------------------------
// Delete click
// -----------------------------
$table.on('click', '.btn-delete-factura', function () {
const row = dt.row($(this).closest('tr')).data();
Swal.fire({
title: window.languageBundle.get(['facturas.delete.title']) || 'Eliminar factura',
html: window.languageBundle.get(['facturas.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(['app.eliminar']) || 'Eliminar',
cancelButtonText: window.languageBundle.get(['app.cancelar']) || 'Cancelar',
}).then((result) => {
if (!result.isConfirmed) return;
$.ajax({
url: `/facturas/api/${row.id}`,
method: 'DELETE',
success: function () {
Swal.fire({
icon: 'success', title: window.languageBundle.get(['facturas.delete.ok.title']) || 'Eliminado',
text: window.languageBundle.get(['facturas.delete.ok.text']) || 'La factura ha sido eliminada correctamente.',
showConfirmButton: false,
timer: 1800,
customClass: {
confirmButton: 'btn btn-secondary w-xs mt-2',
},
});
dt.ajax.reload(null, false);
},
error: function (xhr) {
const msg = (xhr.responseJSON && xhr.responseJSON.message)
|| 'Error al eliminar la serie de facturación.';
Swal.fire({
icon: 'error',
title: 'No se pudo eliminar',
text: msg,
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-secondary me-2', // clases para el botón confirmar
cancelButton: 'btn btn-light' // clases para cancelar
},
});
}
});
});
});
});

View File

@ -0,0 +1,768 @@
$(() => {
const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content');
if (window.$ && csrfToken && csrfHeader) {
$.ajaxSetup({
beforeSend: function (xhr) {
xhr.setRequestHeader(csrfHeader, csrfToken);
}
});
}
const $container = $('#factura-container');
const MIN_LOADER_TIME = 500; // ms (ajusta a gusto)
let loaderStartTime = 0;
function showLoader() {
loaderStartTime = Date.now();
$('#factura-loader').removeClass('d-none');
}
function hideLoader() {
const elapsed = Date.now() - loaderStartTime;
const remaining = Math.max(MIN_LOADER_TIME - elapsed, 0);
setTimeout(() => {
$('#factura-loader').addClass('d-none');
}, remaining);
}
function getFacturaId() {
return $container.data('factura-id');
}
function getFlatpickrLocale() {
const lang = (document.documentElement.lang || 'es').toLowerCase().split('-')[0]; // es-ES -> es
const l10ns = window.flatpickr?.l10ns;
return (l10ns && l10ns[lang]) ? l10ns[lang] : (l10ns?.default || null);
}
function reloadFacturaContainer() {
const id = getFacturaId();
if (!id) return $.Deferred().reject('No factura id').promise();
showLoader();
return $.ajax({
url: `/facturas/${id}/container`,
method: 'GET',
cache: false,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.done((html) => {
const $c = $('#factura-container');
// conserva loader dentro del container
const $loader = $c.find('#factura-loader').detach();
// reemplaza el contenido completo
$c.empty().append($loader).append(html);
afterFacturaRender();
})
.fail((xhr) => {
console.error('Error recargando container:', xhr.status, xhr.responseText);
})
.always(() => {
hideLoader();
});
}
function postAndReload(url, data) {
showLoader();
const isJson = data !== undefined && data !== null;
return $.ajax({
url,
method: 'POST',
data: isJson ? JSON.stringify(data) : null,
contentType: isJson ? 'application/json; charset=UTF-8' : undefined,
processData: !isJson, // importante: si es JSON, no procesar
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.done(() => reloadFacturaContainer())
.fail((xhr) => {
console.error('Error en acción:', xhr.status, xhr.responseText);
})
.always(() => {
hideLoader();
});
}
$container.on('click', '#btn-imprimir-factura', function () {
const id = getFacturaId();
const url = `/api/pdf/factura/${id}?mode=download`;
const a = document.createElement('a');
a.href = url;
a.target = '_self'; // descarga en la misma pestaña
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
// Delegación (funciona aunque reemplacemos el contenido interno)
$container.on('click', '#btn-validar-factura', function () {
const id = getFacturaId();
postAndReload(`/facturas/${id}/validar`);
});
$container.on('click', '#btn-borrador-factura', function () {
const id = getFacturaId();
postAndReload(`/facturas/${id}/borrador`);
});
$container.on('focusin', 'textarea[name="notas"]', function () {
$(this).data('initial', $(this).val() ?? '');
});
$container.on('focusout', 'textarea[name="notas"]', function () {
const before = $(this).data('initial') ?? '';
const now = $(this).val() ?? '';
if (before === now) return;
const id = getFacturaId();
postAndReload(`/facturas/${id}/notas`, { notas: now });
});
$container.on('click', '#btn-guardar-factura', function () {
const facturaId = getFacturaId();
const fechaEmisionStr = $('#facturaFechaEmision').val();
const fechaEmision = parseEsDateToIsoLocal(fechaEmisionStr);
const payload = {
cabecera: {
serieId: $('#facturaSerieId').val() || null,
clienteId: $('#facturaClienteId').val() || null,
fechaEmision: fechaEmision // ISO LocalDateTime (00:00:00)
},
direccionFacturacion: {
razonSocial: $('#dirRazonSocial').val() || '',
identificacionFiscal: $('#dirIdentificacionFiscal').val() || '',
direccion: $('#dirDireccion').val() || '',
cp: $('#dirCp').val() || '',
ciudad: $('#dirCiudad').val() || '',
provincia: $('#dirProvincia').val() || '',
paisKeyword: $('#dirPais').val() || '', // lo que tú guardas como keyword
telefono: $('#dirTelefono').val() || ''
}
};
postAndReload(`/facturas/${facturaId}/guardar`, payload);
});
function destroySelect2($root) {
$root.find('.js-select2-factura').each(function () {
const $el = $(this);
if ($el.hasClass('select2-hidden-accessible')) {
$el.select2('destroy');
}
});
}
function initSelect2ForDraft($root) {
$root.find('.js-select2-factura').each(function () {
const $el = $(this);
// evita doble init
if ($el.hasClass('select2-hidden-accessible')) return;
const url = $el.data('url');
const placeholder = $el.data('placeholder') || '';
$el.select2({
width: '100%',
placeholder,
allowClear: true,
minimumInputLength: 0,
ajax: {
url: url,
dataType: 'json',
delay: 250,
data: function (params) {
return {
q: params.term, // texto buscado
page: params.page || 1
};
},
processResults: function (data) {
return data;
},
cache: true
}
});
});
}
// =========================================================
// MODIFICACIÓN: Modal + Quill para líneas de factura
// =========================================================
let lineaModalInstance = null;
let lineaQuill = null;
function getLineaModal() {
const el = document.getElementById('lineaFacturaModal');
if (!el) return null;
lineaModalInstance = bootstrap.Modal.getOrCreateInstance(el);
return lineaModalInstance;
}
function showLineaModal() {
const m = getLineaModal();
if (m) m.show();
}
function hideLineaModal() {
const m = getLineaModal();
if (m) m.hide();
}
function showLineaModalError(msg) {
const $err = $('#lineaFacturaModalError');
if (!msg) {
$err.addClass('d-none').text('');
return;
}
$err.removeClass('d-none').text(msg);
}
// Quill config (igual que en presupuestos, pero solo para este editor del modal)
function buildSnowConfig() {
return {
theme: 'snow',
modules: {
toolbar: [
[{ 'font': [] }, { 'size': [] }],
['bold', 'italic', 'underline', 'strike'],
[{ 'color': [] }, { 'background': [] }],
[{ 'script': 'super' }, { 'script': 'sub' }],
[{ 'header': [false, 1, 2, 3, 4, 5, 6] }, 'blockquote', 'code-block'],
[{ 'list': 'ordered' }, { 'list': 'bullet' }, { 'indent': '-1' }, { 'indent': '+1' }],
['direction', { 'align': [] }]
]
}
};
}
function getLineaQuill() {
const el = document.getElementById('lineaFacturaDescripcionEditor');
if (!el) return null;
// Evita doble init y evita apuntar a DOM viejo tras reload
if (!lineaQuill) {
lineaQuill = new Quill(el, buildSnowConfig());
}
return lineaQuill;
}
function setLineaDescripcionHtml(html) {
const q = getLineaQuill();
if (!q) return;
q.clipboard.dangerouslyPasteHTML(html ?? '');
}
function getLineaDescripcionHtml() {
const q = getLineaQuill();
if (!q) return '';
return q.root.innerHTML ?? '';
}
function clearLineaDescripcion() {
const q = getLineaQuill();
if (!q) return;
q.setText('');
}
// Formato ES: coma decimal
const nfEs = new Intl.NumberFormat('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
function parseEsNumber(str) {
if (str == null) return null;
const s = String(str).trim();
if (!s) return null;
const normalized = s.replace(/\./g, '').replace(',', '.');
const n = Number(normalized);
return Number.isFinite(n) ? n : null;
}
function formatEsNumber(n) {
const num = Number(n);
if (!Number.isFinite(num)) return '';
return nfEs.format(num);
}
function attachEsDecimalHandlers(selector) {
// input: permitir dígitos y separador decimal, normalizar '.' a ','
$container.on('input', selector, function () {
let v = $(this).val() ?? '';
v = String(v).replace(/[^\d\.,]/g, '');
const parts = v.split(/[.,]/);
if (parts.length > 1) {
v = parts[0] + ',' + parts.slice(1).join('');
}
$(this).val(v);
});
// blur: formatear a 2 decimales
$container.on('blur', selector, function () {
const n = parseEsNumber($(this).val());
$(this).val(formatEsNumber(n ?? 0));
});
}
// (handlers una sola vez; usan delegación)
attachEsDecimalHandlers('#lineaFacturaBase');
attachEsDecimalHandlers('#lineaFacturaIva4');
attachEsDecimalHandlers('#lineaFacturaIva21');
function resetLineaModal() {
$('#lineaFacturaModalTitle').text('Nueva línea');
$('#lineaFacturaId').val('');
clearLineaDescripcion();
$('#lineaFacturaBase').val(formatEsNumber(0));
$('#lineaFacturaIva4').val(formatEsNumber(0));
$('#lineaFacturaIva21').val(formatEsNumber(0));
showLineaModalError(null);
}
function fillLineaModalForEdit({ id, descripcionHtml, base, iva4, iva21 }) {
$('#lineaFacturaModalTitle').text('Editar línea');
$('#lineaFacturaId').val(id ?? '');
setLineaDescripcionHtml(descripcionHtml ?? '');
$('#lineaFacturaBase').val(formatEsNumber(Number(base) ?? 0));
$('#lineaFacturaIva4').val(formatEsNumber(Number(iva4) ?? 0));
$('#lineaFacturaIva21').val(formatEsNumber(Number(iva21) ?? 0));
showLineaModalError(null);
}
// Abrir modal: crear
$container.on('click', '#btn-add-linea-factura', function () {
getLineaQuill(); // asegura init sobre DOM actual
resetLineaModal();
showLineaModal();
});
// Abrir modal: editar
$container.on('click', '.btn-edit-linea-factura', function () {
getLineaQuill(); // asegura init sobre DOM actual
const $btn = $(this);
const id = $btn.data('linea-id');
// Leemos HTML guardado en textarea hidden (por seguridad)
const descripcionHtml = $(`#linea-desc-${id}`).val() ?? '';
fillLineaModalForEdit({
id,
descripcionHtml,
base: $btn.data('base'),
iva4: $btn.data('iva4'),
iva21: $btn.data('iva21')
});
showLineaModal();
});
// Borrar línea
$container.on('click', '.btn-delete-linea-factura', function () {
const $btn = $(this);
const id = $btn.data('linea-id');
const facturaId = getFacturaId();
Swal.fire({
title: window.languageBundle.get(['facturas.lineas.delete.title']) || 'Eliminar línea',
html: window.languageBundle.get(['facturas.lineas.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(['app.eliminar']) || 'Eliminar',
cancelButtonText: window.languageBundle.get(['app.cancelar']) || 'Cancelar',
}).then((result) => {
if (!result.isConfirmed) return;
postAndReload(`/facturas/${facturaId}/lineas/${id}/delete`);
});
});
// Guardar (todavía sin endpoint; deja el payload preparado)
$container.on('click', '#btnGuardarLineaFactura', function () {
showLineaModalError(null);
const idLinea = $('#lineaFacturaId').val() || null;
const descripcionHtml = getLineaDescripcionHtml();
const base = parseEsNumber($('#lineaFacturaBase').val());
const iva4 = parseEsNumber($('#lineaFacturaIva4').val()) ?? 0;
const iva21 = parseEsNumber($('#lineaFacturaIva21').val()) ?? 0;
if (base == null) {
showLineaModalError(window.languageBundle['facturas.lineas.error.base'] || 'La base no es válida.');
return;
}
// Vacío real de Quill
const descTrim = (descripcionHtml ?? '').trim();
const isEmptyQuill = (descTrim === '' || descTrim === '<p><br></p>');
const descripcion = isEmptyQuill ? '' : descripcionHtml;
const payload = {
id: idLinea,
descripcion, // HTML
base,
iva4,
iva21
};
// Aquí conectaremos endpoints:
const facturaId = getFacturaId();
const url = idLinea
? `/facturas/${facturaId}/lineas/${idLinea}`
: `/facturas/${facturaId}/lineas`;
postAndReload(url, payload).done(() => hideLineaModal());
hideLineaModal();
});
// =========================================================
// FIN MODAL
// =========================================================
// =========================================================
// MODAL + Quill + Flatpickr para pagos de factura
// =========================================================
let pagoModalInstance = null;
let pagoQuill = null;
let pagoFlatpickr = null;
function getPagoModal() {
const el = document.getElementById('pagoFacturaModal');
if (!el) return null;
pagoModalInstance = bootstrap.Modal.getOrCreateInstance(el);
return pagoModalInstance;
}
function showPagoModal() {
const m = getPagoModal();
if (m) m.show();
}
function hidePagoModal() {
const m = getPagoModal();
if (m) m.hide();
}
function showPagoModalError(msg) {
const $err = $('#pagoFacturaModalError');
if (!msg) {
$err.addClass('d-none').text('');
return;
}
$err.removeClass('d-none').text(msg);
}
function getPagoQuill() {
const el = document.getElementById('pagoFacturaNotasEditor');
if (!el) return null;
// Evita doble init y evita apuntar a DOM viejo tras reload
if (!pagoQuill) {
pagoQuill = new Quill(el, buildSnowConfig());
}
return pagoQuill;
}
function setPagoNotasHtml(html) {
const q = getPagoQuill();
if (!q) return;
q.clipboard.dangerouslyPasteHTML(html ?? '');
}
function getPagoNotasHtml() {
const q = getPagoQuill();
if (!q) return '';
return q.root.innerHTML ?? '';
}
function clearPagoNotas() {
const q = getPagoQuill();
if (!q) return;
q.setText('');
}
function getPagoFlatpickr() {
const input = document.getElementById('pagoFacturaFecha');
if (!input) return null;
if (!pagoFlatpickr) {
pagoFlatpickr = flatpickr(input, {
enableTime: false,
dateFormat: "d/m/Y",
placeholder: "",
locale: getFlatpickrLocale()
});
}
return pagoFlatpickr;
}
// Convierte "dd/MM/yyyy HH:mm" => "yyyy-MM-ddTHH:mm:00" (LocalDateTime)
function parseEsDateTimeToIsoLocal(str) {
if (!str) return null;
const s = String(str).trim();
if (!s) return null;
const parts = s.split(' ');
if (parts.length < 2) return null;
const [dmy, hm] = parts;
const [dd, mm, yyyy] = dmy.split('/').map(n => Number(n));
const [HH, MM] = hm.split(':').map(n => Number(n));
if (!dd || !mm || !yyyy || Number.isNaN(HH) || Number.isNaN(MM)) return null;
const pad2 = (n) => String(n).padStart(2, '0');
return `${String(yyyy).padStart(4, '0')}-${pad2(mm)}-${pad2(dd)}T${pad2(HH)}:${pad2(MM)}:00`;
}
function parseEsDateToIsoLocal(str) {
if (!str) return null;
const s = String(str).trim();
if (!s) return null;
const [dd, mm, yyyy] = s.split('/').map(n => Number(n));
if (!dd || !mm || !yyyy) return null;
const pad2 = (n) => String(n).padStart(2, '0');
return `${String(yyyy).padStart(4, '0')}-${pad2(mm)}-${pad2(dd)}T00:00:00`;
}
function setPagoFechaFromDataAttr(fechaRaw) {
const fp = getPagoFlatpickr();
if (!fp) return;
if (!fechaRaw) { fp.clear(); return; }
// fechaRaw: "yyyy-MM-dd"
const [Y, M, D] = String(fechaRaw).split('-').map(Number);
if (!Y || !M || !D) return;
fp.setDate(new Date(Y, M - 1, D), true);
}
function resetPagoModal() {
$('#pagoFacturaModalTitle').text('Nuevo pago');
$('#pagoFacturaId').val('');
$('#pagoFacturaMetodo').val('tpv_tarjeta');
$('#pagoFacturaCantidad').val(formatEsNumber(0));
const fp = getPagoFlatpickr();
if (fp) fp.clear();
clearPagoNotas();
showPagoModalError(null);
}
function fillPagoModalForEdit({ id, metodo, cantidad, fechaRaw, notasHtml }) {
$('#pagoFacturaModalTitle').text('Editar pago');
$('#pagoFacturaId').val(id ?? '');
$('#pagoFacturaMetodo').val(metodo ?? 'tpv_tarjeta');
$('#pagoFacturaCantidad').val(formatEsNumber(Number(cantidad) ?? 0));
setPagoFechaFromDataAttr(fechaRaw);
setPagoNotasHtml(notasHtml ?? '');
showPagoModalError(null);
}
// Formato ES para cantidad (mismo handler que líneas)
attachEsDecimalHandlers('#pagoFacturaCantidad');
// Abrir modal: crear
$container.on('click', '#btn-add-pago-factura', function () {
getPagoQuill(); // init sobre DOM actual
getPagoFlatpickr(); // init sobre DOM actual
resetPagoModal();
showPagoModal();
});
// Abrir modal: editar
$container.on('click', '.btn-edit-pago-factura', function () {
getPagoQuill();
getPagoFlatpickr();
const $btn = $(this);
const id = $btn.data('pago-id');
const notasHtml = $(`#pago-notas-${id}`).val() ?? '';
fillPagoModalForEdit({
id,
metodo: $btn.data('metodo'),
cantidad: $btn.data('cantidad'),
fechaRaw: $btn.data('fecha'), // "yyyy-MM-dd HH:mm" desde Thymeleaf
notasHtml
});
showPagoModal();
});
// Borrar pago (Swal igual que líneas)
$container.on('click', '.btn-delete-pago-factura', function () {
const pagoId = $(this).data('pago-id');
const facturaId = getFacturaId();
Swal.fire({
title: window.languageBundle.get(['facturas.pagos.delete.title']) || 'Eliminar pago',
html: window.languageBundle.get(['facturas.pagos.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(['app.eliminar']) || 'Eliminar',
cancelButtonText: window.languageBundle.get(['app.cancelar']) || 'Cancelar',
}).then((result) => {
if (!result.isConfirmed) return;
postAndReload(`/facturas/${facturaId}/pagos/${pagoId}/delete`);
});
});
// Guardar pago
$container.on('click', '#btnGuardarPagoFactura', function () {
showPagoModalError(null);
const facturaId = getFacturaId();
const pagoId = $('#pagoFacturaId').val() || null;
const metodoPago = $('#pagoFacturaMetodo').val();
const cantidad = parseEsNumber($('#pagoFacturaCantidad').val());
const fechaStr = $('#pagoFacturaFecha').val();
if (cantidad == null || cantidad <= 0) {
showPagoModalError(window.languageBundle.get(['facturas.pagos.error.cantidad']) || 'La cantidad no es válida.');
return;
}
const fechaPago = parseEsDateToIsoLocal($('#pagoFacturaFecha').val());
if (!fechaPago) {
showPagoModalError(window.languageBundle.get(['facturas.pagos.error.fecha']) || 'La fecha no es válida.');
return;
}
const notasHtml = getPagoNotasHtml();
const notasTrim = (notasHtml ?? '').trim();
const isEmptyQuill = (notasTrim === '' || notasTrim === '<p><br></p>');
const notas = isEmptyQuill ? '' : notasHtml;
const payload = {
id: pagoId,
metodoPago,
cantidadPagada: cantidad,
fechaPago,
notas
};
const url = pagoId
? `/facturas/${facturaId}/pagos/${pagoId}`
: `/facturas/${facturaId}/pagos`;
postAndReload(url, payload).done(() => hidePagoModal());
});
// =========================================================
let facturaFechaEmisionFp = null;
function getFacturaFechaEmisionFlatpickr() {
const input = document.getElementById('facturaFechaEmision');
if (!input) return null;
if (!facturaFechaEmisionFp) {
facturaFechaEmisionFp = flatpickr(input, {
enableTime: false,
dateFormat: "d/m/Y",
locale: getFlatpickrLocale()
});
}
return facturaFechaEmisionFp;
}
// dd/MM/yyyy -> yyyy-MM-ddT00:00:00
function parseEsDateToIsoLocal(str) {
if (!str) return null;
const s = String(str).trim();
if (!s) return null;
const [dd, mm, yyyy] = s.split('/').map(n => Number(n));
if (!dd || !mm || !yyyy) return null;
const pad2 = (n) => String(n).padStart(2, '0');
return `${String(yyyy).padStart(4, '0')}-${pad2(mm)}-${pad2(dd)}T00:00:00`;
}
function afterFacturaRender() {
const $root = $('#factura-container');
const estado = $root.find('[data-factura-estado]').first().data('factura-estado');
destroySelect2($root);
if (estado === 'borrador') {
initSelect2ForDraft($root);
// ✅ Fecha emisión editable con flatpickr solo en borrador
facturaFechaEmisionFp = null; // reset por si cambió el DOM
getFacturaFechaEmisionFlatpickr(); // init sobre DOM actual
} else {
facturaFechaEmisionFp = null;
}
// resets que ya tenías
lineaQuill = null;
lineaModalInstance = null;
// si tienes pagos: resetea también...
pagoQuill = null;
pagoModalInstance = null;
pagoFlatpickr = null;
}
afterFacturaRender();
});

View File

@ -42,7 +42,7 @@ $(() => {
if (estadoSpan.length) { if (estadoSpan.length) {
estadoSpan.text(response.state); estadoSpan.text(response.state);
} }
if (response.stateKey === 'terminado' || response.stateKey === 'cancelado') { if (response.stateKey === 'enviado' || response.stateKey === 'cancelado') {
$(`.update-estado-button[data-linea-id='${lineaId}']`) $(`.update-estado-button[data-linea-id='${lineaId}']`)
.closest('.update-estado-button') .closest('.update-estado-button')
.addClass('d-none'); .addClass('d-none');

View File

@ -83,4 +83,20 @@ $(() => {
}); });
}); });
} }
if ($(".btn-download-factura").length) {
$(document).on('click', '.btn-download-factura', function () {
const facturaId = $(this).data('factura-id');
const url = `/api/pdf/factura/${facturaId}?mode=download`;
const a = document.createElement('a');
a.href = url;
a.target = '_self'; // descarga en la misma pestaña
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
}
}); });

View File

@ -1,3 +1,4 @@
import { duplicar, reimprimir } from './presupuesto-utils.js';
(() => { (() => {
// si jQuery está cargado, añade CSRF a AJAX // si jQuery está cargado, añade CSRF a AJAX
const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content'); const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
@ -124,6 +125,27 @@
}); });
}); });
$('#presupuestos-clientes-user-datatable').on('click', '.btn-duplicate-privado', function (e) {
e.preventDefault();
const id = $(this).data('id');
let data = table.row($(this).parents('tr')).data();
const tituloOriginal = data.titulo;
duplicar(id, tituloOriginal);
});
$('#presupuestos-clientes-user-datatable').on('click', '.btn-reprint-privado', function (e) {
e.preventDefault();
const id = $(this).data('id');
let data = table.row($(this).parents('tr')).data();
const tituloOriginal = data.titulo;
reimprimir(id, tituloOriginal);
});
$('#presupuestos-clientes-user-datatable').on('keyup', '.presupuesto-filter', function (e) { $('#presupuestos-clientes-user-datatable').on('keyup', '.presupuesto-filter', function (e) {
const colName = $(this).data('col'); const colName = $(this).data('col');
const colIndex = table.column(colName + ':name').index(); const colIndex = table.column(colName + ':name').index();

View File

@ -4,7 +4,6 @@
const scripts = [ const scripts = [
"/assets/libs/toastify-js/src/toastify.js", "/assets/libs/toastify-js/src/toastify.js",
"/assets/libs/choices.js/public/assets/scripts/choices.min.js", "/assets/libs/choices.js/public/assets/scripts/choices.min.js",
"/assets/libs/flatpickr/flatpickr.min.js",
"/assets/libs/feather-icons/feather.min.js" // <- AÑADIMOS feather aquí "/assets/libs/feather-icons/feather.min.js" // <- AÑADIMOS feather aquí
]; ];

View File

@ -0,0 +1,119 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<!-- Fragment: Modal para Alta/Edición de Serie de Facturación -->
<th:block th:fragment="modal">
<div class="modal fade" id="serieFacturacionModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<!-- Header -->
<div class="modal-header">
<h5 class="modal-title" id="serieFacturacionModalTitle" th:text="#{series-facturacion.modal.title.add}">
Añadir serie
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<!-- Body -->
<div class="modal-body">
<!-- Alert placeholder (JS lo rellena) -->
<div id="serieFacturacionAlert" class="alert alert-danger d-none" role="alert"></div>
<form id="serieFacturacionForm" novalidate>
<!-- Para editar: el JS setea este id -->
<input type="hidden" id="serie_id" name="id" value="">
<div class="mb-3">
<label for="nombre_serie" class="form-label" th:text="#{series-facturacion.form.nombre}">
Nombre
</label>
<input type="text"
class="form-control"
id="nombre_serie"
name="nombre_serie"
maxlength="100"
required>
<div class="invalid-feedback" th:text="#{app.validation.required}">
Campo obligatorio
</div>
</div>
<div class="mb-3">
<label for="prefijo" class="form-label" th:text="#{series-facturacion.form.prefijo}">
Prefijo
</label>
<input type="text"
class="form-control"
id="prefijo"
name="prefijo"
maxlength="10"
required>
<div class="invalid-feedback" th:text="#{app.validation.required}">
Campo obligatorio
</div>
<div class="form-text" th:text="#{series-facturacion.form.prefijo.help}">
Ej: FAC, F25...
</div>
</div>
<div class="mb-3">
<label for="tipo" class="form-label" th:text="#{series-facturacion.form.tipo}">
Tipo
</label>
<!-- En BD solo hay facturacion, pero lo dejamos como select por UI -->
<select class="form-select" id="tipo" name="tipo" required>
<option value="facturacion" th:text="#{series-facturacion.tipo.facturacion}">
Facturación
</option>
</select>
</div>
<div class="mb-3">
<label for="numero_actual" class="form-label" th:text="#{series-facturacion.form.numero-actual}">
Número actual
</label>
<input type="number"
class="form-control"
id="numero_actual"
name="numero_actual"
min="1"
step="1"
value="1"
required>
<div class="invalid-feedback" th:text="#{app.validation.required}">
Campo obligatorio
</div>
</div>
</form>
</div>
<!-- Footer -->
<div class="modal-footer">
<button type="button"
class="btn btn-light"
data-bs-dismiss="modal"
th:text="#{app.cancelar}">
Cancelar
</button>
<button type="button"
class="btn btn-secondary"
id="serieFacturacionSaveBtn">
<i class="ri-save-line align-bottom me-1"></i>
<span th:text="#{app.guardar}">Guardar</span>
</button>
</div>
</div>
</div>
</div>
</th:block>
</body>
</html>

View File

@ -0,0 +1,83 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{imprimelibros/layout}">
<head>
<th:block layout:fragment="pagetitle" />
<th:block th:replace="~{imprimelibros/partials/head-css :: head-css}" />
<th:block layout:fragment="pagecss">
<link th:href="@{/assets/css/presupuestador.css}" rel="stylesheet"
th:unless="${#authorization.expression('isAuthenticated()')}" />
<link th:href="@{/assets/libs/datatables/dataTables.bootstrap5.min.css}" rel="stylesheet" />
</th:block>
</head>
<body>
<div th:replace="~{imprimelibros/partials/topbar :: topbar}" />
<div th:replace="~{imprimelibros/partials/sidebar :: sidebar}" />
<th:block layout:fragment="content">
<div th:if="${#authorization.expression('isAuthenticated()')}">
<!-- Modales-->
<div th:replace="~{imprimelibros/configuracion/series-facturas/series-facturacion-modal :: modal}" />
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/"><i class="ri-home-5-fill"></i></a></li>
<li class="breadcrumb-item active" aria-current="page" th:text="#{series-facturacion.breadcrumb}">
Series de Facturación</li>
</ol>
</nav>
<div class="container-fluid">
<button type="button" class="btn btn-secondary mb-3" id="addButton">
<i class="ri-add-line align-bottom me-1"></i> <span
th:text="#{app.add}">Añadir</span>
</button>
<table id="series-datatable" class="table table-striped table-nowrap responsive w-100">
<thead>
<tr>
<th class="text-start" scope="col" th:text="#{series-facturacion.tabla.id}">ID</th>
<th class="text-start" scope="col" th:text="#{series-facturacion.tabla.nombre}">Nombre</th>
<th class="text-start" scope="col" th:text="#{series-facturacion.tabla.prefijo}">Prefijo</th>
<th class="text-start" scope="col" th:text="#{series-facturacion.tabla.tipo}">Tipo</th>
<th class="text-start" scope="col" th:text="#{series-facturacion.tabla.numero-actual}">Número Actual</th>
<th class="text-start" scope="col" th:text="#{series-facturacion.tabla.acciones}">Acciones</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</th:block>
<th:block layout:fragment="modal" />
<th:block th:replace="~{theme/partials/vendor-scripts :: scripts}" />
<th:block layout:fragment="pagejs">
<script th:inline="javascript">
window.languageBundle = /*[[${languageBundle}]]*/ {};
</script>
<script th:src="@{/assets/libs/datatables/datatables.min.js}"></script>
<script th:src="@{/assets/libs/datatables/dataTables.bootstrap5.min.js}"></script>
<!-- JS de Buttons y dependencias -->
<script th:src="@{/assets/libs/datatables/dataTables.buttons.min.js}"></script>
<script th:src="@{/assets/libs/jszip/jszip.min.js}"></script>
<script th:src="@{/assets/libs/pdfmake/pdfmake.min.js}"></script>
<script th:src="@{/assets/libs/pdfmake/vfs_fonts.min.js}"></script>
<script th:src="@{/assets/libs/datatables/buttons.html5.min.js}"></script>
<script th:src="@{/assets/libs/datatables/buttons.print.min.js}"></script>
<script th:src="@{/assets/libs/datatables/buttons.colVis.min.js}"></script>
<script type="module" th:src="@{/assets/js/pages/imprimelibros/configuracion/series-facturacion/list.js}"></script>
</th:block>
</body>
</html>

View File

@ -0,0 +1,120 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{imprimelibros/layout}">
<head>
<th:block layout:fragment="pagetitle" />
<th:block th:replace="~{imprimelibros/partials/head-css :: head-css}" />
<th:block layout:fragment="pagecss">
<link th:href="@{/assets/css/presupuestador.css}" rel="stylesheet"
th:unless="${#authorization.expression('isAuthenticated()')}" />
<link th:href="@{/assets/libs/datatables/dataTables.bootstrap5.min.css}" rel="stylesheet" />
<link sec:authorize="isAuthenticated() and hasAnyRole('SUPERADMIN','ADMIN')"
th:href="@{/assets/libs/quill/quill.snow.css}" rel="stylesheet" type="text/css" />
</th:block>
</head>
<body>
<div th:replace="~{imprimelibros/partials/topbar :: topbar}" />
<div th:replace="~{imprimelibros/partials/sidebar :: sidebar}" />
<th:block layout:fragment="content">
<div th:if="${#authorization.expression('isAuthenticated()')}">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/"><i class="ri-home-5-fill"></i></a></li>
<li class="breadcrumb-item"><a href="/facturas" th:text="#{facturas.breadcrumb}"></a></li>
<li class="breadcrumb-item active" aria-current="page" th:text="#{facturas.breadcrumb.nueva}">
Nueva factura</li>
</ol>
</nav>
<div class="container-fluid position-relative">
<div class="row">
<div class="col-xs-12 col-md-6 mb-3">
<label for="clienteSelect" class="form-label" th:text="#{facturas.form.cliente}">Cliente</label>
<select id="clienteSelect" class="form-select select2"
th:placeholder="#{facturas.form.cliente.placeholder}">
<option th:each="cliente : ${clientes}" th:value="${cliente.id}"
th:text="${cliente.nombre} + ' (' + cliente.email + ')'"></option>
</select>
</div>
<div class="col-xs-12 col-md-6 mb-3">
<label for="serieInput" class="form-label" th:text="#{facturas.form.serie}">Serie de
facturación</label>
<select id="serieInput" class="form-select select2" disabled
th:data-default-serie-rect="${defaultSerieRectificativa}">
</select>
</div>
<!-- salto de fila SOLO en md+ -->
<div class="w-100 d-none d-md-block"></div>
<div class="col-12 col-md-6 mb-3">
<label for="direccionFacturacion" class="form-label"
th:text="#{facturas.form.direccion-facturacion}">
Factura rectificada
</label>
<select id="direccionFacturacion" class="form-select select2" disabled></select>
</div>
<div class="col-12 col-md-6 mb-3 d-none" id="div-factura-rectificada">
<label for="facturaRectificada" class="form-label"
th:text="#{facturas.form.factura-rectificada}">
Factura rectificada
</label>
<select id="facturaRectificada" class="form-select select2"></select>
</div>
</div> <!-- end row -->
<div class="row mt-3 justify-content-md-end g-2">
<div class="col-12 col-md-auto">
<button type="button" th:text="#{app.guardar}" id="save-btn" class="btn btn-secondary w-100">
Guardar
</button>
</div>
<div class="col-12 col-md-auto">
<button type="button" th:text="#{app.cancelar}" id="cancel-btn" class="btn btn-light w-100">
Cancelar
</button>
</div>
</div>
</div>
</div>
</th:block>
<th:block layout:fragment="modal" />
<th:block th:replace="~{theme/partials/vendor-scripts :: scripts}" />
<th:block layout:fragment="pagejs">
<script th:inline="javascript">
window.languageBundle = /*[[${languageBundle}]]*/ {};
</script>
<script th:src="@{/assets/libs/datatables/datatables.min.js}"></script>
<script th:src="@{/assets/libs/datatables/dataTables.bootstrap5.min.js}"></script>
<!-- JS de Buttons y dependencias -->
<script th:src="@{/assets/libs/datatables/dataTables.buttons.min.js}"></script>
<script th:src="@{/assets/libs/jszip/jszip.min.js}"></script>
<script th:src="@{/assets/libs/pdfmake/pdfmake.min.js}"></script>
<script th:src="@{/assets/libs/pdfmake/vfs_fonts.min.js}"></script>
<script th:src="@{/assets/libs/datatables/buttons.html5.min.js}"></script>
<script th:src="@{/assets/libs/datatables/buttons.print.min.js}"></script>
<script th:src="@{/assets/libs/datatables/buttons.colVis.min.js}"></script>
<script sec:authorize="isAuthenticated() and hasAnyRole('SUPERADMIN','ADMIN')"
th:src="@{/assets/libs/quill/quill.min.js}"></script>
<script type="module" th:src="@{/assets/js/pages/imprimelibros/facturas/add.js}"></script>
</th:block>
</body>
</html>

View File

@ -0,0 +1,81 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{imprimelibros/layout}">
<head>
<th:block layout:fragment="pagetitle" />
<th:block th:replace="~{imprimelibros/partials/head-css :: head-css}" />
<th:block layout:fragment="pagecss">
<link th:href="@{/assets/css/presupuestador.css}" rel="stylesheet"
th:unless="${#authorization.expression('isAuthenticated()')}" />
<link th:href="@{/assets/libs/datatables/dataTables.bootstrap5.min.css}" rel="stylesheet" />
<link sec:authorize="isAuthenticated() and hasAnyRole('SUPERADMIN','ADMIN')"
th:href="@{/assets/libs/quill/quill.snow.css}" rel="stylesheet" type="text/css" />
</th:block>
</head>
<body>
<div th:replace="~{imprimelibros/partials/topbar :: topbar}" />
<div th:replace="~{imprimelibros/partials/sidebar :: sidebar}" />
<th:block layout:fragment="content">
<div th:if="${#authorization.expression('isAuthenticated()')}">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/"><i class="ri-home-5-fill"></i></a></li>
<li class="breadcrumb-item"><a href="/facturas" th:text="#{facturas.breadcrumb}"></a></li>
<li class="breadcrumb-item active" aria-current="page" th:text="#{facturas.breadcrumb.ver}">
Ver factura</li>
</ol>
</nav>
<div class="container-fluid position-relative" id="factura-container"
th:attr="data-factura-id=${factura.id}">
<!-- overlay loader -->
<div id="factura-loader" class="d-none position-absolute top-0 start-0 w-100 h-100"
style="background: rgba(255,255,255,.6); z-index: 10;">
<div class="position-absolute top-50 start-50 translate-middle">
<div class="spinner-border" role="status" aria-hidden="true"></div>
</div>
</div>
<div class="factura-inner"
th:replace="~{imprimelibros/facturas/partials/factura-container :: factura-container (factura=${factura})}">
</div>
</div>
</div>
</th:block>
<th:block layout:fragment="modal" />
<th:block th:replace="~{theme/partials/vendor-scripts :: scripts}" />
<th:block layout:fragment="pagejs">
<script th:inline="javascript">
window.languageBundle = /*[[${languageBundle}]]*/ {};
</script>
<script th:src="@{/assets/libs/datatables/datatables.min.js}"></script>
<script th:src="@{/assets/libs/datatables/dataTables.bootstrap5.min.js}"></script>
<!-- JS de Buttons y dependencias -->
<script th:src="@{/assets/libs/datatables/dataTables.buttons.min.js}"></script>
<script th:src="@{/assets/libs/jszip/jszip.min.js}"></script>
<script th:src="@{/assets/libs/pdfmake/pdfmake.min.js}"></script>
<script th:src="@{/assets/libs/pdfmake/vfs_fonts.min.js}"></script>
<script th:src="@{/assets/libs/datatables/buttons.html5.min.js}"></script>
<script th:src="@{/assets/libs/datatables/buttons.print.min.js}"></script>
<script th:src="@{/assets/libs/datatables/buttons.colVis.min.js}"></script>
<script sec:authorize="isAuthenticated() and hasAnyRole('SUPERADMIN','ADMIN')"
th:src="@{/assets/libs/quill/quill.min.js}"></script>
<script type="module" th:src="@{/assets/js/pages/imprimelibros/facturas/view.js}"></script>
</th:block>
</body>
</html>

View File

@ -0,0 +1,83 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{imprimelibros/layout}">
<head>
<th:block layout:fragment="pagetitle" />
<th:block th:replace="~{imprimelibros/partials/head-css :: head-css}" />
<th:block layout:fragment="pagecss">
<link th:href="@{/assets/css/presupuestador.css}" rel="stylesheet"
th:unless="${#authorization.expression('isAuthenticated()')}" />
<link th:href="@{/assets/libs/datatables/dataTables.bootstrap5.min.css}" rel="stylesheet" />
</th:block>
</head>
<body>
<div th:replace="~{imprimelibros/partials/topbar :: topbar}" />
<div th:replace="~{imprimelibros/partials/sidebar :: sidebar}" />
<th:block layout:fragment="content">
<div th:if="${#authorization.expression('isAuthenticated()')}">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/"><i class="ri-home-5-fill"></i></a></li>
<li class="breadcrumb-item active" aria-current="page" th:text="#{facturas.breadcrumb}">
Facturas</li>
</ol>
</nav>
<div class="container-fluid">
<button type="button" class="btn btn-secondary mb-3" id="addButton">
<i class="ri-add-line align-bottom me-1"></i> <span
th:text="#{app.add}">Añadir</span>
</button>
<table id="facturas-datatable" class="table table-striped table-nowrap responsive w-100">
<thead>
<tr>
<th class="text-start" scope="col" th:text="#{facturas.tabla.id}">ID</th>
<th class="text-start" scope="col" th:text="#{facturas.tabla.cliente}">Cliente</th>
<th class="text-start" scope="col" th:text="#{facturas.tabla.num-factura}">Número de Factura</th>
<th class="text-start" scope="col" th:text="#{facturas.tabla.estado}">Estado</th>
<th class="text-start" scope="col" th:text="#{facturas.tabla.estado-pago}">Estado de Pago</th>
<th class="text-start" scope="col" th:text="#{facturas.tabla.total}">Total</th>
<th class="text-start" scope="col" th:text="#{facturas.tabla.fecha-emision}">Fecha de Emisión</th>
<th class="text-start" scope="col" th:text="#{facturas.tabla.acciones}">Acciones</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</th:block>
<th:block layout:fragment="modal" />
<th:block th:replace="~{theme/partials/vendor-scripts :: scripts}" />
<th:block layout:fragment="pagejs">
<script th:inline="javascript">
window.languageBundle = /*[[${languageBundle}]]*/ {};
</script>
<script th:src="@{/assets/libs/datatables/datatables.min.js}"></script>
<script th:src="@{/assets/libs/datatables/dataTables.bootstrap5.min.js}"></script>
<!-- JS de Buttons y dependencias -->
<script th:src="@{/assets/libs/datatables/dataTables.buttons.min.js}"></script>
<script th:src="@{/assets/libs/jszip/jszip.min.js}"></script>
<script th:src="@{/assets/libs/pdfmake/pdfmake.min.js}"></script>
<script th:src="@{/assets/libs/pdfmake/vfs_fonts.min.js}"></script>
<script th:src="@{/assets/libs/datatables/buttons.html5.min.js}"></script>
<script th:src="@{/assets/libs/datatables/buttons.print.min.js}"></script>
<script th:src="@{/assets/libs/datatables/buttons.colVis.min.js}"></script>
<script type="module" th:src="@{/assets/js/pages/imprimelibros/facturas/list.js}"></script>
</th:block>
</body>
</html>

View File

@ -0,0 +1,165 @@
<div th:fragment="factura-cabecera (factura)">
<h3>
<span th:class="|text-${factura.estado.name() == 'borrador' ? 'warning' : 'success'}|"
th:text="#{|facturas.estado.${factura.estado.name()}|}">
</span>
/
<span th:class="|text-${factura.estadoPago.name() == 'pendiente' ? 'warning' : 'success'}|"
th:text="#{|facturas.estado-pago.${factura.estadoPago.name()}|}">
</span>
</h3>
<!-- flag readonly -->
<th:block th:with="isReadonly=${factura.estado.name() == 'validada'}">
<div class="row g-3">
<!-- Número (solo lectura siempre, normalmente) -->
<div th:if="${factura.numeroFactura != null}" class="col-md-3">
<label class="form-label" th:text="#{facturas.form.numero-factura}">Número de factura</label>
<input id="facturaNumero" type="text" class="form-control" th:value="${factura.numeroFactura}" readonly>
</div>
<div th:if="${factura.numeroFactura == null}" class="col-md-3">
<label class="form-label" th:text="#{facturas.form.id}">ID de la factura</label>
<input id="facturaId" type="text" class="form-control" th:value="${factura.id}" readonly>
</div>
<!-- Serie -->
<div class="col-md-3">
<label class="form-label" th:text="#{facturas.form.serie}">Serie</label>
<select id="facturaSerieId" class="form-control js-select2-factura"
data-url="/configuracion/series-facturacion/api/get-series" th:attr="disabled=${isReadonly}">
<option th:value="${factura.serie != null ? factura.serie.id : ''}"
th:text="${factura.serie != null ? factura.serie.nombreSerie : ''}" selected>
</option>
</select>
</div>
<!-- Cliente -->
<div class="col-md-6">
<label class="form-label" th:text="#{facturas.form.cliente}">Cliente</label>
<select id="facturaClienteId" class="form-control js-select2-factura" data-url="/users/api/get-users"
th:attr="disabled=${isReadonly}">
<option th:value="${factura.cliente != null ? factura.cliente.id : ''}"
th:text="${factura.cliente != null ? factura.cliente.fullName : ''}" selected>
</option>
</select>
</div>
<!-- Factura rectificada -->
<div th:if="${factura.facturaRectificada != null}" class="w-100 d-md-block"></div>
<div th:if="${factura.facturaRectificada != null}" class="col-md-3">
<label class="form-label" th:text="#{facturas.form.factura-rectificada}">Factura rectificada</label>
<input readonly id="facturaRectificadaId" class="form-control"
th:value="${factura.facturaRectificada.numeroFactura}" />
</div>
<div th:if="${factura.facturaRectificada != null}" class="w-100 d-md-block"></div>
<div class="col-md-3">
<label class="form-label" th:text="#{facturas.form.fecha-emision}">Fecha</label>
<input id="facturaFechaEmision" type="text" class="form-control"
th:value="${factura.fechaEmision != null ? #temporals.format(factura.fechaEmision, 'dd/MM/yyyy') : ''}"
th:attr="readonly=${isReadonly}, data-estado=${factura.estado.name()}">
</div>
<!-- Notas -->
<div class="col-md-9">
<label class="form-label" th:text="#{facturas.form.notas}">Notas</label>
<textarea class="form-control" rows="3" name="notas" th:text="${factura.notas}">
</textarea>
</div>
</div>
<div class="row g-3">
<div class="col-md-12">
<h5 class="mt-4" th:text="#{facturas.direccion.titulo}">Dirección de facturación</h5>
</div>
<div class="col-md-6">
<label class="form-label" th:text="#{facturas.direccion.razon-social}">Razón Social</label>
<input type="text" id="dirRazonSocial" class="form-control" th:value="${direccionFacturacion != null
? direccionFacturacion.razonSocial
: ''}" th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
</div>
<div class="col-md-6">
<label class="form-label" th:text="#{facturas.direccion.identificacion-fiscal}">Identificacion
Fiscal</label>
<input type="text" id="dirIdentificacionFiscal" class="form-control" th:value="${direccionFacturacion != null
? direccionFacturacion.identificacionFiscal
: ''}" th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
</div>
<div class="col-md-9">
<label class="form-label" th:text="#{facturas.direccion.direccion}">Dirección</label>
<input type="text" id="dirDireccion" class="form-control" th:value="${direccionFacturacion != null
? direccionFacturacion.direccion
: ''}" th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
</div>
<div class="col-md-3">
<label class="form-label" th:text="#{facturas.direccion.codigo-postal}">Código Postal</label>
<input type="text" id="dirCp" class="form-control" th:value="${direccionFacturacion != null
? direccionFacturacion.cp
: ''}" th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
</div>
<div class="col-md-3">
<label class="form-label" th:text="#{facturas.direccion.ciudad}">Ciudad</label>
<input type="text" id="dirCiudad" class="form-control" th:value="${direccionFacturacion != null
? direccionFacturacion.ciudad
: ''}" th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
</div>
<div class="col-md-3">
<label class="form-label" th:text="#{facturas.direccion.provincia}">Provincia</label>
<input type="text" id="dirProvincia" class="form-control" th:value="${direccionFacturacion != null
? direccionFacturacion.provincia
: ''}" th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
</div>
<div class="col-md-3">
<label class="form-label" th:text="#{facturas.direccion.pais}">País</label>
<select id="dirPais" class="form-control js-select2-factura" data-url="/api/paises"
th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
<option th:value="${direccionFacturacion != null
? direccionFacturacion.pais.code3
: ''}" th:text="${direccionFacturacion != null
? #messages.msg('paises.' + direccionFacturacion.pais.keyword)
: ''}" selected>
</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label" th:text="#{facturas.direccion.telefono}">Teléfono</label>
<input type="text" id="dirTelefono" class="form-control" th:value="${direccionFacturacion != null
? direccionFacturacion.telefono
: ''}" th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
</div>
</div>
<div class="row g-3 mt-4 justify-content-end">
<div class="col-md-12 text-end">
<th:block th:if="${factura.estado.name() == 'borrador'}">
<button type="button" class="btn btn-secondary me-2" id="btn-validar-factura"
th:text="#{facturas.form.btn.validar}">Validar factura</button>
<button type="button" class="btn btn-secondary me-2" id="btn-guardar-factura"
th:text="#{facturas.form.btn.guardar}">Guardar</button>
</th:block>
<th:block th:if="${factura.estado.name() == 'validada'}">
<button type="button" class="btn btn-secondary me-2" id="btn-borrador-factura"
th:text="#{facturas.form.btn.borrador}">Pasar a borrador</button>
</th:block>
<button type="button" class="btn btn-secondary me-2" id="btn-imprimir-factura"
th:text="#{facturas.form.btn.imprimir}">Imprimir factura</button>
</div>
</div>
</th:block>
</div>

View File

@ -0,0 +1,56 @@
<div th:fragment="factura-container (factura)"
th:attr="data-factura-estado=${factura.estado.name()}">
<div class="accordion accordion-fill-imprimelibros mb-3" id="cabeceraFactura">
<div class="accordion-item material-shadow">
<h2 class="accordion-header" id="cabeceraHeader">
<button class="accordion-button" type="button" data-bs-toggle="collapse"
data-bs-target="#cabecera" aria-expanded="true" aria-controls="cabecera">
Datos de la factura
</button>
</h2>
<div id="cabecera" class="accordion-collapse collapse show" aria-labelledby="cabeceraHeader"
data-bs-parent="#cabeceraFactura">
<div class="accordion-body">
<div th:replace="~{imprimelibros/facturas/partials/factura-cabecera :: factura-cabecera (factura=${factura})}"></div>
</div>
</div>
</div>
</div>
<div class="accordion accordion-fill-imprimelibros mb-3" id="lineasFactura">
<div class="accordion-item material-shadow">
<h2 class="accordion-header" id="lineasHeader">
<button class="accordion-button" type="button" data-bs-toggle="collapse"
data-bs-target="#lineas" aria-expanded="true" aria-controls="lineas">
Líneas de factura
</button>
</h2>
<div id="lineas" class="accordion-collapse collapse show" aria-labelledby="lineasHeader"
data-bs-parent="#lineasFactura">
<div class="accordion-body">
<div th:replace="~{imprimelibros/facturas/partials/factura-lineas :: factura-lineas (factura=${factura})}"></div>
</div>
</div>
</div>
</div>
<div class="accordion accordion-fill-imprimelibros mb-3" id="pagosFactura">
<div class="accordion-item material-shadow">
<h2 class="accordion-header" id="pagosHeader">
<button class="accordion-button" type="button" data-bs-toggle="collapse"
data-bs-target="#pagos" aria-expanded="true" aria-controls="pagos">
Pagos de factura
</button>
</h2>
<div id="pagos" class="accordion-collapse collapse show" aria-labelledby="pagosHeader"
data-bs-parent="#pagosFactura">
<div class="accordion-body">
<div th:replace="~{imprimelibros/facturas/partials/factura-pagos :: factura-pagos (factura=${factura})}"></div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,83 @@
<div th:fragment="factura-lineas (factura)">
<th:block th:if="${factura.estado != null && factura.estado.name() == 'borrador'}">
<div class="mb-3">
<button type="button" class="btn btn-secondary" id="btn-add-linea-factura">
<i class="fas fa-plus-circle me-2"></i>
<span th:text="#{facturas.lineas.acciones.agregar}">Agregar línea</span>
</button>
</div>
</th:block>
<table class="table table-bordered table-striped table-wrap w-100">
<thead>
<tr>
<th th:if="${factura.estado != null && factura.estado.name() == 'borrador'}"
th:text="#{facturas.lineas.acciones}">Acciones</th>
<th class="w-75" th:text="#{facturas.lineas.descripcion}">Descripción</th>
<th th:text="#{facturas.lineas.base}">Base</th>
<th th:text="#{facturas.lineas.iva_4}">I.V.A. 4%</th>
<th th:text="#{facturas.lineas.iva_21}">I.V.A. 21%</th>
<th th:text="#{facturas.lineas.total}">Total</th>
</tr>
</thead>
<tbody>
<tr th:each="lineaFactura : ${factura.lineas}">
<td th:if="${factura.estado != null && factura.estado.name() == 'borrador'}">
<button type="button" class="btn btn-secondary btn-sm me-2 btn-edit-linea-factura" th:attr="
data-linea-id=${lineaFactura.id},
data-base=${lineaFactura.baseLinea},
data-iva4=${lineaFactura.iva4Linea},
data-iva21=${lineaFactura.iva21Linea}
">
<i class="fas fa-edit me-1"></i>
<span th:text="#{facturas.lineas.acciones.editar}">Editar</span>
</button>
<button type="button" class="btn btn-danger btn-sm btn-delete-linea-factura" th:attr="data-linea-id=${lineaFactura.id}"
th:text="#{facturas.lineas.acciones.eliminar}">
<i class="fas fa-trash-alt"></i>
</button>
<!-- IMPORTANTE: guardamos el HTML aquí (no en data-*) -->
<textarea class="d-none" th:attr="id=${'linea-desc-' + lineaFactura.id}"
th:text="${lineaFactura.descripcion}"></textarea>
</td>
<td th:utext="${lineaFactura.descripcion}">Descripción de la línea</td>
<td class="text-end" th:text="${#numbers.formatCurrency(lineaFactura.baseLinea)}">0.00</td>
<td class="text-end" th:text="${#numbers.formatCurrency(lineaFactura.iva4Linea)}">0.00</td>
<td class="text-end" th:text="${#numbers.formatCurrency(lineaFactura.iva21Linea)}">0.00</td>
<td class="text-end" th:text="${#numbers.formatCurrency(lineaFactura.totalLinea)}">0.00</td>
</tr>
</tbody>
<tfoot>
<tr>
<td class="text-end fw-bold"
th:attr="colspan=${factura.estado != null && factura.estado.name() == 'borrador' ? 5 : 4}"
th:text="#{facturas.lineas.base}">Base</td>
<td class="text-end" colspan="1" th:text="${#numbers.formatCurrency(factura.baseImponible)}">0.00</td>
</tr>
<tr>
<td class="text-end fw-bold"
th:attr="colspan=${factura.estado != null && factura.estado.name() == 'borrador' ? 5 : 4}"
th:text="#{facturas.lineas.iva_4}">I.V.A. 4%</td>
<td class="text-end" colspan="1" th:text="${#numbers.formatCurrency(factura.iva4)}">0.00</td>
</tr>
<tr>
<td class="text-end fw-bold"
th:attr="colspan=${factura.estado != null && factura.estado.name() == 'borrador' ? 5 : 4}"
th:text="#{facturas.lineas.iva_21}">I.V.A. 21%</td>
<td class="text-end" colspan="1" th:text="${#numbers.formatCurrency(factura.iva21)}">0.00</td>
</tr>
<tr>
<td class="text-end fw-bold text-uppercase"
th:attr="colspan=${factura.estado != null && factura.estado.name() == 'borrador' ? 5 : 4}"
th:text="#{facturas.lineas.total}">Total</td>
<td class="text-end fw-bold" colspan="1" th:text="${#numbers.formatCurrency(factura.totalFactura)}">0.00
</td>
</tr>
</table>
<!-- Modal líneas factura (crear/editar) -->
<th:block th:replace="~{imprimelibros/facturas/partials/linea-modal :: linea-modal}"></th:block>
</div>

View File

@ -0,0 +1,69 @@
<!-- imprimelibros/facturas/partials/factura-pagos.html -->
<div th:fragment="factura-pagos (factura)">
<div class="mb-3">
<button type="button" class="btn btn-secondary" id="btn-add-pago-factura">
<i class="fas fa-plus-circle me-2"></i>
<span th:text="#{facturas.pagos.acciones.agregar}">Agregar pago</span>
</button>
</div>
<table class="table table-bordered table-striped table-wrap w-100">
<thead>
<tr>
<th th:text="#{facturas.pagos.acciones}">Acciones</th>
<th th:text="#{facturas.pagos.metodo}">Método</th>
<th th:text="#{facturas.pagos.fecha}">Fecha</th>
<th class="text-center w-50" th:text="#{facturas.pagos.notas}">Notas</th>
<th class="text-end" th:text="#{facturas.pagos.cantidad}">Cantidad</th>
</tr>
</thead>
<tbody>
<tr th:each="pago : ${factura.pagos}" th:if="${pago.deletedAt == null}">
<td>
<button type="button" class="btn btn-secondary btn-sm me-2 btn-edit-pago-factura" th:attr="
data-pago-id=${pago.id},
data-metodo=${pago.metodoPago},
data-cantidad=${pago.cantidadPagada},
data-fecha=${#temporals.format(pago.fechaPago,'yyyy-MM-dd')}">
<i class="fas fa-edit me-1"></i>
<span th:text="#{facturas.pagos.acciones.editar}">Editar</span>
</button>
<button type="button" class="btn btn-danger btn-sm btn-delete-pago-factura"
th:attr="data-pago-id=${pago.id}">
<i class="fas fa-trash-alt"></i>
<span th:text="#{facturas.pagos.acciones.eliminar}">Eliminar</span>
</button>
<!-- notas en HTML (igual que líneas: guardadas en textarea oculto) -->
<textarea class="d-none" th:attr="id=${'pago-notas-' + pago.id}" th:text="${pago.notas}"></textarea>
</td>
<td th:text="${#messages.msg('facturas.pagos.tipo.' + pago.metodoPago.name().toLowerCase())}">
TPV/Tarjeta
</td>
<!-- Formato visual dd/MM/yyyy -->
<td th:text="${#temporals.format(pago.fechaPago,'dd/MM/yyyy')}">01/01/2026 10:00</td>
<td class="text-muted">
<span th:if="${pago.notas == null || #strings.isEmpty(pago.notas)}"></span>
<span th:if="${pago.notas != null && !#strings.isEmpty(pago.notas)}"
th:utext="${pago.notas}"></span>
</td>
<td class="text-end" th:text="${#numbers.formatCurrency(pago.cantidadPagada)}">0,00 €</td>
</tr>
</tbody>
<tfoot>
<tr>
<th colspan="4" class="text-end" th:text="#{facturas.pagos.total_pagado}">Total pagado</th>
<th class="text-end" th:text="${#numbers.formatCurrency(factura.totalPagado)}">0,00 €</th>
</tr>
</tfoot>
</table>
<!-- Modal pagos (crear/editar) -->
<th:block th:replace="~{imprimelibros/facturas/partials/pago-modal :: pago-modal}"></th:block>
</div>

View File

@ -0,0 +1,79 @@
<!-- imprimelibros/facturas/partials/linea-modal.html -->
<div th:fragment="linea-modal">
<div class="modal fade" id="lineaFacturaModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="lineaFacturaModalTitle" th:text="#{facturas.lineas.titulo}">Línea de factura</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cerrar"></button>
</div>
<div class="modal-body">
<!-- hidden: id de la línea (vacío = nueva) -->
<input type="hidden" id="lineaFacturaId" value=""/>
<!-- Descripción con Quill -->
<div class="mb-3">
<label class="form-label" th:text="#{facturas.lineas.descripcion}">Descripción</label>
<!-- Quill Snow Editor -->
<div id="lineaFacturaDescripcionEditor"
class="snow-editor" style="min-height: 200px;"
data-contenido=""></div>
</div>
<div class="row g-3">
<div class="col-12 col-md-4">
<label for="lineaFacturaBase" class="form-label"
th:text="#{facturas.lineas.base}">Base</label>
<input type="text"
class="form-control text-end"
id="lineaFacturaBase"
inputmode="decimal"
autocomplete="off"
placeholder="0,00">
</div>
<div class="col-12 col-md-4">
<label for="lineaFacturaIva4" class="form-label"
th:text="#{facturas.lineas.iva_4}">I.V.A. 4%</label>
<input type="text"
class="form-control text-end"
id="lineaFacturaIva4"
inputmode="decimal"
autocomplete="off"
placeholder="0,00">
<div class="form-text" th:text="#{facturas.lineas.iva_4.help}">Introduce el importe del I.V.A. (no el %).</div>
</div>
<div class="col-12 col-md-4">
<label for="lineaFacturaIva21" class="form-label"
th:text="#{facturas.lineas.iva_21}">I.V.A. 21%</label>
<input type="text"
class="form-control text-end"
id="lineaFacturaIva21"
inputmode="decimal"
autocomplete="off"
placeholder="0,00">
<div class="form-text" th:text="#{facturas.lineas.iva_21.help}">Introduce el importe del I.V.A. (no el %).</div>
</div>
</div>
<!-- zona errores -->
<div class="alert alert-danger d-none mt-3" id="lineaFacturaModalError"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal" th:text="#{app.cancelar}">Cancelar</button>
<button type="button" class="btn btn-secondary" id="btnGuardarLineaFactura" th:text="#{app.guardar}">
Guardar
</button>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,66 @@
<!-- imprimelibros/facturas/partials/pago-modal.html -->
<div th:fragment="pago-modal">
<div class="modal fade" id="pagoFacturaModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="pagoFacturaModalTitle" th:text="#{facturas.pagos.titulo}">
Pago de factura
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cerrar"></button>
</div>
<div class="modal-body">
<input type="hidden" id="pagoFacturaId" value="" />
<div class="row g-3">
<div class="col-12 col-md-4">
<label for="pagoFacturaMetodo" class="form-label"
th:text="#{facturas.pagos.tipo}">Tipo de pago</label>
<select class="form-select" id="pagoFacturaMetodo">
<option value="tpv_tarjeta" th:text="#{facturas.pagos.tipo.tpv_tarjeta}">TPV/Tarjeta
</option>
<option value="tpv_bizum" th:text="#{facturas.pagos.tipo.tpv_bizum}">TPV/Bizum</option>
<option value="transferencia" th:text="#{facturas.pagos.tipo.transferencia}">
Transferencia</option>
<option value="otros" th:text="#{facturas.pagos.tipo.otros}">Otros</option>
</select>
</div>
<div class="col-12 col-md-4">
<label for="pagoFacturaCantidad" class="form-label"
th:text="#{facturas.pagos.cantidad}">Cantidad</label>
<input type="text" class="form-control text-end" id="pagoFacturaCantidad"
inputmode="decimal" autocomplete="off" placeholder="0,00">
</div>
<div class="col-12 col-md-4">
<label for="pagoFacturaFecha" class="form-label" th:text="#{facturas.pagos.fecha}">Fecha de
pago</label>
<input type="text" class="form-control" id="pagoFacturaFecha" autocomplete="off"
placeholder="dd/mm/aaaa hh:mm">
</div>
</div>
<div class="mt-3">
<label class="form-label" th:text="#{facturas.pagos.notas}">Notas</label>
<div id="pagoFacturaNotasEditor" class="snow-editor" style="min-height: 180px;"
data-contenido=""></div>
</div>
<div class="alert alert-danger d-none mt-3" id="pagoFacturaModalError"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal"
th:text="#{app.cancelar}">Cancelar</button>
<button type="button" class="btn btn-secondary" id="btnGuardarPagoFactura" th:text="#{app.guardar}">
Guardar
</button>
</div>
</div>
</div>
</div>
</div>

View File

@ -14,6 +14,7 @@
<th:block th:replace="~{imprimelibros/partials/head-css :: head-css}" /> <th:block th:replace="~{imprimelibros/partials/head-css :: head-css}" />
<link href="/assets/libs/sweetalert2/sweetalert2.min.css" rel="stylesheet" type="text/css" /> <link href="/assets/libs/sweetalert2/sweetalert2.min.css" rel="stylesheet" type="text/css" />
<link href="/assets/libs/select2/select2.min.css" rel="stylesheet" /> <link href="/assets/libs/select2/select2.min.css" rel="stylesheet" />
<link rel="stylesheet" th:href="@{/assets/libs/flatpickr/flatpickr.min.css}">
<th:block layout:fragment="pagecss" /> <th:block layout:fragment="pagecss" />
</head> </head>
@ -38,6 +39,13 @@
<script src="/assets/libs/jquery/jquery-3.6.0.min.js"></script> <script src="/assets/libs/jquery/jquery-3.6.0.min.js"></script>
<script src="/assets/libs/sweetalert2/sweetalert2.min.js"></script> <script src="/assets/libs/sweetalert2/sweetalert2.min.js"></script>
<script src="/assets/libs/select2/select2.min.js"></script> <script src="/assets/libs/select2/select2.min.js"></script>
<script defer th:src="@{/assets/libs/flatpickr/flatpickr.min.js}"></script>
<th:block th:with="fpLang=${#locale.language}">
<script defer th:src="@{'/assets/libs/flatpickr/l10n/' + ${fpLang} + '.js'}"
onerror="console.error('No se pudo cargar flatpickr locale:', this.src)">
</script>
</th:block>
<th:block layout:fragment="pagejs" /> <th:block layout:fragment="pagejs" />
<script th:src="@{/assets/js/app.js}"></script> <script th:src="@{/assets/js/app.js}"></script>
<script th:src="@{/assets/js/pages/imprimelibros/languageBundle.js}"></script> <script th:src="@{/assets/js/pages/imprimelibros/languageBundle.js}"></script>

View File

@ -48,6 +48,11 @@
<i class="ri-book-3-line"></i> <span th:text="#{app.sidebar.pedidos}">Pedidos</span> <i class="ri-book-3-line"></i> <span th:text="#{app.sidebar.pedidos}">Pedidos</span>
</a> </a>
</li> </li>
<li th:if="${#authentication.principal.role == 'SUPERADMIN' or #authentication.principal.role == 'ADMIN'}" class="nav-item">
<a class="nav-link menu-link" href="/facturas">
<i class="ri-bill-line"></i> <span th:text="#{app.sidebar.facturas}">Facturas</span>
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link menu-link" href="/direcciones"> <a class="nav-link menu-link" href="/direcciones">
<i class="ri-truck-line"></i> <i class="ri-truck-line"></i>
@ -88,6 +93,14 @@
</a> </a>
</li> </li>
</div> </div>
<div th:if="${#authentication.principal.role == 'SUPERADMIN'}">
<li class="nav-item">
<a href="/configuracion/series-facturacion" class="nav-link">
<i class="ri-file-list-3-line"></i>
<span th:text="#{series-facturacion.title}">Series de facturación</span>
</a>
</li>
</div>
</ul> </ul>
</li> </li>

View File

@ -0,0 +1,164 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org " lang="es">
<head>
<meta charset="UTF-8" />
<title th:text="'Factura ' + ${factura.numeroFactura}">Factura</title>
<link rel="stylesheet" href="assets/css/bootstrap-for-pdf.css" />
<link rel="stylesheet" href="assets/css/facturapdf.css" />
</head>
<body class="has-watermark">
<!-- PIE -->
<div class="pdf-footer-running">
<div class="footer" id="pdf-footer">
<div class="privacy">
<div class="pv-title" th:text="#{pdf.politica-privacidad}">Política de privacidad</div>
<div class="pv-text" th:text="#{pdf.politica-privacidad.responsable}">Responsable: Impresión Imprime Libros -
CIF:
B04998886 - Teléfono de contacto: 910052574</div>
<div class="pv-text" th:text="#{pdf.politica-privacidad.correo-direccion}">Correo electrónico:
info@imprimelibros.com - Dirección postal: Calle José Picón, Nº 28 Local A, 28028, Madrid</div>
<div class="pv-text" th:text="#{pdf.politica-privacidad.aviso}">
Le comunicamos que los datos que usted nos facilite quedarán incorporados
en nuestro registro interno de actividades de tratamiento con el fin de
llevar a cabo una adecuada gestión fiscal y contable.
Los datos proporcionados se conservarán mientras se mantenga la relación
comercial o durante los años necesarios para cumplir con las obligaciones legales.
Así mismo, los datos no serán cedidos a terceros salvo en aquellos casos en que exista
una obligación legal. Tiene derecho a acceder a sus datos personales, rectificar
los datos inexactos, solicitar su supresión, limitar alguno de los tratamientos
u oponerse a algún uso vía e-mail, personalmente o mediante correo postal.
</div>
</div>
<div class="page-number">
<span th:text="#{pdf.page} ?: 'Página'">Página</span><span> </span><span class="pn"></span>
</div>
</div>
</div>
<div class="page-content">
<!-- HEADER: logo izq + caja empresa dcha -->
<!-- HEADER: logo izq + caja empresa dcha (tabla, sin flex) -->
<table class="il-header">
<tr>
<td class="il-left">
<img src="assets/images/logo-light.png" alt="ImprimeLibros" class="il-logo" />
</td>
<td class="il-right">
<div class="il-company-box">
<span class="corner tl"></span>
<span class="corner tr"></span>
<span class="corner bl"></span>
<span class="corner br"></span>
<div class="company-line company-name" th:text="#{pdf.company.name} ?: 'ImprimeLibros'">
ImprimeLibros ERP</div>
<div class="company-line" th:text="#{pdf.company.address} ?: ''">C/ José Picón, 28 local A</div>
<div class="company-line">
<span th:text="#{pdf.company.postalcode} ?: '28028'">28028</span>
<span th:text="#{pdf.company.city} ?: 'Madrid'">Madrid</span>
</div>
<div class="company-line" th:text="#{pdf.company.phone} ?: '+34 910052574'">+34 910052574</div>
</div>
</td>
</tr>
</table>
<!-- BANDA SUPERIOR -->
<div class="doc-banner">
<div th:text="#{pdf.factura} ?: 'FACTURA'" class="banner-text">FACTURA</div>
</div>
<!-- FICHA Nº / CLIENTE / FECHA -->
<table class="sheet-info">
<tr>
<td class="text-start w-50"><span th:text="#{'pdf.factura.number'}" class="lbl">FACTURA Nº:</span> <span
class="val" th:text="${factura.numeroFactura}">153153</span></td>
<td class="text-end"><span class="lbl" th:text="#{pdf.presupuesto.date}">FECHA:</span> <span class="val"
th:text="${#temporals.format(factura.fechaEmision, 'dd/MM/yyyy')}">10/10/2025</span></td>
</tr>
</table>
<table class="sheet-info">
<tr>
<td class="text-start"><span th:text="#{'pdf.factura.razon-social'}" class="lbl">Razón Social:</span> <span
class="val" th:text="${direccionFacturacion.razonSocial}">153153</span></td>
<td class="text-end"><span th:text="#{'pdf.factura.direccion'}" class="lbl">Dirección:</span>
</td>
</tr>
<tr>
<td class="text-start"><span th:text="#{'pdf.factura.identificacion-fiscal'}" class="lbl">Identificación
Fiscal:</span> <span class="val" th:text="${direccionFacturacion.identificacionFiscal}">153153</span></td>
<td class="text-end">
<span class="val"
th:text="${direccionFacturacion.direccion + ', ' + direccionFacturacion.cp + ', ' + direccionFacturacion.ciudad}">153153</span>
</td>
</tr>
<tr>
<td class="text-start">
</td>
<td class="text-end">
<span class="val"
th:text="${direccionFacturacion.provincia + ', ' + #messages.msg('paises.' + direccionFacturacion.pais.keyword)}">153153</span>
</td>
</tr>
</table>
<!-- DATOS TÉCNICOS -->
<table class="items-table table table-bordered table-striped table-wrap w-100">
<thead>
<tr>
<th class="w-75" th:text="#{pdf.factura.lineas.descripcion}">Descripción</th>
<th class="num" th:text="#{pdf.factura.lineas.base}">Base</th>
<th class="num" th:text="#{pdf.factura.lineas.iva_4}">I.V.A. 4%</th>
<th class="num" th:text="#{pdf.factura.lineas.iva_21}">I.V.A. 21%</th>
<th class="num" th:text="#{pdf.factura.lineas.total}">Total</th>
</tr>
</thead>
<tbody>
<tr th:each="lineaFactura : ${factura.lineas}">
<td class="desc" th:utext="${lineaFactura.descripcion}">Descripción de la línea</td>
<td class="text-end" th:text="${#numbers.formatCurrency(lineaFactura.baseLinea)}">0.00</td>
<td class="text-end" th:text="${#numbers.formatCurrency(lineaFactura.iva4Linea)}">0.00</td>
<td class="text-end" th:text="${#numbers.formatCurrency(lineaFactura.iva21Linea)}">0.00</td>
<td class="text-end" th:text="${#numbers.formatCurrency(lineaFactura.totalLinea)}">0.00</td>
</tr>
</tbody>
<tfoot>
<tr>
<td class="text-end fw-bold" colspan="4" th:text="#{pdf.factura.lineas.base}">Base</td>
<td class="text-end" colspan="1" th:text="${#numbers.formatCurrency(factura.baseImponible)}">0.00</td>
</tr>
<tr>
<td class="text-end fw-bold" colspan="4" th:text="#{pdf.factura.lineas.iva_4}">I.V.A. 4%</td>
<td class="text-end" colspan="1" th:text="${#numbers.formatCurrency(factura.iva4)}">0.00</td>
</tr>
<tr>
<td class="text-end fw-bold" colspan="4" th:text="#{pdf.factura.lineas.iva_21}">I.V.A. 21%</td>
<td class="text-end" colspan="1" th:text="${#numbers.formatCurrency(factura.iva21)}">0.00</td>
</tr>
<tr>
<td class="text-end fw-bold text-uppercase" colspan="4" th:text="#{pdf.factura.lineas.total}">Total</td>
<td class="text-end fw-bold" colspan="1" th:text="${#numbers.formatCurrency(factura.totalFactura)}">0.00
</td>
</tr>
</tfoot>
</table>
</div>
</body>
</html>

View File

@ -73,24 +73,24 @@
Aceptar ferro Aceptar ferro
</button> </button>
<button th:if="${item.estado.priority >= 7 && item.estado.priority < 11 && item.buttons.ferro}" <button
th:if="${item.estado.priority >= 7 and item.estado.priority <= 11 and item['buttons'] != null and item['buttons']['ferro'] == true}"
type="button" class="btn btn-light w-100 btn-download-ferro" type="button" class="btn btn-light w-100 btn-download-ferro"
th:text="#{pedido.view.ferro-download}" th:text="#{pedido.view.ferro-download}" th:attr="data-linea-id=${item.lineaId}">
th:attr="data-linea-id=${item.lineaId}">
Descargar ferro Descargar ferro
</button> </button>
<button th:if="${item.estado.priority >= 7 && item.estado.priority < 11 && item.buttons.cub}" <button
th:if="${item.estado.priority >= 7 and item.estado.priority <= 11 and item['buttons'] != null and item['buttons']['cub'] == true}"
type="button" class="btn btn-light w-100 btn-download-cub" type="button" class="btn btn-light w-100 btn-download-cub"
th:text="#{pedido.view.cub-download}" th:text="#{pedido.view.cub-download}" th:attr="data-linea-id=${item.lineaId}">
th:attr="data-linea-id=${item.lineaId}">
Descargar cubierta Descargar cubierta
</button> </button>
<button th:if="${item.estado.priority >= 7 && item.estado.priority < 11 && item.buttons.tapa}" <button
th:if="${item.estado.priority >= 7 and item.estado.priority <= 11 and item['buttons'] != null and item['buttons']['tapa'] == true}"
type="button" class="btn btn-light w-100 btn-download-tapa" type="button" class="btn btn-light w-100 btn-download-tapa"
th:text="#{pedido.view.tapa-download}" th:text="#{pedido.view.tapa-download}" th:attr="data-linea-id=${item.lineaId}">
th:attr="data-linea-id=${item.lineaId}">
Descargar tapa Descargar tapa
</button> </button>
</div> </div>
@ -136,7 +136,7 @@
<div class="d-flex flex-wrap my-n1"> <div class="d-flex flex-wrap my-n1">
<!-- Actualizar estado--> <!-- Actualizar estado-->
<div class="update-estado-button" <div class="update-estado-button"
th:if="${item.estado.name != 'cancelado' && item.estado.name != 'maquetacion' && item.estado.name != 'terminado'}"> th:if="${item.estado.name != 'cancelado' && item.estado.name != 'maquetacion' && item.estado.name != 'enviado'}">
<a href="javascript:void(0);" class="d-block text-body p-1 px-2 update-status-item" <a href="javascript:void(0);" class="d-block text-body p-1 px-2 update-status-item"
th:attr="data-linea-id=${item.lineaId}"> th:attr="data-linea-id=${item.lineaId}">
<i class="ri-refresh-line text-muted align-bottom me-1"><span <i class="ri-refresh-line text-muted align-bottom me-1"><span

View File

@ -43,6 +43,7 @@
pais=${direccionFacturacion != null ? direccionFacturacion.paisNombre : ''} pais=${direccionFacturacion != null ? direccionFacturacion.paisNombre : ''}
)}"> )}">
</div> </div>
</div> </div>
<th:block th:if="${isAdmin and showCancel}"> <th:block th:if="${isAdmin and showCancel}">
<div sec:authorize="isAuthenticated() and hasAnyRole('SUPERADMIN','ADMIN')" <div sec:authorize="isAuthenticated() and hasAnyRole('SUPERADMIN','ADMIN')"
@ -60,11 +61,23 @@
</div> </div>
</div> </div>
</th:block> </th:block>
<th:block th:if="${showDownloadFactura}">
<div class="col-12 col-md-auto">
<div class="card card border mb-3">
<div class="card-header bg-light">
<span class="fs-16" th:text="#{'pedido.view.actions'}"></span>
</div>
<div class="card-body">
<button type="button" th:attr="data-factura-id=${facturaId}" class="btn btn-secondary w-100 btn-download-factura"
th:text="#{pedido.view.descargar-factura}">
Descargar factura
</button>
</div>
</div>
</div>
</th:block>
</div> </div>
<th:block th:each="linea: ${lineas}"> <th:block th:each="linea: ${lineas}">
<div <div
th:insert="~{imprimelibros/pedidos/pedidos-linea :: pedido-linea (item=${linea}, isAdmin=${isAdmin})}"> th:insert="~{imprimelibros/pedidos/pedidos-linea :: pedido-linea (item=${linea}, isAdmin=${isAdmin})}">

View File

@ -0,0 +1,30 @@
package com.imprimelibros.erp;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.Locale;
import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.presupuesto.PresupuestoRepository;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
@SpringBootTest
public class genericTest {
@Autowired
private Utils utils;
@Autowired
private PresupuestoRepository presupuestoRepository;
@Test
void getTextoPresupuesto() {
Locale locale = Locale.forLanguageTag("es-ES");
Presupuesto presupuesto = presupuestoRepository.findById(86L).orElse(null);
Map<String, Object> texto = utils.getTextoPresupuesto(presupuesto, locale);
System.out.println("🧾 Texto del presupuesto:" + texto);
}
}

View File

@ -1,6 +1,8 @@
// src/test/java/com/imprimelibros/erp/pdf/PdfSmokeTest.java // src/test/java/com/imprimelibros/erp/pdf/PdfSmokeTest.java
package com.imprimelibros.erp.pdf; package com.imprimelibros.erp.pdf;
import java.util.Locale;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
@ -131,4 +133,10 @@ class PdfSmokeTest {
System.out.println("✅ PDF generado en: " + out.toAbsolutePath()); System.out.println("✅ PDF generado en: " + out.toAbsolutePath());
*/ */
} }
@Test
void generaFactura() {
pdfService.generaFactura(2L, Locale.forLanguageTag("es-ES"));
}
} }