From 811d2a24b26dccd3d1b49c209409ea81ead7c1ec Mon Sep 17 00:00:00 2001 From: AgendaPro Dev Date: Sun, 26 Jul 2026 15:59:16 -0600 Subject: [PATCH] feat(scheduling): specialist scoring + availability module with unit tests Pure helpers (parseWorkingHours, getWorkingHoursForDate, specialtyMatch, overlaps, scoreCandidate) + DB-backed functions (getCandidates, isAvailable, pickBestSlotEmployee, autoAssign, runInTransaction). Hard constraint: no overlap with existing (scheduled,completed) appts + within working-hours window. Score = 50*specialty + 30*efficiency + 20*(1-load). 15 node:test unit tests. --- server/lib/scheduling.test.ts | 91 +++++++++++ server/lib/scheduling.ts | 279 ++++++++++++++++++++++++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 server/lib/scheduling.test.ts create mode 100644 server/lib/scheduling.ts diff --git a/server/lib/scheduling.test.ts b/server/lib/scheduling.test.ts new file mode 100644 index 0000000..a80b91d --- /dev/null +++ b/server/lib/scheduling.test.ts @@ -0,0 +1,91 @@ +// server/lib/scheduling.test.ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + parseWorkingHours, isoDayOfWeek, getWorkingHoursForDate, + normalizeText, tokens, specialtyMatch, overlaps, hasConflict, scoreCandidate, +} from "./scheduling.ts"; + +test("parseWorkingHours: json válido → mapa 1..7", () => { + const m = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" }, 6: null, 7: null })); + assert.equal(m?.[1]?.start, "09:00"); + assert.equal(m?.[6], null); + assert.equal(m?.[7], null); + assert.equal(m?.[2], null); // ausente → null +}); + +test("parseWorkingHours: null/invalid → null", () => { + assert.equal(parseWorkingHours(null), null); + assert.equal(parseWorkingHours("no-json"), null); +}); + +test("isoDayOfWeek: lunes=1, domingo=7", () => { + assert.equal(isoDayOfWeek(new Date("2026-07-27T12:00:00")), 1); // lunes + assert.equal(isoDayOfWeek(new Date("2026-07-26T12:00:00")), 7); // domingo +}); + +test("getWorkingHoursForDate: empleado tiene prioridad sobre negocio", () => { + const emp = parseWorkingHours(JSON.stringify({ 1: { start: "10:00", end: "14:00" } })); + const biz = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" } })); + const r = getWorkingHoursForDate(emp, biz, new Date("2026-07-27T12:00:00")); // lunes + assert.equal(r?.startMs, new Date(2026, 6, 27, 10, 0).getTime()); +}); + +test("getWorkingHoursForDate: día cerrado → null", () => { + const biz = parseWorkingHours(JSON.stringify({ 6: null, 7: null })); // fin de semana cerrado + assert.equal(getWorkingHoursForDate(null, biz, new Date("2026-07-25T12:00:00")), null); // sábado +}); + +test("normalizeText/tokens: quita acentos y lowercase", () => { + assert.equal(normalizeText("Coloración"), "coloracion"); + assert.deepEqual(tokens("Corte de cabello"), ["corte", "de", "cabello"]); +}); + +test("specialtyMatch: coincidencia exacta de etiqueta → 1", () => { + assert.equal(specialtyMatch(["corte"], { name: "Corte de cabello", category: "Cabello" }), 1); +}); + +test("specialtyMatch: etiqueta con acento/case → 1", () => { + assert.equal(specialtyMatch(["Coloración"], { name: "Tinte", category: "Coloración" }), 1); +}); + +test("specialtyMatch: sin etiquetas → 0.5", () => { + assert.equal(specialtyMatch([], { name: "Corte", category: "Cabello" }), 0.5); +}); + +test("specialtyMatch: etiqueta no relacionada → 0.5", () => { + assert.equal(specialtyMatch(["barba"], { name: "Manicura", category: "Uñas" }), 0.5); +}); + +test("overlaps: bordes inclusivos de no-traslape", () => { + // una cita termina exactamente cuando empieza otra → NO traslape + assert.equal(overlaps(100, 200, 200, 300), false); + // traslape real + assert.equal(overlaps(100, 250, 200, 300), true); + assert.equal(overlaps(200, 300, 100, 250), true); +}); + +test("hasConflict: lista vacía → false", () => { + assert.equal(hasConflict([], 100, 200), false); +}); + +test("scoreCandidate: pesos 50/30/20", () => { + // todo al máximo → 100 + assert.equal(scoreCandidate({ specialtyMatch: 1, efficiency: 1, loadBalance: 0 }), 100); + // todo al mínimo → 0 + assert.equal(scoreCandidate({ specialtyMatch: 0, efficiency: 0, loadBalance: 1 }), 0); + // sólo specialty (0.5) → 25 + assert.equal(scoreCandidate({ specialtyMatch: 0.5, efficiency: 0, loadBalance: 1 }), 25); +}); + +test("scoreCandidate: mayor efficiency → mayor score", () => { + const low = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.2, loadBalance: 0.5 }); + const high = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.9, loadBalance: 0.5 }); + assert.ok(high > low); +}); + +test("scoreCandidate: menor load (más disponible) → mayor score", () => { + const busy = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.5, loadBalance: 0.9 }); + const free = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.5, loadBalance: 0.1 }); + assert.ok(free > busy); +}); diff --git a/server/lib/scheduling.ts b/server/lib/scheduling.ts new file mode 100644 index 0000000..342aca8 --- /dev/null +++ b/server/lib/scheduling.ts @@ -0,0 +1,279 @@ +// 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; + } +}