trabajando en el formulario de la factura

This commit is contained in:
2025-12-31 18:07:17 +01:00
parent d7b5dedb38
commit 9d4320db9a
20 changed files with 6431 additions and 11351 deletions

View File

@ -9,13 +9,12 @@ import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import org.hibernate.annotations.Formula;
@Entity
@Table(
name = "facturas",
uniqueConstraints = {
@Table(name = "facturas", uniqueConstraints = {
@UniqueConstraint(name = "uq_facturas_numero_factura", columnNames = "numero_factura")
}
)
})
public class Factura extends AbstractAuditedEntitySoftTs {
@Column(name = "pedido_id")
@ -80,11 +79,15 @@ public class Factura extends AbstractAuditedEntitySoftTs {
@OneToMany(mappedBy = "factura", cascade = CascadeType.ALL, orphanRemoval = true)
private List<FacturaPago> pagos = 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);
@ -94,63 +97,154 @@ public class Factura extends AbstractAuditedEntitySoftTs {
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 Long getPedidoId() {
return pedidoId;
}
public Factura getFacturaRectificada() { return facturaRectificada; }
public void setFacturaRectificada(Factura facturaRectificada) { this.facturaRectificada = facturaRectificada; }
public void setPedidoId(Long pedidoId) {
this.pedidoId = pedidoId;
}
public Factura getFacturaRectificativa() { return facturaRectificativa; }
public void setFacturaRectificativa(Factura facturaRectificativa) { this.facturaRectificativa = facturaRectificativa; }
public Factura getFacturaRectificada() {
return facturaRectificada;
}
public User getCliente() { return cliente; }
public void setCliente(User cliente) { this.cliente = cliente; }
public void setFacturaRectificada(Factura facturaRectificada) {
this.facturaRectificada = facturaRectificada;
}
public SerieFactura getSerie() { return serie; }
public void setSerie(SerieFactura serie) { this.serie = serie; }
public Factura getFacturaRectificativa() {
return facturaRectificativa;
}
public String getNumeroFactura() { return numeroFactura; }
public void setNumeroFactura(String numeroFactura) { this.numeroFactura = numeroFactura; }
public void setFacturaRectificativa(Factura facturaRectificativa) {
this.facturaRectificativa = facturaRectificativa;
}
public EstadoFactura getEstado() { return estado; }
public void setEstado(EstadoFactura estado) { this.estado = estado; }
public User getCliente() {
return cliente;
}
public EstadoPagoFactura getEstadoPago() { return estadoPago; }
public void setEstadoPago(EstadoPagoFactura estadoPago) { this.estadoPago = estadoPago; }
public void setCliente(User cliente) {
this.cliente = cliente;
}
public TipoPago getTipoPago() { return tipoPago; }
public void setTipoPago(TipoPago tipoPago) { this.tipoPago = tipoPago; }
public SerieFactura getSerie() {
return serie;
}
public LocalDateTime getFechaEmision() { return fechaEmision; }
public void setFechaEmision(LocalDateTime fechaEmision) { this.fechaEmision = fechaEmision; }
public void setSerie(SerieFactura serie) {
this.serie = serie;
}
public BigDecimal getBaseImponible() { return baseImponible; }
public void setBaseImponible(BigDecimal baseImponible) { this.baseImponible = baseImponible; }
public String getNumeroFactura() {
return numeroFactura;
}
public BigDecimal getIva4() { return iva4; }
public void setIva4(BigDecimal iva4) { this.iva4 = iva4; }
public void setNumeroFactura(String numeroFactura) {
this.numeroFactura = numeroFactura;
}
public BigDecimal getIva21() { return iva21; }
public void setIva21(BigDecimal iva21) { this.iva21 = iva21; }
public EstadoFactura getEstado() {
return estado;
}
public BigDecimal getTotalFactura() { return totalFactura; }
public void setTotalFactura(BigDecimal totalFactura) { this.totalFactura = totalFactura; }
public void setEstado(EstadoFactura estado) {
this.estado = estado;
}
public BigDecimal getTotalPagado() { return totalPagado; }
public void setTotalPagado(BigDecimal totalPagado) { this.totalPagado = totalPagado; }
public EstadoPagoFactura getEstadoPago() {
return estadoPago;
}
public String getNotas() { return notas; }
public void setNotas(String notas) { this.notas = notas; }
public void setEstadoPago(EstadoPagoFactura estadoPago) {
this.estadoPago = estadoPago;
}
public List<FacturaLinea> getLineas() { return lineas; }
public void setLineas(List<FacturaLinea> lineas) { this.lineas = lineas; }
public TipoPago getTipoPago() {
return tipoPago;
}
public List<FacturaPago> getPagos() { return pagos; }
public void setPagos(List<FacturaPago> pagos) { this.pagos = pagos; }
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;
}
}

