La reserva pública no ofrecía horarios en producción. La ventana laboral se
construía con `new Date(y, m, d, hh, mm)`, que resuelve el reloj de pared en la
tz del proceso. El Dockerfile no fijaba TZ y node:22-slim arranca en UTC,
mientras que la máquina de desarrollo está en America/Mexico_City: por eso solo
fallaba desplegado. Un negocio de 09:00-20:00 se publicaba como 09:00-20:00 UTC
(03:00-14:00 de México), y como el generador descarta lo anterior a ahora+30min,
a partir de la 1 PM la lista quedaba vacía.
Toda la API de scheduling.ts lleva ahora `tz` explícita y resuelve el reloj de
pared con wallToUtcDate/bizDateISO de time.ts, que ya existían para esto.
Arrastraba cinco defectos más en la misma ruta:
- getExistingBusy acotaba el día concatenando `${fecha}T00:00:00`. Como start_at
se guarda en UTC, una cita de las 19:00 de México vive en el día UTC siguiente
y quedaba fuera del rango: el guard anti doble-reserva no veía la tarde entera.
Ahora usa bizDayBoundsIsoFor.
- Un negocio recién sembrado nacía con working_hours y slug en NULL, o sea con
cero franjas agendables y /b/:slug en 404: el backfill vivía solo dentro de las
migraciones, que corren antes de que exista la fila. Los defaults se fijan en el
INSERT (server/lib/businessDefaults.ts) en los tres sitios que crean negocios, y
migrateV4ToV5 repara los ya rotos. El demo usa slug fijo `mi-negocio-demo`
porque es la URL ya publicada y el volumen se recrea en cada despliegue.
- El chip mostraba la hora formateada por el servidor y el resumen la del
navegador: dos horas distintas para el mismo slot. Ambas salen ahora del
instante resuelto en la tz del negocio.
- La separación mañana/tarde usaba /PM/i sobre un texto ya localizado, y es-MX
rinde "05:00 p.m." con puntos: nunca casaba, así que el grupo "Tarde"
desaparecía y toda la tarde se agrupaba bajo "Mañana".
- MonthCalendar comparaba canPrev contra el día 1 del mes visible en vez de
contra minDate, de modo que la flecha de mes anterior nunca se podía pulsar.
Las guardas de migración comparaban la versión como texto ("10" >= "2" es false),
lo que habría reejecutado migrateV1ToV2 y su DROP TABLE users al llegar a dos
dígitos; ahora comparan números.
Verificación: scheduling.test.ts fija TZ=UTC y usa negocios en America/Mexico_City
para que la tz del proceso y la del negocio nunca coincidan; el Dockerfile fija
ENV TZ=UTC por lo mismo. 43 unitarias + 33 e2e + 12 booking + 17 admin en verde
con el servidor en UTC y base recién sembrada; typecheck limpio. booking-e2e.mjs
busca el próximo día abierto en vez de asumir "mañana", que lo hacía fallar cada
viernes y sábado por calendario.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
362 lines
14 KiB
TypeScript
362 lines
14 KiB
TypeScript
// 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.
|
||
|
||
import { bizDateISO, bizDayBoundsIsoFor, isoDowFromDateStr, wallToUtcDate, DEFAULT_TZ } from "./time.ts";
|
||
|
||
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;
|
||
}
|
||
|
||
/** @deprecated Fecha natural en la tz del PROCESO. Para lógica de negocio usa
|
||
* `bizDateISO(instant, tz)` de time.ts — esta desplaza el día en un contenedor UTC. */
|
||
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 una fecha
|
||
* natural del negocio, y lo devuelve como instantes absolutos (ms UTC).
|
||
*
|
||
* `dateIso` es "YYYY-MM-DD" **en la tz del negocio** y `tz` su zona IANA. Ambos
|
||
* son obligatorios a propósito: la versión anterior recibía un `Date` y
|
||
* construía la ventana con `new Date(y, m, d, hh, mm)`, o sea en la tz del
|
||
* *proceso*. En dev (Windows, America/Mexico_City) coincidía; en el contenedor
|
||
* de producción (node:22-slim = UTC) desplazaba toda la jornada 6 horas, de modo
|
||
* que un negocio de 09:00–20:00 se publicaba como 03:00–14:00 hora de México.
|
||
*/
|
||
export function getWorkingHoursForDate(
|
||
empWh: WorkingHoursMap | null,
|
||
bizWh: WorkingHoursMap | null,
|
||
dateIso: string,
|
||
tz: string
|
||
): { startMs: number; endMs: number } | null {
|
||
const zone = tz || DEFAULT_TZ;
|
||
const dow = isoDowFromDateStr(dateIso);
|
||
const day = empWh?.[dow] ?? bizWh?.[dow] ?? null;
|
||
if (!day) return null;
|
||
const [y, mo, d] = dateIso.split("-").map(Number);
|
||
const [sh, sm] = day.start.split(":").map(Number);
|
||
const [eh, em] = day.end.split(":").map(Number);
|
||
const startMs = wallToUtcDate(zone, y, mo, d, sh, sm, 0).getTime();
|
||
const endMs = wallToUtcDate(zone, y, mo, d, eh, em, 0).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 = 40·specialtyMatch + 20·efficiency + 40·(1−loadBalance), todo en [0,1]. Rango 0..100.
|
||
* Pesos 40/20/40: specialty sigue mandando en matches reales, pero loadBalance (40) ahora
|
||
* puede superar la ventaja estática de efficiency (20) → el especialista libre/menos cargado
|
||
* gana con más frecuencia (equidad, anti-burnout). */
|
||
export function scoreCandidate(s: ScoreInput): number {
|
||
const sm = clamp01(s.specialtyMatch);
|
||
const eff = clamp01(s.efficiency);
|
||
const inv = clamp01(1 - clamp01(s.loadBalance));
|
||
return 40 * sm + 20 * eff + 40 * 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),
|
||
}));
|
||
}
|
||
|
||
/** Estados que ocupan la ventana de trabajo del empleado para load/availability.
|
||
* NOTA: `no_show` NO está — la silla quedó vacía, así que no cuenta contra la carga
|
||
* del empleado (además ayuda a los sobre-agendados a recuperarse). */
|
||
export const BUSY_STATUSES = ["scheduled", "completed"] as const;
|
||
|
||
/**
|
||
* Citas que ocupan a cada empleado durante el día natural `dateIso` **del negocio**.
|
||
*
|
||
* Los límites se calculan con `bizDayBoundsIsoFor`, no concatenando
|
||
* `${dateIso}T00:00:00`: `start_at` se almacena en UTC, así que una cita de las
|
||
* 19:00 de México vive en el día UTC *siguiente* (01:00Z) y la versión anterior
|
||
* la dejaba fuera del rango — el guard anti doble-reserva no veía ninguna cita
|
||
* de la tarde.
|
||
*/
|
||
export function getExistingBusy(
|
||
db: DatabaseSync,
|
||
employeeIds: number[],
|
||
dateIso: string,
|
||
tz: string
|
||
): Map<number, BusyWindow[]> {
|
||
const map = new Map<number, BusyWindow[]>();
|
||
if (employeeIds.length === 0) return map;
|
||
const { start: startOfDay, end: endOfDay } = bizDayBoundsIsoFor(tz || DEFAULT_TZ, dateIso);
|
||
const statusPh = BUSY_STATUSES.map(() => "?").join(",");
|
||
const rows = db
|
||
.prepare(
|
||
`SELECT employee_id, start_at, end_at FROM appointments
|
||
WHERE employee_id IN (${employeeIds.map(() => "?").join(",")})
|
||
AND status IN (${statusPh}) AND start_at >= ? AND start_at <= ?`
|
||
)
|
||
.all(...employeeIds, ...BUSY_STATUSES, 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,
|
||
tz: string
|
||
): boolean {
|
||
const zone = tz || DEFAULT_TZ;
|
||
const dateIso = bizDateISO(new Date(startMs), zone);
|
||
const busy = getExistingBusy(db, [employeeId], dateIso, zone).get(employeeId) ?? [];
|
||
return !hasConflict(busy, startMs, endMs);
|
||
}
|
||
|
||
interface RankedCandidate { info: CandidateInfo; score: number; loadBalance: 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, loadBalance, reasons };
|
||
}
|
||
|
||
/**
|
||
* Hash determinista (xmur3) → uint32. Sin deps. Misma entrada ⇒ misma salida en cualquier
|
||
* plataforma, para que el desempate por hash sea reproducible por input.
|
||
* Úselo para repartir empates sin sesgo por id/seniority. */
|
||
export function hashStr(str: string): number {
|
||
let h = 1779033703 ^ str.length;
|
||
for (let i = 0; i < str.length; i++) {
|
||
h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
|
||
h = (h << 13) | (h >>> 19);
|
||
}
|
||
h = Math.imul(h ^ (h >>> 16), 2246822507);
|
||
h = Math.imul(h ^ (h >>> 13), 3266489909);
|
||
return (h ^ (h >>> 16)) >>> 0;
|
||
}
|
||
|
||
/** Epsilon para considerar dos loadBalance iguales (ratio de ms → posible ruido float). */
|
||
const LOAD_EPSILON = 1e-9;
|
||
|
||
/**
|
||
* ¿`a` desplaza al actual `best`? Orden de desempate (anti-burnout, sin sesgo por id):
|
||
* 1) mayor score
|
||
* 2) (cerca de empate) menor loadBalance — gana el menos cargado
|
||
* 3) hash determinista de `${dateIso}|${startMs}|${empId}` — mismo slot ⇒ misma decisión,
|
||
* pero slots distintos reparten entre empleados (no siempre cae en la misma persona).
|
||
*/
|
||
function beats(a: RankedCandidate, best: RankedCandidate, dateIso: string, startMs: number): boolean {
|
||
if (a.score !== best.score) return a.score > best.score;
|
||
if (Math.abs(a.loadBalance - best.loadBalance) > LOAD_EPSILON) return a.loadBalance < best.loadBalance;
|
||
return hashStr(`${dateIso}|${startMs}|${a.info.id}`) < hashStr(`${dateIso}|${startMs}|${best.info.id}`);
|
||
}
|
||
|
||
/** 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,
|
||
tz: string
|
||
): { id: number; name: string } | null {
|
||
const zone = tz || DEFAULT_TZ;
|
||
const dateIso = bizDateISO(new Date(startMs), zone);
|
||
let best: RankedCandidate | null = null;
|
||
for (const c of candidates) {
|
||
const wh = getWorkingHoursForDate(c.empWh, bizWh, dateIso, zone);
|
||
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 || beats(ranked, best, dateIso, startMs)) {
|
||
best = ranked;
|
||
}
|
||
}
|
||
return best ? { id: best.info.id, name: best.info.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;
|
||
/** Zona IANA del negocio. Sin ella la ventana laboral se ancla a la tz del proceso. */
|
||
tz: string;
|
||
}
|
||
|
||
/** 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 zone = ctx.tz || DEFAULT_TZ;
|
||
const dateIso = bizDateISO(new Date(ctx.startMs), zone);
|
||
const busyMap = getExistingBusy(db, candidates.map((c) => c.id), dateIso, zone);
|
||
let best: RankedCandidate | null = null;
|
||
for (const c of candidates) {
|
||
const wh = getWorkingHoursForDate(c.empWh, ctx.bizWh, dateIso, zone);
|
||
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 || beats(ranked, best, dateIso, ctx.startMs)) {
|
||
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;
|
||
}
|
||
}
|