feat(recepcion): motor de precios + selector empresa/convenio

- Nueva sección empresa/convenio en recepcion.php: búsqueda live por
  nombre/NIT, selector de subgrupo, campo autorización (cuando req_autoriza),
  diagnóstico CIE-10
- Tabla de precios calculados en tiempo real: resolución por tarifa de la
  empresa o subgrupo, descuento empresa aplicado, botón "Aplicar al cobro"
- Al agregar/quitar exámenes o cambiar empresa/subgrupo se recalcula automáticamente
- Nuevo API get_precios_examenes.php: resuelve precios desde lab_tarifas
  con soporte de tarifas derivadas por porcentaje
- create_solicitud.php guarda nit_empresa, subgrupo_id, autorizacion,
  diag_ppal, items_precio (snapshot JSON al momento de la recepción)
- Migración 10: ALTER turnero_solicitudes añade los 5 campos nuevos

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-04 22:09:26 -05:00
co-authored by Claude Sonnet 4.6
parent b9bb4fc628
commit a7aac706ee
4 changed files with 423 additions and 4 deletions
@@ -0,0 +1,16 @@
-- =============================================================
-- LIS 10 — Extiende turnero_solicitudes con campos de empresa/convenio
-- Todos DEFAULT NULL para no romper datos existentes.
-- =============================================================
ALTER TABLE turnero_solicitudes
ADD COLUMN IF NOT EXISTS nit_empresa VARCHAR(20) DEFAULT NULL
COMMENT 'FK lab_empresas.nit — empresa/EPS del paciente',
ADD COLUMN IF NOT EXISTS subgrupo_id INT UNSIGNED DEFAULT NULL
COMMENT 'FK lab_empresa_subgrupos.id',
ADD COLUMN IF NOT EXISTS autorizacion VARCHAR(100) DEFAULT NULL
COMMENT 'Número de autorización EPS',
ADD COLUMN IF NOT EXISTS diag_ppal VARCHAR(20) DEFAULT NULL
COMMENT 'Diagnóstico principal CIE-10',
ADD COLUMN IF NOT EXISTS items_precio JSON DEFAULT NULL
COMMENT 'Snapshot de precios al momento de la recepción';
+13 -4
View File
@@ -32,7 +32,13 @@ $obs = isset($datos['observaciones']) ? trim((string) $datos['observac
$numOrden = isset($datos['numero_orden']) ? trim((string) $datos['numero_orden']) : null;
$embarazada = !empty($datos['embarazada']) ? 1 : 0;
$soloMuestras = !empty($datos['solo_muestras']) ? 1 : 0;
$medicoId = !empty($datos['medico_id']) ? (int)$datos['medico_id'] : null;
$medicoId = !empty($datos['medico_id']) ? (int)$datos['medico_id'] : null;
$nitEmpresa = !empty($datos['nit_empresa']) ? trim((string)$datos['nit_empresa']) : null;
$subgrupoId = !empty($datos['subgrupo_id']) ? (int)$datos['subgrupo_id'] : null;
$autorizacion = !empty($datos['autorizacion']) ? trim((string)$datos['autorizacion']) : null;
$diagPpal = !empty($datos['diag_ppal']) ? trim((string)$datos['diag_ppal']) : null;
$itemsPrecio = isset($datos['items_precio']) && is_array($datos['items_precio'])
? json_encode($datos['items_precio']) : null;
$pagosDetalle = null;
if ($metodoPago === 'combinado' && isset($datos['pagos_detalle']) && is_array($datos['pagos_detalle'])) {
$pd = $datos['pagos_detalle'];
@@ -117,12 +123,15 @@ try {
$stmt = $pdo->prepare(
"INSERT INTO turnero_solicitudes
(turno_id, paciente_id, lugar_id, numero_orden, total_cobrado, metodo_pago, pagos_detalle, observaciones, embarazada, solo_muestras, medico_id, creado_por)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
(turno_id, paciente_id, lugar_id, numero_orden, total_cobrado, metodo_pago, pagos_detalle,
observaciones, embarazada, solo_muestras, medico_id,
nit_empresa, subgrupo_id, autorizacion, diag_ppal, items_precio, creado_por)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
);
$stmt->execute([
$turnoId, $pacienteId, $lugarId, $numOrden,
$total, $metodoPago ?: null, $pagosDetalle, $obs ?: null, $embarazada, $soloMuestras, $medicoId, adminId(),
$total, $metodoPago ?: null, $pagosDetalle, $obs ?: null, $embarazada, $soloMuestras, $medicoId,
$nitEmpresa, $subgrupoId, $autorizacion, $diagPpal, $itemsPrecio, adminId(),
]);
$solicitudId = (int) $pdo->lastInsertId();
@@ -0,0 +1,149 @@
<?php
/**
* GET /modules/turnero/api/get_precios_examenes.php
*
* Calcula precios de exámenes según empresa/tarifa.
*
* Query params:
* exam_ids[] int[] IDs de exam_tipos
* nit_empresa string opcional
* subgrupo_id int opcional
*
* Response:
* items[] { exam_tipo_id, codigo, nombre, valor_base, valor_final, tiene_precio }
* subtotal precio antes de descuento empresa
* descuento_pct porcentaje de descuento empresa
* descuento_val valor del descuento
* total total a cobrar
* tarifa_id tarifa aplicada
* tarifa_nombre nombre de la tarifa
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('GET');
$examIds = array_filter(array_map('intval', (array)($_GET['exam_ids'] ?? [])));
$nitEmpresa = trim($_GET['nit_empresa'] ?? '');
$subgrupoId = (int)($_GET['subgrupo_id'] ?? 0);
if (empty($examIds)) jsonOk([
'items' => [],
'subtotal' => 0,
'descuento_pct' => 0,
'descuento_val' => 0,
'total' => 0,
'tarifa_id' => null,
'tarifa_nombre' => null,
]);
$pdo = db();
// ── Resolver tarifa efectiva ───────────────────────────────────
$tarifaId = null;
$tarifaNombre = null;
$descuentoPct = 0;
if ($nitEmpresa !== '') {
$stmtE = $pdo->prepare(
"SELECT e.tarifa_id, e.descuento_pct, ti.nombre AS tarifa_nombre
FROM lab_empresas e
LEFT JOIN lab_tarifas_id ti ON ti.id = e.tarifa_id
WHERE e.nit = ?"
);
$stmtE->execute([$nitEmpresa]);
$empresa = $stmtE->fetch(PDO::FETCH_ASSOC);
if ($empresa) {
$tarifaId = $empresa['tarifa_id'];
$tarifaNombre = $empresa['tarifa_nombre'];
$descuentoPct = (float)$empresa['descuento_pct'];
// Si el subgrupo tiene tarifa propia, override
if ($subgrupoId > 0) {
$stmtS = $pdo->prepare(
"SELECT es.tarifa_id, ti.nombre AS tarifa_nombre
FROM lab_empresa_subgrupos es
LEFT JOIN lab_tarifas_id ti ON ti.id = es.tarifa_id
WHERE es.id = ? AND es.nit_empresa = ? AND es.tarifa_id IS NOT NULL"
);
$stmtS->execute([$subgrupoId, $nitEmpresa]);
$sub = $stmtS->fetch(PDO::FETCH_ASSOC);
if ($sub) {
$tarifaId = $sub['tarifa_id'];
$tarifaNombre = $sub['tarifa_nombre'];
}
}
}
}
// ── Obtener nombre/codigo de los exámenes ─────────────────────
$ph = implode(',', array_fill(0, count($examIds), '?'));
$stmtX = $pdo->prepare(
"SELECT id AS exam_tipo_id, codigo, nombre FROM exam_tipos WHERE id IN ($ph)"
);
$stmtX->execute($examIds);
$examMap = [];
foreach ($stmtX->fetchAll(PDO::FETCH_ASSOC) as $r) {
$examMap[(int)$r['exam_tipo_id']] = $r;
}
// ── Obtener precios desde lab_tarifas (con derivación por %) ──
$items = [];
$subtotal = 0;
foreach ($examIds as $examId) {
$examen = $examMap[$examId] ?? ['codigo' => '?', 'nombre' => 'Desconocido'];
$valorFinal = null;
$valorBase = null;
$tienePrecio = false;
if ($tarifaId !== null) {
// Intentar precio en la tarifa exacta
$stmtP = $pdo->prepare(
"SELECT lt.valor, ti.porcentaje, ti.tarifa_origen,
lt_base.valor AS valor_origen
FROM lab_tarifas lt
JOIN lab_tarifas_id ti ON ti.id = lt.tarifa_id
LEFT JOIN lab_tarifas lt_base ON lt_base.exam_tipo_id = lt.exam_tipo_id
AND lt_base.tarifa_id = ti.tarifa_origen
WHERE lt.exam_tipo_id = ? AND lt.tarifa_id = ?"
);
$stmtP->execute([$examId, $tarifaId]);
$precio = $stmtP->fetch(PDO::FETCH_ASSOC);
if ($precio) {
$tienePrecio = true;
$valorBase = (float)$precio['valor'];
if ((float)$precio['porcentaje'] > 0 && $precio['tarifa_origen'] && $precio['valor_origen'] !== null) {
$valorFinal = round((float)$precio['valor_origen'] * (1 + (float)$precio['porcentaje'] / 100), 0);
} else {
$valorFinal = $valorBase;
}
}
}
// Si no hay precio, dejar null (sin tarifa o sin mapeo)
$subtotal += $valorFinal ?? 0;
$items[] = [
'exam_tipo_id' => $examId,
'codigo' => $examen['codigo'],
'nombre' => $examen['nombre'],
'valor_base' => $valorBase,
'valor_final' => $valorFinal,
'tiene_precio' => $tienePrecio,
];
}
// ── Aplicar descuento empresa ─────────────────────────────────
$descuentoVal = round($subtotal * $descuentoPct / 100, 0);
$total = $subtotal - $descuentoVal;
jsonOk([
'items' => $items,
'subtotal' => $subtotal,
'descuento_pct' => $descuentoPct,
'descuento_val' => $descuentoVal,
'total' => $total,
'tarifa_id' => $tarifaId,
'tarifa_nombre' => $tarifaNombre,
]);
+245
View File
@@ -608,6 +608,80 @@ document.addEventListener('DOMContentLoaded', function() {
</div>
</div>
<!-- ── Sección 3.5: Empresa / Convenio ── -->
<div class="ficha-section" id="sec-empresa">
<h6><i class="fas fa-building me-1"></i>Empresa / Convenio <span class="text-muted fw-normal">(opcional)</span></h6>
<!-- Buscador empresa -->
<div id="empresa-seleccionada" class="d-none mb-2">
<div class="d-flex align-items-center gap-2 p-2 rounded border" style="background:#f0f9ff">
<div class="flex-grow-1">
<div class="fw-semibold small" id="empresa-badge-nombre"></div>
<div class="text-muted" style="font-size:.75rem" id="empresa-badge-meta"></div>
</div>
<button type="button" class="btn btn-link btn-sm p-0 text-danger" onclick="quitarEmpresa()" title="Quitar empresa">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<div id="empresa-buscador">
<input type="text" id="inp-buscar-empresa" class="form-control form-control-sm"
placeholder="Buscar empresa por nombre o NIT…"
autocomplete="off" oninput="buscarEmpresa()">
<div class="position-relative">
<ul id="empresa-resultados" class="list-unstyled mb-0 border rounded bg-white shadow-sm position-absolute w-100 d-none"
style="z-index:200;max-height:200px;overflow-y:auto;top:2px"></ul>
</div>
</div>
<input type="hidden" id="inp-empresa-nit">
<!-- Subgrupo (se muestra cuando hay subgrupos) -->
<div id="wrap-subgrupo" class="d-none mt-2">
<label class="form-label mb-1 small fw-semibold">Subgrupo</label>
<select id="sel-subgrupo" class="form-select form-select-sm" onchange="recalcularPrecios()">
<option value="">— Sin subgrupo —</option>
</select>
</div>
<!-- Autorización -->
<div id="wrap-autorizacion" class="d-none mt-2">
<label class="form-label mb-1 small fw-semibold text-warning">
<i class="fas fa-key me-1"></i>N.° Autorización <span class="text-danger">*</span>
</label>
<input type="text" id="inp-autorizacion" class="form-control form-control-sm"
placeholder="Número de autorización EPS" maxlength="100">
</div>
<!-- Diagnóstico -->
<div id="wrap-diag" class="d-none mt-2">
<label class="form-label mb-1 small fw-semibold">Diagnóstico (CIE-10)</label>
<input type="text" id="inp-diag" class="form-control form-control-sm"
placeholder="Código CIE-10 diagnóstico principal" maxlength="20">
</div>
<!-- Tabla de precios calculados -->
<div id="panel-precios" class="d-none mt-3">
<div class="d-flex align-items-center justify-content-between mb-1">
<span class="small fw-semibold text-muted text-uppercase" style="font-size:.7rem;letter-spacing:.06em">
Precios
</span>
<span class="badge bg-light text-dark border small" id="precio-tarifa-label"></span>
</div>
<div id="tabla-precios" class="mb-1" style="font-size:.82rem"></div>
<div class="d-flex justify-content-between align-items-center pt-1 border-top">
<span class="fw-bold small">Total calculado</span>
<span class="fw-bold text-success" id="precio-total-calc">$0</span>
</div>
<div id="precio-dcto-row" class="d-none d-flex justify-content-between text-muted" style="font-size:.78rem">
<span>Descuento empresa (<span id="precio-dcto-pct"></span>%)</span>
<span id="precio-dcto-val" class="text-danger"></span>
</div>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-2"
onclick="aplicarPrecioCalculado()" id="btn-aplicar-precio">
<i class="fas fa-check me-1"></i>Aplicar al cobro
</button>
</div>
</div>
<!-- ── Sección 4: Pago ── -->
<div class="ficha-section">
<h6><i class="fas fa-dollar-sign me-1"></i>Cobro (opcional)</h6>
@@ -904,6 +978,8 @@ document.addEventListener('DOMContentLoaded', () => {
examTS = new TomSelect('#sel-examenes', {
plugins: ['remove_button'],
maxOptions: null,
onItemAdd: () => recalcularPrecios(),
onItemRemove: () => recalcularPrecios(),
render: {
option(data, escape) {
const consent = data['data-consent'] == '1' || data.consent == '1';
@@ -1483,6 +1559,11 @@ async function guardarSolicitud() {
embarazada: document.getElementById('chk-embarazada').checked ? 1 : 0,
solo_muestras: soloMuestras ? 1 : 0,
medico_id: parseInt(document.getElementById('inp-medico-id').value) || null,
nit_empresa: document.getElementById('inp-empresa-nit').value.trim() || null,
subgrupo_id: parseInt(document.getElementById('sel-subgrupo').value) || null,
autorizacion: document.getElementById('inp-autorizacion').value.trim() || null,
diag_ppal: document.getElementById('inp-diag').value.trim() || null,
items_precio: _preciosActivos.length ? _preciosActivos : null,
}),
});
const json = await res.json();
@@ -1890,8 +1971,172 @@ function resetCheckboxes() {
document.getElementById('inp-obs').value = '';
resetPagoCombinado();
resetMedico();
quitarEmpresa();
}
// ── Empresa / Convenio ────────────────────────────────────────
let _empresaActiva = null;
let _empresaBusqTimer;
function buscarEmpresa() {
clearTimeout(_empresaBusqTimer);
_empresaBusqTimer = setTimeout(_doEmpresaSearch, 280);
}
async function _doEmpresaSearch() {
const q = document.getElementById('inp-buscar-empresa').value.trim();
const ul = document.getElementById('empresa-resultados');
if (q.length < 2) { ul.classList.add('d-none'); return; }
const res = await fetch(`${BASE_URL_API_LAB}empresas.php?action=list&search=${encodeURIComponent(q)}&activa=1&limit=10`);
const j = await res.json();
const list = j.empresas || [];
if (!list.length) { ul.innerHTML = '<li class="px-3 py-2 text-muted small">Sin resultados</li>'; ul.classList.remove('d-none'); return; }
ul.innerHTML = list.map(e => `
<li class="px-3 py-2 cursor-pointer hover-bg"
style="cursor:pointer;border-bottom:1px solid #f1f5f9"
onmousedown="seleccionarEmpresa(${JSON.stringify(e)})">
<div class="fw-semibold small">${escHtml(e.nombre)}</div>
<div class="text-muted" style="font-size:.75rem">NIT ${escHtml(e.nit)}${e.tarifa_nombre ? ' · ' + escHtml(e.tarifa_nombre) : ''}</div>
</li>
`).join('');
ul.classList.remove('d-none');
}
async function seleccionarEmpresa(e) {
document.getElementById('empresa-resultados').classList.add('d-none');
document.getElementById('empresa-buscador').classList.add('d-none');
document.getElementById('empresa-seleccionada').classList.remove('d-none');
document.getElementById('empresa-badge-nombre').textContent = e.nombre;
document.getElementById('empresa-badge-meta').textContent =
`NIT ${e.nit}${e.tarifa_nombre ? ' · ' + e.tarifa_nombre : ''}${parseFloat(e.descuento_pct) > 0 ? ' · Dcto: ' + e.descuento_pct + '%' : ''}`;
document.getElementById('inp-empresa-nit').value = e.nit;
_empresaActiva = e;
// Mostrar autorización y diagnóstico si la empresa los exige
document.getElementById('wrap-autorizacion').classList.toggle('d-none', !parseInt(e.req_autoriza));
document.getElementById('wrap-diag').classList.remove('d-none');
// Cargar subgrupos
const res = await fetch(`${BASE_URL_API_LAB}empresa_subgrupos.php?nit_empresa=${encodeURIComponent(e.nit)}`);
const j = await res.json();
const subs = j.subgrupos || [];
const wrapSub = document.getElementById('wrap-subgrupo');
const selSub = document.getElementById('sel-subgrupo');
if (subs.length) {
selSub.innerHTML = '<option value="">— Sin subgrupo —</option>' +
subs.map(s => `<option value="${s.id}">${escHtml(s.subgrupo)}${s.tarifa_nombre ? ' · ' + escHtml(s.tarifa_nombre) : ''}</option>`).join('');
wrapSub.classList.remove('d-none');
} else {
selSub.innerHTML = '<option value="">— Sin subgrupo —</option>';
wrapSub.classList.add('d-none');
}
// Si es EPS, preseleccionar forma de pago
if (document.getElementById('sel-pago').value === '') {
document.getElementById('sel-pago').value = 'eps';
togglePagoCombinado();
}
recalcularPrecios();
}
function quitarEmpresa() {
_empresaActiva = null;
document.getElementById('inp-empresa-nit').value = '';
document.getElementById('inp-buscar-empresa').value = '';
document.getElementById('empresa-seleccionada').classList.add('d-none');
document.getElementById('empresa-buscador').classList.remove('d-none');
document.getElementById('empresa-resultados').classList.add('d-none');
document.getElementById('wrap-subgrupo').classList.add('d-none');
document.getElementById('wrap-autorizacion').classList.add('d-none');
document.getElementById('wrap-diag').classList.add('d-none');
document.getElementById('panel-precios').classList.add('d-none');
document.getElementById('sel-subgrupo').innerHTML = '<option value="">— Sin subgrupo —</option>';
document.getElementById('inp-autorizacion').value = '';
document.getElementById('inp-diag').value = '';
}
// ── Motor de precios ──────────────────────────────────────────
let _recalcTimer;
let _preciosActivos = [];
function recalcularPrecios() {
clearTimeout(_recalcTimer);
_recalcTimer = setTimeout(_doRecalcular, 200);
}
async function _doRecalcular() {
const examIds = examTS ? examTS.getValue() : [];
const nit = document.getElementById('inp-empresa-nit').value.trim();
const subId = document.getElementById('sel-subgrupo').value;
if (!examIds.length || !nit) {
document.getElementById('panel-precios').classList.add('d-none');
return;
}
const params = new URLSearchParams({ nit_empresa: nit });
examIds.forEach(id => params.append('exam_ids[]', id));
if (subId) params.append('subgrupo_id', subId);
const res = await fetch(`${API}get_precios_examenes.php?${params}`);
const j = await res.json();
if (!j.ok) return;
_preciosActivos = j.items || [];
// Tabla
const tabla = document.getElementById('tabla-precios');
const tienePrecios = j.items.some(i => i.tiene_precio);
if (!tienePrecios) {
tabla.innerHTML = '<div class="text-muted small text-center py-1">Sin precios configurados para esta tarifa</div>';
} else {
tabla.innerHTML = j.items.map(i => `
<div class="d-flex justify-content-between py-1" style="border-bottom:1px solid #f1f5f9">
<span class="text-truncate me-2" style="max-width:200px" title="${escHtml(i.nombre)}">${escHtml(i.nombre)}</span>
<span class="${i.tiene_precio ? 'fw-semibold' : 'text-muted'}">
${i.tiene_precio ? '$' + fmt(i.valor_final) : 'Sin precio'}
</span>
</div>
`).join('');
}
// Totales
document.getElementById('precio-tarifa-label').textContent = j.tarifa_nombre || '';
document.getElementById('precio-total-calc').textContent = '$' + fmt(j.subtotal);
const dctoRow = document.getElementById('precio-dcto-row');
if (j.descuento_pct > 0) {
document.getElementById('precio-dcto-pct').textContent = j.descuento_pct;
document.getElementById('precio-dcto-val').textContent = '-$' + fmt(j.descuento_val);
document.getElementById('precio-total-calc').textContent = '$' + fmt(j.total);
dctoRow.classList.remove('d-none');
} else {
dctoRow.classList.add('d-none');
}
document.getElementById('panel-precios').classList.remove('d-none');
}
function aplicarPrecioCalculado() {
const total = _preciosActivos.reduce((sum, i) => sum + (i.valor_final || 0), 0);
const nit = document.getElementById('inp-empresa-nit').value.trim();
const emp = _empresaActiva;
const dcto = parseFloat(emp?.descuento_pct || 0);
const final = Math.round(total * (1 - dcto / 100));
document.getElementById('inp-total').value = final || '';
if (emp && !document.getElementById('sel-pago').value) {
document.getElementById('sel-pago').value = 'eps';
togglePagoCombinado();
}
mostrarToast('Total aplicado: $' + fmt(final), 'ok', 2500);
}
function fmt(n) {
return Math.round(n || 0).toLocaleString('es-CO');
}
const BASE_URL_API_LAB = '<?= BASE_URL ?>/api/lab/';
function escHtml(str) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(String(str)));