feat(newwhats): endpoints de encaixe (analisar + mover) p/ adiantar consulta
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
// 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 —
|
// para o mesmo profissional atuando em clínicas diferentes). Cópia da lógica —
|
||||||
// se a regra do scoreodonto mudar, revisar aqui também.
|
// 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;
|
if (!dentistaId || !start) return null;
|
||||||
const { rows: dr } = await db.query('SELECT usuario_id, email FROM dentistas WHERE id = $1', [dentistaId]);
|
const { rows: dr } = await db.query('SELECT usuario_id, email FROM dentistas WHERE id = $1', [dentistaId]);
|
||||||
let ids = [dentistaId];
|
let ids = [dentistaId];
|
||||||
@@ -117,8 +117,9 @@ async function findConflict(db, dentistaId, start, end) {
|
|||||||
WHERE dentistaid = ANY($1)
|
WHERE dentistaid = ANY($1)
|
||||||
AND (status IS NULL OR lower(status) NOT IN ('cancelado','falta','remarcar'))
|
AND (status IS NULL OR lower(status) NOT IN ('cancelado','falta','remarcar'))
|
||||||
AND start_time < $3 AND COALESCE(end_time, start_time) > $2
|
AND start_time < $3 AND COALESCE(end_time, start_time) > $2
|
||||||
|
AND ($4::text IS NULL OR id <> $4)
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[ids, start, end || start]);
|
[ids, start, end || start, excludeId]);
|
||||||
return rows[0] || null;
|
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) ════════════════
|
// ════════ Cadastro de pacientes (Secretária super inteligente) ════════════════
|
||||||
const soDigitos = (v) => String(v || '').replace(/\D/g, '');
|
const soDigitos = (v) => String(v || '').replace(/\D/g, '');
|
||||||
function idadeDe(dob) {
|
function idadeDe(dob) {
|
||||||
|
|||||||
Reference in New Issue
Block a user