Files
AgendaPro/server/lib/scheduling.ts
T
AgendaPro Dev 811d2a24b2 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.
2026-07-26 15:59:16 -06:00

280 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}
}