View File

@ -0,0 +1,146 @@
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.EstadoFactura;
import com.imprimelibros.erp.facturacion.Factura;
import com.imprimelibros.erp.facturacion.SerieFactura;
import com.imprimelibros.erp.facturacion.TipoSerieFactura;
import com.imprimelibros.erp.facturacion.repo.FacturaRepository;
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.LocalDateTime;
import java.time.format.DateTimeFormatter;
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 FacturaRepository repo;
private final TranslationService translationService;
private final MessageSource messageSource;
public FacturasController(
FacturaRepository repo,
TranslationService translationService,
MessageSource messageSource) {
this.repo = repo;
this.translationService = translationService;
this.messageSource = messageSource;
}
@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("/{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));
model.addAttribute("factura", factura);
return "imprimelibros/facturas/facturas-form";
}
// -----------------------------
// 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);
}
}

View File

@ -98,12 +98,12 @@ public class SeriesFacturacionController {
.add("actions", s -> """
<div class="hstack gap-3 flex-wrap">
<button type="button"
class="btn btn-link p-0 link-success btn-edit-serie fs-15"
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 btn-link p-0 link-danger btn-delete-serie fs-15"
class="btn p-0 link-danger btn-delete-serie fs-15"
data-id="%d">
<i class="ri-delete-bin-5-line"></i>
</button>

View File

@ -2,9 +2,10 @@ package com.imprimelibros.erp.facturacion.repo;
import com.imprimelibros.erp.facturacion.Factura;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.Optional;
public interface FacturaRepository extends JpaRepository<Factura, Long> {
public interface FacturaRepository extends JpaRepository<Factura, Long>, JpaSpecificationExecutor<Factura> {
Optional<Factura> findByNumeroFactura(String numeroFactura);
}

View File

@ -1,17 +1,31 @@
package com.imprimelibros.erp.facturacion.service;
import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.facturacion.*;
import com.imprimelibros.erp.facturacion.dto.FacturaLineaUpsertDto;
import com.imprimelibros.erp.facturacion.dto.FacturaPagoUpsertDto;
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.PedidoLinea;
import com.imprimelibros.erp.pedidos.PedidoLineaRepository;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
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.List;
import java.util.Map;
import java.util.Locale;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Service
@ -20,21 +34,121 @@ public class FacturacionService {
private final FacturaRepository facturaRepo;
private final SerieFacturaRepository serieRepo;
private final FacturaPagoRepository pagoRepo;
private final PedidoLineaRepository pedidoLineaRepo;
private final Utils utils;
private final MessageSource messageSource;
public FacturacionService(
FacturaRepository facturaRepo,
SerieFacturaRepository serieRepo,
FacturaPagoRepository pagoRepo
) {
FacturaPagoRepository pagoRepo,
PedidoLineaRepository pedidoLineaRepo,
Utils utils,
MessageSource messageSource) {
this.facturaRepo = facturaRepo;
this.serieRepo = serieRepo;
this.pagoRepo = pagoRepo;
this.pedidoLineaRepo = pedidoLineaRepo;
this.utils = utils;
this.messageSource = messageSource;
}
public SerieFactura getDefaultSerieFactura() {
List<SerieFactura> series = serieRepo.findAll();
if (series.isEmpty()) {
throw new IllegalStateException("No hay ninguna serie de facturación configurada.");
}
// Aquí simplemente devolvemos la primera. Puedes implementar lógica más
// compleja si es necesario.
return series.get(0);
}
// -----------------------
// 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);
}
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;
}
// -----------------------
// 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 Factura validarFactura(Long facturaId) {
Factura factura = facturaRepo.findById(facturaId)
@ -56,7 +170,8 @@ public class FacturacionService {
// 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()));
.orElseThrow(
() -> new EntityNotFoundException("Serie no encontrada: " + factura.getSerie().getId()));
long next = (serieLocked.getNumeroActual() == null) ? 1L : serieLocked.getNumeroActual();
String numeroFactura = buildNumeroFactura(serieLocked.getPrefijo(), next);
@ -89,7 +204,7 @@ public class FacturacionService {
private String buildNumeroFactura(String prefijo, long numero) {
String pref = (prefijo == null) ? "" : prefijo.trim();
String num = String.format("%07d", numero);
return pref.isBlank() ? num : (pref + "-" + num);
return pref.isBlank() ? num : (pref + " " + num + "/" + LocalDate.now().getYear());
}
// -----------------------
@ -191,7 +306,8 @@ public class FacturacionService {
pago.setFechaPago(dto.getFechaPago() != null ? dto.getFechaPago() : LocalDateTime.now());
pago.setNotas(dto.getNotas());
// El tipo_pago de la factura: si tiene un pago, lo reflejamos (último pago manda)
// El tipo_pago de la factura: si tiene un pago, lo reflejamos (último pago
// manda)
factura.setTipoPago(dto.getMetodoPago());
recalcularTotales(factura);
@ -254,7 +370,8 @@ public class FacturacionService {
factura.setTotalPagado(scale2(pagado));
// estado_pago
// - cancelada: si la factura está marcada como cancelada manualmente (aquí NO lo hacemos automático)
// - 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) {
@ -277,4 +394,90 @@ public class FacturacionService {
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;");
}
}

View File

@ -3,6 +3,9 @@ package com.imprimelibros.erp.payments;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imprimelibros.erp.cart.Cart;
import com.imprimelibros.erp.cart.CartService;
import com.imprimelibros.erp.facturacion.SerieFactura;
import com.imprimelibros.erp.facturacion.TipoPago;
import com.imprimelibros.erp.facturacion.service.FacturacionService;
import com.imprimelibros.erp.payments.model.*;
import com.imprimelibros.erp.payments.repo.PaymentRepository;
import com.imprimelibros.erp.payments.repo.PaymentTransactionRepository;
@ -33,6 +36,7 @@ public class PaymentService {
private final ObjectMapper om = new ObjectMapper();
private final CartService cartService;
private final PedidoService pedidoService;
private final FacturacionService facturacionService;
public PaymentService(PaymentRepository payRepo,
PaymentTransactionRepository txRepo,
@ -40,7 +44,8 @@ public class PaymentService {
RedsysService redsysService,
WebhookEventRepository webhookEventRepo,
CartService cartService,
PedidoService pedidoService) {
PedidoService pedidoService,
FacturacionService facturacionService) {
this.payRepo = payRepo;
this.txRepo = txRepo;
this.refundRepo = refundRepo;
@ -48,6 +53,7 @@ public class PaymentService {
this.webhookEventRepo = webhookEventRepo;
this.cartService = cartService;
this.pedidoService = pedidoService;
this.facturacionService = facturacionService;
}
public Payment findFailedPaymentByOrderId(Long orderId) {
@ -253,6 +259,11 @@ public class PaymentService {
p.setCapturedAt(LocalDateTime.now());
pedidoService.setOrderAsPaid(p.getOrderId());
Pedido pedido = pedidoService.getPedidoById(p.getOrderId());
SerieFactura serie = facturacionService.getDefaultSerieFactura();
facturacionService.crearNuevaFacturaAuto(pedido, serie, notif.isBizum() ? TipoPago.tpv_bizum : TipoPago.tpv_tarjeta, locale);
} else {
p.setStatus(PaymentStatus.failed);
p.setFailedAt(LocalDateTime.now());
@ -452,6 +463,11 @@ public class PaymentService {
// 4) Procesar el pedido asociado al carrito (si existe) o marcar el pedido como pagado
if(p.getOrderId() != null) {
pedidoService.setOrderAsPaid(p.getOrderId());
Pedido pedido = pedidoService.getPedidoById(p.getOrderId());
SerieFactura serie = facturacionService.getDefaultSerieFactura();
facturacionService.crearNuevaFacturaAuto(pedido, serie, TipoPago.transferencia, locale);
}
/*else if (cartId != null) {
// Se procesa el pedido dejando el estado calculado en processOrder

View File

@ -122,27 +122,6 @@ public class PdfService {
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);
model.put("specs", specs);

View File

@ -59,6 +59,12 @@ public class PedidoService {
this.messageSource = messageSource;
}
public Pedido getPedidoById(Long pedidoId) {
return pedidoRepository.findById(pedidoId).orElse(null);
}
@Transactional
public Pedido crearPedido(
Long cartId,

View File

@ -23,6 +23,7 @@ app.logout=Cerrar sesión
app.sidebar.inicio=Inicio
app.sidebar.presupuestos=Presupuestos
app.sidebar.pedidos=Pedidos
app.sidebar.facturas=Facturas
app.sidebar.configuracion=Configuración
app.sidebar.usuarios=Usuarios
app.sidebar.direcciones=Mis Direcciones

View File

@ -0,0 +1,24 @@
facturas.title=Facturas
facturas.breadcrumb=Facturas
facturas.breadcrumb.ver=Ver 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.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.

View File

@ -2914,6 +2914,19 @@ File: Main Css File
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 {
border-color: rgba(239, 174, 78, 0.6);
}

View File

@ -0,0 +1,129 @@
/* 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();
// -----------------------------
// 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,3 @@
$(() => {
});

View File

@ -0,0 +1,115 @@
<!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"><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">
<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 class="accordion accordion-fill-imprimelibros" 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 class="accordion accordion-fill-imprimelibros" 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-cabecera :: factura-cabecera (factura=${factura})}" /> -->
</div>
</div>
</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 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,56 @@
<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 -->
<div class="col-md-3">
<label class="form-label">Número</label>
<input type="text" class="form-control" th:value="${factura.numeroFactura}"
th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
</div>
<!-- Serie -->
<div class="col-md-3">
<label class="form-label">Serie facturación</label>
<input type="text" class="form-control"
th:value="${factura.serie != null ? factura.serie.nombreSerie : ''}" readonly>
</div>
<!-- Cliente -->
<div class="col-md-6">
<label class="form-label">Cliente</label>
<input type="text" class="form-control" th:value="${factura.cliente.fullName}" readonly>
</div>
<!-- Fecha emisión -->
<div class="col-md-3">
<label class="form-label">Fecha</label>
<input type="text" class="form-control" th:value="${factura.fechaEmision != null
? #temporals.format(factura.fechaEmision, 'dd/MM/yyyy')
: ''}" th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
</div>
<!-- Notas -->
<div class="col-md-12">
<label class="form-label">Notas</label>
<textarea class="form-control" rows="3" th:text="${factura.notas}"
th:attrappend="readonly=${isReadonly} ? 'readonly' : null">
</textarea>
</div>
</div>
</th:block>
</div>

View File

@ -48,6 +48,11 @@
<i class="ri-book-3-line"></i> <span th:text="#{app.sidebar.pedidos}">Pedidos</span>
</a>
</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">
<a class="nav-link menu-link" href="/direcciones">
<i class="ri-truck-line"></i>

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);
}
}