Visión General
Las Guías de Remisión Electrónicas (GRE) versión 2022 utilizan API REST de SUNAT en lugar de SOAP. El proceso es asíncrono con tickets.
El sistema soporta dos tipos de guías: Remitente (09) y Transportista (31) .
Diferencias con Facturación
Facturas (SOAP)
Envío sincrónico
Respuesta inmediata con CDR
Endpoint: billService
Autenticación: Clave SOL
Guías (REST)
Envío asíncrono
Retorna ticket
Endpoint: API REST
Autenticación: OAuth 2.0
Autenticación OAuth 2.0
Obtener Token
$authConfig = new Configuration ();
$authConfig -> setHost ( config ( 'sunat.endpoints.gre.auth' ));
$authApi = new AuthApi ( null , $authConfig );
$username = $empresa -> modo !== 'production'
? config ( 'sunat.beta.ruc' ) . config ( 'sunat.beta.usuario_sol' )
: $empresa -> ruc . $empresa -> user_sol ;
$password = $empresa -> modo !== 'production'
? config ( 'sunat.beta.clave_sol' )
: $empresa -> clave_sol ;
$token = $authApi -> getToken (
'password' ,
'https://api-cpe.sunat.gob.pe' ,
$empresa -> gre_client_id ?: config ( 'sunat.endpoints.gre.client_id' ),
$empresa -> gre_client_secret ?: config ( 'sunat.endpoints.gre.client_secret' ),
$username ,
$password
);
El token OAuth tiene una validez corta (5-10 minutos). Debe solicitarse un nuevo token para cada envío.
Generación de Guía de Remisión Remitente (09)
Estructura Principal
public function generarGuiaRemisionXml ( GuiaRemision $guia ) : array
{
$guia -> load ([ 'empresa' , 'detalles' ]);
$empresa = $guia -> empresa ;
$company = $this -> buildCompany ( $empresa );
// Fechas en zona horaria Lima
$fechaEmision = $this -> fechaParaGreenter ( $guia -> getRawOriginal ( 'fecha_emision' ), $guia -> created_at );
$fechaTraslado = $this -> fechaParaGreenter ( $guia -> getRawOriginal ( 'fecha_traslado' ));
// Destinatario
$destinatario = ( new Client ())
-> setTipoDoc ( $guia -> destinatario_tipo_doc )
-> setNumDoc ( $guia -> destinatario_documento )
-> setRznSocial ( $guia -> destinatario_nombre );
// ... configuración de envío ...
}
Datos de Envío (Shipment)
$shipment = ( new Shipment ())
-> setCodTraslado ( $guia -> motivo_traslado ) // '01' = Venta, '02' = Compra, etc.
-> setDesTraslado ( $guia -> descripcion_motivo )
-> setModTraslado ( $guia -> mod_transporte ) // '01' = Público, '02' = Privado
-> setFecTraslado ( $fechaTraslado )
-> setPesoTotal (( float ) $guia -> peso_total )
-> setUndPesoTotal ( $guia -> und_peso_total ?? 'KGM' ) // KGM = kilogramos
-> setPartida ( new Direction ( $guia -> ubigeo_partida , $guia -> dir_partida ))
-> setLlegada ( new Direction ( $guia -> ubigeo_llegada , $guia -> dir_llegada ));
// Indicador para vehículos M1 o L (automóviles/motos)
if ( $guia -> vehiculo_m1l ) {
$shipment -> setIndicadores ([ 'SUNAT_Envio_IndicadorTrasladoVehiculoM1L' ]);
}
Modalidades de Transporte
Transporte Público (01)
if ( $guia -> mod_transporte === '01' && $guia -> transportista_documento ) {
$transportista = ( new Transportist ())
-> setTipoDoc ( $guia -> transportista_tipo_doc )
-> setNumDoc ( $guia -> transportista_documento )
-> setRznSocial ( $guia -> transportista_nombre )
-> setNroMtc ( $guia -> transportista_nro_mtc ); // Número de registro MTC
$shipment -> setTransportista ( $transportista );
}
En transporte público, NO se incluyen datos de conductor ni vehículo. Solo los datos de la empresa transportista.
Transporte Privado (02)
if ( $guia -> mod_transporte === '02' ) {
// Conductor
if ( $guia -> conductor_documento ) {
$driver = ( new Driver ())
-> setTipo ( 'Principal' )
-> setTipoDoc ( $guia -> conductor_tipo_doc )
-> setNroDoc ( $guia -> conductor_documento )
-> setNombres ( $guia -> conductor_nombres )
-> setApellidos ( $guia -> conductor_apellidos )
-> setLicencia ( $guia -> conductor_licencia );
$shipment -> setChoferes ([ $driver ]);
}
// Vehículo
if ( $guia -> vehiculo_placa ) {
$shipment -> setVehiculo (( new Vehicle ()) -> setPlaca ( $guia -> vehiculo_placa ));
}
}
Detalle de Mercancías
$details = [];
foreach ( $guia -> detalles as $item ) {
$details [] = ( new DespatchDetail ())
-> setCodigo ( $item -> codigo ?? 'P001' )
-> setDescripcion ( $item -> descripcion )
-> setUnidad ( $item -> unidad ?? 'NIU' ) // Unidad de medida
-> setCantidad (( float ) $item -> cantidad );
}
Creación del Documento Despatch
$despatch = ( new Despatch ())
-> setVersion ( '2022' ) // Versión GRE
-> setTipoDoc ( '09' ) // Guía remitente
-> setSerie ( $guia -> serie )
-> setCorrelativo (( string ) $guia -> numero )
-> setFechaEmision ( $fechaEmision )
-> setCompany ( $company )
-> setDestinatario ( $destinatario )
-> setEnvio ( $shipment )
-> setDetails ( $details );
if ( $guia -> observaciones ) {
$despatch -> setObservacion ( $guia -> observaciones );
}
Guía de Remisión Transportista (31)
Diferencia Clave En la guía transportista, el remitente (quién envía) es un tercero diferente al emisor.
// Remitente (tercero)
$remitente = null ;
if ( $guia -> remitente_documento ) {
$remitente = ( new Client ())
-> setTipoDoc ( $guia -> remitente_tipo_doc )
-> setNumDoc ( $guia -> remitente_documento )
-> setRznSocial ( $guia -> remitente_nombre );
}
$despatch = ( new Despatch ())
-> setVersion ( '2022' )
-> setTipoDoc ( '31' ) // Guía transportista
-> setSerie ( $guia -> serie )
-> setCorrelativo (( string ) $guia -> numero )
-> setTercero ( $remitente ) // Quién envía la carga
-> setDestinatario ( $destinatario )
-> setEnvio ( $shipment );
Envío a SUNAT (REST API)
Crear ZIP y Enviar
$cpeConfig = new Configuration ();
$cpeConfig -> setHost ( config ( 'sunat.endpoints.gre.cpe' ));
$cpeConfig -> setAccessToken ( $token -> getAccessToken ());
$cpeApi = new CpeApi ( null , $cpeConfig );
// Crear ZIP con el XML
$zipContent = $this -> createZip ( $guia -> nombre_xml . '.xml' , $xmlContent );
$archivo = new CpeDocumentArchivo ();
$archivo -> setNomArchivo ( $guia -> nombre_xml . '.zip' );
$archivo -> setArcGreZip ( base64_encode ( $zipContent ));
$archivo -> setHashZip ( hash ( 'sha256' , $zipContent ));
$cpeDoc = new CpeDocument ();
$cpeDoc -> setArchivo ( $archivo );
try {
$response = $cpeApi -> enviarCpe ( $guia -> nombre_xml , $cpeDoc );
} catch ( \GuzzleHttp\Exception\ ClientException $apiEx ) {
$responseBody = $apiEx -> hasResponse ()
? $apiEx -> getResponse () -> getBody () -> getContents ()
: 'Sin respuesta' ;
Log :: error ( 'SUNAT GRE - Error API al enviar guía' , [
'guia' => $guia -> nombre_xml ,
'status' => $apiEx -> hasResponse () ? $apiEx -> getResponse () -> getStatusCode () : null ,
'response' => $responseBody ,
]);
throw new \Exception ( "SUNAT rechazó la guía: { $responseBody }" );
}
$ticket = $response -> getNumTicket ();
$guia -> update ([
'estado' => 'enviado' ,
'ticket_sunat' => $ticket ,
]);
return [
'success' => true ,
'ticket' => $ticket ,
'message' => 'Guía enviada. Use el ticket para consultar el estado.' ,
];
Crear ZIP
private function createZip ( string $filename , string $content ) : string
{
$tmpFile = tempnam ( sys_get_temp_dir (), 'gre' );
$zip = new \ZipArchive ();
$zip -> open ( $tmpFile , \ZipArchive :: CREATE | \ZipArchive :: OVERWRITE );
$zip -> addFromString ( $filename , $content );
$zip -> close ();
$zipContent = file_get_contents ( $tmpFile );
unlink ( $tmpFile );
return $zipContent ;
}
Consulta de Ticket
Obtener Estado de Envío
public function consultarTicketGuia ( GuiaRemision $guia ) : array
{
$guia -> load ([ 'empresa' ]);
$empresa = $guia -> empresa ;
$ruc = $this -> getRuc ( $empresa );
if ( ! $guia -> ticket_sunat ) {
return [ 'success' => false , 'message' => 'No hay ticket para consultar.' ];
}
// Autenticar con OAuth
$authConfig = new Configuration ();
$authConfig -> setHost ( config ( 'sunat.endpoints.gre.auth' ));
$authApi = new AuthApi ( null , $authConfig );
$token = $authApi -> getToken (
'password' ,
'https://api-cpe.sunat.gob.pe' ,
$empresa -> gre_client_id ?: config ( 'sunat.endpoints.gre.client_id' ),
$empresa -> gre_client_secret ?: config ( 'sunat.endpoints.gre.client_secret' ),
$username ,
$password
);
$cpeConfig = new Configuration ();
$cpeConfig -> setHost ( config ( 'sunat.endpoints.gre.cpe' ));
$cpeConfig -> setAccessToken ( $token -> getAccessToken ());
$cpeApi = new CpeApi ( null , $cpeConfig );
$status = $cpeApi -> consultarEnvio ( $guia -> ticket_sunat );
$codRespuesta = $status -> getCodRespuesta ();
// ... procesar respuesta ...
}
Códigos de Respuesta
if ( $codRespuesta === '0' ) {
// Aceptado
$cdrBase64 = $status -> getArcCdr ();
if ( $cdrBase64 ) {
$cdrContent = base64_decode ( $cdrBase64 );
$cdrDir = storage_path ( "app/sunat/cdr/{ $ruc }" );
if ( ! is_dir ( $cdrDir )) {
mkdir ( $cdrDir , 0755 , true );
}
file_put_contents ( "{ $cdrDir }/R-{ $guia -> nombre_xml }.zip" , $cdrContent );
$guia -> update ([
'estado' => 'aceptado' ,
'cdr_url' => "sunat/cdr/{ $ruc }/R-{ $guia -> nombre_xml }.zip" ,
'codigo_sunat' => $codRespuesta ,
'mensaje_sunat' => 'Aceptado por SUNAT' ,
]);
}
return [
'success' => true ,
'codigo' => $codRespuesta ,
'mensaje' => 'Guía aceptada por SUNAT.' ,
];
}
if ( $codRespuesta === '98' ) {
// En proceso
return [
'success' => true ,
'codigo' => '98' ,
'mensaje' => 'En proceso. Intente nuevamente en unos segundos.' ,
'en_proceso' => true ,
];
}
// Rechazado
$error = $status -> getError ();
$codError = $error ? $error -> getNumError () : $codRespuesta ;
$desError = $error ? $error -> getDesError () : 'Error desconocido' ;
Log :: error ( 'SUNAT - Guía de remisión rechazada' , [
'guia' => $guia -> serie . '-' . $guia -> numero ,
'codigo' => $codError ,
'mensaje' => $desError ,
]);
$guia -> update ([
'estado' => 'rechazado' ,
'codigo_sunat' => $codError ,
'mensaje_sunat' => $desError ,
]);
return [
'success' => false ,
'codigo' => $codError ,
'message' => $desError ,
];
Flujo de Trabajo Completo
Crear Guía
Registrar guía con datos de traslado, conductor, vehículo
Generar XML
Crear XML firmado digitalmente (igual que facturas)
Autenticar
Obtener token OAuth 2.0 con client_id y client_secret
Crear ZIP
Empaquetar XML en archivo ZIP con hash SHA256
Enviar a SUNAT
POST a API REST, recibir ticket
Consultar Ticket (Polling)
Consultar cada 5-10 segundos hasta obtener respuesta definitiva
Descargar CDR
Si aceptado, descargar y almacenar CDR
Motivos de Traslado
Código Descripción 01 Venta 02 Compra 04 Traslado entre establecimientos de la misma empresa 08 Importación 09 Exportación 13 Otros 14 Venta sujeta a confirmación del comprador 18 Traslado emisor itinerante CP
Unidades de Peso
Código Descripción KGM Kilogramo TNE Tonelada métrica GRM Gramo
Integración con Frontend
import { generarGuiaXml , enviarGuiaSunat , consultarTicketGuia } from '@/services/guias' ;
const procesarGuia = async ( guiaId ) => {
// 1. Generar XML
const xmlResult = await generarGuiaXml ( guiaId );
if ( ! xmlResult . success ) {
toast . error ( 'Error al generar XML' );
return ;
}
// 2. Enviar a SUNAT
const envioResult = await enviarGuiaSunat ( guiaId );
if ( ! envioResult . success ) {
toast . error ( 'Error al enviar' );
return ;
}
const ticket = envioResult . ticket ;
toast . info ( `Ticket: ${ ticket } . Consultando estado...` );
// 3. Polling: consultar cada 5 segundos
const maxIntentos = 12 ; // 1 minuto
let intentos = 0 ;
const intervalo = setInterval ( async () => {
intentos ++ ;
const estadoResult = await consultarTicketGuia ( guiaId );
if ( estadoResult . codigo === '98' ) {
// Aún en proceso
console . log ( 'En proceso...' );
return ;
}
clearInterval ( intervalo );
if ( estadoResult . success ) {
toast . success ( 'Guía aceptada por SUNAT' );
} else {
toast . error ( `Rechazada: ${ estadoResult . message } ` );
}
}, 5000 );
// Timeout de 1 minuto
setTimeout (() => {
if ( intentos < maxIntentos ) {
clearInterval ( intervalo );
toast . warning ( 'Tiempo de espera agotado. Consulte manualmente.' );
}
}, 60000 );
};
Errores Comunes
Error: Client ID inválido
Causa : Credenciales OAuth incorrectas.Solución : Obtener client_id y client_secret desde el portal SUNAT y configurar en la tabla empresas o .env.
Error: Ticket no encontrado
Causa : El ticket expiró o no existe.Solución : Volver a enviar la guía para obtener un nuevo ticket.
Error: Peso total requerido
Causa : Falta el campo peso_total.Solución : Asegurarse de que la guía tenga peso_total > 0 y und_peso_total (generalmente ‘KGM’).
Error: Placa de vehículo inválida
Causa : Formato incorrecto de placa (debe ser ABC-123 o A1B-234).Solución : Validar formato de placa según estándar peruano.