This commit is contained in:
Lizandro Guarnizo
2026-04-10 23:04:28 -05:00
parent 64495816a9
commit c8bef9d9c4
8 changed files with 273 additions and 77 deletions
+33 -4
View File
@@ -90,7 +90,24 @@ $filas = $db->fetchAll(
$params
);
// Resumen financiero
// Enriquecer con parcialidades de pago
$domIds = array_column($filas, 'id');
$parciales = [];
if ($domIds) {
$in = implode(',', array_map('intval', $domIds));
$rows = $db->fetchAll(
"SELECT domicilio_id, modo, monto, notas FROM lab_domicilio_pagos WHERE domicilio_id IN ($in) ORDER BY id ASC"
);
foreach ($rows as $row) {
$parciales[(int)$row['domicilio_id']][] = $row;
}
}
foreach ($filas as &$f) {
$f['pagos_detalle'] = $parciales[(int)$f['id']] ?? [];
}
unset($f);
// Resumen financiero — sumar desde parcialidades cuando existen
$resumen = [
'total' => count($filas),
'pagados' => 0,
@@ -104,9 +121,21 @@ foreach ($filas as $f) {
$est = $f['pago_estado'] ?? 'pendiente';
if ($est === 'pagado') {
$resumen['pagados']++;
$resumen['monto_total'] += (float)$f['pago_monto'];
$key = $f['pago_modo'] ?: 'sin_registrar';
$resumen['por_modo'][$key] = ($resumen['por_modo'][$key] ?? 0.0) + (float)$f['pago_monto'];
if (!empty($f['pagos_detalle'])) {
// Sumar desde parcialidades
foreach ($f['pagos_detalle'] as $p) {
$monto = (float)$p['monto'];
$key = $p['modo'] ?: 'sin_registrar';
$resumen['monto_total'] += $monto;
$resumen['por_modo'][$key] = ($resumen['por_modo'][$key] ?? 0.0) + $monto;
}
} else {
// Fallback registro legacy
$monto = (float)$f['pago_monto'];
$key = $f['pago_modo'] ?: 'sin_registrar';
$resumen['monto_total'] += $monto;
$resumen['por_modo'][$key] = ($resumen['por_modo'][$key] ?? 0.0) + $monto;
}
} elseif ($est === 'exento') {
$resumen['exentos']++;
} else {
+8 -2
View File
@@ -32,12 +32,18 @@ try {
// Agenda del día con pleno detalle
$agenda = $enf->agenda($eid, $fecha);
// Enriquecer con servicios extra (el resto ya viene de la query agenda())
// Enriquecer con servicios extra y pagos parciales
$db = Database::getInstance();
foreach ($agenda as &$item) {
$domId = (int)$item['domicilio_id'];
$item['servicios_extra'] = $db->fetchAll(
"SELECT * FROM lab_servicios_extra WHERE domicilio_id = ? ORDER BY created_at ASC",
[(int)$item['domicilio_id']]
[$domId]
);
// Parcialidades de pago (tabla nueva); vacío si no hay pagos registrados
$item['pagos_detalle'] = $db->fetchAll(
"SELECT modo, monto, notas FROM lab_domicilio_pagos WHERE domicilio_id = ? ORDER BY id ASC",
[$domId]
);
}
unset($item);
+53 -18
View File
@@ -2,15 +2,17 @@
/**
* POST /api/lab/registrar_pago.php
* Registra el pago de un domicilio por parte del cliente.
* Solo puede llamarse desde el portal del enfermero o por un admin.
* Soporta múltiples métodos de pago (pagos parciales).
*
* Body JSON:
* {
* domicilio_id: int,
* pago_estado: 'pagado' | 'exento' | 'pendiente',
* pago_modo: 'efectivo' | 'transferencia' | 'otro', (requerido si pagado)
* pago_monto: float, (requerido si pagado)
* pago_notas: string|null
* pagos: [ (requerido si pagado)
* { modo: 'efectivo'|'transferencia'|'otro', monto: float, notas: string|null },
* ...
* ],
* pago_notas: string|null (nota general, opcional)
* }
*/
require_once __DIR__ . '/_helpers.php';
@@ -22,33 +24,64 @@ try {
$domId = (int)($datos['domicilio_id'] ?? 0);
$estado = $datos['pago_estado'] ?? '';
if (!$domId) jsonError('domicilio_id requerido');
if (!$domId) jsonError('domicilio_id requerido');
if (!in_array($estado, ['pagado', 'exento', 'pendiente'], true))
jsonError('pago_estado inválido (pagado|exento|pendiente)');
$pagos = [];
if ($estado === 'pagado') {
if (empty($datos['pago_modo']) || !in_array($datos['pago_modo'], ['efectivo','transferencia','otro'], true))
jsonError('pago_modo inválido (efectivo|transferencia|otro)');
if (!isset($datos['pago_monto']) || (float)$datos['pago_monto'] <= 0)
jsonError('pago_monto debe ser mayor a 0');
$raw = $datos['pagos'] ?? [];
if (!is_array($raw) || empty($raw)) jsonError('Se requiere al menos un método de pago');
$modosValidos = ['efectivo', 'transferencia', 'otro'];
foreach ($raw as $i => $p) {
$modo = $p['modo'] ?? '';
$monto = (float)($p['monto'] ?? 0);
if (!in_array($modo, $modosValidos, true)) jsonError("Método de pago inválido en fila " . ($i + 1));
if ($monto <= 0) jsonError("El monto debe ser mayor a 0 en fila " . ($i + 1));
$pagos[] = [
'modo' => $modo,
'monto' => $monto,
'notas' => isset($p['notas']) && $p['notas'] !== '' ? trim($p['notas']) : null,
];
}
}
$adminId = adminId();
$dom = new Domicilio();
$db = Database::getInstance();
// Verificar que existe
$exist = Database::getInstance()->fetch(
'SELECT id, pago_estado FROM lab_domicilios WHERE id = ?', [$domId]
);
$exist = $db->fetch('SELECT id, pago_estado FROM lab_domicilios WHERE id = ?', [$domId]);
if (!$exist) jsonError("Domicilio #$domId no encontrado");
// ── Persistir parcialidades ──────────────────────────────────────────
// Eliminar registros previos y reinsertar (permite editar el pago)
$db->execute('DELETE FROM lab_domicilio_pagos WHERE domicilio_id = ?', [$domId]);
$totalMonto = 0;
$primerModo = null;
$notasGeneral = !empty($datos['pago_notas']) ? trim($datos['pago_notas']) : null;
if ($estado === 'pagado') {
foreach ($pagos as $p) {
$db->execute(
'INSERT INTO lab_domicilio_pagos (domicilio_id, modo, monto, notas) VALUES (?, ?, ?, ?)',
[$domId, $p['modo'], $p['monto'], $p['notas']]
);
$totalMonto += $p['monto'];
if ($primerModo === null) $primerModo = $p['modo'];
}
}
// ── Actualizar campos resumen en lab_domicilios ─────────────────────
// pago_modo guarda el primer método (o el único); pago_monto guarda el total.
$campos = [
'pago_estado' => $estado,
'pago_modo' => ($estado === 'pagado') ? ($datos['pago_modo'] ?? null) : null,
'pago_monto' => ($estado === 'pagado') ? ((float)$datos['pago_monto']) : null,
'pago_fecha' => ($estado === 'pagado') ? date('Y-m-d H:i:s') : null,
'pago_notas' => !empty($datos['pago_notas']) ? trim($datos['pago_notas']) : null,
'pago_registrado_por' => $adminId,
'pago_estado' => $estado,
'pago_modo' => ($estado === 'pagado') ? $primerModo : null,
'pago_monto' => ($estado === 'pagado') ? $totalMonto : null,
'pago_fecha' => ($estado === 'pagado') ? date('Y-m-d H:i:s') : null,
'pago_notas' => $notasGeneral,
'pago_registrado_por' => $adminId,
];
$dom->actualizar($domId, $campos, $adminId);
@@ -56,6 +89,8 @@ try {
jsonOk([
'domicilio_id' => $domId,
'pago_estado' => $estado,
'pago_total' => $totalMonto,
'pagos' => $pagos,
], 'Pago registrado correctamente');
} catch (InvalidArgumentException $e) {
+6
View File
@@ -163,6 +163,12 @@ class Domicilio {
[$id]
);
// Pagos parciales por método de pago
$dom['pagos_detalle'] = $this->db->fetchAll(
"SELECT modo, monto, notas FROM lab_domicilio_pagos WHERE domicilio_id = ? ORDER BY id ASC",
[$id]
);
// Vínculo: domicilio origen (este fue reprogramado desde otro)
if (!empty($dom['domicilio_origen_id'])) {
$dom['origen_info'] = $this->db->fetch(
+104 -45
View File
@@ -422,28 +422,25 @@ $hoy = date('Y-m-d');
</div>
</div>
</div>
<!-- Métodos de pago (múltiples) -->
<div id="pago-campos-pago">
<div class="mb-3">
<label class="form-label small fw-semibold">Modo de pago <span class="text-danger">*</span></label>
<select class="form-select form-select-sm" id="pago-modo">
<option value="efectivo">💵 Efectivo</option>
<option value="transferencia">📲 Transferencia</option>
<option value="otro">📋 Otro</option>
</select>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Monto cobrado <span class="text-danger">*</span></label>
<div class="input-group input-group-sm">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="pago-monto"
placeholder="0" min="0" step="1000">
</div>
<label class="form-label small fw-semibold">Métodos de pago <span class="text-danger">*</span></label>
<div id="pago-metodos-lista"></div>
<button type="button" class="btn btn-outline-success btn-sm mt-1 w-100"
onclick="pagoModal.agregarMetodo()">
<i class="fas fa-plus me-1"></i>Agregar método de pago
</button>
<div class="d-flex justify-content-between align-items-center mt-2 pt-2 border-top">
<span class="small fw-semibold text-muted">Total:</span>
<span class="fw-bold text-success" id="pago-total-label">$0</span>
</div>
</div>
<div class="mb-2">
<label class="form-label small">Notas / referencia</label>
<div class="mb-2 mt-2">
<label class="form-label small">Notas generales</label>
<input type="text" class="form-control form-control-sm" id="pago-notas"
placeholder="Ej: nequi ref #123456">
placeholder="Ej: incluye copago Nequi">
</div>
</div>
<div class="modal-footer py-2">
@@ -604,12 +601,18 @@ const portal = {
${item.valor_copago ? `<span>Copago: <strong>$${Number(item.valor_copago).toLocaleString('es-CO')}</strong></span>` : ''}
<span class="fw-bold text-success">Total: <strong>$${totalCobro.toLocaleString('es-CO')}</strong></span>
</div>
${item.pago_estado === 'pagado' && item.pago_modo ? `
<div class="small text-success">
${item.pago_estado === 'pagado' && (item.pagos_detalle || []).length ? `
<div class="small text-success mt-1">
${item.pagos_detalle.map(p =>
`<div>${MODO_LABEL[p.modo] || p.modo}: <strong>$${Number(p.monto).toLocaleString('es-CO')}</strong>${p.notas ? ` · <em>${esc(p.notas)}</em>` : ''}</div>`
).join('')}
</div>` :
(item.pago_estado === 'pagado' && item.pago_modo ? `
<div class="small text-success mt-1">
${MODO_LABEL[item.pago_modo] || item.pago_modo}
${item.pago_monto ? ` · $${Number(item.pago_monto).toLocaleString('es-CO')}` : ''}
${item.pago_notas ? ` · <em>${esc(item.pago_notas)}</em>` : ''}
</div>` : ''}
</div>` : '')}
${item.pago_estado !== 'pagado' && item.pago_estado !== 'exento' ? `
<button class="btn btn-sm mt-2 text-white w-100" style="background:#065f46;font-size:.78rem"
onclick="pagoModal.abrir(${item.domicilio_id}, ${totalCobro})">
@@ -1898,23 +1901,29 @@ const formEnvio = {
};
// ══════════════════════════════════════════════════════════
// OBJETO: pagoModal — Gestión del modal de registro de pago
// OBJETO: pagoModal — Gestión del modal de registro de pago (multi-método)
// ══════════════════════════════════════════════════════════
const pagoModal = {
_bsModal: null,
_filas: [], // [{ modo, monto, notas }]
// Abrir modal pre-cargado con domicilio y monto sugerido
abrir(domId, totalSugerido = 0) {
document.getElementById('pago-dom-id').value = domId;
document.getElementById('pago-monto').value = totalSugerido > 0 ? totalSugerido : '';
document.getElementById('pago-notas').value = '';
document.getElementById('pago-modo').value = 'efectivo';
// Recuperar estado actual desde la agenda cargada
// Recuperar estado y pagos actuales desde la agenda cargada
const item = (portal._agenda || []).find(d => d.domicilio_id == domId);
const est = item ? (item.pago_estado || 'pendiente') : 'pendiente';
document.querySelector(`input[name="pago-estado"][value="${est}"]`).checked = true;
// Cargar filas existentes o inicializar con una vacía
const existentes = (item && item.pagos_detalle && item.pagos_detalle.length)
? item.pagos_detalle
: [{ modo: 'efectivo', monto: totalSugerido > 0 ? totalSugerido : '', notas: '' }];
this._filas = existentes.map(p => ({ modo: p.modo || 'efectivo', monto: p.monto || '', notas: p.notas || '' }));
this._renderFilas();
this.toggleModo();
if (!this._bsModal) {
@@ -1923,7 +1932,54 @@ const pagoModal = {
this._bsModal.show();
},
// Mostrar/ocultar campos de modo y monto según estado
// Renderiza la lista de filas de métodos de pago
_renderFilas() {
const lista = document.getElementById('pago-metodos-lista');
if (!lista) return;
lista.innerHTML = this._filas.map((f, i) => `
<div class="d-flex gap-1 align-items-center mb-2" id="pago-fila-${i}">
<select class="form-select form-select-sm" style="max-width:130px"
onchange="pagoModal._filas[${i}].modo=this.value">
<option value="efectivo" ${f.modo==='efectivo' ? 'selected':''}>💵 Efectivo</option>
<option value="transferencia" ${f.modo==='transferencia' ? 'selected':''}>📲 Transferencia</option>
<option value="otro" ${f.modo==='otro' ? 'selected':''}>📋 Otro</option>
</select>
<div class="input-group input-group-sm" style="flex:1">
<span class="input-group-text">$</span>
<input type="number" class="form-control" placeholder="0" min="0" step="1000"
value="${f.monto !== '' ? f.monto : ''}"
oninput="pagoModal._filas[${i}].monto=parseFloat(this.value)||0;pagoModal._actualizarTotal()"
>
</div>
<input type="text" class="form-control form-control-sm" style="flex:1;min-width:60px"
placeholder="Ref." value="${esc(String(f.notas||''))}"
oninput="pagoModal._filas[${i}].notas=this.value">
${this._filas.length > 1
? `<button type="button" class="btn btn-sm btn-outline-danger px-2"
onclick="pagoModal.eliminarFila(${i})"><i class="fas fa-times"></i></button>`
: ''}
</div>`
).join('');
this._actualizarTotal();
},
_actualizarTotal() {
const sum = this._filas.reduce((s, f) => s + (parseFloat(f.monto) || 0), 0);
const lbl = document.getElementById('pago-total-label');
if (lbl) lbl.textContent = '$' + sum.toLocaleString('es-CO');
},
agregarMetodo() {
this._filas.push({ modo: 'efectivo', monto: '', notas: '' });
this._renderFilas();
},
eliminarFila(i) {
this._filas.splice(i, 1);
this._renderFilas();
},
// Mostrar/ocultar sección de métodos según estado
toggleModo() {
const est = document.querySelector('input[name="pago-estado"]:checked')?.value;
document.getElementById('pago-campos-pago').style.display =
@@ -1932,23 +1988,26 @@ const pagoModal = {
// Guardar pago vía API
async guardar() {
const domId = document.getElementById('pago-dom-id').value;
const estado = document.querySelector('input[name="pago-estado"]:checked')?.value;
const modo = document.getElementById('pago-modo').value;
const monto = parseFloat(document.getElementById('pago-monto').value) || 0;
const notas = document.getElementById('pago-notas').value.trim();
const domId = document.getElementById('pago-dom-id').value;
const estado = document.querySelector('input[name="pago-estado"]:checked')?.value;
const notas = document.getElementById('pago-notas').value.trim();
if (!estado) { alert('Selecciona el estado del pago.'); return; }
if (estado === 'pagado' && (!modo || monto <= 0)) {
alert('Para marcar como pagado debes ingresar el modo y el monto cobrado.');
return;
let pagos = [];
if (estado === 'pagado') {
pagos = this._filas.map(f => ({
modo: f.modo,
monto: parseFloat(f.monto) || 0,
notas: f.notas || null,
}));
if (!pagos.length || pagos.some(p => p.monto <= 0)) {
alert('Ingresa al menos un método de pago con monto mayor a 0.');
return;
}
}
const payload = { domicilio_id: domId, pago_estado: estado };
if (estado === 'pagado') {
payload.pago_modo = modo;
payload.pago_monto = monto;
}
const payload = { domicilio_id: domId, pago_estado: estado, pagos };
if (notas) payload.pago_notas = notas;
try {
@@ -1960,16 +2019,16 @@ const pagoModal = {
const res = await r.json();
if (!res.success) throw new Error(res.error || 'Error al guardar');
// Actualizar item en la agenda local para evitar recarga completa
// Actualizar item en la agenda local
const idx = (portal._agenda || []).findIndex(d => d.domicilio_id == domId);
if (idx >= 0) {
portal._agenda[idx].pago_estado = estado;
portal._agenda[idx].pago_modo = estado === 'pagado' ? modo : null;
portal._agenda[idx].pago_monto = estado === 'pagado' ? monto : null;
portal._agenda[idx].pago_notas = notas || null;
portal._agenda[idx].pago_estado = estado;
portal._agenda[idx].pago_monto = estado === 'pagado' ? res.pago_total : null;
portal._agenda[idx].pago_modo = estado === 'pagado' && pagos.length ? pagos[0].modo : null;
portal._agenda[idx].pago_notas = notas || null;
portal._agenda[idx].pagos_detalle = estado === 'pagado' ? pagos : [];
}
// Cerrar modal y re-renderizar
if (this._bsModal) this._bsModal.hide();
portal._renderLista();
} catch(e) {
+17 -6
View File
@@ -894,12 +894,23 @@ async function verDomicilio(id) {
${dom.valor_copago ? `<span class="small">Copago: <strong>$${Number(dom.valor_copago).toLocaleString('es-CO')}</strong></span>` : ''}
${totalC > 0 ? `<span class="small fw-bold text-success">Total: $${totalC.toLocaleString('es-CO')}</span>` : ''}
</div>
${dom.pago_estado === 'pagado' ? `
<div class="small mt-1 text-success">
${MODO_LABEL[dom.pago_modo] || (dom.pago_modo||'')}
${dom.pago_monto ? ` · $${Number(dom.pago_monto).toLocaleString('es-CO')} cobrado` : ''}
${dom.pago_notas ? ` · <em>${esc(dom.pago_notas)}</em>` : ''}
</div>` : ''}
${dom.pago_estado === 'pagado' ? (() => {
// Usar pagos_detalle (multi-método) si existen
const detalle = dom.pagos_detalle || [];
if (detalle.length) {
return `<div class="small mt-1 text-success">
${detalle.map(p =>
`<div>${MODO_LABEL[p.modo] || p.modo}: <strong>$${Number(p.monto).toLocaleString('es-CO')}</strong>${p.notas ? ` · <em>${esc(p.notas)}</em>` : ''}</div>`
).join('')}
</div>`;
}
// Fallback registro legacy (un solo método)
return `<div class="small mt-1 text-success">
${MODO_LABEL[dom.pago_modo] || (dom.pago_modo||'')}
${dom.pago_monto ? ` · $${Number(dom.pago_monto).toLocaleString('es-CO')} cobrado` : ''}
${dom.pago_notas ? ` · <em>${esc(dom.pago_notas)}</em>` : ''}
</div>`;
})() : ''}
</div>
</div>
</div>`;
+18 -2
View File
@@ -812,6 +812,22 @@ async function cargarReportePagos() {
const cliente = f.tipo_cliente === 'seguro' && f.seguro_nombre
? `Seguro: ${esc(f.seguro_nombre)}`
: esc(f.tipo_cliente || 'particular');
// Columna modo: usar parcialidades si existen, sino legacy
const detalle = f.pagos_detalle || [];
const modoHtml = pagoEst === 'pagado'
? (detalle.length
? detalle.map(p => `${esc(MODO_LABEL[p.modo] || p.modo)}: $${Number(p.monto).toLocaleString('es-CO')}`).join('<br>')
: esc(MODO_LABEL[f.pago_modo] || f.pago_modo || '—'))
: '—';
// Monto total recibido
const montoTotal = pagoEst === 'pagado'
? (detalle.length
? detalle.reduce((s, p) => s + parseFloat(p.monto || 0), 0)
: parseFloat(f.pago_monto || 0))
: 0;
return `<tr class="${pagoEst === 'pagado' ? '' : 'opacity-75'}">
<td class="ps-3 text-muted">${f.id}</td>
<td class="text-nowrap">${esc(f.fecha_programada?.slice(0,10) || '—')}</td>
@@ -823,9 +839,9 @@ async function cargarReportePagos() {
<td class="text-center">
<span class="badge bg-${PAGO_BADGE[pagoEst] || 'secondary'}">${esc(pagoEst)}</span>
</td>
<td class="text-center text-muted small">${esc(f.pago_modo) || '—'}</td>
<td class="text-start small" style="white-space:pre-line">${modoHtml}</td>
<td class="text-end fw-semibold ${pagoEst === 'pagado' ? 'text-success' : 'text-muted'}">
${pagoEst === 'pagado' ? fmtMoney(f.pago_monto) : '—'}
${pagoEst === 'pagado' ? fmtMoney(montoTotal) : '—'}
</td>
<td class="text-nowrap text-muted small">${f.pago_fecha ? esc(f.pago_fecha.slice(0,16)) : '—'}</td>
<td>
@@ -0,0 +1,34 @@
-- ============================================================
-- Migration: 20260410_lab_domicilio_pagos_parciales
-- Soporta múltiples métodos de pago por domicilio.
-- ============================================================
CREATE TABLE IF NOT EXISTS `lab_domicilio_pagos` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`domicilio_id` INT(11) NOT NULL COMMENT 'FK lab_domicilios.id',
`modo` ENUM('efectivo','transferencia','otro') NOT NULL DEFAULT 'efectivo'
COMMENT 'Método de pago',
`monto` DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT 'Monto recibido por este método',
`notas` VARCHAR(255) DEFAULT NULL COMMENT 'Referencia / observación del pago',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_domicilio` (`domicilio_id`),
CONSTRAINT `fk_ldp_domicilio`
FOREIGN KEY (`domicilio_id`) REFERENCES `lab_domicilios` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Pagos parciales por método de pago para cada domicilio';
-- Migrar registros existentes que ya tienen pago_modo + pago_monto registrados
INSERT INTO `lab_domicilio_pagos` (`domicilio_id`, `modo`, `monto`, `notas`, `created_at`)
SELECT id,
pago_modo,
pago_monto,
pago_notas,
COALESCE(pago_fecha, created_at)
FROM `lab_domicilios`
WHERE pago_estado = 'pagado'
AND pago_modo IS NOT NULL
AND pago_monto > 0
AND NOT EXISTS (
SELECT 1 FROM `lab_domicilio_pagos` ldp WHERE ldp.domicilio_id = `lab_domicilios`.id
);