From 26f39a60f39ee2a94a6e18ac2ac5614e65620fbc Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Mon, 6 Jul 2026 17:08:49 +0200 Subject: [PATCH] feat(newwhats): endpoints de encaixe (analisar + mover) p/ adiantar consulta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /encaixe/analisar: ocupação do horário desejado (quem está nele) + horário livre mais cedo (flex-inclusivo) p/ oferecer/encaixar. - POST /encaixe/mover: remarca um agendamento (o A aceitou adiantar), com checagem de conflito no destino (findConflict ganha excludeId p/ ignorar o próprio). Co-Authored-By: Claude Opus 4.8 --- backend/newwhats/agenda-bridge.js | 115 +++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 2 deletions(-) diff --git a/backend/newwhats/agenda-bridge.js b/backend/newwhats/agenda-bridge.js index 5f91b02..3ffa389 100644 --- a/backend/newwhats/agenda-bridge.js +++ b/backend/newwhats/agenda-bridge.js @@ -99,7 +99,7 @@ async function janelasClinicaNoDia(pool, clinicaId, dateStr, dow, clinicHours) { // Overbooking: MESMA regra do server.js (agrupa o dentista por usuario_id/email, // para o mesmo profissional atuando em clínicas diferentes). Cópia da lógica — // se a regra do scoreodonto mudar, revisar aqui também. -async function findConflict(db, dentistaId, start, end) { +async function findConflict(db, dentistaId, start, end, excludeId = null) { if (!dentistaId || !start) return null; const { rows: dr } = await db.query('SELECT usuario_id, email FROM dentistas WHERE id = $1', [dentistaId]); let ids = [dentistaId]; @@ -117,8 +117,9 @@ async function findConflict(db, dentistaId, start, end) { WHERE dentistaid = ANY($1) AND (status IS NULL OR lower(status) NOT IN ('cancelado','falta','remarcar')) AND start_time < $3 AND COALESCE(end_time, start_time) > $2 + AND ($4::text IS NULL OR id <> $4) LIMIT 1`, - [ids, start, end || start]); + [ids, start, end || start, excludeId]); return rows[0] || null; } @@ -473,6 +474,116 @@ export function createAgendaBridge(pool) { } }); + // ── GET /encaixe/analisar ───────────────────────────────────────────────────── + // Fluxo de "adiantar consulta": dado um dia (e horário desejado), diz se o horário + // está OCUPADO (por quem) e qual o horário LIVRE mais cedo que dá para oferecer/ + // encaixar — incluindo a janela FLEX (zona cinza), pois na contenção a Secretária + // pode usá-la. Query: clinica_id, dentista_id?, data (YYYY-MM-DD), hora? (HH:MM). + router.get('/encaixe/analisar', async (req, res) => { + const clinicaId = String(req.query.clinica_id || ''); + const data = String(req.query.data || ''); + if (!clinicaId || !data) return res.status(400).json({ error: 'clinica_id e data obrigatórios' }); + const dentistaId = req.query.dentista_id ? String(req.query.dentista_id) : null; + const horaDesejada = req.query.hora ? String(req.query.hora).slice(0, 5) : null; + try { + const day = new Date(`${data}T00:00:00`); + const dow = day.getDay(); + const dParams = [clinicaId]; let dWhere = 'clinica_id = $1 AND ativo IS DISTINCT FROM 0'; + if (dentistaId) { dParams.push(dentistaId); dWhere += ` AND id = $${dParams.length}`; } + const { rows: dentistas } = await pool.query(`SELECT id, nome FROM dentistas WHERE ${dWhere}`, dParams); + if (!dentistas.length) return res.json({ mais_cedo: null, contestado: null }); + + const clinicHours = await carregarHorariosClinica(pool, clinicaId); + const { janelas: clinicWins, fechado } = await janelasClinicaNoDia(pool, clinicaId, data, dow, clinicHours); + if (fechado || !clinicWins.length) return res.json({ mais_cedo: null, contestado: null, motivo: 'fechado' }); + + // Jornada FLEX-inclusiva (usa hora_*_flex quando houver — a zona cinza entra no encaixe). + const { rows: horarios } = await pool.query( + `SELECT dentista_id, + COALESCE(hora_inicio_flex, hora_inicio) AS hora_inicio, + COALESCE(hora_fim_flex, hora_fim) AS hora_fim + FROM dentistas_horarios WHERE clinica_id = $1 AND ativo = 1 AND dia_semana = $2`, [clinicaId, dow]); + const hoursBy = new Map(); + for (const h of horarios) { const a = hoursBy.get(h.dentista_id) || []; a.push([hm(h.hora_inicio), hm(h.hora_fim)]); hoursBy.set(h.dentista_id, a); } + + const start0 = new Date(day); start0.setHours(0, 0, 0, 0); + const end0 = new Date(start0); end0.setDate(end0.getDate() + 1); + const { rows: busy } = await pool.query( + `SELECT id, dentistaid, pacientenome, pacientecelular, start_time, end_time FROM agendamentos + WHERE clinica_id=$1 AND start_time>=$2 AND start_time<$3 + AND (status IS NULL OR lower(status) NOT IN ('cancelado','falta','remarcar'))`, + [clinicaId, start0.toISOString(), end0.toISOString()]); + const busyBy = new Map(); + for (const bz of busy) { const a = busyBy.get(bz.dentistaid) || []; a.push(bz); busyBy.set(bz.dentistaid, a); } + + // Quem ocupa o horário desejado (direto, sem depender do grid). + let contestado = null; + if (horaDesejada) { + const alvo = new Date(`${data}T${horaDesejada}:00`); + const occ = busy.find((b) => new Date(b.start_time) <= alvo && new Date(b.end_time || b.start_time) > alvo) + || busy.find((b) => fmt(new Date(b.start_time)).slice(11, 16) === horaDesejada); + if (occ) { + const dn = dentistas.find((d) => d.id === occ.dentistaid); + contestado = { agendamento_id: occ.id, dentista_id: occ.dentistaid, dentista_nome: dn?.nome || null, + paciente_nome: occ.pacientenome || null, paciente_celular: occ.pacientecelular || null, + start: fmt(new Date(occ.start_time)), hora: fmt(new Date(occ.start_time)).slice(11, 16) }; + } + } + + // Slots LIVRES do dia (flex-inclusivo). + const now = new Date(); const dur = SLOT_MIN; const livres = []; + for (const dent of dentistas) { + const dw = (hoursBy.get(dent.id) && hoursBy.get(dent.id).length) ? hoursBy.get(dent.id) : clinicWins; + const windows = interseccao(clinicWins, dw); + const bs = busyBy.get(dent.id) || []; + for (const [ini, fim] of windows) { + const [hi, mi] = String(ini).split(':').map(Number); + const [hf, mf] = String(fim).split(':').map(Number); + let t = new Date(day); t.setHours(hi, mi || 0, 0, 0); + const limit = new Date(day); limit.setHours(hf, mf || 0, 0, 0); + while (t.getTime() + dur * 60000 <= limit.getTime()) { + const s = new Date(t), e = new Date(t.getTime() + dur * 60000); t = e; + if (s <= now) continue; + if (bs.some((b) => new Date(b.start_time) < e && new Date(b.end_time || b.start_time) > s)) continue; + livres.push({ dentista_id: dent.id, dentista_nome: dent.nome, start: fmt(s), end: fmt(e), hora: fmt(s).slice(11, 16) }); + } + } + } + livres.sort((a, b) => a.start.localeCompare(b.start)); + // mais_cedo = livre imediatamente ANTES do desejado (o mais perto por baixo); senão o 1º livre. + let mais_cedo = null; + if (horaDesejada) { + const antes = livres.filter((s) => s.hora < horaDesejada); + mais_cedo = antes.length ? antes[antes.length - 1] : (livres[0] || null); + } else { + mais_cedo = livres[0] || null; + } + res.json({ mais_cedo, contestado, total_livres: livres.length }); + } catch (e) { res.status(500).json({ error: e.message }); } + }); + + // ── POST /encaixe/mover ─────────────────────────────────────────────────────── + // Move um agendamento existente para um novo horário (o A aceitou adiantar). + // Body: { clinica_id, agendamento_id, start, end? }. + router.post('/encaixe/mover', async (req, res) => { + const b = req.body || {}; + if (!b.clinica_id || !b.agendamento_id || !b.start) return res.status(400).json({ error: 'clinica_id, agendamento_id e start obrigatórios' }); + const end = b.end || new Date(new Date(b.start).getTime() + SLOT_MIN * 60000).toISOString(); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const { rows } = await client.query('SELECT id, dentistaid FROM agendamentos WHERE id=$1 AND clinica_id=$2 FOR UPDATE', [b.agendamento_id, b.clinica_id]); + const ag = rows[0]; + if (!ag) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Agendamento não encontrado.' }); } + const conflict = await findConflict(client, ag.dentistaid, b.start, end, b.agendamento_id); + if (conflict) { await client.query('ROLLBACK'); return res.status(409).json({ error: 'Horário de destino já ocupado.' }); } + await client.query('UPDATE agendamentos SET start_time=$1, end_time=$2, updated_at=NOW() WHERE id=$3', [b.start, end, b.agendamento_id]); + await client.query('COMMIT'); + res.json({ ok: true }); + } catch (e) { try { await client.query('ROLLBACK'); } catch { /* ignore */ } res.status(500).json({ error: e.message }); } + finally { client.release(); } + }); + // ════════ Cadastro de pacientes (Secretária super inteligente) ════════════════ const soDigitos = (v) => String(v || '').replace(/\D/g, ''); function idadeDe(dob) {