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.
This commit is contained in:
AgendaPro Dev
2026-07-26 15:59:16 -06:00
parent 9d78663cfe
commit 811d2a24b2
2 changed files with 370 additions and 0 deletions
+91
View File
@@ -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);
});
+279
View File
@@ -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<number, WorkingDay | null>; // 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<string>([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·(1loadBalance), 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<number, BusyWindow[]> {
const map = new Map<number, BusyWindow[]>();
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<number, BusyWindow[]>,
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<T>(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;
}
}