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:
@@ -6,7 +6,7 @@ habla siempre el mismo contrato, sin importar la tecnología de origen (FoxPro,
|
||||
|
||||
| Conector | Carpeta | Estado |
|
||||
|---|---|---|
|
||||
| FoxPro (DBF/FPT/CDX) | `foxpro/` | 1.0.0 |
|
||||
| FoxPro (DBF/FPT/CDX) | `foxpro/` | 1.1.0 |
|
||||
|
||||
## Reglas que todo conector debe cumplir
|
||||
|
||||
@@ -29,7 +29,7 @@ habla siempre el mismo contrato, sin importar la tecnología de origen (FoxPro,
|
||||
| `GET /v1/sources` | `{ sources: [{ id, name }] }` — carpetas/bases configuradas |
|
||||
| `GET /v1/sources/:source/tables` | `{ tables: [{ name, records, fields, version, modifiedAt }] }` |
|
||||
| `GET /v1/sources/:source/tables/:table/schema` | `{ table, records, version, modifiedAt, fields: [{ name, type, size, decimals }] }` |
|
||||
| `GET /v1/sources/:source/tables/:table/rows` | `{ table, version, modifiedAt, total, offset, limit, fields, rows }` |
|
||||
| `GET /v1/sources/:source/tables/:table/rows` | `{ table, version, modifiedAt, maxRecno, total, offset, limit, fields, rows }` |
|
||||
|
||||
Parámetros de `rows`:
|
||||
|
||||
@@ -38,6 +38,11 @@ Parámetros de `rows`:
|
||||
- `q=texto` y `qFields=A,B`: contiene, sin distinguir mayúsculas ni tildes.
|
||||
- `eq.CAMPO=valor`: igualdad exacta (sin distinguir mayúsculas). Se pueden combinar varios.
|
||||
- `includeDeleted=1`: incluye los registros marcados como borrados (`_deleted`).
|
||||
- Sincronización incremental (v1.1): `afterRecno=N` devuelve solo lo agregado después del registro N;
|
||||
`changedField=CAMPO&changedSince=valor` solo lo modificado después de ese valor (campo de fecha/hora
|
||||
del origen, comparado tal como lo devuelve el conector). Con ambos, basta cumplir uno. La respuesta
|
||||
trae `maxRecno` para guardar como próximo `afterRecno`. Una sincronización completa periódica
|
||||
recoge los borrados.
|
||||
|
||||
Cada fila trae `_recno` (posición en el origen). `version` cambia cuando el origen se modifica:
|
||||
el sistema la compara para saber si hace falta volver a sincronizar.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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' });
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user