Skip to main content

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

SunatService.php:781
$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

SunatService.php:567
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)

SunatService.php:582
$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)

SunatService.php:598
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)

SunatService.php:607
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

SunatService.php:623
$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

SunatService.php:632
$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.
SunatService.php:680
// 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

SunatService.php:805
$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

SunatService.php:944
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

SunatService.php:844
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

SunatService.php:882
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

1

Crear Guía

Registrar guía con datos de traslado, conductor, vehículo
2

Generar XML

Crear XML firmado digitalmente (igual que facturas)
3

Autenticar

Obtener token OAuth 2.0 con client_id y client_secret
4

Crear ZIP

Empaquetar XML en archivo ZIP con hash SHA256
5

Enviar a SUNAT

POST a API REST, recibir ticket
6

Consultar Ticket (Polling)

Consultar cada 5-10 segundos hasta obtener respuesta definitiva
7

Descargar CDR

Si aceptado, descargar y almacenar CDR

Motivos de Traslado

CódigoDescripción
01Venta
02Compra
04Traslado entre establecimientos de la misma empresa
08Importación
09Exportación
13Otros
14Venta sujeta a confirmación del comprador
18Traslado emisor itinerante CP

Unidades de Peso

CódigoDescripción
KGMKilogramo
TNETonelada métrica
GRMGramo

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

Causa: Credenciales OAuth incorrectas.Solución: Obtener client_id y client_secret desde el portal SUNAT y configurar en la tabla empresas o .env.
Causa: El ticket expiró o no existe.Solución: Volver a enviar la guía para obtener un nuevo ticket.
Causa: Falta el campo peso_total.Solución: Asegurarse de que la guía tenga peso_total > 0 y und_peso_total (generalmente ‘KGM’).
Causa: Formato incorrecto de placa (debe ser ABC-123 o A1B-234).Solución: Validar formato de placa según estándar peruano.

Build docs developers (and LLMs) love