diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog20.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog20.java
index 0cec8b7f..02a09de1 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog20.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog20.java
@@ -1,15 +1,93 @@
+/*
+ * Catálogo 20 - Motivo de traslado
+ *
+ * Fuente normativa: Anexo N.° 8 de la RS 000123-2022/SUNAT,
+ * actualizado por RS 000240-2024/SUNAT.
+ */
package io.github.project.openubl.xbuilder.content.catalogs;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+/**
+ * Catálogo N.° 20 - Motivo de traslado.
+ *
+ * Vigente según RS 000123-2022/SUNAT y RS 000240-2024/SUNAT.
+ * Aplicable tanto a GRE-Remitente (09) como GRE-Transportista (31).
+ */
public enum Catalog20 implements Catalog {
+
+ /** 01 - Venta */
VENTA("01"),
- VENTA_SUJETA_A_CONFIRMACION_DEL_COMPRADOR("14"),
+
+ /** 02 - Compra */
COMPRA("02"),
- TRASLADO_ENTRE_ESTABLECIMIENTOS_DE_LA_MISMA_EMPRESA("04"),
- TRASLADO_EMISOR_ITINERANTE_CP("18"),
+
+ /** 03 - Venta con entrega a terceros (consignación) */
+ CONSIGNACION("03"),
+
+ /** 04 - Traslado entre establecimientos de la misma empresa */
+ TRASLADO_ENTRE_ESTABLECIMIENTOS("04"),
+
+ /** 05 - Devolución */
+ DEVOLUCION("05"),
+
+ /** 06 - Traslado de bienes para transformación */
+ TRASLADO_TRANSFORMACION("06"),
+
+ /** 07 - Recojo de bienes transformados */
+ RECOJO_BIENES_TRANSFORMADOS("07"),
+
+ /** 08 - Importación */
IMPORTACION("08"),
+
+ /** 09 - Exportación */
EXPORTACION("09"),
- TRASLADO_A_ZONA_PRIMARIA("19"),
- OTROS("13");
+
+ /**
+ * 10 - Importación: traslado de bienes con DAM/DS con levante.
+ *
+ * Aplica cuando la mercancía tiene levante autorizado.
+ * Incorporado por RS 000240-2024/SUNAT para trazabilidad de comercio exterior.
+ * Vigente desde 14-nov-2024; uso como motivo mandatorio pospuesto al
+ * 01-jul-2026.
+ */
+ IMPORTACION_CON_DAM("10"),
+
+ /** 11 - Importación temporal */
+ IMPORTACION_TEMPORAL("11"),
+
+ /** 13 - Otros */
+ OTROS("13"),
+
+ /** 14 - Venta sujeta a confirmación del comprador */
+ VENTA_SUJETA_A_CONFIRMACION("14"),
+
+ /**
+ * 15 - Traslado de bienes zona IVAP.
+ *
+ * Aplica para traslados de bienes gravados con IVAP (arroz).
+ */
+ TRASLADO_ZONA_IVAP("15"),
+
+ /** 16 - Exportación temporal (admisión temporal) */
+ EXPORTACION_TEMPORAL("16"),
+
+ /** 17 - Reexportación */
+ REEXPORTACION("17"),
+
+ /** 18 - Traslado emisor itinerante de comprobantes de pago */
+ TRASLADO_EMISOR_ITINERANTE_CP("18"),
+
+ /**
+ * 19 - Traslado de mercancía extranjera (zona primaria a depósito temporal).
+ *
+ * Uso obligatorio a partir del 01-jul-2026 para traslado de mercancía
+ * extranjera sin destinación aduanera o sin levante, en reemplazo del ticket de
+ * salida.
+ * Vigencia de la derogación del ticket pospuesta por RS 000133-2025/SUNAT.
+ */
+ TRASLADO_MERCANCIA_EXTRANJERA("19");
private final String code;
@@ -17,6 +95,10 @@ public enum Catalog20 implements Catalog {
this.code = code;
}
+ public static Optional valueOfCode(String code) {
+ return Stream.of(Catalog20.values()).filter(p -> p.code.equals(code)).findFirst();
+ }
+
@Override
public String getCode() {
return code;
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog21.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog21.java
index 1237d471..abd3b35d 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog21.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog21.java
@@ -1,12 +1,53 @@
package io.github.project.openubl.xbuilder.content.catalogs;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+/**
+ * Catálogo N.° 21 - Tipo de documento relacionado a la Guía de Remisión
+ * Electrónica.
+ *
+ * Fuente: Anexo N.° 8 de la RS 000123-2022/SUNAT.
+ * Se utiliza en {@code cac:AdditionalDocumentReference / cbc:DocumentTypeCode}.
+ */
public enum Catalog21 implements Catalog {
+ /** 01 - Numeración DAM (Declaración Aduanera de Mercancías) */
NUMERACION_DAM("01"),
+
+ /** 02 - Número de orden de entrega */
NUMERO_DE_ORDEN_DE_ENTREGA("02"),
+
+ /** 03 - Número SCOP */
NUMERO_SCOP("03"),
+
+ /** 04 - Número de manifiesto de carga */
NUMERO_DE_MANIFIESTO_DE_CARGA("04"),
+
+ /** 05 - Número de constancia de detracción */
NUMERO_DE_CONSTANCIA_DE_DETRACCION("05"),
- OTROS("06");
+
+ /** 06 - Otros */
+ OTROS("06"),
+
+ /** 09 - Guía de remisión remitente */
+ GUIA_REMISION_REMITENTE("09"),
+
+ /** 12 - Declaración Simplificada (DS) */
+ DECLARACION_SIMPLIFICADA("12"),
+
+ /** 31 - Guía de remisión transportista */
+ GUIA_REMISION_TRANSPORTISTA("31"),
+
+ /**
+ * 49 - Ticket de salida ENAPU.
+ *
+ * Vigente condicionalmente: la derogación del ticket de salida ha sido
+ * pospuesta al 01-jul-2026 por RS 000133-2025/SUNAT.
+ */
+ TICKET_SALIDA("49"),
+
+ /** 50 - Código de autorización emitido por SUNAT */
+ CODIGO_AUTORIZACION_SUNAT("50");
private final String code;
@@ -14,6 +55,10 @@ public enum Catalog21 implements Catalog {
this.code = code;
}
+ public static Optional valueOfCode(String code) {
+ return Stream.of(Catalog21.values()).filter(p -> p.code.equals(code)).findFirst();
+ }
+
@Override
public String getCode() {
return code;
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog51.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog51.java
index cd7c0274..38f6c814 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog51.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog51.java
@@ -1,9 +1,69 @@
package io.github.project.openubl.xbuilder.content.catalogs;
+/**
+ * Catálogo 51: Código de tipo de operación.
+ *
+ * Fuente: SUNAT – Anexo V de la RS 097-2012/SUNAT y modificatorias. Usado en {@code cbc:InvoiceTypeCode/@listID} para
+ * facturas y boletas.
+ *
+ *
+ * @see Guía XML Factura 2.1 – Catálogo 51
+ */
public enum Catalog51 implements Catalog {
+
+ // ── Operaciones comunes ──────────────────────────────────────────
+ /** Venta interna (operación estándar gravada, exonerada, inafecta o mixta). */
VENTA_INTERNA("0101"),
+ /** Venta interna – anticipos. */
+ VENTA_INTERNA_ANTICIPOS("0113"),
+ /** Venta itinerante. */
+ VENTA_ITINERANTE("0112"),
+
+ // ── Exportación ──────────────────────────────────────────────────
+ /** Exportación de bienes. */
+ EXPORTACION_BIENES("0200"),
+ /** Exportación de servicios – prestación de servicios (num. 1 art. 33 Ley IGV). */
+ EXPORTACION_SERVICIOS_PRESTACION("0201"),
+ /** Exportación de servicios – hospedaje no domiciliado. */
+ EXPORTACION_SERVICIOS_HOSPEDAJE("0202"),
+ /** Exportación de servicios – transporte navieros. */
+ EXPORTACION_SERVICIOS_TRANSPORTE_NAVIEROS("0203"),
+ /** Exportación de servicios – servicios a turistas no domiciliados. */
+ EXPORTACION_SERVICIOS_TURISTAS("0204"),
+ /** Exportación de servicios – venta de bienes a pasajeros. */
+ EXPORTACION_SERVICIOS_BIENES_PASAJEROS("0205"),
+ /** Exportación de servicios – asistencia técnica. */
+ EXPORTACION_SERVICIOS_ASISTENCIA_TECNICA("0206"),
+ /** Exportación de servicios – otros (arts. 33, 33-A, 76 Ley IGV). */
+ EXPORTACION_SERVICIOS_OTROS("0207"),
+ /** Exportación de servicios – prestación realizada en zona franca. */
+ EXPORTACION_SERVICIOS_ZONA_FRANCA("0208"),
+
+ // ── Operaciones con no domiciliados ──────────────────────────────
+ /** Operación sujeta a detracción – recursos hidrobiológicos. */
OPERACION_SUJETA_A_DETRACCION("1001"),
- OPERACION_SUJETA_A_PERCEPCION("2001");
+ /** Operación sujeta a detracción – servicios de transporte pasajeros. */
+ OPERACION_SUJETA_DETRACCION_TRANSPORTE_PASAJEROS("1002"),
+ /** Operación sujeta a detracción – servicios de transporte carga. */
+ OPERACION_SUJETA_DETRACCION_TRANSPORTE_CARGA("1003"),
+ /** Operación sujeta a detracción – IVAP (arroz pilado). */
+ OPERACION_SUJETA_DETRACCION_IVAP("1004"),
+
+ // ── Percepción ───────────────────────────────────────────────────
+ /** Operación sujeta a percepción. */
+ OPERACION_SUJETA_A_PERCEPCION("2001"),
+
+ // ── Gratuitas ────────────────────────────────────────────────────
+ /** Operación gratuita – transferencia gratuita. */
+ OPERACION_GRATUITA("0112"),
+
+ // ── NRUS ─────────────────────────────────────────────────────────
+ /** Venta realizada por sujeto del NRUS. */
+ VENTA_NRUS("0113"),
+
+ // ── Otros ────────────────────────────────────────────────────────
+ /** Factura guía (venta interna + guía de remisión embebida). */
+ FACTURA_GUIA("0401");
private final String code;
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog54.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog54.java
index 58aa6fef..d4ff6868 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog54.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog54.java
@@ -1,9 +1,59 @@
package io.github.project.openubl.xbuilder.content.catalogs;
+/**
+ * Catálogo 54: Códigos de bienes y servicios sujetos a detracción.
+ *
+ * Fuente: SUNAT – RS 183-2004/SUNAT y modificatorias (RS 071-2018, RS 082-2018, etc.). Usado en
+ * {@code cbc:PaymentMeansID/@schemeName="Codigo de detraccion SUNAT"}.
+ *
+ *
+ * @see Guía XML Factura 2.1 – Catálogo 54
+ */
public enum Catalog54 implements Catalog {
+
+ // ── Venta de bienes (Anexo 1) ─────────────────────────────────
AZUCAR("001"),
ALCOHOL_ETILICO("003"),
- RECURSOS_HIDROBIOLOGICOS("004");
+ RECURSOS_HIDROBIOLOGICOS("004"),
+ MAIZ_AMARILLO_DURO("005"),
+ ALGODON("006"),
+ CANA_DE_AZUCAR("007"),
+ MADERA("008"),
+ ARENA_Y_PIEDRA("009"),
+ RESIDUOS_SUBPRODUCTOS("010"),
+ BIENES_GRAVADOS_CON_IGV_RENUNCIANDO_EXONERACION("011"),
+ INTERMEDIACION_LABORAL("012"),
+ ANIMALES_VIVOS("013"),
+ CARNES_Y_DESPOJOS("014"),
+ ABONOS_CUEROS_PIELES("015"),
+ ACEITE_DE_PESCADO("016"),
+ HARINA_POLVO_PELLETS_PESCADO("017"),
+ EMBARCACIONES_PESQUERAS("018"),
+ LECHE("019"),
+ ARROZ_PILADO("020"),
+ MINERALES_METALICOS_NO_METALICOS("021"),
+ BIENES_EXONERADOS_IGV("022"),
+ ORO_DEMAS_MINERALES("023"),
+ MINERALES_NO_METALICOS("024"),
+ ORO_AMALGAMA("025"),
+ PLOMO("026"),
+ PIMIENTO_PIQUILLO("027"),
+ ESPARRAGOS("028"),
+ ZINC("029"),
+ JUREL_Y_CABALLA("030"),
+ PAPA("031"),
+
+ // ── Servicios (Anexo 3) ───────────────────────────────────────
+ SERVICIOS_TRANSPORTE_BIENES_VIA_TERRESTRE("012"),
+ SERVICIOS_TRANSPORTE_PASAJEROS_VIA_TERRESTRE("020"),
+ ARRENDAMIENTO_BIENES("014"),
+ MANTENIMIENTO_REPARACION("017"),
+ MOVIMIENTO_DE_CARGA("019"),
+ OTROS_SERVICIOS_EMPRESARIALES("022"),
+ FABRICACION_ENCARGO("023"),
+ SERVICIO_TRANSPORTE_PERSONAS("027"),
+ CONTRATOS_CONSTRUCCION("037"),
+ DEMAS_SERVICIOS_GRAVADOS_IGV("012");
private final String code;
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog61.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog61.java
new file mode 100644
index 00000000..053852c8
--- /dev/null
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog61.java
@@ -0,0 +1,68 @@
+/*
+ * Catálogo 61 - Tipo de documento adicional relacionado al transporte
+ *
+ * Fuente normativa: Anexo N.° 8, Catálogo N.° 61 de la RS 000123-2022/SUNAT.
+ * Estos documentos se consignan en cac:AdditionalDocumentReference de la GRE
+ * con un DocumentTypeCode basado en este catálogo.
+ */
+package io.github.project.openubl.xbuilder.content.catalogs;
+
+import java.util.Optional;
+import java.util.stream.Stream;
+
+/**
+ * Catálogo N.° 61 - Tipo de documento adicional relacionado al transporte.
+ *
+ * Aplicable a los campos {@code cac:AdditionalDocumentReference} de la GRE
+ * correspondientes a documentos del ámbito de transporte y comercio exterior.
+ */
+public enum Catalog61 implements Catalog {
+
+ /** 01 - Factura */
+ FACTURA("01"),
+
+ /** 02 - Boleta de venta */
+ BOLETA_VENTA("02"),
+
+ /** 03 - Liquidación de compra */
+ LIQUIDACION_COMPRA("03"),
+
+ /** 04 - Guía de remisión remitente */
+ GUIA_REMISION_REMITENTE("04"),
+
+ /** 05 - Guía de remisión transportista */
+ GUIA_REMISION_TRANSPORTISTA("05"),
+
+ /** 06 - Carta de porte aéreo */
+ CARTA_PORTE_AEREO("06"),
+
+ /** 07 - Póliza de adjudicación */
+ POLIZA_ADJUDICACION("07"),
+
+ /** 09 - Guía de remisión remitente complementaria */
+ GUIA_REMISION_REMITENTE_COMP("09"),
+
+ /** 10 - Guía de remisión transportista complementaria */
+ GUIA_REMISION_TRANSPORTISTA_COMP("10"),
+
+ /** 50 - DAM (Declaración Aduanera de Mercancías) */
+ DAM("50"),
+
+ /** 52 - Declaración Simplificada de Importación/Exportación */
+ DECLARACION_SIMPLIFICADA("52");
+
+ private final String code;
+
+ Catalog61(String code) {
+ this.code = code;
+ }
+
+ public static Optional valueOfCode(String code) {
+ return Stream.of(Catalog61.values()).filter(p -> p.code.equals(code)).findFirst();
+ }
+
+ @Override
+ public String getCode() {
+ return code;
+ }
+}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog62.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog62.java
new file mode 100644
index 00000000..f4a79b02
--- /dev/null
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog62.java
@@ -0,0 +1,50 @@
+/*
+ * Catálogo 62 - Bienes normalizados sujetos a SPOT/IVAP
+ *
+ * Fuente normativa: Anexo N.° 8, Catálogo N.° 62 de la RS 000123-2022/SUNAT.
+ *
+ * Nota SUNAT FAQ #25: Se consideran bienes normalizados los bienes detallados
+ * en este catálogo CUANDO se encuentran sujetos al SPOT o IVAP. Si no están
+ * sujetos, no califican como bien normalizado y no se marca el indicador.
+ */
+package io.github.project.openubl.xbuilder.content.catalogs;
+
+import java.util.Optional;
+import java.util.stream.Stream;
+
+/**
+ * Catálogo N.° 62 - Bienes normalizados sujetos a detracción (SPOT) o IVAP.
+ *
+ * Se utiliza para marcar el indicador SUNAT_Envio_IndicadorBienNormalizado
+ * en el XML de la GRE cuando se transportan estos bienes y están sujetos
+ * a SPOT/IVAP.
+ */
+public enum Catalog62 implements Catalog {
+
+ /** Azúcar - sujeto a SPOT */
+ AZUCAR("01"),
+
+ /** Arroz - sujeto a IVAP */
+ ARROZ("02"),
+
+ /** Alcohol etílico - sujeto a SPOT */
+ ALCOHOL_ETILICO("03"),
+
+ /** Cemento (zonas de control de IQBF) */
+ CEMENTO("04");
+
+ private final String code;
+
+ Catalog62(String code) {
+ this.code = code;
+ }
+
+ public static Optional valueOfCode(String code) {
+ return Stream.of(Catalog62.values()).filter(p -> p.code.equals(code)).findFirst();
+ }
+
+ @Override
+ public String getCode() {
+ return code;
+ }
+}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog63.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog63.java
new file mode 100644
index 00000000..4f9f6827
--- /dev/null
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog63.java
@@ -0,0 +1,60 @@
+/*
+ * Catálogo 63 - Puertos
+ *
+ * Fuente normativa: Anexo N.° 8, Catálogo N.° 63 de la RS 000123-2022/SUNAT.
+ * Se utiliza como código de ubicación en FirstArrivalPortLocation con LocationTypeCode=1.
+ *
+ * Nota: Los puertos mencionados explícitamente en el RCP art. 21 numeral 3.2.9
+ * son Callao, Paita, Salaverry, Chimbote, Pisco, Ilo, Matarani y Chancay
+ * (este último agregado por RS 000240-2024/SUNAT).
+ */
+package io.github.project.openubl.xbuilder.content.catalogs;
+
+import java.util.Optional;
+import java.util.stream.Stream;
+
+/**
+ * Catálogo N.° 63 - Puertos nacionales.
+ *
+ * Utilizado en {@code cac:FirstArrivalPortLocation / cbc:ID} con
+ * {@code cbc:LocationTypeCode = 1} para indicar puerto de embarque/desembarque.
+ */
+public enum Catalog63 implements Catalog {
+
+ CALLAO("CALLAO", "Puerto del Callao"),
+ PAITA("PAITA", "Puerto de Paita"),
+ SALAVERRY("SALAVERRY", "Puerto de Salaverry"),
+ CHIMBOTE("CHIMBOTE", "Puerto de Chimbote"),
+ PISCO("PISCO", "Puerto de Pisco"),
+ ILO("ILO", "Puerto de Ilo"),
+ MATARANI("MATARANI", "Puerto de Matarani"),
+
+ /**
+ * Puerto de Chancay - agregado por RS 000240-2024/SUNAT para comercio exterior.
+ * Vigente desde 14-nov-2024.
+ */
+ CHANCAY("CHANCAY", "Puerto de Chancay");
+
+ private final String code;
+ private final String description;
+
+ Catalog63(String code, String description) {
+ this.code = code;
+ this.description = description;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public static Optional valueOfCode(String code) {
+ return Stream.of(Catalog63.values())
+ .filter(p -> p.code.equalsIgnoreCase(code))
+ .findFirst();
+ }
+
+ @Override
+ public String getCode() {
+ return code;
+ }
+}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog64.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog64.java
new file mode 100644
index 00000000..50c9fb59
--- /dev/null
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog64.java
@@ -0,0 +1,54 @@
+/*
+ * Catálogo 64 - Aeropuertos
+ *
+ * Fuente normativa: Anexo N.° 8, Catálogo N.° 64 de la RS 000123-2022/SUNAT.
+ * Se utiliza como código de ubicación en FirstArrivalPortLocation con LocationTypeCode=2.
+ */
+package io.github.project.openubl.xbuilder.content.catalogs;
+
+import java.util.Optional;
+import java.util.stream.Stream;
+
+/**
+ * Catálogo N.° 64 - Aeropuertos nacionales.
+ *
+ * Utilizado en {@code cac:FirstArrivalPortLocation / cbc:ID} con
+ * {@code cbc:LocationTypeCode = 2} para indicar aeropuerto de
+ * embarque/desembarque.
+ */
+public enum Catalog64 implements Catalog {
+
+ JORGE_CHAVEZ("LIM", "Aeropuerto Internacional Jorge Chávez"),
+ RODRIGUEZ_BALLON("AQP", "Aeropuerto Alfredo Rodríguez Ballón"),
+ ALEJANDRO_VELASCO("CUZ", "Aeropuerto Alejandro Velasco Astete"),
+ CAP_FAP_CARLOS_MARTINEZ_DE_PINILLOS("TRU", "Aeropuerto Carlos Martínez de Pinillos"),
+ CAP_FAP_JOSE_A_QUINONES("CIX", "Aeropuerto José A. Quiñones"),
+ INCA_MANCO_CAPAC("JUL", "Aeropuerto Inca Manco Cápac"),
+ PADRE_ALDAMIZ("PEM", "Aeropuerto Padre Aldamiz"),
+ CORONEL_FAP_FRANCISCO_SECADA("IQT", "Aeropuerto Coronel FAP Francisco Secada"),
+ CAP_FAP_DAVID_ABENSUR("PCL", "Aeropuerto David Abensur Rengifo"),
+ MAYOR_GENERAL_FAP_ARMANDO_REVOREDO("CJA", "Aeropuerto Mayor General FAP Armando Revoredo");
+
+ private final String code;
+ private final String description;
+
+ Catalog64(String code, String description) {
+ this.code = code;
+ this.description = description;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public static Optional valueOfCode(String code) {
+ return Stream.of(Catalog64.values())
+ .filter(p -> p.code.equalsIgnoreCase(code))
+ .findFirst();
+ }
+
+ @Override
+ public String getCode() {
+ return code;
+ }
+}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog8.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog8.java
index 7e517368..6f830c72 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog8.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/Catalog8.java
@@ -1,9 +1,22 @@
package io.github.project.openubl.xbuilder.content.catalogs;
+/**
+ * Catálogo 08: Sistema de cálculo del ISC (Impuesto Selectivo al Consumo).
+ *
+ * Fuente: SUNAT – Guía XML Factura 2.1, Catálogo 08. Usado en {@code cac:TaxSubtotal/cac:TaxCategory/cbc:TierRange}.
+ *
+ *
+ * Nota: Los códigos "02" y "03" corresponden a sistemas distintos del ISC según la normativa. El código correcto para
+ * "Sistema de precios de venta al público" es "03" (corregido de la versión anterior que usaba "02" erróneamente).
+ *
+ */
public enum Catalog8 implements Catalog {
+ /** Sistema al valor (porcentaje sobre valor de venta). */
SISTEMA_AL_VALOR("01"),
+ /** Aplicación al monto fijo (monto fijo por unidad). */
APLICACION_AL_MONTO_FIJO("02"),
- SISTEMA_DE_PRECIOS_DE_VENTA_AL_PUBLICO("02");
+ /** Sistema de precios de venta al público (ISC sobre PVP). */
+ SISTEMA_DE_PRECIOS_DE_VENTA_AL_PUBLICO("03");
private final String code;
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/IndicadorEnvio.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/IndicadorEnvio.java
new file mode 100644
index 00000000..89f7e5c9
--- /dev/null
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/IndicadorEnvio.java
@@ -0,0 +1,113 @@
+/*
+ * Indicadores especiales de envío para la GRE
+ *
+ * Fuente normativa: Anexo N.° 14, UBL 2.1 de la RS 000123-2022/SUNAT.
+ * Estos indicadores se consignan como cbc:SpecialInstructions dentro de cac:Shipment.
+ *
+ * Nota: No todos los indicadores aplican a ambos tipos de GRE.
+ * La columna "Aplica GRE-Remitente / GRE-Transportista" se documenta en cada valor.
+ */
+package io.github.project.openubl.xbuilder.content.catalogs;
+
+import java.util.Optional;
+import java.util.stream.Stream;
+
+/**
+ * Indicadores especiales de envío (SpecialInstructions) para la GRE.
+ *
+ * Se mapean a {@code cbc:SpecialInstructions} dentro de {@code cac:Shipment}.
+ * Cada indicador tiene un código literal que debe coincidir exactamente con
+ * lo que valida SUNAT.
+ */
+public enum IndicadorEnvio implements Catalog {
+
+ /**
+ * Indica que el traslado involucra la totalidad de la DAM/DS.
+ *
+ * Aplica: GRE-Remitente (motivos 08, 09, 10, 19).
+ * Si se marca, no se requiere detalle de ítems (se acepta línea vacía
+ * obligatoria por UBL).
+ * Ref: FAQ #24 SUNAT.
+ */
+ TRASLADO_TOTAL_DAM_DS("SUNAT_Envio_IndicadorTrasladoTotalDAMDS"),
+
+ /**
+ * Indica que los bienes trasladados son bienes normalizados sujetos a
+ * SPOT/IVAP.
+ *
+ * Aplica: GRE-Remitente.
+ * Condición: solo se marca si el bien está en el Catálogo 62 Y está sujeto a
+ * SPOT o IVAP.
+ * Ref: FAQ #25 SUNAT - si un bien del catálogo 62 NO está sujeto a SPOT o IVAP,
+ * NO califica como bien normalizado.
+ */
+ BIEN_NORMALIZADO("SUNAT_Envio_IndicadorBienNormalizado"),
+
+ /**
+ * Indica que el traslado es en vehículos de categoría M1 o L (vehículos
+ * menores).
+ *
+ * Aplica: GRE-Remitente (transporte privado).
+ * Cuando se marca, no se requiere consignar placa ni conductor.
+ *
+ * PENDIENTE DE VALIDACIÓN: El token exacto de este indicador debe
+ * confirmarse contra el Anexo N.° 14 o el catálogo interno del portal SUNAT.
+ * La separación conceptual respecto de {@link #TRANSBORDO_PROGRAMADO} es
+ * correcta.
+ *
+ * Ref: Anexo N.° 14, RS 000123-2022/SUNAT — campo cbc:SpecialInstructions.
+ */
+ VEHICULO_M1_L("SUNAT_Envio_IndicadorTrasladoVehiculoM1L"),
+
+ /**
+ * Indica que se ha producido un transbordo programado durante el trayecto.
+ *
+ * Este indicador corresponde a la GRE por eventos: se utiliza cuando ocurre
+ * un hecho no imputable al emisor que obliga a un transbordo o reinicio del
+ * traslado. NO es equivalente a vehículo categoría M1/L.
+ *
+ * Aplica: GRE-Remitente, GRE-Transportista (GRE complementaria por eventos).
+ * Ref: Numeral 4, Anexo RS 000123-2022/SUNAT — GRE por eventos.
+ */
+ TRANSBORDO_PROGRAMADO("SUNAT_Envio_IndicadorTransbordoProgramado"),
+
+ /**
+ * Indica que el retorno del vehículo está programado y la GRE ampara el
+ * retorno.
+ *
+ * Aplica: GRE-Remitente.
+ */
+ RETORNO_VEHICULO_ENVASES("SUNAT_Envio_IndicadorRetornoVehiculoEnvasesVacios"),
+
+ /**
+ * Indica que el retorno del vehículo con envases vacíos está programado.
+ *
+ * Aplica: GRE-Remitente.
+ */
+ RETORNO_VEHICULO_VACIO("SUNAT_Envio_IndicadorRetornoVehiculoVacio"),
+
+ /**
+ * Indica que el traslado es operación de importación de bienes en zona
+ * primaria.
+ *
+ * Aplica: GRE-Remitente (motivo 19 mercancía extranjera).
+ * Incorporado por RS 000240-2024/SUNAT para trazabilidad.
+ * Vigente desde 14-nov-2024; obligatoriedad plena pospuesta al 01-jul-2026.
+ */
+ TRASLADO_ZONA_PRIMARIA_COMEXT("SUNAT_Envio_IndicadorTrasladoVehiculoPesadoCarga");
+
+ private final String code;
+
+ IndicadorEnvio(String code) {
+ this.code = code;
+ }
+
+ public static Optional valueOfCode(String code) {
+ return Stream.of(IndicadorEnvio.values()).filter(p -> p.code.equals(code)).findFirst();
+ }
+
+ @Override
+ public String getCode() {
+ return code;
+ }
+}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/TipoConductor.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/TipoConductor.java
new file mode 100644
index 00000000..70c133f5
--- /dev/null
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/catalogs/TipoConductor.java
@@ -0,0 +1,39 @@
+package io.github.project.openubl.xbuilder.content.catalogs;
+
+/**
+ * Tipo de conductor en la Guía de Remisión Electrónica.
+ *
+ * Se mapea al campo {@code cbc:JobTitle} del elemento {@code cac:DriverPerson}
+ * en el XML UBL 2.1.
+ *
+ * SUNAT distingue dos roles:
+ *
+ * - Principal: conductor responsable del vehículo (obligatorio).
+ * - Secundario: conductor de relevo o copiloto (opcional).
+ *
+ *
+ * Ref: Anexo N.° 14 UBL 2.1, RS 000123-2022/SUNAT.
+ */
+public enum TipoConductor implements Catalog {
+
+ /**
+ * Conductor principal — obligatorio cuando se requiere consignar conductor.
+ */
+ PRINCIPAL("Principal"),
+
+ /**
+ * Conductor secundario (relevo, copiloto) — opcional.
+ */
+ SECUNDARIO("Secundario");
+
+ private final String code;
+
+ TipoConductor(String code) {
+ this.code = code;
+ }
+
+ @Override
+ public String getCode() {
+ return code;
+ }
+}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/jaxb/mappers/DespatchAdviceMapper.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/jaxb/mappers/DespatchAdviceMapper.java
index 22332ed4..0a6fc682 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/jaxb/mappers/DespatchAdviceMapper.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/jaxb/mappers/DespatchAdviceMapper.java
@@ -11,6 +11,7 @@
import io.github.project.openubl.xbuilder.content.models.common.Proveedor;
import io.github.project.openubl.xbuilder.content.models.standard.guia.DespatchAdvice;
import io.github.project.openubl.xbuilder.content.models.standard.guia.DespatchAdviceItem;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.Contenedor;
import io.github.project.openubl.xbuilder.content.models.standard.guia.Comprador;
import io.github.project.openubl.xbuilder.content.models.standard.guia.Tercero;
import io.github.project.openubl.xbuilder.content.models.standard.guia.Destinatario;
@@ -37,8 +38,10 @@
}, nullValuePropertyMappingStrategy = org.mapstruct.NullValuePropertyMappingStrategy.SET_TO_DEFAULT)
public interface DespatchAdviceMapper {
- @Mapping(target = "serie", source = "documentId", qualifiedBy = { SerieNumeroTranslator.class, SerieTranslator.class })
- @Mapping(target = "numero", source = "documentId", qualifiedBy = { SerieNumeroTranslator.class, Numero2Translator.class })
+ @Mapping(target = "serie", source = "documentId", qualifiedBy = { SerieNumeroTranslator.class,
+ SerieTranslator.class })
+ @Mapping(target = "numero", source = "documentId", qualifiedBy = { SerieNumeroTranslator.class,
+ Numero2Translator.class })
@Mapping(target = "version", source = "customizationId")
@Mapping(target = "fechaEmision", source = "issueDate")
@Mapping(target = "horaEmision", source = "issueTime")
@@ -57,6 +60,8 @@ public interface DespatchAdviceMapper {
@Mapping(target = "comprador", source = "buyerCustomerParty")
@Mapping(target = "documentoAdicional", ignore = true)
@Mapping(target = "detalle", ignore = true)
+ @Mapping(target = "documentoRelacionadoAdicional", ignore = true)
+ @Mapping(target = "documentosRelacionados", ignore = true)
DespatchAdvice map(XMLDespatchAdvice xml);
@Mapping(target = "tipoDocumento", source = "orderTypeCode")
@@ -141,12 +146,15 @@ default Tercero mapDespatchAdviceTercero(XMLDespatchAdvice.SellerSupplierParty x
@Mapping(target = "chofer", ignore = true)
@Mapping(target = "indicador", ignore = true)
@Mapping(target = "contenedor", ignore = true)
+ @Mapping(target = "declaracionAduanera", ignore = true)
+ @Mapping(target = "declaracionesAduaneras", ignore = true)
+ @Mapping(target = "numeroManifiesto", ignore = true)
Envio mapEnvio(XMLDespatchAdvice.Shipment xml);
@Condition
@Named("transportistaRequirements")
default boolean conditionTransportista(XMLDespatchAdvice.ShipmentStage xml) {
- return xml.getCarrierParty() != null && xml.getTransportMeans() != null && xml.getDriverPersons() != null && !xml.getDriverPersons().isEmpty();
+ return xml.getCarrierParty() != null;
}
@Mapping(target = "tipoDocumentoIdentidad", source = "carrierParty.partyIdentification.id.schemeID")
@@ -225,23 +233,18 @@ default String mapPrimaryDriverNum(List drivers)
}
@Named("mapContenedores")
- default List mapContenedores(List units) {
+ default List mapContenedores(List units) {
if (units == null)
return java.util.Collections.emptyList();
- List result = new java.util.ArrayList<>();
+ List result = new java.util.ArrayList<>();
for (XMLDespatchAdvice.TransportHandlingUnit unit : units) {
if (unit.getPackages() != null) {
- unit.getPackages().stream().map(XMLDespatchAdvice.Package::getTraceID)
- .filter(java.util.Objects::nonNull)
- .forEach(result::add);
- }
- if (unit.getTransportEquipments() != null) {
- // Only add as container if it's NOT a vehicle (simple ID, no transport means)
- unit.getTransportEquipments().stream()
- .filter(e -> e.getApplicableTransportMeans() == null)
- .map(XMLDespatchAdvice.TransportEquipment::getId)
- .filter(java.util.Objects::nonNull)
- .forEach(result::add);
+ for (XMLDespatchAdvice.Package pkg : unit.getPackages()) {
+ result.add(Contenedor.builder()
+ .numero(pkg.getId())
+ .precinto(pkg.getTraceID())
+ .build());
+ }
}
}
return result;
@@ -294,11 +297,27 @@ default Puerto mapAeropuerto(XMLDespatchAdvice.FirstArrivalPortLocation xml) {
default Vehicle mapVehiculo(List units) {
if (units == null)
return null;
- // The first equipment with transport means or multiple are usually vehicles
- return units.stream()
+ // First try: find equipment with transport means or attached (vehicle with
+ // details)
+ Vehicle detailed = units.stream()
.filter(u -> u.getTransportEquipments() != null)
.flatMap(u -> u.getTransportEquipments().stream())
- .filter(e -> e.getApplicableTransportMeans() != null || e.getAttachedTransportEquipments() != null)
+ .filter(e -> e.getApplicableTransportMeans() != null || e.getAttachedTransportEquipments() != null
+ || e.getShipmentDocumentReferences() != null)
+ .findFirst()
+ .map(this::mapDetailedVehicle)
+ .orElse(null);
+ if (detailed != null)
+ return detailed;
+
+ // Fallback: find any TransportEquipment as vehicle
+ // Identify vehicles by exclusion: TransportEquipment that is in a THU
+ // separate from packages (containers). A THU with only TransportEquipment
+ // and no Packages is assumed to be a vehicle.
+ return units.stream()
+ .filter(u -> u.getTransportEquipments() != null && !u.getTransportEquipments().isEmpty())
+ .filter(u -> u.getPackages() == null || u.getPackages().isEmpty())
+ .flatMap(u -> u.getTransportEquipments().stream())
.findFirst()
.map(this::mapDetailedVehicle)
.orElse(null);
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/jaxb/mappers/SummaryDocumentsMapper.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/jaxb/mappers/SummaryDocumentsMapper.java
index 6ade9936..6efcd28a 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/jaxb/mappers/SummaryDocumentsMapper.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/jaxb/mappers/SummaryDocumentsMapper.java
@@ -25,90 +25,110 @@
import java.util.stream.Collectors;
@Mapper(uses = {
- SerieNumeroMapper.class,
- FirmanteMapper.class,
- ProveedorMapper.class
+ SerieNumeroMapper.class,
+ FirmanteMapper.class,
+ ProveedorMapper.class
})
public interface SummaryDocumentsMapper {
- @Mapping(target = "fechaEmision", source = "issueDate")
- @Mapping(target = "firmante", source = "signature")
- @Mapping(target = "proveedor", source = "accountingSupplierParty")
-
- @Mapping(target = "numero", source = "documentId", qualifiedBy = {SerieNumeroTranslator.class, Numero3Translator.class})
- @Mapping(target = "fechaEmisionComprobantes", source = "referenceDate")
- @Mapping(target = "comprobantes", source = "lines")
- SummaryDocuments map(XMLSummaryDocuments xml);
-
- @Mapping(target = "tipoOperacion", source = "status.conditionCode")
- @Mapping(target = "comprobante", source = ".")
- SummaryDocumentsItem mapLines(XMLSummaryDocumentsLine xml);
-
- @Mapping(target = "moneda", source = "totalAmount.currencyID")
- @Mapping(target = "tipoComprobante", source = "documentTypeCode")
- @Mapping(target = "serieNumero", source = "documentId")
- @Mapping(target = "cliente", source = "accountingCustomerParty")
- @Mapping(target = "comprobanteAfectado.serieNumero", source = "billingReference.invoiceDocumentReference.id")
- @Mapping(target = "comprobanteAfectado.tipoComprobante", source = "billingReference.invoiceDocumentReference.documentTypeCode")
- @Mapping(target = "valorVenta", source = ".")
- @Mapping(target = "impuestos", source = ".")
- Comprobante mapLineComprobante(XMLSummaryDocumentsLine xml);
-
- @Mapping(target = "numeroDocumentoIdentidad", source = "customerAssignedAccountID")
- @Mapping(target = "tipoDocumentoIdentidad", source = "additionalAccountID")
- Cliente mapCliente(XMLSummaryDocumentsLine.AccountingCustomerParty xml);
-
- default ComprobanteValorVenta mapLineComprobanteValorVenta(XMLSummaryDocumentsLine xml) {
- if (xml == null) {
- return null;
- }
+ @Mapping(target = "fechaEmision", source = "issueDate")
+ @Mapping(target = "firmante", source = "signature")
+ @Mapping(target = "proveedor", source = "accountingSupplierParty")
+
+ @Mapping(target = "numero", source = "documentId", qualifiedBy = { SerieNumeroTranslator.class,
+ Numero3Translator.class })
+ @Mapping(target = "fechaEmisionComprobantes", source = "referenceDate")
+ @Mapping(target = "comprobantes", source = "lines")
+ SummaryDocuments map(XMLSummaryDocuments xml);
+
+ @Mapping(target = "tipoOperacion", source = "status.conditionCode")
+ @Mapping(target = "comprobante", source = ".")
+ SummaryDocumentsItem mapLines(XMLSummaryDocumentsLine xml);
+
+ @Mapping(target = "moneda", source = "totalAmount.currencyID")
+ @Mapping(target = "tipoComprobante", source = "documentTypeCode")
+ @Mapping(target = "serieNumero", source = "documentId")
+ @Mapping(target = "cliente", source = "accountingCustomerParty")
+ @Mapping(target = "comprobanteAfectado.serieNumero", source = "billingReference.invoiceDocumentReference.id")
+ @Mapping(target = "comprobanteAfectado.tipoComprobante", source = "billingReference.invoiceDocumentReference.documentTypeCode")
+ @Mapping(target = "valorVenta", source = ".")
+ @Mapping(target = "impuestos", source = ".")
+ Comprobante mapLineComprobante(XMLSummaryDocumentsLine xml);
+
+ @Mapping(target = "numeroDocumentoIdentidad", source = "customerAssignedAccountID")
+ @Mapping(target = "tipoDocumentoIdentidad", source = "additionalAccountID")
+ Cliente mapCliente(XMLSummaryDocumentsLine.AccountingCustomerParty xml);
+
+ default ComprobanteValorVenta mapLineComprobanteValorVenta(XMLSummaryDocumentsLine xml) {
+ if (xml == null) {
+ return null;
+ }
- Map billingPayments = Optional.ofNullable(xml.getBillingPayments())
- .orElse(Collections.emptyList())
- .stream()
- .collect(Collectors.toMap(
- XMLSummaryDocumentsLine.BillingPayment::getInstructionId,
- XMLSummaryDocumentsLine.BillingPayment::getPaidAmount
- ));
-
- BigDecimal importeTotal = Optional.ofNullable(xml.getTotalAmount())
- .map(XMLSummaryDocumentsLine.TotalAmount::getValue)
- .orElse(null);
- BigDecimal otrosCargos = Optional.ofNullable(xml.getAllowanceCharge())
- .map(XMLSummaryDocumentsLine.AllowanceCharge::getValue)
- .orElse(null);
-
- return ComprobanteValorVenta.builder()
- .importeTotal(importeTotal)
- .gravado(billingPayments.get("01"))
- .exonerado(billingPayments.get("02"))
- .inafecto(billingPayments.get("03"))
- .gratuito(billingPayments.get("05"))
- .otrosCargos(otrosCargos)
- .build();
- }
-
- default ComprobanteImpuestos mapLineComprobanteImpuestos(XMLSummaryDocumentsLine xml) {
- if (xml == null) {
- return null;
+ Map billingPayments = Optional.ofNullable(xml.getBillingPayments())
+ .orElse(Collections.emptyList())
+ .stream()
+ .collect(Collectors.toMap(
+ XMLSummaryDocumentsLine.BillingPayment::getInstructionId,
+ XMLSummaryDocumentsLine.BillingPayment::getPaidAmount));
+
+ BigDecimal importeTotal = Optional.ofNullable(xml.getTotalAmount())
+ .map(XMLSummaryDocumentsLine.TotalAmount::getValue)
+ .orElse(null);
+ BigDecimal otrosCargos = Optional.ofNullable(xml.getAllowanceCharge())
+ .map(XMLSummaryDocumentsLine.AllowanceCharge::getValue)
+ .orElse(null);
+
+ return ComprobanteValorVenta.builder()
+ .importeTotal(importeTotal)
+ .gravado(billingPayments.get("01"))
+ .exonerado(billingPayments.get("02"))
+ .inafecto(billingPayments.get("03"))
+ .gratuito(billingPayments.get("05"))
+ .otrosCargos(otrosCargos)
+ .build();
}
- Map taxTotals = Optional.ofNullable(xml.getTaxTotals())
- .orElse(Collections.emptyList())
- .stream()
- .collect(Collectors.toMap(
- taxTotal -> Optional.ofNullable(taxTotal.getTaxSubtotals())
+ default ComprobanteImpuestos mapLineComprobanteImpuestos(XMLSummaryDocumentsLine xml) {
+ if (xml == null) {
+ return null;
+ }
+
+ Map taxTotals = Optional.ofNullable(xml.getTaxTotals())
+ .orElse(Collections.emptyList())
+ .stream()
+ .collect(Collectors.toMap(
+ taxTotal -> Optional.ofNullable(taxTotal.getTaxSubtotals())
+ .flatMap(f -> Optional.ofNullable(f.getTaxCategory()))
+ .flatMap(f -> Optional.ofNullable(f.getTaxScheme()))
+ .flatMap(taxScheme -> Optional
+ .ofNullable(taxScheme.getId()))
+ .flatMap(code -> Catalog.valueOfCode(Catalog5.class,
+ code))
+ .orElse(null),
+ taxTotal -> Optional.ofNullable(taxTotal.getTaxAmount())
+ .orElse(BigDecimal.ZERO)));
+
+ BigDecimal tasaIgv = Optional.ofNullable(xml.getTaxTotals())
+ .orElse(Collections.emptyList())
+ .stream()
+ .filter(taxTotal -> Optional.ofNullable(taxTotal.getTaxSubtotals())
+ .flatMap(f -> Optional.ofNullable(f.getTaxCategory()))
+ .flatMap(f -> Optional.ofNullable(f.getTaxScheme()))
+ .flatMap(taxScheme -> Optional.ofNullable(taxScheme.getId()))
+ .flatMap(code -> Catalog.valueOfCode(Catalog5.class, code))
+ .filter(c -> c == Catalog5.IGV)
+ .isPresent())
+ .findFirst()
+ .flatMap(taxTotal -> Optional.ofNullable(taxTotal.getTaxSubtotals()))
.flatMap(f -> Optional.ofNullable(f.getTaxCategory()))
- .flatMap(f -> Optional.ofNullable(f.getTaxScheme()))
- .flatMap(taxScheme -> Optional.ofNullable(taxScheme.getId()))
- .flatMap(code -> Catalog.valueOfCode(Catalog5.class, code))
- .orElse(null),
- taxTotal -> Optional.ofNullable(taxTotal.getTaxAmount()).orElse(BigDecimal.ZERO)
- ));
-
- return ComprobanteImpuestos.builder()
- .igv(taxTotals.get(Catalog5.IGV))
- .icb(taxTotals.get(Catalog5.ICBPER))
- .build();
- }
+ .flatMap(f -> Optional.ofNullable(f.getPercent()))
+ .map(percent -> percent.divide(BigDecimal.valueOf(100)))
+ .orElse(null);
+
+ return ComprobanteImpuestos.builder()
+ .igv(taxTotals.get(Catalog5.IGV))
+ .tasaIgv(tasaIgv)
+ .icb(taxTotals.get(Catalog5.ICBPER))
+ .build();
+ }
}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/jaxb/models/XMLSummaryDocumentsLine.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/jaxb/models/XMLSummaryDocumentsLine.java
index 7de9302b..0eea22e6 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/jaxb/models/XMLSummaryDocumentsLine.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/jaxb/models/XMLSummaryDocumentsLine.java
@@ -149,6 +149,9 @@ public static class TaxSubtotal {
@Data
@NoArgsConstructor
public static class TaxCategory {
+ @XmlElement(name = "Percent", namespace = XMLConstants.CBC)
+ private BigDecimal percent;
+
@XmlElement(name = "TaxScheme", namespace = XMLConstants.CAC)
private TaxScheme taxScheme;
}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/common/Firmante.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/common/Firmante.java
index 5e3f3e9f..1c4cbcac 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/common/Firmante.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/common/Firmante.java
@@ -28,4 +28,12 @@ public class Firmante {
*/
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String razonSocial;
+
+ /**
+ * URI de referencia para la firma digital (cbc:URI dentro de cac:DigitalSignatureAttachment). Si no se proporciona,
+ * se usa el valor por defecto {@code #PROJECT-OPENUBL-SIGN}.
+ */
+ @Schema(description = "URI de referencia para la firma digital", defaultValue = "#PROJECT-OPENUBL-SIGN")
+ @Builder.Default
+ private String signatureUri = "#PROJECT-OPENUBL-SIGN";
}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/CreditNote.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/CreditNote.java
index 27b47a83..9a37aae4 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/CreditNote.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/CreditNote.java
@@ -4,14 +4,12 @@
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
-import lombok.extern.jackson.Jacksonized;
/**
* Clase base para CreditNote y DebitNote.
*
* @author Carlos Feria
*/
-@Jacksonized
@Data
@SuperBuilder
@NoArgsConstructor
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/DebitNote.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/DebitNote.java
index b37c2000..b873532c 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/DebitNote.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/DebitNote.java
@@ -4,9 +4,7 @@
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
-import lombok.extern.jackson.Jacksonized;
-@Jacksonized
@Data
@SuperBuilder
@NoArgsConstructor
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/Detraccion.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/Detraccion.java
index cc1a29e6..7c3a63f0 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/Detraccion.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/Detraccion.java
@@ -9,9 +9,20 @@
import java.math.BigDecimal;
/**
- * Detracción asociada a un Invoice
+ * Detracción asociada a una factura electrónica.
+ *
+ * Obligatoria cuando {@code tipoOperacion} = "1001" (Catálogo 51). Se renderiza en el XML como:
+ *
+ * - {@code cac:PaymentMeans} con {@code PaymentMeansCode} = Catálogo 59
+ * - {@code cac:PaymentTerms} con monto, porcentaje y código de detracción (Catálogo 54)
+ *
+ *
+ * Regla SUNAT: El monto de detracción se calcula como {@code porcentaje × importeConImpuestos} y se auto-calcula
+ * por el enricher si no se especifica explícitamente.
+ *
*
- * @author Carlos Feria
+ * @see io.github.project.openubl.xbuilder.content.catalogs.Catalog54
+ * @see io.github.project.openubl.xbuilder.content.catalogs.Catalog59
*/
@Data
@Builder
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/DocumentoVentaDetalle.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/DocumentoVentaDetalle.java
index b95cce8a..0005e6d1 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/DocumentoVentaDetalle.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/DocumentoVentaDetalle.java
@@ -5,70 +5,157 @@
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
+import lombok.Singular;
import java.math.BigDecimal;
-
+import java.util.List;
+
+/**
+ * Línea de detalle de un documento de venta (factura, boleta, nota de crédito/débito).
+ *
+ * Cada instancia representa una línea {@code cac:InvoiceLine} / {@code cac:CreditNoteLine} / {@code cac:DebitNoteLine}
+ * en el XML UBL 2.1.
+ *
+ *
+ * Campos calculados automáticamente por el enricher: {@code igv}, {@code igvBaseImponible}, {@code isc},
+ * {@code iscBaseImponible}, {@code icb}, {@code totalImpuestos}, {@code precioReferencia},
+ * {@code precioReferenciaTipo}.
+ *
+ *
+ * @see io.github.project.openubl.xbuilder.content.catalogs.Catalog7
+ * @see io.github.project.openubl.xbuilder.content.catalogs.Catalog8
+ * @see io.github.project.openubl.xbuilder.content.catalogs.Catalog16
+ */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DocumentoVentaDetalle {
- @Schema(description = "Descripcion del bien o servicio", requiredMode = Schema.RequiredMode.REQUIRED)
+ // ── Descripción e identificación ──────────────────────────────
+
+ /** Descripción del bien o servicio ({@code cbc:Description}). */
+ @Schema(description = "Descripción del bien o servicio", requiredMode = Schema.RequiredMode.REQUIRED)
private String descripcion;
+ /**
+ * Código interno del producto del vendedor ({@code cac:SellersItemIdentification/cbc:ID}). Opcional, pero
+ * recomendado para trazabilidad.
+ */
+ @Schema(description = "Código interno del vendedor")
+ private String codigoProducto;
+
+ /**
+ * Código de producto SUNAT / UNSPSC ({@code cac:CommodityClassification/cbc:ItemClassificationCode}). Obligatorio
+ * para: exportaciones, detracciones y cuando RS 133-2019/SUNAT lo exija según cronograma.
+ */
+ @Schema(description = "Código UNSPSC (Catálogo 25 SUNAT)")
+ private String codigoProductoSunat;
+
+ /**
+ * Código de producto estándar GS1 – GTIN/EAN ({@code cac:StandardItemIdentification/cbc:ID}). Opcional.
+ */
+ @Schema(description = "Código GS1/GTIN/EAN del producto")
+ private String codigoProductoGS1;
+
+ // ── Cantidad y medida ─────────────────────────────────────────
+
+ /** Unidad de medida (Catálogo 03 SUNAT). Default: "NIU" (unidad). */
private String unidadMedida;
+ /** Cantidad del bien o servicio. */
@Schema(requiredMode = Schema.RequiredMode.REQUIRED, minimum = "0", exclusiveMinimum = true)
private BigDecimal cantidad;
+ // ── Precios ───────────────────────────────────────────────────
+
+ /** Precio unitario sin incluir impuestos ({@code cac:Price/cbc:PriceAmount}). */
@Schema(description = "Precio sin incluir impuestos", minimum = "0")
private BigDecimal precio;
+ /** Si {@code true}, el campo {@code precio} ya incluye IGV y se recalculará internamente. */
@Schema(description = "Precio incluyendo impuestos")
private boolean precioConImpuestos;
+ /** Precio de referencia unitario ({@code cac:PricingReference/cac:AlternativeConditionPrice}). Calculado. */
@Schema(minimum = "0")
private BigDecimal precioReferencia;
- @Schema(description = "Catalog 16")
+ /** Tipo de precio de referencia (Catálogo 16). Calculado. */
+ @Schema(description = "Catálogo 16")
private String precioReferenciaTipo;
- // Impuestos
+ // ── IGV ───────────────────────────────────────────────────────
+
+ /** Tasa de IGV. Ejemplo: 0.18. Heredada del documento padre si no se especifica. */
@Schema(description = "Ejemplo: 0.18", minimum = "0", maximum = "1")
private BigDecimal tasaIgv;
+ /** Monto total de IGV de esta línea. Calculado. */
@Schema(description = "Monto total de IGV", minimum = "0")
private BigDecimal igv;
+ /** Base imponible del IGV. Calculado. */
@Schema(minimum = "0")
private BigDecimal igvBaseImponible;
- @Schema(description = "Catalogo 07")
+ /**
+ * Tipo de afectación al IGV (Catálogo 07). Default: "10" (gravado – operación onerosa).
+ */
+ @Schema(description = "Catálogo 07")
private String igvTipo;
+ // ── ICBPER (Impuesto al Consumo de Bolsas de Plástico) ───────
+
+ /** Tasa del ICBPER por unidad. Ejemplo: 0.50 (PEN por bolsa). */
@Schema(minimum = "0")
private BigDecimal tasaIcb;
+ /** Monto total del ICBPER. Calculado: cantidad × tasaIcb. */
@Schema(minimum = "0")
private BigDecimal icb;
- @Schema(description = "'true' si ICB is aplicado a este bien o servicio")
+ /** {@code true} si el ICBPER aplica a esta línea (bolsas de plástico). */
+ @Schema(description = "'true' si ICB es aplicado a este bien o servicio")
private boolean icbAplica;
+ // ── ISC (Impuesto Selectivo al Consumo) ──────────────────────
+
+ /** Tasa del ISC. Ejemplo: 0.17. */
@Schema(description = "Ejemplo: 0.17", minimum = "0", maximum = "1")
private BigDecimal tasaIsc;
+ /** Monto total del ISC. Calculado. */
@Schema(description = "Monto total de ISC", minimum = "0")
private BigDecimal isc;
+ /** Base imponible del ISC. Calculado. */
@Schema(minimum = "0")
private BigDecimal iscBaseImponible;
- @Schema(description = "Catalogo 08")
+ /** Sistema de cálculo del ISC (Catálogo 08). */
+ @Schema(description = "Catálogo 08")
private String iscTipo;
- // Totales
+ // ── Descuentos/cargos por línea ──────────────────────────────
+
+ /**
+ * Descuentos aplicados a esta línea ({@code cac:AllowanceCharge} con {@code ChargeIndicator=false}). Catálogo 53 –
+ * código "00" (descuento que afecta base imponible) o "01" (descuento que no afecta).
+ */
+ @Singular
+ private List descuentos;
+
+ /**
+ * Cargos aplicados a esta línea ({@code cac:AllowanceCharge} con {@code ChargeIndicator=true}). Catálogo 53 –
+ * código "47" (cargo que afecta base imponible) o "48" (otros cargos).
+ */
+ @Singular
+ private List cargos;
+
+ // ── Totales ──────────────────────────────────────────────────
+
+ /** Total de impuestos de la línea (IGV + ISC + ICBPER). Calculado. */
@Schema(minimum = "0")
private BigDecimal totalImpuestos;
}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/Invoice.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/Invoice.java
index 63257183..7cf196be 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/Invoice.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/Invoice.java
@@ -8,12 +8,28 @@
import lombok.Singular;
import lombok.ToString;
import lombok.experimental.SuperBuilder;
-import lombok.extern.jackson.Jacksonized;
import java.time.LocalDate;
import java.util.List;
-@Jacksonized
+/**
+ * Modelo de Factura Electrónica (01) y Boleta de Venta Electrónica (03).
+ *
+ * Ambos documentos comparten la misma estructura UBL 2.1 ({@code Invoice}). La diferencia normativa está en:
+ *
+ * - Serie: Factura = Fxxx, Boleta = Bxxx
+ * - Tipo comprobante (Catálogo 01): Factura = "01", Boleta = "03"
+ * - Receptor: Factura requiere RUC (6). Boleta acepta DNI (1), CE (4), etc.
+ * - Detracción: Solo aplica a facturas
+ * - Resumen diario: Las boletas se informan vía ResumenDiario; las facturas se envían individualmente
+ *
+ *
+ * El campo {@code tipoComprobante} se deduce automáticamente de la serie si no se especifica.
+ *
+ *
+ * @see io.github.project.openubl.xbuilder.content.catalogs.Catalog1_Invoice
+ * @see io.github.project.openubl.xbuilder.content.catalogs.Catalog51
+ */
@Data
@SuperBuilder
@NoArgsConstructor
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/InvoiceValidator.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/InvoiceValidator.java
new file mode 100644
index 00000000..c7d52de5
--- /dev/null
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/InvoiceValidator.java
@@ -0,0 +1,211 @@
+package io.github.project.openubl.xbuilder.content.models.standard.general;
+
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog;
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog1_Invoice;
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog51;
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog7;
+import io.github.project.openubl.xbuilder.content.models.common.Cliente;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.validation.ValidationMessage;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.validation.ValidationResult;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Validador de reglas de negocio para Factura Electrónica (01) y Boleta de Venta Electrónica (03).
+ *
+ * Implementa validaciones previas al renderizado XML según:
+ *
+ * - Guía XML Factura 2.1 – SUNAT
+ * - Guía XML Boleta 2.1 – SUNAT
+ * - Reglas de Validación CPE (versión vigente)
+ * - RS 007-99/SUNAT – Reglamento de Comprobantes de Pago
+ *
+ *
+ * IMPORTANTE: Este validador NO reemplaza la validación de SUNAT (XSD/XSL). Es una capa de validación temprana
+ * para detectar errores comunes antes del firmado y envío.
+ *
+ *
+ * @since 5.3.0
+ * @see Invoice
+ * @see SalesDocumentValidator
+ */
+public final class InvoiceValidator {
+
+ private InvoiceValidator() {
+ }
+
+ /**
+ * Valida un Invoice y retorna solo los mensajes de error.
+ *
+ * @param invoice el Invoice a validar
+ * @return lista de errores (vacía si es válido)
+ */
+ public static List validate(Invoice invoice) {
+ return validateDetailed(invoice).getErrors();
+ }
+
+ /**
+ * Valida un Invoice y retorna un {@link ValidationResult} con errores y advertencias.
+ *
+ * @param invoice el Invoice a validar
+ * @return resultado detallado con severidad
+ */
+ public static ValidationResult validateDetailed(Invoice invoice) {
+ List messages = new ArrayList<>();
+
+ // ── Campos básicos ───────────────────────────────────────
+ SalesDocumentValidator.validateBasicFields(invoice, messages);
+
+ // ── Tipo de comprobante ──────────────────────────────────
+ String tipo = invoice.getTipoComprobante();
+ boolean isFactura = "01".equals(tipo);
+ boolean isBoleta = "03".equals(tipo);
+
+ if (tipo != null && !isFactura && !isBoleta) {
+ messages.add(ValidationMessage.error(
+ "tipoComprobante debe ser '01' (Factura) o '03' (Boleta), valor: " + tipo));
+ }
+
+ // ── Serie coherente con tipo ─────────────────────────────
+ String serie = invoice.getSerie();
+ if (serie != null && tipo != null) {
+ if (isFactura && !serie.toUpperCase().startsWith("F")) {
+ messages.add(ValidationMessage.error(
+ "Factura (01) debe tener serie que empiece con 'F', valor: " + serie));
+ }
+ if (isBoleta && !serie.toUpperCase().startsWith("B")) {
+ messages.add(ValidationMessage.error(
+ "Boleta (03) debe tener serie que empiece con 'B', valor: " + serie));
+ }
+ }
+
+ // ── Tipo de operación (Catálogo 51) ──────────────────────
+ String tipoOp = invoice.getTipoOperacion();
+ if (tipoOp != null && Catalog.valueOfCode(Catalog51.class, tipoOp).isEmpty()) {
+ messages.add(ValidationMessage.warning(
+ "tipoOperacion '" + tipoOp + "' no encontrado en Catálogo 51"));
+ }
+
+ // ── Cliente/receptor ─────────────────────────────────────
+ validateCliente(invoice.getCliente(), isFactura, isBoleta, messages);
+
+ // ── Detracción solo en facturas ──────────────────────────
+ if (invoice.getDetraccion() != null && isBoleta) {
+ messages.add(ValidationMessage.error(
+ "La detracción solo aplica a Facturas (01), no a Boletas (03)"));
+ }
+
+ // ── Detracción requiere tipoOperacion 1001 ──────────────
+ if (invoice.getDetraccion() != null) {
+ validateDetraccion(invoice.getDetraccion(), tipoOp, messages);
+ }
+
+ // ── Percepción requiere tipoOperacion 2001 ──────────────
+ if (invoice.getPercepcion() != null) {
+ validatePercepcion(invoice.getPercepcion(), tipoOp, messages);
+ }
+
+ // ── Detalles ─────────────────────────────────────────────
+ SalesDocumentValidator.validateDetalles(invoice.getDetalles(), messages);
+
+ // ── Exportación requiere código producto SUNAT ───────────
+ if (tipoOp != null && tipoOp.startsWith("02")) {
+ validateExportacionItems(invoice.getDetalles(), messages);
+ }
+
+ return new ValidationResult(messages);
+ }
+
+ // ── Validaciones de cliente ──────────────────────────────────
+
+ private static void validateCliente(Cliente cliente, boolean isFactura, boolean isBoleta,
+ List messages) {
+ if (cliente == null) {
+ messages.add(ValidationMessage.error("El cliente/receptor es requerido"));
+ return;
+ }
+ if (isBlank(cliente.getNombre())) {
+ messages.add(ValidationMessage.error("El nombre del cliente es requerido"));
+ }
+
+ String tipoDoc = cliente.getTipoDocumentoIdentidad();
+ String numDoc = cliente.getNumeroDocumentoIdentidad();
+
+ if (isFactura) {
+ // Factura: receptor debe tener RUC (tipo "6") o DNI para montos <= 700 PEN
+ if (isBlank(tipoDoc) || isBlank(numDoc)) {
+ messages.add(ValidationMessage.error(
+ "Factura: el tipo y número de documento del receptor son obligatorios"));
+ }
+ if ("6".equals(tipoDoc) && (numDoc == null || numDoc.length() != 11)) {
+ messages.add(ValidationMessage.error(
+ "Factura: si tipoDocumentoIdentidad='6' (RUC), el número debe tener 11 dígitos"));
+ }
+ }
+
+ if (isBoleta) {
+ // Boleta: para montos > 700 PEN se requiere documento del receptor
+ // (validación del monto se hace en el enricher/post-enricher)
+ if (isBlank(tipoDoc) && isBlank(numDoc)) {
+ messages.add(ValidationMessage.warning(
+ "Boleta: se recomienda consignar documento de identidad del receptor"));
+ }
+ }
+ }
+
+ // ── Validaciones de detracción ──────────────────────────────
+
+ private static void validateDetraccion(Detraccion detraccion, String tipoOp,
+ List messages) {
+ if (tipoOp != null && !tipoOp.startsWith("10")) {
+ messages.add(ValidationMessage.error(
+ "Detracción requiere tipoOperacion 1001-1004 (Catálogo 51), valor: " + tipoOp));
+ }
+ if (isBlank(detraccion.getMedioDePago())) {
+ messages.add(ValidationMessage.error("Detracción: medioDePago es requerido (Catálogo 59)"));
+ }
+ if (isBlank(detraccion.getCuentaBancaria())) {
+ messages.add(ValidationMessage.error("Detracción: cuentaBancaria es requerida"));
+ }
+ if (isBlank(detraccion.getTipoBienDetraido())) {
+ messages.add(ValidationMessage.error("Detracción: tipoBienDetraido es requerido (Catálogo 54)"));
+ }
+ if (detraccion.getPorcentaje() == null || detraccion.getPorcentaje().compareTo(BigDecimal.ZERO) <= 0) {
+ messages.add(ValidationMessage.error("Detracción: porcentaje debe ser > 0"));
+ }
+ }
+
+ // ── Validaciones de percepción ──────────────────────────────
+
+ private static void validatePercepcion(Percepcion percepcion, String tipoOp,
+ List messages) {
+ if (!"2001".equals(tipoOp)) {
+ messages.add(ValidationMessage.error(
+ "Percepción requiere tipoOperacion='2001' (Catálogo 51), valor: " + tipoOp));
+ }
+ if (isBlank(percepcion.getTipo())) {
+ messages.add(ValidationMessage.error("Percepción: tipo es requerido (Catálogo 53)"));
+ }
+ }
+
+ // ── Exportación ─────────────────────────────────────────────
+
+ private static void validateExportacionItems(List detalles,
+ List messages) {
+ if (detalles == null)
+ return;
+ for (int i = 0; i < detalles.size(); i++) {
+ DocumentoVentaDetalle d = detalles.get(i);
+ if (isBlank(d.getCodigoProductoSunat())) {
+ messages.add(ValidationMessage.warning(
+ "Línea " + (i + 1) + ": código producto SUNAT (UNSPSC) recomendado para exportación"));
+ }
+ }
+ }
+
+ private static boolean isBlank(String value) {
+ return value == null || value.isBlank();
+ }
+}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/Note.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/Note.java
index 817e0f03..d6e57007 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/Note.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/Note.java
@@ -7,9 +7,18 @@
import lombok.experimental.SuperBuilder;
/**
- * Clase base para CreditNote y DebitNOte.
+ * Clase base abstracta para Nota de Crédito ({@link CreditNote}) y Nota de Débito ({@link DebitNote}).
+ *
+ * Ambas notas comparten la misma estructura: referencia al comprobante afectado, motivo de emisión y sustento
+ * descriptivo. Las diferencias normativas son:
+ *
+ * - Nota de Crédito: Catálogo 09 para {@code tipoNota}. Anula total o parcialmente una factura/boleta.
+ * - Nota de Débito: Catálogo 10 para {@code tipoNota}. Incrementa el importe del documento afectado.
+ *
+ *
*
- * @author Carlos Feria
+ * @see io.github.project.openubl.xbuilder.content.catalogs.Catalog9
+ * @see io.github.project.openubl.xbuilder.content.catalogs.Catalog10
*/
@Data
@SuperBuilder
@@ -28,8 +37,7 @@ public abstract class Note extends SalesDocument {
private String tipoNota;
/**
- * Serie y número del comprobante al que le aplica la nota de crédito/débito.
- * Ejemplo: F001-1
+ * Serie y número del comprobante al que le aplica la nota de crédito/débito. Ejemplo: F001-1
*/
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String comprobanteAfectadoSerieNumero;
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/NoteValidator.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/NoteValidator.java
new file mode 100644
index 00000000..8e19e2bc
--- /dev/null
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/NoteValidator.java
@@ -0,0 +1,106 @@
+package io.github.project.openubl.xbuilder.content.models.standard.general;
+
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog;
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog9;
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog10;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.validation.ValidationMessage;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.validation.ValidationResult;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Validador de reglas de negocio para Notas de Crédito y Notas de Débito.
+ *
+ * Implementa validaciones previas al renderizado según:
+ *
+ * - Guía XML Nota de Crédito 2.1 – SUNAT
+ * - Guía XML Nota de Débito 2.1 – SUNAT
+ * - Reglas de Validación CPE (versión vigente)
+ *
+ *
+ * @since 5.3.0
+ * @see CreditNote
+ * @see DebitNote
+ */
+public final class NoteValidator {
+
+ private NoteValidator() {
+ }
+
+ /**
+ * Valida una CreditNote y retorna solo los mensajes de error.
+ */
+ public static List validate(CreditNote note) {
+ return validateCreditNoteDetailed(note).getErrors();
+ }
+
+ /**
+ * Valida una DebitNote y retorna solo los mensajes de error.
+ */
+ public static List validate(DebitNote note) {
+ return validateDebitNoteDetailed(note).getErrors();
+ }
+
+ /**
+ * Valida una CreditNote con resultado detallado (errores + advertencias).
+ */
+ public static ValidationResult validateCreditNoteDetailed(CreditNote note) {
+ List messages = new ArrayList<>();
+ validateCommon(note, messages);
+
+ // tipoNota debe ser del Catálogo 09
+ if (note.getTipoNota() != null && Catalog.valueOfCode(Catalog9.class, note.getTipoNota()).isEmpty()) {
+ messages.add(ValidationMessage.error(
+ "tipoNota '" + note.getTipoNota() + "' no encontrado en Catálogo 09 (Nota de Crédito)"));
+ }
+
+ return new ValidationResult(messages);
+ }
+
+ /**
+ * Valida una DebitNote con resultado detallado (errores + advertencias).
+ */
+ public static ValidationResult validateDebitNoteDetailed(DebitNote note) {
+ List messages = new ArrayList<>();
+ validateCommon(note, messages);
+
+ // tipoNota debe ser del Catálogo 10
+ if (note.getTipoNota() != null && Catalog.valueOfCode(Catalog10.class, note.getTipoNota()).isEmpty()) {
+ messages.add(ValidationMessage.error(
+ "tipoNota '" + note.getTipoNota() + "' no encontrado en Catálogo 10 (Nota de Débito)"));
+ }
+
+ return new ValidationResult(messages);
+ }
+
+ private static void validateCommon(Note note, List messages) {
+ SalesDocumentValidator.validateBasicFields(note, messages);
+
+ if (isBlank(note.getComprobanteAfectadoSerieNumero())) {
+ messages.add(ValidationMessage.error(
+ "comprobanteAfectadoSerieNumero es requerido (serie-numero del documento afectado)"));
+ }
+
+ if (isBlank(note.getComprobanteAfectadoTipo())) {
+ messages.add(ValidationMessage.error(
+ "comprobanteAfectadoTipo es requerido (Catálogo 01: tipo del documento afectado)"));
+ }
+
+ if (isBlank(note.getTipoNota())) {
+ messages.add(ValidationMessage.error(
+ "tipoNota es requerido (Catálogo 09 para NC, Catálogo 10 para ND)"));
+ }
+
+ if (isBlank(note.getSustentoDescripcion())) {
+ messages.add(ValidationMessage.error(
+ "sustentoDescripcion es requerido (motivo de la nota)"));
+ }
+
+ SalesDocumentValidator.validateDetalles(note.getDetalles(), messages);
+ }
+
+ private static boolean isBlank(String value) {
+ return value == null || value.isBlank();
+ }
+}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/Percepcion.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/Percepcion.java
index 0790e28a..f023a188 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/Percepcion.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/Percepcion.java
@@ -8,6 +8,23 @@
import java.math.BigDecimal;
+/**
+ * Percepción asociada a una factura electrónica.
+ *
+ * Obligatoria cuando {@code tipoOperacion} = "2001" (Catálogo 51). El {@code tipo} corresponde al Catálogo 53 (códigos
+ * de percepción: "51", "52", "53").
+ *
+ *
+ * Campos auto-calculados por el enricher:
+ *
+ * - {@code montoBase} = importeSinImpuestos del documento
+ * - {@code monto} = montoBase × porcentaje
+ * - {@code montoTotal} = montoBase + monto
+ *
+ *
+ *
+ * @see io.github.project.openubl.xbuilder.content.catalogs.Catalog53
+ */
@Data
@Builder
@NoArgsConstructor
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/SalesDocument.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/SalesDocument.java
index 3d2d6581..ed53f81a 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/SalesDocument.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/SalesDocument.java
@@ -2,6 +2,7 @@
import io.github.project.openubl.xbuilder.content.models.common.Cliente;
import io.github.project.openubl.xbuilder.content.models.common.Document;
+import io.github.project.openubl.xbuilder.content.models.common.TipoCambio;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -100,6 +101,13 @@ public abstract class SalesDocument extends Document {
@ArraySchema
private List documentosRelacionados;
+ /**
+ * Tipo de cambio aplicable cuando la moneda es distinta a PEN. Obligatorio si {@code moneda} ≠ "PEN" (Regla de
+ * validación SUNAT). Fuente: Guía XML Factura 2.1 – "PaymentExchangeRate".
+ */
+ @Schema(description = "Tipo de cambio cuando moneda ≠ PEN")
+ private TipoCambio tipoCambio;
+
/**
* Cargos globales del documento
*/
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/SalesDocumentValidator.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/SalesDocumentValidator.java
new file mode 100644
index 00000000..7e96b64d
--- /dev/null
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/general/SalesDocumentValidator.java
@@ -0,0 +1,84 @@
+package io.github.project.openubl.xbuilder.content.models.standard.general;
+
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog;
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog7;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.validation.ValidationMessage;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * Validaciones compartidas entre Invoice, CreditNote y DebitNote.
+ *
+ * Valida campos comunes de {@link SalesDocument} y sus líneas de detalle.
+ *
+ *
+ * @since 5.3.0
+ */
+public final class SalesDocumentValidator {
+
+ private SalesDocumentValidator() {
+ }
+
+ /**
+ * Valida campos básicos del documento de venta: serie, número, proveedor.
+ */
+ static void validateBasicFields(SalesDocument doc, List messages) {
+ if (doc.getSerie() == null || doc.getSerie().isBlank()) {
+ messages.add(ValidationMessage.error("La serie es requerida"));
+ } else if (doc.getSerie().length() != 4) {
+ messages.add(ValidationMessage.error(
+ "La serie debe tener 4 caracteres, valor: '" + doc.getSerie() + "'"));
+ }
+
+ if (doc.getNumero() == null) {
+ messages.add(ValidationMessage.error("El número es requerido"));
+ } else if (doc.getNumero() < 1 || doc.getNumero() > 99999999) {
+ messages.add(ValidationMessage.error(
+ "El número debe estar entre 1 y 99999999, valor: " + doc.getNumero()));
+ }
+
+ if (doc.getProveedor() == null) {
+ messages.add(ValidationMessage.error("El proveedor (emisor) es requerido"));
+ } else {
+ if (doc.getProveedor().getRuc() == null || doc.getProveedor().getRuc().length() != 11) {
+ messages.add(ValidationMessage.error("El RUC del proveedor debe tener 11 dígitos"));
+ }
+ if (doc.getProveedor().getRazonSocial() == null || doc.getProveedor().getRazonSocial().isBlank()) {
+ messages.add(ValidationMessage.error("La razón social del proveedor es requerida"));
+ }
+ }
+ }
+
+ /**
+ * Valida las líneas de detalle del documento.
+ */
+ static void validateDetalles(List detalles, List messages) {
+ if (detalles == null || detalles.isEmpty()) {
+ messages.add(ValidationMessage.error("El documento debe tener al menos una línea de detalle"));
+ return;
+ }
+
+ for (int i = 0; i < detalles.size(); i++) {
+ DocumentoVentaDetalle d = detalles.get(i);
+ String prefix = "Línea " + (i + 1) + ": ";
+
+ if (d.getDescripcion() == null || d.getDescripcion().isBlank()) {
+ messages.add(ValidationMessage.error(prefix + "descripción es requerida"));
+ }
+ if (d.getCantidad() == null || d.getCantidad().compareTo(BigDecimal.ZERO) <= 0) {
+ messages.add(ValidationMessage.error(prefix + "cantidad debe ser > 0"));
+ }
+ if (d.getPrecio() == null && !d.isPrecioConImpuestos()) {
+ messages.add(ValidationMessage.error(prefix + "precio es requerido"));
+ }
+
+ // Validar igvTipo si se especifica
+ String igvTipo = d.getIgvTipo();
+ if (igvTipo != null && Catalog.valueOfCode(Catalog7.class, igvTipo).isEmpty()) {
+ messages.add(ValidationMessage.warning(
+ prefix + "igvTipo '" + igvTipo + "' no encontrado en Catálogo 07"));
+ }
+ }
+ }
+}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Contenedor.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Contenedor.java
new file mode 100644
index 00000000..730ffd63
--- /dev/null
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Contenedor.java
@@ -0,0 +1,44 @@
+package io.github.project.openubl.xbuilder.content.models.standard.guia;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Modelo para contenedor y precinto de transporte.
+ *
+ * Según el Anexo N.° 14 UBL 2.1, cada contenedor se representa en
+ * {@code cac:TransportHandlingUnit / cac:Package} con su número de contenedor
+ * (cbc:ID) y opcionalmente su número de precinto (cbc:TraceID).
+ *
+ * RS 000240-2024/SUNAT agrega campos adicionales para comercio exterior
+ * (mercancía extranjera y zona primaria).
+ *
+ * @since 2.0
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class Contenedor {
+
+ /**
+ * Número del contenedor.
+ *
+ * Requerido cuando el motivo de traslado involucra comercio exterior
+ * (importación, exportación, mercancía extranjera).
+ * Para motivos domésticos es opcional.
+ */
+ @Schema(description = "Número del contenedor", requiredMode = Schema.RequiredMode.REQUIRED)
+ private String numero;
+
+ /**
+ * Número de precinto del contenedor.
+ *
+ * Opcional. Se consigna en {@code cbc:TraceID}.
+ */
+ @Schema(description = "Número de precinto del contenedor")
+ private String precinto;
+}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/DeclaracionAduanera.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/DeclaracionAduanera.java
new file mode 100644
index 00000000..98cfc19b
--- /dev/null
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/DeclaracionAduanera.java
@@ -0,0 +1,50 @@
+package io.github.project.openubl.xbuilder.content.models.standard.guia;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Modelo para referencia a Declaración Aduanera de Mercancías (DAM)
+ * o Declaración Simplificada (DS).
+ *
+ * Según RS 000240-2024/SUNAT, cuando el motivo de traslado es Importación (10),
+ * Exportación (09), o Traslado de Mercancía Extranjera (19), se debe consignar
+ * la información de la DAM o DS.
+ *
+ * Se mapea a un {@code cac:AdditionalDocumentReference} con el código de
+ * catálogo 61
+ * correspondiente (50=DAM, 52=DS).
+ *
+ * @since 2.0
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class DeclaracionAduanera {
+
+ /**
+ * Tipo de declaración: "DAM" o "DS".
+ * Se usa para determinar el código del Catálogo 61:
+ * - DAM = "50"
+ * - DS = "52"
+ */
+ @Schema(description = "Tipo: DAM o DS", requiredMode = Schema.RequiredMode.REQUIRED)
+ private String tipo;
+
+ /**
+ * Número de la declaración aduanera.
+ * Formato típico: 118-2024-10-XXXXXX
+ */
+ @Schema(description = "Número de la DAM/DS", requiredMode = Schema.RequiredMode.REQUIRED)
+ private String numero;
+
+ /**
+ * RUC de la aduana o agente que emitió la declaración (opcional).
+ */
+ @Schema(description = "RUC del emisor de la declaración")
+ private String rucEmisor;
+}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/DespatchAdvice.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/DespatchAdvice.java
index 9337c08a..66da1a43 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/DespatchAdvice.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/DespatchAdvice.java
@@ -9,13 +9,32 @@
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.Singular;
-import lombok.extern.jackson.Jacksonized;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
-@Jacksonized
+/**
+ * Modelo principal de la Guía de Remisión Electrónica (GRE).
+ *
+ * Soporta ambos tipos:
+ *
+ * - GRE-Remitente (09): Serie Txxx. Emitida por el remitente de los
+ * bienes.
+ * El campo {@code remitente} contiene los datos del remitente (RUC + razón
+ * social).
+ * El {@code transportista} se consigna dentro de {@code envio} si modalidad es
+ * pública.
+ * - GRE-Transportista (31): Serie Vxxx. Emitida por el transportista.
+ * El campo {@code remitente} contiene los datos del transportista emitente.
+ * El {@code tercero} contiene los datos del remitente original (quien envía los
+ * bienes).
+ *
+ *
+ * Fuente normativa: RS 000123-2022/SUNAT (base), RS 000240-2024/SUNAT (comercio
+ * exterior),
+ * RS 000133-2025/SUNAT (prórroga hasta 01-jul-2026).
+ */
@Data
@Builder
@NoArgsConstructor
@@ -27,7 +46,11 @@ public class DespatchAdvice {
private String version;
/**
- * Serie del comprobante
+ * Serie del comprobante.
+ *
+ * - GRE-Remitente: Txxx (alfanumérico) desde SEE del contribuyente
+ * - GRE-Transportista: Vxxx (alfanumérico) desde SEE del contribuyente
+ *
*/
@Schema(requiredMode = Schema.RequiredMode.REQUIRED, minLength = 4, pattern = "^[T|t|V|v].*$")
private String serie;
@@ -50,6 +73,13 @@ public class DespatchAdvice {
@Schema(description = "Format: \"HH:MM:SS\". Ejemplo 12:00:00", pattern = "^\\d{2}:\\d{2}:\\d{2}$")
private LocalTime horaEmision;
+ /**
+ * Tipo de comprobante según Catálogo 01:
+ *
+ * - "09" = Guía de Remisión Remitente
+ * - "31" = Guía de Remisión Transportista
+ *
+ */
@Schema(description = "Catalogo 01")
private String tipoComprobante;
@@ -61,6 +91,18 @@ public class DespatchAdvice {
@Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private DocumentoRelacionado documentoRelacionado;
+ /**
+ * Documentos relacionados adicionales.
+ * Permite vincular múltiples documentos como GRE-Remitente, DAM, DS, etc.
+ * Ver también {@link Envio#getDeclaracionesAduaneras()} para referencias DAM/DS
+ * específicas de comercio exterior.
+ *
+ * @since 2.0 - Permite múltiples documentos relacionados (antes solo uno).
+ */
+ @Singular("documentoRelacionadoAdicional")
+ @Schema(description = "Documentos relacionados adicionales (Catálogo 21)")
+ private List documentosRelacionados;
+
/**
* Documentos adicionales relacionados al transporte (Catálogo 61)
*/
@@ -71,6 +113,14 @@ public class DespatchAdvice {
@Schema(description = "Persona que firma electrónicamente el comprobante. Si NULL los datos del proveedor son usados.")
private Firmante firmante;
+ /**
+ * Datos del remitente.
+ *
+ * - GRE-Remitente (09): El remitente es quien envía los bienes.
+ * - GRE-Transportista (31): El remitente es el transportista emisor
+ * (DespatchSupplierParty).
+ *
+ */
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private Remitente remitente;
@@ -81,13 +131,18 @@ public class DespatchAdvice {
private Proveedor proveedor;
/**
- * Datos del tercero (vendedor de los bienes cuando aplica)
+ * Datos del tercero (vendedor/remitente original de los bienes).
+ *
+ * Aplica principalmente en GRE-Transportista (31): el tercero es el remitente
+ * original que solicita el servicio de transporte.
+ * Se mapea a {@code cac:SellerSupplierParty}.
*/
@Schema(description = "Tercero/Vendedor de los bienes")
private Tercero tercero;
/**
- * Datos del comprador (adquiriente de los bienes)
+ * Datos del comprador (adquiriente de los bienes).
+ * Se mapea a {@code cac:BuyerCustomerParty}.
*/
@Schema(description = "Comprador/Adquiriente de los bienes")
private Comprador comprador;
@@ -98,4 +153,20 @@ public class DespatchAdvice {
@Singular
@ArraySchema(minItems = 1, schema = @Schema(requiredMode = Schema.RequiredMode.REQUIRED))
private List detalles;
+
+ // == Métodos utilitarios ==
+
+ /**
+ * Determina si esta GRE es de tipo Remitente (código 09).
+ */
+ public boolean isGRERemitente() {
+ return "09".equals(tipoComprobante);
+ }
+
+ /**
+ * Determina si esta GRE es de tipo Transportista (código 31).
+ */
+ public boolean isGRETransportista() {
+ return "31".equals(tipoComprobante);
+ }
}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/DespatchAdviceItem.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/DespatchAdviceItem.java
index 6184d717..dd973c79 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/DespatchAdviceItem.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/DespatchAdviceItem.java
@@ -5,24 +5,44 @@
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.Singular;
-import lombok.extern.jackson.Jacksonized;
import java.math.BigDecimal;
import java.util.List;
-@Jacksonized
+/**
+ * Línea de detalle de la Guía de Remisión Electrónica.
+ *
+ * Cada ítem representa un bien trasladado con su cantidad, unidad de medida, descripción y código. Toda GRE requiere al
+ * menos un ítem (requerimiento UBL).
+ *
+ * FAQ #24 SUNAT: cuando se usa el indicador de traslado total DAM/DS, la línea puede contener solo los campos mínimos
+ * obligatorios.
+ *
+ * @since 2.0
+ * @see DespatchAdvice
+ */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DespatchAdviceItem {
+
+ /** Unidad de medida según código UN/ECE Rec. 20 (ejemplo: "NIU", "KGM"). */
private String unidadMedida;
+
+ /** Cantidad del bien trasladado. */
private BigDecimal cantidad;
+ /** Descripción del bien. */
private String descripcion;
+
+ /** Código interno del bien asignado por el emisor. */
private String codigo;
+
+ /** Código SUNAT del bien (Catálogo según corresponda). */
private String codigoSunat;
+ /** Atributos adicionales del ítem (pares código-valor). */
@Singular
private List atributos;
}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/DespatchAdviceValidator.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/DespatchAdviceValidator.java
new file mode 100644
index 00000000..fc27f93e
--- /dev/null
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/DespatchAdviceValidator.java
@@ -0,0 +1,103 @@
+package io.github.project.openubl.xbuilder.content.models.standard.guia;
+
+import io.github.project.openubl.xbuilder.content.models.standard.guia.validation.DespatchAdviceCommonValidator;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.validation.ValidationMessage;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.validation.ValidationResult;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Validador de reglas de negocio para la Guía de Remisión Electrónica (GRE).
+ *
+ * Implementa las reglas funcionales de SUNAT según:
+ *
+ * - RS 000123-2022/SUNAT — Reglas base GRE-Remitente y GRE-Transportista
+ * - RS 000240-2024/SUNAT — Reglas de comercio exterior (vigentes desde 14-nov-2024)
+ * - RS 000133-2025/SUNAT — Prórroga: derogatoria del ticket de salida al 01-jul-2026
+ *
+ *
+ * Delega la lógica de validación a {@link DespatchAdviceCommonValidator} para evitar duplicación de reglas con
+ * {@link GRERemitente} y {@link GRETransportista}.
+ *
+ * Las validaciones retornan una lista de mensajes de error. Si la lista está vacía, el documento es válido respecto a
+ * las reglas implementadas.
+ *
+ * IMPORTANTE: Este validador NO reemplaza la validación de SUNAT (XSD/XSL). Es una capa de validación temprana
+ * para detectar errores comunes antes del envío.
+ *
+ * @since 5.0.0
+ * @see DespatchAdviceCommonValidator
+ * @see ValidationResult
+ */
+public class DespatchAdviceValidator {
+
+ /**
+ * Valida un DespatchAdvice completo y retorna la lista de errores encontrados.
+ *
+ * Nota: Solo retorna errores (severidad {@code ERROR}). Para obtener errores y advertencias con severidad
+ * diferenciada, use {@link #validateDetailed(DespatchAdvice)}.
+ *
+ * @param da el DespatchAdvice a validar
+ * @return lista de mensajes de error (vacía si es válido)
+ */
+ public static List validate(DespatchAdvice da) {
+ return validateDetailed(da).getErrors();
+ }
+
+ /**
+ * Valida un DespatchAdvice completo y retorna un {@link ValidationResult} con errores y advertencias diferenciados
+ * por severidad.
+ *
+ * Criterio de severidad:
+ *
+ * - {@code ERROR} — Regla UBL o funcional SUNAT que causa rechazo
+ * - {@code WARNING} — Recomendación normativa (discrecionalidad vigente)
+ *
+ *
+ * @param da el DespatchAdvice a validar
+ * @return resultado de validación con errores y advertencias
+ * @since 5.2.0
+ */
+ public static ValidationResult validateDetailed(DespatchAdvice da) {
+ List messages = new ArrayList<>();
+
+ // Campos básicos
+ DespatchAdviceCommonValidator.validateBasicFields(da.getSerie(), da.getNumero(), messages);
+
+ // Coherencia serie ↔ tipo comprobante
+ DespatchAdviceCommonValidator.validateSerieCoherence(
+ da.getSerie(), da.getTipoComprobante(), messages);
+
+ // Partes
+ DespatchAdviceCommonValidator.validateRemitente(da.getRemitente(), messages);
+ DespatchAdviceCommonValidator.validateDestinatario(da.getDestinatario(), messages);
+
+ // Tercero obligatorio para GRE-Transportista (31)
+ if ("31".equals(da.getTipoComprobante()) && da.getTercero() == null && da.getProveedor() == null) {
+ messages.add(ValidationMessage.error(
+ "GRE-Transportista (31): el tercero (remitente original) o proveedor "
+ + "en SellerSupplierParty es requerido"));
+ }
+
+ // Envío
+ DespatchAdviceCommonValidator.validateEnvioRequired(da.getEnvio(), messages);
+ DespatchAdviceCommonValidator.validatePartidaDestino(da.getEnvio(), messages);
+
+ // Modalidad de transporte
+ DespatchAdviceCommonValidator.validateModalidadGeneric(
+ da.getTipoComprobante(), da.getEnvio(), messages);
+
+ // Comercio exterior (advertencias)
+ DespatchAdviceCommonValidator.validateComercioExterior(da.getEnvio(), messages);
+
+ // Detalles
+ DespatchAdviceCommonValidator.validateDetalles(da.getDetalles(), messages);
+
+ return new ValidationResult(messages);
+ }
+
+ private DespatchAdviceValidator() {
+ // Utility class
+ }
+}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Destinatario.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Destinatario.java
index 31bba93d..c2258865 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Destinatario.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Destinatario.java
@@ -6,18 +6,32 @@
import lombok.Data;
import lombok.NoArgsConstructor;
+/**
+ * Datos del destinatario de los bienes en la Guía de Remisión Electrónica.
+ *
+ * Se mapea a {@code cac:DeliveryCustomerParty} en el XML UBL. El destinatario es obligatorio en toda GRE.
+ *
+ * FAQ #22 SUNAT: para emisor itinerante, el destinatario puede ser el mismo remitente, pero el campo sigue siendo
+ * obligatorio en el XML.
+ *
+ * @since 2.0
+ * @see DespatchAdvice#getDestinatario()
+ */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Destinatario {
+ /** Tipo de documento de identidad del destinatario (Catálogo 06). */
@Schema(description = "Catalogo 06", requiredMode = Schema.RequiredMode.REQUIRED)
private String tipoDocumentoIdentidad;
+ /** Número de documento de identidad del destinatario. */
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String numeroDocumentoIdentidad;
+ /** Razón social o nombre del destinatario. */
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String nombre;
}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Destino.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Destino.java
index 1093c9d0..b77edeb1 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Destino.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Destino.java
@@ -6,15 +6,26 @@
import lombok.Data;
import lombok.NoArgsConstructor;
+/**
+ * Punto de destino/llegada del traslado en la Guía de Remisión Electrónica.
+ *
+ * Se mapea a {@code cac:Shipment/cac:Delivery/cac:DeliveryAddress} en el XML UBL. El UBIGEO y la dirección son
+ * obligatorios.
+ *
+ * @since 2.0
+ * @see Envio#getDestino()
+ */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Destino {
+ /** Código UBIGEO INEI del punto de destino (6 dígitos). */
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String ubigeo;
+ /** Dirección completa del punto de destino. */
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String direccion;
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/DocumentoBaja.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/DocumentoBaja.java
index f0a7e6a9..19c636e0 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/DocumentoBaja.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/DocumentoBaja.java
@@ -6,15 +6,25 @@
import lombok.Data;
import lombok.NoArgsConstructor;
+/**
+ * Referencia al documento dado de baja que justifica la emisión de una nueva Guía de Remisión Electrónica.
+ *
+ * Se mapea a {@code cac:OrderReference} en el XML UBL. Aplica cuando la GRE actual reemplaza a una GRE previamente
+ * anulada.
+ *
+ * @since 2.0
+ */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DocumentoBaja {
+ /** Tipo de documento del comprobante dado de baja (Catálogo 01). */
@Schema(description = "Catalog 01", requiredMode = Schema.RequiredMode.REQUIRED)
private String tipoDocumento;
+ /** Serie-número del comprobante dado de baja (ejemplo: "T001-123"). */
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String serieNumero;
}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/DocumentoRelacionado.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/DocumentoRelacionado.java
index 47b86140..6f032142 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/DocumentoRelacionado.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/DocumentoRelacionado.java
@@ -6,15 +6,27 @@
import lombok.Data;
import lombok.NoArgsConstructor;
+/**
+ * Referencia a un documento relacionado con la Guía de Remisión Electrónica.
+ *
+ * Se mapea a {@code cac:AdditionalDocumentReference} en el XML UBL. Permite vincular la GRE con facturas, guías
+ * previas, u otros documentos tributarios (Catálogo 21).
+ *
+ * @since 2.0
+ * @see DespatchAdvice#getDocumentoRelacionado()
+ * @see DespatchAdvice#getDocumentosRelacionados()
+ */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DocumentoRelacionado {
+ /** Tipo de documento relacionado (Catálogo 21). */
@Schema(description = "Catalog 21", requiredMode = Schema.RequiredMode.REQUIRED)
private String tipoDocumento;
+ /** Serie-número del documento relacionado (ejemplo: "F001-456"). */
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String serieNumero;
}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Envio.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Envio.java
index 5dcf868b..b34c6117 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Envio.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Envio.java
@@ -11,6 +11,11 @@
import java.time.LocalDate;
import java.util.List;
+/**
+ * Modelo de envío/shipment de la Guía de Remisión Electrónica.
+ *
+ * Fuente: Anexo N.° 14 UBL 2.1, RS 000123-2022/SUNAT, RS 000240-2024/SUNAT.
+ */
@Data
@Builder
@NoArgsConstructor
@@ -29,13 +34,17 @@ public class Envio {
private String pesoTotalUnidadMedida;
/**
- * Peso de los ítems seleccionados (en KGM)
+ * Peso de los ítems seleccionados (en KGM).
+ * Requerido cuando el peso bruto total difiere del peso de los ítems
+ * (traslado parcial de DAM con carga a granel, FAQ #30 SUNAT).
*/
@Schema(description = "Peso bruto de los items seleccionados")
private BigDecimal pesoItems;
/**
- * Sustento de la diferencia del peso bruto total respecto al peso de los ítems
+ * Sustento de la diferencia del peso bruto total respecto al peso de los ítems.
+ * Requerido cuando {@code pesoItems} difiere de {@code pesoTotal}.
+ * Ejemplo: "Retiro parcial de carga a granel", "Carga fraccionada" (FAQ #33).
*/
@Schema(description = "Sustento de diferencia de peso")
private String sustentoPeso;
@@ -49,20 +58,25 @@ public class Envio {
private LocalDate fechaTraslado;
/**
- * Lista de contenedores/precintos
+ * Lista de contenedores con número y precinto.
+ *
+ * Cada contenedor puede incluir número de identificación y precinto.
+ * Aplica especialmente para comercio exterior (RS 000240-2024/SUNAT).
*/
@Singular("contenedor")
- @Schema(description = "Lista de contenedores o precintos")
- private List contenedores;
+ @Schema(description = "Lista de contenedores con número y precinto")
+ private List contenedores;
/**
- * Puerto de embarque/desembarque
+ * Puerto de embarque/desembarque.
+ * Catálogo 63 de SUNAT.
*/
@Schema(description = "Puerto de embarque/desembarque (Catalogo 63)")
private Puerto puerto;
/**
- * Aeropuerto de embarque/desembarque
+ * Aeropuerto de embarque/desembarque.
+ * Catálogo 64 de SUNAT.
*/
@Schema(description = "Aeropuerto de embarque/desembarque (Catalogo 64)")
private Puerto aeropuerto;
@@ -71,20 +85,46 @@ public class Envio {
private Transportista transportista;
/**
- * Lista de conductores (principal y secundarios)
+ * Lista de conductores (principal y secundarios).
+ *
+ * GRE-Remitente con transporte privado: requerido (al menos conductor
+ * principal).
+ * GRE-Remitente con transporte público: no aplica (lo provee el transportista).
+ * GRE-Transportista: requerido siempre.
*/
@Singular("chofer")
@Schema(description = "Lista de conductores")
private List choferes;
/**
- * Vehículo principal con posibles vehículos secundarios
+ * Vehículo principal con posibles vehículos secundarios.
+ *
+ * GRE-Remitente con transporte privado: requerido.
+ * GRE-Remitente con transporte público: no aplica.
+ * GRE-Transportista: requerido siempre.
*/
@Schema(description = "Vehículo de transporte")
private Vehicle vehiculo;
/**
- * Indicadores especiales de transporte (SUNAT_Envio_*)
+ * Indicadores especiales de transporte.
+ *
+ * Se mapean a {@code cbc:SpecialInstructions} en el XML.
+ * Ver
+ * {@link io.github.project.openubl.xbuilder.content.catalogs.IndicadorEnvio}
+ * para la lista de valores tipados. Se acepta String libre para extensibilidad.
+ *
+ * Indicadores comunes:
+ *
+ * - SUNAT_Envio_IndicadorTrasladoTotalDAMDS: traslado total de DAM/DS
+ * - SUNAT_Envio_IndicadorBienNormalizado: bien sujeto a SPOT/IVAP
+ * - SUNAT_Envio_IndicadorTrasladoVehiculoM1L: vehículos categoría M1/L (exime
+ * conductor/vehículo)
+ * - SUNAT_Envio_IndicadorTransbordoProgramado: transbordo programado por
+ * eventos
+ * - SUNAT_Envio_IndicadorRetornoVehiculoEnvasesVacios
+ * - SUNAT_Envio_IndicadorRetornoVehiculoVacio
+ *
*/
@Singular("indicador")
@Schema(description = "Indicadores especiales de transporte")
@@ -95,4 +135,32 @@ public class Envio {
@Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private Destino destino;
+
+ // == Campos de comercio exterior (RS 000240-2024/SUNAT) ==
+
+ /**
+ * Número de manifiesto de carga.
+ *
+ * Relevante para motivos de traslado de comercio exterior (08, 09, 10, 19).
+ * Se consigna como documento relacionado (Catálogo 21, código 04).
+ *
+ * @since 2.0
+ */
+ @Schema(description = "Número de manifiesto de carga")
+ private String numeroManifiesto;
+
+ /**
+ * Referencias a Declaraciones Aduaneras de Mercancías (DAM) o
+ * Declaraciones Simplificadas (DS).
+ *
+ * Requerido para motivos 08 (Importación), 09 (Exportación),
+ * 10 (Importación con DAM) y 19 (Mercancía extranjera).
+ *
+ * Se mapean a {@code cac:AdditionalDocumentReference} con Catálogo 61.
+ *
+ * @since 2.0
+ */
+ @Singular("declaracionAduanera")
+ @Schema(description = "Declaraciones aduaneras (DAM/DS)")
+ private List declaracionesAduaneras;
}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/GRERemitente.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/GRERemitente.java
new file mode 100644
index 00000000..cfb4bcc1
--- /dev/null
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/GRERemitente.java
@@ -0,0 +1,209 @@
+package io.github.project.openubl.xbuilder.content.models.standard.guia;
+
+import io.github.project.openubl.xbuilder.content.models.common.Firmante;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.validation.DespatchAdviceCommonValidator;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.validation.ValidationMessage;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.validation.ValidationResult;
+import lombok.Builder;
+import lombok.Data;
+import lombok.Singular;
+
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Modelo para Guía de Remisión Electrónica - Remitente (tipo 09).
+ *
+ * Emitida por el remitente de los bienes. Serie TXXX.
+ *
+ * Reglas según RS 000123-2022/SUNAT:
+ *
+ * - Serie debe iniciar con 'T'
+ * - Transporte privado (02): requiere conductor y vehículo
+ * - Transporte público (01): requiere datos del transportista
+ * - El remitente es quien envía los bienes (DespatchSupplierParty)
+ *
+ *
+ * Uso:
+ *
+ *
{@code
+ * GRERemitente gre = GRERemitente.builder()
+ * .serie("T001")
+ * .numero(1)
+ * .remitente(Remitente.builder().ruc("20100010001").razonSocial("Mi Empresa").build())
+ * .destinatario(Destinatario.builder()
+ * .tipoDocumentoIdentidad("6")
+ * .numeroDocumentoIdentidad("20200020002")
+ * .nombre("Cliente")
+ * .build())
+ * .envio(Envio.builder()
+ * .tipoTraslado("01")
+ * .pesoTotal(BigDecimal.ONE)
+ * .pesoTotalUnidadMedida("KGM")
+ * .tipoModalidadTraslado("02")
+ * .fechaTraslado(LocalDate.now())
+ * .chofer(Driver.builder()
+ * .tipoDocumentoIdentidad("1")
+ * .numeroDocumentoIdentidad("12345678")
+ * .nombres("Juan")
+ * .apellidos("Perez")
+ * .licencia("Q123")
+ * .build())
+ * .vehiculo(Vehicle.builder().placa("ABC-123").build())
+ * .partida(Partida.builder().ubigeo("150101").direccion("Origen").build())
+ * .destino(Destino.builder().ubigeo("150102").direccion("Destino").build())
+ * .build())
+ * .detalle(DespatchAdviceItem.builder().cantidad(BigDecimal.ONE).unidadMedida("NIU").codigo("001").build())
+ * .build();
+ *
+ * DespatchAdvice da = gre.toDespatchAdvice();
+ * }
+ *
+ * @see GRETransportista
+ * @see DespatchAdvice
+ */
+@Data
+@Builder
+public class GRERemitente {
+
+ // == Datos del comprobante ==
+
+ private String serie;
+ private Integer numero;
+ private String version;
+ private LocalDate fechaEmision;
+ private LocalTime horaEmision;
+ private String observaciones;
+
+ // == Partes ==
+
+ private Remitente remitente;
+ private Destinatario destinatario;
+ private Comprador comprador;
+ private Firmante firmante;
+
+ // == Documentos relacionados ==
+
+ private DocumentoBaja documentoBaja;
+ private DocumentoRelacionado documentoRelacionado;
+
+ @Singular("documentoRelacionadoAdicional")
+ private List documentosRelacionados;
+
+ @Singular("documentoAdicional")
+ private List documentosAdicionales;
+
+ // == Datos de envío ==
+
+ private Envio envio;
+
+ // == Detalle de bienes ==
+
+ @Singular
+ private List detalles;
+
+ /**
+ * Valida las reglas de negocio para GRE-Remitente y retorna los errores.
+ *
+ * Delega las validaciones comunes a {@link DespatchAdviceCommonValidator} y aplica solo las reglas específicas del
+ * tipo 09 (serie T*, modalidad remitente).
+ *
+ * Solo retorna errores (severidad {@code ERROR}). Para obtener errores y advertencias diferenciados, use
+ * {@link #validateDetailed()}.
+ *
+ * @return lista de errores (vacía si es válido)
+ * @see #validateDetailed()
+ */
+ public List validate() {
+ return validateDetailed().getErrors();
+ }
+
+ /**
+ * Valida las reglas de negocio y retorna un {@link ValidationResult} con errores y advertencias diferenciados por
+ * severidad.
+ *
+ * @return resultado de validación con errores y advertencias
+ * @since 5.2.0
+ */
+ public ValidationResult validateDetailed() {
+ List messages = new ArrayList<>();
+
+ // Campos básicos (serie, número)
+ DespatchAdviceCommonValidator.validateBasicFields(serie, numero, messages);
+
+ // Serie específica remitente: debe iniciar con 'T'
+ DespatchAdviceCommonValidator.validateSerieRemitente(serie, messages);
+
+ // Partes
+ DespatchAdviceCommonValidator.validateRemitente(remitente, messages);
+ DespatchAdviceCommonValidator.validateDestinatario(destinatario, messages);
+
+ // Envío
+ DespatchAdviceCommonValidator.validateEnvioRequired(envio, messages);
+ DespatchAdviceCommonValidator.validatePartidaDestino(envio, messages);
+
+ // Modalidad específica remitente (privado/público)
+ DespatchAdviceCommonValidator.validateModalidadRemitente(envio, messages);
+
+ // Comercio exterior (advertencias)
+ DespatchAdviceCommonValidator.validateComercioExterior(envio, messages);
+
+ // Detalles
+ DespatchAdviceCommonValidator.validateDetalles(detalles, messages);
+
+ return new ValidationResult(messages);
+ }
+
+ /**
+ * Convierte este modelo a {@link DespatchAdvice} para renderizado XML. El tipo de comprobante se fija a "09"
+ * (GRE-Remitente).
+ *
+ * @return DespatchAdvice listo para enriquecer y renderizar
+ */
+ public DespatchAdvice toDespatchAdvice() {
+ DespatchAdvice.DespatchAdviceBuilder builder = DespatchAdvice.builder()
+ .serie(serie)
+ .numero(numero)
+ .version(version)
+ .fechaEmision(fechaEmision)
+ .horaEmision(horaEmision)
+ .tipoComprobante("09")
+ .observaciones(observaciones)
+ .remitente(remitente)
+ .destinatario(destinatario)
+ .comprador(comprador)
+ .firmante(firmante)
+ .documentoBaja(documentoBaja)
+ .documentoRelacionado(documentoRelacionado)
+ .envio(envio);
+
+ if (documentosRelacionados != null) {
+ documentosRelacionados.forEach(builder::documentoRelacionadoAdicional);
+ }
+ if (documentosAdicionales != null) {
+ documentosAdicionales.forEach(builder::documentoAdicional);
+ }
+ if (detalles != null) {
+ detalles.forEach(builder::detalle);
+ }
+
+ return builder.build();
+ }
+
+ /**
+ * Valida y convierte a DespatchAdvice.
+ *
+ * @return DespatchAdvice validado
+ * @throws IllegalStateException si hay errores de validación
+ */
+ public DespatchAdvice toDespatchAdviceValidated() {
+ List errors = validate();
+ if (!errors.isEmpty()) {
+ throw new IllegalStateException(
+ "GRE-Remitente inválido:\n- " + String.join("\n- ", errors));
+ }
+ return toDespatchAdvice();
+ }
+}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/GRETransportista.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/GRETransportista.java
new file mode 100644
index 00000000..a5002f4b
--- /dev/null
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/GRETransportista.java
@@ -0,0 +1,286 @@
+package io.github.project.openubl.xbuilder.content.models.standard.guia;
+
+import io.github.project.openubl.xbuilder.content.models.common.Firmante;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.validation.DespatchAdviceCommonValidator;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.validation.ValidationMessage;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.validation.ValidationResult;
+import lombok.Builder;
+import lombok.Data;
+import lombok.Singular;
+
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Modelo para Guía de Remisión Electrónica - Transportista (tipo 31).
+ *
+ * Emitida por el transportista que presta el servicio. Serie VXXX.
+ *
+ * Reglas según RS 000123-2022/SUNAT:
+ *
+ * - Serie debe iniciar con 'V'
+ * - Siempre requiere al menos un conductor
+ * - Siempre requiere vehículo
+ * - El remitente/emisor del XML es el transportista (DespatchSupplierParty)
+ * - El tercero (SellerSupplierParty) es el remitente original de los bienes
+ *
+ *
+ * Uso:
+ *
+ *
{@code
+ * GRETransportista gre = GRETransportista.builder()
+ * .serie("V001")
+ * .numero(1)
+ * .transportistaEmisor(Transportista.builder()
+ * .tipoDocumentoIdentidad("6")
+ * .numeroDocumentoIdentidad("20300030003")
+ * .nombre("Transportes S.A.C.")
+ * .numeroRegistroMTC("MTC-123")
+ * .build())
+ * .remitente(Tercero.builder()
+ * .tipoDocumentoIdentidad("6")
+ * .numeroDocumentoIdentidad("20100010001")
+ * .nombre("Empresa Remitente S.A.C.")
+ * .build())
+ * .destinatario(Destinatario.builder()
+ * .tipoDocumentoIdentidad("6")
+ * .numeroDocumentoIdentidad("20200020002")
+ * .nombre("Cliente")
+ * .build())
+ * .conductor(Driver.builder()
+ * .tipoDocumentoIdentidad("1")
+ * .numeroDocumentoIdentidad("12345678")
+ * .nombres("Juan")
+ * .apellidos("Perez")
+ * .licencia("Q123")
+ * .build())
+ * .vehiculo(Vehicle.builder().placa("ABC-123").build())
+ * .envio(Envio.builder()
+ * .tipoTraslado("01")
+ * .pesoTotal(BigDecimal.ONE)
+ * .pesoTotalUnidadMedida("KGM")
+ * .tipoModalidadTraslado("01")
+ * .fechaTraslado(LocalDate.now())
+ * .partida(Partida.builder().ubigeo("150101").direccion("Origen").build())
+ * .destino(Destino.builder().ubigeo("150102").direccion("Destino").build())
+ * .build())
+ * .detalle(DespatchAdviceItem.builder().cantidad(BigDecimal.ONE).unidadMedida("NIU").codigo("001").build())
+ * .build();
+ *
+ * DespatchAdvice da = gre.toDespatchAdvice();
+ * }
+ *
+ * @see GRERemitente
+ * @see DespatchAdvice
+ */
+@Data
+@Builder
+public class GRETransportista {
+
+ // == Datos del comprobante ==
+
+ private String serie;
+ private Integer numero;
+ private String version;
+ private LocalDate fechaEmision;
+ private LocalTime horaEmision;
+ private String observaciones;
+
+ // == Partes ==
+
+ /**
+ * El transportista que emite la guía. Se convierte a {@link Remitente} para mapear a DespatchSupplierParty en el
+ * XML.
+ *
+ * Se usa {@link Transportista} en lugar de {@link Remitente} porque semánticamente SUNAT distingue remitente y
+ * transportista como sujetos diferentes.
+ */
+ private Transportista transportistaEmisor;
+
+ /**
+ * El remitente original de los bienes (quien contrata el transporte). Se mapea a SellerSupplierParty (tercero) en
+ * el XML.
+ */
+ private Tercero remitente;
+
+ /**
+ * El destinatario de los bienes.
+ */
+ private Destinatario destinatario;
+
+ private Comprador comprador;
+ private Firmante firmante;
+
+ // == Conductor(es) y vehículo - siempre requeridos para transportista ==
+
+ /**
+ * Conductor(es) del transporte. Al menos uno es obligatorio. El primero es el conductor principal.
+ */
+ @Singular("conductor")
+ private List conductores;
+
+ /**
+ * Vehículo principal del transporte. Obligatorio.
+ */
+ private Vehicle vehiculo;
+
+ // == Documentos relacionados ==
+
+ private DocumentoBaja documentoBaja;
+
+ @Singular("documentoRelacionadoAdicional")
+ private List documentosRelacionados;
+
+ @Singular("documentoAdicional")
+ private List documentosAdicionales;
+
+ // == Datos de envío ==
+
+ /**
+ * Datos de envío. El conductor y vehículo se inyectan automáticamente desde los campos {@code conductores} y
+ * {@code vehiculo} de este modelo.
+ */
+ private Envio envio;
+
+ // == Detalle de bienes ==
+
+ @Singular
+ private List detalles;
+
+ /**
+ * Valida las reglas de negocio para GRE-Transportista y retorna los errores.
+ *
+ * Delega las validaciones comunes a {@link DespatchAdviceCommonValidator} y aplica solo las reglas específicas del
+ * tipo 31 (serie V*, transportista emisor, conductor/vehículo siempre obligatorios, tercero obligatorio).
+ *
+ * Solo retorna errores (severidad {@code ERROR}). Para obtener errores y advertencias diferenciados, use
+ * {@link #validateDetailed()}.
+ *
+ * @return lista de errores (vacía si es válido)
+ * @see #validateDetailed()
+ */
+ public List validate() {
+ return validateDetailed().getErrors();
+ }
+
+ /**
+ * Valida las reglas de negocio y retorna un {@link ValidationResult} con errores y advertencias diferenciados por
+ * severidad.
+ *
+ * @return resultado de validación con errores y advertencias
+ * @since 5.2.0
+ */
+ public ValidationResult validateDetailed() {
+ List messages = new ArrayList<>();
+
+ // Campos básicos (serie, número)
+ DespatchAdviceCommonValidator.validateBasicFields(serie, numero, messages);
+
+ // Serie específica transportista: debe iniciar con 'V'
+ DespatchAdviceCommonValidator.validateSerieTransportista(serie, messages);
+
+ // Transportista emisor (RUC 11 dígitos)
+ DespatchAdviceCommonValidator.validateTransportistaEmisor(transportistaEmisor, messages);
+
+ // Remitente original (tercero) — obligatorio para tipo 31
+ DespatchAdviceCommonValidator.validateTerceroTransportista(remitente, messages);
+
+ // Destinatario
+ DespatchAdviceCommonValidator.validateDestinatario(destinatario, messages);
+
+ // Conductor y vehículo — siempre obligatorios para transportista
+ DespatchAdviceCommonValidator.validateConductorVehiculoTransportista(conductores, vehiculo, messages);
+
+ // Envío
+ DespatchAdviceCommonValidator.validateEnvioRequired(envio, messages);
+ DespatchAdviceCommonValidator.validatePartidaDestino(envio, messages);
+
+ // Comercio exterior (advertencias)
+ DespatchAdviceCommonValidator.validateComercioExterior(envio, messages);
+
+ // Detalles
+ DespatchAdviceCommonValidator.validateDetalles(detalles, messages);
+
+ return new ValidationResult(messages);
+ }
+
+ /**
+ * Convierte este modelo a {@link DespatchAdvice} para renderizado XML. El tipo de comprobante se fija a "31"
+ * (GRE-Transportista). Los conductores y vehículo se inyectan en el envío automáticamente.
+ *
+ * @return DespatchAdvice listo para enriquecer y renderizar
+ */
+ public DespatchAdvice toDespatchAdvice() {
+ // Build envio with conductores and vehiculo injected
+ Envio envioConTransporte = envio != null ? Envio.builder()
+ .tipoTraslado(envio.getTipoTraslado())
+ .motivoTraslado(envio.getMotivoTraslado())
+ .pesoTotal(envio.getPesoTotal())
+ .pesoTotalUnidadMedida(envio.getPesoTotalUnidadMedida())
+ .pesoItems(envio.getPesoItems())
+ .sustentoPeso(envio.getSustentoPeso())
+ .numeroDeBultos(envio.getNumeroDeBultos())
+ .tipoModalidadTraslado(envio.getTipoModalidadTraslado())
+ .fechaTraslado(envio.getFechaTraslado())
+ .contenedores(envio.getContenedores())
+ .puerto(envio.getPuerto())
+ .aeropuerto(envio.getAeropuerto())
+ .choferes(conductores)
+ .vehiculo(vehiculo)
+ .indicadores(envio.getIndicadores())
+ .partida(envio.getPartida())
+ .destino(envio.getDestino())
+ .numeroManifiesto(envio.getNumeroManifiesto())
+ .declaracionesAduaneras(envio.getDeclaracionesAduaneras())
+ .build() : null;
+
+ DespatchAdvice.DespatchAdviceBuilder builder = DespatchAdvice.builder()
+ .serie(serie)
+ .numero(numero)
+ .version(version)
+ .fechaEmision(fechaEmision)
+ .horaEmision(horaEmision)
+ .tipoComprobante("31")
+ .observaciones(observaciones)
+ .remitente(transportistaEmisor != null ? Remitente.builder()
+ .ruc(transportistaEmisor.getNumeroDocumentoIdentidad())
+ .razonSocial(transportistaEmisor.getNombre())
+ .numeroRegistroMTC(transportistaEmisor.getNumeroRegistroMTC())
+ .build() : null)
+ .destinatario(destinatario)
+ .comprador(comprador)
+ .tercero(remitente)
+ .firmante(firmante)
+ .documentoBaja(documentoBaja)
+ .envio(envioConTransporte);
+
+ if (documentosRelacionados != null) {
+ documentosRelacionados.forEach(builder::documentoRelacionadoAdicional);
+ }
+ if (documentosAdicionales != null) {
+ documentosAdicionales.forEach(builder::documentoAdicional);
+ }
+ if (detalles != null) {
+ detalles.forEach(builder::detalle);
+ }
+
+ return builder.build();
+ }
+
+ /**
+ * Valida y convierte a DespatchAdvice.
+ *
+ * @return DespatchAdvice validado
+ * @throws IllegalStateException si hay errores de validación
+ */
+ public DespatchAdvice toDespatchAdviceValidated() {
+ List errors = validate();
+ if (!errors.isEmpty()) {
+ throw new IllegalStateException(
+ "GRE-Transportista inválido:\n- " + String.join("\n- ", errors));
+ }
+ return toDespatchAdvice();
+ }
+}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/GuiaItemAttribute.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/GuiaItemAttribute.java
index d107dbee..e08f3fa9 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/GuiaItemAttribute.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/GuiaItemAttribute.java
@@ -4,15 +4,30 @@
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
-import lombok.extern.jackson.Jacksonized;
-@Jacksonized
+/**
+ * Atributo adicional de un ítem de la Guía de Remisión Electrónica.
+ *
+ * Permite agregar pares clave-valor con información complementaria sobre el bien trasladado (por ejemplo, lote, fecha
+ * de vencimiento, número de serie, etc.).
+ *
+ * Se mapea a {@code cac:DespatchLine/cac:Item/cac:AdditionalItemProperty}.
+ *
+ * @since 2.0
+ * @see DespatchAdviceItem#getAtributos()
+ */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GuiaItemAttribute {
+
+ /** Código del atributo (identificador del tipo de propiedad). */
private String code;
+
+ /** Nombre descriptivo del atributo. */
private String name;
+
+ /** Valor del atributo. */
private String value;
}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Partida.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Partida.java
index fbff322b..8b717012 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Partida.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Partida.java
@@ -6,15 +6,26 @@
import lombok.Data;
import lombok.NoArgsConstructor;
+/**
+ * Punto de partida del traslado en la Guía de Remisión Electrónica.
+ *
+ * Se mapea a {@code cac:Shipment/cac:Delivery/cac:Despatch/cac:DespatchAddress} en el XML UBL. El UBIGEO y la dirección
+ * son obligatorios.
+ *
+ * @since 2.0
+ * @see Envio#getPartida()
+ */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Partida {
+ /** Código UBIGEO INEI del punto de partida (6 dígitos). */
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String ubigeo;
+ /** Dirección completa del punto de partida. */
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String direccion;
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Remitente.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Remitente.java
index dd2fc4c5..24c7389b 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Remitente.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Remitente.java
@@ -6,18 +6,31 @@
import lombok.Data;
import lombok.NoArgsConstructor;
+/**
+ * Datos del remitente de los bienes en la Guía de Remisión Electrónica.
+ *
+ * En una GRE-Remitente (09), el remitente es quien envía los bienes (se mapea a {@code cac:DespatchSupplierParty}). En
+ * una GRE-Transportista (31), este campo contiene los datos del transportista emitente (el mapeo se hace vía
+ * {@link GRETransportista#toDespatchAdvice()}).
+ *
+ * @since 2.0
+ * @see DespatchAdvice#getRemitente()
+ */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Remitente {
+ /** RUC del remitente (11 dígitos, obligatorio). */
@Schema(requiredMode = Schema.RequiredMode.REQUIRED, minLength = 11, maxLength = 11, pattern = "[0-9]+")
private String ruc;
+ /** Razón social del remitente. */
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String razonSocial;
+ /** Número de registro del Ministerio de Transportes y Comunicaciones (opcional). */
@Schema(description = "Número de registro del Ministerio de Transportes y Comunicaciones")
private String numeroRegistroMTC;
}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Transportista.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Transportista.java
index 4413f90a..d7f53476 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Transportista.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/Transportista.java
@@ -6,18 +6,36 @@
import lombok.Data;
import lombok.NoArgsConstructor;
+/**
+ * Datos del transportista en la Guía de Remisión Electrónica.
+ *
+ * Se usa en dos contextos:
+ *
+ * - GRE-Remitente con transporte público (01): el transportista contratado se consigna en
+ * {@link Envio#getTransportista()}.
+ * - GRE-Transportista: el transportista emisor se mapea desde {@link GRETransportista#getTransportistaEmisor()} hacia
+ * {@code cac:DespatchSupplierParty}.
+ *
+ *
+ * @since 2.0
+ * @see Envio#getTransportista()
+ * @see GRETransportista#getTransportistaEmisor()
+ */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Transportista {
+ /** Tipo de documento de identidad (Catálogo 06). */
@Schema(description = "Catalogo 06", requiredMode = Schema.RequiredMode.REQUIRED)
private String tipoDocumentoIdentidad;
+ /** Número de documento de identidad (RUC para empresas). */
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String numeroDocumentoIdentidad;
+ /** Razón social o nombre del transportista. */
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String nombre;
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/validation/DespatchAdviceCommonValidator.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/validation/DespatchAdviceCommonValidator.java
new file mode 100644
index 00000000..f22b94d4
--- /dev/null
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/validation/DespatchAdviceCommonValidator.java
@@ -0,0 +1,438 @@
+package io.github.project.openubl.xbuilder.content.models.standard.guia.validation;
+
+import io.github.project.openubl.xbuilder.content.models.standard.guia.*;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Reglas de validación compartidas para la Guía de Remisión Electrónica (GRE).
+ *
+ * Centraliza las validaciones comunes entre {@code DespatchAdviceValidator},
+ * {@code GRERemitente.validate()} y {@code GRETransportista.validate()}, eliminando
+ * la duplicación de reglas y garantizando coherencia funcional.
+ *
+ * Criterio de severidad:
+ *
+ * - {@link ValidationSeverity#ERROR} — Regla de UBL o funcional SUNAT cuyo incumplimiento
+ * causa rechazo del documento. Siempre bloquea la emisión.
+ * - {@link ValidationSeverity#WARNING} — Recomendación normativa cuya obligatoriedad está
+ * diferida (RS 000133-2025/SUNAT), es discrecional, o depende del contexto del emisor.
+ *
+ *
+ * Fuente normativa: RS 000123-2022/SUNAT, RS 000240-2024/SUNAT, RS 000133-2025/SUNAT.
+ *
+ * @since 5.2.0
+ * @see io.github.project.openubl.xbuilder.content.models.standard.guia.DespatchAdviceValidator
+ * @see io.github.project.openubl.xbuilder.content.models.standard.guia.GRERemitente
+ * @see io.github.project.openubl.xbuilder.content.models.standard.guia.GRETransportista
+ */
+public final class DespatchAdviceCommonValidator {
+
+ /**
+ * Motivos de traslado de comercio exterior (RS 000240-2024/SUNAT).
+ */
+ static final Set MOTIVOS_COMERCIO_EXTERIOR = new HashSet<>(Arrays.asList(
+ "08", // Importación
+ "09", // Exportación
+ "10", // Importación con DAM
+ "19" // Mercancía extranjera
+ ));
+
+ /**
+ * Motivos que requieren puerto o aeropuerto.
+ */
+ static final Set MOTIVOS_CON_PUERTO = new HashSet<>(Arrays.asList(
+ "08", "09", "10", "19"
+ ));
+
+ private DespatchAdviceCommonValidator() {
+ // Utility class
+ }
+
+ // ========================================================================
+ // Reglas comunes — usadas por los tres validadores
+ // ========================================================================
+
+ /**
+ * Valida campos básicos del comprobante: serie y número.
+ *
+ * Regla UBL: serie y número son obligatorios para identificar el documento.
+ *
+ * @param serie serie del comprobante
+ * @param numero número del comprobante
+ * @param messages lista donde se agregan los mensajes
+ */
+ public static void validateBasicFields(String serie, Integer numero,
+ List messages) {
+ if (serie == null || serie.isBlank()) {
+ messages.add(ValidationMessage.error("La serie es requerida"));
+ }
+ if (numero == null || numero < 1) {
+ messages.add(ValidationMessage.error("El número debe ser mayor a 0"));
+ }
+ }
+
+ /**
+ * Valida que la serie sea coherente con el tipo de comprobante (09 → T*, 31 → V*).
+ *
+ * Regla SUNAT FAQ #7.
+ *
+ * @param serie serie del comprobante
+ * @param tipoComprobante tipo de comprobante ("09" o "31")
+ * @param messages lista donde se agregan los mensajes
+ */
+ public static void validateSerieCoherence(String serie, String tipoComprobante,
+ List messages) {
+ if (serie == null || tipoComprobante == null) {
+ return;
+ }
+ String serieUpper = serie.toUpperCase();
+ if ("09".equals(tipoComprobante) && !serieUpper.startsWith("T")) {
+ messages.add(ValidationMessage.error(
+ "GRE-Remitente (09) requiere serie que inicie con 'T'. Serie actual: " + serie));
+ }
+ if ("31".equals(tipoComprobante) && !serieUpper.startsWith("V")) {
+ messages.add(ValidationMessage.error(
+ "GRE-Transportista (31) requiere serie que inicie con 'V'. Serie actual: " + serie));
+ }
+ }
+
+ /**
+ * Valida que la serie del GRE-Remitente inicie con 'T'.
+ *
+ * @param serie serie del comprobante
+ * @param messages lista donde se agregan los mensajes
+ */
+ public static void validateSerieRemitente(String serie, List messages) {
+ if (serie != null && !serie.isBlank() && !serie.toUpperCase().startsWith("T")) {
+ messages.add(ValidationMessage.error(
+ "GRE-Remitente requiere serie que inicie con 'T'. Serie actual: " + serie));
+ }
+ }
+
+ /**
+ * Valida que la serie del GRE-Transportista inicie con 'V'.
+ *
+ * @param serie serie del comprobante
+ * @param messages lista donde se agregan los mensajes
+ */
+ public static void validateSerieTransportista(String serie, List messages) {
+ if (serie != null && !serie.isBlank() && !serie.toUpperCase().startsWith("V")) {
+ messages.add(ValidationMessage.error(
+ "GRE-Transportista requiere serie que inicie con 'V'. Serie actual: " + serie));
+ }
+ }
+
+ /**
+ * Valida el RUC del remitente (debe tener 11 dígitos).
+ *
+ * Regla SUNAT: el RUC es obligatorio y de longitud 11.
+ *
+ * @param remitente datos del remitente
+ * @param messages lista donde se agregan los mensajes
+ */
+ public static void validateRemitente(Remitente remitente, List messages) {
+ if (remitente == null) {
+ messages.add(ValidationMessage.error("El remitente es requerido"));
+ return;
+ }
+ if (remitente.getRuc() == null || remitente.getRuc().length() != 11) {
+ messages.add(ValidationMessage.error("El RUC del remitente debe tener 11 dígitos"));
+ }
+ }
+
+ /**
+ * Valida el RUC del transportista emisor (debe tener 11 dígitos).
+ *
+ * @param transportista datos del transportista emisor
+ * @param messages lista donde se agregan los mensajes
+ */
+ public static void validateTransportistaEmisor(Transportista transportista,
+ List messages) {
+ if (transportista == null) {
+ messages.add(ValidationMessage.error("El transportista emisor es requerido"));
+ return;
+ }
+ if (transportista.getNumeroDocumentoIdentidad() == null
+ || transportista.getNumeroDocumentoIdentidad().length() != 11) {
+ messages.add(ValidationMessage.error(
+ "El RUC del transportista emisor debe tener 11 dígitos"));
+ }
+ }
+
+ /**
+ * Valida que el destinatario esté presente.
+ *
+ * @param destinatario datos del destinatario
+ * @param messages lista donde se agregan los mensajes
+ */
+ public static void validateDestinatario(Destinatario destinatario,
+ List messages) {
+ if (destinatario == null) {
+ messages.add(ValidationMessage.error("El destinatario es requerido"));
+ }
+ }
+
+ /**
+ * Valida que el tercero (remitente original) esté presente en GRE-Transportista.
+ *
+ * Para GRE-Transportista (31), el tercero identifica al remitente de los bienes.
+ * Es un campo requerido por SUNAT en SellerSupplierParty.
+ *
+ * @param tercero datos del tercero
+ * @param messages lista donde se agregan los mensajes
+ */
+ public static void validateTerceroTransportista(Tercero tercero,
+ List messages) {
+ if (tercero == null) {
+ messages.add(ValidationMessage.error(
+ "El remitente original (tercero) es requerido para GRE-Transportista"));
+ }
+ }
+
+ /**
+ * Valida los campos obligatorios del envío (shipment).
+ *
+ * Campos obligatorios según UBL/SUNAT:
+ *
+ * - Motivo de traslado (Catálogo 20)
+ * - Peso total
+ * - Modalidad de traslado (Catálogo 18)
+ * - Fecha de traslado
+ *
+ *
+ * @param envio datos de envío
+ * @param messages lista donde se agregan los mensajes
+ */
+ public static void validateEnvioRequired(Envio envio, List messages) {
+ if (envio == null) {
+ messages.add(ValidationMessage.error("Los datos de envío son requeridos"));
+ return;
+ }
+ if (envio.getTipoTraslado() == null || envio.getTipoTraslado().isBlank()) {
+ messages.add(ValidationMessage.error(
+ "El motivo de traslado (Catálogo 20) es requerido"));
+ }
+ if (envio.getPesoTotal() == null) {
+ messages.add(ValidationMessage.error("El peso total es requerido"));
+ }
+ if (envio.getTipoModalidadTraslado() == null) {
+ messages.add(ValidationMessage.error(
+ "La modalidad de traslado (Catálogo 18) es requerida"));
+ }
+ if (envio.getFechaTraslado() == null) {
+ messages.add(ValidationMessage.error("La fecha de traslado es requerida"));
+ }
+ }
+
+ /**
+ * Valida punto de partida y destino del envío.
+ *
+ * Ambos son obligatorios según el esquema UBL de la GRE.
+ *
+ * @param envio datos de envío
+ * @param messages lista donde se agregan los mensajes
+ */
+ public static void validatePartidaDestino(Envio envio, List messages) {
+ if (envio == null) {
+ return;
+ }
+ if (envio.getPartida() == null) {
+ messages.add(ValidationMessage.error("El punto de partida es requerido"));
+ }
+ if (envio.getDestino() == null) {
+ messages.add(ValidationMessage.error("El punto de destino es requerido"));
+ }
+ }
+
+ /**
+ * Valida las reglas de modalidad de transporte para GRE-Remitente (tipo 09).
+ *
+ *
+ * - Transporte privado (02): requiere conductor y vehículo, salvo que se
+ * indique categoría M1/L. No debe consignar transportista externo.
+ * - Transporte público (01): requiere datos del transportista.
+ *
+ *
+ * @param envio datos de envío
+ * @param messages lista donde se agregan los mensajes
+ */
+ public static void validateModalidadRemitente(Envio envio,
+ List messages) {
+ if (envio == null || envio.getTipoModalidadTraslado() == null) {
+ return;
+ }
+ String modalidad = envio.getTipoModalidadTraslado();
+
+ boolean tieneIndicadorM1L = envio.getIndicadores() != null
+ && envio.getIndicadores().contains("SUNAT_Envio_IndicadorTrasladoVehiculoM1L");
+
+ if ("02".equals(modalidad)) {
+ // Transporte privado
+ if (!tieneIndicadorM1L) {
+ if (envio.getChoferes() == null || envio.getChoferes().isEmpty()) {
+ messages.add(ValidationMessage.error(
+ "Transporte privado requiere al menos un conductor "
+ + "(salvo vehículo categoría M1/L)"));
+ }
+ if (envio.getVehiculo() == null) {
+ messages.add(ValidationMessage.error(
+ "Transporte privado requiere datos del vehículo "
+ + "(salvo vehículo categoría M1/L)"));
+ }
+ }
+ if (envio.getTransportista() != null) {
+ messages.add(ValidationMessage.error(
+ "Transporte privado no debe consignar transportista externo "
+ + "(usar modalidad pública si subcontrata)"));
+ }
+ } else if ("01".equals(modalidad)) {
+ // Transporte público
+ if (envio.getTransportista() == null) {
+ messages.add(ValidationMessage.error(
+ "Transporte público requiere datos del transportista"));
+ }
+ }
+ }
+
+ /**
+ * Valida las reglas de modalidad de transporte para GRE-Transportista (tipo 31).
+ *
+ * El transportista siempre requiere al menos un conductor y vehículo,
+ * independientemente de la modalidad de traslado.
+ *
+ * @param conductores lista de conductores
+ * @param vehiculo vehículo principal
+ * @param messages lista donde se agregan los mensajes
+ */
+ public static void validateConductorVehiculoTransportista(List conductores,
+ Vehicle vehiculo,
+ List messages) {
+ if (conductores == null || conductores.isEmpty()) {
+ messages.add(ValidationMessage.error(
+ "GRE-Transportista requiere al menos un conductor"));
+ }
+ if (vehiculo == null) {
+ messages.add(ValidationMessage.error(
+ "GRE-Transportista requiere datos del vehículo"));
+ }
+ }
+
+ /**
+ * Valida las reglas de modalidad para un DespatchAdvice genérico (post-conversión).
+ *
+ * Se usa cuando se valida el modelo después de la conversión desde GRERemitente
+ * o GRETransportista. Aplica reglas según el tipo de comprobante detectado.
+ *
+ * @param tipoComprobante tipo de comprobante ("09" o "31")
+ * @param envio datos de envío
+ * @param messages lista donde se agregan los mensajes
+ */
+ public static void validateModalidadGeneric(String tipoComprobante, Envio envio,
+ List messages) {
+ if (envio == null || envio.getTipoModalidadTraslado() == null) {
+ return;
+ }
+ String modalidad = envio.getTipoModalidadTraslado();
+ boolean isRemitente = "09".equals(tipoComprobante);
+
+ if ("02".equals(modalidad) && isRemitente) {
+ boolean tieneM1L = envio.getIndicadores() != null
+ && envio.getIndicadores().contains("SUNAT_Envio_IndicadorTrasladoVehiculoM1L");
+ if (!tieneM1L) {
+ if (envio.getChoferes() == null || envio.getChoferes().isEmpty()) {
+ messages.add(ValidationMessage.error(
+ "Transporte privado en GRE-Remitente requiere al menos un conductor "
+ + "(salvo vehículo categoría M1/L)"));
+ }
+ if (envio.getVehiculo() == null) {
+ messages.add(ValidationMessage.error(
+ "Transporte privado en GRE-Remitente requiere datos del vehículo "
+ + "(salvo vehículo categoría M1/L)"));
+ }
+ }
+ }
+
+ if ("01".equals(modalidad) && isRemitente) {
+ if (envio.getTransportista() == null) {
+ messages.add(ValidationMessage.error(
+ "Transporte público en GRE-Remitente requiere datos del transportista"));
+ }
+ }
+
+ if ("31".equals(tipoComprobante)) {
+ if (envio.getChoferes() == null || envio.getChoferes().isEmpty()) {
+ messages.add(ValidationMessage.error(
+ "GRE-Transportista requiere al menos un conductor"));
+ }
+ if (envio.getVehiculo() == null) {
+ messages.add(ValidationMessage.error(
+ "GRE-Transportista requiere datos del vehículo"));
+ }
+ }
+ }
+
+ /**
+ * Valida reglas de comercio exterior (RS 000240-2024/SUNAT).
+ *
+ * Cuando el motivo de traslado es de comercio exterior (08, 09, 10, 19):
+ *
+ * - WARNING: Se recomienda incluir referencia DAM/DS
+ * - WARNING: Se recomienda incluir puerto o aeropuerto
+ *
+ *
+ * NOTA: La obligatoriedad plena del motivo 19 y la derogación del ticket de
+ * salida fue pospuesta al 01-jul-2026 por RS 000133-2025/SUNAT.
+ * Hasta esa fecha se aplica discrecionalidad.
+ *
+ * @param envio datos de envío
+ * @param messages lista donde se agregan los mensajes
+ */
+ public static void validateComercioExterior(Envio envio,
+ List messages) {
+ if (envio == null) {
+ return;
+ }
+ String motivo = envio.getTipoTraslado();
+ if (motivo == null || !MOTIVOS_COMERCIO_EXTERIOR.contains(motivo)) {
+ return;
+ }
+
+ boolean tieneDAM = envio.getDeclaracionesAduaneras() != null
+ && !envio.getDeclaracionesAduaneras().isEmpty();
+ if (!tieneDAM) {
+ messages.add(ValidationMessage.warning(
+ "Comercio exterior: se recomienda incluir referencia a DAM/DS "
+ + "(puede no ser obligatorio según FAQ #32; discrecionalidad vigente "
+ + "hasta 01-jul-2026 por RS 000133-2025/SUNAT)"));
+ }
+
+ if (MOTIVOS_CON_PUERTO.contains(motivo)
+ && envio.getPuerto() == null && envio.getAeropuerto() == null) {
+ messages.add(ValidationMessage.warning(
+ "Comercio exterior: se recomienda incluir puerto o aeropuerto "
+ + "para motivos de traslado 08, 09, 10 o 19"));
+ }
+ }
+
+ /**
+ * Valida que haya al menos una línea de detalle.
+ *
+ * Requerimiento UBL: toda GRE debe tener al menos un ítem.
+ * FAQ #24: con indicador de traslado total DAM/DS la línea puede tener solo
+ * campos mínimos, pero debe existir.
+ *
+ * @param detalles lista de ítems
+ * @param messages lista donde se agregan los mensajes
+ */
+ public static void validateDetalles(List detalles,
+ List messages) {
+ if (detalles == null || detalles.isEmpty()) {
+ messages.add(ValidationMessage.error(
+ "Se requiere al menos una línea de detalle (requerimiento UBL)"));
+ }
+ }
+}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/validation/ValidationMessage.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/validation/ValidationMessage.java
new file mode 100644
index 00000000..715341ce
--- /dev/null
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/validation/ValidationMessage.java
@@ -0,0 +1,80 @@
+package io.github.project.openubl.xbuilder.content.models.standard.guia.validation;
+
+/**
+ * Mensaje individual de validación con severidad asociada.
+ *
+ * Cada instancia representa una regla evaluada que no se cumplió.
+ *
+ * @since 5.2.0
+ * @see ValidationSeverity
+ * @see ValidationResult
+ */
+public class ValidationMessage {
+
+ private final ValidationSeverity severity;
+ private final String message;
+
+ /**
+ * Crea un mensaje de validación.
+ *
+ * @param severity severidad del mensaje ({@link ValidationSeverity#ERROR} o {@link ValidationSeverity#WARNING})
+ * @param message texto descriptivo del problema detectado
+ */
+ public ValidationMessage(ValidationSeverity severity, String message) {
+ this.severity = severity;
+ this.message = message;
+ }
+
+ /**
+ * Crea un mensaje de error ({@link ValidationSeverity#ERROR}).
+ *
+ * @param message texto descriptivo
+ * @return nueva instancia con severidad ERROR
+ */
+ public static ValidationMessage error(String message) {
+ return new ValidationMessage(ValidationSeverity.ERROR, message);
+ }
+
+ /**
+ * Crea un mensaje de advertencia ({@link ValidationSeverity#WARNING}).
+ *
+ * @param message texto descriptivo
+ * @return nueva instancia con severidad WARNING
+ */
+ public static ValidationMessage warning(String message) {
+ return new ValidationMessage(ValidationSeverity.WARNING, message);
+ }
+
+ /**
+ * @return la severidad de este mensaje
+ */
+ public ValidationSeverity getSeverity() {
+ return severity;
+ }
+
+ /**
+ * @return el texto descriptivo del problema
+ */
+ public String getMessage() {
+ return message;
+ }
+
+ /**
+ * @return {@code true} si la severidad es {@link ValidationSeverity#ERROR}
+ */
+ public boolean isError() {
+ return severity == ValidationSeverity.ERROR;
+ }
+
+ /**
+ * @return {@code true} si la severidad es {@link ValidationSeverity#WARNING}
+ */
+ public boolean isWarning() {
+ return severity == ValidationSeverity.WARNING;
+ }
+
+ @Override
+ public String toString() {
+ return "[" + severity + "] " + message;
+ }
+}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/validation/ValidationResult.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/validation/ValidationResult.java
new file mode 100644
index 00000000..969236fe
--- /dev/null
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/validation/ValidationResult.java
@@ -0,0 +1,99 @@
+package io.github.project.openubl.xbuilder.content.models.standard.guia.validation;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * Resultado agregado de una validación de la GRE.
+ *
+ * Contiene la lista completa de {@link ValidationMessage} y ofrece métodos de conveniencia para consultar errores y
+ * advertencias por separado.
+ *
+ *
{@code
+ * ValidationResult result = DespatchAdviceValidator.validateDetailed(da);
+ * if (result.hasErrors()) {
+ * result.getErrors().forEach(System.err::println);
+ * }
+ * result.getWarnings().forEach(log::warn);
+ * }
+ *
+ * @since 5.2.0
+ * @see DespatchAdviceCommonValidator
+ */
+public class ValidationResult {
+
+ private final List messages;
+
+ /**
+ * Crea un resultado con la lista de mensajes proporcionada.
+ *
+ * @param messages lista de mensajes de validación
+ */
+ public ValidationResult(List messages) {
+ this.messages = messages != null ? Collections.unmodifiableList(new ArrayList<>(messages))
+ : Collections.emptyList();
+ }
+
+ /**
+ * @return lista completa e inmutable de todos los mensajes (errores + advertencias)
+ */
+ public List getMessages() {
+ return messages;
+ }
+
+ /**
+ * @return solo los textos de los mensajes con severidad {@link ValidationSeverity#ERROR}
+ */
+ public List getErrors() {
+ return messages.stream()
+ .filter(ValidationMessage::isError)
+ .map(ValidationMessage::getMessage)
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * @return solo los textos de los mensajes con severidad {@link ValidationSeverity#WARNING}
+ */
+ public List getWarnings() {
+ return messages.stream()
+ .filter(ValidationMessage::isWarning)
+ .map(ValidationMessage::getMessage)
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * @return {@code true} si hay al menos un mensaje de severidad {@link ValidationSeverity#ERROR}
+ */
+ public boolean hasErrors() {
+ return messages.stream().anyMatch(ValidationMessage::isError);
+ }
+
+ /**
+ * @return {@code true} si hay al menos un mensaje de severidad {@link ValidationSeverity#WARNING}
+ */
+ public boolean hasWarnings() {
+ return messages.stream().anyMatch(ValidationMessage::isWarning);
+ }
+
+ /**
+ * @return {@code true} si no hay errores (puede haber advertencias)
+ */
+ public boolean isValid() {
+ return !hasErrors();
+ }
+
+ /**
+ * @return solo los textos de mensajes de error (retrocompatible con {@code List})
+ */
+ public List getErrorMessages() {
+ return getErrors();
+ }
+
+ @Override
+ public String toString() {
+ return "ValidationResult{errors=" + getErrors().size()
+ + ", warnings=" + getWarnings().size() + "}";
+ }
+}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/validation/ValidationSeverity.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/validation/ValidationSeverity.java
new file mode 100644
index 00000000..e9c60a01
--- /dev/null
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/standard/guia/validation/ValidationSeverity.java
@@ -0,0 +1,29 @@
+package io.github.project.openubl.xbuilder.content.models.standard.guia.validation;
+
+/**
+ * Severidad de un mensaje de validación de la GRE.
+ *
+ * Define dos niveles:
+ *
+ * - {@link #ERROR} — El documento será rechazado por SUNAT (falla de UBL/regla funcional). Siempre impide la
+ * emisión.
+ * - {@link #WARNING} — Recomendación funcional o normativa cuya obligatoriedad está pospuesta, es discrecional, o
+ * depende del contexto de negocio. No impide la emisión, pero conviene atender.
+ *
+ *
+ * @since 5.2.0
+ * @see ValidationMessage
+ * @see ValidationResult
+ */
+public enum ValidationSeverity {
+
+ /**
+ * Error duro: el documento será rechazado por SUNAT si se envía con este defecto.
+ */
+ ERROR,
+
+ /**
+ * Advertencia: recomendación de cumplimiento normativo, no bloquea el envío.
+ */
+ WARNING
+}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/sunat/baja/Reversion.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/sunat/baja/Reversion.java
index 57fa796c..413f60d9 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/sunat/baja/Reversion.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/sunat/baja/Reversion.java
@@ -5,9 +5,7 @@
import lombok.NoArgsConstructor;
import lombok.ToString;
import lombok.experimental.SuperBuilder;
-import lombok.extern.jackson.Jacksonized;
-@Jacksonized
@Data
@SuperBuilder
@NoArgsConstructor
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/sunat/baja/VoidedDocuments.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/sunat/baja/VoidedDocuments.java
index 71705892..cdd9e31a 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/sunat/baja/VoidedDocuments.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/sunat/baja/VoidedDocuments.java
@@ -9,11 +9,9 @@
import lombok.Singular;
import lombok.ToString;
import lombok.experimental.SuperBuilder;
-import lombok.extern.jackson.Jacksonized;
import java.util.List;
-@Jacksonized
@Data
@SuperBuilder
@NoArgsConstructor
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/sunat/percepcionretencion/Perception.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/sunat/percepcionretencion/Perception.java
index c394f87e..0f043512 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/sunat/percepcionretencion/Perception.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/sunat/percepcionretencion/Perception.java
@@ -6,11 +6,9 @@
import lombok.NoArgsConstructor;
import lombok.ToString;
import lombok.experimental.SuperBuilder;
-import lombok.extern.jackson.Jacksonized;
import java.math.BigDecimal;
-@Jacksonized
@Data
@SuperBuilder
@NoArgsConstructor
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/sunat/percepcionretencion/Retention.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/sunat/percepcionretencion/Retention.java
index a6554abc..5d1df7ba 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/sunat/percepcionretencion/Retention.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/sunat/percepcionretencion/Retention.java
@@ -6,11 +6,9 @@
import lombok.NoArgsConstructor;
import lombok.ToString;
import lombok.experimental.SuperBuilder;
-import lombok.extern.jackson.Jacksonized;
import java.math.BigDecimal;
-@Jacksonized
@Data
@SuperBuilder
@NoArgsConstructor
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/sunat/resumen/ComprobanteImpuestos.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/sunat/resumen/ComprobanteImpuestos.java
index 49772424..2620eda6 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/sunat/resumen/ComprobanteImpuestos.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/sunat/resumen/ComprobanteImpuestos.java
@@ -17,6 +17,9 @@ public class ComprobanteImpuestos {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "IGV del comprobante")
private BigDecimal igv;
+ @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = "Tasa de IGV del comprobante. Ejemplo: 0.18")
+ private BigDecimal tasaIgv;
+
@Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = "ICB del comprobante")
private BigDecimal icb;
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/sunat/resumen/SummaryDocuments.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/sunat/resumen/SummaryDocuments.java
index 2ec38cc8..aa6edf1d 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/sunat/resumen/SummaryDocuments.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/content/models/sunat/resumen/SummaryDocuments.java
@@ -9,11 +9,9 @@
import lombok.Singular;
import lombok.ToString;
import lombok.experimental.SuperBuilder;
-import lombok.extern.jackson.Jacksonized;
import java.util.List;
-@Jacksonized
@Data
@SuperBuilder
@NoArgsConstructor
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/enricher/ContentEnricher.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/enricher/ContentEnricher.java
index 35979d7f..93a1557d 100644
--- a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/enricher/ContentEnricher.java
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/enricher/ContentEnricher.java
@@ -22,159 +22,174 @@
public class ContentEnricher {
- private final Defaults defaults;
- private final DateProvider dateProvider;
-
- public ContentEnricher(Defaults defaults, DateProvider dateProvider) {
- this.defaults = defaults;
- this.dateProvider = dateProvider;
- }
-
- public void enrich(Invoice input) {
- Stream
- .of(RulePhase.PhaseType.ENRICH, RulePhase.PhaseType.PROCESS, RulePhase.PhaseType.SUMMARY)
- .forEach(phaseType -> {
- // Header
- HeaderRuleContext ruleContextHeader = HeaderRuleContext.builder()
- .localDate(dateProvider.now())
- .build();
- RuleUnit ruleUnitHeader = new HeaderRuleUnit(phaseType, defaults, ruleContextHeader);
- ruleUnitHeader.modify(input);
-
- // Body
- BodyRuleContext ruleContextBody = BodyRuleContext.builder()
- .moneda(input.getMoneda())
- .tasaIgv(input.getTasaIgv())
- .tasaIvap(input.getTasaIvap())
- .tasaIcb(input.getTasaIcb())
- .build();
- RuleUnit ruleUnitBody = new BodyRuleUnit(phaseType, defaults, ruleContextBody);
-
- input.getDetalles().forEach(ruleUnitBody::modify);
- input.getAnticipos().forEach(ruleUnitBody::modify);
- input.getDescuentos().forEach(ruleUnitBody::modify);
- });
- }
-
- public void enrich(CreditNote input) {
- enrichNote(input);
- }
-
- public void enrich(DebitNote input) {
- enrichNote(input);
- }
-
- private void enrichNote(Note input) {
- Stream
- .of(RulePhase.PhaseType.ENRICH, RulePhase.PhaseType.PROCESS, RulePhase.PhaseType.SUMMARY)
- .forEach(phaseType -> {
- // Header
- HeaderRuleContext ruleContextHeader = HeaderRuleContext.builder()
- .localDate(dateProvider.now())
- .build();
- RuleUnit ruleUnitHeader = new HeaderRuleUnit(phaseType, defaults, ruleContextHeader);
- ruleUnitHeader.modify(input);
-
- // Body
- BodyRuleContext ruleContextBody = BodyRuleContext.builder()
- .moneda(input.getMoneda())
- .tasaIgv(input.getTasaIgv())
- .tasaIcb(input.getTasaIcb())
- .build();
- RuleUnit ruleUnitBody = new BodyRuleUnit(phaseType, defaults, ruleContextBody);
- input.getDetalles().forEach(ruleUnitBody::modify);
- });
- }
-
- public void enrich(VoidedDocuments input) {
- Stream
- .of(RulePhase.PhaseType.ENRICH, RulePhase.PhaseType.PROCESS, RulePhase.PhaseType.SUMMARY)
- .forEach(phaseType -> {
- // Header
- HeaderRuleContext ruleContextHeader = HeaderRuleContext.builder()
- .localDate(dateProvider.now())
- .build();
- RuleUnit ruleUnitHeader = new HeaderRuleUnit(phaseType, defaults, ruleContextHeader);
- ruleUnitHeader.modify(input);
-
- // Body
- BodyRuleContext ruleContextBody = BodyRuleContext.builder()
- .moneda(input.getMoneda())
- .build();
-
- RuleUnit ruleUnitBody = new BodyRuleUnit(phaseType, defaults, ruleContextBody);
- input.getComprobantes().forEach(ruleUnitBody::modify);
- });
- }
-
- public void enrich(SummaryDocuments input) {
- Stream
- .of(RulePhase.PhaseType.ENRICH, RulePhase.PhaseType.PROCESS, RulePhase.PhaseType.SUMMARY)
- .forEach(phaseType -> {
- // Header
- HeaderRuleContext ruleContextHeader = HeaderRuleContext.builder()
- .localDate(dateProvider.now())
- .build();
- RuleUnit ruleUnitHeader = new HeaderRuleUnit(phaseType, defaults, ruleContextHeader);
- ruleUnitHeader.modify(input);
-
- // Body
- BodyRuleContext ruleContextBody = BodyRuleContext.builder()
- .moneda(input.getMoneda())
- .build();
-
- RuleUnit ruleUnitBody = new BodyRuleUnit(phaseType, defaults, ruleContextBody);
- input.getComprobantes().forEach(ruleUnitBody::modify);
- });
- }
-
- public void enrich(Perception input) {
- Stream
- .of(RulePhase.PhaseType.ENRICH, RulePhase.PhaseType.PROCESS, RulePhase.PhaseType.SUMMARY)
- .forEach(phaseType -> {
- // Header
- HeaderRuleContext ruleContextHeader = HeaderRuleContext.builder()
- .localDate(dateProvider.now())
- .build();
- RuleUnit ruleUnitHeader = new HeaderRuleUnit(phaseType, defaults, ruleContextHeader);
- ruleUnitHeader.modify(input);
-
- // Body
- });
- }
-
- public void enrich(Retention input) {
- Stream
- .of(RulePhase.PhaseType.ENRICH, RulePhase.PhaseType.PROCESS, RulePhase.PhaseType.SUMMARY)
- .forEach(phaseType -> {
- // Header
- HeaderRuleContext ruleContextHeader = HeaderRuleContext.builder()
- .localDate(dateProvider.now())
- .build();
- RuleUnit ruleUnitHeader = new HeaderRuleUnit(phaseType, defaults, ruleContextHeader);
- ruleUnitHeader.modify(input);
-
- // Body
- });
- }
-
- public void enrich(DespatchAdvice input) {
- Stream
- .of(RulePhase.PhaseType.ENRICH, RulePhase.PhaseType.PROCESS, RulePhase.PhaseType.SUMMARY)
- .forEach(phaseType -> {
- // Header
- HeaderRuleContext ruleContextHeader = HeaderRuleContext.builder()
- .localDate(dateProvider.now())
- .build();
- RuleUnit ruleUnitHeader = new HeaderRuleUnit(phaseType, defaults, ruleContextHeader);
- ruleUnitHeader.modify(input);
-
- // Body
- BodyRuleContext ruleContextBody = BodyRuleContext.builder()
- .build();
- RuleUnit ruleUnitBody = new BodyRuleUnit(phaseType, defaults, ruleContextBody);
- input.getDetalles().forEach(ruleUnitBody::modify);
- });
- }
+ private final Defaults defaults;
+ private final DateProvider dateProvider;
+
+ public ContentEnricher(Defaults defaults, DateProvider dateProvider) {
+ this.defaults = defaults;
+ this.dateProvider = dateProvider;
+ }
+
+ public void enrich(Invoice input) {
+ Stream
+ .of(RulePhase.PhaseType.ENRICH, RulePhase.PhaseType.PROCESS,
+ RulePhase.PhaseType.SUMMARY)
+ .forEach(phaseType -> {
+ // Header
+ HeaderRuleContext ruleContextHeader = HeaderRuleContext.builder()
+ .localDate(dateProvider.now())
+ .build();
+ RuleUnit ruleUnitHeader = new HeaderRuleUnit(phaseType, defaults,
+ ruleContextHeader);
+ ruleUnitHeader.modify(input);
+
+ // Body
+ BodyRuleContext ruleContextBody = BodyRuleContext.builder()
+ .moneda(input.getMoneda())
+ .tasaIgv(input.getTasaIgv())
+ .tasaIvap(input.getTasaIvap())
+ .tasaIcb(input.getTasaIcb())
+ .build();
+ RuleUnit ruleUnitBody = new BodyRuleUnit(phaseType, defaults, ruleContextBody);
+
+ input.getDetalles().forEach(ruleUnitBody::modify);
+ input.getAnticipos().forEach(ruleUnitBody::modify);
+ input.getDescuentos().forEach(ruleUnitBody::modify);
+ });
+ }
+
+ public void enrich(CreditNote input) {
+ enrichNote(input);
+ }
+
+ public void enrich(DebitNote input) {
+ enrichNote(input);
+ }
+
+ private void enrichNote(Note input) {
+ Stream
+ .of(RulePhase.PhaseType.ENRICH, RulePhase.PhaseType.PROCESS,
+ RulePhase.PhaseType.SUMMARY)
+ .forEach(phaseType -> {
+ // Header
+ HeaderRuleContext ruleContextHeader = HeaderRuleContext.builder()
+ .localDate(dateProvider.now())
+ .build();
+ RuleUnit ruleUnitHeader = new HeaderRuleUnit(phaseType, defaults,
+ ruleContextHeader);
+ ruleUnitHeader.modify(input);
+
+ // Body
+ BodyRuleContext ruleContextBody = BodyRuleContext.builder()
+ .moneda(input.getMoneda())
+ .tasaIgv(input.getTasaIgv())
+ .tasaIcb(input.getTasaIcb())
+ .build();
+ RuleUnit ruleUnitBody = new BodyRuleUnit(phaseType, defaults, ruleContextBody);
+ input.getDetalles().forEach(ruleUnitBody::modify);
+ });
+ }
+
+ public void enrich(VoidedDocuments input) {
+ Stream
+ .of(RulePhase.PhaseType.ENRICH, RulePhase.PhaseType.PROCESS,
+ RulePhase.PhaseType.SUMMARY)
+ .forEach(phaseType -> {
+ // Header
+ HeaderRuleContext ruleContextHeader = HeaderRuleContext.builder()
+ .localDate(dateProvider.now())
+ .build();
+ RuleUnit ruleUnitHeader = new HeaderRuleUnit(phaseType, defaults,
+ ruleContextHeader);
+ ruleUnitHeader.modify(input);
+
+ // Body
+ BodyRuleContext ruleContextBody = BodyRuleContext.builder()
+ .moneda(input.getMoneda())
+ .build();
+
+ RuleUnit ruleUnitBody = new BodyRuleUnit(phaseType, defaults, ruleContextBody);
+ input.getComprobantes().forEach(ruleUnitBody::modify);
+ });
+ }
+
+ public void enrich(SummaryDocuments input) {
+ Stream
+ .of(RulePhase.PhaseType.ENRICH, RulePhase.PhaseType.PROCESS,
+ RulePhase.PhaseType.SUMMARY)
+ .forEach(phaseType -> {
+ // Header
+ HeaderRuleContext ruleContextHeader = HeaderRuleContext.builder()
+ .localDate(dateProvider.now())
+ .build();
+ RuleUnit ruleUnitHeader = new HeaderRuleUnit(phaseType, defaults,
+ ruleContextHeader);
+ ruleUnitHeader.modify(input);
+
+ // Body
+ BodyRuleContext ruleContextBody = BodyRuleContext.builder()
+ .moneda(input.getMoneda())
+ .tasaIgv(defaults.getIgvTasa())
+ .build();
+
+ RuleUnit ruleUnitBody = new BodyRuleUnit(phaseType, defaults, ruleContextBody);
+ input.getComprobantes().forEach(ruleUnitBody::modify);
+ });
+ }
+
+ public void enrich(Perception input) {
+ Stream
+ .of(RulePhase.PhaseType.ENRICH, RulePhase.PhaseType.PROCESS,
+ RulePhase.PhaseType.SUMMARY)
+ .forEach(phaseType -> {
+ // Header
+ HeaderRuleContext ruleContextHeader = HeaderRuleContext.builder()
+ .localDate(dateProvider.now())
+ .build();
+ RuleUnit ruleUnitHeader = new HeaderRuleUnit(phaseType, defaults,
+ ruleContextHeader);
+ ruleUnitHeader.modify(input);
+
+ // Body
+ });
+ }
+
+ public void enrich(Retention input) {
+ Stream
+ .of(RulePhase.PhaseType.ENRICH, RulePhase.PhaseType.PROCESS,
+ RulePhase.PhaseType.SUMMARY)
+ .forEach(phaseType -> {
+ // Header
+ HeaderRuleContext ruleContextHeader = HeaderRuleContext.builder()
+ .localDate(dateProvider.now())
+ .build();
+ RuleUnit ruleUnitHeader = new HeaderRuleUnit(phaseType, defaults,
+ ruleContextHeader);
+ ruleUnitHeader.modify(input);
+
+ // Body
+ });
+ }
+
+ public void enrich(DespatchAdvice input) {
+ Stream
+ .of(RulePhase.PhaseType.ENRICH, RulePhase.PhaseType.PROCESS,
+ RulePhase.PhaseType.SUMMARY)
+ .forEach(phaseType -> {
+ // Header
+ HeaderRuleContext ruleContextHeader = HeaderRuleContext.builder()
+ .localDate(dateProvider.now())
+ .build();
+ RuleUnit ruleUnitHeader = new HeaderRuleUnit(phaseType, defaults,
+ ruleContextHeader);
+ ruleUnitHeader.modify(input);
+
+ // Body
+ BodyRuleContext ruleContextBody = BodyRuleContext.builder()
+ .build();
+ RuleUnit ruleUnitBody = new BodyRuleUnit(phaseType, defaults, ruleContextBody);
+ input.getDetalles().forEach(ruleUnitBody::modify);
+ });
+ }
}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/enricher/kie/rules/enrich/body/summaryDocumentItem/TasaIgvRule.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/enricher/kie/rules/enrich/body/summaryDocumentItem/TasaIgvRule.java
new file mode 100644
index 00000000..00a00247
--- /dev/null
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/enricher/kie/rules/enrich/body/summaryDocumentItem/TasaIgvRule.java
@@ -0,0 +1,31 @@
+package io.github.project.openubl.xbuilder.enricher.kie.rules.enrich.body.summaryDocumentItem;
+
+import io.github.project.openubl.xbuilder.content.models.sunat.resumen.SummaryDocumentsItem;
+import io.github.project.openubl.xbuilder.enricher.kie.AbstractBodyRule;
+import io.github.project.openubl.xbuilder.enricher.kie.RulePhase;
+
+import java.util.function.Consumer;
+
+import static io.github.project.openubl.xbuilder.enricher.kie.rules.utils.Helpers.isSummaryDocumentsItem;
+import static io.github.project.openubl.xbuilder.enricher.kie.rules.utils.Helpers.whenSummaryDocumentsItem;
+
+@RulePhase(type = RulePhase.PhaseType.ENRICH)
+public class TasaIgvRule extends AbstractBodyRule {
+
+ @Override
+ public boolean test(Object object) {
+ return (isSummaryDocumentsItem.test(object) && whenSummaryDocumentsItem.apply(object)
+ .map(item -> item.getComprobante() != null &&
+ item.getComprobante().getImpuestos() != null &&
+ item.getComprobante().getImpuestos().getTasaIgv() == null)
+ .orElse(false));
+ }
+
+ @Override
+ public void modify(Object object) {
+ Consumer consumer = item -> {
+ item.getComprobante().getImpuestos().setTasaIgv(getRuleContext().getTasaIgv());
+ };
+ whenSummaryDocumentsItem.apply(object).ifPresent(consumer);
+ }
+}
diff --git a/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/enricher/kie/rules/enrich/header/DespatchAdviceTipoComprobanteRule.java b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/enricher/kie/rules/enrich/header/DespatchAdviceTipoComprobanteRule.java
new file mode 100644
index 00000000..8c7d8773
--- /dev/null
+++ b/xbuilder/core/src/main/java/io/github/project/openubl/xbuilder/enricher/kie/rules/enrich/header/DespatchAdviceTipoComprobanteRule.java
@@ -0,0 +1,42 @@
+package io.github.project.openubl.xbuilder.enricher.kie.rules.enrich.header;
+
+import io.github.project.openubl.xbuilder.enricher.kie.AbstractHeaderRule;
+import io.github.project.openubl.xbuilder.enricher.kie.RulePhase;
+
+import static io.github.project.openubl.xbuilder.enricher.kie.rules.utils.Helpers.isDespatchAdvice;
+import static io.github.project.openubl.xbuilder.enricher.kie.rules.utils.Helpers.whenDespatchAdvice;
+
+/**
+ * Regla de enriquecimiento para autodetectar el tipo de comprobante de la GRE a partir del prefijo de la serie.
+ *
+ * Regla funcional SUNAT:
+ *
+ * - Serie T* → GRE-Remitente (tipo "09")
+ * - Serie V* → GRE-Transportista (tipo "31")
+ *
+ *
+ * Esta regla solo se aplica si {@code tipoComprobante} no fue establecido explícitamente por el usuario, permitiendo
+ * autocompletar.
+ */
+@RulePhase(type = RulePhase.PhaseType.ENRICH)
+public class DespatchAdviceTipoComprobanteRule extends AbstractHeaderRule {
+
+ @Override
+ public boolean test(Object object) {
+ return isDespatchAdvice.test(object) && whenDespatchAdvice.apply(object)
+ .map(da -> da.getTipoComprobante() == null && da.getSerie() != null)
+ .orElse(false);
+ }
+
+ @Override
+ public void modify(Object object) {
+ whenDespatchAdvice.apply(object).ifPresent(da -> {
+ String serie = da.getSerie().toUpperCase();
+ if (serie.startsWith("T")) {
+ da.setTipoComprobante("09");
+ } else if (serie.startsWith("V")) {
+ da.setTipoComprobante("31");
+ }
+ });
+ }
+}
diff --git a/xbuilder/core/src/main/resources/META-INF/services/io.github.project.openubl.xbuilder.enricher.kie.RuleFactory b/xbuilder/core/src/main/resources/META-INF/services/io.github.project.openubl.xbuilder.enricher.kie.RuleFactory
index 4932d66b..d18c7562 100644
--- a/xbuilder/core/src/main/resources/META-INF/services/io.github.project.openubl.xbuilder.enricher.kie.RuleFactory
+++ b/xbuilder/core/src/main/resources/META-INF/services/io.github.project.openubl.xbuilder.enricher.kie.RuleFactory
@@ -11,6 +11,9 @@ io.github.project.openubl.xbuilder.enricher.kie.rules.enrich.header.ProveedorDir
## Enrich - Invoice
io.github.project.openubl.xbuilder.enricher.kie.rules.enrich.header.invoice.FormaDePagoRule
+
+## Enrich - DespatchAdvice
+io.github.project.openubl.xbuilder.enricher.kie.rules.enrich.header.DespatchAdviceTipoComprobanteRule
io.github.project.openubl.xbuilder.enricher.kie.rules.enrich.header.invoice.FormaDePagoTipoRule
io.github.project.openubl.xbuilder.enricher.kie.rules.enrich.header.invoice.FormaDePagoTotalRule
io.github.project.openubl.xbuilder.enricher.kie.rules.enrich.header.invoice.TipoComprobanteRule
@@ -63,6 +66,7 @@ io.github.project.openubl.xbuilder.enricher.kie.rules.enrich.body.voidedDocument
## Enrich - SummaryDocuments
io.github.project.openubl.xbuilder.enricher.kie.rules.enrich.body.summaryDocumentItem.TipoOperacionRule
io.github.project.openubl.xbuilder.enricher.kie.rules.enrich.body.summaryDocumentItem.MonedaRule
+io.github.project.openubl.xbuilder.enricher.kie.rules.enrich.body.summaryDocumentItem.TasaIgvRule
## Process - Detalle
io.github.project.openubl.xbuilder.enricher.kie.rules.process.body.detalle.PrecioDeReferenciaRule
diff --git a/xbuilder/core/src/main/resources/templates/Renderer/despatchAdvice.xml b/xbuilder/core/src/main/resources/templates/Renderer/despatchAdvice.xml
index 03ae1b94..b499d0e9 100644
--- a/xbuilder/core/src/main/resources/templates/Renderer/despatchAdvice.xml
+++ b/xbuilder/core/src/main/resources/templates/Renderer/despatchAdvice.xml
@@ -23,6 +23,12 @@
{documentoRelacionado.tipoDocumento}
{/if}
+ {#each documentosRelacionados.orEmpty}
+
+ {it.serieNumero}
+ {it.tipoDocumento}
+
+ {/each}
{#each documentosAdicionales.orEmpty}
{it.numero}
@@ -39,9 +45,21 @@
{/if}
{/each}
+ {#each envio.declaracionesAduaneras.orEmpty}
+
+ {it.numero}
+ {#if it.tipo == 'DS'}52{#else}50{/if}
+ {#if it.rucEmisor}
+
+
+ {it.rucEmisor}
+
+
+ {/if}
+
+ {/each}
{#include ubl/common/signature.xml firmante=this.firmante /}
- {remitente.ruc}
{remitente.ruc}
@@ -183,8 +201,10 @@
{#each envio.contenedores.orEmpty}
- {it_index.add(1)}
- {it}
+ {it.numero}
+ {#if it.precinto}
+ {it.precinto}
+ {/if}
{/each}
@@ -245,9 +265,11 @@
{#if it.descripcion}
{/if}
+ {#if it.codigo}
{it.codigo}
+ {/if}
{#if it.codigoSunat}
{it.codigoSunat}
diff --git a/xbuilder/core/src/main/resources/templates/Renderer/summaryDocuments.xml b/xbuilder/core/src/main/resources/templates/Renderer/summaryDocuments.xml
index 979271a2..329fffb9 100644
--- a/xbuilder/core/src/main/resources/templates/Renderer/summaryDocuments.xml
+++ b/xbuilder/core/src/main/resources/templates/Renderer/summaryDocuments.xml
@@ -86,6 +86,7 @@
{it.comprobante.impuestos.igv}
+ {it.comprobante.impuestos.tasaIgv.multiplyByInt(100).scale(2)}
1000
IGV
diff --git a/xbuilder/core/src/main/resources/templates/ubl/common/signature.xml b/xbuilder/core/src/main/resources/templates/ubl/common/signature.xml
index 18dc6461..b73a1577 100644
--- a/xbuilder/core/src/main/resources/templates/ubl/common/signature.xml
+++ b/xbuilder/core/src/main/resources/templates/ubl/common/signature.xml
@@ -10,7 +10,7 @@
- #PROJECT-OPENUBL-SIGN
+ {firmante.signatureUri ?: '#PROJECT-OPENUBL-SIGN'}
diff --git a/xbuilder/core/src/main/resources/templates/ubl/standard/include/document-line.xml b/xbuilder/core/src/main/resources/templates/ubl/standard/include/document-line.xml
index 38abf80d..9346af02 100644
--- a/xbuilder/core/src/main/resources/templates/ubl/standard/include/document-line.xml
+++ b/xbuilder/core/src/main/resources/templates/ubl/standard/include/document-line.xml
@@ -57,6 +57,21 @@
+ {#if item.codigoProducto}
+
+ {item.codigoProducto}
+
+ {/if}
+ {#if item.codigoProductoGS1}
+
+ {item.codigoProductoGS1}
+
+ {/if}
+ {#if item.codigoProductoSunat}
+
+ {item.codigoProductoSunat}
+
+ {/if}
{item.precio.scale(2)}
diff --git a/xbuilder/core/src/test/java/e2e/AbstractTest.java b/xbuilder/core/src/test/java/e2e/AbstractTest.java
index 495dbf6f..c89223b2 100644
--- a/xbuilder/core/src/test/java/e2e/AbstractTest.java
+++ b/xbuilder/core/src/test/java/e2e/AbstractTest.java
@@ -1,11 +1,7 @@
package e2e;
-import com.fasterxml.jackson.annotation.JsonInclude;
-import com.fasterxml.jackson.databind.SerializationFeature;
-import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
-import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator;
-import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
-import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONWriter;
import e2e.renderer.XMLAssertUtils;
import io.github.project.openubl.xbuilder.content.jaxb.mappers.CreditNoteMapper;
import io.github.project.openubl.xbuilder.content.jaxb.mappers.DebitNoteMapper;
@@ -27,6 +23,8 @@
import io.github.project.openubl.xbuilder.content.models.standard.general.DebitNote;
import io.github.project.openubl.xbuilder.content.models.standard.general.Invoice;
import io.github.project.openubl.xbuilder.content.models.standard.guia.DespatchAdvice;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.GRERemitente;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.GRETransportista;
import io.github.project.openubl.xbuilder.content.models.sunat.baja.VoidedDocuments;
import io.github.project.openubl.xbuilder.content.models.sunat.baja.Reversion;
import io.github.project.openubl.xbuilder.content.models.sunat.percepcionretencion.Perception;
@@ -39,8 +37,11 @@
import io.quarkus.qute.Template;
import org.mapstruct.factory.Mappers;
import org.xml.sax.InputSource;
+import org.yaml.snakeyaml.DumperOptions;
+import org.yaml.snakeyaml.Yaml;
import jakarta.xml.bind.JAXBContext;
+import java.io.FileWriter;
import java.io.IOException;
import java.io.StringReader;
import java.math.BigDecimal;
@@ -49,6 +50,7 @@
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDate;
+import java.util.LinkedHashMap;
import java.util.Map;
public class AbstractTest {
@@ -57,7 +59,8 @@ public class AbstractTest {
private static final CreditNoteMapper creditNoteMapper = Mappers.getMapper(CreditNoteMapper.class);
private static final DebitNoteMapper debitNoteMapper = Mappers.getMapper(DebitNoteMapper.class);
private static final VoidedDocumentsMapper voidedDocumentsMapper = Mappers.getMapper(VoidedDocumentsMapper.class);
- private static final SummaryDocumentsMapper summaryDocumentsMapper = Mappers.getMapper(SummaryDocumentsMapper.class);
+ private static final SummaryDocumentsMapper summaryDocumentsMapper = Mappers
+ .getMapper(SummaryDocumentsMapper.class);
private static final PerceptionMapper perceptionMapper = Mappers.getMapper(PerceptionMapper.class);
private static final RetentionMapper retentionMapper = Mappers.getMapper(RetentionMapper.class);
private static final DespatchAdviceMapper despatchAdviceMapper = Mappers.getMapper(DespatchAdviceMapper.class);
@@ -70,29 +73,36 @@ public class AbstractTest {
protected static final DateProvider dateProvider = () -> LocalDate.of(2019, 12, 24);
- public YAMLMapper getYamlMapper() {
- YAMLMapper mapper = new YAMLMapper(new YAMLFactory());
- mapper.registerModule(new JavaTimeModule());
- mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
- mapper.configure(YAMLGenerator.Feature.LITERAL_BLOCK_STYLE, true);
- mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
- return mapper;
+ private Yaml createYaml() {
+ var options = new DumperOptions();
+ options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
+ options.setDefaultScalarStyle(DumperOptions.ScalarStyle.LITERAL);
+ options.setPrettyFlow(true);
+ return new Yaml(options);
}
+ @SuppressWarnings("unchecked")
public void writeYaml(String kind, Object input, String snapshotFilename) throws URISyntaxException, IOException {
- String rootDir = getClass().getName().replaceAll("\\.", "/");
+ String rootDir = getClass().getName().replace('.', '/');
- String snapshotFileContent = Files.readString(Paths.get(getClass().getClassLoader().getResource(rootDir + "/" + snapshotFilename).toURI()));
+ String snapshotFileContent = Files.readString(
+ Paths.get(getClass().getClassLoader().getResource(rootDir + "/" + snapshotFilename).toURI()));
Path directoryPath = Paths.get("../quarkus-extension/integration-tests/src/test/resources").resolve(rootDir);
Files.createDirectories(directoryPath);
- Path filePath = directoryPath.resolve(snapshotFilename.replaceAll(".xml", "") + ".yaml");
+ Path filePath = directoryPath.resolve(snapshotFilename.replace(".xml", "") + ".yaml");
- getYamlMapper().writeValue(filePath.toFile(), Map.of(
- "kind", kind,
- "input", input,
- "snapshot", snapshotFileContent
- ));
+ String jsonStr = JSON.toJSONString(input, JSONWriter.Feature.NotWriteDefaultValue);
+ Map inputMap = JSON.parseObject(jsonStr, LinkedHashMap.class);
+
+ Map data = new LinkedHashMap<>();
+ data.put("kind", kind);
+ data.put("input", inputMap);
+ data.put("snapshot", snapshotFileContent);
+
+ try (var writer = new FileWriter(filePath.toFile())) {
+ createYaml().dump(data, writer);
+ }
}
protected void assertInput(Invoice input, String snapshotFilename) throws Exception {
@@ -287,6 +297,14 @@ protected void assertInput(DespatchAdvice input, String snapshotFilename) throws
writeYaml("DespatchAdvice", input, snapshotFilename);
}
+ protected void assertInput(GRERemitente input, String snapshotFilename) throws Exception {
+ assertInput(input.toDespatchAdvice(), snapshotFilename);
+ }
+
+ protected void assertInput(GRETransportista input, String snapshotFilename) throws Exception {
+ assertInput(input.toDespatchAdvice(), snapshotFilename);
+ }
+
protected void assertInputReversion(Reversion input, String snapshotFilename) throws Exception {
ContentEnricher enricher = new ContentEnricher(defaults, dateProvider);
enricher.enrich(input);
diff --git a/xbuilder/core/src/test/java/e2e/renderer/XMLAssertUtils.java b/xbuilder/core/src/test/java/e2e/renderer/XMLAssertUtils.java
index 626fbbfb..3c06ec4d 100644
--- a/xbuilder/core/src/test/java/e2e/renderer/XMLAssertUtils.java
+++ b/xbuilder/core/src/test/java/e2e/renderer/XMLAssertUtils.java
@@ -85,7 +85,8 @@ public class XMLAssertUtils {
private static void assertSnapshot(String expected, Class> clasz, String snapshotFile) throws SAXException {
String rootDir = clasz.getName().replaceAll("\\.", "/");
- // Update snapshots and if updated do not verify since it doesn't make sense anymore
+ // Update snapshots and if updated do not verify since it doesn't make sense
+ // anymore
boolean updateSnapshots = Boolean.parseBoolean(System.getProperty("xbuilder.snapshot.update", "false"));
if (updateSnapshots) {
try {
@@ -117,20 +118,21 @@ private static void assertSnapshot(String expected, Class> clasz, String snaps
assertFalse(myDiff.hasDifferences(), expected + "\n" + myDiff);
}
- public static void assertSnapshot(String expected, String expectedReverse, Class> clasz, String snapshotFile) throws SAXException {
+ public static void assertSnapshot(String expected, String expectedReverse, Class> clasz, String snapshotFile)
+ throws SAXException {
assertSnapshot(expected, clasz, snapshotFile);
assertSnapshot(expectedReverse, clasz, snapshotFile);
}
- public static void assertSendSunat(String xmlWithoutSignature, String xsdSchema, String... allowedNotes) throws Exception {
+ public static void assertSendSunat(String xmlWithoutSignature, String xsdSchema, String... allowedNotes)
+ throws Exception {
String skipSunat = System.getProperty("skipSunat", "false");
if (skipSunat != null && skipSunat.equals("false")) {
Document signedXML = XMLSigner.signXML(
xmlWithoutSignature,
SIGN_REFERENCE_ID,
CERTIFICATE.getX509Certificate(),
- CERTIFICATE.getPrivateKey()
- );
+ CERTIFICATE.getPrivateKey());
isCompliantWithXsd(xsdSchema, signedXML);
sendFileToSunat(signedXML, xmlWithoutSignature, allowedNotes);
}
@@ -153,7 +155,8 @@ private static void isCompliantWithXsd(String xsdSchema, Document signedXML) thr
//
- private static void sendFileToSunat(Document document, String xmlWithoutSignature, String... allowedNotes) throws Exception {
+ private static void sendFileToSunat(Document document, String xmlWithoutSignature, String... allowedNotes)
+ throws Exception {
byte[] bytesFromDocument = XmlSignatureHelper.getBytesFromDocument(document);
CamelContext camelContext = StandaloneCamel.getInstance().getMainCamel().getCamelContext();
@@ -176,8 +179,7 @@ private static void sendFileToSunat(Document document, String xmlWithoutSignatur
Constants.XSENDER_BILL_SERVICE_URI,
camelData.getBody(),
camelData.getHeaders(),
- SunatResponse.class
- );
+ SunatResponse.class);
if (sendFileSunatResponse.getMetadata() != null && sendFileSunatResponse.getMetadata().getNotes() != null) {
List allowedNotesList = Arrays.asList(allowedNotes);
@@ -195,43 +197,52 @@ private static void sendFileToSunat(Document document, String xmlWithoutSignatur
XmlContent xmlContent = fileAnalyzer.getXmlContent();
// Check ticket
- if (
- !xmlContent.getDocumentType().equals(DocumentType.VOIDED_DOCUMENT) &&
- !xmlContent.getDocumentType().equals(DocumentType.SUMMARY_DOCUMENT)
- ) {
+ if (!xmlContent.getDocumentType().equals(DocumentType.VOIDED_DOCUMENT) &&
+ !xmlContent.getDocumentType().equals(DocumentType.SUMMARY_DOCUMENT)) {
assertEquals(
Status.ACEPTADO,
sendFileSunatResponse.getStatus(),
- xmlWithoutSignature + " \n sunat [codigo=" + sendFileSunatResponse.getMetadata().getResponseCode() + "], [descripcion=" + sendFileSunatResponse.getMetadata().getDescription() + "]"
- );
+ xmlWithoutSignature + " \n sunat [codigo=" + sendFileSunatResponse.getMetadata().getResponseCode()
+ + "], [descripcion=" + sendFileSunatResponse.getMetadata().getDescription() + "]");
} else {
+ assertNotNull(sendFileSunatResponse.getSunat(),
+ "SunatResponse.getSunat() is null. Status=" + sendFileSunatResponse.getStatus()
+ + ", Description="
+ + (sendFileSunatResponse.getMetadata() != null
+ ? sendFileSunatResponse.getMetadata().getDescription()
+ : "null"));
assertNotNull(sendFileSunatResponse.getSunat().getTicket());
CamelData camelTicketData = CamelUtils.getBillServiceCamelData(
sendFileSunatResponse.getSunat().getTicket(),
ticketDestination,
- credentials
- );
-
- // TODO ticket get status are not working in SUNAT BETA so stopping it until it is supporeted
-// SunatResponse verifyTicketSunatResponse = camelContext
-// .createProducerTemplate()
-// .requestBodyAndHeaders(
-// Constants.XSENDER_BILL_SERVICE_URI,
-// camelTicketData.getBody(),
-// camelTicketData.getHeaders(),
-// SunatResponse.class
-// );
-//
-// assertEquals(
-// Status.ACEPTADO,
-// verifyTicketSunatResponse.getStatus(),
-// xmlWithoutSignature + " sunat [status=" + verifyTicketSunatResponse.getStatus() + "], [descripcion=" + verifyTicketSunatResponse.getMetadata().getDescription() + "]"
-// );
-// assertNotNull(
-// verifyTicketSunatResponse.getSunat().getCdr(),
-// xmlWithoutSignature + " sunat [codigo=" + verifyTicketSunatResponse.getMetadata().getResponseCode() + "], [descripcion=" + verifyTicketSunatResponse.getMetadata().getDescription() + "]"
-// );
+ credentials);
+
+ // TODO ticket get status are not working in SUNAT BETA so stopping it until it
+ // is supporeted
+ // SunatResponse verifyTicketSunatResponse = camelContext
+ // .createProducerTemplate()
+ // .requestBodyAndHeaders(
+ // Constants.XSENDER_BILL_SERVICE_URI,
+ // camelTicketData.getBody(),
+ // camelTicketData.getHeaders(),
+ // SunatResponse.class
+ // );
+ //
+ // assertEquals(
+ // Status.ACEPTADO,
+ // verifyTicketSunatResponse.getStatus(),
+ // xmlWithoutSignature + " sunat [status=" +
+ // verifyTicketSunatResponse.getStatus() + "], [descripcion=" +
+ // verifyTicketSunatResponse.getMetadata().getDescription() + "]"
+ // );
+ // assertNotNull(
+ // verifyTicketSunatResponse.getSunat().getCdr(),
+ // xmlWithoutSignature + " sunat [codigo=" +
+ // verifyTicketSunatResponse.getMetadata().getResponseCode() + "],
+ // [descripcion=" + verifyTicketSunatResponse.getMetadata().getDescription() +
+ // "]"
+ // );
}
}
}
diff --git a/xbuilder/core/src/test/java/e2e/renderer/despatchadvice/DespatchAdviceComercioExteriorTest.java b/xbuilder/core/src/test/java/e2e/renderer/despatchadvice/DespatchAdviceComercioExteriorTest.java
new file mode 100644
index 00000000..33632645
--- /dev/null
+++ b/xbuilder/core/src/test/java/e2e/renderer/despatchadvice/DespatchAdviceComercioExteriorTest.java
@@ -0,0 +1,230 @@
+package e2e.renderer.despatchadvice;
+
+import e2e.AbstractTest;
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog1;
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog18;
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog20;
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog6;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.*;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+
+/**
+ * Tests de GRE-Remitente para operaciones de comercio exterior.
+ *
+ * Escenarios cubiertos:
+ *
+ * - Importación con DAM y traslado total
+ * - Exportación con DAM/DS
+ * - Mercancía extranjera (motivo 19) desde puerto a depósito temporal
+ * - Contenedores con precinto
+ *
+ *
+ * Fuentes normativas:
+ * - RS 000240-2024/SUNAT (trazabilidad comercio exterior)
+ * - RS 000133-2025/SUNAT (prórroga al 01-jul-2026)
+ * - FAQ #24, #30, #31, #32, #33, #40 de SUNAT CPE
+ */
+public class DespatchAdviceComercioExteriorTest extends AbstractTest {
+
+ /**
+ * GRE-Remitente: Importación con DAM y traslado TOTAL.
+ *
+ * Según FAQ #24: cuando es traslado total de la DAM/DS,
+ * no se requiere detalle de bienes, pero UBL exige al menos una línea vacía.
+ * Se marca el indicador SUNAT_Envio_IndicadorTrasladoTotalDAMDS.
+ */
+ @Test
+ public void testImportacionConDAMTrasladoTotal() throws Exception {
+ DespatchAdvice input = DespatchAdvice.builder()
+ .serie("T001")
+ .numero(200)
+ .tipoComprobante(Catalog1.GUIA_REMISION_REMITENTE.getCode())
+ .remitente(Remitente.builder()
+ .ruc("20100010001")
+ .razonSocial("Importadora Nacional S.A.C.")
+ .build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20100010001")
+ .nombre("Importadora Nacional S.A.C.")
+ .build())
+ .envio(Envio.builder()
+ .tipoTraslado(Catalog20.IMPORTACION_CON_DAM.getCode()) // "10"
+ .pesoTotal(new BigDecimal("5000.00"))
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado(Catalog18.TRANSPORTE_PUBLICO.getCode())
+ .fechaTraslado(dateProvider.now())
+ .transportista(Transportista.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20300030003")
+ .nombre("Transportes Pesados S.A.C.")
+ .numeroRegistroMTC("MTC-123456")
+ .build())
+ .indicador("SUNAT_Envio_IndicadorTrasladoTotalDAMDS")
+ .contenedor(Contenedor.builder()
+ .numero("CONT-2024-001")
+ .precinto("PREC-001")
+ .build())
+ .contenedor(Contenedor.builder()
+ .numero("CONT-2024-002")
+ .precinto("PREC-002")
+ .build())
+ .puerto(Puerto.builder()
+ .codigo("CALLAO")
+ .nombre("Puerto del Callao")
+ .build())
+ .declaracionAduanera(DeclaracionAduanera.builder()
+ .tipo("DAM")
+ .numero("118-2024-10-000123")
+ .rucEmisor("20100010001")
+ .build())
+ .partida(Partida.builder()
+ .direccion("Terminal Portuario del Callao")
+ .ubigeo("070101")
+ .build())
+ .destino(Destino.builder()
+ .direccion("Almacen Deposito Temporal S.A.")
+ .ubigeo("150101")
+ .build())
+ .build())
+ // FAQ #24: línea vacía mínima requerida por UBL cuando traslado total
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(new BigDecimal("1.00"))
+ .unidadMedida("ZZ")
+ .codigo("-")
+ .build())
+ .build();
+
+ assertInput(input, "importacionDAMTotal.xml");
+ }
+
+ /**
+ * GRE-Remitente: Exportación sin DAM numerada aún.
+ *
+ * FAQ #32: cuando no se cuenta con la DAM/DS de exportación numerada,
+ * se debe emitir GRE con motivo "otros" (13) y especificar en observaciones.
+ */
+ @Test
+ public void testExportacionSinDAM() throws Exception {
+ DespatchAdvice input = DespatchAdvice.builder()
+ .serie("T001")
+ .numero(201)
+ .tipoComprobante(Catalog1.GUIA_REMISION_REMITENTE.getCode())
+ .observaciones("Traslado para exportación, DAM en trámite")
+ .remitente(Remitente.builder()
+ .ruc("20500050005")
+ .razonSocial("Exportadora Perú S.A.C.")
+ .build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20500050005")
+ .nombre("Exportadora Perú S.A.C.")
+ .build())
+ .envio(Envio.builder()
+ .tipoTraslado(Catalog20.OTROS.getCode()) // "13" por FAQ #32
+ .motivoTraslado("Traslado para exportación sin DAM numerada")
+ .pesoTotal(new BigDecimal("2000.00"))
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado(Catalog18.TRANSPORTE_PRIVADO.getCode())
+ .fechaTraslado(dateProvider.now())
+ .chofer(Driver.builder()
+ .tipoDocumentoIdentidad(Catalog6.DNI.getCode())
+ .numeroDocumentoIdentidad("12345678")
+ .nombres("Pedro")
+ .apellidos("Gonzales")
+ .licencia("Q9876543")
+ .tipo("Principal")
+ .build())
+ .vehiculo(Vehicle.builder()
+ .placa("ABC-123")
+ .build())
+ .partida(Partida.builder()
+ .direccion("Planta de producción")
+ .ubigeo("150101")
+ .build())
+ .destino(Destino.builder()
+ .direccion("Depósito Temporal para exportación")
+ .ubigeo("070101")
+ .build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(new BigDecimal("100.00"))
+ .unidadMedida("KGM")
+ .codigo("EXPORT-001")
+ .descripcion("Quinua orgánica para exportación")
+ .build())
+ .build();
+
+ assertInput(input, "exportacionSinDAM.xml");
+ }
+
+ /**
+ * GRE-Remitente: Mercancía extranjera motivo 19 desde puerto a depósito
+ * temporal.
+ *
+ * FAQ #40: Cuando se traslada del puerto un contenedor con carga consolidada
+ * con destino a un depósito temporal, corresponde emitir GRE-Remitente
+ * motivo "19 Traslado de mercancía extranjera" a cargo del DT.
+ *
+ * Nota: La obligatoriedad plena del motivo 19 en reemplazo del ticket de salida
+ * fue pospuesta al 01-jul-2026 por RS 000133-2025/SUNAT.
+ * Actualmente se puede usar tanto ticket de salida como GRE.
+ */
+ @Test
+ public void testMercanciaExtranjeraMotivo19() throws Exception {
+ DespatchAdvice input = DespatchAdvice.builder()
+ .serie("T001")
+ .numero(202)
+ .tipoComprobante(Catalog1.GUIA_REMISION_REMITENTE.getCode())
+ .remitente(Remitente.builder()
+ .ruc("20600060006")
+ .razonSocial("Depósito Temporal del Callao S.A.")
+ .build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20600060006")
+ .nombre("Depósito Temporal del Callao S.A.")
+ .build())
+ .envio(Envio.builder()
+ .tipoTraslado(Catalog20.TRASLADO_MERCANCIA_EXTRANJERA.getCode()) // "19"
+ .pesoTotal(new BigDecimal("10000.00"))
+ .pesoTotalUnidadMedida("KGM")
+ .numeroDeBultos(1)
+ .tipoModalidadTraslado(Catalog18.TRANSPORTE_PUBLICO.getCode())
+ .fechaTraslado(dateProvider.now())
+ .transportista(Transportista.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20700070007")
+ .nombre("Transportes Portuarios S.A.C.")
+ .build())
+ .contenedor(Contenedor.builder()
+ .numero("MSKU1234567")
+ .precinto("SEAL-ABC123")
+ .build())
+ .puerto(Puerto.builder()
+ .codigo("CALLAO")
+ .nombre("Puerto del Callao")
+ .build())
+ .numeroManifiesto("2024-000456")
+ .partida(Partida.builder()
+ .direccion("Terminal Portuario del Callao")
+ .ubigeo("070101")
+ .build())
+ .destino(Destino.builder()
+ .direccion("Depósito Temporal del Callao")
+ .ubigeo("070106")
+ .build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(new BigDecimal("1.00"))
+ .unidadMedida("ZZ")
+ .codigo("CONT-CONSOLIDADO")
+ .descripcion("Contenedor carga consolidada")
+ .build())
+ .build();
+
+ assertInput(input, "mercanciaExtranjera.xml");
+ }
+}
diff --git a/xbuilder/core/src/test/java/e2e/renderer/despatchadvice/DespatchAdviceComplexTest.java b/xbuilder/core/src/test/java/e2e/renderer/despatchadvice/DespatchAdviceComplexTest.java
index d8bfb9b7..f8ed100f 100644
--- a/xbuilder/core/src/test/java/e2e/renderer/despatchadvice/DespatchAdviceComplexTest.java
+++ b/xbuilder/core/src/test/java/e2e/renderer/despatchadvice/DespatchAdviceComplexTest.java
@@ -12,119 +12,112 @@
public class DespatchAdviceComplexTest extends AbstractTest {
- @Test
- public void testComplexData() throws Exception {
- // Given
- DespatchAdvice input = DespatchAdvice.builder()
- .serie("T001")
- .numero(100)
- .version("2.1")
- .tipoComprobante(Catalog1.GUIA_REMISION_REMITENTE.getCode())
- .remitente(Remitente.builder()
- .ruc("12345678912")
- .razonSocial("Softgreen S.A.C.")
- .build()
- )
- .destinatario(Destinatario.builder()
- .tipoDocumentoIdentidad(Catalog6.DNI.getCode())
- .numeroDocumentoIdentidad("12345678")
- .nombre("Mi Cliente S.A.C.")
- .build()
- )
- .comprador(Comprador.builder()
- .tipoDocumentoIdentidad(Catalog6.DNI.getCode())
- .numeroDocumentoIdentidad("12345678")
- .nombre("Mi Cliente S.A.C.")
- .build()
- )
- .documentoAdicional(DocumentoAdicional.builder()
- .tipoDocumento("09")
- .numero("T001-1")
- .rucEmisor("20100010001")
- .tipoDocumentoDescripcion("GUIA REMISION")
- .build()
- )
- .envio(Envio.builder()
- .tipoTraslado(Catalog20.VENTA.getCode())
- .pesoTotal(new BigDecimal("100.50"))
- .pesoTotalUnidadMedida("KGM")
- .pesoItems(new BigDecimal("90.00"))
- .sustentoPeso("Empaque madera")
- .numeroDeBultos(10)
- .tipoModalidadTraslado(Catalog18.TRANSPORTE_PUBLICO.getCode())
- .fechaTraslado(dateProvider.now())
- .chofer(Driver.builder()
- .tipoDocumentoIdentidad(Catalog6.DNI.getCode())
- .numeroDocumentoIdentidad("11111111")
- .nombres("Juan")
- .apellidos("Perez")
- .licencia("Q1234567")
- .tipo("CONDUCTOR_PRINCIPAL")
- .build())
- .chofer(Driver.builder()
- .tipoDocumentoIdentidad(Catalog6.DNI.getCode())
- .numeroDocumentoIdentidad("22222222")
- .nombres("Jose")
- .apellidos("Gomez")
- .tipo("COPILOTO")
- .build())
- .contenedor("CONT-001")
- .contenedor("CONT-002")
- .vehiculo(Vehicle.builder()
- .placa("ABC-123")
- .numeroCirculacion("TUC-001")
- .numeroAutorizacion("AUTH-001")
- .codigoEmisor("MTC")
- .secundario(Vehicle.builder()
- .placa("CAR-456")
- .numeroCirculacion("TUC-SEC")
- .build()
- )
- .build()
- )
- .puerto(Puerto.builder()
- .codigo("CALLAO")
- .nombre("Puerto del Callao")
- .build()
- )
- .partida(Partida.builder()
- .direccion("Av. Origen 123")
- .ubigeo("010101")
- .codigoLocal("0001")
- .ruc("12345678912")
- .build()
- )
- .destino(Destino.builder()
- .direccion("Av. Destino 456")
- .ubigeo("020202")
- .codigoLocal("0002")
- .ruc("87654321098")
- .build()
- )
- .build()
- )
- .detalle(DespatchAdviceItem.builder()
- .cantidad(new BigDecimal("5.00"))
- .unidadMedida("NIU")
- .codigo("ITEM-01")
- .descripcion("Caja de herramientas")
- .atributo(GuiaItemAttribute.builder()
- .name("Color")
- .code("1001")
- .value("Rojo")
- .build()
- )
- .atributo(GuiaItemAttribute.builder()
- .name("Marca")
- .code("1002")
- .value("ToolMaster")
- .build()
- )
- .build()
- )
- .build();
+ @Test
+ public void testComplexData() throws Exception {
+ // Given
+ DespatchAdvice input = DespatchAdvice.builder()
+ .serie("T001")
+ .numero(100)
+ .version("2.1")
+ .tipoComprobante(Catalog1.GUIA_REMISION_REMITENTE.getCode())
+ .remitente(Remitente.builder()
+ .ruc("12345678912")
+ .razonSocial("Softgreen S.A.C.")
+ .build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad(Catalog6.DNI.getCode())
+ .numeroDocumentoIdentidad("12345678")
+ .nombre("Mi Cliente S.A.C.")
+ .build())
+ .comprador(Comprador.builder()
+ .tipoDocumentoIdentidad(Catalog6.DNI.getCode())
+ .numeroDocumentoIdentidad("12345678")
+ .nombre("Mi Cliente S.A.C.")
+ .build())
+ .documentoAdicional(DocumentoAdicional.builder()
+ .tipoDocumento("09")
+ .numero("T001-1")
+ .rucEmisor("20100010001")
+ .tipoDocumentoDescripcion("GUIA REMISION")
+ .build())
+ .envio(Envio.builder()
+ .tipoTraslado(Catalog20.VENTA.getCode())
+ .pesoTotal(new BigDecimal("100.50"))
+ .pesoTotalUnidadMedida("KGM")
+ .pesoItems(new BigDecimal("90.00"))
+ .sustentoPeso("Empaque madera")
+ .numeroDeBultos(10)
+ .tipoModalidadTraslado(Catalog18.TRANSPORTE_PUBLICO.getCode())
+ .fechaTraslado(dateProvider.now())
+ .chofer(Driver.builder()
+ .tipoDocumentoIdentidad(Catalog6.DNI.getCode())
+ .numeroDocumentoIdentidad("11111111")
+ .nombres("Juan")
+ .apellidos("Perez")
+ .licencia("Q1234567")
+ .tipo("CONDUCTOR_PRINCIPAL")
+ .build())
+ .chofer(Driver.builder()
+ .tipoDocumentoIdentidad(Catalog6.DNI.getCode())
+ .numeroDocumentoIdentidad("22222222")
+ .nombres("Jose")
+ .apellidos("Gomez")
+ .tipo("COPILOTO")
+ .build())
+ .contenedor(Contenedor.builder()
+ .numero("1")
+ .precinto("CONT-001")
+ .build())
+ .contenedor(Contenedor.builder()
+ .numero("2")
+ .precinto("CONT-002")
+ .build())
+ .vehiculo(Vehicle.builder()
+ .placa("ABC-123")
+ .numeroCirculacion("TUC-001")
+ .numeroAutorizacion("AUTH-001")
+ .codigoEmisor("MTC")
+ .secundario(Vehicle.builder()
+ .placa("CAR-456")
+ .numeroCirculacion("TUC-SEC")
+ .build())
+ .build())
+ .puerto(Puerto.builder()
+ .codigo("CALLAO")
+ .nombre("Puerto del Callao")
+ .build())
+ .partida(Partida.builder()
+ .direccion("Av. Origen 123")
+ .ubigeo("010101")
+ .codigoLocal("0001")
+ .ruc("12345678912")
+ .build())
+ .destino(Destino.builder()
+ .direccion("Av. Destino 456")
+ .ubigeo("020202")
+ .codigoLocal("0002")
+ .ruc("87654321098")
+ .build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(new BigDecimal("5.00"))
+ .unidadMedida("NIU")
+ .codigo("ITEM-01")
+ .descripcion("Caja de herramientas")
+ .atributo(GuiaItemAttribute.builder()
+ .name("Color")
+ .code("1001")
+ .value("Rojo")
+ .build())
+ .atributo(GuiaItemAttribute.builder()
+ .name("Marca")
+ .code("1002")
+ .value("ToolMaster")
+ .build())
+ .build())
+ .build();
- assertInput(input, "complexData.xml");
- }
+ assertInput(input, "complexData.xml");
+ }
}
diff --git a/xbuilder/core/src/test/java/e2e/renderer/despatchadvice/GRERemitenteCasuisticasTest.java b/xbuilder/core/src/test/java/e2e/renderer/despatchadvice/GRERemitenteCasuisticasTest.java
new file mode 100644
index 00000000..a95eb5d4
--- /dev/null
+++ b/xbuilder/core/src/test/java/e2e/renderer/despatchadvice/GRERemitenteCasuisticasTest.java
@@ -0,0 +1,481 @@
+package e2e.renderer.despatchadvice;
+
+import e2e.AbstractTest;
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog18;
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog20;
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog6;
+import io.github.project.openubl.xbuilder.content.catalogs.IndicadorEnvio;
+import io.github.project.openubl.xbuilder.content.catalogs.TipoConductor;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.*;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+
+/**
+ * Tests de GRE-Remitente usando el modelo {@link GRERemitente}.
+ *
+ * Casuísticas cubiertas:
+ *
+ * - Transporte privado básico (conductor + vehículo, solo placa)
+ * - Transporte público (transportista contratado consignado en
+ * GRE-Remitente)
+ * - Transporte privado con múltiples conductores (relevo)
+ * - Transporte privado con vehículo principal + secundarios (TUC
+ * condicional)
+ * - Transporte público con transportista consignado en GRE-Remitente —
+ * NO confundir con GRE-Transportista (que emite su propia guía V*)
+ * - Vehículo categoría M1/L — indicador
+ * SUNAT_Envio_IndicadorTrasladoVehiculoM1L
+ * (sin conductor ni vehículo, distinto de transbordo programado)
+ * - Mercancía extranjera motivo 19 desde zona primaria (contenedores +
+ * precintos)
+ *
+ */
+public class GRERemitenteCasuisticasTest extends AbstractTest {
+
+ /**
+ * Casuística 1: Transporte PRIVADO básico.
+ *
+ * El remitente traslada sus propios bienes con su propio vehículo.
+ * Requiere: conductor principal + vehículo (solo placa, TUC no es
+ * obligatorio salvo inscripción ante el MTC).
+ */
+ @Test
+ public void testTransportePrivadoBasico() throws Exception {
+ GRERemitente gre = GRERemitente.builder()
+ .serie("T001")
+ .numero(1)
+ .remitente(Remitente.builder()
+ .ruc("20100010001")
+ .razonSocial("Empresa Remitente S.A.C.")
+ .build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20200020002")
+ .nombre("Almacén Destino S.A.C.")
+ .build())
+ .envio(Envio.builder()
+ .tipoTraslado(Catalog20.VENTA.getCode())
+ .pesoTotal(new BigDecimal("50.000"))
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado(Catalog18.TRANSPORTE_PRIVADO.getCode())
+ .fechaTraslado(dateProvider.now())
+ .chofer(Driver.builder()
+ .tipo(TipoConductor.PRINCIPAL.getCode())
+ .tipoDocumentoIdentidad(Catalog6.DNI.getCode())
+ .numeroDocumentoIdentidad("12345678")
+ .nombres("Carlos")
+ .apellidos("Ramirez")
+ .licencia("Q1234567")
+ .build())
+ .vehiculo(Vehicle.builder()
+ .placa("ABC-123")
+ .build())
+ .partida(Partida.builder()
+ .ubigeo("150101")
+ .direccion("Av. Industrial 456, Lima")
+ .build())
+ .destino(Destino.builder()
+ .ubigeo("150102")
+ .direccion("Jr. Comercio 789, Rímac")
+ .build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(new BigDecimal("10.00"))
+ .unidadMedida("NIU")
+ .codigo("PROD-001")
+ .descripcion("Cajas de producto terminado")
+ .build())
+ .build();
+
+ assertInput(gre, "transportePrivadoBasico.xml");
+ }
+
+ /**
+ * Casuística 2: Transporte PÚBLICO.
+ *
+ * El remitente contrata a un transportista.
+ * Requiere: datos del transportista (RUC, razón social).
+ * No requiere conductor/vehículo del remitente (lo provee el transportista).
+ */
+ @Test
+ public void testTransportePublico() throws Exception {
+ GRERemitente gre = GRERemitente.builder()
+ .serie("T001")
+ .numero(2)
+ .remitente(Remitente.builder()
+ .ruc("20100010001")
+ .razonSocial("Empresa Remitente S.A.C.")
+ .build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20200020002")
+ .nombre("Cliente Final S.A.")
+ .build())
+ .envio(Envio.builder()
+ .tipoTraslado(Catalog20.VENTA.getCode())
+ .pesoTotal(new BigDecimal("200.000"))
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado(Catalog18.TRANSPORTE_PUBLICO.getCode())
+ .fechaTraslado(dateProvider.now())
+ .transportista(Transportista.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20300030003")
+ .nombre("Transportes Nacionales S.A.C.")
+ .numeroRegistroMTC("MTC-001234")
+ .build())
+ .partida(Partida.builder()
+ .ubigeo("150101")
+ .direccion("Almacén Central, Lima")
+ .build())
+ .destino(Destino.builder()
+ .ubigeo("040101")
+ .direccion("Sucursal Arequipa")
+ .build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(new BigDecimal("50.00"))
+ .unidadMedida("NIU")
+ .codigo("PROD-002")
+ .descripcion("Mercadería general")
+ .build())
+ .build();
+
+ assertInput(gre, "transportePublico.xml");
+ }
+
+ /**
+ * Casuística 3: Transporte privado con MÚLTIPLES CONDUCTORES.
+ *
+ * Según SUNAT, se pueden consignar conductores adicionales
+ * (copiloto, relevo) además del conductor principal.
+ */
+ @Test
+ public void testMultiplesConductores() throws Exception {
+ GRERemitente gre = GRERemitente.builder()
+ .serie("T001")
+ .numero(3)
+ .remitente(Remitente.builder()
+ .ruc("20100010001")
+ .razonSocial("Empresa Remitente S.A.C.")
+ .build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20200020002")
+ .nombre("Destino S.A.C.")
+ .build())
+ .envio(Envio.builder()
+ .tipoTraslado(Catalog20.TRASLADO_ENTRE_ESTABLECIMIENTOS.getCode())
+ .pesoTotal(new BigDecimal("1500.000"))
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado(Catalog18.TRANSPORTE_PRIVADO.getCode())
+ .fechaTraslado(dateProvider.now())
+ .chofer(Driver.builder()
+ .tipo(TipoConductor.PRINCIPAL.getCode())
+ .tipoDocumentoIdentidad(Catalog6.DNI.getCode())
+ .numeroDocumentoIdentidad("11111111")
+ .nombres("Juan")
+ .apellidos("Perez")
+ .licencia("Q1111111")
+ .build())
+ .chofer(Driver.builder()
+ .tipo(TipoConductor.SECUNDARIO.getCode())
+ .tipoDocumentoIdentidad(Catalog6.DNI.getCode())
+ .numeroDocumentoIdentidad("22222222")
+ .nombres("Pedro")
+ .apellidos("Gomez")
+ .licencia("Q2222222")
+ .build())
+ .vehiculo(Vehicle.builder()
+ .placa("DEF-456")
+ .build())
+ .partida(Partida.builder()
+ .ubigeo("150101")
+ .direccion("Planta Principal, Lima")
+ .build())
+ .destino(Destino.builder()
+ .ubigeo("130101")
+ .direccion("Sucursal Tacna")
+ .build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(new BigDecimal("100.00"))
+ .unidadMedida("KGM")
+ .codigo("MAT-001")
+ .descripcion("Materia prima")
+ .build())
+ .build();
+
+ assertInput(gre, "multiplesConductores.xml");
+ }
+
+ /**
+ * Casuística 4: Transporte privado con VEHÍCULO + CARRETA (secundarios).
+ *
+ * El vehículo principal puede llevar vehículos secundarios adjuntos
+ * (carretas, semirremolques) con sus propias placas.
+ *
+ * El TUC (Tarjeta Única de Circulación) es condicional: solo se consigna
+ * cuando el vehículo tiene obligación de inscripción ante el MTC
+ * (vehículos de transporte de carga pesada). No es dato universal.
+ */
+ @Test
+ public void testVehiculoConCarreta() throws Exception {
+ GRERemitente gre = GRERemitente.builder()
+ .serie("T001")
+ .numero(4)
+ .remitente(Remitente.builder()
+ .ruc("20100010001")
+ .razonSocial("Empresa Remitente S.A.C.")
+ .build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20200020002")
+ .nombre("Destino S.A.C.")
+ .build())
+ .envio(Envio.builder()
+ .tipoTraslado(Catalog20.VENTA.getCode())
+ .pesoTotal(new BigDecimal("8000.000"))
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado(Catalog18.TRANSPORTE_PRIVADO.getCode())
+ .fechaTraslado(dateProvider.now())
+ .chofer(Driver.builder()
+ .tipo(TipoConductor.PRINCIPAL.getCode())
+ .tipoDocumentoIdentidad(Catalog6.DNI.getCode())
+ .numeroDocumentoIdentidad("33333333")
+ .nombres("Roberto")
+ .apellidos("Silva")
+ .licencia("Q3333333")
+ .build())
+ .vehiculo(Vehicle.builder()
+ .placa("GHI-789")
+ .numeroCirculacion("TUC-MAIN") // TUC: vehículo inscrito ante MTC
+ .numeroAutorizacion("AUTH-001") // Dato de ejemplo condicional, no obligatorio en todo
+ // vehículo
+ .codigoEmisor("MTC") // Dato de ejemplo condicional, asociado a numeroAutorizacion
+ .secundario(Vehicle.builder()
+ .placa("CAR-001")
+ .numeroCirculacion("TUC-SEC1") // Carreta también inscrita
+ .build())
+ .secundario(Vehicle.builder()
+ .placa("CAR-002")
+ // Sin TUC: carreta sin obligación de inscripción
+ .build())
+ .build())
+ .partida(Partida.builder()
+ .ubigeo("150101")
+ .direccion("Centro de distribución, Lima")
+ .build())
+ .destino(Destino.builder()
+ .ubigeo("060101")
+ .direccion("Depósito Cajamarca")
+ .build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(new BigDecimal("500.00"))
+ .unidadMedida("KGM")
+ .codigo("FERT-001")
+ .descripcion("Fertilizantes")
+ .build())
+ .build();
+
+ assertInput(gre, "vehiculoConCarreta.xml");
+ }
+
+ /**
+ * Casuística 5: Transporte público — transportista consignado en GRE-Remitente.
+ *
+ * El remitente contrata un servicio de transporte público y consigna los
+ * datos del transportista contratado (CarrierParty) en su GRE-Remitente.
+ *
+ * IMPORTANTE: Este XML refleja solo la perspectiva del remitente.
+ * El transportista efectivo que realiza el traslado debe emitir su propia
+ * GRE-Transportista (serie V*) con conductor, vehículo y referencia a esta GRE.
+ * En transporte público, la GRE-Remitente y la GRE-Transportista pueden
+ * coexistir para el mismo traslado, según corresponda al sujeto obligado
+ * y al flujo operativo aplicable.
+ * traslado.
+ *
+ * Ref: RS 000123-2022/SUNAT — relación entre GRE-Remitente y GRE-Transportista.
+ */
+ @Test
+ public void testTransporteSubcontratado() throws Exception {
+ GRERemitente gre = GRERemitente.builder()
+ .serie("T001")
+ .numero(5)
+ .remitente(Remitente.builder()
+ .ruc("20100010001")
+ .razonSocial("Empresa Remitente S.A.C.")
+ .build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20200020002")
+ .nombre("Tienda Destino S.A.")
+ .build())
+ .envio(Envio.builder()
+ .tipoTraslado(Catalog20.VENTA.getCode())
+ .pesoTotal(new BigDecimal("3000.000"))
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado(Catalog18.TRANSPORTE_PUBLICO.getCode())
+ .fechaTraslado(dateProvider.now())
+ .transportista(Transportista.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20400040004")
+ .nombre("Logística Express S.A.C.")
+ .numeroRegistroMTC("MTC-567890")
+ .build())
+ .partida(Partida.builder()
+ .ubigeo("150101")
+ .direccion("Almacén Principal, Lima")
+ .codigoLocal("0001")
+ .ruc("20100010001")
+ .build())
+ .destino(Destino.builder()
+ .ubigeo("150132")
+ .direccion("Tienda San Isidro")
+ .build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(new BigDecimal("200.00"))
+ .unidadMedida("NIU")
+ .codigo("ELEC-001")
+ .descripcion("Equipos electrónicos")
+ .build())
+ .build();
+
+ assertInput(gre, "transporteSubcontratado.xml");
+ }
+
+ /**
+ * Casuística 6: Transporte privado en vehículo de categoría M1 o L.
+ *
+ * Cuando el traslado se realiza en vehículos de categoría M1 (automóviles)
+ * o categoría L (motocicletas, mototaxis), SUNAT permite no consignar
+ * conductor ni datos del vehículo.
+ *
+ * Se utiliza el indicador {@code SUNAT_Envio_IndicadorTrasladoVehiculoM1L}.
+ *
+ * PENDIENTE DE VALIDACIÓN DOCUMENTAL: El token exacto
+ * {@code SUNAT_Envio_IndicadorTrasladoVehiculoM1L} debe confirmarse contra
+ * el Anexo N.° 14 o el catálogo interno del portal SUNAT. La separación
+ * conceptual respecto de {@code SUNAT_Envio_IndicadorTransbordoProgramado}
+ * es correcta, pero el literal del indicador M1/L queda sujeto a
+ * verificación final antes de considerar este caso como cerrado.
+ *
+ * Ref: Anexo N.° 14 UBL 2.1, RS 000123-2022/SUNAT.
+ */
+ @Test
+ public void testVehiculoM1L() throws Exception {
+ GRERemitente gre = GRERemitente.builder()
+ .serie("T001")
+ .numero(6)
+ .remitente(Remitente.builder()
+ .ruc("20100010001")
+ .razonSocial("Empresa Remitente S.A.C.")
+ .build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad(Catalog6.DNI.getCode())
+ .numeroDocumentoIdentidad("87654321")
+ .nombre("Persona Natural")
+ .build())
+ .envio(Envio.builder()
+ .tipoTraslado(Catalog20.VENTA.getCode())
+ .pesoTotal(new BigDecimal("5.000"))
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado(Catalog18.TRANSPORTE_PRIVADO.getCode())
+ .fechaTraslado(dateProvider.now())
+ .indicador(IndicadorEnvio.VEHICULO_M1_L.getCode())
+ .partida(Partida.builder()
+ .ubigeo("150101")
+ .direccion("Tienda Lima Centro")
+ .build())
+ .destino(Destino.builder()
+ .ubigeo("150101")
+ .direccion("Domicilio Cliente")
+ .build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(new BigDecimal("1.00"))
+ .unidadMedida("NIU")
+ .codigo("PEQ-001")
+ .descripcion("Paquete pequeño")
+ .build())
+ .build();
+
+ assertInput(gre, "vehiculoM1L.xml");
+ }
+
+ /**
+ * Casuística 7: Mercancía extranjera — motivo 19 desde zona primaria.
+ *
+ * RS 000240-2024/SUNAT creó el motivo "19 - Traslado de mercancía extranjera"
+ * específicamente para la trazabilidad de mercancía que sale de zona primaria
+ * (puerto/aeropuerto) hacia depósito temporal, sin destinación aduanera o sin
+ * levante.
+ *
+ * Los campos contenedor, precinto y el indicador de zona primaria son
+ * condicionales al motivo 19 del Catálogo 20 — no aplican a cualquier
+ * motivo de importación.
+ *
+ * NOTA: La obligatoriedad plena del motivo 19 (en reemplazo del ticket
+ * de salida) fue pospuesta al 01-jul-2026 por RS 000133-2025/SUNAT.
+ * Hasta esa fecha coexisten el ticket de salida y la GRE con motivo 19.
+ */
+ @Test
+ public void testMercanciaExtranjeraMotivo19() throws Exception {
+ GRERemitente gre = GRERemitente.builder()
+ .serie("T001")
+ .numero(7)
+ .remitente(Remitente.builder()
+ .ruc("20500050005")
+ .razonSocial("Depósito Temporal del Callao S.A.")
+ .build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20500050005")
+ .nombre("Depósito Temporal del Callao S.A.")
+ .build())
+ .envio(Envio.builder()
+ .tipoTraslado(Catalog20.TRASLADO_MERCANCIA_EXTRANJERA.getCode()) // "19"
+ .pesoTotal(new BigDecimal("12000.000"))
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado(Catalog18.TRANSPORTE_PUBLICO.getCode())
+ .fechaTraslado(dateProvider.now())
+ .transportista(Transportista.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20600060006")
+ .nombre("Transporte Portuario S.A.C.")
+ .build())
+ .indicador(IndicadorEnvio.TRASLADO_TOTAL_DAM_DS.getCode())
+ .contenedor(Contenedor.builder()
+ .numero("MSKU-2024-001")
+ .precinto("SEAL-001")
+ .build())
+ .contenedor(Contenedor.builder()
+ .numero("MSKU-2024-002")
+ .precinto("SEAL-002")
+ .build())
+ .puerto(Puerto.builder()
+ .codigo("CALLAO")
+ .nombre("Puerto del Callao")
+ .build())
+ .partida(Partida.builder()
+ .ubigeo("070101")
+ .direccion("Terminal Portuario del Callao")
+ .build())
+ .destino(Destino.builder()
+ .ubigeo("150101")
+ .direccion("Almacén Depósito Temporal")
+ .build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(new BigDecimal("1.00"))
+ .unidadMedida("ZZ")
+ .codigo("CONT-CONSOLIDADO")
+ .descripcion("Contenedor carga consolidada - mercancía extranjera")
+ .build())
+ .build();
+
+ assertInput(gre, "mercanciaExtranjeraMotivo19.xml");
+ }
+}
diff --git a/xbuilder/core/src/test/java/e2e/renderer/despatchadvice/GRETransportistaCasuisticasTest.java b/xbuilder/core/src/test/java/e2e/renderer/despatchadvice/GRETransportistaCasuisticasTest.java
new file mode 100644
index 00000000..070675a7
--- /dev/null
+++ b/xbuilder/core/src/test/java/e2e/renderer/despatchadvice/GRETransportistaCasuisticasTest.java
@@ -0,0 +1,326 @@
+package e2e.renderer.despatchadvice;
+
+import e2e.AbstractTest;
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog18;
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog20;
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog6;
+import io.github.project.openubl.xbuilder.content.catalogs.IndicadorEnvio;
+import io.github.project.openubl.xbuilder.content.catalogs.TipoConductor;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.*;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+
+/**
+ * Tests de GRE-Transportista usando el modelo {@link GRETransportista}.
+ *
+ * Casuísticas cubiertas:
+ *
+ * - Transportista básico (un conductor, un vehículo)
+ * - Transportista con múltiples conductores (relevo)
+ * - Transportista con vehículo + carreta
+ * - Transportista en comercio exterior con contenedores
+ *
+ */
+public class GRETransportistaCasuisticasTest extends AbstractTest {
+
+ /**
+ * Casuística 1: GRE-Transportista BÁSICA.
+ *
+ * El transportista emite la guía para trasladar bienes de un cliente.
+ * En esta casuística básica se consigna conductor y vehículo.
+ */
+ @Test
+ public void testTransportistaBasico() throws Exception {
+ GRETransportista gre = GRETransportista.builder()
+ .serie("V001")
+ .numero(1)
+ .transportistaEmisor(Transportista.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20300030003")
+ .nombre("Transportes Rápidos S.A.C.")
+ .numeroRegistroMTC("MTC-001234")
+ .build())
+ .remitente(Tercero.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20100010001")
+ .nombre("Empresa Remitente S.A.C.")
+ .build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20200020002")
+ .nombre("Cliente Final S.A.")
+ .build())
+ .conductor(Driver.builder()
+ .tipo(TipoConductor.PRINCIPAL.getCode())
+ .tipoDocumentoIdentidad(Catalog6.DNI.getCode())
+ .numeroDocumentoIdentidad("44444444")
+ .nombres("Miguel")
+ .apellidos("Torres")
+ .licencia("Q4444444")
+ .build())
+ .vehiculo(Vehicle.builder()
+ .placa("JKL-012")
+ .numeroCirculacion("TUC-JKL") // TUC: transportista inscrito ante MTC
+ .build())
+ .envio(Envio.builder()
+ .tipoTraslado(Catalog20.VENTA.getCode())
+ .pesoTotal(new BigDecimal("300.000"))
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado(Catalog18.TRANSPORTE_PUBLICO.getCode())
+ .fechaTraslado(dateProvider.now())
+ .partida(Partida.builder()
+ .ubigeo("150101")
+ .direccion("Almacén Remitente, Lima")
+ .build())
+ .destino(Destino.builder()
+ .ubigeo("040101")
+ .direccion("Sucursal Arequipa")
+ .build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(new BigDecimal("25.00"))
+ .unidadMedida("NIU")
+ .codigo("MER-001")
+ .descripcion("Mercadería general")
+ .build())
+ .build();
+
+ assertInput(gre, "transportistaBasico.xml");
+ }
+
+ /**
+ * Casuística 2: GRE-Transportista con MÚLTIPLES CONDUCTORES.
+ *
+ * Para viajes largos se requiere conductor de relevo.
+ */
+ @Test
+ public void testTransportistaMultiplesConductores() throws Exception {
+ GRETransportista gre = GRETransportista.builder()
+ .serie("V001")
+ .numero(2)
+ .transportistaEmisor(Transportista.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20300030003")
+ .nombre("Transportes Rápidos S.A.C.")
+ .numeroRegistroMTC("MTC-001234")
+ .build())
+ .remitente(Tercero.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20100010001")
+ .nombre("Empresa Remitente S.A.C.")
+ .build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20200020002")
+ .nombre("Destino S.A.")
+ .build())
+ .conductor(Driver.builder()
+ .tipo(TipoConductor.PRINCIPAL.getCode())
+ .tipoDocumentoIdentidad(Catalog6.DNI.getCode())
+ .numeroDocumentoIdentidad("55555555")
+ .nombres("Luis")
+ .apellidos("Fernandez")
+ .licencia("Q5555555")
+ .build())
+ .conductor(Driver.builder()
+ .tipo(TipoConductor.SECUNDARIO.getCode())
+ .tipoDocumentoIdentidad(Catalog6.DNI.getCode())
+ .numeroDocumentoIdentidad("66666666")
+ .nombres("Mario")
+ .apellidos("Vargas")
+ .licencia("Q6666666")
+ .build())
+ .vehiculo(Vehicle.builder()
+ .placa("MNO-345")
+ .numeroCirculacion("TUC-MNO") // TUC: transportista inscrito ante MTC
+ .build())
+ .envio(Envio.builder()
+ .tipoTraslado(Catalog20.VENTA.getCode())
+ .pesoTotal(new BigDecimal("5000.000"))
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado(Catalog18.TRANSPORTE_PUBLICO.getCode())
+ .fechaTraslado(dateProvider.now())
+ .partida(Partida.builder()
+ .ubigeo("150101")
+ .direccion("Centro de Carga Lima")
+ .build())
+ .destino(Destino.builder()
+ .ubigeo("130101")
+ .direccion("Terminal Tacna")
+ .build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(new BigDecimal("300.00"))
+ .unidadMedida("KGM")
+ .codigo("CARG-001")
+ .descripcion("Carga general")
+ .build())
+ .build();
+
+ assertInput(gre, "transportistaMultiplesConductores.xml");
+ }
+
+ /**
+ * Casuística 3: GRE-Transportista con VEHÍCULO + CARRETA.
+ *
+ * Vehículo principal con semirremolque adjunto. Los datos de autorización
+ * y código de entidad autorizadora se consignan como datos de ejemplo
+ * para esta casuística; son campos contextuales según el tipo de vehículo
+ * y autorización vigente, no universalmente obligatorios.
+ */
+ @Test
+ public void testTransportistaConCarreta() throws Exception {
+ GRETransportista gre = GRETransportista.builder()
+ .serie("V001")
+ .numero(3)
+ .transportistaEmisor(Transportista.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20300030003")
+ .nombre("Transportes Pesados S.A.C.")
+ .numeroRegistroMTC("MTC-PESADOS")
+ .build())
+ .remitente(Tercero.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20100010001")
+ .nombre("Minera del Sur S.A.")
+ .build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20200020002")
+ .nombre("Fundición Norte S.A.")
+ .build())
+ .conductor(Driver.builder()
+ .tipo(TipoConductor.PRINCIPAL.getCode())
+ .tipoDocumentoIdentidad(Catalog6.DNI.getCode())
+ .numeroDocumentoIdentidad("77777777")
+ .nombres("Andres")
+ .apellidos("Quispe")
+ .licencia("Q7777777")
+ .build())
+ .vehiculo(Vehicle.builder()
+ .placa("PQR-678")
+ .numeroCirculacion("TUC-PQR")
+ .numeroAutorizacion("AUTH-PESADOS") // Dato de ejemplo condicional, no obligatorio en todo
+ // vehículo
+ .codigoEmisor("MTC") // Dato de ejemplo condicional, asociado a numeroAutorizacion
+ .secundario(Vehicle.builder()
+ .placa("REM-001")
+ .numeroCirculacion("TUC-REM")
+ .build())
+ .build())
+ .envio(Envio.builder()
+ .tipoTraslado(Catalog20.VENTA.getCode())
+ .pesoTotal(new BigDecimal("20000.000"))
+ .pesoTotalUnidadMedida("KGM")
+ .numeroDeBultos(1)
+ .tipoModalidadTraslado(Catalog18.TRANSPORTE_PUBLICO.getCode())
+ .fechaTraslado(dateProvider.now())
+ .partida(Partida.builder()
+ .ubigeo("040101")
+ .direccion("Mina del Sur, Arequipa")
+ .build())
+ .destino(Destino.builder()
+ .ubigeo("060101")
+ .direccion("Planta procesadora, Cajamarca")
+ .build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(new BigDecimal("20000.00"))
+ .unidadMedida("KGM")
+ .codigo("MIN-001")
+ .descripcion("Concentrado de cobre")
+ .build())
+ .build();
+
+ assertInput(gre, "transportistaConCarreta.xml");
+ }
+
+ /**
+ * Casuística 4: GRE-Transportista en COMERCIO EXTERIOR — mercancía extranjera
+ * motivo 19.
+ *
+ * Traslado de contenedores desde zona primaria (puerto) a depósito temporal.
+ * El motivo 19 fue introducido por RS 000240-2024/SUNAT dentro del esquema de
+ * trazabilidad de comercio exterior. RS 000133-2025/SUNAT movió al
+ * 01-jul-2026 la entrada en vigor de la disposición derogatoria del uso
+ * exclusivo del ticket de salida, vinculada a este motivo.
+ *
+ * Este caso valida el soporte de modelo/render para: contenedores con precinto,
+ * puerto e indicador de traslado total. Es una casuística de modelado soportada
+ * y no un ejemplo exhaustivo de todos los campos posibles del motivo 19.
+ *
+ * Nota: la inclusión de {@code declaracionAduanera} en este escenario
+ * específico
+ * depende del mapeo exacto del Anexo 14 para la variante motivo 19 con traslado
+ * total desde zona primaria. Se omite aquí hasta confirmar su obligatoriedad
+ * para esta casuística concreta.
+ */
+ @Test
+ public void testTransportistaComercioExterior() throws Exception {
+ GRETransportista gre = GRETransportista.builder()
+ .serie("V001")
+ .numero(4)
+ .transportistaEmisor(Transportista.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20700070007")
+ .nombre("Transportes Portuarios S.A.C.")
+ .numeroRegistroMTC("MTC-PORT-001")
+ .build())
+ .remitente(Tercero.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20500050005")
+ .nombre("Importadora Nacional S.A.C.")
+ .build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20500050005")
+ .nombre("Importadora Nacional S.A.C.")
+ .build())
+ .conductor(Driver.builder()
+ .tipo(TipoConductor.PRINCIPAL.getCode())
+ .tipoDocumentoIdentidad(Catalog6.DNI.getCode())
+ .numeroDocumentoIdentidad("88888888")
+ .nombres("Fernando")
+ .apellidos("Ruiz")
+ .licencia("Q8888888")
+ .build())
+ .vehiculo(Vehicle.builder()
+ .placa("STU-901")
+ .numeroCirculacion("TUC-STU") // TUC: transportista inscrito ante MTC
+ .build())
+ .envio(Envio.builder()
+ .tipoTraslado(Catalog20.TRASLADO_MERCANCIA_EXTRANJERA.getCode()) // "19"
+ .pesoTotal(new BigDecimal("15000.000"))
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado(Catalog18.TRANSPORTE_PUBLICO.getCode())
+ .fechaTraslado(dateProvider.now())
+ .indicador(IndicadorEnvio.TRASLADO_TOTAL_DAM_DS.getCode())
+ .contenedor(Contenedor.builder()
+ .numero("CONT-IMP-001")
+ .precinto("SEAL-IMP-001")
+ .build())
+ .puerto(Puerto.builder()
+ .codigo("CALLAO")
+ .nombre("Puerto del Callao")
+ .build())
+ .partida(Partida.builder()
+ .ubigeo("070101")
+ .direccion("Terminal Portuario del Callao")
+ .build())
+ .destino(Destino.builder()
+ .ubigeo("150101")
+ .direccion("Almacén Lima")
+ .build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(new BigDecimal("1.00"))
+ .unidadMedida("ZZ")
+ .codigo("CARGA-EXT-001")
+ .descripcion("Carga consolidada comercio exterior")
+ .build())
+ .build();
+
+ assertInput(gre, "transportistaComercioExterior.xml");
+ }
+}
diff --git a/xbuilder/core/src/test/java/io/github/project/openubl/xbuilder/content/models/standard/general/InvoiceValidatorTest.java b/xbuilder/core/src/test/java/io/github/project/openubl/xbuilder/content/models/standard/general/InvoiceValidatorTest.java
new file mode 100644
index 00000000..8006fc46
--- /dev/null
+++ b/xbuilder/core/src/test/java/io/github/project/openubl/xbuilder/content/models/standard/general/InvoiceValidatorTest.java
@@ -0,0 +1,474 @@
+package io.github.project.openubl.xbuilder.content.models.standard.general;
+
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog51;
+import io.github.project.openubl.xbuilder.content.models.common.Cliente;
+import io.github.project.openubl.xbuilder.content.models.common.Firmante;
+import io.github.project.openubl.xbuilder.content.models.common.Proveedor;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.validation.ValidationResult;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests de validación para Factura Electrónica y Boleta de Venta Electrónica.
+ *
+ * Casos de prueba basados en:
+ *
+ * - Guía XML Factura 2.1 – SUNAT
+ * - Guía XML Boleta 2.1 – SUNAT
+ * - Reglas de Validación CPE vigentes
+ *
+ */
+@DisplayName("InvoiceValidator")
+class InvoiceValidatorTest {
+
+ // ── Fixtures de datos reales ─────────────────────────────────
+
+ private static Proveedor proveedorValido() {
+ return Proveedor.builder()
+ .ruc("20601234567")
+ .razonSocial("EMPRESA DE PRUEBA SAC")
+ .build();
+ }
+
+ private static Firmante firmanteValido() {
+ return Firmante.builder()
+ .ruc("20601234567")
+ .razonSocial("EMPRESA DE PRUEBA SAC")
+ .build();
+ }
+
+ private static Cliente clienteRuc() {
+ return Cliente.builder()
+ .nombre("ACME CORPORATION SAC")
+ .numeroDocumentoIdentidad("20100047218")
+ .tipoDocumentoIdentidad("6")
+ .build();
+ }
+
+ private static Cliente clienteDni() {
+ return Cliente.builder()
+ .nombre("JUAN PEREZ GARCIA")
+ .numeroDocumentoIdentidad("44556677")
+ .tipoDocumentoIdentidad("1")
+ .build();
+ }
+
+ private static DocumentoVentaDetalle lineaGravada() {
+ return DocumentoVentaDetalle.builder()
+ .descripcion("Laptop HP ProBook 450 G8")
+ .cantidad(BigDecimal.valueOf(2))
+ .precio(BigDecimal.valueOf(3500.00))
+ .codigoProducto("LAPTOP-HP-450")
+ .codigoProductoSunat("43211507")
+ .igvTipo("10")
+ .build();
+ }
+
+ private static DocumentoVentaDetalle lineaExonerada() {
+ return DocumentoVentaDetalle.builder()
+ .descripcion("Consulta médica general")
+ .cantidad(BigDecimal.ONE)
+ .precio(BigDecimal.valueOf(150.00))
+ .igvTipo("20")
+ .build();
+ }
+
+ private static DocumentoVentaDetalle lineaGratuita() {
+ return DocumentoVentaDetalle.builder()
+ .descripcion("Muestra gratis - Producto promocional")
+ .cantidad(BigDecimal.ONE)
+ .precio(BigDecimal.ZERO)
+ .igvTipo("31")
+ .build();
+ }
+
+ private static DocumentoVentaDetalle lineaConIcbper() {
+ return DocumentoVentaDetalle.builder()
+ .descripcion("Bolsa plástica")
+ .cantidad(BigDecimal.valueOf(3))
+ .precio(BigDecimal.valueOf(0.50))
+ .igvTipo("10")
+ .icbAplica(true)
+ .build();
+ }
+
+ private static Invoice facturaBase() {
+ return Invoice.builder()
+ .serie("F001")
+ .numero(1)
+ .fechaEmision(LocalDate.now())
+ .moneda("PEN")
+ .proveedor(proveedorValido())
+ .firmante(firmanteValido())
+ .cliente(clienteRuc())
+ .tipoOperacion(Catalog51.VENTA_INTERNA.getCode())
+ .detalle(lineaGravada())
+ .build();
+ }
+
+ private static Invoice boletaBase() {
+ return Invoice.builder()
+ .serie("B001")
+ .numero(1)
+ .fechaEmision(LocalDate.now())
+ .moneda("PEN")
+ .proveedor(proveedorValido())
+ .firmante(firmanteValido())
+ .cliente(clienteDni())
+ .tipoOperacion(Catalog51.VENTA_INTERNA.getCode())
+ .detalle(lineaGravada())
+ .build();
+ }
+
+ // ══════════════════════════════════════════════════════════════
+ // Factura Electrónica (01) — Casos válidos
+ // ══════════════════════════════════════════════════════════════
+
+ @Nested
+ @DisplayName("Factura válida - casos comunes")
+ class FacturaValidaTests {
+
+ @Test
+ @DisplayName("Factura gravada estándar - venta interna con IGV")
+ void facturaGravadaEstandar() {
+ Invoice invoice = facturaBase();
+ List errors = InvoiceValidator.validate(invoice);
+ assertTrue(errors.isEmpty(), "Factura gravada estándar debe ser válida: " + errors);
+ }
+
+ @Test
+ @DisplayName("Factura con operación exonerada (IGV tipo 20)")
+ void facturaExonerada() {
+ Invoice invoice = Invoice.builder()
+ .serie("F001")
+ .numero(1)
+ .fechaEmision(LocalDate.now())
+ .moneda("PEN")
+ .proveedor(proveedorValido())
+ .firmante(firmanteValido())
+ .cliente(clienteRuc())
+ .tipoOperacion(Catalog51.VENTA_INTERNA.getCode())
+ .detalle(lineaExonerada())
+ .build();
+ List errors = InvoiceValidator.validate(invoice);
+ assertTrue(errors.isEmpty(), "Factura exonerada debe ser válida: " + errors);
+ }
+
+ @Test
+ @DisplayName("Factura con líneas mixtas (gravada + exonerada + ICBPER)")
+ void facturaMixta() {
+ Invoice invoice = Invoice.builder()
+ .serie("F001")
+ .numero(1)
+ .fechaEmision(LocalDate.now())
+ .moneda("PEN")
+ .proveedor(proveedorValido())
+ .firmante(firmanteValido())
+ .cliente(clienteRuc())
+ .tipoOperacion(Catalog51.VENTA_INTERNA.getCode())
+ .detalles(List.of(lineaGravada(), lineaExonerada(), lineaConIcbper()))
+ .build();
+ List errors = InvoiceValidator.validate(invoice);
+ assertTrue(errors.isEmpty(), "Factura mixta debe ser válida: " + errors);
+ }
+
+ @Test
+ @DisplayName("Factura con detracción (tipo operación 1001)")
+ void facturaConDetraccion() {
+ Invoice invoice = Invoice.builder()
+ .serie("F001")
+ .numero(1)
+ .fechaEmision(LocalDate.now())
+ .moneda("PEN")
+ .proveedor(proveedorValido())
+ .firmante(firmanteValido())
+ .cliente(clienteRuc())
+ .tipoOperacion(Catalog51.OPERACION_SUJETA_A_DETRACCION.getCode())
+ .detalle(lineaGravada())
+ .detraccion(Detraccion.builder()
+ .medioDePago("001")
+ .cuentaBancaria("00-123-456789")
+ .tipoBienDetraido("004")
+ .porcentaje(BigDecimal.valueOf(0.08))
+ .monto(BigDecimal.valueOf(500))
+ .build())
+ .build();
+ List errors = InvoiceValidator.validate(invoice);
+ assertTrue(errors.isEmpty(), "Factura con detracción debe ser válida: " + errors);
+ }
+
+ @Test
+ @DisplayName("Factura con percepción (tipo operación 2001)")
+ void facturaConPercepcion() {
+ Invoice invoice = Invoice.builder()
+ .serie("F001")
+ .numero(1)
+ .fechaEmision(LocalDate.now())
+ .moneda("PEN")
+ .proveedor(proveedorValido())
+ .firmante(firmanteValido())
+ .cliente(clienteRuc())
+ .tipoOperacion(Catalog51.OPERACION_SUJETA_A_PERCEPCION.getCode())
+ .detalle(lineaGravada())
+ .percepcion(Percepcion.builder()
+ .tipo("51")
+ .porcentaje(BigDecimal.valueOf(0.02))
+ .build())
+ .build();
+ List errors = InvoiceValidator.validate(invoice);
+ assertTrue(errors.isEmpty(), "Factura con percepción debe ser válida: " + errors);
+ }
+
+ @Test
+ @DisplayName("Factura al crédito con cuotas")
+ void facturaCredito() {
+ Invoice invoice = Invoice.builder()
+ .serie("F001")
+ .numero(1)
+ .fechaEmision(LocalDate.now())
+ .moneda("PEN")
+ .proveedor(proveedorValido())
+ .firmante(firmanteValido())
+ .cliente(clienteRuc())
+ .tipoOperacion(Catalog51.VENTA_INTERNA.getCode())
+ .detalle(lineaGravada())
+ .formaDePago(FormaDePago.builder()
+ .tipo("Credito")
+ .total(BigDecimal.valueOf(4130.00))
+ .cuota(CuotaDePago.builder()
+ .importe(BigDecimal.valueOf(2065.00))
+ .fechaPago(LocalDate.now().plusDays(30))
+ .build())
+ .cuota(CuotaDePago.builder()
+ .importe(BigDecimal.valueOf(2065.00))
+ .fechaPago(LocalDate.now().plusDays(60))
+ .build())
+ .build())
+ .build();
+ List errors = InvoiceValidator.validate(invoice);
+ assertTrue(errors.isEmpty(), "Factura al crédito debe ser válida: " + errors);
+ }
+
+ @Test
+ @DisplayName("Factura con transferencia gratuita (IGV tipo 31)")
+ void facturaGratuita() {
+ Invoice invoice = Invoice.builder()
+ .serie("F001")
+ .numero(1)
+ .fechaEmision(LocalDate.now())
+ .moneda("PEN")
+ .proveedor(proveedorValido())
+ .firmante(firmanteValido())
+ .cliente(clienteRuc())
+ .tipoOperacion(Catalog51.VENTA_INTERNA.getCode())
+ .detalle(lineaGratuita())
+ .build();
+ List errors = InvoiceValidator.validate(invoice);
+ assertTrue(errors.isEmpty(), "Factura gratuita debe ser válida: " + errors);
+ }
+ }
+
+ // ══════════════════════════════════════════════════════════════
+ // Boleta de Venta (03) — Casos válidos
+ // ══════════════════════════════════════════════════════════════
+
+ @Nested
+ @DisplayName("Boleta válida - casos comunes")
+ class BoletaValidaTests {
+
+ @Test
+ @DisplayName("Boleta estándar con DNI")
+ void boletaEstandarConDni() {
+ Invoice boleta = boletaBase();
+ List errors = InvoiceValidator.validate(boleta);
+ assertTrue(errors.isEmpty(), "Boleta con DNI debe ser válida: " + errors);
+ }
+
+ @Test
+ @DisplayName("Boleta sin documento de identidad (monto bajo)")
+ void boletaSinDocumento() {
+ Invoice boleta = Invoice.builder()
+ .serie("B001")
+ .numero(1)
+ .fechaEmision(LocalDate.now())
+ .moneda("PEN")
+ .proveedor(proveedorValido())
+ .firmante(firmanteValido())
+ .cliente(Cliente.builder().nombre("CONSUMIDOR FINAL").build())
+ .tipoOperacion(Catalog51.VENTA_INTERNA.getCode())
+ .detalle(lineaGravada())
+ .build();
+ ValidationResult result = InvoiceValidator.validateDetailed(boleta);
+ assertTrue(result.getErrors().isEmpty(),
+ "Boleta sin documento para montos bajos no debe tener errores: " + result.getErrors());
+ }
+
+ @Test
+ @DisplayName("Boleta con ICBPER (bolsas de plástico)")
+ void boletaConIcbper() {
+ Invoice boleta = Invoice.builder()
+ .serie("B001")
+ .numero(1)
+ .fechaEmision(LocalDate.now())
+ .moneda("PEN")
+ .proveedor(proveedorValido())
+ .firmante(firmanteValido())
+ .cliente(clienteDni())
+ .tipoOperacion(Catalog51.VENTA_INTERNA.getCode())
+ .detalles(List.of(lineaGravada(), lineaConIcbper()))
+ .build();
+ List errors = InvoiceValidator.validate(boleta);
+ assertTrue(errors.isEmpty(), "Boleta con ICBPER debe ser válida: " + errors);
+ }
+ }
+
+ // ══════════════════════════════════════════════════════════════
+ // Casos inválidos — Errores de validación
+ // ══════════════════════════════════════════════════════════════
+
+ @Nested
+ @DisplayName("Casos inválidos")
+ class CasosInvalidosTests {
+
+ @Test
+ @DisplayName("Serie incorrecta para factura (B en lugar de F)")
+ void serieIncorrectaFactura() {
+ Invoice invoice = Invoice.builder()
+ .serie("B001")
+ .numero(1)
+ .moneda("PEN")
+ .proveedor(proveedorValido())
+ .cliente(clienteRuc())
+ .tipoComprobante("01")
+ .tipoOperacion(Catalog51.VENTA_INTERNA.getCode())
+ .detalle(lineaGravada())
+ .build();
+ List errors = InvoiceValidator.validate(invoice);
+ assertFalse(errors.isEmpty(), "Factura con serie B debe ser inválida");
+ assertTrue(errors.stream().anyMatch(e -> e.contains("serie")),
+ "Debe mencionar error de serie");
+ }
+
+ @Test
+ @DisplayName("Factura sin cliente")
+ void facturaSinCliente() {
+ Invoice invoice = Invoice.builder()
+ .serie("F001")
+ .numero(1)
+ .moneda("PEN")
+ .proveedor(proveedorValido())
+ .detalle(lineaGravada())
+ .build();
+ List errors = InvoiceValidator.validate(invoice);
+ assertTrue(errors.stream().anyMatch(e -> e.contains("cliente")),
+ "Debe requerir cliente");
+ }
+
+ @Test
+ @DisplayName("Factura sin detalles")
+ void facturaSinDetalles() {
+ Invoice invoice = Invoice.builder()
+ .serie("F001")
+ .numero(1)
+ .moneda("PEN")
+ .proveedor(proveedorValido())
+ .cliente(clienteRuc())
+ .build();
+ List errors = InvoiceValidator.validate(invoice);
+ assertTrue(errors.stream().anyMatch(e -> e.contains("detalle")),
+ "Debe requerir al menos una línea");
+ }
+
+ @Test
+ @DisplayName("Detracción en boleta debe ser error")
+ void detraccionEnBoleta() {
+ Invoice boleta = Invoice.builder()
+ .serie("B001")
+ .numero(1)
+ .moneda("PEN")
+ .proveedor(proveedorValido())
+ .cliente(clienteDni())
+ .tipoComprobante("03")
+ .tipoOperacion("1001")
+ .detalle(lineaGravada())
+ .detraccion(Detraccion.builder()
+ .medioDePago("001")
+ .cuentaBancaria("00-123-456789")
+ .tipoBienDetraido("004")
+ .porcentaje(BigDecimal.valueOf(0.08))
+ .monto(BigDecimal.valueOf(100))
+ .build())
+ .build();
+ List errors = InvoiceValidator.validate(boleta);
+ assertTrue(
+ errors.stream()
+ .anyMatch(e -> e.toLowerCase().contains("detracción")
+ || e.toLowerCase().contains("detraccion")),
+ "Detracción en boleta debe ser error");
+ }
+
+ @Test
+ @DisplayName("Detracción sin cuenta bancaria")
+ void detraccionSinCuenta() {
+ Invoice invoice = Invoice.builder()
+ .serie("F001")
+ .numero(1)
+ .moneda("PEN")
+ .proveedor(proveedorValido())
+ .cliente(clienteRuc())
+ .tipoOperacion("1001")
+ .detalle(lineaGravada())
+ .detraccion(Detraccion.builder()
+ .medioDePago("001")
+ .tipoBienDetraido("004")
+ .porcentaje(BigDecimal.valueOf(0.08))
+ .build())
+ .build();
+ List errors = InvoiceValidator.validate(invoice);
+ assertTrue(errors.stream().anyMatch(e -> e.contains("cuentaBancaria")),
+ "Debe requerir cuenta bancaria");
+ }
+
+ @Test
+ @DisplayName("Línea sin descripción")
+ void lineaSinDescripcion() {
+ Invoice invoice = Invoice.builder()
+ .serie("F001")
+ .numero(1)
+ .moneda("PEN")
+ .proveedor(proveedorValido())
+ .cliente(clienteRuc())
+ .detalle(DocumentoVentaDetalle.builder()
+ .cantidad(BigDecimal.ONE)
+ .precio(BigDecimal.TEN)
+ .build())
+ .build();
+ List errors = InvoiceValidator.validate(invoice);
+ assertTrue(errors.stream().anyMatch(e -> e.contains("descripción")),
+ "Debe requerir descripción en línea");
+ }
+
+ @Test
+ @DisplayName("RUC del proveedor inválido")
+ void rucProveedorInvalido() {
+ Invoice invoice = Invoice.builder()
+ .serie("F001")
+ .numero(1)
+ .moneda("PEN")
+ .proveedor(Proveedor.builder().ruc("12345").razonSocial("TEST").build())
+ .cliente(clienteRuc())
+ .detalle(lineaGravada())
+ .build();
+ List errors = InvoiceValidator.validate(invoice);
+ assertTrue(errors.stream().anyMatch(e -> e.contains("11 dígitos")),
+ "Debe validar longitud de RUC");
+ }
+ }
+}
diff --git a/xbuilder/core/src/test/java/io/github/project/openubl/xbuilder/content/models/standard/general/NoteValidatorTest.java b/xbuilder/core/src/test/java/io/github/project/openubl/xbuilder/content/models/standard/general/NoteValidatorTest.java
new file mode 100644
index 00000000..c2af7509
--- /dev/null
+++ b/xbuilder/core/src/test/java/io/github/project/openubl/xbuilder/content/models/standard/general/NoteValidatorTest.java
@@ -0,0 +1,199 @@
+package io.github.project.openubl.xbuilder.content.models.standard.general;
+
+import io.github.project.openubl.xbuilder.content.models.common.Cliente;
+import io.github.project.openubl.xbuilder.content.models.common.Firmante;
+import io.github.project.openubl.xbuilder.content.models.common.Proveedor;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests de validación para Notas de Crédito y Notas de Débito.
+ */
+@DisplayName("NoteValidator")
+class NoteValidatorTest {
+
+ private static Proveedor proveedor() {
+ return Proveedor.builder()
+ .ruc("20601234567")
+ .razonSocial("EMPRESA SAC")
+ .build();
+ }
+
+ private static Cliente cliente() {
+ return Cliente.builder()
+ .nombre("CLIENTE SAC")
+ .numeroDocumentoIdentidad("20100047218")
+ .tipoDocumentoIdentidad("6")
+ .build();
+ }
+
+ private static DocumentoVentaDetalle linea() {
+ return DocumentoVentaDetalle.builder()
+ .descripcion("Producto devuelto")
+ .cantidad(BigDecimal.ONE)
+ .precio(BigDecimal.valueOf(100))
+ .build();
+ }
+
+ @Nested
+ @DisplayName("Nota de Crédito - casos válidos")
+ class CreditNoteValidTests {
+
+ @Test
+ @DisplayName("NC estándar por anulación total")
+ void ncAnulacionTotal() {
+ CreditNote nc = CreditNote.builder()
+ .serie("F001")
+ .numero(1)
+ .fechaEmision(LocalDate.now())
+ .moneda("PEN")
+ .proveedor(proveedor())
+ .firmante(Firmante.builder().ruc("20601234567").razonSocial("EMPRESA SAC").build())
+ .cliente(cliente())
+ .tipoNota("01")
+ .comprobanteAfectadoSerieNumero("F001-100")
+ .comprobanteAfectadoTipo("01")
+ .sustentoDescripcion("Anulación de la operación")
+ .detalle(linea())
+ .build();
+ List errors = NoteValidator.validate(nc);
+ assertTrue(errors.isEmpty(), "NC válida: " + errors);
+ }
+
+ @Test
+ @DisplayName("NC por corrección de nombre/razón social (motivo 10)")
+ void ncCorreccionNombre() {
+ CreditNote nc = CreditNote.builder()
+ .serie("B001")
+ .numero(1)
+ .fechaEmision(LocalDate.now())
+ .moneda("PEN")
+ .proveedor(proveedor())
+ .firmante(Firmante.builder().ruc("20601234567").razonSocial("EMPRESA SAC").build())
+ .cliente(cliente())
+ .tipoNota("10")
+ .comprobanteAfectadoSerieNumero("B001-50")
+ .comprobanteAfectadoTipo("03")
+ .sustentoDescripcion("Corrección de denominación del receptor")
+ .detalle(linea())
+ .build();
+ List errors = NoteValidator.validate(nc);
+ assertTrue(errors.isEmpty(), "NC motivo 10 válida: " + errors);
+ }
+ }
+
+ @Nested
+ @DisplayName("Nota de Débito - casos válidos")
+ class DebitNoteValidTests {
+
+ @Test
+ @DisplayName("ND por intereses por mora (motivo 01)")
+ void ndInteresesMora() {
+ DebitNote nd = DebitNote.builder()
+ .serie("F001")
+ .numero(1)
+ .fechaEmision(LocalDate.now())
+ .moneda("PEN")
+ .proveedor(proveedor())
+ .firmante(Firmante.builder().ruc("20601234567").razonSocial("EMPRESA SAC").build())
+ .cliente(cliente())
+ .tipoNota("01")
+ .comprobanteAfectadoSerieNumero("F001-100")
+ .comprobanteAfectadoTipo("01")
+ .sustentoDescripcion("Intereses por mora en pago")
+ .detalle(DocumentoVentaDetalle.builder()
+ .descripcion("Intereses por mora")
+ .cantidad(BigDecimal.ONE)
+ .precio(BigDecimal.valueOf(250))
+ .build())
+ .build();
+ List errors = NoteValidator.validate(nd);
+ assertTrue(errors.isEmpty(), "ND válida: " + errors);
+ }
+ }
+
+ @Nested
+ @DisplayName("Nota - casos inválidos")
+ class InvalidTests {
+
+ @Test
+ @DisplayName("NC sin comprobante afectado")
+ void ncSinComprobanteAfectado() {
+ CreditNote nc = CreditNote.builder()
+ .serie("F001")
+ .numero(1)
+ .proveedor(proveedor())
+ .cliente(cliente())
+ .tipoNota("01")
+ .sustentoDescripcion("Anulación")
+ .detalle(linea())
+ .build();
+ List errors = NoteValidator.validate(nc);
+ assertTrue(errors.stream().anyMatch(e -> e.contains("comprobanteAfectadoSerieNumero")),
+ "Debe requerir comprobante afectado");
+ }
+
+ @Test
+ @DisplayName("NC sin sustento")
+ void ncSinSustento() {
+ CreditNote nc = CreditNote.builder()
+ .serie("F001")
+ .numero(1)
+ .proveedor(proveedor())
+ .cliente(cliente())
+ .tipoNota("01")
+ .comprobanteAfectadoSerieNumero("F001-1")
+ .comprobanteAfectadoTipo("01")
+ .detalle(linea())
+ .build();
+ List errors = NoteValidator.validate(nc);
+ assertTrue(errors.stream().anyMatch(e -> e.contains("sustento")),
+ "Debe requerir sustento descriptivo");
+ }
+
+ @Test
+ @DisplayName("NC con tipo nota inválido")
+ void ncTipoNotaInvalido() {
+ CreditNote nc = CreditNote.builder()
+ .serie("F001")
+ .numero(1)
+ .proveedor(proveedor())
+ .cliente(cliente())
+ .tipoNota("99")
+ .comprobanteAfectadoSerieNumero("F001-1")
+ .comprobanteAfectadoTipo("01")
+ .sustentoDescripcion("Test")
+ .detalle(linea())
+ .build();
+ List errors = NoteValidator.validate(nc);
+ assertTrue(errors.stream().anyMatch(e -> e.contains("Catálogo 09")),
+ "Debe validar tipo nota contra Catálogo 09");
+ }
+
+ @Test
+ @DisplayName("ND con tipo nota inválido")
+ void ndTipoNotaInvalido() {
+ DebitNote nd = DebitNote.builder()
+ .serie("F001")
+ .numero(1)
+ .proveedor(proveedor())
+ .cliente(cliente())
+ .tipoNota("99")
+ .comprobanteAfectadoSerieNumero("F001-1")
+ .comprobanteAfectadoTipo("01")
+ .sustentoDescripcion("Test")
+ .detalle(linea())
+ .build();
+ List errors = NoteValidator.validate(nd);
+ assertTrue(errors.stream().anyMatch(e -> e.contains("Catálogo 10")),
+ "Debe validar tipo nota contra Catálogo 10");
+ }
+ }
+}
diff --git a/xbuilder/core/src/test/java/unit/catalogs/GRECatalogosTest.java b/xbuilder/core/src/test/java/unit/catalogs/GRECatalogosTest.java
new file mode 100644
index 00000000..8205b1d1
--- /dev/null
+++ b/xbuilder/core/src/test/java/unit/catalogs/GRECatalogosTest.java
@@ -0,0 +1,114 @@
+package unit.catalogs;
+
+import io.github.project.openubl.xbuilder.content.catalogs.*;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests unitarios para los catálogos SUNAT de la GRE.
+ * Verifica que los códigos sean correctos según Anexo N.° 8.
+ */
+public class GRECatalogosTest {
+
+ @Test
+ public void testCatalog20_MotivoTraslado() {
+ assertEquals("01", Catalog20.VENTA.getCode());
+ assertEquals("02", Catalog20.COMPRA.getCode());
+ assertEquals("03", Catalog20.CONSIGNACION.getCode());
+ assertEquals("04", Catalog20.TRASLADO_ENTRE_ESTABLECIMIENTOS.getCode());
+ assertEquals("05", Catalog20.DEVOLUCION.getCode());
+ assertEquals("06", Catalog20.TRASLADO_TRANSFORMACION.getCode());
+ assertEquals("07", Catalog20.RECOJO_BIENES_TRANSFORMADOS.getCode());
+ assertEquals("08", Catalog20.IMPORTACION.getCode());
+ assertEquals("09", Catalog20.EXPORTACION.getCode());
+ assertEquals("10", Catalog20.IMPORTACION_CON_DAM.getCode());
+ assertEquals("11", Catalog20.IMPORTACION_TEMPORAL.getCode());
+ assertEquals("13", Catalog20.OTROS.getCode());
+ assertEquals("14", Catalog20.VENTA_SUJETA_A_CONFIRMACION.getCode());
+ assertEquals("15", Catalog20.TRASLADO_ZONA_IVAP.getCode());
+ assertEquals("16", Catalog20.EXPORTACION_TEMPORAL.getCode());
+ assertEquals("17", Catalog20.REEXPORTACION.getCode());
+ assertEquals("18", Catalog20.TRASLADO_EMISOR_ITINERANTE_CP.getCode());
+ assertEquals("19", Catalog20.TRASLADO_MERCANCIA_EXTRANJERA.getCode());
+
+ // Verify lookup
+ assertTrue(Catalog20.valueOfCode("10").isPresent());
+ assertEquals(Catalog20.IMPORTACION_CON_DAM, Catalog20.valueOfCode("10").get());
+ assertFalse(Catalog20.valueOfCode("99").isPresent());
+ }
+
+ @Test
+ public void testCatalog21_DocumentoRelacionado() {
+ assertEquals("01", Catalog21.NUMERACION_DAM.getCode());
+ assertEquals("02", Catalog21.NUMERO_DE_ORDEN_DE_ENTREGA.getCode());
+ assertEquals("03", Catalog21.NUMERO_SCOP.getCode());
+ assertEquals("04", Catalog21.NUMERO_DE_MANIFIESTO_DE_CARGA.getCode());
+ assertEquals("05", Catalog21.NUMERO_DE_CONSTANCIA_DE_DETRACCION.getCode());
+ assertEquals("06", Catalog21.OTROS.getCode());
+ assertEquals("09", Catalog21.GUIA_REMISION_REMITENTE.getCode());
+ assertEquals("12", Catalog21.DECLARACION_SIMPLIFICADA.getCode());
+ assertEquals("31", Catalog21.GUIA_REMISION_TRANSPORTISTA.getCode());
+ assertEquals("49", Catalog21.TICKET_SALIDA.getCode());
+ assertEquals("50", Catalog21.CODIGO_AUTORIZACION_SUNAT.getCode());
+
+ assertTrue(Catalog21.valueOfCode("49").isPresent());
+ }
+
+ @Test
+ public void testCatalog61_DocumentoTransporte() {
+ assertEquals("01", Catalog61.FACTURA.getCode());
+ assertEquals("04", Catalog61.GUIA_REMISION_REMITENTE.getCode());
+ assertEquals("05", Catalog61.GUIA_REMISION_TRANSPORTISTA.getCode());
+ assertEquals("50", Catalog61.DAM.getCode());
+ assertEquals("52", Catalog61.DECLARACION_SIMPLIFICADA.getCode());
+ }
+
+ @Test
+ public void testCatalog62_BienesNormalizados() {
+ assertEquals("01", Catalog62.AZUCAR.getCode());
+ assertEquals("02", Catalog62.ARROZ.getCode());
+ assertEquals("03", Catalog62.ALCOHOL_ETILICO.getCode());
+ assertEquals("04", Catalog62.CEMENTO.getCode());
+ }
+
+ @Test
+ public void testCatalog63_Puertos() {
+ assertEquals("CALLAO", Catalog63.CALLAO.getCode());
+ assertEquals("PAITA", Catalog63.PAITA.getCode());
+ assertEquals("CHANCAY", Catalog63.CHANCAY.getCode()); // RS 000240-2024
+
+ assertTrue(Catalog63.valueOfCode("CALLAO").isPresent());
+ assertTrue(Catalog63.valueOfCode("callao").isPresent()); // Case insensitive
+ assertFalse(Catalog63.valueOfCode("INEXISTENTE").isPresent());
+ }
+
+ @Test
+ public void testCatalog64_Aeropuertos() {
+ assertEquals("LIM", Catalog64.JORGE_CHAVEZ.getCode());
+ assertEquals("AQP", Catalog64.RODRIGUEZ_BALLON.getCode());
+ assertEquals("CUZ", Catalog64.ALEJANDRO_VELASCO.getCode());
+
+ assertTrue(Catalog64.valueOfCode("LIM").isPresent());
+ }
+
+ @Test
+ public void testIndicadorEnvio() {
+ assertEquals("SUNAT_Envio_IndicadorTrasladoTotalDAMDS",
+ IndicadorEnvio.TRASLADO_TOTAL_DAM_DS.getCode());
+ assertEquals("SUNAT_Envio_IndicadorBienNormalizado",
+ IndicadorEnvio.BIEN_NORMALIZADO.getCode());
+ }
+
+ @Test
+ public void testCatalog18_ModalidadTraslado() {
+ assertEquals("01", Catalog18.TRANSPORTE_PUBLICO.getCode());
+ assertEquals("02", Catalog18.TRANSPORTE_PRIVADO.getCode());
+ }
+
+ @Test
+ public void testCatalog1Guia_TipoDocumento() {
+ assertEquals("09", Catalog1_Guia.GUIA_REMISION_REMITENTE.getCode());
+ assertEquals("31", Catalog1_Guia.GUIA_REMISION_TRANSPORTISTA.getCode());
+ }
+}
diff --git a/xbuilder/core/src/test/java/unit/validator/DespatchAdviceCommonValidatorTest.java b/xbuilder/core/src/test/java/unit/validator/DespatchAdviceCommonValidatorTest.java
new file mode 100644
index 00000000..3a22a2f3
--- /dev/null
+++ b/xbuilder/core/src/test/java/unit/validator/DespatchAdviceCommonValidatorTest.java
@@ -0,0 +1,983 @@
+package unit.validator;
+
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog18;
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog20;
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog6;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.*;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.validation.DespatchAdviceCommonValidator;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.validation.ValidationMessage;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.validation.ValidationResult;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.validation.ValidationSeverity;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests unitarios para {@link DespatchAdviceCommonValidator} y el modelo de validación.
+ *
+ * Verifica que las reglas centralizadas producen los mensajes correctos con la severidad adecuada (ERROR vs WARNING).
+ */
+public class DespatchAdviceCommonValidatorTest {
+
+ // == Helpers para construir datos mínimos ==
+
+ private static Envio minimalEnvio() {
+ return Envio.builder()
+ .tipoTraslado(Catalog20.VENTA.getCode())
+ .pesoTotal(BigDecimal.ONE)
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado(Catalog18.TRANSPORTE_PRIVADO.getCode())
+ .fechaTraslado(LocalDate.now())
+ .chofer(Driver.builder()
+ .tipoDocumentoIdentidad(Catalog6.DNI.getCode())
+ .numeroDocumentoIdentidad("11111111")
+ .nombres("Juan")
+ .apellidos("Perez")
+ .licencia("Q123")
+ .build())
+ .vehiculo(Vehicle.builder().placa("ABC-123").build())
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build();
+ }
+
+ // ================================================================
+ // ValidationMessage / ValidationResult tests
+ // ================================================================
+
+ @Nested
+ class ValidationModelTests {
+
+ @Test
+ public void testValidationMessageError() {
+ ValidationMessage msg = ValidationMessage.error("test error");
+ assertTrue(msg.isError());
+ assertFalse(msg.isWarning());
+ assertEquals(ValidationSeverity.ERROR, msg.getSeverity());
+ assertEquals("test error", msg.getMessage());
+ }
+
+ @Test
+ public void testValidationMessageWarning() {
+ ValidationMessage msg = ValidationMessage.warning("test warning");
+ assertFalse(msg.isError());
+ assertTrue(msg.isWarning());
+ assertEquals(ValidationSeverity.WARNING, msg.getSeverity());
+ }
+
+ @Test
+ public void testValidationResultEmpty() {
+ ValidationResult result = new ValidationResult(new ArrayList<>());
+ assertTrue(result.isValid());
+ assertFalse(result.hasErrors());
+ assertFalse(result.hasWarnings());
+ assertTrue(result.getErrors().isEmpty());
+ assertTrue(result.getWarnings().isEmpty());
+ }
+
+ @Test
+ public void testValidationResultWithErrorsAndWarnings() {
+ List messages = List.of(
+ ValidationMessage.error("error 1"),
+ ValidationMessage.warning("warning 1"),
+ ValidationMessage.error("error 2"));
+ ValidationResult result = new ValidationResult(messages);
+
+ assertFalse(result.isValid());
+ assertTrue(result.hasErrors());
+ assertTrue(result.hasWarnings());
+ assertEquals(2, result.getErrors().size());
+ assertEquals(1, result.getWarnings().size());
+ assertEquals(3, result.getMessages().size());
+ }
+
+ @Test
+ public void testValidationResultOnlyWarnings() {
+ List messages = List.of(
+ ValidationMessage.warning("w1"),
+ ValidationMessage.warning("w2"));
+ ValidationResult result = new ValidationResult(messages);
+
+ assertTrue(result.isValid());
+ assertFalse(result.hasErrors());
+ assertTrue(result.hasWarnings());
+ }
+
+ @Test
+ public void testValidationResultNullMessages() {
+ ValidationResult result = new ValidationResult(null);
+ assertTrue(result.isValid());
+ assertTrue(result.getMessages().isEmpty());
+ }
+ }
+
+ // ================================================================
+ // BasicFields
+ // ================================================================
+
+ @Nested
+ class BasicFieldsTests {
+
+ @Test
+ public void testSerieNull() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validateBasicFields(null, 1, msgs);
+ assertTrue(msgs.stream().anyMatch(m -> m.isError() && m.getMessage().contains("serie")));
+ }
+
+ @Test
+ public void testSerieBlank() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validateBasicFields(" ", 1, msgs);
+ assertTrue(msgs.stream().anyMatch(m -> m.isError() && m.getMessage().contains("serie")));
+ }
+
+ @Test
+ public void testNumeroNull() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validateBasicFields("T001", null, msgs);
+ assertTrue(msgs.stream().anyMatch(m -> m.isError() && m.getMessage().contains("número")));
+ }
+
+ @Test
+ public void testNumeroZero() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validateBasicFields("T001", 0, msgs);
+ assertTrue(msgs.stream().anyMatch(m -> m.isError() && m.getMessage().contains("mayor a 0")));
+ }
+
+ @Test
+ public void testValidBasicFields() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validateBasicFields("T001", 1, msgs);
+ assertTrue(msgs.isEmpty());
+ }
+ }
+
+ // ================================================================
+ // Serie coherence
+ // ================================================================
+
+ @Nested
+ class SerieCoherenceTests {
+
+ @Test
+ public void testRemitenteSerieTOK() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validateSerieCoherence("T001", "09", msgs);
+ assertTrue(msgs.isEmpty());
+ }
+
+ @Test
+ public void testRemitenteSerieVFails() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validateSerieCoherence("V001", "09", msgs);
+ assertTrue(msgs.stream()
+ .anyMatch(m -> m.isError()
+ && m.getMessage().contains("GRE-Remitente (09)")));
+ }
+
+ @Test
+ public void testTransportistaSerieVOK() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validateSerieCoherence("V001", "31", msgs);
+ assertTrue(msgs.isEmpty());
+ }
+
+ @Test
+ public void testTransportistaSerieTFails() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validateSerieCoherence("T001", "31", msgs);
+ assertTrue(msgs.stream()
+ .anyMatch(m -> m.isError()
+ && m.getMessage().contains("GRE-Transportista (31)")));
+ }
+
+ @Test
+ public void testNullSerieSkips() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validateSerieCoherence(null, "09", msgs);
+ assertTrue(msgs.isEmpty());
+ }
+
+ @Test
+ public void testNullTipoSkips() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validateSerieCoherence("T001", null, msgs);
+ assertTrue(msgs.isEmpty());
+ }
+ }
+
+ // ================================================================
+ // Remitente / Transportista Emisor
+ // ================================================================
+
+ @Nested
+ class PartyTests {
+
+ @Test
+ public void testRemitenteNull() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validateRemitente(null, msgs);
+ assertTrue(msgs.stream().anyMatch(m -> m.isError() && m.getMessage().contains("remitente")));
+ }
+
+ @Test
+ public void testRemitenteRucInvalido() {
+ List msgs = new ArrayList<>();
+ Remitente rem = Remitente.builder().ruc("123").razonSocial("X").build();
+ DespatchAdviceCommonValidator.validateRemitente(rem, msgs);
+ assertTrue(msgs.stream().anyMatch(m -> m.isError() && m.getMessage().contains("11 dígitos")));
+ }
+
+ @Test
+ public void testRemitenteValido() {
+ List msgs = new ArrayList<>();
+ Remitente rem = Remitente.builder().ruc("12345678901").razonSocial("X").build();
+ DespatchAdviceCommonValidator.validateRemitente(rem, msgs);
+ assertTrue(msgs.isEmpty());
+ }
+
+ @Test
+ public void testTransportistaEmisorNull() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validateTransportistaEmisor(null, msgs);
+ assertTrue(msgs.stream()
+ .anyMatch(m -> m.isError()
+ && m.getMessage().contains("transportista emisor")));
+ }
+
+ @Test
+ public void testTransportistaEmisorRucInvalido() {
+ List msgs = new ArrayList<>();
+ Transportista t = Transportista.builder()
+ .tipoDocumentoIdentidad("6")
+ .numeroDocumentoIdentidad("123")
+ .nombre("X")
+ .build();
+ DespatchAdviceCommonValidator.validateTransportistaEmisor(t, msgs);
+ assertTrue(msgs.stream().anyMatch(m -> m.isError() && m.getMessage().contains("11 dígitos")));
+ }
+
+ @Test
+ public void testDestinatarioNull() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validateDestinatario(null, msgs);
+ assertTrue(msgs.stream().anyMatch(m -> m.isError() && m.getMessage().contains("destinatario")));
+ }
+
+ @Test
+ public void testDestinatarioPresente() {
+ List msgs = new ArrayList<>();
+ Destinatario d = Destinatario.builder()
+ .tipoDocumentoIdentidad("1")
+ .numeroDocumentoIdentidad("12345678")
+ .nombre("C")
+ .build();
+ DespatchAdviceCommonValidator.validateDestinatario(d, msgs);
+ assertTrue(msgs.isEmpty());
+ }
+
+ @Test
+ public void testTerceroNull() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validateTerceroTransportista(null, msgs);
+ assertTrue(msgs.stream()
+ .anyMatch(m -> m.isError()
+ && m.getMessage().contains("remitente original")));
+ }
+
+ @Test
+ public void testTerceroPresente() {
+ List msgs = new ArrayList<>();
+ Tercero t = Tercero.builder()
+ .tipoDocumentoIdentidad("6")
+ .numeroDocumentoIdentidad("20100010001")
+ .nombre("R")
+ .build();
+ DespatchAdviceCommonValidator.validateTerceroTransportista(t, msgs);
+ assertTrue(msgs.isEmpty());
+ }
+ }
+
+ // ================================================================
+ // Envio required fields
+ // ================================================================
+
+ @Nested
+ class EnvioRequiredTests {
+
+ @Test
+ public void testEnvioNull() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validateEnvioRequired(null, msgs);
+ assertTrue(msgs.stream().anyMatch(m -> m.isError() && m.getMessage().contains("envío")));
+ }
+
+ @Test
+ public void testEnvioSinMotivo() {
+ List msgs = new ArrayList<>();
+ Envio envio = Envio.builder()
+ .pesoTotal(BigDecimal.ONE)
+ .tipoModalidadTraslado("02")
+ .fechaTraslado(LocalDate.now())
+ .build();
+ DespatchAdviceCommonValidator.validateEnvioRequired(envio, msgs);
+ assertTrue(msgs.stream()
+ .anyMatch(m -> m.isError()
+ && m.getMessage().contains("motivo de traslado")));
+ }
+
+ @Test
+ public void testEnvioSinPeso() {
+ List msgs = new ArrayList<>();
+ Envio envio = Envio.builder()
+ .tipoTraslado("01")
+ .tipoModalidadTraslado("02")
+ .fechaTraslado(LocalDate.now())
+ .build();
+ DespatchAdviceCommonValidator.validateEnvioRequired(envio, msgs);
+ assertTrue(msgs.stream()
+ .anyMatch(m -> m.isError()
+ && m.getMessage().contains("peso total")));
+ }
+
+ @Test
+ public void testEnvioSinModalidad() {
+ List msgs = new ArrayList<>();
+ Envio envio = Envio.builder()
+ .tipoTraslado("01")
+ .pesoTotal(BigDecimal.ONE)
+ .fechaTraslado(LocalDate.now())
+ .build();
+ DespatchAdviceCommonValidator.validateEnvioRequired(envio, msgs);
+ assertTrue(msgs.stream()
+ .anyMatch(m -> m.isError()
+ && m.getMessage().contains("modalidad de traslado")));
+ }
+
+ @Test
+ public void testEnvioSinFecha() {
+ List msgs = new ArrayList<>();
+ Envio envio = Envio.builder()
+ .tipoTraslado("01")
+ .pesoTotal(BigDecimal.ONE)
+ .tipoModalidadTraslado("02")
+ .build();
+ DespatchAdviceCommonValidator.validateEnvioRequired(envio, msgs);
+ assertTrue(msgs.stream()
+ .anyMatch(m -> m.isError()
+ && m.getMessage().contains("fecha de traslado")));
+ }
+
+ @Test
+ public void testEnvioCompletoValido() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validateEnvioRequired(minimalEnvio(), msgs);
+ assertTrue(msgs.isEmpty());
+ }
+ }
+
+ // ================================================================
+ // Partida / Destino
+ // ================================================================
+
+ @Nested
+ class PartidaDestinoTests {
+
+ @Test
+ public void testSinPartida() {
+ List msgs = new ArrayList<>();
+ Envio envio = Envio.builder()
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build();
+ DespatchAdviceCommonValidator.validatePartidaDestino(envio, msgs);
+ assertTrue(msgs.stream().anyMatch(m -> m.isError() && m.getMessage().contains("partida")));
+ }
+
+ @Test
+ public void testSinDestino() {
+ List msgs = new ArrayList<>();
+ Envio envio = Envio.builder()
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .build();
+ DespatchAdviceCommonValidator.validatePartidaDestino(envio, msgs);
+ assertTrue(msgs.stream().anyMatch(m -> m.isError() && m.getMessage().contains("destino")));
+ }
+
+ @Test
+ public void testPartidaDestinoPresentes() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validatePartidaDestino(minimalEnvio(), msgs);
+ assertTrue(msgs.isEmpty());
+ }
+
+ @Test
+ public void testEnvioNullNoFalla() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validatePartidaDestino(null, msgs);
+ assertTrue(msgs.isEmpty());
+ }
+ }
+
+ // ================================================================
+ // Modalidad Remitente
+ // ================================================================
+
+ @Nested
+ class ModalidadRemitenteTests {
+
+ @Test
+ public void testPrivadoSinConductorNiVehiculo() {
+ List msgs = new ArrayList<>();
+ Envio envio = Envio.builder()
+ .tipoTraslado("01")
+ .pesoTotal(BigDecimal.ONE)
+ .tipoModalidadTraslado("02")
+ .fechaTraslado(LocalDate.now())
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build();
+ DespatchAdviceCommonValidator.validateModalidadRemitente(envio, msgs);
+ assertEquals(2, msgs.stream().filter(ValidationMessage::isError).count());
+ assertTrue(msgs.stream().anyMatch(m -> m.getMessage().contains("conductor")));
+ assertTrue(msgs.stream().anyMatch(m -> m.getMessage().contains("vehículo")));
+ }
+
+ @Test
+ public void testPrivadoConIndicadorM1L() {
+ List msgs = new ArrayList<>();
+ Envio envio = Envio.builder()
+ .tipoTraslado("01")
+ .pesoTotal(BigDecimal.ONE)
+ .tipoModalidadTraslado("02")
+ .fechaTraslado(LocalDate.now())
+ .indicador("SUNAT_Envio_IndicadorTrasladoVehiculoM1L")
+ .build();
+ DespatchAdviceCommonValidator.validateModalidadRemitente(envio, msgs);
+ assertTrue(msgs.isEmpty(), "M1/L exime conductor y vehículo");
+ }
+
+ @Test
+ public void testPrivadoConTransportista() {
+ List msgs = new ArrayList<>();
+ Envio envio = Envio.builder()
+ .tipoTraslado("01")
+ .pesoTotal(BigDecimal.ONE)
+ .tipoModalidadTraslado("02")
+ .fechaTraslado(LocalDate.now())
+ .chofer(Driver.builder()
+ .tipoDocumentoIdentidad("1")
+ .numeroDocumentoIdentidad("11111111")
+ .nombres("J")
+ .apellidos("P")
+ .licencia("Q123")
+ .build())
+ .vehiculo(Vehicle.builder().placa("ABC-123").build())
+ .transportista(Transportista.builder()
+ .tipoDocumentoIdentidad("6")
+ .numeroDocumentoIdentidad("20300030003")
+ .nombre("T")
+ .build())
+ .build();
+ DespatchAdviceCommonValidator.validateModalidadRemitente(envio, msgs);
+ assertTrue(msgs.stream()
+ .anyMatch(m -> m.isError()
+ && m.getMessage().contains("Transporte privado no debe consignar transportista")));
+ }
+
+ @Test
+ public void testPublicoSinTransportista() {
+ List msgs = new ArrayList<>();
+ Envio envio = Envio.builder()
+ .tipoTraslado("01")
+ .pesoTotal(BigDecimal.ONE)
+ .tipoModalidadTraslado("01")
+ .fechaTraslado(LocalDate.now())
+ .build();
+ DespatchAdviceCommonValidator.validateModalidadRemitente(envio, msgs);
+ assertTrue(msgs.stream()
+ .anyMatch(m -> m.isError()
+ && m.getMessage().contains("transportista")));
+ }
+
+ @Test
+ public void testPublicoConTransportista() {
+ List msgs = new ArrayList<>();
+ Envio envio = Envio.builder()
+ .tipoTraslado("01")
+ .pesoTotal(BigDecimal.ONE)
+ .tipoModalidadTraslado("01")
+ .fechaTraslado(LocalDate.now())
+ .transportista(Transportista.builder()
+ .tipoDocumentoIdentidad("6")
+ .numeroDocumentoIdentidad("20300030003")
+ .nombre("T")
+ .build())
+ .build();
+ DespatchAdviceCommonValidator.validateModalidadRemitente(envio, msgs);
+ assertTrue(msgs.isEmpty());
+ }
+
+ @Test
+ public void testModalidadNullNoFalla() {
+ List msgs = new ArrayList<>();
+ Envio envio = Envio.builder().tipoTraslado("01").build();
+ DespatchAdviceCommonValidator.validateModalidadRemitente(envio, msgs);
+ assertTrue(msgs.isEmpty());
+ }
+ }
+
+ // ================================================================
+ // Conductor/Vehículo Transportista
+ // ================================================================
+
+ @Nested
+ class ConductorVehiculoTransportistaTests {
+
+ @Test
+ public void testSinConductorNiVehiculo() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validateConductorVehiculoTransportista(null, null, msgs);
+ assertEquals(2, msgs.stream().filter(ValidationMessage::isError).count());
+ }
+
+ @Test
+ public void testConductorVacio() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validateConductorVehiculoTransportista(
+ new ArrayList<>(), Vehicle.builder().placa("X").build(), msgs);
+ assertEquals(1, msgs.stream().filter(ValidationMessage::isError).count());
+ assertTrue(msgs.stream().anyMatch(m -> m.getMessage().contains("conductor")));
+ }
+
+ @Test
+ public void testVehiculoNull() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validateConductorVehiculoTransportista(
+ List.of(Driver.builder().build()), null, msgs);
+ assertEquals(1, msgs.stream().filter(ValidationMessage::isError).count());
+ assertTrue(msgs.stream().anyMatch(m -> m.getMessage().contains("vehículo")));
+ }
+
+ @Test
+ public void testConductorYVehiculoPresentes() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validateConductorVehiculoTransportista(
+ List.of(Driver.builder().build()),
+ Vehicle.builder().placa("X").build(), msgs);
+ assertTrue(msgs.isEmpty());
+ }
+ }
+
+ // ================================================================
+ // Comercio Exterior (WARNINGS)
+ // ================================================================
+
+ @Nested
+ class ComercioExteriorTests {
+
+ @Test
+ public void testImportacionSinDAM() {
+ List msgs = new ArrayList<>();
+ Envio envio = Envio.builder().tipoTraslado("08").build();
+ DespatchAdviceCommonValidator.validateComercioExterior(envio, msgs);
+ assertTrue(msgs.stream().anyMatch(m -> m.isWarning() && m.getMessage().contains("DAM/DS")),
+ "Debe generar warning por falta de DAM/DS");
+ }
+
+ @Test
+ public void testExportacionSinPuerto() {
+ List msgs = new ArrayList<>();
+ Envio envio = Envio.builder().tipoTraslado("09").build();
+ DespatchAdviceCommonValidator.validateComercioExterior(envio, msgs);
+ assertTrue(msgs.stream()
+ .anyMatch(m -> m.isWarning()
+ && m.getMessage().contains("puerto o aeropuerto")));
+ }
+
+ @Test
+ public void testMotivoVentaSinAdvertencias() {
+ List msgs = new ArrayList<>();
+ Envio envio = Envio.builder()
+ .tipoTraslado(Catalog20.VENTA.getCode())
+ .build();
+ DespatchAdviceCommonValidator.validateComercioExterior(envio, msgs);
+ assertTrue(msgs.isEmpty(), "Venta no requiere reglas de comercio exterior");
+ }
+
+ @Test
+ public void testComercioExteriorConDAMYPuerto() {
+ List msgs = new ArrayList<>();
+ Envio envio = Envio.builder()
+ .tipoTraslado("08")
+ .declaracionAduanera(DeclaracionAduanera.builder()
+ .tipo("DAM")
+ .numero("118-2024-10-001")
+ .build())
+ .puerto(Puerto.builder().codigo("PECLL").nombre("Callao").build())
+ .build();
+ DespatchAdviceCommonValidator.validateComercioExterior(envio, msgs);
+ assertTrue(msgs.isEmpty(), "Con DAM y puerto no debe haber warnings");
+ }
+
+ @Test
+ public void testComercioExteriorConAeropuerto() {
+ List msgs = new ArrayList<>();
+ Envio envio = Envio.builder()
+ .tipoTraslado("09")
+ .declaracionAduanera(DeclaracionAduanera.builder()
+ .tipo("DAM")
+ .numero("118-2024-10-001")
+ .build())
+ .aeropuerto(Puerto.builder().codigo("SPJC").nombre("Jorge Chávez").build())
+ .build();
+ DespatchAdviceCommonValidator.validateComercioExterior(envio, msgs);
+ assertTrue(msgs.isEmpty(), "Aeropuerto satisface el requisito de puerto");
+ }
+
+ @Test
+ public void testEnvioNullNoFalla() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validateComercioExterior(null, msgs);
+ assertTrue(msgs.isEmpty());
+ }
+
+ @Test
+ public void testSeveridadEsWarningNoError() {
+ List msgs = new ArrayList<>();
+ Envio envio = Envio.builder().tipoTraslado("10").build();
+ DespatchAdviceCommonValidator.validateComercioExterior(envio, msgs);
+ assertTrue(msgs.stream().allMatch(ValidationMessage::isWarning),
+ "Todas las reglas de comercio exterior deben ser WARNING, no ERROR");
+ assertTrue(msgs.stream().noneMatch(ValidationMessage::isError));
+ }
+ }
+
+ // ================================================================
+ // Detalles
+ // ================================================================
+
+ @Nested
+ class DetallesTests {
+
+ @Test
+ public void testDetallesNull() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validateDetalles(null, msgs);
+ assertTrue(msgs.stream().anyMatch(m -> m.isError() && m.getMessage().contains("detalle")));
+ }
+
+ @Test
+ public void testDetallesVacios() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validateDetalles(new ArrayList<>(), msgs);
+ assertTrue(msgs.stream().anyMatch(m -> m.isError() && m.getMessage().contains("detalle")));
+ }
+
+ @Test
+ public void testDetallesConItems() {
+ List msgs = new ArrayList<>();
+ DespatchAdviceCommonValidator.validateDetalles(
+ List.of(DespatchAdviceItem.builder()
+ .cantidad(BigDecimal.ONE)
+ .unidadMedida("NIU")
+ .codigo("001")
+ .build()),
+ msgs);
+ assertTrue(msgs.isEmpty());
+ }
+ }
+
+ // ================================================================
+ // validateDetailed integration via DespatchAdviceValidator
+ // ================================================================
+
+ @Nested
+ class ValidateDetailedIntegration {
+
+ @Test
+ public void testValidateDetailedReturnsValidResult() {
+ DespatchAdvice da = DespatchAdvice.builder()
+ .serie("T001")
+ .numero(1)
+ .tipoComprobante("09")
+ .remitente(Remitente.builder().ruc("12345678901").razonSocial("Test").build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad("1")
+ .numeroDocumentoIdentidad("12345678")
+ .nombre("C")
+ .build())
+ .envio(minimalEnvio())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(BigDecimal.ONE)
+ .unidadMedida("NIU")
+ .codigo("001")
+ .build())
+ .build();
+
+ ValidationResult result = DespatchAdviceValidator.validateDetailed(da);
+ assertTrue(result.isValid(), "Debe ser válido: " + result.getErrors());
+ assertFalse(result.hasErrors());
+ }
+
+ @Test
+ public void testValidateDetailedReturnsWarningsForComercioExterior() {
+ DespatchAdvice da = DespatchAdvice.builder()
+ .serie("T001")
+ .numero(1)
+ .tipoComprobante("09")
+ .remitente(Remitente.builder().ruc("12345678901").razonSocial("Test").build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad("1")
+ .numeroDocumentoIdentidad("12345678")
+ .nombre("C")
+ .build())
+ .envio(Envio.builder()
+ .tipoTraslado("08") // Importación
+ .pesoTotal(BigDecimal.ONE)
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado("02")
+ .fechaTraslado(LocalDate.now())
+ .chofer(Driver.builder()
+ .tipoDocumentoIdentidad("1")
+ .numeroDocumentoIdentidad("11111111")
+ .nombres("J")
+ .apellidos("P")
+ .licencia("Q123")
+ .build())
+ .vehiculo(Vehicle.builder().placa("ABC-123").build())
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(BigDecimal.ONE)
+ .unidadMedida("NIU")
+ .codigo("001")
+ .build())
+ .build();
+
+ ValidationResult result = DespatchAdviceValidator.validateDetailed(da);
+ assertTrue(result.isValid(), "No debe tener errores: " + result.getErrors());
+ assertTrue(result.hasWarnings(), "Debe tener warnings de comercio exterior");
+ assertTrue(result.getWarnings().stream().anyMatch(w -> w.contains("DAM/DS")));
+ }
+
+ @Test
+ public void testValidateReturnsOnlyErrors() {
+ DespatchAdvice da = DespatchAdvice.builder()
+ .serie("T001")
+ .numero(1)
+ .tipoComprobante("09")
+ .remitente(Remitente.builder().ruc("12345678901").razonSocial("Test").build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad("1")
+ .numeroDocumentoIdentidad("12345678")
+ .nombre("C")
+ .build())
+ .envio(Envio.builder()
+ .tipoTraslado("08") // Importación
+ .pesoTotal(BigDecimal.ONE)
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado("02")
+ .fechaTraslado(LocalDate.now())
+ .chofer(Driver.builder()
+ .tipoDocumentoIdentidad("1")
+ .numeroDocumentoIdentidad("11111111")
+ .nombres("J")
+ .apellidos("P")
+ .licencia("Q123")
+ .build())
+ .vehiculo(Vehicle.builder().placa("ABC-123").build())
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(BigDecimal.ONE)
+ .unidadMedida("NIU")
+ .codigo("001")
+ .build())
+ .build();
+
+ List errors = DespatchAdviceValidator.validate(da);
+ assertTrue(errors.isEmpty(),
+ "validate() must not include warnings: " + errors);
+ }
+ }
+
+ // ================================================================
+ // GRERemitente.validateDetailed()
+ // ================================================================
+
+ @Nested
+ class GRERemitenteValidateDetailedTests {
+
+ @Test
+ public void testValidateDetailedValid() {
+ GRERemitente gre = GRERemitente.builder()
+ .serie("T001")
+ .numero(1)
+ .remitente(Remitente.builder().ruc("12345678901").razonSocial("Test").build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad("1")
+ .numeroDocumentoIdentidad("12345678")
+ .nombre("C")
+ .build())
+ .envio(minimalEnvio())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(BigDecimal.ONE)
+ .unidadMedida("NIU")
+ .codigo("001")
+ .build())
+ .build();
+
+ ValidationResult result = gre.validateDetailed();
+ assertTrue(result.isValid(), "GRERemitente válido: " + result.getErrors());
+ }
+
+ @Test
+ public void testValidateDetailedWithImportWarnings() {
+ GRERemitente gre = GRERemitente.builder()
+ .serie("T001")
+ .numero(1)
+ .remitente(Remitente.builder().ruc("12345678901").razonSocial("Test").build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad("1")
+ .numeroDocumentoIdentidad("12345678")
+ .nombre("C")
+ .build())
+ .envio(Envio.builder()
+ .tipoTraslado("08") // Importación
+ .pesoTotal(BigDecimal.ONE)
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado("02")
+ .fechaTraslado(LocalDate.now())
+ .chofer(Driver.builder()
+ .tipoDocumentoIdentidad("1")
+ .numeroDocumentoIdentidad("11111111")
+ .nombres("J")
+ .apellidos("P")
+ .licencia("Q123")
+ .build())
+ .vehiculo(Vehicle.builder().placa("ABC-123").build())
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(BigDecimal.ONE)
+ .unidadMedida("NIU")
+ .codigo("001")
+ .build())
+ .build();
+
+ ValidationResult result = gre.validateDetailed();
+ assertTrue(result.isValid(), "No errors: " + result.getErrors());
+ assertTrue(result.hasWarnings());
+ }
+ }
+
+ // ================================================================
+ // GRETransportista.validateDetailed()
+ // ================================================================
+
+ @Nested
+ class GRETransportistaValidateDetailedTests {
+
+ @Test
+ public void testValidateDetailedValid() {
+ GRETransportista gre = GRETransportista.builder()
+ .serie("V001")
+ .numero(1)
+ .transportistaEmisor(Transportista.builder()
+ .tipoDocumentoIdentidad("6")
+ .numeroDocumentoIdentidad("20300030003")
+ .nombre("Transportes")
+ .build())
+ .remitente(Tercero.builder()
+ .tipoDocumentoIdentidad("6")
+ .numeroDocumentoIdentidad("20100010001")
+ .nombre("R")
+ .build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad("6")
+ .numeroDocumentoIdentidad("20200020002")
+ .nombre("D")
+ .build())
+ .conductor(Driver.builder()
+ .tipoDocumentoIdentidad("1")
+ .numeroDocumentoIdentidad("44444444")
+ .nombres("M")
+ .apellidos("T")
+ .licencia("Q444")
+ .build())
+ .vehiculo(Vehicle.builder().placa("XYZ-789").build())
+ .envio(Envio.builder()
+ .tipoTraslado("01")
+ .pesoTotal(BigDecimal.ONE)
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado("01")
+ .fechaTraslado(LocalDate.now())
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(BigDecimal.ONE)
+ .unidadMedida("NIU")
+ .codigo("001")
+ .build())
+ .build();
+
+ ValidationResult result = gre.validateDetailed();
+ assertTrue(result.isValid(), "Válido: " + result.getErrors());
+ }
+
+ @Test
+ public void testValidateDetailedSinConductorEsError() {
+ GRETransportista gre = GRETransportista.builder()
+ .serie("V001")
+ .numero(1)
+ .transportistaEmisor(Transportista.builder()
+ .tipoDocumentoIdentidad("6")
+ .numeroDocumentoIdentidad("20300030003")
+ .nombre("T")
+ .build())
+ .remitente(Tercero.builder()
+ .tipoDocumentoIdentidad("6")
+ .numeroDocumentoIdentidad("20100010001")
+ .nombre("R")
+ .build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad("6")
+ .numeroDocumentoIdentidad("20200020002")
+ .nombre("D")
+ .build())
+ // Sin conductor
+ .vehiculo(Vehicle.builder().placa("XYZ-789").build())
+ .envio(Envio.builder()
+ .tipoTraslado("01")
+ .pesoTotal(BigDecimal.ONE)
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado("01")
+ .fechaTraslado(LocalDate.now())
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(BigDecimal.ONE)
+ .unidadMedida("NIU")
+ .codigo("001")
+ .build())
+ .build();
+
+ ValidationResult result = gre.validateDetailed();
+ assertFalse(result.isValid());
+ assertTrue(result.getErrors().stream().anyMatch(e -> e.contains("conductor")));
+ }
+ }
+}
diff --git a/xbuilder/core/src/test/java/unit/validator/DespatchAdviceValidatorTest.java b/xbuilder/core/src/test/java/unit/validator/DespatchAdviceValidatorTest.java
new file mode 100644
index 00000000..3cbac193
--- /dev/null
+++ b/xbuilder/core/src/test/java/unit/validator/DespatchAdviceValidatorTest.java
@@ -0,0 +1,259 @@
+package unit.validator;
+
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog18;
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog20;
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog6;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.*;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests unitarios para {@link DespatchAdviceValidator}.
+ *
+ * Valida las reglas de negocio SUNAT sin depender del renderizado XML.
+ */
+public class DespatchAdviceValidatorTest {
+
+ private static DespatchAdvice.DespatchAdviceBuilder minimalGRERemitente() {
+ return DespatchAdvice.builder()
+ .serie("T001")
+ .numero(1)
+ .tipoComprobante("09")
+ .remitente(Remitente.builder()
+ .ruc("12345678912")
+ .razonSocial("Test S.A.C.")
+ .build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad(Catalog6.DNI.getCode())
+ .numeroDocumentoIdentidad("12345678")
+ .nombre("Cliente")
+ .build())
+ .envio(Envio.builder()
+ .tipoTraslado(Catalog20.VENTA.getCode())
+ .pesoTotal(BigDecimal.ONE)
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado(Catalog18.TRANSPORTE_PRIVADO.getCode())
+ .fechaTraslado(LocalDate.now())
+ .chofer(Driver.builder()
+ .tipoDocumentoIdentidad(Catalog6.DNI.getCode())
+ .numeroDocumentoIdentidad("11111111")
+ .nombres("Juan")
+ .apellidos("Perez")
+ .licencia("Q1234567")
+ .build())
+ .vehiculo(Vehicle.builder()
+ .placa("ABC-123")
+ .build())
+ .partida(Partida.builder().ubigeo("010101").direccion("Origen").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("Destino").build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(BigDecimal.ONE)
+ .unidadMedida("NIU")
+ .codigo("001")
+ .build());
+ }
+
+ @Test
+ public void testValidGRERemitente() {
+ DespatchAdvice da = minimalGRERemitente().build();
+ List errors = DespatchAdviceValidator.validate(da);
+ assertTrue(errors.isEmpty(), "GRE-Remitente válido: " + errors);
+ }
+
+ @Test
+ public void testSerieInvalida_GRERemitente() {
+ DespatchAdvice da = minimalGRERemitente()
+ .serie("V001") // Serie V no corresponde a tipo 09
+ .build();
+ List errors = DespatchAdviceValidator.validate(da);
+ assertTrue(errors.stream().anyMatch(e -> e.contains("GRE-Remitente (09) requiere serie que inicie con 'T'")),
+ "Debe detectar serie inválida para GRE-Remitente");
+ }
+
+ @Test
+ public void testSerieInvalida_GRETransportista() {
+ DespatchAdvice da = minimalGRERemitente()
+ .serie("T001")
+ .tipoComprobante("31") // Transportista requiere V*
+ .build();
+ List errors = DespatchAdviceValidator.validate(da);
+ assertTrue(
+ errors.stream().anyMatch(e -> e.contains("GRE-Transportista (31) requiere serie que inicie con 'V'")),
+ "Debe detectar serie inválida para GRE-Transportista");
+ }
+
+ @Test
+ public void testTransportePrivadoSinConductor() {
+ DespatchAdvice da = DespatchAdvice.builder()
+ .serie("T001")
+ .numero(1)
+ .tipoComprobante("09")
+ .remitente(Remitente.builder().ruc("12345678912").razonSocial("Test").build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad("1").numeroDocumentoIdentidad("12345678").nombre("C").build())
+ .envio(Envio.builder()
+ .tipoTraslado("01")
+ .pesoTotal(BigDecimal.ONE)
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado(Catalog18.TRANSPORTE_PRIVADO.getCode()) // "02"
+ .fechaTraslado(LocalDate.now())
+ // Sin conductor ni vehículo
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build())
+ .detalle(
+ DespatchAdviceItem.builder().cantidad(BigDecimal.ONE).unidadMedida("NIU").codigo("001").build())
+ .build();
+
+ List errors = DespatchAdviceValidator.validate(da);
+ assertTrue(errors.stream().anyMatch(e -> e.contains("conductor")),
+ "Transporte privado requiere conductor");
+ assertTrue(errors.stream().anyMatch(e -> e.contains("vehículo")),
+ "Transporte privado requiere vehículo");
+ }
+
+ @Test
+ public void testTransportePublicoSinTransportista() {
+ DespatchAdvice da = DespatchAdvice.builder()
+ .serie("T001")
+ .numero(1)
+ .tipoComprobante("09")
+ .remitente(Remitente.builder().ruc("12345678912").razonSocial("Test").build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad("1").numeroDocumentoIdentidad("12345678").nombre("C").build())
+ .envio(Envio.builder()
+ .tipoTraslado("01")
+ .pesoTotal(BigDecimal.ONE)
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado(Catalog18.TRANSPORTE_PUBLICO.getCode()) // "01"
+ .fechaTraslado(LocalDate.now())
+ // Sin transportista
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build())
+ .detalle(
+ DespatchAdviceItem.builder().cantidad(BigDecimal.ONE).unidadMedida("NIU").codigo("001").build())
+ .build();
+
+ List errors = DespatchAdviceValidator.validate(da);
+ assertTrue(errors.stream().anyMatch(e -> e.contains("transportista")),
+ "Transporte público requiere datos del transportista");
+ }
+
+ @Test
+ public void testGRETransportistaSinConductor() {
+ DespatchAdvice da = DespatchAdvice.builder()
+ .serie("V001")
+ .numero(1)
+ .tipoComprobante("31")
+ .remitente(Remitente.builder().ruc("20123456789").razonSocial("Transportes S.A.C.").build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad("6").numeroDocumentoIdentidad("20876543210").nombre("C").build())
+ .tercero(Tercero.builder()
+ .tipoDocumentoIdentidad("6").numeroDocumentoIdentidad("20555555555")
+ .nombre("Remitente Original").build())
+ .envio(Envio.builder()
+ .tipoTraslado("01")
+ .pesoTotal(BigDecimal.ONE)
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado(Catalog18.TRANSPORTE_PUBLICO.getCode())
+ .fechaTraslado(LocalDate.now())
+ // Sin conductor ni vehículo (obligatorio para transportista)
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build())
+ .detalle(
+ DespatchAdviceItem.builder().cantidad(BigDecimal.ONE).unidadMedida("NIU").codigo("001").build())
+ .build();
+
+ List errors = DespatchAdviceValidator.validate(da);
+ assertTrue(errors.stream().anyMatch(e -> e.contains("GRE-Transportista requiere al menos un conductor")),
+ "GRE-Transportista siempre requiere conductor");
+ assertTrue(errors.stream().anyMatch(e -> e.contains("GRE-Transportista requiere datos del vehículo")),
+ "GRE-Transportista siempre requiere vehículo");
+ }
+
+ @Test
+ public void testGRETransportistaSinTercero() {
+ DespatchAdvice da = DespatchAdvice.builder()
+ .serie("V001")
+ .numero(1)
+ .tipoComprobante("31")
+ .remitente(Remitente.builder().ruc("20123456789").razonSocial("Transportes S.A.C.").build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad("6").numeroDocumentoIdentidad("20876543210").nombre("C").build())
+ // Sin tercero ni proveedor - se espera recomendación
+ .envio(Envio.builder()
+ .tipoTraslado("01")
+ .pesoTotal(BigDecimal.ONE)
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado("01")
+ .fechaTraslado(LocalDate.now())
+ .chofer(Driver.builder()
+ .tipoDocumentoIdentidad("1").numeroDocumentoIdentidad("11111111")
+ .nombres("J").apellidos("P").licencia("Q1234567").build())
+ .vehiculo(Vehicle.builder().placa("ABC-123").build())
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build())
+ .detalle(
+ DespatchAdviceItem.builder().cantidad(BigDecimal.ONE).unidadMedida("NIU").codigo("001").build())
+ .build();
+
+ List errors = DespatchAdviceValidator.validate(da);
+ assertTrue(errors.stream().anyMatch(e -> e.contains("tercero")),
+ "GRE-Transportista debe advertir que falta el tercero");
+ }
+
+ @Test
+ public void testSinDetalles() {
+ DespatchAdvice da = DespatchAdvice.builder()
+ .serie("T001")
+ .numero(1)
+ .tipoComprobante("09")
+ .remitente(Remitente.builder().ruc("12345678912").razonSocial("Test").build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad("1").numeroDocumentoIdentidad("12345678").nombre("C").build())
+ .envio(Envio.builder()
+ .tipoTraslado("01").pesoTotal(BigDecimal.ONE).pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado("02").fechaTraslado(LocalDate.now())
+ .chofer(Driver.builder().tipoDocumentoIdentidad("1").numeroDocumentoIdentidad("11111111")
+ .nombres("J").apellidos("P").licencia("Q1234567").build())
+ .vehiculo(Vehicle.builder().placa("ABC-123").build())
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build())
+ .build();
+
+ List errors = DespatchAdviceValidator.validate(da);
+ assertTrue(errors.stream().anyMatch(e -> e.contains("línea de detalle")),
+ "Debe requerir al menos una línea de detalle");
+ }
+
+ @Test
+ public void testRUCInvalido() {
+ DespatchAdvice da = minimalGRERemitente().build();
+ da.getRemitente().setRuc("123"); // RUC inválido
+
+ List errors = DespatchAdviceValidator.validate(da);
+ assertTrue(errors.stream().anyMatch(e -> e.contains("11 dígitos")),
+ "Debe rechazar RUC con longitud incorrecta");
+ }
+
+ @Test
+ public void testHelperMethods() {
+ DespatchAdvice remitente = DespatchAdvice.builder().tipoComprobante("09").build();
+ assertTrue(remitente.isGRERemitente());
+ assertFalse(remitente.isGRETransportista());
+
+ DespatchAdvice transportista = DespatchAdvice.builder().tipoComprobante("31").build();
+ assertFalse(transportista.isGRERemitente());
+ assertTrue(transportista.isGRETransportista());
+ }
+}
diff --git a/xbuilder/core/src/test/java/unit/validator/GRERemitenteValidatorTest.java b/xbuilder/core/src/test/java/unit/validator/GRERemitenteValidatorTest.java
new file mode 100644
index 00000000..fb067354
--- /dev/null
+++ b/xbuilder/core/src/test/java/unit/validator/GRERemitenteValidatorTest.java
@@ -0,0 +1,312 @@
+package unit.validator;
+
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog18;
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog20;
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog6;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.*;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests unitarios para la validación de {@link GRERemitente}.
+ */
+public class GRERemitenteValidatorTest {
+
+ private static GRERemitente.GRERemitenteBuilder minimalPrivado() {
+ return GRERemitente.builder()
+ .serie("T001")
+ .numero(1)
+ .remitente(Remitente.builder().ruc("12345678912").razonSocial("Test S.A.C.").build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad(Catalog6.DNI.getCode())
+ .numeroDocumentoIdentidad("12345678").nombre("Cliente").build())
+ .envio(Envio.builder()
+ .tipoTraslado(Catalog20.VENTA.getCode())
+ .pesoTotal(BigDecimal.ONE).pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado(Catalog18.TRANSPORTE_PRIVADO.getCode())
+ .fechaTraslado(LocalDate.now())
+ .chofer(Driver.builder()
+ .tipoDocumentoIdentidad("1").numeroDocumentoIdentidad("11111111")
+ .nombres("J").apellidos("P").licencia("Q123").build())
+ .vehiculo(Vehicle.builder().placa("ABC-123").build())
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(BigDecimal.ONE).unidadMedida("NIU").codigo("001").build());
+ }
+
+ private static GRERemitente.GRERemitenteBuilder minimalPublico() {
+ return GRERemitente.builder()
+ .serie("T001")
+ .numero(1)
+ .remitente(Remitente.builder().ruc("12345678912").razonSocial("Test S.A.C.").build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20200020002").nombre("Cliente S.A.").build())
+ .envio(Envio.builder()
+ .tipoTraslado(Catalog20.VENTA.getCode())
+ .pesoTotal(BigDecimal.ONE).pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado(Catalog18.TRANSPORTE_PUBLICO.getCode())
+ .fechaTraslado(LocalDate.now())
+ .transportista(Transportista.builder()
+ .tipoDocumentoIdentidad("6").numeroDocumentoIdentidad("20300030003")
+ .nombre("Transportes S.A.C.").build())
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(BigDecimal.ONE).unidadMedida("NIU").codigo("001").build());
+ }
+
+ @Nested
+ class ValidCases {
+
+ @Test
+ public void testGRERemitentePrivadoValido() {
+ GRERemitente gre = minimalPrivado().build();
+ List errors = gre.validate();
+ assertTrue(errors.isEmpty(), "Debería ser válido: " + errors);
+ }
+
+ @Test
+ public void testGRERemitentePublicoValido() {
+ GRERemitente gre = minimalPublico().build();
+ List errors = gre.validate();
+ assertTrue(errors.isEmpty(), "Debería ser válido: " + errors);
+ }
+
+ @Test
+ public void testVehiculoM1LSinConductor() {
+ GRERemitente gre = GRERemitente.builder()
+ .serie("T001").numero(1)
+ .remitente(Remitente.builder().ruc("12345678912").razonSocial("Test").build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad("1").numeroDocumentoIdentidad("12345678").nombre("C").build())
+ .envio(Envio.builder()
+ .tipoTraslado("01").pesoTotal(BigDecimal.ONE).pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado("02").fechaTraslado(LocalDate.now())
+ .indicador("SUNAT_Envio_IndicadorTrasladoVehiculoM1L")
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(BigDecimal.ONE).unidadMedida("NIU").codigo("001").build())
+ .build();
+ List errors = gre.validate();
+ assertTrue(errors.isEmpty(), "M1/L no requiere conductor: " + errors);
+ }
+ }
+
+ @Nested
+ class SerieValidation {
+
+ @Test
+ public void testSerieFaltante() {
+ GRERemitente gre = minimalPrivado().serie(null).build();
+ List errors = gre.validate();
+ assertTrue(errors.stream().anyMatch(e -> e.contains("serie")));
+ }
+
+ @Test
+ public void testSerieInvalidaV() {
+ GRERemitente gre = minimalPrivado().serie("V001").build();
+ List errors = gre.validate();
+ assertTrue(errors.stream().anyMatch(e -> e.contains("inicie con 'T'")));
+ }
+
+ @Test
+ public void testNumeroInvalido() {
+ GRERemitente gre = minimalPrivado().numero(0).build();
+ List errors = gre.validate();
+ assertTrue(errors.stream().anyMatch(e -> e.contains("mayor a 0")));
+ }
+ }
+
+ @Nested
+ class PartyValidation {
+
+ @Test
+ public void testRemitenteFaltante() {
+ GRERemitente gre = minimalPrivado().remitente(null).build();
+ List errors = gre.validate();
+ assertTrue(errors.stream().anyMatch(e -> e.contains("remitente")));
+ }
+
+ @Test
+ public void testRUCInvalido() {
+ GRERemitente gre = minimalPrivado()
+ .remitente(Remitente.builder().ruc("123").razonSocial("X").build())
+ .build();
+ List errors = gre.validate();
+ assertTrue(errors.stream().anyMatch(e -> e.contains("11 dígitos")));
+ }
+
+ @Test
+ public void testDestinatarioFaltante() {
+ GRERemitente gre = minimalPrivado().destinatario(null).build();
+ List errors = gre.validate();
+ assertTrue(errors.stream().anyMatch(e -> e.contains("destinatario")));
+ }
+ }
+
+ @Nested
+ class TransportePrivadoValidation {
+
+ @Test
+ public void testSinConductor() {
+ GRERemitente gre = GRERemitente.builder()
+ .serie("T001").numero(1)
+ .remitente(Remitente.builder().ruc("12345678912").razonSocial("Test").build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad("1").numeroDocumentoIdentidad("12345678").nombre("C").build())
+ .envio(Envio.builder()
+ .tipoTraslado("01").pesoTotal(BigDecimal.ONE).pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado("02").fechaTraslado(LocalDate.now())
+ .vehiculo(Vehicle.builder().placa("ABC-123").build())
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(BigDecimal.ONE).unidadMedida("NIU").codigo("001").build())
+ .build();
+ List errors = gre.validate();
+ assertTrue(errors.stream().anyMatch(e -> e.contains("conductor")));
+ }
+
+ @Test
+ public void testSinVehiculo() {
+ GRERemitente gre = GRERemitente.builder()
+ .serie("T001").numero(1)
+ .remitente(Remitente.builder().ruc("12345678912").razonSocial("Test").build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad("1").numeroDocumentoIdentidad("12345678").nombre("C").build())
+ .envio(Envio.builder()
+ .tipoTraslado("01").pesoTotal(BigDecimal.ONE).pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado("02").fechaTraslado(LocalDate.now())
+ .chofer(Driver.builder().tipoDocumentoIdentidad("1").numeroDocumentoIdentidad("11111111")
+ .nombres("J").apellidos("P").licencia("Q123").build())
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(BigDecimal.ONE).unidadMedida("NIU").codigo("001").build())
+ .build();
+ List errors = gre.validate();
+ assertTrue(errors.stream().anyMatch(e -> e.contains("vehículo")));
+ }
+
+ @Test
+ public void testPrivadoConTransportista() {
+ // Transporte privado NO debe tener transportista externo
+ GRERemitente gre = minimalPrivado().envio(Envio.builder()
+ .tipoTraslado("01").pesoTotal(BigDecimal.ONE).pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado("02").fechaTraslado(LocalDate.now())
+ .chofer(Driver.builder().tipoDocumentoIdentidad("1").numeroDocumentoIdentidad("11111111")
+ .nombres("J").apellidos("P").licencia("Q123").build())
+ .vehiculo(Vehicle.builder().placa("ABC-123").build())
+ .transportista(Transportista.builder()
+ .tipoDocumentoIdentidad("6").numeroDocumentoIdentidad("20300030003")
+ .nombre("Trans S.A.C.").build())
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build()).build();
+ List errors = gre.validate();
+ assertTrue(errors.stream().anyMatch(e -> e.contains("Transporte privado no debe consignar transportista")));
+ }
+ }
+
+ @Nested
+ class TransportePublicoValidation {
+
+ @Test
+ public void testSinTransportista() {
+ GRERemitente gre = GRERemitente.builder()
+ .serie("T001").numero(1)
+ .remitente(Remitente.builder().ruc("12345678912").razonSocial("Test").build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad("1").numeroDocumentoIdentidad("12345678").nombre("C").build())
+ .envio(Envio.builder()
+ .tipoTraslado("01").pesoTotal(BigDecimal.ONE).pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado("01").fechaTraslado(LocalDate.now())
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(BigDecimal.ONE).unidadMedida("NIU").codigo("001").build())
+ .build();
+ List errors = gre.validate();
+ assertTrue(errors.stream().anyMatch(e -> e.contains("transportista")));
+ }
+ }
+
+ @Nested
+ class EnvioValidation {
+
+ @Test
+ public void testSinEnvio() {
+ GRERemitente gre = minimalPrivado().envio(null).build();
+ List errors = gre.validate();
+ assertTrue(errors.stream().anyMatch(e -> e.contains("envío")));
+ }
+
+ @Test
+ public void testSinDetalles() {
+ GRERemitente gre = GRERemitente.builder()
+ .serie("T001").numero(1)
+ .remitente(Remitente.builder().ruc("12345678912").razonSocial("Test").build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad("1").numeroDocumentoIdentidad("12345678").nombre("C").build())
+ .envio(Envio.builder()
+ .tipoTraslado("01").pesoTotal(BigDecimal.ONE).pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado("02").fechaTraslado(LocalDate.now())
+ .chofer(Driver.builder().tipoDocumentoIdentidad("1").numeroDocumentoIdentidad("11111111")
+ .nombres("J").apellidos("P").licencia("Q123").build())
+ .vehiculo(Vehicle.builder().placa("ABC-123").build())
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build())
+ .build();
+ List errors = gre.validate();
+ assertTrue(errors.stream().anyMatch(e -> e.contains("detalle")));
+ }
+ }
+
+ @Nested
+ class ConversionTests {
+
+ @Test
+ public void testToDespatchAdvice() {
+ GRERemitente gre = minimalPrivado().build();
+ DespatchAdvice da = gre.toDespatchAdvice();
+
+ assertEquals("09", da.getTipoComprobante());
+ assertEquals("T001", da.getSerie());
+ assertEquals(1, da.getNumero());
+ assertNotNull(da.getRemitente());
+ assertNotNull(da.getDestinatario());
+ assertNotNull(da.getEnvio());
+ assertFalse(da.getDetalles().isEmpty());
+ }
+
+ @Test
+ public void testToDespatchAdviceValidatedSuccess() {
+ GRERemitente gre = minimalPrivado().build();
+ DespatchAdvice da = gre.toDespatchAdviceValidated();
+ assertNotNull(da);
+ assertTrue(da.isGRERemitente());
+ }
+
+ @Test
+ public void testToDespatchAdviceValidatedFails() {
+ GRERemitente gre = minimalPrivado().serie("V001").build();
+ assertThrows(IllegalStateException.class, gre::toDespatchAdviceValidated);
+ }
+ }
+}
diff --git a/xbuilder/core/src/test/java/unit/validator/GRETransportistaValidatorTest.java b/xbuilder/core/src/test/java/unit/validator/GRETransportistaValidatorTest.java
new file mode 100644
index 00000000..811e65e5
--- /dev/null
+++ b/xbuilder/core/src/test/java/unit/validator/GRETransportistaValidatorTest.java
@@ -0,0 +1,241 @@
+package unit.validator;
+
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog18;
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog20;
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog6;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.*;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests unitarios para la validación de {@link GRETransportista}.
+ */
+public class GRETransportistaValidatorTest {
+
+ private static GRETransportista.GRETransportistaBuilder minimalTransportista() {
+ return GRETransportista.builder()
+ .serie("V001")
+ .numero(1)
+ .transportistaEmisor(Transportista.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20300030003").nombre("Transportes S.A.C.").build())
+ .remitente(Tercero.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20100010001").nombre("Remitente S.A.C.").build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20200020002").nombre("Destino S.A.").build())
+ .conductor(Driver.builder()
+ .tipoDocumentoIdentidad("1").numeroDocumentoIdentidad("44444444")
+ .nombres("M").apellidos("T").licencia("Q444").build())
+ .vehiculo(Vehicle.builder().placa("XYZ-789").build())
+ .envio(Envio.builder()
+ .tipoTraslado(Catalog20.VENTA.getCode())
+ .pesoTotal(BigDecimal.ONE).pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado(Catalog18.TRANSPORTE_PUBLICO.getCode())
+ .fechaTraslado(LocalDate.now())
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(BigDecimal.ONE).unidadMedida("NIU").codigo("001").build());
+ }
+
+ @Nested
+ class ValidCases {
+
+ @Test
+ public void testTransportistaValido() {
+ GRETransportista gre = minimalTransportista().build();
+ List errors = gre.validate();
+ assertTrue(errors.isEmpty(), "Debería ser válido: " + errors);
+ }
+ }
+
+ @Nested
+ class SerieValidation {
+
+ @Test
+ public void testSerieFaltante() {
+ GRETransportista gre = minimalTransportista().serie(null).build();
+ List errors = gre.validate();
+ assertTrue(errors.stream().anyMatch(e -> e.contains("serie")));
+ }
+
+ @Test
+ public void testSerieInvalidaT() {
+ GRETransportista gre = minimalTransportista().serie("T001").build();
+ List errors = gre.validate();
+ assertTrue(errors.stream().anyMatch(e -> e.contains("inicie con 'V'")));
+ }
+
+ @Test
+ public void testNumeroInvalido() {
+ GRETransportista gre = minimalTransportista().numero(0).build();
+ List errors = gre.validate();
+ assertTrue(errors.stream().anyMatch(e -> e.contains("mayor a 0")));
+ }
+ }
+
+ @Nested
+ class PartyValidation {
+
+ @Test
+ public void testTransportistaEmisorFaltante() {
+ GRETransportista gre = minimalTransportista().transportistaEmisor(null).build();
+ List errors = gre.validate();
+ assertTrue(errors.stream().anyMatch(e -> e.contains("transportista emisor")));
+ }
+
+ @Test
+ public void testRUCTransportistaInvalido() {
+ GRETransportista gre = minimalTransportista()
+ .transportistaEmisor(Transportista.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("123").nombre("X").build())
+ .build();
+ List errors = gre.validate();
+ assertTrue(errors.stream().anyMatch(e -> e.contains("11 dígitos")));
+ }
+
+ @Test
+ public void testRemitenteFaltante() {
+ GRETransportista gre = minimalTransportista().remitente(null).build();
+ List errors = gre.validate();
+ assertTrue(errors.stream().anyMatch(e -> e.contains("remitente original")));
+ }
+
+ @Test
+ public void testDestinatarioFaltante() {
+ GRETransportista gre = minimalTransportista().destinatario(null).build();
+ List errors = gre.validate();
+ assertTrue(errors.stream().anyMatch(e -> e.contains("destinatario")));
+ }
+ }
+
+ @Nested
+ class ConductorVehiculoValidation {
+
+ @Test
+ public void testSinConductor() {
+ GRETransportista gre = GRETransportista.builder()
+ .serie("V001").numero(1)
+ .transportistaEmisor(Transportista.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20300030003").nombre("T").build())
+ .remitente(Tercero.builder().tipoDocumentoIdentidad("6")
+ .numeroDocumentoIdentidad("20100010001").nombre("R").build())
+ .destinatario(Destinatario.builder().tipoDocumentoIdentidad("6")
+ .numeroDocumentoIdentidad("20200020002").nombre("D").build())
+ // Sin conductor
+ .vehiculo(Vehicle.builder().placa("XYZ-789").build())
+ .envio(Envio.builder()
+ .tipoTraslado("01").pesoTotal(BigDecimal.ONE).pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado("01").fechaTraslado(LocalDate.now())
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(BigDecimal.ONE).unidadMedida("NIU").codigo("001").build())
+ .build();
+ List errors = gre.validate();
+ assertTrue(errors.stream().anyMatch(e -> e.contains("conductor")),
+ "Transportista siempre requiere conductor");
+ }
+
+ @Test
+ public void testSinVehiculo() {
+ GRETransportista gre = minimalTransportista().vehiculo(null).build();
+ List errors = gre.validate();
+ assertTrue(errors.stream().anyMatch(e -> e.contains("vehículo")),
+ "Transportista siempre requiere vehículo");
+ }
+ }
+
+ @Nested
+ class EnvioValidation {
+
+ @Test
+ public void testSinEnvio() {
+ GRETransportista gre = minimalTransportista().envio(null).build();
+ List errors = gre.validate();
+ assertTrue(errors.stream().anyMatch(e -> e.contains("envío")));
+ }
+
+ @Test
+ public void testSinDetalles() {
+ GRETransportista gre = GRETransportista.builder()
+ .serie("V001").numero(1)
+ .transportistaEmisor(Transportista.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20300030003").nombre("T").build())
+ .remitente(Tercero.builder().tipoDocumentoIdentidad("6")
+ .numeroDocumentoIdentidad("20100010001").nombre("R").build())
+ .destinatario(Destinatario.builder().tipoDocumentoIdentidad("6")
+ .numeroDocumentoIdentidad("20200020002").nombre("D").build())
+ .conductor(Driver.builder().tipoDocumentoIdentidad("1").numeroDocumentoIdentidad("44444444")
+ .nombres("M").apellidos("T").licencia("Q444").build())
+ .vehiculo(Vehicle.builder().placa("XYZ-789").build())
+ .envio(Envio.builder()
+ .tipoTraslado("01").pesoTotal(BigDecimal.ONE).pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado("01").fechaTraslado(LocalDate.now())
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build())
+ .build();
+ List errors = gre.validate();
+ assertTrue(errors.stream().anyMatch(e -> e.contains("detalle")));
+ }
+ }
+
+ @Nested
+ class ConversionTests {
+
+ @Test
+ public void testToDespatchAdvice() {
+ GRETransportista gre = minimalTransportista().build();
+ DespatchAdvice da = gre.toDespatchAdvice();
+
+ assertEquals("31", da.getTipoComprobante());
+ assertEquals("V001", da.getSerie());
+ assertEquals(1, da.getNumero());
+ assertTrue(da.isGRETransportista());
+ assertNotNull(da.getTercero(), "Tercero debe estar presente");
+ assertNotNull(da.getEnvio().getChoferes(), "Conductores deben inyectarse en envío");
+ assertFalse(da.getEnvio().getChoferes().isEmpty());
+ assertNotNull(da.getEnvio().getVehiculo(), "Vehículo debe inyectarse en envío");
+ }
+
+ @Test
+ public void testConductoresInyectadosEnEnvio() {
+ GRETransportista gre = minimalTransportista()
+ .conductor(Driver.builder()
+ .tipoDocumentoIdentidad("1").numeroDocumentoIdentidad("99999999")
+ .nombres("Extra").apellidos("Driver").licencia("Q999").build())
+ .build();
+ DespatchAdvice da = gre.toDespatchAdvice();
+ assertEquals(2, da.getEnvio().getChoferes().size(),
+ "Ambos conductores deben inyectarse en el envío");
+ }
+
+ @Test
+ public void testToDespatchAdviceValidatedSuccess() {
+ GRETransportista gre = minimalTransportista().build();
+ DespatchAdvice da = gre.toDespatchAdviceValidated();
+ assertNotNull(da);
+ assertTrue(da.isGRETransportista());
+ }
+
+ @Test
+ public void testToDespatchAdviceValidatedFails() {
+ GRETransportista gre = minimalTransportista().serie("T001").build();
+ assertThrows(IllegalStateException.class, gre::toDespatchAdviceValidated);
+ }
+ }
+}
diff --git a/xbuilder/core/src/test/java/unit/validator/ValidationCoherenceTest.java b/xbuilder/core/src/test/java/unit/validator/ValidationCoherenceTest.java
new file mode 100644
index 00000000..063accb2
--- /dev/null
+++ b/xbuilder/core/src/test/java/unit/validator/ValidationCoherenceTest.java
@@ -0,0 +1,368 @@
+package unit.validator;
+
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog18;
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog20;
+import io.github.project.openubl.xbuilder.content.catalogs.Catalog6;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.*;
+import io.github.project.openubl.xbuilder.content.models.standard.guia.validation.ValidationResult;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests de integración que verifican la coherencia entre los tres validadores (DespatchAdviceValidator, GRERemitente,
+ * GRETransportista) después de la centralización en DespatchAdviceCommonValidator.
+ *
+ * Asegura que:
+ *
+ * - La misma regla produce el mismo resultado en los tres puntos de entrada
+ * - Los mensajes de error tienen el texto esperado
+ * - La conversión toDespatchAdvice() + validación produce resultados coherentes
+ * - validate() y validateDetailed() son consistentes
+ *
+ */
+public class ValidationCoherenceTest {
+
+ // ================================================================
+ // Helpers
+ // ================================================================
+
+ private static GRERemitente.GRERemitenteBuilder minimalRemitente() {
+ return GRERemitente.builder()
+ .serie("T001")
+ .numero(1)
+ .remitente(Remitente.builder().ruc("12345678912").razonSocial("Test S.A.C.").build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad(Catalog6.DNI.getCode())
+ .numeroDocumentoIdentidad("12345678")
+ .nombre("Cliente")
+ .build())
+ .envio(Envio.builder()
+ .tipoTraslado(Catalog20.VENTA.getCode())
+ .pesoTotal(BigDecimal.ONE)
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado(Catalog18.TRANSPORTE_PRIVADO.getCode())
+ .fechaTraslado(LocalDate.now())
+ .chofer(Driver.builder()
+ .tipoDocumentoIdentidad("1")
+ .numeroDocumentoIdentidad("11111111")
+ .nombres("J")
+ .apellidos("P")
+ .licencia("Q123")
+ .build())
+ .vehiculo(Vehicle.builder().placa("ABC-123").build())
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(BigDecimal.ONE)
+ .unidadMedida("NIU")
+ .codigo("001")
+ .build());
+ }
+
+ private static GRETransportista.GRETransportistaBuilder minimalTransportista() {
+ return GRETransportista.builder()
+ .serie("V001")
+ .numero(1)
+ .transportistaEmisor(Transportista.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20300030003")
+ .nombre("Transportes S.A.C.")
+ .build())
+ .remitente(Tercero.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20100010001")
+ .nombre("Remitente S.A.C.")
+ .build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad(Catalog6.RUC.getCode())
+ .numeroDocumentoIdentidad("20200020002")
+ .nombre("Destino S.A.")
+ .build())
+ .conductor(Driver.builder()
+ .tipoDocumentoIdentidad("1")
+ .numeroDocumentoIdentidad("44444444")
+ .nombres("M")
+ .apellidos("T")
+ .licencia("Q444")
+ .build())
+ .vehiculo(Vehicle.builder().placa("XYZ-789").build())
+ .envio(Envio.builder()
+ .tipoTraslado(Catalog20.VENTA.getCode())
+ .pesoTotal(BigDecimal.ONE)
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado(Catalog18.TRANSPORTE_PUBLICO.getCode())
+ .fechaTraslado(LocalDate.now())
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(BigDecimal.ONE)
+ .unidadMedida("NIU")
+ .codigo("001")
+ .build());
+ }
+
+ // ================================================================
+ // Coherencia: GRERemitente.validate() ↔ DespatchAdviceValidator.validate()
+ // ================================================================
+
+ @Nested
+ class RemitenteCoherence {
+
+ @Test
+ public void testValidRemitentePassesBothValidators() {
+ GRERemitente gre = minimalRemitente().build();
+
+ // GRERemitente propio
+ List remitenteErrors = gre.validate();
+ assertTrue(remitenteErrors.isEmpty(), "GRERemitente.validate: " + remitenteErrors);
+
+ // Post-conversión
+ DespatchAdvice da = gre.toDespatchAdvice();
+ List daErrors = DespatchAdviceValidator.validate(da);
+ assertTrue(daErrors.isEmpty(), "DespatchAdviceValidator.validate: " + daErrors);
+ }
+
+ @Test
+ public void testInvalidSerieDetectedByBoth() {
+ GRERemitente gre = minimalRemitente().serie("V001").build();
+
+ List remitenteErrors = gre.validate();
+ assertTrue(remitenteErrors.stream().anyMatch(e -> e.contains("'T'")),
+ "GRERemitente debe detectar serie inválida");
+
+ DespatchAdvice da = gre.toDespatchAdvice();
+ List daErrors = DespatchAdviceValidator.validate(da);
+ assertTrue(daErrors.stream().anyMatch(e -> e.contains("GRE-Remitente (09)")),
+ "DespatchAdviceValidator debe detectar serie incoherente con tipo 09");
+ }
+
+ @Test
+ public void testRucInvalidoDetectedByBoth() {
+ GRERemitente gre = minimalRemitente()
+ .remitente(Remitente.builder().ruc("123").razonSocial("X").build())
+ .build();
+
+ List remitenteErrors = gre.validate();
+ assertTrue(remitenteErrors.stream().anyMatch(e -> e.contains("11 dígitos")));
+
+ DespatchAdvice da = gre.toDespatchAdvice();
+ List daErrors = DespatchAdviceValidator.validate(da);
+ assertTrue(daErrors.stream().anyMatch(e -> e.contains("11 dígitos")));
+ }
+
+ @Test
+ public void testValidateAndValidateDetailedAreConsistent() {
+ GRERemitente gre = minimalRemitente().serie(null).build();
+
+ List errors = gre.validate();
+ ValidationResult result = gre.validateDetailed();
+
+ assertEquals(errors, result.getErrors(),
+ "validate() y validateDetailed().getErrors() deben coincidir");
+ }
+ }
+
+ // ================================================================
+ // Coherencia: GRETransportista.validate() ↔ DespatchAdviceValidator.validate()
+ // ================================================================
+
+ @Nested
+ class TransportistaCoherence {
+
+ @Test
+ public void testValidTransportistaPassesBothValidators() {
+ GRETransportista gre = minimalTransportista().build();
+
+ List transportistaErrors = gre.validate();
+ assertTrue(transportistaErrors.isEmpty(),
+ "GRETransportista.validate: " + transportistaErrors);
+
+ DespatchAdvice da = gre.toDespatchAdvice();
+ List daErrors = DespatchAdviceValidator.validate(da);
+ assertTrue(daErrors.isEmpty(),
+ "DespatchAdviceValidator.validate: " + daErrors);
+ }
+
+ @Test
+ public void testInvalidSerieDetectedByBoth() {
+ GRETransportista gre = minimalTransportista().serie("T001").build();
+
+ List transportistaErrors = gre.validate();
+ assertTrue(transportistaErrors.stream().anyMatch(e -> e.contains("'V'")));
+
+ DespatchAdvice da = gre.toDespatchAdvice();
+ List daErrors = DespatchAdviceValidator.validate(da);
+ assertTrue(daErrors.stream().anyMatch(e -> e.contains("GRE-Transportista (31)")));
+ }
+
+ @Test
+ public void testConversionInjectsConductoresAndVehiculo() {
+ GRETransportista gre = minimalTransportista()
+ .conductor(Driver.builder()
+ .tipoDocumentoIdentidad("1")
+ .numeroDocumentoIdentidad("99999999")
+ .nombres("Extra")
+ .apellidos("D")
+ .licencia("Q999")
+ .build())
+ .build();
+
+ DespatchAdvice da = gre.toDespatchAdvice();
+ assertNotNull(da.getEnvio().getChoferes());
+ assertEquals(2, da.getEnvio().getChoferes().size(),
+ "Conductores deben inyectarse en envío");
+ assertNotNull(da.getEnvio().getVehiculo(),
+ "Vehículo debe inyectarse en envío");
+ }
+
+ @Test
+ public void testValidateAndValidateDetailedAreConsistent() {
+ GRETransportista gre = minimalTransportista()
+ .transportistaEmisor(null)
+ .build();
+
+ List errors = gre.validate();
+ ValidationResult result = gre.validateDetailed();
+
+ assertEquals(errors, result.getErrors());
+ }
+ }
+
+ // ================================================================
+ // toDespatchAdviceValidated() integration
+ // ================================================================
+
+ @Nested
+ class ValidatedConversion {
+
+ @Test
+ public void testRemitenteValidatedSuccess() {
+ GRERemitente gre = minimalRemitente().build();
+ DespatchAdvice da = gre.toDespatchAdviceValidated();
+ assertNotNull(da);
+ assertEquals("09", da.getTipoComprobante());
+ assertTrue(da.isGRERemitente());
+ }
+
+ @Test
+ public void testRemitenteValidatedFails() {
+ GRERemitente gre = minimalRemitente().serie("V001").build();
+ IllegalStateException ex = assertThrows(IllegalStateException.class,
+ gre::toDespatchAdviceValidated);
+ assertTrue(ex.getMessage().contains("GRE-Remitente inválido"));
+ }
+
+ @Test
+ public void testTransportistaValidatedSuccess() {
+ GRETransportista gre = minimalTransportista().build();
+ DespatchAdvice da = gre.toDespatchAdviceValidated();
+ assertNotNull(da);
+ assertEquals("31", da.getTipoComprobante());
+ assertTrue(da.isGRETransportista());
+ }
+
+ @Test
+ public void testTransportistaValidatedFails() {
+ GRETransportista gre = minimalTransportista().serie("T001").build();
+ IllegalStateException ex = assertThrows(IllegalStateException.class,
+ gre::toDespatchAdviceValidated);
+ assertTrue(ex.getMessage().contains("GRE-Transportista inválido"));
+ }
+ }
+
+ // ================================================================
+ // Severidad uniformidad
+ // ================================================================
+
+ @Nested
+ class SeverityUniformity {
+
+ @Test
+ public void testComercioExteriorWarningsInAllThreeValidators() {
+ // DespatchAdviceValidator
+ DespatchAdvice da = DespatchAdvice.builder()
+ .serie("T001")
+ .numero(1)
+ .tipoComprobante("09")
+ .remitente(Remitente.builder().ruc("12345678912").razonSocial("T").build())
+ .destinatario(Destinatario.builder()
+ .tipoDocumentoIdentidad("1")
+ .numeroDocumentoIdentidad("12345678")
+ .nombre("C")
+ .build())
+ .envio(Envio.builder()
+ .tipoTraslado("08") // Importación
+ .pesoTotal(BigDecimal.ONE)
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado("02")
+ .fechaTraslado(LocalDate.now())
+ .chofer(Driver.builder()
+ .tipoDocumentoIdentidad("1")
+ .numeroDocumentoIdentidad("11111111")
+ .nombres("J")
+ .apellidos("P")
+ .licencia("Q123")
+ .build())
+ .vehiculo(Vehicle.builder().placa("ABC-123").build())
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build())
+ .detalle(DespatchAdviceItem.builder()
+ .cantidad(BigDecimal.ONE)
+ .unidadMedida("NIU")
+ .codigo("001")
+ .build())
+ .build();
+
+ ValidationResult daResult = DespatchAdviceValidator.validateDetailed(da);
+ assertTrue(daResult.isValid(), "Importación sin DAM: valid (warnings only)");
+ assertTrue(daResult.hasWarnings(), "Debe tener warnings");
+ assertTrue(daResult.getWarnings().stream().anyMatch(w -> w.contains("DAM/DS")));
+
+ // GRERemitente: same
+ GRERemitente gre = minimalRemitente().envio(Envio.builder()
+ .tipoTraslado("08")
+ .pesoTotal(BigDecimal.ONE)
+ .pesoTotalUnidadMedida("KGM")
+ .tipoModalidadTraslado("02")
+ .fechaTraslado(LocalDate.now())
+ .chofer(Driver.builder()
+ .tipoDocumentoIdentidad("1")
+ .numeroDocumentoIdentidad("11111111")
+ .nombres("J")
+ .apellidos("P")
+ .licencia("Q123")
+ .build())
+ .vehiculo(Vehicle.builder().placa("ABC-123").build())
+ .partida(Partida.builder().ubigeo("010101").direccion("O").build())
+ .destino(Destino.builder().ubigeo("020202").direccion("D").build())
+ .build()).build();
+
+ ValidationResult greResult = gre.validateDetailed();
+ assertTrue(greResult.isValid());
+ assertTrue(greResult.hasWarnings());
+ assertTrue(greResult.getWarnings().stream().anyMatch(w -> w.contains("DAM/DS")));
+ }
+
+ @Test
+ public void testHardErrorsNeverDowngradedToWarning() {
+ // Missing serie: must be ERROR everywhere
+ GRERemitente gre = minimalRemitente().serie(null).build();
+ ValidationResult result = gre.validateDetailed();
+ assertTrue(result.hasErrors());
+ assertTrue(result.getMessages()
+ .stream()
+ .filter(m -> m.getMessage().contains("serie"))
+ .allMatch(m -> m
+ .getSeverity() == io.github.project.openubl.xbuilder.content.models.standard.guia.validation.ValidationSeverity.ERROR));
+ }
+ }
+}
diff --git a/xbuilder/core/src/test/resources/e2e/renderer/creditnote/CreditNoteIscTest/isc_sistemaDePreciosDeVentalAlPublico.xml b/xbuilder/core/src/test/resources/e2e/renderer/creditnote/CreditNoteIscTest/isc_sistemaDePreciosDeVentalAlPublico.xml
index 1215b9f5..507f233e 100644
--- a/xbuilder/core/src/test/resources/e2e/renderer/creditnote/CreditNoteIscTest/isc_sistemaDePreciosDeVentalAlPublico.xml
+++ b/xbuilder/core/src/test/resources/e2e/renderer/creditnote/CreditNoteIscTest/isc_sistemaDePreciosDeVentalAlPublico.xml
@@ -119,7 +119,7 @@
20.00
10.00
- 02
+ 03
2000
ISC
diff --git a/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/DespatchAdviceCarrierTest/carrierData.xml b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/DespatchAdviceCarrierTest/carrierData.xml
index 5bdcab82..bdb28c75 100644
--- a/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/DespatchAdviceCarrierTest/carrierData.xml
+++ b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/DespatchAdviceCarrierTest/carrierData.xml
@@ -32,7 +32,6 @@
- 20123456789
20123456789
diff --git a/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/DespatchAdviceComercioExteriorTest/exportacionSinDAM.xml b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/DespatchAdviceComercioExteriorTest/exportacionSinDAM.xml
new file mode 100644
index 00000000..a0e5210d
--- /dev/null
+++ b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/DespatchAdviceComercioExteriorTest/exportacionSinDAM.xml
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+
+ 2.1
+ 2.0
+ T001-201
+ 2019-12-24
+ 09
+
+
+ 20500050005
+
+
+ 20500050005
+
+
+
+
+
+
+
+ #PROJECT-OPENUBL-SIGN
+
+
+
+
+
+
+ 20500050005
+
+
+
+
+
+
+
+
+
+ 20500050005
+
+
+
+
+
+
+
+ SUNAT_Envio
+ 13
+ Traslado para exportación sin DAM numerada
+ 2000.000
+
+ 02
+
+ 2019-12-24
+
+
+ 12345678
+ Pedro
+ Gonzales
+ Principal
+
+ Q9876543
+
+
+
+
+
+ 070101
+
+ Depósito Temporal para exportación
+
+
+
+
+ 150101
+
+ Planta de producción
+
+
+
+
+
+
+ ABC-123
+
+
+
+
+ 1
+ 100.00
+
+ 1
+
+
+
+
+ EXPORT-001
+
+
+
+
diff --git a/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/DespatchAdviceComercioExteriorTest/importacionDAMTotal.xml b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/DespatchAdviceComercioExteriorTest/importacionDAMTotal.xml
new file mode 100644
index 00000000..f63ce4ae
--- /dev/null
+++ b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/DespatchAdviceComercioExteriorTest/importacionDAMTotal.xml
@@ -0,0 +1,129 @@
+
+
+
+
+
+
+
+ 2.1
+ 2.0
+ T001-200
+ 2019-12-24
+ 09
+
+ 118-2024-10-000123
+ 50
+
+
+ 20100010001
+
+
+
+
+ 20100010001
+
+
+ 20100010001
+
+
+
+
+
+
+
+ #PROJECT-OPENUBL-SIGN
+
+
+
+
+
+
+ 20100010001
+
+
+
+
+
+
+
+
+
+ 20100010001
+
+
+
+
+
+
+
+ SUNAT_Envio
+ 10
+ 5000.000
+ SUNAT_Envio_IndicadorTrasladoTotalDAMDS
+
+ 01
+
+ 2019-12-24
+
+
+
+ 20300030003
+
+
+
+ MTC-123456
+
+
+
+
+
+ 150101
+
+ Almacen Deposito Temporal S.A.
+
+
+
+
+ 070101
+
+ Terminal Portuario del Callao
+
+
+
+
+
+
+ CONT-2024-001
+ PREC-001
+
+
+
+
+ CONT-2024-002
+ PREC-002
+
+
+
+ CALLAO
+ 1
+ Puerto del Callao
+
+
+
+ 1
+ 1.00
+
+ 1
+
+
+
+ -
+
+
+
+
diff --git a/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/DespatchAdviceComercioExteriorTest/mercanciaExtranjera.xml b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/DespatchAdviceComercioExteriorTest/mercanciaExtranjera.xml
new file mode 100644
index 00000000..bee5ab3e
--- /dev/null
+++ b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/DespatchAdviceComercioExteriorTest/mercanciaExtranjera.xml
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+ 2.1
+ 2.0
+ T001-202
+ 2019-12-24
+ 09
+
+ 20600060006
+
+
+ 20600060006
+
+
+
+
+
+
+
+ #PROJECT-OPENUBL-SIGN
+
+
+
+
+
+
+ 20600060006
+
+
+
+
+
+
+
+
+
+ 20600060006
+
+
+
+
+
+
+
+ SUNAT_Envio
+ 19
+ 10000.000
+ 1
+
+ 01
+
+ 2019-12-24
+
+
+
+ 20700070007
+
+
+
+
+
+
+
+
+ 070106
+
+ Depósito Temporal del Callao
+
+
+
+
+ 070101
+
+ Terminal Portuario del Callao
+
+
+
+
+
+
+ MSKU1234567
+ SEAL-ABC123
+
+
+
+ CALLAO
+ 1
+ Puerto del Callao
+
+
+
+ 1
+ 1.00
+
+ 1
+
+
+
+
+ CONT-CONSOLIDADO
+
+
+
+
diff --git a/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/DespatchAdviceComplexTest/complexData.xml b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/DespatchAdviceComplexTest/complexData.xml
index 4bfd1add..7cdeb9fb 100644
--- a/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/DespatchAdviceComplexTest/complexData.xml
+++ b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/DespatchAdviceComplexTest/complexData.xml
@@ -42,7 +42,6 @@
- 12345678912
12345678912
diff --git a/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/DespatchAdviceTest/minData.xml b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/DespatchAdviceTest/minData.xml
index 13d36baf..a45e5686 100644
--- a/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/DespatchAdviceTest/minData.xml
+++ b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/DespatchAdviceTest/minData.xml
@@ -32,7 +32,6 @@
- 12345678912
12345678912
diff --git a/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRERemitenteCasuisticasTest/mercanciaExtranjeraMotivo19.xml b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRERemitenteCasuisticasTest/mercanciaExtranjeraMotivo19.xml
new file mode 100644
index 00000000..86b06a42
--- /dev/null
+++ b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRERemitenteCasuisticasTest/mercanciaExtranjeraMotivo19.xml
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+ 2.1
+ 2.0
+ T001-7
+ 2019-12-24
+ 09
+
+ 20500050005
+
+
+ 20500050005
+
+
+
+
+
+
+
+ #PROJECT-OPENUBL-SIGN
+
+
+
+
+
+
+ 20500050005
+
+
+
+
+
+
+
+
+
+ 20500050005
+
+
+
+
+
+
+
+ SUNAT_Envio
+ 19
+ 12000.000
+ SUNAT_Envio_IndicadorTrasladoTotalDAMDS
+
+ 01
+
+ 2019-12-24
+
+
+
+ 20600060006
+
+
+
+
+
+
+
+
+ 150101
+
+ Almacén Depósito Temporal
+
+
+
+
+ 070101
+
+ Terminal Portuario del Callao
+
+
+
+
+
+
+ MSKU-2024-001
+ SEAL-001
+
+
+
+
+ MSKU-2024-002
+ SEAL-002
+
+
+
+ CALLAO
+ 1
+ Puerto del Callao
+
+
+
+ 1
+ 1.00
+
+ 1
+
+
+
+
+ CONT-CONSOLIDADO
+
+
+
+
diff --git a/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRERemitenteCasuisticasTest/multiplesConductores.xml b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRERemitenteCasuisticasTest/multiplesConductores.xml
new file mode 100644
index 00000000..642a3e80
--- /dev/null
+++ b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRERemitenteCasuisticasTest/multiplesConductores.xml
@@ -0,0 +1,117 @@
+
+
+
+
+
+
+
+ 2.1
+ 2.0
+ T001-3
+ 2019-12-24
+ 09
+
+ 20100010001
+
+
+ 20100010001
+
+
+
+
+
+
+
+ #PROJECT-OPENUBL-SIGN
+
+
+
+
+
+
+ 20100010001
+
+
+
+
+
+
+
+
+
+ 20200020002
+
+
+
+
+
+
+
+ SUNAT_Envio
+ 04
+ 1500.000
+
+ 02
+
+ 2019-12-24
+
+
+ 11111111
+ Juan
+ Perez
+ Principal
+
+ Q1111111
+
+
+
+ 22222222
+ Pedro
+ Gomez
+ Secundario
+
+ Q2222222
+
+
+
+
+
+ 130101
+
+ Sucursal Tacna
+
+
+
+
+ 150101
+
+ Planta Principal, Lima
+
+
+
+
+
+
+ DEF-456
+
+
+
+
+ 1
+ 100.00
+
+ 1
+
+
+
+
+ MAT-001
+
+
+
+
diff --git a/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRERemitenteCasuisticasTest/transportePrivadoBasico.xml b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRERemitenteCasuisticasTest/transportePrivadoBasico.xml
new file mode 100644
index 00000000..bb1bb8ae
--- /dev/null
+++ b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRERemitenteCasuisticasTest/transportePrivadoBasico.xml
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+ 2.1
+ 2.0
+ T001-1
+ 2019-12-24
+ 09
+
+ 20100010001
+
+
+ 20100010001
+
+
+
+
+
+
+
+ #PROJECT-OPENUBL-SIGN
+
+
+
+
+
+
+ 20100010001
+
+
+
+
+
+
+
+
+
+ 20200020002
+
+
+
+
+
+
+
+ SUNAT_Envio
+ 01
+ 50.000
+
+ 02
+
+ 2019-12-24
+
+
+ 12345678
+ Carlos
+ Ramirez
+ Principal
+
+ Q1234567
+
+
+
+
+
+ 150102
+
+ Jr. Comercio 789, Rímac
+
+
+
+
+ 150101
+
+ Av. Industrial 456, Lima
+
+
+
+
+
+
+ ABC-123
+
+
+
+
+ 1
+ 10.00
+
+ 1
+
+
+
+
+ PROD-001
+
+
+
+
diff --git a/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRERemitenteCasuisticasTest/transportePublico.xml b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRERemitenteCasuisticasTest/transportePublico.xml
new file mode 100644
index 00000000..0b7b93fb
--- /dev/null
+++ b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRERemitenteCasuisticasTest/transportePublico.xml
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+ 2.1
+ 2.0
+ T001-2
+ 2019-12-24
+ 09
+
+ 20100010001
+
+
+ 20100010001
+
+
+
+
+
+
+
+ #PROJECT-OPENUBL-SIGN
+
+
+
+
+
+
+ 20100010001
+
+
+
+
+
+
+
+
+
+ 20200020002
+
+
+
+
+
+
+
+ SUNAT_Envio
+ 01
+ 200.000
+
+ 01
+
+ 2019-12-24
+
+
+
+ 20300030003
+
+
+
+ MTC-001234
+
+
+
+
+
+ 040101
+
+ Sucursal Arequipa
+
+
+
+
+ 150101
+
+ Almacén Central, Lima
+
+
+
+
+
+
+ 1
+ 50.00
+
+ 1
+
+
+
+
+ PROD-002
+
+
+
+
diff --git a/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRERemitenteCasuisticasTest/transporteSubcontratado.xml b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRERemitenteCasuisticasTest/transporteSubcontratado.xml
new file mode 100644
index 00000000..b2726483
--- /dev/null
+++ b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRERemitenteCasuisticasTest/transporteSubcontratado.xml
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+ 2.1
+ 2.0
+ T001-5
+ 2019-12-24
+ 09
+
+ 20100010001
+
+
+ 20100010001
+
+
+
+
+
+
+
+ #PROJECT-OPENUBL-SIGN
+
+
+
+
+
+
+ 20100010001
+
+
+
+
+
+
+
+
+
+ 20200020002
+
+
+
+
+
+
+
+ SUNAT_Envio
+ 01
+ 3000.000
+
+ 01
+
+ 2019-12-24
+
+
+
+ 20400040004
+
+
+
+ MTC-567890
+
+
+
+
+
+ 150132
+
+ Tienda San Isidro
+
+
+
+
+ 150101
+ 0001
+
+ Almacén Principal, Lima
+
+
+
+
+
+
+ 1
+ 200.00
+
+ 1
+
+
+
+
+ ELEC-001
+
+
+
+
diff --git a/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRERemitenteCasuisticasTest/vehiculoConCarreta.xml b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRERemitenteCasuisticasTest/vehiculoConCarreta.xml
new file mode 100644
index 00000000..82065b79
--- /dev/null
+++ b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRERemitenteCasuisticasTest/vehiculoConCarreta.xml
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+ 2.1
+ 2.0
+ T001-4
+ 2019-12-24
+ 09
+
+ 20100010001
+
+
+ 20100010001
+
+
+
+
+
+
+
+ #PROJECT-OPENUBL-SIGN
+
+
+
+
+
+
+ 20100010001
+
+
+
+
+
+
+
+
+
+ 20200020002
+
+
+
+
+
+
+
+ SUNAT_Envio
+ 01
+ 8000.000
+
+ 02
+
+ 2019-12-24
+
+
+ 33333333
+ Roberto
+ Silva
+ Principal
+
+ Q3333333
+
+
+
+
+
+ 060101
+
+ Depósito Cajamarca
+
+
+
+
+ 150101
+
+ Centro de distribución, Lima
+
+
+
+
+
+
+ GHI-789
+
+ TUC-MAIN
+
+
+ CAR-001
+
+ TUC-SEC1
+
+
+
+ CAR-002
+
+
+ AUTH-001
+
+
+
+
+
+ 1
+ 500.00
+
+ 1
+
+
+
+
+ FERT-001
+
+
+
+
diff --git a/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRERemitenteCasuisticasTest/vehiculoM1L.xml b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRERemitenteCasuisticasTest/vehiculoM1L.xml
new file mode 100644
index 00000000..23fd2e7d
--- /dev/null
+++ b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRERemitenteCasuisticasTest/vehiculoM1L.xml
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+ 2.1
+ 2.0
+ T001-6
+ 2019-12-24
+ 09
+
+ 20100010001
+
+
+ 20100010001
+
+
+
+
+
+
+
+ #PROJECT-OPENUBL-SIGN
+
+
+
+
+
+
+ 20100010001
+
+
+
+
+
+
+
+
+
+ 87654321
+
+
+
+
+
+
+
+ SUNAT_Envio
+ 01
+ 5.000
+ SUNAT_Envio_IndicadorTrasladoVehiculoM1L
+
+ 02
+
+ 2019-12-24
+
+
+
+
+ 150101
+
+ Domicilio Cliente
+
+
+
+
+ 150101
+
+ Tienda Lima Centro
+
+
+
+
+
+
+ 1
+ 1.00
+
+ 1
+
+
+
+
+ PEQ-001
+
+
+
+
diff --git a/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRETransportistaCasuisticasTest/transportistaBasico.xml b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRETransportistaCasuisticasTest/transportistaBasico.xml
new file mode 100644
index 00000000..8dc7b690
--- /dev/null
+++ b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRETransportistaCasuisticasTest/transportistaBasico.xml
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
+ 2.1
+ 2.0
+ V001-1
+ 2019-12-24
+ 31
+
+ 20300030003
+
+
+ 20300030003
+
+
+
+
+
+
+
+ #PROJECT-OPENUBL-SIGN
+
+
+
+
+
+
+ 20300030003
+
+
+
+ MTC-001234
+
+
+
+
+
+
+ 20200020002
+
+
+
+
+
+
+
+
+
+ 20100010001
+
+
+
+
+
+
+
+ SUNAT_Envio
+ 01
+ 300.000
+
+ 01
+
+ 2019-12-24
+
+
+ 44444444
+ Miguel
+ Torres
+ Principal
+
+ Q4444444
+
+
+
+
+
+ 040101
+
+ Sucursal Arequipa
+
+
+
+
+ 150101
+
+ Almacén Remitente, Lima
+
+
+
+
+
+
+ JKL-012
+
+ TUC-JKL
+
+
+
+
+
+ 1
+ 25.00
+
+ 1
+
+
+
+
+ MER-001
+
+
+
+
diff --git a/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRETransportistaCasuisticasTest/transportistaComercioExterior.xml b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRETransportistaCasuisticasTest/transportistaComercioExterior.xml
new file mode 100644
index 00000000..c524e65a
--- /dev/null
+++ b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRETransportistaCasuisticasTest/transportistaComercioExterior.xml
@@ -0,0 +1,134 @@
+
+
+
+
+
+
+
+ 2.1
+ 2.0
+ V001-4
+ 2019-12-24
+ 31
+
+ 20700070007
+
+
+ 20700070007
+
+
+
+
+
+
+
+ #PROJECT-OPENUBL-SIGN
+
+
+
+
+
+
+ 20700070007
+
+
+
+ MTC-PORT-001
+
+
+
+
+
+
+ 20500050005
+
+
+
+
+
+
+
+
+
+ 20500050005
+
+
+
+
+
+
+
+ SUNAT_Envio
+ 19
+ 15000.000
+ SUNAT_Envio_IndicadorTrasladoTotalDAMDS
+
+ 01
+
+ 2019-12-24
+
+
+ 88888888
+ Fernando
+ Ruiz
+ Principal
+
+ Q8888888
+
+
+
+
+
+ 150101
+
+ Almacén Lima
+
+
+
+
+ 070101
+
+ Terminal Portuario del Callao
+
+
+
+
+
+
+ CONT-IMP-001
+ SEAL-IMP-001
+
+
+
+
+ STU-901
+
+ TUC-STU
+
+
+
+
+ CALLAO
+ 1
+ Puerto del Callao
+
+
+
+ 1
+ 1.00
+
+ 1
+
+
+
+
+ CARGA-EXT-001
+
+
+
+
diff --git a/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRETransportistaCasuisticasTest/transportistaConCarreta.xml b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRETransportistaCasuisticasTest/transportistaConCarreta.xml
new file mode 100644
index 00000000..eff84f34
--- /dev/null
+++ b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRETransportistaCasuisticasTest/transportistaConCarreta.xml
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
+ 2.1
+ 2.0
+ V001-3
+ 2019-12-24
+ 31
+
+ 20300030003
+
+
+ 20300030003
+
+
+
+
+
+
+
+ #PROJECT-OPENUBL-SIGN
+
+
+
+
+
+
+ 20300030003
+
+
+
+ MTC-PESADOS
+
+
+
+
+
+
+ 20200020002
+
+
+
+
+
+
+
+
+
+ 20100010001
+
+
+
+
+
+
+
+ SUNAT_Envio
+ 01
+ 20000.000
+ 1
+
+ 01
+
+ 2019-12-24
+
+
+ 77777777
+ Andres
+ Quispe
+ Principal
+
+ Q7777777
+
+
+
+
+
+ 060101
+
+ Planta procesadora, Cajamarca
+
+
+
+
+ 040101
+
+ Mina del Sur, Arequipa
+
+
+
+
+
+
+ PQR-678
+
+ TUC-PQR
+
+
+ REM-001
+
+ TUC-REM
+
+
+
+ AUTH-PESADOS
+
+
+
+
+
+ 1
+ 20000.00
+
+ 1
+
+
+
+
+ MIN-001
+
+
+
+
diff --git a/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRETransportistaCasuisticasTest/transportistaMultiplesConductores.xml b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRETransportistaCasuisticasTest/transportistaMultiplesConductores.xml
new file mode 100644
index 00000000..e3cdf72f
--- /dev/null
+++ b/xbuilder/core/src/test/resources/e2e/renderer/despatchadvice/GRETransportistaCasuisticasTest/transportistaMultiplesConductores.xml
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+ 2.1
+ 2.0
+ V001-2
+ 2019-12-24
+ 31
+
+ 20300030003
+
+
+ 20300030003
+
+
+
+
+
+
+
+ #PROJECT-OPENUBL-SIGN
+
+
+
+
+
+
+ 20300030003
+
+
+
+ MTC-001234
+
+
+
+
+
+
+ 20200020002
+
+
+
+
+
+
+
+
+
+ 20100010001
+
+
+
+
+
+
+
+ SUNAT_Envio
+ 01
+ 5000.000
+
+ 01
+
+ 2019-12-24
+
+
+ 55555555
+ Luis
+ Fernandez
+ Principal
+
+ Q5555555
+
+
+
+ 66666666
+ Mario
+ Vargas
+ Secundario
+
+ Q6666666
+
+
+
+
+
+ 130101
+
+ Terminal Tacna
+
+
+
+
+ 150101
+
+ Centro de Carga Lima
+
+
+
+
+
+
+ MNO-345
+
+ TUC-MNO
+
+
+
+
+
+ 1
+ 300.00
+
+ 1
+
+
+
+
+ CARG-001
+
+
+
+
diff --git a/xbuilder/core/src/test/resources/e2e/renderer/invoice/InvoiceIscTest/isc_sistemaDePreciosDeVentalAlPublico.xml b/xbuilder/core/src/test/resources/e2e/renderer/invoice/InvoiceIscTest/isc_sistemaDePreciosDeVentalAlPublico.xml
index 12a84ca9..931a29ff 100644
--- a/xbuilder/core/src/test/resources/e2e/renderer/invoice/InvoiceIscTest/isc_sistemaDePreciosDeVentalAlPublico.xml
+++ b/xbuilder/core/src/test/resources/e2e/renderer/invoice/InvoiceIscTest/isc_sistemaDePreciosDeVentalAlPublico.xml
@@ -115,7 +115,7 @@
20.00
10.00
- 02
+ 03
2000
ISC
diff --git a/xbuilder/core/src/test/resources/e2e/renderer/summarydocuments/SummaryDocumentsTest/summaryDocuments.xml b/xbuilder/core/src/test/resources/e2e/renderer/summarydocuments/SummaryDocumentsTest/summaryDocuments.xml
index 94e5c73d..7a1eded6 100644
--- a/xbuilder/core/src/test/resources/e2e/renderer/summarydocuments/SummaryDocumentsTest/summaryDocuments.xml
+++ b/xbuilder/core/src/test/resources/e2e/renderer/summarydocuments/SummaryDocumentsTest/summaryDocuments.xml
@@ -65,6 +65,7 @@
18
+ 18.00
1000
IGV
@@ -114,6 +115,7 @@
18
+ 18.00
1000
IGV
diff --git a/xbuilder/core/src/test/resources/e2e/renderer/summarydocuments/SummaryDocumentsTest/summaryDocuments_anularBoletaExonerada.xml b/xbuilder/core/src/test/resources/e2e/renderer/summarydocuments/SummaryDocumentsTest/summaryDocuments_anularBoletaExonerada.xml
index daead507..29d3c279 100644
--- a/xbuilder/core/src/test/resources/e2e/renderer/summarydocuments/SummaryDocumentsTest/summaryDocuments_anularBoletaExonerada.xml
+++ b/xbuilder/core/src/test/resources/e2e/renderer/summarydocuments/SummaryDocumentsTest/summaryDocuments_anularBoletaExonerada.xml
@@ -61,6 +61,7 @@
0
+