|
|
|
@@ -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;
|
|
|
|
|
}
|