diff --git a/AGENTS.md b/AGENTS.md
index dacba56..6ddd8ae 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -82,8 +82,9 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador):
- Notificaciones in-app
- Equipos (inventario TI alimentado por agentes GLPI — ver `docs/ARQUITECTURA-INVENTARIO.md`)
- Configuración (catálogos: estados, prioridades, tipos de proyecto)
-- Conexiones externas (en curso): datos de otros sistemas vía conectores de solo lectura en el
- servidor origen (`data/conectores/`, primero FoxPro) — ver `docs/CONEXIONES-EXTERNAS.md`
+- Conexiones externas: datos de otros sistemas vía conectores de solo lectura en el servidor origen
+ (`data/conectores/`, FoxPro instalado en Exilaser), pacientes de SAGA y empleados de nómina —
+ ver `docs/CONEXIONES-EXTERNAS.md`
## Restricciones tecnicas
diff --git a/API.md b/API.md
index a359c67..e4e8607 100644
--- a/API.md
+++ b/API.md
@@ -33,6 +33,17 @@ campo en multipart. La API responde `428` si falta o es inválido y `409` si otr
el registro. En empresa y SMTP la respuesta `GET` incluye `edit_version`.
Las reuniones siguen el mismo contrato (`PUT /api/meetings/:id`).
+## Conexiones externas
+
+`/api/integrations` (JWT). Superadmin: `GET/POST /connections`, `PUT /connections/:id`,
+`POST /connections/:id/test`, `GET /connections/:id/browse?source=&table=` (fuentes, tablas,
+estructura + 20 filas), `PUT /sources/:purpose` (`patients`/`employees`, valida el mapeo contra la
+estructura real), `POST /sources/patients/sync {full}`. Admin y superadmin: `GET /sources` (el admin
+solo ve estado), `GET /employees[?available=1]`, `POST /employees/sync`. El token nunca se devuelve.
+En la Agenda (recepción): `GET /api/clinic/patients/source`, `GET /api/clinic/patients/external?search=`
+(modo en vivo) y `POST /api/clinic/patients/external/:codigo/import`. `POST /api/users` acepta
+`employeeId` (Desde nómina).
+
## Agenda médica (v4.7.0)
Todas las rutas bajo `/api/clinic` exigen JWT, módulo `clinic` y fila activa en `clinic_staff`.
diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md
index 4a5a222..5aeb096 100644
--- a/BASE_DE_DATOS.md
+++ b/BASE_DE_DATOS.md
@@ -15,6 +15,15 @@ producción. Crea tablas e índices de reuniones y amplía el tipo de entidad de
`DATETIME` se guarda como hora civil de `America/Guayaquil`; la conexión usa `dateStrings: true`.
La recurrencia se almacena como JSON de texto y se expande al consultar, sin filas por ocurrencia.
+## Conexiones externas (migración 038)
+
+`038_integrations.sql` (aplicada en dev 2026-10-29): `external_connections` (URL, token cifrado
+AES-256-GCM), `external_sources` (una por destino `patients`/`employees`: tabla, modo, mapeo,
+filtro, campo de cambios y estado de sincronización: `last_max_recno`, `last_changed_value`),
+`employees` (importados de nómina; `user_id` único al crear el usuario desde nómina) y columnas en
+`clinic_patients`: `external_source`, `external_id` (único con la fuente), `source_name`,
+`name_split`, `synced_at`. Ver `docs/CONEXIONES-EXTERNAS.md`.
+
## Agenda médica (migración 036, v4.7.0)
`backend/database/migrations/036_clinic.sql` y `037_clinic_traceability.sql`: **aplicadas en dev**
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a6be9b4..54dfb8b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,25 @@
# Changelog - project.linkedcorp.net
+## [4.12.0] - 2026-10-29
+### Added
+- Conexiones externas en el sistema (migración 038, `modules/integrations`): Configuración →
+ Conexiones externas (superadmin) con conexiones a conectores (token cifrado, Probar), fuentes de
+ Pacientes y Empleados (vista previa de 20 filas, mapeo de campos, filtro, modo importar/en vivo).
+- Pacientes desde SAGA: sincronización completa lunes a viernes 05:00 y solo cambios cada 15 min
+ (06:00–21:00); vínculo por cédula (manda SAGA); nombre de SAGA guardado entero hasta que recepción
+ lo separe. En modo en vivo la Agenda busca en SAGA y trae solo el paciente elegido.
+- Organización → Empleados (admin/superadmin) con "Actualizar empleados" (a pedido); Nuevo usuario →
+ Desde nómina (precarga nombre, cargo, departamento, correo y vincula). Empleados con usuario no se
+ actualizan ni inactivan automáticamente.
+- Médicos que facturan como proveedores de SAGA cargados como empleados (`origin=provider`) con
+ `backend/src/scripts/import-provider-employees.js` (lee del conector por código).
+- Conector FoxPro 1.1.0: sincronización incremental (`afterRecno`, `changedField/changedSince`),
+ recarga mínima de 60 s, fechas vacías como `null`, tabla de proveedores (sin datos bancarios).
+### Fixed
+- Revisión de código: lotes con códigos repetidos, lectura paginada reiniciada si el origen cambia,
+ usuario no queda huérfano si falla el vínculo con el empleado, auditoría al traer un paciente,
+ fuente con conexión desactivada, campo de cambios solo fecha-hora.
+
## [4.11.0] - 2026-10-29
### Added
- Agenda médica: al elegir el paciente de una cita, aviso (no bloquea) con sus citas futuras
diff --git a/DECISIONES.md b/DECISIONES.md
index 95c8fed..5bee183 100644
--- a/DECISIONES.md
+++ b/DECISIONES.md
@@ -13,3 +13,8 @@
| 5 | 2026-10-29 | Formularios en popup siguen el modelo "Nueva reunión" de Calendario; listados con `ListView` y popup lectura → Editar (aplicado a toda la Agenda médica) | Consistencia pedida por el usuario; la Agenda (hecha por Codex sin guías) tenía un campo por línea y formularios siempre abiertos | Mantener el diseño propio de la Agenda | vigente |
| 6 | 2026-10-29 | Citas futuras del mismo paciente: **aviso, no bloqueo**, al elegir el paciente (cualquier fecha/médico) | Pedido del usuario; recepción decide | Bloquear o pedir confirmación al guardar | vigente |
| 7 | 2026-10-29 | Al delegar a Codex: reglas escritas en el encargo + solo los `.md`/secciones necesarios + herramientas; Claude revisa y prueba siempre (también en el orquestador) | Lo entregado sin guías salió fuera de las reglas | Delegar con "lee la documentación" | vigente |
+| 8 | 2026-10-29 | Pacientes: con conexión activa se pueden crear a mano; al vincular por cédula **manda SAGA** | Pedido del usuario: el paciente puede llegar antes de estar en SAGA, y SAGA es la fuente oficial | Solo crear si no está en SAGA; mandan los datos locales | vigente |
+| 9 | 2026-10-29 | Sincronización **incremental** (agregados por `_recno` + modificados por `FECHAACT`) y recarga mínima de 60 s en el conector; completa periódica para borrados | `clientes.DBF` ~20 MB y SAGA lo escribe todo el día | Copiar y enviar toda la tabla en cada consulta | vigente |
+| 10 | 2026-10-29 | Nombre de SAGA (`CLINOM`) **no se separa automáticamente**: se guarda entero y recepción lo separa al editar; desde ahí la sincronización no lo pisa | SAGA no respeta orden apellidos/nombres | Separar por las 2 primeras palabras | vigente |
+| 11 | 2026-10-29 | Sincronización completa de pacientes **lunes a viernes 05:00**; cambios cada 15 min de 06:00 a 21:00; empleados solo a pedido | Pedido del usuario: carga principal + solo cambios; recarga total temprano | Recarga total frecuente | vigente |
+| 12 | 2026-10-29 | Médicos: se toman de proveedores de SAGA (y de nómina) como empleados `origin=provider`; los empleados con usuario no se actualizan ni inactivan automáticamente | Casi todos los médicos facturan, no están en nómina; lo que tiene usuario lo administra la clínica | Crear médicos sin vínculo con SAGA | vigente |
diff --git a/ESTADO.md b/ESTADO.md
index 581d9d9..c3ee779 100644
--- a/ESTADO.md
+++ b/ESTADO.md
@@ -1,23 +1,31 @@
# ESTADO — project.linkedcorp.net
-Ultima actualizacion: 2026-10-29 (v4.11.0 commit + push, sin desplegar)
+Ultima actualizacion: 2026-10-29 (v4.12.0 commit + push, sin desplegar)
Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo +
autorregistro/aprobacion/SMTP.
-## Conexiones externas (2026-10-29, en curso, sin commit)
+## Conexiones externas (v4.12.0, en dev, sin desplegar)
-- Diseño y requisitos acordados en `docs/CONEXIONES-EXTERNAS.md`; decisiones #2–#4 en `DECISIONES.md`.
-- Hecho: conector FoxPro 1.0.0 en `data/conectores/foxpro/` (API solo lectura, contrato v1 en
- `data/conectores/README.md`). `npm test` 16/16 OK con DBF temporal. Sin probar con tablas reales
- de SAGA ni los `.ps1` en Windows.
-- Siguiente: con permiso del usuario, revisar por SSH `192.168.0.253` las carpetas de nómina y
- clientes; instalar en `C:\api_project\foxpro`; luego el módulo del sistema.
+- Conector FoxPro **1.1.0 instalado en Exilaser** (`C:\api_project\foxpro`, puerto 8455, tarea
+ `LinkedCorp-Conector-FoxPro`). Token en `~/.config/linkedcorp/conector-foxpro-exilaser.token`.
+- **Módulo del sistema hecho en dev**: migración 038 aplicada en dev, `modules/integrations`, job
+ `integrationSync`, Configuración → Conexiones externas, Organización → Empleados, Nuevo usuario →
+ Desde nómina, pacientes de SAGA en la Agenda. Dev quedó con la conexión "SAGA Exilaser", 31
+ empleados y 31 220 pacientes importados (modo importar). Probado por API y navegador (usuario
+ superadmin temporal, ya borrado), sin errores de consola.
+- Base de dev **sin datos de prueba de la Agenda** (borrados 2026-10-29).
+- Revisión `medium` hecha y corregida. Médicos cargados en dev como empleados (9 proveedores + 2 de
+ nómina) con `import-provider-employees.js`; empleados con usuario protegidos de la sincronización.
+- **Producción está en 2.19.0 (migraciones hasta 027)**: para cargar allá los médicos hay que
+ desplegar 4.12.0 (migraciones 028–038), crear la conexión con el mismo token, las fuentes, y correr
+ el script con los mismos códigos. El firewall del Windows ya permite a `192.168.222.3`.
+- Detalle en `docs/CONEXIONES-EXTERNAS.md`; decisiones #2–#4 y #8–#11 en `DECISIONES.md`.
## Agenda médica (v4.7.0, sin desplegar)
- 2026-10-29 (v4.11.0): refactor de la pantalla en dev. Formularios cita/paciente/bloqueo con el modelo de "Nueva reunión" (`ClinicForms.jsx`), Pacientes y Bloqueos en `ListView`, Configuración como listados + popup lectura/Editar (`ClinicConfigTab.jsx`; se quitó el campo "ID de usuario"). Corregido: paciente/cédula que se arrastraba a la siguiente cita (borrador huérfano al cancelar "Nuevo paciente") y caída al "Descartar cambios" en un registro nuevo. Probado en navegador (recepción y admin temporal), sin errores de consola.
- 2026-10-29 (v4.11.0): aviso sin bloqueo al elegir paciente en una cita si tiene citas futuras agendadas/confirmadas (lista hasta 5; excluye la cita que se edita). Diálogo compartido "Cambios sin guardar": botones ya no se desbordan (`app.css`).
-- 2026-10-29: BD de dev con datos de prueba de la agenda (15-oct a 28-nov): 7 médicos (`own`) y recepción `pruiz`, `jcabrera` (`all`) `@agenda.prueba` + mgonzalez, 60 pacientes (nota `[DATO DE PRUEBA]`), 4 tipos "(prueba)", 569 citas, 6 bloqueos. Script: `seed-agenda.cjs` del scratchpad de la sesión (con `--limpiar` los borra).
+- (borrados el 2026-10-29) BD de dev tuvo datos de prueba de la agenda (15-oct a 28-nov): 7 médicos (`own`) y recepción `pruiz`, `jcabrera` (`all`) `@agenda.prueba` + mgonzalez, 60 pacientes (nota `[DATO DE PRUEBA]`), 4 tipos "(prueba)", 569 citas, 6 bloqueos. Script: `seed-agenda.cjs` del scratchpad de la sesión (con `--limpiar` los borra).
- v4.9.1: la agenda usa el componente compartido `CalendarBoard` (el mismo del Calendario); siempre
abre en Mes (`plc_clinic_view_v3`). En prod se veía vacía por la vista Día guardada y sin médicos.
- v4.9.0: clic en un día/hora libre abre la cita nueva con esa fecha; clic en una cita la abre en
diff --git a/SPEC.md b/SPEC.md
index 95ba811..db4d6aa 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -124,6 +124,12 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa
en el detalle. `DATETIME` representa hora civil `America/Guayaquil`.
- El calendario personal muestra al médico sus citas activas y bloqueos en la capa Citas médicas.
+### 7 ter. Conexiones externas
+- Datos de otros sistemas por conectores de solo lectura (contrato en `data/conectores/README.md`).
+ Requisitos, decisiones y funcionamiento en `docs/CONEXIONES-EXTERNAS.md`: pacientes desde SAGA
+ (importar o en vivo; completa L-V 05:00 + cambios cada 15 min) y empleados de nómina a pedido, con
+ alta de usuario desde nómina.
+
### 8. Gantt
- Vista de solo lectura por proyecto: barras por actividad (inicio/fin/avance), sin edición directa
desde el gráfico en el MVP.
diff --git a/VERSION b/VERSION
index a162ea7..815588e 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-4.11.0
+4.12.0
diff --git a/backend/database/migrations/038_integrations.sql b/backend/database/migrations/038_integrations.sql
new file mode 100644
index 0000000..b19dadf
--- /dev/null
+++ b/backend/database/migrations/038_integrations.sql
@@ -0,0 +1,77 @@
+-- Conexiones externas (conectores de solo lectura, contrato v1 en data/conectores/README.md),
+-- empleados importados de nómina y vínculo de pacientes con el sistema origen. Idempotente.
+SET NAMES utf8mb4;
+
+CREATE TABLE IF NOT EXISTS external_connections (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ name VARCHAR(120) NOT NULL,
+ base_url VARCHAR(255) NOT NULL,
+ token_encrypted TEXT NOT NULL,
+ is_active TINYINT(1) NOT NULL DEFAULT 1,
+ created_by INT NULL,
+ edit_version INT UNSIGNED NOT NULL DEFAULT 1,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT fk_ext_conn_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Una fuente por destino: qué tabla del conector alimenta Pacientes o Empleados, cómo (importar o
+-- consultar en vivo), el mapeo de campos y el estado de la última sincronización.
+CREATE TABLE IF NOT EXISTS external_sources (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ purpose ENUM('patients','employees') NOT NULL UNIQUE,
+ connection_id INT NOT NULL,
+ source_key VARCHAR(64) NOT NULL,
+ table_name VARCHAR(64) NOT NULL,
+ mode ENUM('import','live') NOT NULL DEFAULT 'import',
+ field_map_json TEXT NOT NULL,
+ filter_json TEXT NULL,
+ changed_field VARCHAR(64) NULL,
+ last_full_sync_at DATETIME NULL,
+ last_sync_at DATETIME NULL,
+ last_max_recno INT UNSIGNED NULL,
+ last_changed_value VARCHAR(40) NULL,
+ last_result_json TEXT NULL,
+ edit_version INT UNSIGNED NOT NULL DEFAULT 1,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT fk_ext_source_conn FOREIGN KEY (connection_id) REFERENCES external_connections(id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS employees (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ identification VARCHAR(40) NOT NULL UNIQUE,
+ last_names VARCHAR(120) NOT NULL DEFAULT '',
+ first_names VARCHAR(120) NOT NULL DEFAULT '',
+ phone VARCHAR(40) NULL,
+ phone2 VARCHAR(40) NULL,
+ birth_date DATE NULL,
+ position VARCHAR(120) NULL,
+ department_name VARCHAR(120) NULL,
+ hire_date DATE NULL,
+ leave_date DATE NULL,
+ is_active TINYINT(1) NOT NULL DEFAULT 1,
+ in_source TINYINT(1) NOT NULL DEFAULT 1,
+ user_id INT NULL UNIQUE,
+ synced_at DATETIME NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_employee_name (last_names, first_names),
+ CONSTRAINT fk_employee_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Origen del empleado: nómina (se sincroniza) o proveedor de SAGA cargado a mano (médicos que facturan;
+-- la sincronización de nómina no los inactiva). Correo: los proveedores lo tienen, la nómina no.
+ALTER TABLE employees ADD COLUMN IF NOT EXISTS origin ENUM('payroll','provider') NOT NULL DEFAULT 'payroll' AFTER is_active;
+ALTER TABLE employees ADD COLUMN IF NOT EXISTS email VARCHAR(160) NULL AFTER phone2;
+
+-- Pacientes vinculados al origen (SAGA): source_name guarda el nombre tal como viene (SAGA no
+-- separa nombres y apellidos ni respeta un orden); mientras recepción no lo separe a mano, el
+-- nombre visible es ese. Manda el origen en identificación/teléfono/correo/nombre de origen.
+ALTER TABLE clinic_patients ADD COLUMN IF NOT EXISTS external_source VARCHAR(20) NULL AFTER notes;
+ALTER TABLE clinic_patients ADD COLUMN IF NOT EXISTS external_id VARCHAR(40) NULL AFTER external_source;
+ALTER TABLE clinic_patients ADD COLUMN IF NOT EXISTS source_name VARCHAR(160) NULL AFTER external_id;
+ALTER TABLE clinic_patients ADD COLUMN IF NOT EXISTS name_split TINYINT(1) NOT NULL DEFAULT 1 AFTER source_name;
+ALTER TABLE clinic_patients ADD COLUMN IF NOT EXISTS synced_at DATETIME NULL AFTER name_split;
+ALTER TABLE clinic_patients MODIFY phone VARCHAR(40) NOT NULL DEFAULT '';
+CREATE UNIQUE INDEX IF NOT EXISTS uq_clinic_patient_external ON clinic_patients (external_source, external_id);
diff --git a/backend/package-lock.json b/backend/package-lock.json
index a05a426..68a65d2 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "project-linkedcorp-backend",
- "version": "4.11.0",
+ "version": "4.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "project-linkedcorp-backend",
- "version": "4.11.0",
+ "version": "4.12.0",
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
diff --git a/backend/package.json b/backend/package.json
index 9077498..4072f56 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
- "version": "4.11.0",
+ "version": "4.12.0",
"private": true,
"type": "module",
"main": "src/server.js",
diff --git a/backend/src/app.js b/backend/src/app.js
index 75f2cbf..f828e88 100644
--- a/backend/src/app.js
+++ b/backend/src/app.js
@@ -30,6 +30,7 @@ import inventoryIngestRoutes from './modules/devices/ingest.js';
import aiRoutes from './modules/ai/routes.js';
import meetingsRoutes from './modules/meetings/routes.js';
import clinicRoutes from './modules/clinic/routes.js';
+import integrationRoutes from './modules/integrations/routes.js';
export const app = express();
@@ -76,6 +77,7 @@ app.use('/api/devices', devicesRoutes);
app.use('/api/ai', aiRoutes);
app.use('/api/meetings', meetingsRoutes);
app.use('/api/clinic', clinicRoutes);
+app.use('/api/integrations', integrationRoutes);
// Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no
// interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
// especifico registrado despues (ver ESTADO.md, deuda tecnica).
diff --git a/backend/src/jobs/integrationSync.js b/backend/src/jobs/integrationSync.js
new file mode 100644
index 0000000..8632d13
--- /dev/null
+++ b/backend/src/jobs/integrationSync.js
@@ -0,0 +1,19 @@
+import { scheduledPatientSync } from '../modules/integrations/service.js';
+
+const CHECK_INTERVAL_MS = 5 * 60 * 1000;
+
+// Pacientes desde el sistema origen (modo importar): completa lunes a viernes a las 05:00 y
+// incremental cada 15 min de 06:00 a 21:00 (ver integrations/service.scheduledPatientSync).
+// Mismo patrón que los demás jobs: dentro del proceso del backend, sin cron del SO.
+export function startIntegrationSyncJob() {
+ const run = async () => {
+ try {
+ const result = await scheduledPatientSync();
+ if (result) console.log(`[integrations] pacientes ${result.full ? 'completa' : 'incremental'}: ${result.received} recibidos, ${result.created} nuevos, ${result.updated} actualizados, ${result.deactivated} inactivados`);
+ } catch (err) {
+ if (err.status !== 409) console.error('[integrations] error sincronizando pacientes:', err.message);
+ }
+ };
+ setTimeout(run, 30000);
+ setInterval(run, CHECK_INTERVAL_MS);
+}
diff --git a/backend/src/modules/clinic/routes.js b/backend/src/modules/clinic/routes.js
index c8bb9dc..0cbd048 100644
--- a/backend/src/modules/clinic/routes.js
+++ b/backend/src/modules/clinic/routes.js
@@ -3,6 +3,7 @@ import { requireAuth } from '../../middleware/auth.js';
import { requireModule } from '../../middleware/modules.js';
import { asyncHandler, ok } from '../../utils/http.js';
import * as clinic from './service.js';
+import * as integrations from '../integrations/service.js';
const router = Router();
router.use(requireAuth, requireModule('clinic'));
// Configuracion (solo admin/superadmin): no exige ser personal de la agenda, por eso va antes
@@ -46,6 +47,11 @@ write('post', '/blocks', (body, user, staff) => clinic.saveBlock(body, user, sta
write('put', '/blocks/:id', (body, user, staff, n) => clinic.saveBlock(body, user, staff, n));
router.delete('/blocks/:id', asyncHandler(async (req, res) => ok(res, await clinic.deleteBlock(req.params.id, req.query, req.currentUser, req.clinicStaff))));
get('/patients', (q, s) => clinic.patients(q, s));
+// Pacientes del sistema origen (Conexiones externas): estado de la fuente, búsqueda en vivo e
+// importación del elegido. Solo recepción (gestiona pacientes).
+get('/patients/source', async (q, s) => { clinic.requireReception(s); return integrations.patientSourceInfo(); });
+get('/patients/external', async (q, s) => { clinic.requireReception(s); return integrations.searchExternalPatients(q.search); });
+write('post', '/patients/external/:id/import', async (body, user, staff, n) => { clinic.requireReception(staff); return integrations.importExternalPatient(n, user); });
router.get('/patients/:id/appointments', asyncHandler(async (req, res) => ok(res, await clinic.patientHistory(req.params.id, req.clinicStaff))));
write('post', '/patients', (body, user, staff) => clinic.savePatient(body, user, staff));
write('put', '/patients/:id', (body, user, staff, n) => clinic.savePatient(body, user, staff, n));
diff --git a/backend/src/modules/clinic/service.js b/backend/src/modules/clinic/service.js
index d7b6fa7..26ec47c 100644
--- a/backend/src/modules/clinic/service.js
+++ b/backend/src/modules/clinic/service.js
@@ -37,6 +37,9 @@ const audit = async (user, action, entityType, entityId, connection = pool) => l
connection
});
const roleAdmin = user => ['admin', 'superadmin'].includes(user.roleCode);
+export function requireReception(staff) {
+ if (staff.scope !== 'all') throw new HttpError(403, 'Solo recepción gestiona pacientes');
+}
export function requireConfig(user) {
if (!roleAdmin(user)) throw new HttpError(403, 'Solo administración configura la agenda');
}
@@ -88,9 +91,9 @@ export async function patients(q, staff) {
pageSize = q.pageSize === undefined ? 20 : Number(q.pageSize);
if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 50 || !Number.isSafeInteger((page - 1) * pageSize)) throw new HttpError(400, 'Paginación inválida');
const like = `%${search}%`;
- const where = 'is_active=1 AND (first_names LIKE ? OR last_names LIKE ? OR identification LIKE ? OR phone LIKE ?)';
- const [count] = await exec(pool, `SELECT COUNT(*) AS n FROM clinic_patients WHERE ${where}`, [like, like, like, like]);
- const items = await exec(pool, `SELECT * FROM clinic_patients WHERE ${where} ORDER BY last_names,first_names LIMIT ? OFFSET ?`, [like, like, like, like, pageSize, (page - 1) * pageSize]);
+ const where = 'is_active=1 AND (first_names LIKE ? OR last_names LIKE ? OR source_name LIKE ? OR identification LIKE ? OR phone LIKE ?)';
+ const [count] = await exec(pool, `SELECT COUNT(*) AS n FROM clinic_patients WHERE ${where}`, [like, like, like, like, like]);
+ const items = await exec(pool, `SELECT * FROM clinic_patients WHERE ${where} ORDER BY last_names,first_names LIMIT ? OFFSET ?`, [like, like, like, like, like, pageSize, (page - 1) * pageSize]);
return {
items,
total: count.n,
@@ -122,7 +125,7 @@ export async function savePatient(raw, user, staff, patientId = null) {
const data = patientData(raw);
let result;
try {
- if (patientId) result = await exec(pool, 'UPDATE clinic_patients SET first_names=?,last_names=?,identification=?,phone=?,email=?,birth_date=?,notes=?,edit_version=edit_version+1 WHERE id=? AND edit_version=?', [...data, id(patientId), version(raw.editVersion)]);else result = await exec(pool, 'INSERT INTO clinic_patients (first_names,last_names,identification,phone,email,birth_date,notes,created_by) VALUES (?,?,?,?,?,?,?,?)', [...data, user.id]);
+ if (patientId) result = await exec(pool, 'UPDATE clinic_patients SET first_names=?,last_names=?,identification=?,phone=?,email=?,birth_date=?,notes=?,name_split=1,edit_version=edit_version+1 WHERE id=? AND edit_version=?', [...data, id(patientId), version(raw.editVersion)]);else result = await exec(pool, 'INSERT INTO clinic_patients (first_names,last_names,identification,phone,email,birth_date,notes,created_by) VALUES (?,?,?,?,?,?,?,?)', [...data, user.id]);
} catch (e) {
if (e.code === 'ER_DUP_ENTRY') throw new HttpError(409, 'Identificación ya registrada');
throw e;
diff --git a/backend/src/modules/integrations/connector.js b/backend/src/modules/integrations/connector.js
new file mode 100644
index 0000000..127ce98
--- /dev/null
+++ b/backend/src/modules/integrations/connector.js
@@ -0,0 +1,53 @@
+import { HttpError } from '../../utils/http.js';
+
+// Cliente del contrato v1 de conectores (data/conectores/README.md). Solo GET, con token y tiempo
+// máximo; los errores del conector se traducen a mensajes para el administrador.
+const TIMEOUT_MS = 60000;
+
+export function normalizeBaseUrl(raw) {
+ let url;
+ try {
+ url = new URL(String(raw || '').trim());
+ } catch {
+ throw new HttpError(400, 'URL del conector inválida');
+ }
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new HttpError(400, 'URL del conector inválida');
+ return `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
+}
+
+export async function connectorGet(conn, path, query = {}) {
+ const url = new URL(`${conn.baseUrl}${path}`);
+ for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v));
+ let res;
+ try {
+ res = await fetch(url, { headers: { Authorization: `Bearer ${conn.token}` }, signal: AbortSignal.timeout(TIMEOUT_MS) });
+ } catch (err) {
+ throw new HttpError(502, err.name === 'TimeoutError' ? 'El conector no respondió a tiempo' : 'No se pudo conectar con el conector');
+ }
+ let body = null;
+ try { body = await res.json(); } catch { /* respuesta no JSON */ }
+ if (!res.ok) {
+ const reason = res.status === 401 ? 'token inválido' : res.status === 403 ? 'IP no permitida' : body?.error || `HTTP ${res.status}`;
+ throw new HttpError(502, `Conector: ${reason}`);
+ }
+ return body;
+}
+
+// Recorre todas las páginas de una consulta de filas (máximo 1000 por página en el conector).
+// Si el origen cambia entre páginas (otra versión del archivo), se reinicia la lectura para no
+// duplicar ni saltar filas.
+export async function connectorAllRows(conn, source, table, query = {}) {
+ for (let attempt = 0; attempt < 3; attempt++) {
+ const rows = [];
+ let offset = 0, page, version = null, stable = true;
+ do {
+ page = await connectorGet(conn, `/v1/sources/${encodeURIComponent(source)}/tables/${encodeURIComponent(table)}/rows`, { ...query, limit: 1000, offset });
+ if (version !== null && page.version !== version) { stable = false; break; }
+ version = page.version;
+ rows.push(...page.rows);
+ offset += page.rows.length;
+ } while (page.rows.length && offset < page.total);
+ if (stable) return { rows, maxRecno: page.maxRecno, version };
+ }
+ throw new HttpError(503, 'El origen cambió durante la lectura; se reintentará');
+}
diff --git a/backend/src/modules/integrations/routes.js b/backend/src/modules/integrations/routes.js
new file mode 100644
index 0000000..c555caa
--- /dev/null
+++ b/backend/src/modules/integrations/routes.js
@@ -0,0 +1,22 @@
+import { Router } from 'express';
+import { requireAuth } from '../../middleware/auth.js';
+import { asyncHandler, ok } from '../../utils/http.js';
+import * as svc from './service.js';
+
+// Conexiones externas. Permisos en el servicio: configuración solo superadmin; empleados
+// (listar y "Actualizar empleados") admin y superadmin.
+const router = Router();
+router.use(requireAuth);
+const h = (fn, status = 200) => asyncHandler(async (req, res) => ok(res, await fn(req), status));
+
+router.get('/connections', h((req) => svc.listConnections(req.currentUser)));
+router.post('/connections', h((req) => svc.saveConnection(req.body, req.currentUser), 201));
+router.put('/connections/:id', h((req) => svc.saveConnection(req.body, req.currentUser, req.params.id)));
+router.post('/connections/:id/test', h((req) => svc.testConnection(req.params.id, req.currentUser)));
+router.get('/connections/:id/browse', h((req) => svc.browse(req.params.id, req.query, req.currentUser)));
+router.get('/sources', h((req) => svc.listSources(req.currentUser)));
+router.put('/sources/:purpose', h((req) => svc.saveSource(req.params.purpose, req.body, req.currentUser)));
+router.post('/sources/patients/sync', h((req) => svc.syncPatients({ full: !!req.body?.full, user: req.currentUser })));
+router.get('/employees', h((req) => svc.listEmployees(req.currentUser, req.query)));
+router.post('/employees/sync', h((req) => svc.syncEmployees(req.currentUser)));
+export default router;
diff --git a/backend/src/modules/integrations/service.js b/backend/src/modules/integrations/service.js
new file mode 100644
index 0000000..2be9ef6
--- /dev/null
+++ b/backend/src/modules/integrations/service.js
@@ -0,0 +1,390 @@
+import { pool } from '../../config/db.js';
+import { HttpError } from '../../utils/http.js';
+import { encrypt, decrypt } from '../../utils/crypto.js';
+import { logAudit } from '../../utils/audit.js';
+import { requireEditVersion } from '../../utils/editVersion.js';
+import { guayaquilNow } from '../meetings/recurrence.js';
+import { connectorGet, connectorAllRows, normalizeBaseUrl } from './connector.js';
+
+// Conexiones externas (ver docs/CONEXIONES-EXTERNAS.md). El sistema nunca escribe en el origen:
+// solo lee por el conector y copia/consulta lo que el superadmin mapeó.
+
+const exec = async (db, sql, params = []) => (await db.query(sql, params))[0];
+const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
+const TARGETS = {
+ employees: { required: ['identification'], fields: ['identification', 'lastNames', 'firstNames', 'phone', 'phone2', 'birthDate', 'position', 'department', 'hireDate', 'leaveDate', 'active'] },
+ patients: { required: ['externalId', 'fullName'], fields: ['externalId', 'identification', 'fullName', 'phone', 'email'] }
+};
+
+const isSuperadmin = (user) => user.roleCode === 'superadmin';
+const isAdmin = (user) => ['admin', 'superadmin'].includes(user.roleCode);
+export function requireSuperadmin(user) {
+ if (!isSuperadmin(user)) throw new HttpError(403, 'Solo el superadmin configura las conexiones externas');
+}
+export function requireAdmin(user) {
+ if (!isAdmin(user)) throw new HttpError(403, 'No tienes permisos para esta acción');
+}
+const num = (v) => {
+ const n = Number(v);
+ if (!Number.isSafeInteger(n) || n < 1) throw new HttpError(400, 'ID inválido');
+ return n;
+};
+const purposeOf = (p) => {
+ if (!TARGETS[p]) throw new HttpError(400, 'Destino inválido');
+ return p;
+};
+const clip = (v, max) => {
+ if (v === null || v === undefined) return null;
+ const s = String(v).replace(/[\u0000-\u001f\u007f]/g, ' ').trim();
+ return s ? s.slice(0, max) : null;
+};
+const dateOnly = (v) => (typeof v === 'string' && /^\d{4}-\d\d-\d\d/.test(v) ? v.slice(0, 10) : null);
+const truthy = (v) => v === true || v === 1 || /^(1|s|si|sí|true|t|y|activo)$/i.test(String(v ?? '').trim());
+
+// --- Conexiones ---
+
+async function loadConnection(id) {
+ const [row] = await exec(pool, 'SELECT * FROM external_connections WHERE id=?', [num(id)]);
+ if (!row) throw new HttpError(404, 'Conexión no encontrada');
+ return row;
+}
+const clientFor = (row) => ({ baseUrl: row.base_url, token: decrypt(row.token_encrypted) });
+const publicConnection = (r) => ({ id: r.id, name: r.name, base_url: r.base_url, is_active: !!r.is_active, edit_version: r.edit_version, created_at: r.created_at, updated_at: r.updated_at });
+
+export async function listConnections(user) {
+ requireSuperadmin(user);
+ return (await exec(pool, 'SELECT * FROM external_connections ORDER BY name')).map(publicConnection);
+}
+
+export async function saveConnection(raw, user, connId = null) {
+ requireSuperadmin(user);
+ const name = clip(raw.name, 120);
+ if (!name) throw new HttpError(400, 'El nombre es obligatorio');
+ const baseUrl = normalizeBaseUrl(raw.baseUrl);
+ const token = raw.token ? String(raw.token).trim() : '';
+ if (connId) {
+ const before = await loadConnection(connId);
+ const version = requireEditVersion(Number(raw.editVersion));
+ const result = await exec(pool, `UPDATE external_connections SET name=?,base_url=?,is_active=?${token ? ',token_encrypted=?' : ''},edit_version=edit_version+1 WHERE id=? AND edit_version=?`,
+ [name, baseUrl, raw.isActive === false ? 0 : 1, ...(token ? [encrypt(token)] : []), before.id, version]);
+ if (!result.affectedRows) throw new HttpError(409, 'Conexión modificada por otra persona');
+ await logAudit({ userId: user.id, action: 'update', entityType: 'external_connection', entityId: before.id, detail: { name, baseUrl, tokenChanged: !!token } });
+ return publicConnection(await loadConnection(before.id));
+ }
+ if (!token) throw new HttpError(400, 'El token es obligatorio');
+ const result = await exec(pool, 'INSERT INTO external_connections (name,base_url,token_encrypted,created_by) VALUES (?,?,?,?)', [name, baseUrl, encrypt(token), user.id]);
+ await logAudit({ userId: user.id, action: 'create', entityType: 'external_connection', entityId: result.insertId, detail: { name, baseUrl } });
+ return publicConnection(await loadConnection(result.insertId));
+}
+
+export async function testConnection(connId, user) {
+ requireSuperadmin(user);
+ const row = await loadConnection(connId);
+ const health = await connectorGet(clientFor(row), '/v1/health');
+ await logAudit({ userId: user.id, action: 'test', entityType: 'external_connection', entityId: row.id });
+ return health;
+}
+
+// Exploración para el superadmin: fuentes, tablas, estructura y una muestra de filas.
+export async function browse(connId, q, user) {
+ requireSuperadmin(user);
+ const conn = clientFor(await loadConnection(connId));
+ if (!q.source) return connectorGet(conn, '/v1/sources');
+ if (!ID_RE.test(q.source)) throw new HttpError(400, 'Fuente inválida');
+ const base = `/v1/sources/${encodeURIComponent(q.source)}/tables`;
+ if (!q.table) return connectorGet(conn, base);
+ if (!ID_RE.test(q.table)) throw new HttpError(400, 'Tabla inválida');
+ const [schema, sample] = await Promise.all([
+ connectorGet(conn, `${base}/${encodeURIComponent(q.table)}/schema`),
+ connectorGet(conn, `${base}/${encodeURIComponent(q.table)}/rows`, { limit: 20 })
+ ]);
+ await logAudit({ userId: user.id, action: 'preview', entityType: 'external_connection', entityId: Number(connId), detail: { source: q.source, table: q.table } });
+ return { schema, sample: sample.rows };
+}
+
+// --- Fuentes (qué alimenta Pacientes / Empleados) ---
+
+const parseSource = (r) => r && ({ ...r, field_map: JSON.parse(r.field_map_json), filter: r.filter_json ? JSON.parse(r.filter_json) : {}, last_result: r.last_result_json ? JSON.parse(r.last_result_json) : null });
+async function sourceRow(purpose) {
+ const [row] = await exec(pool, 'SELECT * FROM external_sources WHERE purpose=?', [purposeOf(purpose)]);
+ return parseSource(row);
+}
+
+export async function listSources(user) {
+ requireAdmin(user);
+ const rows = (await exec(pool, 'SELECT s.*, c.name AS connection_name FROM external_sources s JOIN external_connections c ON c.id=s.connection_id')).map(parseSource);
+ // El admin solo ve el estado (para "Actualizar empleados"); el mapeo es del superadmin.
+ return isSuperadmin(user) ? rows : rows.map((r) => ({ purpose: r.purpose, mode: r.mode, connection_name: r.connection_name, last_sync_at: r.last_sync_at, last_full_sync_at: r.last_full_sync_at, last_result: r.last_result }));
+}
+
+export async function saveSource(purpose, raw, user) {
+ requireSuperadmin(user);
+ const p = purposeOf(purpose);
+ const conn = await loadConnection(raw.connectionId);
+ const sourceKey = String(raw.sourceKey || ''), tableName = String(raw.tableName || '');
+ if (!ID_RE.test(sourceKey) || !ID_RE.test(tableName)) throw new HttpError(400, 'Fuente o tabla inválida');
+ const mode = p === 'employees' ? 'import' : raw.mode === 'live' ? 'live' : 'import';
+ // El mapeo se valida contra la estructura real que publica el conector.
+ const schema = await connectorGet(clientFor(conn), `/v1/sources/${encodeURIComponent(sourceKey)}/tables/${encodeURIComponent(tableName)}/schema`);
+ const known = new Set(schema.fields.map((f) => f.name));
+ const map = {};
+ for (const target of TARGETS[p].fields) {
+ const field = raw.fieldMap?.[target];
+ if (!field) continue;
+ if (!known.has(field)) throw new HttpError(400, `Campo inexistente en la tabla: ${field}`);
+ map[target] = field;
+ }
+ for (const r of TARGETS[p].required) if (!map[r]) throw new HttpError(400, `Falta mapear el campo obligatorio: ${r}`);
+ const filter = {};
+ for (const [field, value] of Object.entries(raw.filter || {})) {
+ if (!known.has(field)) throw new HttpError(400, `Campo de filtro inexistente: ${field}`);
+ if (String(value).trim()) filter[field] = String(value).trim().slice(0, 60);
+ }
+ const changedField = raw.changedField ? String(raw.changedField) : null;
+ if (changedField && schema.fields.find((f) => f.name === changedField)?.type !== 'T') throw new HttpError(400, 'El campo de cambios debe ser de fecha y hora');
+ const before = await sourceRow(p);
+ if (p === 'patients' && before && before.field_map.externalId !== map.externalId) {
+ const [{ n }] = await exec(pool, 'SELECT COUNT(*) AS n FROM clinic_patients WHERE external_source=?', [patientKey(before)]);
+ if (n) throw new HttpError(409, 'No se puede cambiar el campo "código en el origen" con pacientes ya importados');
+ }
+ const sameTable = before && before.connection_id === conn.id && before.source_key === sourceKey && before.table_name === tableName;
+ if (before) {
+ const result = await exec(pool, `UPDATE external_sources SET connection_id=?,source_key=?,table_name=?,mode=?,field_map_json=?,filter_json=?,changed_field=?,
+ ${sameTable ? '' : 'last_full_sync_at=NULL,last_sync_at=NULL,last_max_recno=NULL,last_changed_value=NULL,'}edit_version=edit_version+1 WHERE id=? AND edit_version=?`,
+ [conn.id, sourceKey, tableName, mode, JSON.stringify(map), JSON.stringify(filter), changedField, before.id, requireEditVersion(Number(raw.editVersion))]);
+ if (!result.affectedRows) throw new HttpError(409, 'Fuente modificada por otra persona');
+ } else {
+ await exec(pool, 'INSERT INTO external_sources (purpose,connection_id,source_key,table_name,mode,field_map_json,filter_json,changed_field) VALUES (?,?,?,?,?,?,?,?)',
+ [p, conn.id, sourceKey, tableName, mode, JSON.stringify(map), JSON.stringify(filter), changedField]);
+ }
+ await logAudit({ userId: user.id, action: before ? 'update' : 'create', entityType: 'external_source', entityId: null, detail: { purpose: p, sourceKey, tableName, mode, map, filter } });
+ return sourceRow(p);
+}
+
+async function sourceClient(purpose) {
+ const src = await sourceRow(purpose);
+ if (!src) throw new HttpError(409, purpose === 'employees' ? 'No hay fuente de nómina configurada' : 'No hay fuente de pacientes configurada');
+ const conn = await loadConnection(src.connection_id);
+ if (!conn.is_active) throw new HttpError(409, 'La conexión está desactivada');
+ return { src, conn: clientFor(conn) };
+}
+const eqQuery = (filter) => Object.fromEntries(Object.entries(filter || {}).map(([k, v]) => [`eq.${k}`, v]));
+const fieldsQuery = (src) => [...new Set([...Object.values(src.field_map), ...(src.changed_field ? [src.changed_field] : [])])].join(',');
+
+async function finishSync(src, values, result) {
+ await exec(pool, `UPDATE external_sources SET last_sync_at=?,${values.full ? 'last_full_sync_at=?,' : ''}last_max_recno=?,last_changed_value=?,last_result_json=? WHERE id=?`,
+ [guayaquilNow(), ...(values.full ? [guayaquilNow()] : []), values.maxRecno ?? src.last_max_recno, values.changedValue ?? src.last_changed_value, JSON.stringify(result), src.id]);
+}
+
+// --- Empleados (nómina): siempre importados, a pedido (admin/superadmin) ---
+
+const running = new Set();
+async function exclusive(key, fn) {
+ if (running.has(key)) throw new HttpError(409, 'Ya hay una sincronización en curso');
+ running.add(key);
+ try { return await fn(); } finally { running.delete(key); }
+}
+
+export async function syncEmployees(user) {
+ requireAdmin(user);
+ return exclusive('employees', async () => {
+ const { src, conn } = await sourceClient('employees');
+ const m = src.field_map;
+ const { rows, maxRecno } = await connectorAllRows(conn, src.source_key, src.table_name, { fields: fieldsQuery(src), ...eqQuery(src.filter) });
+ const now = guayaquilNow();
+ const seen = [];
+ let created = 0, updated = 0, protectedRows = 0;
+ // Empleados con usuario en el sistema (p. ej. médicos) no se actualizan ni se inactivan solos.
+ const known = await exec(pool, 'SELECT identification, user_id FROM employees');
+ const withUser = new Set(known.filter((e) => e.user_id).map((e) => e.identification));
+ const existed = new Set(known.map((e) => e.identification));
+ for (const r of rows) {
+ const identification = clip(r[m.identification], 40);
+ if (!identification) continue;
+ seen.push(identification);
+ if (withUser.has(identification)) { protectedRows++; continue; }
+ const leave = m.leaveDate ? dateOnly(r[m.leaveDate]) : null;
+ const active = m.active ? truthy(r[m.active]) : !leave;
+ const values = [clip(m.lastNames && r[m.lastNames], 120) || '', clip(m.firstNames && r[m.firstNames], 120) || '', clip(m.phone && r[m.phone], 40), clip(m.phone2 && r[m.phone2], 40),
+ m.birthDate ? dateOnly(r[m.birthDate]) : null, clip(m.position && r[m.position], 120), clip(m.department && r[m.department], 120), m.hireDate ? dateOnly(r[m.hireDate]) : null, leave, active ? 1 : 0];
+ const result = await exec(pool, `INSERT INTO employees (identification,last_names,first_names,phone,phone2,birth_date,position,department_name,hire_date,leave_date,is_active,in_source,synced_at)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,1,?) ON DUPLICATE KEY UPDATE last_names=VALUES(last_names),first_names=VALUES(first_names),phone=VALUES(phone),phone2=VALUES(phone2),birth_date=VALUES(birth_date),
+ position=VALUES(position),department_name=VALUES(department_name),hire_date=VALUES(hire_date),leave_date=VALUES(leave_date),is_active=VALUES(is_active),in_source=1,origin='payroll'`,
+ [identification, ...values, now]);
+ // MySQL informa 2 filas afectadas solo cuando el registro existente cambió de verdad.
+ if (!existed.has(identification)) created++;
+ else if (result.affectedRows === 2) updated++;
+ }
+ const synced = seen.filter((x) => !withUser.has(x));
+ if (synced.length) await exec(pool, 'UPDATE employees SET synced_at=? WHERE identification IN (?)', [now, synced]);
+ // Los que ya no están en la nómina quedan inactivos (no se borran: pueden tener usuario).
+ const gone = seen.length
+ ? await exec(pool, "UPDATE employees SET in_source=0,is_active=0 WHERE in_source=1 AND origin='payroll' AND user_id IS NULL AND identification NOT IN (?)", [seen])
+ : { affectedRows: 0 };
+ const result = { at: now, full: true, total: rows.length, created, updated, removed: gone.affectedRows, protected: protectedRows };
+ await finishSync(src, { full: true, maxRecno }, result);
+ await logAudit({ userId: user.id, action: 'sync', entityType: 'employees', entityId: null, detail: result });
+ return result;
+ });
+}
+
+export async function listEmployees(user, q = {}) {
+ requireAdmin(user);
+ const onlyFree = q.available === '1';
+ return exec(pool, `SELECT e.*, u.name AS user_name FROM employees e LEFT JOIN users u ON u.id=e.user_id
+ WHERE 1=1 ${onlyFree ? 'AND e.is_active=1 AND e.user_id IS NULL' : ''} ORDER BY e.last_names, e.first_names`);
+}
+
+export async function requireFreeEmployee(employeeId) {
+ const [e] = await exec(pool, 'SELECT id FROM employees WHERE id=? AND is_active=1 AND user_id IS NULL', [num(employeeId)]);
+ if (!e) throw new HttpError(409, 'Ese empleado ya tiene usuario, está inactivo o no existe');
+}
+
+// Vincula un usuario recién creado con su empleado (desde "Nuevo usuario" → Desde nómina).
+export async function linkEmployeeToUser(employeeId, userId, connection = pool) {
+ const result = await exec(connection, 'UPDATE employees SET user_id=? WHERE id=? AND user_id IS NULL', [userId, num(employeeId)]);
+ if (!result.affectedRows) throw new HttpError(409, 'Ese empleado ya tiene usuario o no existe');
+}
+
+// --- Pacientes (clientes del origen) ---
+
+const patientKey = (src) => `src${src.id}`;
+function patientValues(src, r) {
+ const m = src.field_map;
+ const email = clip(m.email && r[m.email], 160);
+ return {
+ externalId: clip(r[m.externalId], 40),
+ identification: clip(m.identification && r[m.identification], 40),
+ fullName: clip(r[m.fullName], 120),
+ phone: clip(m.phone && r[m.phone], 40) || '',
+ email: email && /^[^\s@,;]+@[^\s@,;]+\.[^\s@,;]+$/.test(email) ? email : null
+ };
+}
+
+// Upsert de un lote: manda el origen (cédula, teléfono, correo, nombre de origen). El nombre visible
+// solo se reemplaza mientras recepción no lo haya separado a mano (name_split=0).
+async function upsertPatients(src, rows, now) {
+ const key = patientKey(src);
+ const stats = { created: 0, updated: 0, linked: 0, unchanged: 0, skipped: 0, identConflicts: 0 };
+ const seen = new Set();
+ if (!rows.length) return { stats, seen };
+ // Lotes chicos (incremental, importar uno): solo se precarga lo del lote; la completa trae todo.
+ const small = rows.length <= 2000;
+ const batchIds = rows.map((r) => clip(r[src.field_map.externalId], 40)).filter(Boolean);
+ const batchIdents = src.field_map.identification ? rows.map((r) => clip(r[src.field_map.identification], 40)).filter(Boolean) : [];
+ const existing = new Map((small && !batchIds.length ? [] : await exec(pool, `SELECT id,external_id,identification,source_name,phone,email,is_active,name_split FROM clinic_patients WHERE external_source=?${small ? ' AND external_id IN (?)' : ''}`, small ? [key, batchIds] : [key])).map((p) => [p.external_id, p]));
+ const byIdent = new Map((small && !batchIdents.length ? [] : await exec(pool, `SELECT id,identification,external_id FROM clinic_patients WHERE identification IS NOT NULL${small ? ' AND identification IN (?)' : ''}`, small ? [batchIdents] : [])).map((p) => [p.identification, p]));
+ for (const r of rows) {
+ const v = patientValues(src, r);
+ if (v.externalId) seen.add(v.externalId);
+ if (!v.externalId || !v.fullName) { stats.skipped++; continue; }
+ // Código repetido en el lote (duplicado en el origen): se procesa una sola vez.
+ if (existing.get(v.externalId)?.batch) { stats.skipped++; continue; }
+ const owner = v.identification ? byIdent.get(v.identification) : null;
+ const cur = existing.get(v.externalId);
+ // La cédula es única: si otro paciente ya la tiene (duplicado en el origen), no se repite.
+ const identFor = (selfId) => (v.identification && owner && owner.id !== selfId ? (stats.identConflicts++, null) : v.identification);
+ if (cur) {
+ const ident = identFor(cur.id);
+ if (cur.source_name === v.fullName && (cur.identification || null) === ident && cur.phone === v.phone && (cur.email || null) === v.email && cur.is_active) { cur.batch = true; stats.unchanged++; continue; }
+ await exec(pool, 'UPDATE clinic_patients SET source_name=?,first_names=IF(name_split=0,?,first_names),identification=?,phone=?,email=?,is_active=1,synced_at=? WHERE id=?',
+ [v.fullName, v.fullName, ident, v.phone, v.email, now, cur.id]);
+ if (ident) byIdent.set(ident, { id: cur.id, identification: ident });
+ cur.batch = true;
+ stats.updated++;
+ } else if (owner && !owner.external_id) {
+ // Paciente creado a mano con la misma cédula: se vincula y manda el origen (sin tocar su nombre separado).
+ await exec(pool, 'UPDATE clinic_patients SET external_source=?,external_id=?,source_name=?,phone=?,email=?,is_active=1,synced_at=? WHERE id=?',
+ [key, v.externalId, v.fullName, v.phone, v.email, now, owner.id]);
+ owner.external_id = v.externalId;
+ existing.set(v.externalId, { id: owner.id, batch: true });
+ stats.linked++;
+ } else {
+ const result = await exec(pool, `INSERT INTO clinic_patients (first_names,last_names,identification,phone,email,external_source,external_id,source_name,name_split,synced_at)
+ VALUES (?,'',?,?,?,?,?,?,0,?)`, [v.fullName, identFor(null), v.phone, v.email, key, v.externalId, v.fullName, now]);
+ if (v.identification && !owner) byIdent.set(v.identification, { id: result.insertId, identification: v.identification, external_id: v.externalId });
+ existing.set(v.externalId, { id: result.insertId, batch: true });
+ stats.created++;
+ }
+ }
+ return { stats, seen };
+}
+
+export async function syncPatients({ full = false, user = null } = {}) {
+ if (user) requireSuperadmin(user);
+ return exclusive('patients', async () => {
+ const { src, conn } = await sourceClient('patients');
+ if (src.mode !== 'import') throw new HttpError(409, 'La fuente de pacientes está en modo consulta en vivo');
+ const doFull = full || !src.last_full_sync_at;
+ const query = { fields: fieldsQuery(src), ...eqQuery(src.filter) };
+ if (!doFull) {
+ query.afterRecno = src.last_max_recno ?? 0;
+ if (src.changed_field && src.last_changed_value) Object.assign(query, { changedField: src.changed_field, changedSince: src.last_changed_value });
+ }
+ const { rows, maxRecno } = await connectorAllRows(conn, src.source_key, src.table_name, query);
+ const now = guayaquilNow();
+ const { stats, seen } = await upsertPatients(src, rows, now);
+ let deactivated = 0;
+ if (doFull && seen.size) {
+ // Los que ya no están en el origen (borrados) quedan inactivos; su historial se conserva.
+ deactivated = (await exec(pool, 'UPDATE clinic_patients SET is_active=0 WHERE external_source=? AND is_active=1 AND external_id NOT IN (?)', [patientKey(src), [...seen]])).affectedRows;
+ }
+ let changedValue = src.last_changed_value;
+ if (src.changed_field) for (const r of rows) { const c = r[src.changed_field]; if (c && (!changedValue || String(c) > changedValue)) changedValue = String(c).slice(0, 40); }
+ const result = { at: now, full: doFull, received: rows.length, ...stats, deactivated };
+ await finishSync(src, { full: doFull, maxRecno, changedValue }, result);
+ if (user) await logAudit({ userId: user.id, action: 'sync', entityType: 'clinic_patients', entityId: null, detail: result });
+ return result;
+ });
+}
+
+// Modo consulta en vivo: buscar en el origen y traer al sistema solo el paciente elegido.
+export async function patientSourceInfo() {
+ const [src] = await exec(pool, "SELECT s.mode, s.last_sync_at FROM external_sources s JOIN external_connections c ON c.id=s.connection_id WHERE s.purpose='patients' AND c.is_active=1");
+ return src ? { configured: true, mode: src.mode, last_sync_at: src.last_sync_at } : { configured: false };
+}
+
+export async function searchExternalPatients(text) {
+ const { src, conn } = await sourceClient('patients');
+ const m = src.field_map;
+ const term = String(text || '').trim();
+ if (term.length < 3) return [];
+ const page = await connectorGet(conn, `/v1/sources/${encodeURIComponent(src.source_key)}/tables/${encodeURIComponent(src.table_name)}/rows`, {
+ q: term.slice(0, 60), qFields: [m.fullName, m.identification, m.phone].filter(Boolean).join(','), fields: fieldsQuery(src), limit: 20, ...eqQuery(src.filter)
+ });
+ const ids = page.rows.map((r) => clip(r[m.externalId], 40)).filter(Boolean);
+ const local = ids.length ? new Map((await exec(pool, 'SELECT id,external_id FROM clinic_patients WHERE external_source=? AND external_id IN (?)', [patientKey(src), ids])).map((p) => [p.external_id, p.id])) : new Map();
+ return page.rows.map((r) => {
+ const v = patientValues(src, r);
+ return { external_id: v.externalId, identification: v.identification, full_name: v.fullName, phone: v.phone, email: v.email, local_id: local.get(v.externalId) || null };
+ }).filter((p) => p.external_id && p.full_name);
+}
+
+export async function importExternalPatient(externalId, user) {
+ const { src, conn } = await sourceClient('patients');
+ const ext = clip(externalId, 40);
+ if (!ext) throw new HttpError(400, 'Paciente externo inválido');
+ const page = await connectorGet(conn, `/v1/sources/${encodeURIComponent(src.source_key)}/tables/${encodeURIComponent(src.table_name)}/rows`, {
+ [`eq.${src.field_map.externalId}`]: ext, fields: fieldsQuery(src), limit: 1, ...eqQuery(src.filter)
+ });
+ if (!page.rows.length) throw new HttpError(404, 'El paciente ya no existe en el sistema origen');
+ await upsertPatients(src, page.rows, guayaquilNow());
+ const [row] = await exec(pool, 'SELECT * FROM clinic_patients WHERE external_source=? AND external_id=?', [patientKey(src), ext]);
+ if (!row) throw new HttpError(409, 'No se pudo traer el paciente');
+ await logAudit({ userId: user.id, action: 'import', entityType: 'clinic_patient', entityId: row.id, detail: { externalId: ext } });
+ return row;
+}
+
+// Programación (job): completa lunes a viernes a las 05:00; incremental cada 15 min de 06:00 a 21:00.
+export async function scheduledPatientSync() {
+ const src = await sourceRow('patients');
+ if (!src || src.mode !== 'import') return null;
+ const now = guayaquilNow();
+ const weekday = new Date(`${now.slice(0, 10)}T12:00:00Z`).getUTCDay();
+ const hour = Number(now.slice(11, 13));
+ const fullToday = src.last_full_sync_at && String(src.last_full_sync_at).slice(0, 10) === now.slice(0, 10);
+ if (!src.last_full_sync_at || (weekday >= 1 && weekday <= 5 && hour === 5 && !fullToday)) return syncPatients({ full: true });
+ const minutesSince = src.last_sync_at ? (Date.parse(`${now.replace(' ', 'T')}Z`) - Date.parse(`${String(src.last_sync_at).replace(' ', 'T')}Z`)) / 60000 : Infinity;
+ if (hour >= 6 && hour < 21 && minutesSince >= 15) return syncPatients({ full: false });
+ return null;
+}
diff --git a/backend/src/modules/users/service.js b/backend/src/modules/users/service.js
index 0aee0b9..b692825 100644
--- a/backend/src/modules/users/service.js
+++ b/backend/src/modules/users/service.js
@@ -1,5 +1,6 @@
import crypto from 'node:crypto';
import bcrypt from 'bcryptjs';
+import { linkEmployeeToUser, requireFreeEmployee } from '../integrations/service.js';
import * as repo from './repository.js';
import { HttpError } from '../../utils/http.js';
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
@@ -57,10 +58,20 @@ export async function createUser(payload, actor) {
const existing = await repo.findByEmail(payload.email);
if (existing) throw new HttpError(400, 'Ya existe un usuario con ese email');
await checkRole(payload.roleId, actor);
+ // "Desde nómina": el empleado debe existir, estar activo y no tener usuario (se valida antes de crear).
+ if (payload.employeeId) await requireFreeEmployee(payload.employeeId);
const passwordHash = await bcrypt.hash(payload.password, 12);
const user = await repo.create({ ...payload, passwordHash });
- await logAudit({ userId: actor.id, action: 'create', entityType: 'user', entityId: user.id });
+ if (payload.employeeId) {
+ try {
+ await linkEmployeeToUser(payload.employeeId, user.id);
+ } catch (err) {
+ await repo.remove(user.id);
+ throw err;
+ }
+ }
+ await logAudit({ userId: actor.id, action: 'create', entityType: 'user', entityId: user.id, detail: payload.employeeId ? { employeeId: Number(payload.employeeId) } : undefined });
return user;
}
diff --git a/backend/src/scripts/import-provider-employees.js b/backend/src/scripts/import-provider-employees.js
new file mode 100644
index 0000000..16412db
--- /dev/null
+++ b/backend/src/scripts/import-provider-employees.js
@@ -0,0 +1,42 @@
+// Carga como empleados (origin=provider) a proveedores de SAGA que trabajan en la clínica pero no
+// están en nómina (médicos que facturan). Lee del conector configurado en Conexiones externas, así
+// no se guardan datos personales en el repositorio.
+// node src/scripts/import-provider-employees.js --position=MEDICO 137+031 142+033 809 ...
+// Cada argumento es una persona; "+" une códigos duplicados de la misma persona (el primero da el
+// nombre; teléfono y correo se toman del primero que los tenga). Si la cédula ya existe como
+// empleado (p. ej. de nómina) solo completa teléfono y correo vacíos.
+import 'dotenv/config';
+import { pool } from '../config/db.js';
+import { decrypt } from '../utils/crypto.js';
+import { connectorGet } from '../modules/integrations/connector.js';
+
+const SOURCE = 'clientes', TABLE = 'PROVEDOR';
+const args = process.argv.slice(2);
+const position = (args.find((a) => a.startsWith('--position=')) || '--position=MEDICO').split('=')[1];
+const people = args.filter((a) => !a.startsWith('--')).map((a) => a.split('+'));
+if (!people.length) { console.error('Uso: import-provider-employees.js [--position=X] COD[+COD] ...'); process.exit(1); }
+
+const [conn] = (await pool.query("SELECT c.base_url, c.token_encrypted FROM external_sources s JOIN external_connections c ON c.id=s.connection_id WHERE s.purpose='patients' AND c.is_active=1"))[0];
+if (!conn) { console.error('No hay conexión activa (Configuración → Conexiones externas)'); process.exit(1); }
+const client = { baseUrl: conn.base_url, token: decrypt(conn.token_encrypted) };
+const valid = (v) => v && !/^9+$/.test(v);
+
+for (const codes of people) {
+ const rows = [];
+ for (const code of codes) {
+ const page = await connectorGet(client, `/v1/sources/${SOURCE}/tables/${TABLE}/rows`, { [`eq.PROVCOD`]: code, limit: 1 });
+ if (page.rows[0]) rows.push(page.rows[0]);
+ }
+ if (!rows.length) { console.log(`${codes.join('+')}: no existe en proveedores`); continue; }
+ const words = String(rows[0].PROVNOM).replace(/^DRA?\.?\s+/i, '').trim().split(/\s+/);
+ const identification = String(rows[0].PROVRUC || '').slice(0, 10);
+ if (!/^\d{10}$/.test(identification)) { console.log(`${codes.join('+')}: cédula inválida`); continue; }
+ const phone = rows.map((r) => r.PROVTEL).find(valid) || null;
+ const email = rows.map((r) => r.PROVEMAIL).find((e) => e && /@/.test(e)) || null;
+ const last = words.slice(0, 2).join(' '), first = words.slice(2).join(' ');
+ const [res] = await pool.query(`INSERT INTO employees (identification,last_names,first_names,phone,email,position,is_active,origin,in_source,synced_at)
+ VALUES (?,?,?,?,?,?,1,'provider',0,NOW()) ON DUPLICATE KEY UPDATE phone=COALESCE(phone,VALUES(phone)),email=COALESCE(email,VALUES(email))`,
+ [identification, last, first, phone, email, position]);
+ console.log(`${codes.join('+')}: ${last} ${first} → ${res.affectedRows === 1 ? 'creado' : res.affectedRows === 2 ? 'ya existía (completado)' : 'ya existía'}`);
+}
+await pool.end();
diff --git a/backend/src/server.js b/backend/src/server.js
index 12d5b91..03aebb2 100644
--- a/backend/src/server.js
+++ b/backend/src/server.js
@@ -2,6 +2,7 @@ import { app } from './app.js';
import { env } from './config/env.js';
import { startRecurringTaskReminderJob } from './jobs/recurringTaskReminders.js';
import { startMeetingReminderJob } from './jobs/meetingReminders.js';
+import { startIntegrationSyncJob } from './jobs/integrationSync.js';
app.listen(env.port, () => {
console.log(`[server] project.linkedcorp.net backend escuchando en :${env.port} (${env.nodeEnv})`);
@@ -9,3 +10,4 @@ app.listen(env.port, () => {
startRecurringTaskReminderJob();
startMeetingReminderJob();
+startIntegrationSyncJob();
diff --git a/data/conectores/README.md b/data/conectores/README.md
index d3a7677..e6a4af8 100644
--- a/data/conectores/README.md
+++ b/data/conectores/README.md
@@ -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.
diff --git a/data/conectores/foxpro/README.md b/data/conectores/foxpro/README.md
index c71acae..7ed8130 100644
--- a/data/conectores/foxpro/README.md
+++ b/data/conectores/foxpro/README.md
@@ -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.
diff --git a/data/conectores/foxpro/config.example.json b/data/conectores/foxpro/config.example.json
index 31b21a3..5ef22e4 100644
--- a/data/conectores/foxpro/config.example.json
+++ b/data/conectores/foxpro/config.example.json
@@ -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"
}
diff --git a/data/conectores/foxpro/package.json b/data/conectores/foxpro/package.json
index 34d3256..d2ca976 100644
--- a/data/conectores/foxpro/package.json
+++ b/data/conectores/foxpro/package.json
@@ -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": {
diff --git a/data/conectores/foxpro/scripts/prueba-local.js b/data/conectores/foxpro/scripts/prueba-local.js
index bddaf91..3f8f4e1 100644
--- a/data/conectores/foxpro/scripts/prueba-local.js
+++ b/data/conectores/foxpro/scripts/prueba-local.js
@@ -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' });
diff --git a/data/conectores/foxpro/src/config.js b/data/conectores/foxpro/src/config.js
index 0931946..c1c9988 100644
--- a/data/conectores/foxpro/src/config.js
+++ b/data/conectores/foxpro/src/config.js
@@ -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'),
};
diff --git a/data/conectores/foxpro/src/dbf.js b/data/conectores/foxpro/src/dbf.js
index 1019c76..f9f13df 100644
--- a/data/conectores/foxpro/src/dbf.js
+++ b/data/conectores/foxpro/src/dbf.js
@@ -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 };
diff --git a/docs/CONEXIONES-EXTERNAS.md b/docs/CONEXIONES-EXTERNAS.md
index 8eff221..2258669 100644
--- a/docs/CONEXIONES-EXTERNAS.md
+++ b/docs/CONEXIONES-EXTERNAS.md
@@ -4,8 +4,9 @@
> nómina, FoxPro u otra tecnología). No leer si: la tarea no toca datos externos.
> Contrato técnico de los conectores: [`data/conectores/README.md`](../data/conectores/README.md).
-Estado (2026-10-29): **conector FoxPro 1.0.0 hecho** (`data/conectores/foxpro/`, 16 pruebas locales
-OK, sin probar con tablas reales ni en Windows). **Módulo del sistema: pendiente** (solo diseño).
+Estado (2026-10-29): **conector FoxPro 1.1.0 instalado y funcionando en Exilaser** (ver abajo).
+**Módulo del sistema hecho en dev** (migración 038, `backend/src/modules/integrations`, job
+`integrationSync`), sin commit ni producción.
## Arquitectura
@@ -16,8 +17,22 @@ el sistema no cambia. Ver decisión #2 en `DECISIONES.md`.
- Primer caso: Exilaser, Windows Server `192.168.0.253` (SAGA/SistViso en FoxPro), alcanzado por
la VPN WireGuard. Conector en `C:\api_project\foxpro`, puerto `8455`.
-- Fuentes previstas: `Sistemas\nomina` (empleados) y la tabla de clientes de SAGA (pacientes de la
- Agenda médica). Nombres reales de tablas y campos: **por confirmar** en el servidor.
+- Fuentes reales (revisadas por SSH, solo estructura, 2026-10-29):
+
+| Fuente | Ruta | Tabla | Registros | Codificación | Campos útiles |
+|---|---|---|---|---|---|
+| Nómina | `C:\Sistema\Nomina\Datos\Exilaser` | `empleado.DBF` (VFP 0x30, sin memo) | 31 | cp1252 | `EMPCED`, `EMPAPE`, `EMPNOM`, `EMPTEL`, `EMPTEL1`, `EMPFECNAC`, `EMPCARGO`, `EMPDEPART`, `EMPINGRESO`, `EMPSALIDA`, `EMPACTIVO` |
+| SAGA clientes | `C:\Sistema\Datos\exilaser` | `clientes.DBF` (VFP 0x30, sin memo, ~20 MB) | 32 985 | **cp850** | `CLICOD`, `CLIRUC`, `CLINOM`, `CLITEL`, `CLIEMAIL`, `CLIDIR`, `CLICIU`, `CLIPERSONA`, `CLITIPRUC`, `CLISEXO`, `INACTIVO`, `CLIESTADO`, `FECHAACT` |
+
+ - **No exponer** de nómina: `EMPSUELDO`, `EMPBANCO`, `EMPCUENT`, `EMPTIPOCUE`, `EMPNUMIESS`,
+ `EMPLET*`, `FIJO1-5`, `EMPCARGAS`, `EMPESTADO` (es estado civil) (van en `denyFields` o se usa lista de campos permitidos).
+ - Nómina **no tiene correo**: al crear un usuario desde nómina el correo se escribe a mano.
+ - Clientes: el nombre viene en **un solo campo** (`CLINOM`), sin nacimiento; incluye empresas
+ (RUC) — para pacientes filtrar personas naturales (`CLIPERSONA`/`CLITIPRUC`). El orden
+ apellidos/nombres de `CLINOM` se confirma con una muestra al instalar.
+ - `exilaser14`…`exilaser25` son cierres de años anteriores: **no** se exponen.
+ - `clientes.DBF` cambia todo el día (SAGA escribe): conviene modo **importar** o caché con
+ recarga mínima, para no copiar 20 MB en cada consulta.
## Requisitos acordados con el usuario
@@ -50,12 +65,62 @@ el sistema no cambia. Ver decisión #2 en `DECISIONES.md`.
- En el sistema: configuración solo superadmin; "Actualizar empleados" admin/superadmin; auditoría
de pruebas de conexión, importaciones y consultas.
+## Instalación en Exilaser (2026-10-29)
+
+- `C:\api_project\foxpro`, Node 24 del servidor, tarea programada `LinkedCorp-Conector-FoxPro`
+ (al arranque, usuario SYSTEM, reinicio si cae), regla de firewall del mismo nombre: TCP 8455 solo
+ desde `192.168.222.3` (prod) y `192.168.222.40` (dev).
+- URL desde LinkedCorp: `http://192.168.0.253:8455` (por la VPN). Token: archivo
+ `~/.config/linkedcorp/conector-foxpro-exilaser.token` (600) en el equipo de desarrollo; en el
+ Windows solo está su SHA-256.
+- Probado desde dev: sin token → 401; nómina 31 empleados (11 campos permitidos); clientes 32 988
+ (13 campos); primera carga de clientes ~8 s, luego caché (recarga como mínimo cada 60 s);
+ incremental `changedField=FECHAACT` OK; tildes/ñ correctas (cp850).
+- `CLINOM` viene como **APELLIDOS NOMBRES** (ej. "GONZALEZ JIMENEZ RAUL VICENTE"): al importar se
+ separa en las 2 primeras palabras = apellidos, resto = nombres (los apellidos compuestos se
+ corrigen a mano). Pacientes = `CLIPERSONA = Natural` (en una muestra de 1000: 988 naturales).
+- Mejora a futuro: correr la tarea con una cuenta de dominio de solo lectura en vez de SYSTEM.
+- Actualizar el conector: copiar `src/` a `C:\api_project\foxpro\src`, detener la tarea, matar el
+ `node` del conector y volver a iniciar la tarea.
+
+## Decisiones del usuario sobre pacientes (2026-10-29)
+
+- Con conexión activa **se permite crear pacientes a mano** (quedan como locales).
+- Al vincular por cédula, si los datos difieren **manda SAGA** (el cambio queda en auditoría).
+
## Pendientes
-- [ ] Revisar en el server (SSH, solo lectura, con permiso del usuario) las carpetas de nómina y
- clientes: tablas, campos, versión de los DBF y codificación (cp1252/cp850).
-- [ ] Instalar el conector en `C:\api_project\foxpro` (node.exe portable + `config.json` + token +
- `instalar-servicio.ps1`) y probar desde el backend.
-- [ ] Módulo del sistema: migración (conexiones, fuentes/mapeos, empleados, vínculo usuario↔empleado),
- backend `modules/integrations`, pantalla en Configuración, botón en Organización, alta de
- usuario desde nómina, búsqueda de pacientes externa en la Agenda.
+- [x] Revisar en el server las carpetas de nómina y clientes (estructura, codificación).
+- [x] Instalar el conector en `C:\api_project\foxpro` y probar desde dev.
+- [x] Módulo del sistema (ver "Cómo quedó en el sistema").
+- [ ] Commit, revisión y producción (migraciones 036–038, conexión y fuentes a configurar allá).
+
+## Cómo quedó en el sistema (2026-10-29)
+
+- **Configuración → Conexiones externas** (superadmin): conexiones (URL + token cifrado, Probar) y
+ fuentes Pacientes/Empleados: conexión → fuente → tabla, vista previa de 20 filas, mapeo de campos,
+ filtro (ej. `CLIPERSONA = Natural`), campo de cambios (`FECHAACT`) y modo (pacientes: importar o en
+ vivo). Botones "Traer cambios" y "Sincronización completa".
+- **Programación** (job en el backend, cada 5 min revisa): pacientes en modo importar → completa
+ **lunes a viernes a las 05:00** (recoge borrados: quedan inactivos) e incremental cada 15 min de
+ 06:00 a 21:00 (agregados por `_recno` + modificados por `FECHAACT`). La primera vez hace la completa.
+ El conector solo copia el DBF cuando el sistema le pide datos (como mínimo cada 60 s).
+- **Organización → Empleados** (admin/superadmin): listado y **"Actualizar empleados"** (a pedido).
+ Los que salen de la nómina quedan inactivos. **Nuevo usuario → Desde nómina** precarga nombre,
+ cargo y departamento (si coincide por nombre) y vincula el usuario con el empleado.
+- **Médicos (2026-10-29)**: casi todos facturan como **proveedores** de SAGA (`provedor.DBF`, expuesto
+ en el conector solo con código, nombre, RUC, teléfono, correo, ciudad, tipo e inactivo). Se cargan
+ como empleados con `origin=provider` con `backend/src/scripts/import-provider-employees.js` (lee
+ del conector por código de proveedor; sin datos personales en Git). En dev: 9 proveedores + 2 que ya
+ eran de nómina (Torres Palacios Nataly, Rodríguez Nava Christian). Códigos usados:
+ `137+031 142+033 809 234 040 794 817 565 540 405 676` (`--position=MEDICO`).
+- **Protección**: un empleado con usuario en el sistema (p. ej. médico) **no se actualiza ni se
+ inactiva** al "Actualizar empleados"; los de origen proveedor tampoco los toca la nómina.
+- **Agenda**: en modo importar se busca entre los importados (también por nombre de origen); en modo
+ en vivo el selector agrega los del origen ("Del sistema origen") y al elegir uno se trae solo ese.
+ El detalle del paciente muestra su origen.
+- **Nombres de SAGA**: `CLINOM` no tiene orden fijo (a veces nombre primero), así que no se separa:
+ se guarda entero como nombre visible (`name_split=0`). Al editar el paciente, recepción separa
+ apellidos y nombres; desde ahí la sincronización ya no pisa su nombre (sí cédula/teléfono/correo).
+- Prueba en dev con datos reales: 31 empleados (24 activos), 31 220 pacientes en 24 s; repetir la
+ completa sin cambios 1,7 s; 14 cédulas repetidas en SAGA quedan sin cédula en el segundo registro.
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 29f6aaf..eb9a5cd 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "project-linkedcorp-frontend",
- "version": "4.11.0",
+ "version": "4.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "project-linkedcorp-frontend",
- "version": "4.11.0",
+ "version": "4.12.0",
"dependencies": {
"dompurify": "^3.4.16",
"highlight.js": "^11.12.0",
diff --git a/frontend/package.json b/frontend/package.json
index 06f26a1..ec12a4e 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
- "version": "4.11.0",
+ "version": "4.12.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js
index c111a3b..5bb0718 100644
--- a/frontend/src/i18n/messages.js
+++ b/frontend/src/i18n/messages.js
@@ -663,6 +663,69 @@ export const messages = {
org_departments: 'Departamentos',
org_users: 'Usuarios',
+ config_integrations: 'Conexiones externas',
+ int_connections: 'Conexiones',
+ int_new_connection: 'Nueva conexión',
+ int_url: 'URL del conector',
+ int_token: 'Token del conector',
+ int_token_keep: 'Déjalo vacío para conservar el token guardado.',
+ int_test: 'Probar conexión',
+ int_test_ok: 'Conexión correcta',
+ int_sources: 'Fuentes de datos',
+ int_purpose_patients: 'Pacientes (Agenda médica)',
+ int_purpose_employees: 'Empleados (nómina)',
+ int_not_configured: 'Sin configurar',
+ int_configure: 'Configurar',
+ int_connection: 'Conexión',
+ int_source: 'Fuente',
+ int_table: 'Tabla',
+ int_mode: 'Modo',
+ int_mode_import: 'Importar (copia en el sistema)',
+ int_mode_live: 'Consulta en vivo',
+ int_preview: 'Vista previa (20 filas)',
+ int_mapping: 'Campos a usar',
+ int_filter: 'Filtro (solo registros con este valor)',
+ int_filter_field: 'Campo',
+ int_filter_value: 'Valor',
+ int_changed_field: 'Campo de última modificación (para traer solo cambios)',
+ int_last_sync: 'Última sincronización',
+ int_sync_now: 'Traer cambios',
+ int_sync_full: 'Sincronización completa',
+ int_schedule_hint: 'Automático: completa de lunes a viernes a las 05:00 y solo cambios cada 15 min de 06:00 a 21:00.',
+ int_received: 'recibidos',
+ int_created: 'nuevos',
+ int_updated: 'actualizados',
+ int_deactivated: 'inactivados',
+ int_target_identification: 'Cédula / identificación',
+ int_target_lastNames: 'Apellidos',
+ int_target_firstNames: 'Nombres',
+ int_target_phone: 'Teléfono',
+ int_target_phone2: 'Teléfono 2',
+ int_target_birthDate: 'Nacimiento',
+ int_target_position: 'Cargo',
+ int_target_department: 'Departamento',
+ int_target_hireDate: 'Ingreso',
+ int_target_leaveDate: 'Salida',
+ int_target_active: 'Activo',
+ int_target_externalId: 'Código en el origen',
+ int_target_fullName: 'Nombre completo',
+ int_target_email: 'Correo',
+ int_required: 'obligatorio',
+ org_employees: 'Empleados',
+ emp_origin: 'Origen',
+ emp_origin_payroll: 'Nómina',
+ emp_origin_provider: 'Proveedor (SAGA)',
+ emp_protected_hint: 'Los empleados con usuario en el sistema no se actualizan ni se inactivan al actualizar.',
+ emp_update: 'Actualizar empleados',
+ emp_never: 'Nunca actualizado',
+ emp_user: 'Usuario del sistema',
+ emp_no_source: 'No hay fuente de nómina configurada (Configuración → Conexiones externas).',
+ user_from_payroll: 'Desde nómina',
+ clinic_origin: 'Origen',
+ clinic_origin_local: 'Creado aquí',
+ clinic_source_name: 'Nombre en el origen',
+ clinic_name_unsplit: 'Nombre traído del origen sin separar: al editar, separa apellidos y nombres.',
+ clinic_external_results: 'Del sistema origen',
department_new: 'Nuevo departamento',
department_parent: 'Departamento padre',
user_new: 'Nuevo usuario',
@@ -1357,6 +1420,69 @@ export const messages = {
org_departments: 'Departments',
org_users: 'Users',
+ config_integrations: 'External connections',
+ int_connections: 'Connections',
+ int_new_connection: 'New connection',
+ int_url: 'Connector URL',
+ int_token: 'Connector token',
+ int_token_keep: 'Leave empty to keep the saved token.',
+ int_test: 'Test connection',
+ int_test_ok: 'Connection OK',
+ int_sources: 'Data sources',
+ int_purpose_patients: 'Patients (Medical schedule)',
+ int_purpose_employees: 'Employees (payroll)',
+ int_not_configured: 'Not configured',
+ int_configure: 'Configure',
+ int_connection: 'Connection',
+ int_source: 'Source',
+ int_table: 'Table',
+ int_mode: 'Mode',
+ int_mode_import: 'Import (copy into the system)',
+ int_mode_live: 'Live lookup',
+ int_preview: 'Preview (20 rows)',
+ int_mapping: 'Fields to use',
+ int_filter: 'Filter (only records with this value)',
+ int_filter_field: 'Field',
+ int_filter_value: 'Value',
+ int_changed_field: 'Last-modified field (to fetch only changes)',
+ int_last_sync: 'Last sync',
+ int_sync_now: 'Fetch changes',
+ int_sync_full: 'Full sync',
+ int_schedule_hint: 'Automatic: full sync Monday to Friday at 05:00 and changes only every 15 min from 06:00 to 21:00.',
+ int_received: 'received',
+ int_created: 'new',
+ int_updated: 'updated',
+ int_deactivated: 'deactivated',
+ int_target_identification: 'ID number',
+ int_target_lastNames: 'Last names',
+ int_target_firstNames: 'First names',
+ int_target_phone: 'Phone',
+ int_target_phone2: 'Phone 2',
+ int_target_birthDate: 'Birth date',
+ int_target_position: 'Position',
+ int_target_department: 'Department',
+ int_target_hireDate: 'Hire date',
+ int_target_leaveDate: 'Leave date',
+ int_target_active: 'Active',
+ int_target_externalId: 'Source code',
+ int_target_fullName: 'Full name',
+ int_target_email: 'Email',
+ int_required: 'required',
+ org_employees: 'Employees',
+ emp_origin: 'Source',
+ emp_origin_payroll: 'Payroll',
+ emp_origin_provider: 'Supplier (SAGA)',
+ emp_protected_hint: 'Employees with a system user are not updated or deactivated by the update.',
+ emp_update: 'Update employees',
+ emp_never: 'Never updated',
+ emp_user: 'System user',
+ emp_no_source: 'No payroll source configured (Settings → External connections).',
+ user_from_payroll: 'From payroll',
+ clinic_origin: 'Source',
+ clinic_origin_local: 'Created here',
+ clinic_source_name: 'Name in source',
+ clinic_name_unsplit: 'Name imported without split: when editing, separate last and first names.',
+ clinic_external_results: 'From source system',
department_new: 'New department',
department_parent: 'Parent department',
user_new: 'New user',
diff --git a/frontend/src/pages/AgendaMedica/AgendaMedicaPage.jsx b/frontend/src/pages/AgendaMedica/AgendaMedicaPage.jsx
index 5545818..335185b 100644
--- a/frontend/src/pages/AgendaMedica/AgendaMedicaPage.jsx
+++ b/frontend/src/pages/AgendaMedica/AgendaMedicaPage.jsx
@@ -54,7 +54,10 @@ export default function AgendaMedicaPage() {
[patientTotal, setPatientTotal] = useState(0);
// Selector de paciente dentro de la cita: estado propio, se reinicia en cada cita.
const [apptPatients, setApptPatients] = useState([]),
- [apptSearch, setApptSearch] = useState('');
+ [apptSearch, setApptSearch] = useState(''),
+ // Pacientes del sistema origen (Conexiones externas, modo consulta en vivo): aún no importados.
+ [externalPatients, setExternalPatients] = useState([]),
+ [patientSource, setPatientSource] = useState(null);
const [popup, setPopup] = useState(null),
[editing, setEditing] = useState(false),
[conflict, setConflict] = useState(''),
@@ -98,6 +101,7 @@ export default function AgendaMedicaPage() {
api.clinicMe().then(async staff => {
setMe(staff);
await loadDoctors();
+ if (staff.scope === 'all') api.clinicPatientSource().then(setPatientSource).catch(() => setPatientSource(null));
}).catch(e => {
if (isAdmin) setTab('config');
else setError(e.message);
@@ -169,6 +173,7 @@ export default function AgendaMedicaPage() {
if (kind === 'appointment' && isReception) {
// Búsqueda propia de la cita: arranca vacía en cada cita y no toca la pestaña Pacientes.
setApptSearch('');
+ setExternalPatients([]);
const current = item ? [{ id: item.patient_id, first_names: item.first_names, last_names: item.last_names }] : extra.patient ? [extra.patient] : [];
setApptPatients(current);
api.clinicPatients('', 1).then(x => setApptPatients([...current, ...x.items.filter(p => !current.some(c => c.id === p.id))])).catch(() => {});
@@ -180,6 +185,9 @@ export default function AgendaMedicaPage() {
function searchApptPatients(text) {
setApptSearch(text);
const seq = ++searchSeq.current;
+ if (patientSource?.mode === 'live' && text.trim().length >= 3) {
+ api.clinicExternalPatients(text).then(rows => { if (seq === searchSeq.current) setExternalPatients(rows.filter(r => !r.local_id)); }).catch(() => setExternalPatients([]));
+ } else setExternalPatients([]);
api.clinicPatients(text, 1).then(x => {
if (seq !== searchSeq.current) return;
setApptPatients(prev => {
@@ -188,6 +196,18 @@ export default function AgendaMedicaPage() {
});
}).catch(() => {});
}
+ // Elegir paciente; si es del sistema origen (ext:código) se trae primero al sistema.
+ async function pickApptPatient(value) {
+ if (!String(value).startsWith('ext:')) return draft.setField('patientId', value);
+ try {
+ const p = await api.clinicImportExternalPatient(String(value).slice(4));
+ setApptPatients(list => [p, ...list.filter(x => x.id !== p.id)]);
+ setExternalPatients(list => list.filter(x => `ext:${x.external_id}` !== value));
+ draft.setField('patientId', p.id);
+ } catch (e) {
+ setConflict(e.message);
+ }
+ }
// "Nuevo paciente" desde la cita: la cita queda abierta debajo y se vuelve a ella al guardar,
// cancelar o cerrar. Si lo buscado parece una cédula, se precarga.
function newPatientFromAppointment() {
@@ -403,7 +423,9 @@ export default function AgendaMedicaPage() {
] : popup.kind === 'patient' ? [
[t('clinic_first_names'), it.first_names], [t('clinic_last_names'), it.last_names],
[t('clinic_identification'), it.identification], [t('clinic_phone'), it.phone],
- [t('clinic_email'), it.email], [t('clinic_birth_date'), it.birth_date], [t('clinic_notes'), it.notes]
+ [t('clinic_email'), it.email], [t('clinic_birth_date'), it.birth_date], [t('clinic_notes'), it.notes],
+ [t('clinic_origin'), it.external_source ? `${t('clinic_external_results')} (${it.external_id})` : t('clinic_origin_local')],
+ [t('clinic_source_name'), it.external_source && it.name_split ? it.source_name : null]
] : [
[t('clinic_doctor'), doctorName(it.doctor_user_id)], [t('clinic_kind'), t(`clinic_kind_${it.kind}`)],
[t('clinic_title'), it.title], [t('clinic_created_by'), it.created_by_name],
@@ -520,8 +542,8 @@ export default function AgendaMedicaPage() {
}
> : <>
- {popup.kind === 'appointment' && }
- {popup.kind === 'patient' && }
+ {popup.kind === 'appointment' && void pickApptPatient(x)} search={apptSearch} onSearch={searchApptPatients} onNewPatient={newPatientFromAppointment} slots={slots} upcoming={upcoming} />}
+ {popup.kind === 'patient' && }
{popup.kind === 'block' && }
diff --git a/frontend/src/pages/AgendaMedica/ClinicForms.jsx b/frontend/src/pages/AgendaMedica/ClinicForms.jsx
index 808ccc0..698ec5c 100644
--- a/frontend/src/pages/AgendaMedica/ClinicForms.jsx
+++ b/frontend/src/pages/AgendaMedica/ClinicForms.jsx
@@ -12,9 +12,9 @@ import { plus } from './clinicUtils.js';
// Formularios de la Agenda médica con la misma estructura que "Nueva reunión" de Calendario:
// campos cortos en pares (FieldRow), texto largo a lo ancho, fecha + hora con DateTimeEditor.
-const patientLabel = (p) => `${p.last_names} ${p.first_names}${p.identification || p.phone ? ` · ${p.identification || p.phone}` : ''}`;
+const patientLabel = (p) => `${`${p.last_names} ${p.first_names}`.trim()}${p.identification || p.phone ? ` · ${p.identification || p.phone}` : ''}`;
-export function AppointmentForm({ draft, doctors, types, patients, search, onSearch, onNewPatient, slots, upcoming = [] }) {
+export function AppointmentForm({ draft, doctors, types, patients, externalPatients = [], onPatientChange, search, onSearch, onNewPatient, slots, upcoming = [] }) {
const { t } = useUi();
const v = draft.values;
const duration = (typeId) => types.find((z) => z.id === Number(typeId))?.duration_minutes || 30;
@@ -23,7 +23,7 @@ export function AppointmentForm({ draft, doctors, types, patients, search, onSea
onSearch(e.target.value)} />