v1.0.11 - Plugin BCV Exchange Rate para iDempiere v10

This commit is contained in:
2026-07-03 15:00:31 -04:00
commit e9c19b4b3b
16 changed files with 1700 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# Compiled class files
*.class
# Package files
*.jar
*.war
*.ear
# Build directories
build/
dist/
# IDE files
.idea/
*.iml
.project
.classpath
.settings/
# OS files
.DS_Store
Thumbs.db
# Log files
*.log
+15
View File
@@ -0,0 +1,15 @@
Manifest-Version: 1.0
Bundle-ManifestVersion: 2
Bundle-Name: BCV Exchange Rate Plugin
Bundle-SymbolicName: com.venezuela.bcvrate;singleton:=true
Bundle-Version: 1.0.11
Bundle-Vendor: Ezerpa
Bundle-RequiredExecutionEnvironment: JavaSE-11
Bundle-ActivationPolicy: lazy
Bundle-Activator: com.venezuela.bcvrate.Activator
Require-Bundle: org.adempiere.base;bundle-version="10.0.0",
json;bundle-version="20190722.0.0"
Import-Package: org.osgi.framework
Export-Package: com.venezuela.bcvrate.process,
com.venezuela.bcvrate.service,
com.venezuela.bcvrate.factory
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<scr:component xmlns:scr="http://www.osgi.org/xmlns/scr/v1.1.0"
name="com.venezuela.bcvrate.factory.BCVProcessFactory"
immediate="true">
<implementation class="com.venezuela.bcvrate.factory.BCVProcessFactory"/>
<service>
<provide interface="org.adempiere.base.IProcessFactory"/>
</service>
<property name="service.ranking" type="Integer" value="100"/>
<property name="component.name" type="String" value="com.venezuela.bcvrate.factory.BCVProcessFactory"/>
</scr:component>
+322
View File
@@ -0,0 +1,322 @@
# BCV Exchange Rate Plugin for iDempiere v10
## Descripción
Plugin OSGi que obtiene automáticamente la tasa de cambio oficial del BCV (Banco Central de Venezuela) USD/VES y la registra en la tabla C_Conversion_Rate de iDempiere.
## Características
- **Doble fuente de datos**: API bcv.today + scraping directo del sitio web del BCV
- **Fallback automático**: Si la API no tiene datos recientes, scrapea el sitio del BCV
- **Busca hacia atrás** cuando el BCV no publica tasa para un día hábil
- **Rellena automáticamente** días sin publicación (fines de semana, feriados)
- **Ejecución manual** con rango de fechas específico
- **Ejecución automática** que detecta días pendientes y los rellena
- **Valida duplicados** antes de insertar
- **Modo simulación** para pruebas sin guardar
- **Scheduler automático** diario
## Fuentes de Datos
### Fuente Principal: API bcv.today
- **Endpoint**: `https://bcv.today/api/v1/rate.json`
- **Endpoint por fecha**: `https://bcv.today/api/v1/history/YYYY-MM-DD.json`
- **Ventaja**: Rápida, estructurada
- **Desventaja**: Puede tener retraso deactualización
### Fuente de Respaldo: Scraping BCV
- **URL**: `https://www.bcv.org.ve/`
- **Método**: Extracción directa del HTML con regex
- **Ventaja**: Siempre actualizada (datos oficiales)
- **Desventaja**: Requiere SSL bypass y parsing de HTML
### Flujo de Selección
```
1. Intentar API bcv.today (búsqueda hacia adelante 1-10 días)
↓ (si no encuentra)
2. Scraping directo del sitio web del BCV
↓ (si falla)
3. API bcv.today getCurrentRate() (último recurso)
```
## Comportamiento del Proceso
### Ejecución Manual (con parámetros de fecha)
- Procesa cada día en el rango DateFrom → DateTo
- Para cada día, busca la última tasa BCV hacia atrás (hasta 10 días)
- Usa la tasa encontrada pero asigna ValidFrom = fecha solicitada
### Ejecución Automática (sin parámetros de fecha)
1. Obtiene la última tasa BCV publicada (API + scraping)
2. Compara con la última tasa en BD
3. Si hay tasa nueva no registrada, procesa desde la última en BD hasta esa fecha
4. Si ya están todas → "No hay días pendientes"
### Lógica de Fallback por Día
| Situación | Acción |
|-----------|--------|
| BCV publica tasa para la fecha | Usa esa tasa, ValidFrom = fecha solicitada |
| BCV no publica (feriado, fin de semana) | Busca hacia atrás, usa última tasa disponible |
| No se encuentra tasa en 10 días | Registra error |
### Ejemplo
| Fecha solicitada | BCV publica | Tasa usada | ValidFrom |
|------------------|-------------|------------|-----------|
| 24/06 (miércoles) | No | 617.6388 (del 23/06) | 24/06 |
| 25/06 (jueves) | Sí (621.5299) | 621.5299 | 25/06 |
| 27/06 (sábado) | No | tasa del 26/06 | 27/06 |
| 28/06 (domingo) | No | tasa del 26/06 | 28/06 |
## Archivos del Plugin
```
com.venezuela.bcvrate/
├ META-INF/
│ └ MANIFEST.MF # Manifest OSGi (Bundle-Activator)
├ src/com/venezuela/bcvrate/
│ ├ Activator.java # Registra IProcessFactory via BundleContext
│ ├ factory/
│ │ └ BCVProcessFactory.java # Factory del proceso
│ ├ process/
│ │ └ BCVExchangeRateProcess.java # Lógica principal del proceso
│ └ service/
│ ├ BCVApiService.java # Cliente HTTP + scraping BCV
│ └ BCVRateResponse.java # Modelo de respuesta
├ dist/
│ └ com.venezuela.bcvrate.jar # JAR compilado listo para instalar
├ lib/
│ └ org.osgi.framework.jar # Dependencia OSGi
├ migration/
│ ├ install_v10.sql # SQL para registrar proceso
│ └ BCV_ExchangeRateProcess_2Pack.xml # 2pack para despliegue
└ build.sh # Script de build para Linux
```
## Instalación
### Prerrequisitos
- iDempiere v10 ejecutándose
- Acceso a consola Felix (http://servidor:8080/system/console)
- Acceso a la base de datos PostgreSQL
### Paso 1: Instalar JAR en Felix
1. Abrir consola Felix → **Bundles**
2. Click **Install/Update Bundle**
3. Seleccionar `com.venezuela.bcvrate.jar`
4. Seleccionar **Start**
5. Verificar estado **Active**
**Importante**: Si actualizas el plugin, debes **uninstall** el bundle anterior primero, luego **install** el nuevo. Equinox no recarga si la versión es la misma.
### Paso 2: Registrar Proceso
Opción A: Ejecutar `migration/install_v10.sql` en la BD
Opción B: Crear manualmente via UI:
- **Proceso**: BCV Exchange Rate Update
- **ClassName**: com.venezuela.bcvrate.process.BCVExchangeRateProcess
- **AccessLevel**: 3 (Client + Organization)
- **Parámetros**: Client, Conversion Type, Date From, Date To, Simulation Mode
Opción C: Importar 2pack `migration/BCV_ExchangeRateProcess_2Pack.xml`
### Paso 3: Agregar al Menú
Crear registro en AD_TreeBar o importar 2pack con el menú configurado.
### Paso 4: Configurar Scheduler (Agenda)
1. Ir a **Agenda** (Process Scheduler)
2. Crear nuevo registro
3. Seleccionar proceso: **BCV Exchange Rate Update**
4. Frecuencia: **1 dia**
5. Hora: **17:00-18:00** (cuando BCV publica la tasa)
6. Dejar parámetros de fecha **vacíos**
**Recomendación**: Configurar dos ejecuciones (17:00 y 21:00) por si la primera no captura la tasa.
## Parámetros del Proceso
| Parámetro | Tipo | Requerido | Default | Descripción |
|-----------|------|-----------|---------|-------------|
| Grupo Empresarial | List | Sí | @#AD_Client_ID@ | Cliente de iDempiere |
| Tipo de Conversión | List | Sí | BCV | Tipo de conversión |
| Fecha desde | Date | No | (vacío) | Fecha inicio (vacío = auto) |
| Fecha hasta | Date | No | (vacío) | Fecha fin (vacío = auto) |
| Simulación | Yes/No | No | N | Ejecutar sin guardar cambios |
## Datos Clave
| Campo | Valor | Descripción |
|-------|-------|-------------|
| C_Currency_ID | 100 | USD (hardcoded) |
| C_Currency_ID_To | (dinámico) | Moneda funcional del cliente |
| AD_Org_ID | 0 | Todas las organizaciones |
| MultiplyRate | (API/scraping) | Tasa USD → VES (4 decimales) |
| DivideRate | 1/MultiplyRate | Tasa VES → USD (10 decimales) |
| ValidFrom | (fecha solicitada) | Fecha que necesita la empresa |
| ValidTo | (fecha solicitada) | Misma que ValidFrom |
## Solución de Problemas
### Error "Failed to create new process instance"
- Verificar que el bundle está **Active** en Felix
- Verificar logs: `grep -i bcvrate /opt/idempiere-server/logs/idempiere.log`
### Error "Cross tenant PO writing"
- Se resolvió usando `set_ValueNoCheck()` en vez de `setMultiplyRate()`
- El proceso crea el registro con el client ID correcto del contexto
### Error "No se pudo obtener la moneda funcional"
- Verificar que el cliente tiene un esquema contable configurado
- Verificar que la moneda funcional no sea USD
### API bcv.today no responde o tiene retraso
- Verificar conectividad: `curl https://bcv.today/api/v1/rate.json`
- El plugin automáticamente intenta scraping del sitio web del BCV como respaldo
### Error SSL en scraping del BCV
- El plugin incluye SSL bypass para el sitio web del BCV
- Si persiste, verificar que el bundle tiene la última versión
### Tasa con decimales incorrectos
- Se resolvió usando `set_ValueNoCheck()` en vez de setters de MConversionRate
- MultiplyRate: 4 decimales
- DivideRate: 10 decimales
### Scraping captura tasa incorrecta (EUR en vez de USD)
- El regex busca específicamente después de `USD</span>` en el HTML
- Verificar que el HTML del BCV no ha cambiado su estructura
## Compilación
### En Windows (desarrollo)
```powershell
# Requiere: JDK 11, JARs de iDempiere v10
$javac = "C:\Program Files\Eclipse Adoptium\jdk-11.0.31.11-hotspot\bin\javac.exe"
$v10Plugins = "path/to/idempiere-v10/plugins"
$sources = Get-ChildItem -Path src -Recurse -Filter "*.java"
$classpath = "$v10Plugins\org.adempiere.base_*.jar;$v10Plugins\json_*.jar;lib\org.osgi.framework.jar"
& $javac -cp $classpath -d dist -source 11 -target 11 @($sources)
& jar cfm dist/com.venezuela.bcvrate.jar META-INF/MANIFEST.MF -C dist .
```
### En Linux (servidor)
```bash
chmod +x build.sh
./build.sh
```
## Dependencias
- **org.adempiere.base** >= 10.0.0 - Framework iDempiere
- **json** >= 20190722.0.0 - Parsing JSON (solo para API bcv.today)
- **org.osgi.framework** - Framework OSGi (sistema)
## Notas Técnicas
### Dual Source: API + Scraping
El plugin implementa dos fuentes de datos para máxima confiabilidad:
1. **API bcv.today**: Consulta REST rápida, pero puede tener retraso deactualización
2. **Scraping BCV**: Extracción directa del HTML del sitio web oficial
El flujo automático intenta la API primero y fallback al scraping si no hay datos recientes.
### SSL Bypass para Scraping
El sitio web del BCV usa certificados SSL que pueden no estar en el cacerts de Java. El plugin implementa SSL bypass por-conexión (no global) para evitar conflictos con iDempiere:
```java
TrustManager[] trustAllCerts = new TrustManager[]{...};
SSLContext sc = SSLContext.getInstance("TLS");
sc.init(null, trustAllCerts, new SecureRandom());
HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
conn.setSSLSocketFactory(sc.getSocketFactory());
conn.setHostnameVerifier((hostname, session) -> true);
```
### Extracción de Tasa USD del HTML
El regex busca específicamente la tasa del dólar después del elemento `USD</span>`:
```
USD</span>.*?strong-tb">\s*([\d.,]+)\s*</strong>
```
Esto evita capturar otras tasas (EUR, CNY, etc.) que aparecen en la misma página.
### Por qué se usa BundleActivator en vez de DS
- `component.xml` (Declarative Services) no funcionó en iDempiere v10
- `@Component` annotation tampoco funcionó
- `BundleActivator` con `BundleContext.registerService()` es el más confiable
- Requiere `Import-Package: org.osgi.framework` en MANIFEST.MF
### Por qué se usa regex en vez del JSON library
- El `json_20190722.0.0.jar` bundled con iDempiere v10 tiene bugs de precisión
- `getDouble("USD")` y `get("USD").toString()` corrompen decimales
- Se extrae el valor directamente del string raw con regex: `"USD"\s*:\s*([0-9.]+)`
### Por qué se usa set_ValueNoCheck en vez de setters
- `MConversionRate.setMultiplyRate()` modifica el valor internamente
- `MConversionRate.setDivideRate()` redondea a 4 decimales
- `set_ValueNoCheck()` bypassa la lógica interna y almacena el valor exacto
### Cross Tenant
- `MConversionRate.setRate()` intenta guardar con AD_Client_ID=0
- Se crea el registro directamente con `MConversionRate` y se usa `set_ValueNoCheck("AD_Client_ID", ...)`
### Por qué el JAR debe cambiar de versión
- Equinox (runtime OSGi) cachea bundles por versión
- Si la versión no cambia, el bundle anterior permanece activo
- Siempre incrementar `Bundle-Version` en MANIFEST.MF al actualizar
## Historial de Cambios
### v1.0.11 (2026-07-03)
- Fix regex scraping: captura tasa USD específicamente (no EUR)
- Regex busca después de `USD</span>` en el HTML
### v1.0.10 (2026-07-03)
- Logging mejorado en proceso para ver fuente de datos
- Scraping status visible en output del proceso
### v1.0.9 (2026-07-03)
- SSL bypass por-conexión (no global) para scraping BCV
- Logging detallado en getLatestRate()
### v1.0.8 (2026-07-03)
- Scraping directo del sitio web del BCV como fuente de respaldo
- Dual source: API bcv.today + scraping bcv.org.ve
- Método getRateFromBCVWebsite() con extracción regex del HTML
### v1.0.7 (2026-06-23)
- Lógica automática: detecta días pendientes entre BD y última tasa BCV
- Ejecución sin parámetros: busca última tasa BCV y rellena huecos
### v1.0.6 (2026-06-23)
- Fix: ejecución automática ahora detecta si hay tasa para hoy
- Evita procesar si ya existen todas las tasas
### v1.0.5 (2026-06-23)
- Lógica de fallback: busca hacia atrás cuando BCV no publica tasa
- Rellena días sin publicación con última tasa disponible
### v1.0.4 (2026-06-23)
- getRateForDateWithFallback(): busca hacia atrás hasta 10 días
- BCVRateResponse: campo requestedDate vs effectiveDate
### v1.0.3 (2026-06-23)
- DivideRate: 10 decimales en vez de 4
### v1.0.2 (2026-06-23)
- Todos los setters usan set_ValueNoCheck() para bypass MConversionRate
- Log de verificación post-save
### v1.0.1 (2026-06-23)
- Regex para extraer USD directo del string raw (bypass JSON library)
### v1.0.0 (2026-06-22)
- Versión inicial
- API: bcv.today
- BundleActivator para registro de IProcessFactory
## Licencia
Plugin personalizado para uso interno de Ezerpa.
+5
View File
@@ -0,0 +1,5 @@
source.. = src/
output.. = bin/
bin.includes = META-INF/,\
OSGI-INF/,\
.
+111
View File
@@ -0,0 +1,111 @@
#!/bin/bash
# Build script for BCV Exchange Rate Plugin - iDempiere v10
# Run this on the server where iDempiere v10 is installed
set -e
# Configuration
IDEMPIERE_HOME="${IDEMPIERE_HOME:-/opt/idempiere-server}"
PLUGIN_DIR="$(dirname "$0")"
DIST_DIR="$PLUGIN_DIR/dist"
BUILD_DIR="$PLUGIN_DIR/build"
echo "=== BCV Exchange Rate Plugin Build ==="
echo "iDempiere Home: $IDEMPIERE_HOME"
# Find required JARs
BASE_JAR=$(find "$IDEMPIERE_HOME/plugins" -name "org.adempiere.base_*.jar" | head -1)
JSON_JAR=$(find "$IDEMPIERE_HOME/plugins" -name "json_*.jar" | head -1)
OSGI_JAR=$(find "$IDEMPIERE_HOME/plugins" -name "org.osgi.framework_*.jar" | head -1)
COMPONENT_ANNOTATIONS_JAR=$(find "$IDEMPIERE_HOME/plugins" -name "org.osgi.service.component.annotations_*.jar" | head -1)
# Check required JARs
if [ -z "$BASE_JAR" ]; then
echo "ERROR: org.adempiere.base JAR not found"
exit 1
fi
if [ -z "$JSON_JAR" ]; then
echo "ERROR: json JAR not found"
exit 1
fi
# If OSGi framework JAR not found, try to use the annotations from the base JAR
if [ -z "$OSGI_JAR" ]; then
echo "WARNING: org.osgi.framework JAR not found, checking if it's in base JAR"
fi
echo "Base JAR: $BASE_JAR"
echo "JSON JAR: $JSON_JAR"
# Create directories
mkdir -p "$DIST_DIR"
mkdir -p "$BUILD_DIR"
# Find Java
JAVA_HOME="${JAVA_HOME:-$(dirname $(dirname $(readlink -f $(which java))))}"
JAVAC="$JAVA_HOME/bin/javac"
JAR="$JAVA_HOME/bin/jar"
if [ ! -f "$JAVAC" ]; then
echo "ERROR: javac not found. Please set JAVA_HOME"
exit 1
fi
echo "Using javac: $JAVAC"
# Build classpath
CLASSPATH="$BASE_JAR:$JSON_JAR"
if [ -n "$OSGI_JAR" ]; then
CLASSPATH="$CLASSPATH:$OSGI_JAR"
fi
if [ -n "$COMPONENT_ANNOTATIONS_JAR" ]; then
CLASSPATH="$CLASSPATH:$COMPONENT_ANNOTATIONS_JAR"
fi
# Find all Java source files
SOURCES=$(find "$PLUGIN_DIR/src" -name "*.java")
SOURCE_COUNT=$(echo "$SOURCES" | wc -l)
echo "Found $SOURCE_COUNT source files"
# Compile
echo "Compiling..."
$JAVAC -cp "$CLASSPATH" \
-d "$BUILD_DIR" \
-source 11 -target 11 \
$SOURCES
if [ $? -ne 0 ]; then
echo "ERROR: Compilation failed"
exit 1
fi
echo "Compilation successful"
# Create JAR
echo "Creating JAR..."
$JAR cfm "$DIST_DIR/com.venezuela.bcvrate.jar" "$PLUGIN_DIR/META-INF/MANIFEST.MF" \
-C "$BUILD_DIR" .
if [ $? -ne 0 ]; then
echo "ERROR: JAR creation failed"
exit 1
fi
JAR_SIZE=$(du -h "$DIST_DIR/com.venezuela.bcvrate.jar" | cut -f1)
echo "JAR created: $DIST_DIR/com.venezuela.bcvrate.jar ($JAR_SIZE)"
# Copy to iDempiere plugins directory
echo "Copying to iDempiere plugins..."
cp "$DIST_DIR/com.venezuela.bcvrate.jar" "$IDEMPIERE_HOME/plugins/"
echo ""
echo "=== Build Complete ==="
echo "JAR installed at: $IDEMPIERE_HOME/plugins/com.venezuela.bcvrate.jar"
echo ""
echo "Next steps:"
echo "1. Restart iDempiere server"
echo "2. Check bundle status in Felix console: felix:ls | grep bcvrate"
echo "3. Create AD_Process via UI (if not done)"
echo "4. Add process to menu"
echo "5. Test the process"
+182
View File
@@ -0,0 +1,182 @@
<?xml version="1.0" encoding="UTF-8"?>
<Migration>
<Application>
<AD_Migration>
<Step seqNo="10" type="D" name="Create Process BCV_ExchangeRateUpdate">
<Table name="AD_Process">
<Record>
<AD_Process_ID>5000000</AD_Process_ID>
<AD_Client_ID>0</AD_Client_ID>
<AD_Org_ID>0</AD_Org_ID>
<IsActive>Y</IsActive>
<Created>2026-06-22</Created>
<CreatedBy>100</CreatedBy>
<Updated>2026-06-22</Updated>
<UpdatedBy>100</UpdatedBy>
<Name>BCV_ExchangeRateUpdate</Name>
<Description>Actualización automática de tasa de cambio oficial BCV (USD/VES)</Description>
<Help>Obtiene la tasa de cambio oficial del Banco Central de Venezuela y la registra en la tabla C_Conversion_Rate. La moneda funcional se obtiene del esquema contable principal del grupo empresarial.</Help>
<Classname>com.venezuela.bcvrate.process.BCVExchangeRateProcess</Classname>
<IsDirectPrint>N</IsDirectPrint>
<IsReport>N</IsReport>
<IsServerProcess>Y</IsServerProcess>
<ShowHelp>L</ShowHelp>
<AD_WF_Process_ID>0</AD_WF_Process_ID>
<Value>BCV_ExchangeRateUpdate</Value>
</Record>
</Table>
</Step>
<Step seqNo="20" type="D" name="Create Process Parameter AD_Client_ID">
<Table name="AD_Process_Para">
<Record>
<AD_Process_Para_ID>5000000</AD_Process_Para_ID>
<AD_Client_ID>0</AD_Client_ID>
<AD_Org_ID>0</AD_Org_ID>
<IsActive>Y</IsActive>
<Created>2026-06-22</Created>
<CreatedBy>100</CreatedBy>
<Updated>2026-06-22</Updated>
<UpdatedBy>100</UpdatedBy>
<Name>Client</Name>
<Description>Grupo empresarial destino para la tasa de cambio</Description>
<AD_Process_ID>5000000</AD_Process_ID>
<SeqNo>10</SeqNo>
<AD_Reference_ID>19</AD_Reference_ID>
<AD_Reference_Value_ID>157</AD_Reference_Value_ID>
<DefaultValue></DefaultValue>
<IsMandatory>Y</IsMandatory>
<RangeHigh>0</RangeHigh>
<RangeLow>0</RangeLow>
<Value>AD_Client_ID</Value>
</Record>
</Table>
</Step>
<Step seqNo="30" type="D" name="Create Process Parameter C_ConversionType_ID">
<Table name="AD_Process_Para">
<Record>
<AD_Process_Para_ID>5000001</AD_Process_Para_ID>
<AD_Client_ID>0</AD_Client_ID>
<AD_Org_ID>0</AD_Org_ID>
<IsActive>Y</IsActive>
<Created>2026-06-22</Created>
<CreatedBy>100</CreatedBy>
<Updated>2026-06-22</Updated>
<UpdatedBy>100</UpdatedBy>
<Name>Currency Conversion Type</Name>
<Description>Tipo de conversión de moneda (Spot, Corporate, etc.)</Description>
<AD_Process_ID>5000000</AD_Process_ID>
<SeqNo>20</SeqNo>
<AD_Reference_ID>19</AD_Reference_ID>
<AD_Reference_Value_ID>232</AD_Reference_Value_ID>
<DefaultValue>@SQL=SELECT C_ConversionType_ID FROM C_ConversionType WHERE IsDefault='Y' AND AD_Client_ID IN (0, @AD_Client_ID@) ORDER BY AD_Client_ID DESC LIMIT 1</DefaultValue>
<IsMandatory>N</IsMandatory>
<RangeHigh>0</RangeHigh>
<RangeLow>0</RangeLow>
<Value>C_ConversionType_ID</Value>
</Record>
</Table>
</Step>
<Step seqNo="40" type="D" name="Create Process Parameter DateFrom">
<Table name="AD_Process_Para">
<Record>
<AD_Process_Para_ID>5000002</AD_Process_Para_ID>
<AD_Client_ID>0</AD_Client_ID>
<AD_Org_ID>0</AD_Org_ID>
<IsActive>Y</IsActive>
<Created>2026-06-22</Created>
<CreatedBy>100</CreatedBy>
<Updated>2026-06-22</Updated>
<UpdatedBy>100</UpdatedBy>
<Name>Date From</Name>
<Description>Fecha de inicio para buscar tasas de cambio. Si no se indica, usa la fecha actual.</Description>
<AD_Process_ID>5000000</AD_Process_ID>
<SeqNo>30</SeqNo>
<AD_Reference_ID>16</AD_Reference_ID>
<DefaultValue>@#Date@</DefaultValue>
<IsMandatory>N</IsMandatory>
<RangeHigh>0</RangeHigh>
<RangeLow>0</RangeLow>
<Value>DateFrom</Value>
</Record>
</Table>
</Step>
<Step seqNo="50" type="D" name="Create Process Parameter DateTo">
<Table name="AD_Process_Para">
<Record>
<AD_Process_Para_ID>5000003</AD_Process_Para_ID>
<AD_Client_ID>0</AD_Client_ID>
<AD_Org_ID>0</AD_Org_ID>
<IsActive>Y</IsActive>
<Created>2026-06-22</Created>
<CreatedBy>100</CreatedBy>
<Updated>2026-06-22</Updated>
<UpdatedBy>100</UpdatedBy>
<Name>Date To</Name>
<Description>Fecha de fin para buscar tasas de cambio. Si no se indica, usa la fecha actual.</Description>
<AD_Process_ID>5000000</AD_Process_ID>
<SeqNo>40</SeqNo>
<AD_Reference_ID>16</AD_Reference_ID>
<DefaultValue>@#Date@</DefaultValue>
<IsMandatory>N</IsMandatory>
<RangeHigh>0</RangeHigh>
<RangeLow>0</RangeLow>
<Value>DateTo</Value>
</Record>
</Table>
</Step>
<Step seqNo="60" type="D" name="Create Process Parameter IsSimulation">
<Table name="AD_Process_Para">
<Record>
<AD_Process_Para_ID>5000004</AD_Process_Para_ID>
<AD_Client_ID>0</AD_Client_ID>
<AD_Org_ID>0</AD_Org_ID>
<IsActive>Y</IsActive>
<Created>2026-06-22</Created>
<CreatedBy>100</CreatedBy>
<Updated>2026-06-22</Updated>
<UpdatedBy>100</UpdatedBy>
<Name>Simulation</Name>
<Description>Si está marcado, el proceso solo muestra qué haría sin grabar datos</Description>
<AD_Process_ID>5000000</AD_Process_ID>
<SeqNo>50</SeqNo>
<AD_Reference_ID>20</AD_Reference_ID>
<DefaultValue>N</DefaultValue>
<IsMandatory>N</IsMandatory>
<RangeHigh>0</RangeHigh>
<RangeLow>0</RangeLow>
<Value>IsSimulation</Value>
</Record>
</Table>
</Step>
<Step seqNo="70" type="D" name="Create Schedule for BCV Rate Update">
<Table name="AD_Schedule">
<Record>
<AD_Schedule_ID>5000000</AD_Schedule_ID>
<AD_Client_ID>0</AD_Client_ID>
<AD_Org_ID>0</AD_Org_ID>
<IsActive>Y</IsActive>
<Created>2026-06-22</Created>
<CreatedBy>100</CreatedBy>
<Updated>2026-06-22</Updated>
<UpdatedBy>100</UpdatedBy>
<Name>BCV_DailyRateUpdate</Name>
<Description>Actualización diaria de tasa BCV - Lunes a Viernes 16:00-23:00 cada 15 min</Description>
<ScheduleType>C</ScheduleType>
<CronPattern>0 0/15 16-23 * * 1-5</CronPattern>
<Frequency>15</Frequency>
<FrequencyType>M</FrequencyType>
<IsIgnoreProcessingTime>Y</IsIgnoreProcessingTime>
<IsSystemSchedule>Y</IsSystemSchedule>
<AD_Process_ID>5000000</AD_Process_ID>
</Record>
</Table>
</Step>
</AD_Migration>
</Application>
</Migration>
+94
View File
@@ -0,0 +1,94 @@
-- ============================================
-- BCV Exchange Rate Plugin - Registro en iDempiere
-- ============================================
-- Ejecutar este script en la base de datos de iDempiere
-- ANTES de instalar el plugin OSGi
-- ============================================
-- 1. Crear el Proceso
INSERT INTO AD_Process (
AD_Process_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, Value, Description, Help, Classname,
AccessLevel, EntityType, IsDirectPrint, IsReport, IsServerProcess, ShowHelp
) VALUES (
5000000, 0, 0, 'Y', CURRENT_TIMESTAMP, 100, CURRENT_TIMESTAMP, 100,
'BCV_ExchangeRateUpdate', 'BCV_ExchangeRateUpdate',
'Actualización automática de tasa de cambio oficial BCV (USD/VES)',
'Obtiene la tasa de cambio oficial del Banco Central de Venezuela y la registra en C_Conversion_Rate.',
'com.venezuela.bcvrate.process.BCVExchangeRateProcess',
'4', 'U', 'N', 'N', 'Y', 'L'
)
ON CONFLICT (AD_Process_ID) DO NOTHING;
-- 2. Parámetro: AD_Client_ID (Grupo Empresarial)
INSERT INTO AD_Process_Para (
AD_Process_Para_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, ColumnName, Description, AD_Process_ID, SeqNo,
AD_Reference_ID, AD_Reference_Value_ID, IsMandatory, DefaultValue,
FieldLength, IsRange
) VALUES (
5000000, 0, 0, 'Y', CURRENT_TIMESTAMP, 100, CURRENT_TIMESTAMP, 100,
'Client', 'AD_Client_ID', 'Grupo empresarial destino',
5000000, 10, 19, 157, 'Y', '', 10, 'N'
)
ON CONFLICT (AD_Process_Para_ID) DO NOTHING;
-- 3. Parámetro: C_ConversionType_ID (Tipo de Conversión)
INSERT INTO AD_Process_Para (
AD_Process_Para_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, ColumnName, Description, AD_Process_ID, SeqNo,
AD_Reference_ID, AD_Reference_Value_ID, IsMandatory, DefaultValue,
FieldLength, IsRange
) VALUES (
5000001, 0, 0, 'Y', CURRENT_TIMESTAMP, 100, CURRENT_TIMESTAMP, 100,
'Currency Conversion Type', 'C_ConversionType_ID', 'Tipo de conversión (Spot, Corporate, etc.)',
5000000, 20, 19, 232, 'N', '', 10, 'N'
)
ON CONFLICT (AD_Process_Para_ID) DO NOTHING;
-- 4. Parámetro: DateFrom (Fecha Desde)
INSERT INTO AD_Process_Para (
AD_Process_Para_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, ColumnName, Description, AD_Process_ID, SeqNo,
AD_Reference_ID, IsMandatory, DefaultValue,
FieldLength, IsRange
) VALUES (
5000002, 0, 0, 'Y', CURRENT_TIMESTAMP, 100, CURRENT_TIMESTAMP, 100,
'Date From', 'DateFrom', 'Fecha inicio (default: hoy)',
5000000, 30, 16, 'N', '@#Date@', 10, 'N'
)
ON CONFLICT (AD_Process_Para_ID) DO NOTHING;
-- 5. Parámetro: DateTo (Fecha Hasta)
INSERT INTO AD_Process_Para (
AD_Process_Para_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, ColumnName, Description, AD_Process_ID, SeqNo,
AD_Reference_ID, IsMandatory, DefaultValue,
FieldLength, IsRange
) VALUES (
5000003, 0, 0, 'Y', CURRENT_TIMESTAMP, 100, CURRENT_TIMESTAMP, 100,
'Date To', 'DateTo', 'Fecha fin (default: hoy)',
5000000, 40, 16, 'N', '@#Date@', 10, 'N'
)
ON CONFLICT (AD_Process_Para_ID) DO NOTHING;
-- 6. Parámetro: IsSimulation (Modo Simulación)
INSERT INTO AD_Process_Para (
AD_Process_Para_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, ColumnName, Description, AD_Process_ID, SeqNo,
AD_Reference_ID, IsMandatory, DefaultValue,
FieldLength, IsRange
) VALUES (
5000004, 0, 0, 'Y', CURRENT_TIMESTAMP, 100, CURRENT_TIMESTAMP, 100,
'Simulation', 'IsSimulation', 'Si está marcado, solo muestra qué haría sin grabar',
5000000, 50, 20, 'N', 'N', 1, 'N'
)
ON CONFLICT (AD_Process_Para_ID) DO NOTHING;
-- ============================================
-- NOTA: El Scheduler se crea manualmente en iDempiere:
-- Ir a: Procesos > Scheduler > Nuevo
-- Nombre: BCV_DailyRateUpdate
-- Patrón Cron: 0 0/15 16-23 * * 1-5
-- Proceso: BCV_ExchangeRateUpdate
-- ============================================
+90
View File
@@ -0,0 +1,90 @@
-- ============================================================
-- BCV Exchange Rate Process Registration for iDempiere v10
-- ============================================================
-- 1. Register the Process
INSERT INTO AD_Process (
AD_Process_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, Value, Description, Help, ClassName, IsReport, IsDirectPrint,
IsServerProcess, IsBetaFunctionality, AccessLevel
) VALUES (
50001, 0, 0, 'Y', CURRENT_TIMESTAMP, 0, CURRENT_TIMESTAMP, 0,
'BCV Exchange Rate Update',
'BCVExchangeRate',
'Fetches USD/VES exchange rate from BCV official API and registers it',
'Connects to BCV API to get the latest USD/VES exchange rate.',
'com.venezuela.bcvrate.process.BCVExchangeRateProcess',
'N', 'N', 'Y', 'N', '3'
);
-- 2. Register Process Parameters
INSERT INTO AD_Process_Para (
AD_Process_Para_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, Description, AD_Process_ID, AD_Reference_ID, AD_Val_Rule_ID,
IsMandatory, IsRange, FieldLength, DefaultValue, SeqNo,
IsEncrypted, IsCentrallyMaintained, EntityType
) VALUES (
50001, 0, 0, 'Y', CURRENT_TIMESTAMP, 0, CURRENT_TIMESTAMP, 0,
'Client', 'Client for this installation',
50001, 18, 100,
'Y', 'N', 0, '@#AD_Client_ID@', 10,
'N', 'Y', 'D'
);
INSERT INTO AD_Process_Para (
AD_Process_Para_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, Description, AD_Process_ID, AD_Reference_ID, AD_Val_Rule_ID,
IsMandatory, IsRange, FieldLength, DefaultValue, SeqNo,
IsEncrypted, IsCentrallyMaintained, EntityType
) VALUES (
50002, 0, 0, 'Y', CURRENT_TIMESTAMP, 0, CURRENT_TIMESTAMP, 0,
'Conversion Type', 'Currency conversion type',
50001, 18, 153,
'Y', 'N', 0, 'S', 20,
'N', 'Y', 'D'
);
INSERT INTO AD_Process_Para (
AD_Process_Para_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, Description, AD_Process_ID, AD_Reference_ID,
IsMandatory, IsRange, FieldLength, DefaultValue, SeqNo,
IsEncrypted, IsCentrallyMaintained, EntityType
) VALUES (
50003, 0, 0, 'Y', CURRENT_TIMESTAMP, 0, CURRENT_TIMESTAMP, 0,
'Date From', 'Start date for rate lookup',
50001, 16,
'N', 'N', 0, '@#Date@', 30,
'N', 'Y', 'D'
);
INSERT INTO AD_Process_Para (
AD_Process_Para_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, Description, AD_Process_ID, AD_Reference_ID,
IsMandatory, IsRange, FieldLength, DefaultValue, SeqNo,
IsEncrypted, IsCentrallyMaintained, EntityType
) VALUES (
50004, 0, 0, 'Y', CURRENT_TIMESTAMP, 0, CURRENT_TIMESTAMP, 0,
'Date To', 'End date for rate lookup',
50001, 16,
'N', 'N', 0, '@#Date@', 40,
'N', 'Y', 'D'
);
INSERT INTO AD_Process_Para (
AD_Process_Para_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, Description, AD_Process_ID, AD_Reference_ID,
IsMandatory, IsRange, FieldLength, DefaultValue, SeqNo,
IsEncrypted, IsCentrallyMaintained, EntityType
) VALUES (
50005, 0, 0, 'Y', CURRENT_TIMESTAMP, 0, CURRENT_TIMESTAMP, 0,
'Simulation Mode', 'Run in simulation mode - no actual changes saved',
50001, 28,
'N', 'N', 0, 'N', 50,
'N', 'Y', 'D'
);
-- 3. Verify
SELECT p.Name, p.ClassName,
(SELECT COUNT(*) FROM AD_Process_Para pp WHERE pp.AD_Process_ID = p.AD_Process_ID) as Params
FROM AD_Process p WHERE p.ClassName = 'com.venezuela.bcvrate.process.BCVExchangeRateProcess';
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.venezuela</groupId>
<artifactId>com.venezuela.bcvrate</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>eclipse-plugin</packaging>
<name>BCV Exchange Rate Plugin for iDempiere</name>
<description>
Plugin para iDempiere v10 que obtiene la tasa de cambio oficial del BCV
(Banco Central de Venezuela) y la registra automáticamente en el sistema.
</description>
<properties>
<tycho.version>4.0.4</tycho.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.eclipse.tycho</groupId>
<artifactId>tycho-maven-plugin</artifactId>
<version>${tycho.version}</version>
<extensions>true</extensions>
</plugin>
</plugins>
</build>
</project>
+23
View File
@@ -0,0 +1,23 @@
package com.venezuela.bcvrate;
import org.adempiere.base.IProcessFactory;
import org.osgi.framework.BundleActivator;
import org.osgi.framework.BundleContext;
import org.osgi.framework.ServiceRegistration;
import com.venezuela.bcvrate.factory.BCVProcessFactory;
public class Activator implements BundleActivator {
private ServiceRegistration<?> reg;
@Override
public void start(BundleContext ctx) throws Exception {
reg = ctx.registerService(IProcessFactory.class.getName(), new BCVProcessFactory(), null);
}
@Override
public void stop(BundleContext ctx) throws Exception {
if (reg != null) reg.unregister();
}
}
@@ -0,0 +1,17 @@
package com.venezuela.bcvrate.factory;
import org.adempiere.base.IProcessFactory;
import org.compiere.process.SvrProcess;
import com.venezuela.bcvrate.process.BCVExchangeRateProcess;
public class BCVProcessFactory implements IProcessFactory {
@Override
public SvrProcess newProcessInstance(String className) {
if ("com.venezuela.bcvrate.process.BCVExchangeRateProcess".equals(className)) {
return new BCVExchangeRateProcess();
}
return null;
}
}
@@ -0,0 +1,324 @@
package com.venezuela.bcvrate.process;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.sql.Timestamp;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.compiere.model.MAcctSchema;
import org.compiere.model.MConversionRate;
import org.compiere.model.MConversionType;
import org.compiere.model.MCurrency;
import org.compiere.model.Query;
import org.compiere.process.ProcessInfoParameter;
import org.compiere.process.SvrProcess;
import org.compiere.util.Env;
import com.venezuela.bcvrate.service.BCVApiService;
import com.venezuela.bcvrate.service.BCVRateResponse;
public class BCVExchangeRateProcess extends SvrProcess {
private static final Logger log = Logger.getLogger(BCVExchangeRateProcess.class.getName());
private static final int C_CURRENCY_USD = 100;
private int p_AD_Client_ID = 0;
private int p_C_ConversionType_ID = 0;
private Timestamp p_DateFrom = null;
private Timestamp p_DateTo = null;
private boolean p_IsSimulation = false;
private int currencyToId = 0;
private String currencyToIso = null;
private BCVApiService apiService;
private int countInserted = 0;
private int countSkipped = 0;
private int countErrors = 0;
@Override
protected void prepare() {
ProcessInfoParameter[] params = getParameter();
for (ProcessInfoParameter param : params) {
String name = param.getParameterName();
if (param.getParameter() == null) {
continue;
}
if ("AD_Client_ID".equals(name)) {
p_AD_Client_ID = param.getParameterAsInt();
} else if ("C_ConversionType_ID".equals(name)) {
p_C_ConversionType_ID = param.getParameterAsInt();
} else if ("DateFrom".equals(name)) {
p_DateFrom = (Timestamp) param.getParameter();
} else if ("DateTo".equals(name)) {
p_DateTo = (Timestamp) param.getParameter();
} else if ("IsSimulation".equals(name)) {
p_IsSimulation = "Y".equals(param.getParameter());
}
}
if (p_DateFrom == null) {
p_DateFrom = truncateToDay(new Timestamp(System.currentTimeMillis()));
}
if (p_DateTo == null) {
p_DateTo = truncateToDay(new Timestamp(System.currentTimeMillis()));
}
if (p_AD_Client_ID <= 0) {
p_AD_Client_ID = Env.getAD_Client_ID(getCtx());
}
}
@Override
protected String doIt() throws Exception {
log.info("=== Inicio proceso BCV Exchange Rate Update ===");
log.info("Cliente: " + p_AD_Client_ID);
log.info("Fecha desde: " + p_DateFrom);
log.info("Fecha hasta: " + p_DateTo);
log.info("Modo simulacion: " + p_IsSimulation);
apiService = new BCVApiService();
if (!obtenerMonedaFuncional()) {
return "@Error@ No se pudo obtener la moneda funcional del esquema contable";
}
if (p_C_ConversionType_ID <= 0) {
p_C_ConversionType_ID = MConversionType.getDefault(p_AD_Client_ID);
log.info("Usando tipo de conversion Spot por defecto: " + p_C_ConversionType_ID);
}
Env.setContext(getCtx(), "#AD_Client_ID", p_AD_Client_ID);
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
Calendar cal = Calendar.getInstance();
boolean esEjecucionAutomatica = (p_DateFrom.equals(p_DateTo)
&& p_DateFrom.equals(truncateToDay(new Timestamp(System.currentTimeMillis()))));
Timestamp fechaInicio = p_DateFrom;
Timestamp fechaFin = p_DateTo;
if (esEjecucionAutomatica) {
Timestamp ultimaFechaConTasa = getUltimaFechaConTasa();
BCVRateResponse ultimaTasaBCV = apiService.getLatestRate();
if (ultimaTasaBCV != null && ultimaTasaBCV.isValid()) {
String fechaTasaBCV = ultimaTasaBCV.getDate();
SimpleDateFormat sdfParse = new SimpleDateFormat("yyyy-MM-dd");
Timestamp fechaBCV = truncateToDay(new Timestamp(sdfParse.parse(fechaTasaBCV).getTime()));
Timestamp hoy = truncateToDay(new Timestamp(System.currentTimeMillis()));
if (!fechaBCV.equals(hoy) && fechaBCV.after(hoy)) {
log.info("Tasa BCV publicada para " + fechaTasaBCV + " (dia futuro)");
}
if (ultimaFechaConTasa != null) {
if (!fechaBCV.after(ultimaFechaConTasa)) {
log.info("Ultima tasa BCV (" + sdf.format(fechaBCV)
+ ") ya registrada en BD (" + sdf.format(ultimaFechaConTasa) + ")");
String resumen = "=== Resumen ===\nNo hay dias pendientes";
log.info(resumen);
addLog(resumen);
return "@OK@ No hay dias pendientes";
}
cal.setTime(ultimaFechaConTasa);
cal.add(Calendar.DAY_OF_MONTH, 1);
fechaInicio = new Timestamp(cal.getTimeInMillis());
} else {
fechaInicio = truncateToDay(new Timestamp(System.currentTimeMillis()));
}
fechaFin = fechaBCV;
} else {
log.info("No se encontro tasa BCV futura");
String resumen = "=== Resumen ===\nNo hay dias pendientes";
log.info(resumen);
addLog(resumen);
return "@OK@ No hay dias pendientes";
}
log.info("Ejecucion automatica: procesando desde " + sdf.format(fechaInicio)
+ " hasta " + sdf.format(fechaFin));
}
cal.setTime(fechaInicio);
Timestamp currentDate = fechaInicio;
while (!currentDate.after(fechaFin)) {
String dateStr = sdf.format(currentDate);
procesarDia(currentDate, dateStr);
cal.setTime(currentDate);
cal.add(Calendar.DAY_OF_MONTH, 1);
currentDate = new Timestamp(cal.getTimeInMillis());
}
String resumen = "=== Resumen ===" +
"\nTasas insertadas: " + countInserted +
"\nYa existian (omitidas): " + countSkipped +
"\nErrores: " + countErrors;
log.info(resumen);
addLog(resumen);
return "@OK@ Insertadas: " + countInserted +
", Omitidas: " + countSkipped + ", Errores: " + countErrors;
}
private boolean obtenerMonedaFuncional() {
try {
MAcctSchema[] schemas = MAcctSchema.getClientAcctSchema(
getCtx(), p_AD_Client_ID, get_TrxName());
if (schemas == null || schemas.length == 0) {
log.severe("No se encontro esquema contable para el cliente: " + p_AD_Client_ID);
return false;
}
MAcctSchema primarySchema = schemas[0];
currencyToId = primarySchema.getC_Currency_ID();
currencyToIso = MCurrency.getISO_Code(getCtx(), currencyToId);
log.info("Moneda funcional detectada: " + currencyToIso + " (ID: " + currencyToId + ")");
if (C_CURRENCY_USD == currencyToId) {
log.severe("La moneda origen (USD) es igual a la moneda destino.");
return false;
}
return true;
} catch (Exception e) {
log.log(Level.SEVERE, "Error obteniendo moneda funcional", e);
return false;
}
}
private void procesarDia(Timestamp date, String dateStr) {
log.info("Procesando fecha: " + dateStr);
addLog("Buscando tasa para " + dateStr + "...");
if (existeTasaParaFecha(date)) {
log.info("Ya existe tasa para " + dateStr + ", omitiendo...");
addLog("Fecha " + dateStr + ": Ya existe tasa (omitida)");
countSkipped++;
return;
}
BCVRateResponse rateResponse = apiService.getLatestRate();
if (rateResponse == null || !rateResponse.isValid()) {
log.warning("No se pudo obtener tasa del BCV para " + dateStr);
addLog("Fecha " + dateStr + ": Error - No se obtuvo tasa del BCV (API + scraping fallaron)");
countErrors++;
return;
}
String apiEffectiveDate = rateResponse.getEffectiveDate();
log.info("Tasa BCV: " + rateResponse.getDollar()
+ " fecha_solicitada=" + dateStr
+ " fecha_bcv=" + apiEffectiveDate);
addLog("Fecha " + dateStr + ": Tasa encontrada = " + rateResponse.getDollar()
+ " (BCV fecha: " + apiEffectiveDate + ")");
if (!p_IsSimulation) {
boolean created = crearRegistroTasa(date, rateResponse.getDollar());
if (created) {
addLog("Fecha " + dateStr + ": Tasa insertada = " + rateResponse.getDollar()
+ " (BCV fecha: " + apiEffectiveDate + ")");
countInserted++;
} else {
addLog("Fecha " + dateStr + ": Error al insertar tasa");
countErrors++;
}
} else {
addLog("Fecha " + dateStr + ": [SIMULACION] Tasa = " + rateResponse.getDollar()
+ " (BCV fecha: " + apiEffectiveDate + ")");
countInserted++;
}
}
private boolean existeTasaParaFecha(Timestamp date) {
Timestamp startOfDay = truncateToDay(date);
Timestamp endOfDay = addOneDay(startOfDay);
String whereClause = "C_Currency_ID=? AND C_Currency_ID_To=? " +
"AND C_ConversionType_ID=? AND AD_Client_ID=? " +
"AND ValidFrom>=? AND ValidFrom<?";
long count = new Query(getCtx(), "C_Conversion_Rate", whereClause, get_TrxName())
.setParameters(C_CURRENCY_USD, currencyToId, p_C_ConversionType_ID,
p_AD_Client_ID, startOfDay, endOfDay)
.count();
return count > 0;
}
private boolean crearRegistroTasa(Timestamp date, BigDecimal rate) {
try {
Timestamp startOfDay = truncateToDay(date);
BigDecimal multiplyRate = rate.setScale(4, RoundingMode.HALF_UP);
BigDecimal divideRate = BigDecimal.ONE.divide(multiplyRate, 10, RoundingMode.HALF_UP);
log.info("Insertando tasa: USD -> " + currencyToIso + " = " + multiplyRate + " fecha=" + startOfDay);
MConversionRate cr = new MConversionRate(getCtx(), 0, get_TrxName());
cr.set_ValueNoCheck("AD_Client_ID", p_AD_Client_ID);
cr.set_ValueNoCheck("AD_Org_ID", 0);
cr.set_ValueNoCheck("C_Currency_ID", C_CURRENCY_USD);
cr.set_ValueNoCheck("C_Currency_ID_To", currencyToId);
cr.set_ValueNoCheck("C_ConversionType_ID", p_C_ConversionType_ID);
cr.set_ValueNoCheck("ValidFrom", startOfDay);
cr.set_ValueNoCheck("ValidTo", startOfDay);
cr.set_ValueNoCheck("MultiplyRate", multiplyRate);
cr.set_ValueNoCheck("DivideRate", divideRate);
cr.saveEx();
log.info("Registro creado: ID=" + cr.get_ID()
+ " MultiplyRate=" + cr.get_Value("MultiplyRate")
+ " DivideRate=" + cr.get_Value("DivideRate"));
return true;
} catch (Exception e) {
String msg = "Error creando registro: " + e.getMessage();
log.log(Level.SEVERE, msg, e);
addLog(" Detalle: " + e.getClass().getSimpleName() + " - " + e.getMessage());
return false;
}
}
private Timestamp getUltimaFechaConTasa() {
String whereClause = "C_Currency_ID=? AND C_Currency_ID_To=? " +
"AND C_ConversionType_ID=? AND AD_Client_ID=?";
MConversionRate lastRate = new Query(getCtx(), "C_Conversion_Rate", whereClause, get_TrxName())
.setParameters(C_CURRENCY_USD, currencyToId, p_C_ConversionType_ID, p_AD_Client_ID)
.setOrderBy("ValidFrom DESC")
.first();
if (lastRate != null) {
Timestamp lastDate = lastRate.getValidFrom();
log.info("Ultima fecha con tasa en BD: " + new SimpleDateFormat("yyyy-MM-dd").format(lastDate));
return lastDate;
}
log.info("No hay tasas previas en BD");
return null;
}
private Timestamp truncateToDay(Timestamp ts) {
Calendar cal = Calendar.getInstance();
cal.setTime(ts);
cal.set(Calendar.HOUR_OF_DAY, 0);
cal.set(Calendar.MINUTE, 0);
cal.set(Calendar.SECOND, 0);
cal.set(Calendar.MILLISECOND, 0);
return new Timestamp(cal.getTimeInMillis());
}
private Timestamp addOneDay(Timestamp ts) {
Calendar cal = Calendar.getInstance();
cal.setTime(ts);
cal.add(Calendar.DAY_OF_MONTH, 1);
return new Timestamp(cal.getTimeInMillis());
}
}
@@ -0,0 +1,276 @@
package com.venezuela.bcvrate.service;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
public class BCVApiService {
private static final Logger log = Logger.getLogger(BCVApiService.class.getName());
private static final String BASE_URL = "https://bcv.today/api/v1";
private static final String BCV_WEBSITE = "https://www.bcv.org.ve/";
private static final int CONNECTION_TIMEOUT = 15000;
private static final int READ_TIMEOUT = 30000;
private static final int MAX_DAYS_BACK = 10;
public BCVRateResponse getRateForDateWithFallback(String requestedDate) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
Calendar cal = Calendar.getInstance();
try {
cal.setTime(sdf.parse(requestedDate));
} catch (Exception e) {
log.log(Level.WARNING, "Error parseando fecha: " + requestedDate, e);
return null;
}
for (int i = 0; i <= MAX_DAYS_BACK; i++) {
String dateToQuery = sdf.format(cal.getTime());
BCVRateResponse rate = getRateFromApi(dateToQuery);
if (rate != null && rate.isValid()) {
log.info("Tasa encontrada (API) consultando " + dateToQuery
+ " para fecha solicitada " + requestedDate
+ ": USD=" + rate.getDollar()
+ " effective=" + rate.getDate());
return new BCVRateResponse(rate.getDollar(), requestedDate, rate.getDate());
}
cal.add(Calendar.DAY_OF_MONTH, -1);
}
log.info("API no tiene tasa para " + requestedDate + ", intentando scraping BCV...");
BCVRateResponse websiteRate = getRateFromBCVWebsite();
if (websiteRate != null && websiteRate.isValid()) {
log.info("Tasa obtenida del sitio web BCV: USD=" + websiteRate.getDollar()
+ " effective=" + websiteRate.getDate());
return new BCVRateResponse(websiteRate.getDollar(), requestedDate, websiteRate.getDate());
}
log.info("No se encontro tasa en los ultimos " + MAX_DAYS_BACK + " dias para: " + requestedDate);
return null;
}
public BCVRateResponse getRateForDate(String date) {
return getRateFromApi(date);
}
public BCVRateResponse getLatestRate() {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
for (int i = 1; i <= 10; i++) {
Calendar cal = Calendar.getInstance();
cal.add(Calendar.DAY_OF_MONTH, i);
String futureDate = sdf.format(cal.getTime());
BCVRateResponse rate = getRateFromApi(futureDate);
if (rate != null && rate.isValid()) {
log.info("Tasa encontrada (API futura) para " + futureDate
+ ": USD=" + rate.getDollar() + " effective=" + rate.getDate());
return rate;
}
}
log.info("API no tiene tasa futura, intentando scraping BCV...");
try {
BCVRateResponse websiteRate = getRateFromBCVWebsite();
if (websiteRate != null && websiteRate.isValid()) {
log.info("Tasa obtenida del sitio web BCV: USD=" + websiteRate.getDollar()
+ " effective=" + websiteRate.getDate());
return websiteRate;
}
log.info("Scraping BCV no devolvio tasa valida");
} catch (Exception e) {
log.log(Level.WARNING, "Scraping BCV fallo", e);
}
log.info("Fallback a API actual...");
BCVRateResponse apiRate = getCurrentRate();
return apiRate;
}
private BCVRateResponse getRateFromApi(String date) {
String urlStr = BASE_URL + "/history/" + date + ".json";
try {
String response = httpGet(urlStr);
if (response == null) return null;
BigDecimal dollar = extractUSD(response);
String effectiveDate = extractField(response, "effective_date");
if (effectiveDate == null) effectiveDate = extractField(response, "date");
if (dollar != null && effectiveDate != null) {
return new BCVRateResponse(dollar, effectiveDate);
}
return null;
} catch (Exception e) {
log.log(Level.WARNING, "Error consultando fecha: " + date, e);
return null;
}
}
public BCVRateResponse getCurrentRate() {
String urlStr = BASE_URL + "/rate.json";
try {
String response = httpGet(urlStr);
if (response == null) return null;
BigDecimal dollar = extractUSD(response);
String effectiveDate = extractField(response, "effective_date");
if (effectiveDate == null) effectiveDate = extractField(response, "date");
if (dollar != null && effectiveDate != null) {
log.info("Tasa actual (API): USD=" + dollar + " effective=" + effectiveDate);
return new BCVRateResponse(dollar, effectiveDate);
}
return null;
} catch (Exception e) {
log.log(Level.WARNING, "Error consultando tasa actual", e);
return null;
}
}
private BCVRateResponse getRateFromBCVWebsite() {
try {
log.info("Iniciando scraping de BCV website...");
String html = httpGetHtml(BCV_WEBSITE);
if (html == null) {
log.warning("No se pudo obtener pagina BCV (httpGetHtml returned null)");
return null;
}
log.info("Pagina BCV obtenida, longitud: " + html.length());
Matcher rateMatcher = Pattern.compile(
"USD</span>.*?strong-tb\">\\s*([\\d.,]+)\\s*</strong>", Pattern.DOTALL).matcher(html);
if (!rateMatcher.find()) {
log.warning("No se encontro tasa USD en pagina BCV (regex no match)");
return null;
}
String rateStr = rateMatcher.group(1).replace(",", ".");
log.info("Tasa encontrada en HTML: " + rateStr);
BigDecimal dollar = new BigDecimal(rateStr).setScale(4, RoundingMode.HALF_UP);
Matcher dateMatcher = Pattern.compile(
"content=\"(\\d{4}-\\d{2}-\\d{2})T").matcher(html);
String effectiveDate = null;
if (dateMatcher.find()) {
effectiveDate = dateMatcher.group(1);
}
if (dollar.compareTo(BigDecimal.ZERO) > 0) {
if (effectiveDate == null) {
effectiveDate = new SimpleDateFormat("yyyy-MM-dd").format(new java.util.Date());
}
log.info("Scraping BCV exitoso: USD=" + dollar + " effective=" + effectiveDate);
return new BCVRateResponse(dollar, effectiveDate);
}
return null;
} catch (Exception e) {
log.log(Level.WARNING, "Error scraping BCV website: " + e.getMessage(), e);
return null;
}
}
private BigDecimal extractUSD(String json) {
Matcher m = Pattern.compile("\"USD\"\\s*:\\s*([0-9.]+)").matcher(json);
if (m.find()) {
return new BigDecimal(m.group(1)).setScale(4, RoundingMode.HALF_UP);
}
return null;
}
private String extractField(String json, String field) {
Matcher m = Pattern.compile("\"" + field + "\"\\s*:\\s*\"([^\"]+)\"").matcher(json);
if (m.find()) {
return m.group(1);
}
return null;
}
private String httpGet(String urlStr) throws Exception {
URL url = new URL(urlStr);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "application/json");
conn.setConnectTimeout(CONNECTION_TIMEOUT);
conn.setReadTimeout(READ_TIMEOUT);
int responseCode = conn.getResponseCode();
if (responseCode != 200) {
conn.disconnect();
return null;
}
StringBuilder sb = new StringBuilder();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
} finally {
conn.disconnect();
}
return sb.toString();
}
private String httpGetHtml(String urlStr) throws Exception {
URL url = new URL(urlStr);
TrustManager[] trustAllCerts = new TrustManager[]{
new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() { return null; }
public void checkClientTrusted(X509Certificate[] certs, String authType) {}
public void checkServerTrusted(X509Certificate[] certs, String authType) {}
}
};
SSLContext sc = SSLContext.getInstance("TLS");
sc.init(null, trustAllCerts, new SecureRandom());
HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
conn.setSSLSocketFactory(sc.getSocketFactory());
conn.setHostnameVerifier((hostname, session) -> true);
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "text/html");
conn.setConnectTimeout(CONNECTION_TIMEOUT);
conn.setReadTimeout(READ_TIMEOUT);
int responseCode = conn.getResponseCode();
if (responseCode != 200) {
conn.disconnect();
return null;
}
StringBuilder sb = new StringBuilder();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream(), "UTF-8"))) {
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
} finally {
conn.disconnect();
}
return sb.toString();
}
}
@@ -0,0 +1,107 @@
package com.venezuela.bcvrate.service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
/**
* Modelo para la respuesta del historial de tasas de la API BCV.
* Endpoint: GET /rates/history?start_date={start}&end_date={end}
* Respuesta: {"start_date": "...", "end_date": "...", "rates": [...]}
*/
public class BCVHistoryResponse {
private String start_date;
private String end_date;
private List<RateEntry> rates;
public BCVHistoryResponse() {
this.rates = new ArrayList<>();
}
public String getStart_date() {
return start_date;
}
public void setStart_date(String start_date) {
this.start_date = start_date;
}
public String getEnd_date() {
return end_date;
}
public void setEnd_date(String end_date) {
this.end_date = end_date;
}
public List<RateEntry> getRates() {
return rates;
}
public void setRates(List<RateEntry> rates) {
this.rates = rates;
}
/**
* Obtiene la tasa más reciente del historial (primer elemento de la lista).
* La API retorna las tasas ordenadas de más reciente a más antigua.
* @return la tasa más reciente, o null si no hay tasas
*/
public RateEntry getMostRecentRate() {
if (rates != null && !rates.isEmpty()) {
return rates.get(0);
}
return null;
}
/**
* Modelo interno para cada entrada de tasa en el historial.
*/
public static class RateEntry {
private BigDecimal dollar;
private String date;
public RateEntry() {
}
public RateEntry(BigDecimal dollar, String date) {
this.dollar = dollar;
this.date = date;
}
public BigDecimal getDollar() {
return dollar;
}
public void setDollar(BigDecimal dollar) {
this.dollar = dollar;
}
public String getDate() {
return date;
}
public void setDate(String date) {
this.date = date;
}
public boolean isValid() {
return dollar != null
&& dollar.compareTo(BigDecimal.ZERO) > 0
&& date != null
&& !date.isEmpty();
}
@Override
public String toString() {
return "RateEntry{dollar=" + dollar + ", date='" + date + "'}";
}
}
@Override
public String toString() {
return "BCVHistoryResponse{start_date='" + start_date + "', end_date='" + end_date
+ "', rates_count=" + (rates != null ? rates.size() : 0) + "}";
}
}
@@ -0,0 +1,62 @@
package com.venezuela.bcvrate.service;
import java.math.BigDecimal;
public class BCVRateResponse {
private BigDecimal dollar;
private String date;
private String effectiveDate;
public BCVRateResponse() {
}
public BCVRateResponse(BigDecimal dollar, String date) {
this.dollar = dollar;
this.date = date;
this.effectiveDate = date;
}
public BCVRateResponse(BigDecimal dollar, String requestedDate, String effectiveDate) {
this.dollar = dollar;
this.date = requestedDate;
this.effectiveDate = effectiveDate;
}
public BigDecimal getDollar() {
return dollar;
}
public void setDollar(BigDecimal dollar) {
this.dollar = dollar;
}
public String getDate() {
return date;
}
public void setDate(String date) {
this.date = date;
}
public String getEffectiveDate() {
return effectiveDate;
}
public void setEffectiveDate(String effectiveDate) {
this.effectiveDate = effectiveDate;
}
public boolean isValid() {
return dollar != null
&& dollar.compareTo(BigDecimal.ZERO) > 0
&& date != null
&& !date.isEmpty();
}
@Override
public String toString() {
return "BCVRateResponse{dollar=" + dollar + ", date='" + date
+ "', effectiveDate='" + effectiveDate + "'}";
}
}