Extracción de datos de factura en tiempo real: PHP y AWS Textract
Usa la operación síncrona AnalyzeExpense de AWS Textract para convertir una imagen
local de una factura en datos candidatos en JSON para su revisión. La CLI de PHP que aparece a
continuación conserva campos repetidos, partidas y puntuaciones numéricas de confianza.
No valida los cálculos de una factura ni autoriza un pago.
Configura AWS Textract y el SDK de PHP v3
Necesitas PHP con cURL, Fileinfo, SimpleXML y GD con soporte para PNG/JPEG, además de Composer instalado por separado y Bash para los comandos de shell. Este ejemplo se probó con PHP 8.5.11 y Composer 2.10.3. Usa una versión de PHP 8.5 con mantenimiento activo; el SDK admite PHP 8.1 y versiones posteriores, pero eso no establece la compatibilidad de todas las dependencias de esta instalación.
Configura las credenciales de AWS mediante la
cadena de proveedores de credenciales del SDK.
La identidad que realiza la llamada necesita el permiso textract:AnalyzeExpense en la región
seleccionada. Este ejemplo de subida de bytes no usa ningún bucket de S3, tema de SNS ni cola de SQS.
Cada solicitud al servicio puede generar un cargo.
Pega lo siguiente desde el directorio donde quieras crear un proyecto invoice-ocr.
El subshell mantiene intactos tu directorio actual y las opciones del shell. Si el destino ya existe,
la configuración se detiene antes de la instalación. Las versiones de los paquetes fijan el SDK
probado y el transporte de Guzzle que el ejemplo usa directamente. El manifiesto local y la
eliminación de las personalizaciones de rutas de Composer mantienen la instalación dentro de este
proyecto.
(
for tool in php composer; do
command -v "$tool" >/dev/null || { printf 'Missing prerequisite: %s\n' "$tool" >&2; exit 1; }
done &&
php -r 'foreach (["curl", "fileinfo", "SimpleXML", "gd"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: $extension\n"); exit(1); } } if (!(imagetypes() & IMG_PNG) || !(imagetypes() & IMG_JPG)) { fwrite(STDERR, "GD needs PNG and JPEG support\n"); exit(1); }' &&
unset COMPOSER COMPOSER_VENDOR_DIR COMPOSER_BIN_DIR &&
mkdir invoice-ocr &&
cd invoice-ocr &&
printf '{"require": {}, "config": {"vendor-dir": "vendor", "bin-dir": "vendor/bin"}}\n' > composer.json &&
composer require aws/aws-sdk-php:3.400.1 guzzlehttp/guzzle:8.2.0 &&
php -r 'require "vendor/autoload.php"; if (!class_exists(Aws\Textract\TextractClient::class) || !class_exists(GuzzleHttp\Handler\CurlHandler::class)) { exit(1); }'
)
Conserva composer.json y composer.lock. Si la instalación falla
tras crear el directorio, resuelve el problema de requisitos previos o de red indicado y vuelve a
intentarlo dentro de ese directorio, conservando los archivos existentes:
(
unset COMPOSER COMPOSER_VENDOR_DIR COMPOSER_BIN_DIR &&
cd invoice-ocr &&
{ [ -f composer.json ] || printf '{"require": {}, "config": {"vendor-dir": "vendor", "bin-dir": "vendor/bin"}}\n' > composer.json; } &&
composer require aws/aws-sdk-php:3.400.1 guzzlehttp/guzzle:8.2.0 &&
php -r 'require "vendor/autoload.php"; if (!class_exists(Aws\Textract\TextractClient::class) || !class_exists(GuzzleHttp\Handler\CurlHandler::class)) { exit(1); }'
)
Comprende la API AnalyzeExpense
AnalyzeExpense
devuelve ExpenseDocuments, con SummaryFields y
LineItemGroups anidados. Las clasificaciones de los campos se infieren: el nombre de
un proveedor puede estar ausente incluso cuando se reconocen totales y partidas útiles.
Esta CLI acepta un archivo local PNG o JPEG no vacío que contenga como máximo 5.000.000 bytes, con un máximo de 5.000 píxeles por dimensión y 10.000.000 píxeles en total. Estos son límites de la aplicación, incluidos los bytes al final del archivo, y no una afirmación sobre el tamaño máximo de los datos que admite AWS. GD debe decodificar la imagen sin que se informe ninguna advertencia. La decodificación comprueba la legibilidad, pero no demuestra que todos los píxeles de una imagen dañada estén intactos. Esta CLI no abarca PDF, TIFF, URL ni procesamiento de varias páginas.
Crea una CLI síncrona para facturas
Guarda este programa completo como invoice-ocr/invoice.php. Envía los bytes originales de
la imagen e imprime JSON solo después de que el análisis del servicio y el procesamiento de la
respuesta se completen correctamente. El SDK se encarga de la
serialización de bytes;
no codifiques tú mismo la entrada en base64.
<?php
use Aws\Handler\Guzzle\GuzzleHandler;
use Aws\Textract\TextractClient;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\CurlHandler;
use GuzzleHttp\HandlerStack;
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
if ($argc !== 2) {
fwrite(STDERR, "Usage: php invoice.php <local-invoice.png|local-invoice.jpg>\n");
exit(1);
}
function parseExpense(array $result): array
{
return array_map(function (array $document): array {
$field = static fn (array $value): array => [
'type' => $value['Type']['Text'] ?? null,
'text' => $value['ValueDetection']['Text'] ?? null,
'confidence' => $value['ValueDetection']['Confidence'] ?? null,
];
$groups = [];
foreach ($document['LineItemGroups'] ?? [] as $group) {
$rows = [];
foreach ($group['LineItems'] ?? [] as $row) {
$rows[] = array_map($field, $row['LineItemExpenseFields'] ?? []);
}
$groups[] = ['index' => $group['LineItemGroupIndex'] ?? null, 'rows' => $rows];
}
return [
'expense_index' => $document['ExpenseIndex'] ?? null,
'summary' => array_map($field, $document['SummaryFields'] ?? []),
'line_item_groups' => $groups,
'requires_review' => true,
];
}, $result['ExpenseDocuments'] ?? []);
}
try {
foreach (['curl', 'fileinfo', 'SimpleXML', 'gd'] as $extension) {
if (!extension_loaded($extension)) {
throw new RuntimeException('Required PHP extension unavailable');
}
}
set_error_handler(static function (int $severity, string $message): never {
throw new RuntimeException('Local image or dependency could not be read');
});
$path = $argv[1];
if (str_contains($path, '://') || !is_file($path) || !is_readable($path)) {
throw new RuntimeException('Use a readable local file');
}
$bytes = file_get_contents($path, false, null, 0, 5_000_001);
if ($bytes === false || $bytes === '' || strlen($bytes) > 5_000_000) {
throw new RuntimeException('Use 1 to 5,000,000 bytes');
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->buffer($bytes);
if (!in_array($mime, ['image/png', 'image/jpeg'], true)) {
throw new RuntimeException('Use PNG or JPEG');
}
$size = getimagesizefromstring($bytes);
if ($size === false || $size[0] > 5_000 || $size[1] > 5_000
|| $size[0] * $size[1] > 10_000_000) {
throw new RuntimeException('Image dimensions exceed the application bounds');
}
$image = imagecreatefromstring($bytes);
if ($image === false) {
throw new RuntimeException('Image cannot be decoded');
}
unset($image);
require __DIR__ . '/vendor/autoload.php';
$textract = new TextractClient([
'region' => getenv('AWS_REGION') ?: 'us-east-1',
'version' => '2018-06-27',
'http_handler' => new GuzzleHandler(new Client([
'handler' => HandlerStack::create(new CurlHandler()),
])),
'http' => ['connect_timeout' => 5, 'timeout' => 30],
'retries' => 2,
]);
$result = $textract->analyzeExpense(['Document' => ['Bytes' => $bytes]]);
$documents = parseExpense($result->toArray());
if ($documents === []) {
throw new RuntimeException('No expense documents returned');
}
echo json_encode($documents, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), "\n";
} catch (Throwable $error) {
fwrite(STDERR, "Invoice analysis failed. Check local file readability, PNG/JPEG decoding, byte and pixel bounds, PHP extensions, SDK installation, credentials, region, and AnalyzeExpense permission.\n");
exit(1);
}
Coloca la imagen de tu factura en el proyecto y ejecuta lo siguiente desde el directorio que contiene
invoice-ocr:
(cd invoice-ocr && php invoice.php ./invoice.png)
Para JPEG, pasa ./invoice.jpg en su lugar. Las rutas con espacios necesitan comillas
del shell. Usa ./ antes de un nombre de archivo que empiece con un guion.
El programa solo escribe en stdout y stderr; deja intactos el archivo de entrada y los archivos de
resultados existentes. Evita redirigir stdout sobre un resultado anterior antes de comprobar que la
operación se completó correctamente, ya que el shell puede truncar ese archivo incluso si el análisis
falla. El JSON de una factura puede contener datos sensibles, así que mantenlo fuera de los registros
públicos.
Extrae y estructura los datos de las facturas
El analizador proyecta cada candidato de resumen y de partida en type,
text y confidence, conservando su orden y la separación
entre documentos y grupos. Los arrays conservan los tipos repetidos que un mapa con claves basadas
en TOTAL, NAME o OTHER
sobrescribiría. Las propiedades ausentes permanecen como null; la confianza
es la puntuación del detector de valores, no una garantía contable.
Fragmento de una respuesta de ejemplo
Este documento ilustrativo tiene dos candidatos del mismo tipo. Las categorías, los valores y las puntuaciones reales varían. La proyección omite la geometría, las etiquetas, los metadatos de moneda y las referencias a las páginas de origen.
[
{
"expense_index": 1,
"summary": [
{ "type": "TOTAL", "text": "125.00", "confidence": 97.2 },
{ "type": "TOTAL", "text": "125.00 USD", "confidence": 88.5 }
],
"line_item_groups": [],
"requires_review": true
}
]
Antes de importar los candidatos a cuentas por pagar, inspecciona la imagen de origen, interpreta las convenciones de decimales y monedas, concilia los totales con las partidas y registra una decisión de revisión. Escapa el texto del OCR en un panel. Si la revisión necesita coordenadas o referencias a páginas, conserva la respuesta completa del SDK según tu política de datos en lugar de depender de esta pequeña proyección. Para el preprocesamiento y la conversión de documentos, consulta nuestro servicio de procesamiento de documentos.
Gestión de errores y estrategias de reintento
El controlador de cURL impone un tiempo de espera de conexión de cinco segundos y un tiempo de espera de 30 segundos por intento. La configuración de reintentos del SDK permite dos reintentos después del primer intento. La espera entre reintentos, la búsqueda de credenciales y el procesamiento local requieren tiempo adicional, por lo que estos ajustes no establecen un plazo máximo para todo el comando. Una conexión lenta puede fallar incluso con una imagen pequeña. Comprueba la conexión y vuelve a intentarlo de forma deliberada; las llamadas repetidas pueden costar dinero.
Conserva HandlerStack::create: el
middleware predeterminado de Guzzle
convierte los fallos HTTP en promesas rechazadas que el SDK puede interpretar. Eliminar ese stack
cambia la ruta de gestión de errores del servicio. Una solicitud rechazada termina con un código de
salida distinto de cero, stdout vacío y un diagnóstico depurado de datos sensibles. No consideres
un archivo vacío creado por la redirección del shell como un resultado válido de una factura.
Guía rápida para resolver problemas
| Síntoma | Siguiente comprobación |
|---|---|
| La configuración se detuvo antes de crear el proyecto | Comprueba cada ejecutable y extensión de PHP requeridos |
| Composer falló después de crear el proyecto | Resuelve el error indicado y vuelve a intentar la instalación como se explica arriba |
| Se rechazó la imagen local | Comprueba que tenga bytes, contenido PNG/JPEG real, que se decodifique y que cumpla ambos límites de píxeles |
| El análisis falló después de las comprobaciones locales | Comprueba las credenciales, la región, el acceso a la red y el permiso textract:AnalyzeExpense |
| Falta un campo esperado | Inspecciona el origen y los demás candidatos; las categorías y la exhaustividad no están garantizadas |
| Los valores parecen plausibles | Conserva requires_review; la confianza no demuestra que los totales, la moneda o el pago sean correctos |
Implementa el procesamiento asíncrono con SNS + SQS
Para facturas de varias páginas o una tarea que deba continuar después de que termine quien la
invoca, elige el
flujo de trabajo asíncrono para gastos.
StartExpenseAnalysis lee desde S3; GetExpenseAnalysis recupera los resultados
completados, con paginación. La guía de procesamiento asíncrono de AWS
describe las notificaciones de finalización de SNS y un consumidor de SQS. Es una aplicación
independiente: aprovisiona sus recursos y permisos, conserva de forma persistente la titularidad de
las tareas y el estado de idempotencia, y confirma la recepción de los mensajes solo después de
procesarlos de forma duradera. La CLI síncrona anterior no implementa ese flujo de trabajo.
