<# .SYNOPSIS Conector de carpeta de Comprobia: emite las planillas (CSV, TXT o XLSX) que un sistema deja en una carpeta. .DESCRIPTION Para el ERP o el sistema de ventas que no habla con una API pero sí sabe exportar un archivo. Cada vez que corre: 1. toma los archivos de entrada\ (con al menos 10 segundos de quietos, para no leer uno a medio escribir); 2. los manda a Comprobia (POST /api/v1/{ruc}/planillas); 3. deja el resultado en resultados\ (.json y .resultado.csv con el número y el enlace de cada comprobante) y mueve el archivo a procesados\ o, si algún comprobante no salió, a errores\. Sin conexión, o con Comprobia ocupado, el archivo se queda en entrada\ y se reintenta en la próxima pasada. Reenviar un archivo nunca emite dos veces la misma venta: Comprobia reconoce cada comprobante por su referencia (la columna «comprobante»), que tiene que ser única por venta. La clave de API se guarda cifrada con DPAPI de Windows: sólo la puede leer el mismo usuario en el mismo equipo. Nunca se escribe en el log. Funciona con Windows PowerShell 5.1 (el que trae Windows) y con PowerShell 7. .PARAMETER Carpeta Dónde viven entrada\, procesados\, errores\, resultados\ y la configuración. Por defecto, la carpeta del script. .PARAMETER Configurar Pide la URL, el RUC y la clave de API, y crea las carpetas. .PARAMETER Revisar Sólo valida los archivos (no emite ni los mueve): para probar una planilla. .EXAMPLE powershell -ExecutionPolicy Bypass -File comprobia-carpeta.ps1 -Configurar .EXAMPLE powershell -ExecutionPolicy Bypass -File comprobia-carpeta.ps1 .EXAMPLE # Cada minuto, con el Programador de tareas de Windows: schtasks /Create /TN "Comprobia carpeta" /SC MINUTE /MO 1 /TR "powershell -NoProfile -ExecutionPolicy Bypass -File C:\Comprobia\comprobia-carpeta.ps1" #> [CmdletBinding()] param( [string]$Carpeta = '', [switch]$Configurar, [switch]$Revisar ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version 2.0 # En Windows PowerShell 5.1, $PSScriptRoot llega vacío si se usa como valor por # defecto de un parámetro: se resuelve aquí. if ([string]::IsNullOrWhiteSpace($Carpeta)) { $Carpeta = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path } } $Version = '1.0.0' $Config = Join-Path $Carpeta 'comprobia.json' $Log = Join-Path $Carpeta 'comprobia.log' $Entrada = Join-Path $Carpeta 'entrada' $Procesados = Join-Path $Carpeta 'procesados' $Errores = Join-Path $Carpeta 'errores' $Resultados = Join-Path $Carpeta 'resultados' $Extensiones = @('.csv', '.txt', '.xlsx') $MaximoBytes = 1MB function Anotar([string]$mensaje) { $linea = '{0:yyyy-MM-dd HH:mm:ss} {1}' -f (Get-Date), $mensaje Add-Content -Path $Log -Value $linea -Encoding UTF8 Write-Host $linea } function Crear-Carpetas { foreach ($c in @($Entrada, $Procesados, $Errores, $Resultados)) { if (-not (Test-Path $c)) { New-Item -ItemType Directory -Path $c | Out-Null } } } function Mover([IO.FileInfo]$archivo, [string]$destino) { $nombre = '{0:yyyyMMdd-HHmmss}-{1}' -f (Get-Date), $archivo.Name Move-Item -LiteralPath $archivo.FullName -Destination (Join-Path $destino $nombre) } if ($Configurar) { $url = Read-Host 'URL de Comprobia [https://comprobia.stackealabs.com]' if ([string]::IsNullOrWhiteSpace($url)) { $url = 'https://comprobia.stackealabs.com' } $ruc = (Read-Host 'RUC del emisor').Trim() if ($ruc -notmatch '^\d{11}$') { throw 'El RUC tiene 11 digitos.' } $clave = Read-Host 'Clave de API (nx_beta_... o nx_produccion_...)' -AsSecureString [ordered]@{ url = $url.TrimEnd('/') ruc = $ruc # DPAPI: sólo este usuario, en este equipo, la puede descifrar. clave_protegida = ConvertFrom-SecureString $clave } | ConvertTo-Json | Set-Content -Path $Config -Encoding UTF8 Crear-Carpetas Write-Host "Listo. Deja las planillas en $Entrada y ejecuta este script (o programalo cada minuto)." exit 0 } if (-not (Test-Path $Config)) { Write-Error "Falta la configuracion: ejecuta primero con -Configurar." exit 2 } Crear-Carpetas # Una sola pasada a la vez: si el Programador de tareas arranca otra mientras # ésta sigue subiendo, la segunda sale sin hacer nada. $cerrojo = $null try { $cerrojo = [IO.File]::Open((Join-Path $Carpeta '.comprobia.lock'), 'OpenOrCreate', 'ReadWrite', 'None') } catch { exit 0 } $http = $null $hubo_errores = $false try { $cfg = Get-Content -Path $Config -Raw | ConvertFrom-Json $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR((ConvertTo-SecureString $cfg.clave_protegida)) try { $clave = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } Add-Type -AssemblyName System.Net.Http [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 $http = New-Object System.Net.Http.HttpClient $http.Timeout = [TimeSpan]::FromSeconds(120) $http.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue('Bearer', $clave) $http.DefaultRequestHeaders.Accept.ParseAdd('application/json') $http.DefaultRequestHeaders.UserAgent.ParseAdd("Comprobia-Carpeta/$Version") $clave = $null $endpoint = "$($cfg.url)/api/v1/$($cfg.ruc)/planillas" if ($Revisar) { $endpoint += '?revisar=1' } $quietos = (Get-Date).AddSeconds(-10) $archivos = @(Get-ChildItem -LiteralPath $Entrada -File | Where-Object { ($Extensiones -contains $_.Extension.ToLower()) -and $_.LastWriteTime -lt $quietos } | Sort-Object LastWriteTime) foreach ($archivo in $archivos) { if ($archivo.Length -gt $MaximoBytes) { Anotar "$($archivo.Name): pesa mas de 1 MB; partelo en varios archivos." Mover $archivo $Errores $hubo_errores = $true continue } $cuerpo = @{ archivo = $archivo.Name contenido_base64 = [Convert]::ToBase64String([IO.File]::ReadAllBytes($archivo.FullName)) } | ConvertTo-Json -Compress $contenido = New-Object System.Net.Http.StringContent($cuerpo, [Text.Encoding]::UTF8, 'application/json') try { $respuesta = $http.PostAsync($endpoint, $contenido).GetAwaiter().GetResult() } catch { Anotar "$($archivo.Name): sin conexion con Comprobia; se reintenta en la proxima pasada." continue } $codigo = [int]$respuesta.StatusCode $texto = $respuesta.Content.ReadAsStringAsync().GetAwaiter().GetResult() $base = Join-Path $Resultados $archivo.BaseName if ($codigo -eq 429 -or $codigo -ge 500) { Anotar "$($archivo.Name): Comprobia respondio $codigo; se reintenta en la proxima pasada." continue } if ($codigo -ne 200) { $mensaje = $texto try { $mensaje = ($texto | ConvertFrom-Json).message } catch { } Set-Content -Path "$base.error.txt" -Value "HTTP $codigo`r`n$texto" -Encoding UTF8 Anotar "$($archivo.Name): rechazado ($codigo) - $mensaje" if (-not $Revisar) { Mover $archivo $Errores } $hubo_errores = $true continue } $r = $texto | ConvertFrom-Json Set-Content -Path "$base.json" -Value $texto -Encoding UTF8 $filas = @('referencia;estado;numero;enlace;errores') foreach ($c in $r.comprobantes) { $errores_c = (@($c.errores) -join ' | ') -replace ';', ',' $filas += ('{0};{1};{2};{3};{4}' -f $c.referencia, $c.estado, $c.numero_completo, $c.consulta_publica, $errores_c) } Set-Content -Path "$base.resultado.csv" -Value $filas -Encoding UTF8 if ($Revisar) { Anotar "$($archivo.Name): revisado - $($r.validos_sin_emitir) validos, $($r.con_errores) con errores (no se emitio nada)." } elseif ($r.con_errores -eq 0) { Anotar "$($archivo.Name): $($r.emitidos) comprobante(s) emitidos." Mover $archivo $Procesados } else { Anotar "$($archivo.Name): $($r.emitidos) emitidos y $($r.con_errores) con errores; corrige esas filas y vuelve a dejar el archivo (lo emitido no se repite)." Mover $archivo $Errores $hubo_errores = $true } } } finally { if ($http) { $http.Dispose() } if ($cerrojo) { $cerrojo.Dispose() } } if ($hubo_errores) { exit 1 } exit 0