// server/lib/scheduling.ts // Disponibilidad + scoring de especialistas. Las funciones puras de aquí // se unit-testean en scheduling.test.ts; las que tocan la DB se añaden más abajo. export interface WorkingDay { start: string; end: string; } // "HH:mm" export type WorkingHoursMap = Record; // 1=Lun … 7=Dom export interface BusyWindow { startMs: number; endMs: number; } export interface ScoreInput { specialtyMatch: number; // 0..1 efficiency: number; // 0..1 loadBalance: number; // 0..1 (fracción de jornada ocupada) } export function clamp01(n: number): number { return Math.max(0, Math.min(1, n)); } export function parseWorkingHours(json: string | null | undefined): WorkingHoursMap | null { if (!json) return null; try { const obj = JSON.parse(json); if (typeof obj !== "object" || obj === null) return null; const out: WorkingHoursMap = {}; for (let d = 1; d <= 7; d++) { const v = obj[String(d)]; if (v && typeof v.start === "string" && typeof v.end === "string") out[d] = { start: v.start, end: v.end }; else out[d] = null; } return out; } catch { return null; } } /** JS getDay(): 0=Dom..6=Sáb → ISO 1=Lun..7=Dom. */ export function isoDayOfWeek(date: Date): number { const j = date.getDay(); return j === 0 ? 7 : j; } export function isoDateStr(d: Date): string { const pad = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; } /** Resuelve el horario (empleado tiene prioridad; si no, negocio) para esa fecha. Devuelve ms o null si es día cerrado. */ export function getWorkingHoursForDate( empWh: WorkingHoursMap | null, bizWh: WorkingHoursMap | null, date: Date ): { startMs: number; endMs: number } | null { const dow = isoDayOfWeek(date); const day = empWh?.[dow] ?? bizWh?.[dow] ?? null; if (!day) return null; const [sh, sm] = day.start.split(":").map(Number); const [eh, em] = day.end.split(":").map(Number); const startMs = new Date(date.getFullYear(), date.getMonth(), date.getDate(), sh, sm).getTime(); const endMs = new Date(date.getFullYear(), date.getMonth(), date.getDate(), eh, em).getTime(); if (!isFinite(startMs) || !isFinite(endMs) || endMs <= startMs) return null; return { startMs, endMs }; } export function normalizeText(s: string): string { return (s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim(); } export function tokens(s: string): string[] { return normalizeText(s).split(/[^a-z0-9]+/).filter(Boolean); } /** * 1.0 si una especialidad coincide con la categoría o un token del nombre del servicio. * 0.5 si no hay etiquetas o no coincide (el caller ya garantiza que ofrece el servicio). */ export function specialtyMatch(specialties: string[], service: { name: string; category: string }): number { if (!specialties || specialties.length === 0) return 0.5; const catN = normalizeText(service.category); const nameTokens = tokens(service.name); const hay = new Set([catN, ...nameTokens]); for (const sp of specialties) { const spn = normalizeText(sp); if (!spn) continue; if (hay.has(spn)) return 1; if (catN && (catN.includes(spn) || spn.includes(catN))) return 1; for (const t of nameTokens) { if (t.length >= 4 && (t.includes(spn) || spn.includes(t))) return 1; } } return 0.5; } export function overlaps(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean { return aStart < bEnd && aEnd > bStart; } export function hasConflict(existing: BusyWindow[], startMs: number, endMs: number): boolean { return existing.some((b) => overlaps(startMs, endMs, b.startMs, b.endMs)); } /** score = 50·specialtyMatch + 30·efficiency + 20·(1−loadBalance), todo en [0,1]. Rango 0..100. */ export function scoreCandidate(s: ScoreInput): number { const sm = clamp01(s.specialtyMatch); const eff = clamp01(s.efficiency); const inv = clamp01(1 - clamp01(s.loadBalance)); return 50 * sm + 30 * eff + 20 * inv; } import type { DatabaseSync } from "node:sqlite"; export interface CandidateInfo { id: number; name: string; color: string; role: string; specialties: string[]; efficiency_score: number; empWh: WorkingHoursMap | null; } function safeArr(json: unknown): string[] { if (typeof json !== "string" || !json) return []; try { const a = JSON.parse(json); return Array.isArray(a) ? a.map(String) : []; } catch { return []; } } export function getCandidates(db: DatabaseSync, businessId: number, serviceId: number): CandidateInfo[] { const rows = db .prepare( `SELECT e.id, e.name, e.color, e.role, e.specialties, e.working_hours wh, e.efficiency_score FROM employees e JOIN employee_services es ON es.employee_id = e.id WHERE e.business_id = ? AND es.service_id = ? AND e.active = 1` ) .all(businessId, serviceId) as any[]; return rows.map((r) => ({ id: r.id, name: r.name, color: r.color, role: r.role, specialties: safeArr(r.specialties), efficiency_score: typeof r.efficiency_score === "number" ? r.efficiency_score : 50, empWh: parseWorkingHours(r.wh), })); } export function getExistingBusy(db: DatabaseSync, employeeIds: number[], dateIso: string): Map { const map = new Map(); if (employeeIds.length === 0) return map; const startOfDay = `${dateIso}T00:00:00`; const endOfDay = `${dateIso}T23:59:59`; const rows = db .prepare( `SELECT employee_id, start_at, end_at FROM appointments WHERE employee_id IN (${employeeIds.map(() => "?").join(",")}) AND status IN ('scheduled','completed') AND start_at >= ? AND start_at <= ?` ) .all(...employeeIds, startOfDay, endOfDay) as any[]; for (const r of rows) { const arr = map.get(r.employee_id) ?? []; arr.push({ startMs: new Date(r.start_at).getTime(), endMs: new Date(r.end_at).getTime() }); map.set(r.employee_id, arr); } return map; } export function isAvailable(db: DatabaseSync, employeeId: number, startMs: number, endMs: number): boolean { const dateIso = isoDateStr(new Date(startMs)); const busy = getExistingBusy(db, [employeeId], dateIso).get(employeeId) ?? []; return !hasConflict(busy, startMs, endMs); } interface RankedCandidate { info: CandidateInfo; score: number; reasons: string[] } function rankOne( c: CandidateInfo, busy: BusyWindow[], wh: { startMs: number; endMs: number }, service: { name: string; category: string } ): RankedCandidate | null { const workingMs = Math.max(1, wh.endMs - wh.startMs); let occupiedMs = 0; for (const b of busy) { occupiedMs += Math.min(b.endMs, wh.endMs) - Math.max(b.startMs, wh.startMs); if (occupiedMs < 0) occupiedMs = 0; } const loadBalance = clamp01(occupiedMs / workingMs); const sm = specialtyMatch(c.specialties, service); const score = scoreCandidate({ specialtyMatch: sm, efficiency: c.efficiency_score / 100, loadBalance }); const reasons: string[] = []; if (sm >= 1) reasons.push("Especialidad coincidente"); if (c.efficiency_score >= 75) reasons.push("Alta eficiencia"); if (loadBalance <= 0.25) reasons.push("Buena disponibilidad"); return { info: c, score, reasons }; } /** Usado por el endpoint de slots: devuelve el mejor candidato libre para un slot concreto. */ export function pickBestSlotEmployee( candidates: CandidateInfo[], busyMap: Map, bizWh: WorkingHoursMap | null, service: { name: string; category: string }, startMs: number, endMs: number ): { id: number; name: string } | null { const date = new Date(startMs); let best: { id: number; name: string; score: number } | null = null; for (const c of candidates) { const wh = getWorkingHoursForDate(c.empWh, bizWh, date); if (!wh) continue; if (startMs < wh.startMs || endMs > wh.endMs) continue; if (hasConflict(busyMap.get(c.id) ?? [], startMs, endMs)) continue; const ranked = rankOne(c, busyMap.get(c.id) ?? [], wh, service); if (!ranked) continue; if (!best || ranked.score > best.score || (ranked.score === best.score && c.id < best.id)) { best = { id: c.id, name: c.name, score: ranked.score }; } } return best ? { id: best.id, name: best.name } : null; } export interface AutoAssignResult { employeeId: number; score: number; reasons: string[]; } export interface AutoAssignCtx { businessId: number; serviceId: number; serviceName: string; serviceCategory: string; startMs: number; endMs: number; bizWh: WorkingHoursMap | null; } /** Asigna el mejor especialista disponible (hard constraints + score). Llamar dentro de transacción. */ export function autoAssign(db: DatabaseSync, ctx: AutoAssignCtx): AutoAssignResult | null { const candidates = getCandidates(db, ctx.businessId, ctx.serviceId); if (candidates.length === 0) return null; const dateIso = isoDateStr(new Date(ctx.startMs)); const busyMap = getExistingBusy(db, candidates.map((c) => c.id), dateIso); let best: RankedCandidate | null = null; for (const c of candidates) { const wh = getWorkingHoursForDate(c.empWh, ctx.bizWh, new Date(ctx.startMs)); if (!wh) continue; if (ctx.startMs < wh.startMs || ctx.endMs > wh.endMs) continue; if (hasConflict(busyMap.get(c.id) ?? [], ctx.startMs, ctx.endMs)) continue; const ranked = rankOne(c, busyMap.get(c.id) ?? [], wh, { name: ctx.serviceName, category: ctx.serviceCategory }); if (!ranked) continue; if (!best || ranked.score > best.score || (ranked.score === best.score && c.id < best.info.id)) { best = ranked; } } if (!best) return null; return { employeeId: best.info.id, score: best.score, reasons: best.reasons.length ? best.reasons : ["Disponible"], }; } /** Ejecuta fn dentro de una transacción BEGIN IMMEDIATE…COMMIT; ROLLBACK en error. */ export function runInTransaction(db: DatabaseSync, fn: () => T): T { db.exec("BEGIN IMMEDIATE"); try { const r = fn(); db.exec("COMMIT"); return r; } catch (err) { try { db.exec("ROLLBACK"); } catch { /* ignore */ } throw err; } }