feat: version 4.12.0 -- Conexiones externas: pacientes de SAGA, empleados de nomina y medicos

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-29 17:06:10 -05:00
parent fabe5b530d
commit 0ee9ed65bf
43 changed files with 1402 additions and 69 deletions
+6 -4
View File
@@ -1,4 +1,4 @@
# Conector FoxPro 1.0.0
# Conector FoxPro 1.1.0
API de solo lectura para tablas FoxPro/Visual FoxPro (`.dbf` con memo `.fpt`), según el contrato
v1 de [`../README.md`](../README.md). Node.js sin servidor web adicional; única dependencia: `dbffile`.
@@ -7,14 +7,16 @@ v1 de [`../README.md`](../README.md). Node.js sin servidor web adicional; única
copia, así no choca con los bloqueos de SAGA. Si FoxPro tiene la tabla en uso exclusivo → `423`.
- El índice `.cdx` no se usa (se recorre la tabla). Las tablas se guardan en caché y la caché se
renueva sola cuando cambia el archivo.
- Tablas que el origen escribe todo el día: no se recopian más de una vez cada `limits.minReloadSeconds`
(60 por defecto).
- Tipos soportados: C, N, F, Y, I, L, D, T, B, M. Otros (V, Q, W…) se omiten.
## Instalación en el Windows (`C:\api_project\foxpro`)
1. Copiar esta carpeta a `C:\api_project\foxpro` **con** `node_modules` (no requiere internet en el
server). Sin `config.json` ni `logs` de otro equipo.
2. Node: copiar `node.exe` portable (Node 20 LTS o superior, Windows x64) a
`C:\api_project\foxpro\node\node.exe`. Así no hay que instalar Node en el servidor.
2. Node: usa el Node instalado en el servidor (Exilaser tiene Node 24); si no hay, copiar `node.exe`
portable (Node 20+ x64) a `C:\api_project\foxpro\node\node.exe`.
3. Token: `node\node.exe scripts\generar-token.js`. El **TOKEN** se carga en el sistema; el
**tokenSha256** va en `config.json`.
4. `config.json` (a partir de `config.example.json`):
@@ -40,5 +42,5 @@ Logs: `logs\acceso-AAAA-MM-DD.log` (sin tokens ni valores buscados).
## Desarrollo
- `npm test`: crea una tabla DBF temporal, levanta el conector y valida el contrato (16 pruebas).
- `npm test`: crea una tabla DBF temporal, levanta el conector y valida el contrato (19 pruebas).
- `npm run token`: genera token + hash.
+62 -10
View File
@@ -1,24 +1,75 @@
{
"host": "0.0.0.0",
"port": 8455,
"tls": { "pfx": "", "passphraseEnv": "CONECTOR_TLS_PASS" },
"tls": {
"pfx": "",
"passphraseEnv": "CONECTOR_TLS_PASS"
},
"tokenSha256": "PEGAR_AQUI_EL_SHA256_DE_npm_run_token",
"allowIps": ["192.168.222.3", "192.168.222.40"],
"allowIps": [
"192.168.222.3",
"192.168.222.40"
],
"sources": [
{
"id": "nomina",
"name": "Nómina",
"path": "C:\\Sistemas\\nomina",
"path": "C:\\Sistema\\Nomina\\Datos\\Exilaser",
"encoding": "cp1252",
"tables": "*",
"denyFields": ["SUELDO", "SALARIO", "CLAVE", "PASSWORD"]
"tables": {
"EMPLEADO": {
"fields": [
"EMPCED",
"EMPAPE",
"EMPNOM",
"EMPTEL",
"EMPTEL1",
"EMPFECNAC",
"EMPCARGO",
"EMPDEPART",
"EMPINGRESO",
"EMPSALIDA",
"EMPACTIVO"
]
}
}
},
{
"id": "clientes",
"name": "Clientes SAGA",
"path": "C:\\Sistemas\\saga",
"encoding": "cp1252",
"tables": { "CLIENTES": { "fields": ["CODIGO", "CEDULA", "NOMBRE", "TELEFONO", "EMAIL", "FECNAC"] } }
"name": "SAGA (clientes y proveedores)",
"path": "C:\\Sistema\\Datos\\exilaser",
"encoding": "cp850",
"tables": {
"CLIENTES": {
"fields": [
"CLICOD",
"CLIRUC",
"CLINOM",
"CLITEL",
"CLIEMAIL",
"CLIDIR",
"CLICIU",
"CLIPERSONA",
"CLITIPRUC",
"CLISEXO",
"INACTIVO",
"CLIESTADO",
"FECHAACT"
]
},
"PROVEDOR": {
"fields": [
"PROVCOD",
"PROVNOM",
"PROVRUC",
"PROVTEL",
"PROVEMAIL",
"PROVCIU",
"PROVTIPO",
"INACTIVO"
]
}
}
}
],
"limits": {
@@ -28,7 +79,8 @@
"maxQueuedReads": 10,
"cacheMaxRecordsPerTable": 100000,
"cacheMaxTables": 10,
"requestTimeoutMs": 30000
"requestTimeoutMs": 30000,
"minReloadSeconds": 60
},
"logDir": "logs"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "linkedcorp-conector-foxpro",
"version": "1.0.0",
"version": "1.1.0",
"description": "Conector de solo lectura para tablas FoxPro (DBF/FPT) - contrato v1",
"main": "src/server.js",
"scripts": {
@@ -45,6 +45,7 @@ async function main() {
allowIps: ['127.0.0.1'],
sources: [{ id: 'nomina', path: data, encoding: 'cp1252', tables: { EMPLEADO: {} }, denyFields: ['SUELDO'] }],
logDir: path.join(dir, 'logs'),
limits: { minReloadSeconds: 0 },
}));
const srv = spawn(process.execPath, [path.join(__dirname, '..', 'src', 'server.js')], { env: { ...process.env, CONECTOR_CONFIG: cfgPath }, stdio: ['ignore', 'pipe', 'inherit'] });
@@ -103,6 +104,16 @@ async function main() {
assert.strictEqual(r.rows.length, 1);
assert.strictEqual(r.rows[0]._recno, 2);
});
await check('incremental: afterRecno', async () => {
const r = (await get('/v1/sources/nomina/tables/EMPLEADO/rows?afterRecno=1')).body;
assert.deepStrictEqual(r.rows.map((x) => x._recno), [2]);
assert.strictEqual(r.maxRecno, 3);
});
await check('incremental: changedSince', async () => {
const r = (await get('/v1/sources/nomina/tables/EMPLEADO/rows?changedField=INGRESO&changedSince=2021-01-01')).body;
assert.deepStrictEqual(r.rows.map((x) => x.CEDULA), ['0911122233']);
});
await check('incremental: changedField sin changedSince → 400', async () => assert.strictEqual((await get('/v1/sources/nomina/tables/EMPLEADO/rows?changedField=INGRESO')).status, 400));
await check('versión cambia al modificar la tabla', async () => {
const v1 = (await get('/v1/sources/nomina/tables/EMPLEADO/schema')).body.version;
const f = await DBFFile.open(path.join(data, 'EMPLEADO.DBF'), { encoding: 'cp1252' });
+1
View File
@@ -73,6 +73,7 @@ function load() {
cacheMaxRecordsPerTable: Number(limits.cacheMaxRecordsPerTable || 100000),
cacheMaxTables: Number(limits.cacheMaxTables || 10),
requestTimeoutMs: Number(limits.requestTimeoutMs || 30000),
minReloadSeconds: Number(limits.minReloadSeconds ?? 60),
},
logDir: path.resolve(ROOT, raw.logDir || 'logs'),
};
+16 -4
View File
@@ -98,7 +98,8 @@ async function withSnapshot(ref, fn) {
function toPlain(value, type) {
if (value === null || value === undefined) return null;
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) return null;
// Fecha vacía en FoxPro: la librería la convierte en el día juliano 0 (año -4713).
if (Number.isNaN(value.getTime()) || value.getUTCFullYear() < 1000) return null;
return type === 'D' ? value.toISOString().slice(0, 10) : value.toISOString();
}
if (typeof value === 'string') {
@@ -122,7 +123,10 @@ async function loadTable(source, ref, limits) {
const key = `${source.id}/${ref.table}`;
const sig = await signature(ref);
const hit = cache.get(key);
if (hit && hit.version === sig.version) {
// Tablas que el sistema origen escribe todo el día: no se vuelve a copiar más de una vez cada
// minReloadSeconds aunque la firma haya cambiado.
const fresh = hit && Date.now() - hit.loadedAt < limits.minReloadSeconds * 1000;
if (hit && (hit.version === sig.version || fresh)) {
cache.delete(key);
cache.set(key, hit); // LRU
return hit;
@@ -138,7 +142,7 @@ async function loadTable(source, ref, limits) {
for (const f of fields) row[f.name] = toPlain(r[f.name], f.type);
records.push(row);
}
return { version: sig.version, modifiedAt: sig.modifiedAt, fields, records };
return { version: sig.version, modifiedAt: sig.modifiedAt, loadedAt: Date.now(), fields, records };
});
if (entry.records.length <= limits.cacheMaxRecordsPerTable) cachePut(key, entry, limits);
return entry;
@@ -206,9 +210,17 @@ async function readRows(source, tableName, q, limits) {
.filter(([k]) => k.startsWith('eq.'))
.map(([k, v]) => [pick(k.slice(3), 'eq')[0], fold(v).trim()]);
const text = q.q ? fold(q.q).trim() : '';
// Sincronización incremental: solo lo agregado (afterRecno) y/o lo modificado desde una fecha en
// un campo de fecha/hora del origen (changedField + changedSince). Si se dan ambos, cualquiera basta.
const afterRecno = q.afterRecno !== undefined ? Math.max(parseInt(q.afterRecno, 10) || 0, 0) : null;
const changedField = q.changedField ? pick(q.changedField, 'changedField')[0] : null;
const changedSince = q.changedSince ? String(q.changedSince) : null;
if (!!changedField !== !!changedSince) throw new HttpError(400, 'changedField y changedSince van juntos');
const incremental = afterRecno !== null || changedField;
const qFields = text ? (q.qFields ? pick(q.qFields, 'qFields') : fields) : [];
const matches = data.records.filter((r) => {
if (incremental && !((afterRecno !== null && r._recno > afterRecno) || (changedField && r[changedField] !== null && String(r[changedField]) > changedSince))) return false;
if (!includeDeleted && r._deleted) return false;
for (const [f, v] of eq) if (r[f] === null || fold(r[f]).trim() !== v) return false;
if (text && !qFields.some((f) => r[f] !== null && fold(r[f]).includes(text))) return false;
@@ -221,7 +233,7 @@ async function readRows(source, tableName, q, limits) {
for (const f of fields) o[f] = r[f];
return o;
});
return { table: ref.table, version: data.version, modifiedAt: data.modifiedAt, total: matches.length, offset, limit, fields, rows };
return { table: ref.table, version: data.version, modifiedAt: data.modifiedAt, maxRecno: data.records.length, total: matches.length, offset, limit, fields, rows };
}
module.exports = { listTables, getSchema, readRows, HttpError };