refactor(hours): FK user_id en tramites_hours y tramites_obs

Hasta ahora el vínculo "esta hora/observación es de X" era por string
(h.abogado = users.name). Se rompía cuando un Socio renombraba un
usuario en el Panel de Control (las entradas históricas conservaban
el nombre viejo y los filtros del Reporte de Horas no las hallaban).

- Migración 014: ADD COLUMN user_id INT NULL + FK ON DELETE SET NULL
  en ambas tablas, con backfill via JOIN users.name = abogado
- tramites/api.php: helper userIdByName() + add_obs/add_hours/
  update_hours ahora persisten user_id junto con el string abogado
- horas/api.php: filtro WHERE (user_id = :uid OR (user_id IS NULL
  AND abogado = :name)) — prioriza FK, cae al string solo para
  filas legacy sin user_id. by_user y by_user_tramite agrupan por
  COALESCE(u.name, h.abogado) para usar siempre el nombre actual.
This commit is contained in:
Soporte ZH Consultores
2026-06-04 22:33:41 -05:00
parent eff590af47
commit 0a8dc6fc46
3 changed files with 97 additions and 27 deletions
+37 -21
View File
@@ -26,23 +26,26 @@ try {
$to = clean_date($_GET['to'] ?? null); $to = clean_date($_GET['to'] ?? null);
$userParam = isset($_GET['user']) ? (int)$_GET['user'] : 0; $userParam = isset($_GET['user']) ? (int)$_GET['user'] : 0;
// Determinar por qué nombre filtrar: // Determinar por qué usuario filtrar:
// - Socio/admin: por user seleccionado, o todos si user=0 // - Socio/admin: por user seleccionado (id+nombre), o todos si user=0
// - Resto: forzado a sus propias horas (matched por su name) // - Resto: forzado a su propio user_id (con fallback a su name para legacy)
$filterName = null; $filterUserId = null;
$filterName = null;
if (Auth::isSocio()) { if (Auth::isSocio()) {
if ($userParam > 0) { if ($userParam > 0) {
$sel = $pdo->prepare('SELECT name FROM users WHERE id = :id'); $sel = $pdo->prepare('SELECT id, name FROM users WHERE id = :id');
$sel->execute([':id' => $userParam]); $sel->execute([':id' => $userParam]);
$name = $sel->fetchColumn(); $u = $sel->fetch();
if ($name !== false && $name !== '') $filterName = (string)$name; if ($u) { $filterUserId = (int)$u['id']; $filterName = (string)$u['name']; }
} }
} else { } else {
$filterName = Auth::userName(); $filterUserId = Auth::userId();
if ($filterName === '') { $filterName = Auth::userName();
if ($filterUserId <= 0 && $filterName === '') {
ok([ ok([
'entries' => [], 'entries' => [],
'by_user' => [], 'by_user' => [],
'by_user_tramite' => [],
'avail_users' => [], 'avail_users' => [],
'total_hrs' => 0, 'total_hrs' => 0,
'is_socio' => false, 'is_socio' => false,
@@ -52,9 +55,16 @@ try {
} }
} }
// Filtro por usuario: prefiere user_id (canónico); cae al name solo
// para filas legacy donde user_id quedó NULL.
$where = ['1=1']; $where = ['1=1'];
$params = []; $params = [];
if ($filterName !== null) { if ($filterUserId !== null && $filterUserId > 0) {
$where[] = '(h.user_id = :uid OR (h.user_id IS NULL AND h.abogado = :name))';
$params[':uid'] = $filterUserId;
$params[':name'] = (string)$filterName;
} elseif ($filterName !== null && $filterName !== '') {
// Fallback: solo nombre disponible (no debería ocurrir tras 014)
$where[] = 'h.abogado = :name'; $where[] = 'h.abogado = :name';
$params[':name'] = $filterName; $params[':name'] = $filterName;
} }
@@ -102,10 +112,12 @@ try {
if ($to) { $byWhere[] = 'h.fecha <= :to'; $byParams[':to'] = $to . ' 23:59:59'; } if ($to) { $byWhere[] = 'h.fecha <= :to'; $byParams[':to'] = $to . ' 23:59:59'; }
$whereByUser = implode(' AND ', $byWhere); $whereByUser = implode(' AND ', $byWhere);
$bySql = "SELECT h.abogado AS name, SUM(h.hrs) AS total, COUNT(*) AS entries $bySql = "SELECT COALESCE(u.name, h.abogado) AS name,
SUM(h.hrs) AS total, COUNT(*) AS entries
FROM tramites_hours h FROM tramites_hours h
LEFT JOIN users u ON u.id = h.user_id
WHERE $whereByUser WHERE $whereByUser
GROUP BY h.abogado GROUP BY COALESCE(u.name, h.abogado)
ORDER BY total DESC"; ORDER BY total DESC";
$byStmt = $pdo->prepare($bySql); $byStmt = $pdo->prepare($bySql);
$byStmt->execute($byParams); $byStmt->execute($byParams);
@@ -117,16 +129,18 @@ try {
unset($bu); unset($bu);
// Breakdown por usuario × trámite // Breakdown por usuario × trámite
$butSql = "SELECT h.abogado AS name, t.id AS tramite_id, t.codigo AS tramite_codigo, $butSql = "SELECT COALESCE(u.name, h.abogado) AS name,
t.id AS tramite_id, t.codigo AS tramite_codigo,
t.departamento AS departamento, t.departamento AS departamento,
COALESCE(NULLIF(t.nombre,''), NULLIF(t.tipo_tramite,''), NULLIF(t.tipo_proceso,''), t.codigo) AS tramite_title, COALESCE(NULLIF(t.nombre,''), NULLIF(t.tipo_tramite,''), NULLIF(t.tipo_proceso,''), t.codigo) AS tramite_title,
t.cliente AS tramite_cliente, t.cliente AS tramite_cliente,
SUM(h.hrs) AS total, COUNT(*) AS entries SUM(h.hrs) AS total, COUNT(*) AS entries
FROM tramites_hours h FROM tramites_hours h
JOIN tramites_legales t ON t.id = h.tramite_id JOIN tramites_legales t ON t.id = h.tramite_id
LEFT JOIN users u ON u.id = h.user_id
WHERE $whereByUser WHERE $whereByUser
GROUP BY h.abogado, t.id GROUP BY COALESCE(u.name, h.abogado), t.id
ORDER BY h.abogado ASC, total DESC"; ORDER BY name ASC, total DESC";
$butStmt = $pdo->prepare($butSql); $butStmt = $pdo->prepare($butSql);
$butStmt->execute($byParams); $butStmt->execute($byParams);
$byUserTramite = $butStmt->fetchAll(); $byUserTramite = $butStmt->fetchAll();
@@ -136,21 +150,23 @@ try {
$r['entries'] = (int)$r['entries']; $r['entries'] = (int)$r['entries'];
} }
unset($r); unset($r);
} elseif ($filterName !== null) { } elseif ($filterUserId !== null && $filterUserId > 0) {
// Abogado/usuario regular: su propio desglose por trámite // Abogado/usuario regular: su propio desglose por trámite (vía user_id + fallback name)
$butParams = [':name' => $filterName]; $butParams = [':uid' => $filterUserId, ':name' => (string)$filterName];
$butWhere = ['h.abogado = :name']; $butWhere = ['(h.user_id = :uid OR (h.user_id IS NULL AND h.abogado = :name))'];
if ($from) { $butWhere[] = 'h.fecha >= :from'; $butParams[':from'] = $from . ' 00:00:00'; } if ($from) { $butWhere[] = 'h.fecha >= :from'; $butParams[':from'] = $from . ' 00:00:00'; }
if ($to) { $butWhere[] = 'h.fecha <= :to'; $butParams[':to'] = $to . ' 23:59:59'; } if ($to) { $butWhere[] = 'h.fecha <= :to'; $butParams[':to'] = $to . ' 23:59:59'; }
$butSql = "SELECT h.abogado AS name, t.id AS tramite_id, t.codigo AS tramite_codigo, $butSql = "SELECT COALESCE(u.name, h.abogado) AS name,
t.id AS tramite_id, t.codigo AS tramite_codigo,
t.departamento AS departamento, t.departamento AS departamento,
COALESCE(NULLIF(t.nombre,''), NULLIF(t.tipo_tramite,''), NULLIF(t.tipo_proceso,''), t.codigo) AS tramite_title, COALESCE(NULLIF(t.nombre,''), NULLIF(t.tipo_tramite,''), NULLIF(t.tipo_proceso,''), t.codigo) AS tramite_title,
t.cliente AS tramite_cliente, t.cliente AS tramite_cliente,
SUM(h.hrs) AS total, COUNT(*) AS entries SUM(h.hrs) AS total, COUNT(*) AS entries
FROM tramites_hours h FROM tramites_hours h
JOIN tramites_legales t ON t.id = h.tramite_id JOIN tramites_legales t ON t.id = h.tramite_id
LEFT JOIN users u ON u.id = h.user_id
WHERE " . implode(' AND ', $butWhere) . " WHERE " . implode(' AND ', $butWhere) . "
GROUP BY h.abogado, t.id GROUP BY COALESCE(u.name, h.abogado), t.id
ORDER BY total DESC"; ORDER BY total DESC";
$butStmt = $pdo->prepare($butSql); $butStmt = $pdo->prepare($butSql);
$butStmt->execute($butParams); $butStmt->execute($butParams);
+18 -6
View File
@@ -128,6 +128,15 @@ function canView(PDO $pdo, int $tid): bool {
return $stmt->fetchColumn() !== false; return $stmt->fetchColumn() !== false;
} }
/** Resuelve el user_id (activo) a partir del nombre completo; null si no match. */
function userIdByName(PDO $pdo, string $name): ?int {
if ($name === '') return null;
$stmt = $pdo->prepare('SELECT id FROM users WHERE name = :n AND activo = 1 LIMIT 1');
$stmt->execute([':n' => $name]);
$id = $stmt->fetchColumn();
return $id === false ? null : (int)$id;
}
/** Reemplaza la lista de involucrados del trámite por la lista dada (array de user_id). */ /** Reemplaza la lista de involucrados del trámite por la lista dada (array de user_id). */
function syncInvolucrados(PDO $pdo, int $tid, array $userIds, string $addedBy): void { function syncInvolucrados(PDO $pdo, int $tid, array $userIds, string $addedBy): void {
$pdo->prepare('DELETE FROM tramites_involucrados WHERE tramite_id = :t')->execute([':t' => $tid]); $pdo->prepare('DELETE FROM tramites_involucrados WHERE tramite_id = :t')->execute([':t' => $tid]);
@@ -331,8 +340,9 @@ try {
if (!canView($pdo, $id)) err('No tienes acceso a este trámite', 403); if (!canView($pdo, $id)) err('No tienes acceso a este trámite', 403);
$pdo->prepare('INSERT INTO tramites_obs (tramite_id, texto, abogado, autor_email) VALUES (?,?,?,?)') $uidObs = userIdByName($pdo, $abog);
->execute([$id, $texto, $abog, $user['email']]); $pdo->prepare('INSERT INTO tramites_obs (tramite_id, texto, abogado, user_id, autor_email) VALUES (?,?,?,?,?)')
->execute([$id, $texto, $abog, $uidObs, $user['email']]);
$pdo->prepare('UPDATE tramites_legales SET actualizado_por = :u WHERE id = :id') $pdo->prepare('UPDATE tramites_legales SET actualizado_por = :u WHERE id = :id')
->execute([':u' => $user['email'], ':id' => $id]); ->execute([':u' => $user['email'], ':id' => $id]);
@@ -358,8 +368,9 @@ try {
if (!canView($pdo, $id)) err('No tienes acceso a este trámite', 403); if (!canView($pdo, $id)) err('No tienes acceso a este trámite', 403);
$pdo->prepare('INSERT INTO tramites_hours (tramite_id, abogado, hrs, descripcion, autor_email) VALUES (?,?,?,?,?)') $uidH = userIdByName($pdo, $abog);
->execute([$id, $abog, $hrs, $desc, $user['email']]); $pdo->prepare('INSERT INTO tramites_hours (tramite_id, abogado, user_id, hrs, descripcion, autor_email) VALUES (?,?,?,?,?,?)')
->execute([$id, $abog, $uidH, $hrs, $desc, $user['email']]);
$pdo->prepare('UPDATE tramites_legales SET actualizado_por = :u WHERE id = :id') $pdo->prepare('UPDATE tramites_legales SET actualizado_por = :u WHERE id = :id')
->execute([':u' => $user['email'], ':id' => $id]); ->execute([':u' => $user['email'], ':id' => $id]);
@@ -394,8 +405,9 @@ try {
$desc = trim((string)($b['descripcion'] ?? $row['descripcion'])); $desc = trim((string)($b['descripcion'] ?? $row['descripcion']));
if ($hrs <= 0) err('horas debe ser > 0'); if ($hrs <= 0) err('horas debe ser > 0');
$upd = $pdo->prepare('UPDATE tramites_hours SET abogado = :a, hrs = :h, descripcion = :d WHERE id = :id'); $uidU = userIdByName($pdo, $abog);
$upd->execute([':a' => $abog, ':h' => $hrs, ':d' => $desc, ':id' => $hourId]); $upd = $pdo->prepare('UPDATE tramites_hours SET abogado = :a, user_id = :uid, hrs = :h, descripcion = :d WHERE id = :id');
$upd->execute([':a' => $abog, ':uid' => $uidU, ':h' => $hrs, ':d' => $desc, ':id' => $hourId]);
$pdo->prepare('UPDATE tramites_legales SET actualizado_por = :u WHERE id = :id') $pdo->prepare('UPDATE tramites_legales SET actualizado_por = :u WHERE id = :id')
->execute([':u' => $user['email'], ':id' => $tramiteId]); ->execute([':u' => $user['email'], ':id' => $tramiteId]);
+42
View File
@@ -0,0 +1,42 @@
-- ============================================================
-- Donna — Schema 014: FK user_id en tramites_hours y tramites_obs
-- ============================================================
-- Hasta ahora el vínculo "esta hora/observación es de Fulanito"
-- se hacía por string (h.abogado = users.name). Eso rompe cuando:
-- - Hay variantes legacy (parcial vs completo) → ya arreglado en 013
-- - Un Socio renombra al usuario en el Panel de Control (las
-- entradas históricas conservan el nombre viejo y los filtros
-- no las encuentran al buscar por el nuevo nombre)
--
-- Esta migración agrega user_id INT NULL con FK a users(id) y
-- ON DELETE SET NULL (si borran al usuario, la entrada queda con
-- user_id NULL pero conserva el string `abogado` como historia).
-- El backfill solo asocia donde hay match exacto y único.
-- 1. tramites_hours
ALTER TABLE tramites_hours
ADD COLUMN IF NOT EXISTS user_id INT NULL AFTER abogado,
ADD INDEX IF NOT EXISTS idx_user_id (user_id);
UPDATE tramites_hours h
JOIN users u ON u.activo = 1 AND u.name = h.abogado
SET h.user_id = u.id
WHERE h.user_id IS NULL;
ALTER TABLE tramites_hours
ADD CONSTRAINT fk_hours_user
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;
-- 2. tramites_obs
ALTER TABLE tramites_obs
ADD COLUMN IF NOT EXISTS user_id INT NULL AFTER abogado,
ADD INDEX IF NOT EXISTS idx_user_id (user_id);
UPDATE tramites_obs o
JOIN users u ON u.activo = 1 AND u.name = o.abogado
SET o.user_id = u.id
WHERE o.user_id IS NULL;
ALTER TABLE tramites_obs
ADD CONSTRAINT fk_obs_user
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;