fix: anclar la agenda a la zona horaria del negocio, no a la del proceso
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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0069d23744
commit
f48a9ac3bf
@@ -0,0 +1,49 @@
|
||||
// server/lib/businessDefaults.ts
|
||||
//
|
||||
// Valores por defecto de un negocio recién creado. Existen porque el backfill de
|
||||
// `slug` y `working_hours` vivía SOLO dentro de las migraciones (migrateV2ToV3 y
|
||||
// migrateV3ToV4), y las migraciones corren al importar db.ts, es decir **antes** de
|
||||
// que `ensureSeed()` inserte ningún negocio. Resultado en una instalación nueva:
|
||||
//
|
||||
// • `working_hours = NULL` → `getWorkingHoursForDate` devuelve null para todos los
|
||||
// días → el endpoint de slots responde `[]` en cualquier fecha y no se puede
|
||||
// reservar nada.
|
||||
// • `slug = NULL` → `publicBusiness()` nunca encuentra el negocio → /b/:slug da 404.
|
||||
//
|
||||
// Un backfill de migración no puede cubrir filas que aún no existen: el default tiene
|
||||
// que aplicarse en el INSERT. Este módulo es la única definición, compartida por el
|
||||
// seed, el alta desde /api/admin y las propias migraciones.
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/** Lun-Vie 09:00-20:00, fin de semana cerrado. */
|
||||
export const DEFAULT_WORKING_HOURS = JSON.stringify({
|
||||
1: { start: "09:00", end: "20:00" },
|
||||
2: { start: "09:00", end: "20:00" },
|
||||
3: { start: "09:00", end: "20:00" },
|
||||
4: { start: "09:00", end: "20:00" },
|
||||
5: { start: "09:00", end: "20:00" },
|
||||
6: null,
|
||||
7: null,
|
||||
});
|
||||
|
||||
/** "Lumière Estética & Spa" → "lumiere-estetica-spa". Puro. */
|
||||
export function slugify(s: string): string {
|
||||
return (s || "negocio")
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 60) || "negocio";
|
||||
}
|
||||
|
||||
/** Slug único para `name`, añadiendo sufijo numérico si ya está tomado. */
|
||||
export function uniqueSlug(db: DatabaseSync, name: string, excludeId: number | null = null): string {
|
||||
const base = slugify(name);
|
||||
let slug = base;
|
||||
let n = 2;
|
||||
const taken = db.prepare(`SELECT id FROM businesses WHERE slug = ? AND id IS NOT ?`);
|
||||
while (taken.get(slug, excludeId)) slug = `${base}-${n++}`;
|
||||
return slug;
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
// server/lib/scheduling.test.ts
|
||||
//
|
||||
// La suite corre con TZ=UTC A PROPÓSITO, y los casos usan negocios en
|
||||
// America/Mexico_City: la tz del proceso y la del negocio nunca coinciden, así que
|
||||
// cualquier cálculo que se ancle a la del proceso falla aquí y no en producción.
|
||||
// (Se fija en runtime en vez de en el script de npm porque `TZ=x cmd` no es
|
||||
// portable a cmd.exe en Windows.)
|
||||
process.env.TZ = "UTC";
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
@@ -8,6 +16,13 @@ import {
|
||||
} from "./scheduling.ts";
|
||||
import type { CandidateInfo, BusyWindow } from "./scheduling.ts";
|
||||
|
||||
const MX = "America/Mexico_City";
|
||||
/** Instante UTC de un reloj de pared mexicano. Los tests no deben depender de la tz del proceso. */
|
||||
function mx(dateIso: string, hh: number, mm = 0): number {
|
||||
const [y, mo, d] = dateIso.split("-").map(Number);
|
||||
return Date.UTC(y, mo - 1, d, hh + 6, mm); // México = UTC-6 todo el año desde 2022
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -26,16 +41,9 @@ test("isoDayOfWeek: lunes=1, domingo=7", () => {
|
||||
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
|
||||
assert.equal(getWorkingHoursForDate(null, biz, "2026-07-25", MX), null); // sábado
|
||||
});
|
||||
|
||||
test("normalizeText/tokens: quita acentos y lowercase", () => {
|
||||
@@ -106,8 +114,8 @@ test("scoreCandidate: fairness — libre+ineficiente vence a ocupado+eficiente (
|
||||
|
||||
test("pickBestSlotEmployee: empate en score → gana el de menor loadBalance (no por id)", () => {
|
||||
const bizWh = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "17:00" } }));
|
||||
const startMs = new Date(2026, 6, 27, 13, 0).getTime(); // lunes, dentro de horario
|
||||
const endMs = new Date(2026, 6, 27, 14, 0).getTime();
|
||||
const startMs = mx("2026-07-27", 13); // lunes, dentro de horario
|
||||
const endMs = mx("2026-07-27", 14);
|
||||
// A (id menor, senior): especialidad coincide (sm=1), ocupado media jornada (load=0.5)
|
||||
// → 40·1 + 20·0.5 + 40·0.5 = 70
|
||||
// B (id mayor, junior): sin especialidad (sm=0.5), libre (load=0)
|
||||
@@ -115,23 +123,23 @@ test("pickBestSlotEmployee: empate en score → gana el de menor loadBalance (no
|
||||
const A: CandidateInfo = { id: 1, name: "Senior", color: "#fff", role: "stylist", specialties: ["corte"], efficiency_score: 50, empWh: null };
|
||||
const B: CandidateInfo = { id: 2, name: "Junior", color: "#fff", role: "stylist", specialties: [], efficiency_score: 50, empWh: null };
|
||||
const busy = new Map<number, BusyWindow[]>([
|
||||
[1, [{ startMs: new Date(2026, 6, 27, 9, 0).getTime(), endMs: new Date(2026, 6, 27, 13, 0).getTime() }]],
|
||||
[1, [{ startMs: mx("2026-07-27", 9), endMs: mx("2026-07-27", 13) }]],
|
||||
[2, []],
|
||||
]);
|
||||
const best = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs);
|
||||
const best = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs, MX);
|
||||
assert.equal(best?.id, 2, "el junior libre gana el empate por menor loadBalance, NO por id menor");
|
||||
});
|
||||
|
||||
test("pickBestSlotEmployee: empate total (score+load) → decisión determinista y reproducible", () => {
|
||||
const bizWh = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "17:00" } }));
|
||||
const startMs = new Date(2026, 6, 27, 9, 0).getTime();
|
||||
const endMs = new Date(2026, 6, 27, 10, 0).getTime();
|
||||
const startMs = mx("2026-07-27", 9);
|
||||
const endMs = mx("2026-07-27", 10);
|
||||
// Dos candidatos idénticos salvo el id → mismo score y mismo loadBalance → hash decide.
|
||||
const A: CandidateInfo = { id: 10, name: "Diez", color: "#fff", role: "s", specialties: ["corte"], efficiency_score: 50, empWh: null };
|
||||
const B: CandidateInfo = { id: 20, name: "Veinte", color: "#fff", role: "s", specialties: ["corte"], efficiency_score: 50, empWh: null };
|
||||
const busy = new Map<number, BusyWindow[]>([[10, []], [20, []]]);
|
||||
const r1 = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs);
|
||||
const r2 = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs);
|
||||
const r1 = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs, MX);
|
||||
const r2 = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs, MX);
|
||||
assert.ok(r1 && (r1.id === 10 || r1.id === 20), "debe elegir uno de los dos candidatos");
|
||||
assert.equal(r1?.id, r2?.id, "misma entrada → misma decisión (hash determinista, reproducible)");
|
||||
});
|
||||
@@ -143,3 +151,58 @@ test("BUSY_STATUSES: scheduled+completed sí cuentan; no_show NO (silla vacía)"
|
||||
assert.equal(s.includes("no_show"), false);
|
||||
assert.equal(s.length, 2);
|
||||
});
|
||||
|
||||
// ── Regresión: la ventana laboral debe anclarse a la tz del NEGOCIO ────────────
|
||||
// Estos tests fallaban en producción (contenedor node:22-slim = UTC) y pasaban en
|
||||
// dev (Windows = America/Mexico_City), porque getWorkingHoursForDate construía la
|
||||
// ventana con `new Date(y, m, d, hh, mm)`, es decir en la tz del *proceso*.
|
||||
// Corren bajo `TZ=UTC` en test:unit precisamente para que el desfase no se esconda.
|
||||
|
||||
test("getWorkingHoursForDate: ancla la ventana a la tz del negocio, no a la del proceso", () => {
|
||||
const biz = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" } }));
|
||||
const r = getWorkingHoursForDate(null, biz, "2026-08-31", "America/Mexico_City"); // lunes
|
||||
assert.ok(r, "el lunes debe estar abierto");
|
||||
// 09:00 CST = 15:00Z ; 20:00 CST = 02:00Z del día siguiente
|
||||
assert.equal(new Date(r!.startMs).toISOString(), "2026-08-31T15:00:00.000Z");
|
||||
assert.equal(new Date(r!.endMs).toISOString(), "2026-09-01T02:00:00.000Z");
|
||||
});
|
||||
|
||||
test("getWorkingHoursForDate: la misma fecha en otra tz da otra ventana absoluta", () => {
|
||||
const biz = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" } }));
|
||||
const mx = getWorkingHoursForDate(null, biz, "2026-08-31", "America/Mexico_City");
|
||||
const utc = getWorkingHoursForDate(null, biz, "2026-08-31", "UTC");
|
||||
assert.notEqual(mx!.startMs, utc!.startMs);
|
||||
assert.equal(new Date(utc!.startMs).toISOString(), "2026-08-31T09:00:00.000Z");
|
||||
});
|
||||
|
||||
test("getWorkingHoursForDate: el día de la semana sale de la fecha natural, no de un Date local", () => {
|
||||
const biz = parseWorkingHours(JSON.stringify({ 6: null, 7: null, 1: { start: "09:00", end: "20:00" } }));
|
||||
assert.equal(getWorkingHoursForDate(null, biz, "2026-08-29", "America/Mexico_City"), null); // sábado
|
||||
assert.equal(getWorkingHoursForDate(null, biz, "2026-08-30", "America/Mexico_City"), null); // domingo
|
||||
assert.ok(getWorkingHoursForDate(null, biz, "2026-08-31", "America/Mexico_City")); // lunes
|
||||
});
|
||||
|
||||
test("getWorkingHoursForDate: empleado tiene prioridad sobre negocio (tz-explícito)", () => {
|
||||
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, "2026-08-31", "America/Mexico_City");
|
||||
assert.equal(new Date(r!.startMs).toISOString(), "2026-08-31T16:00:00.000Z"); // 10:00 CST
|
||||
});
|
||||
|
||||
test("pickBestSlotEmployee: un slot de la tarde del negocio sigue dentro de la ventana", () => {
|
||||
// 19:00 México = 01:00Z del día siguiente. Con la ventana mal anclada este slot
|
||||
// quedaba fuera de rango y el endpoint devolvía la tarde vacía.
|
||||
const bizWh = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" } }));
|
||||
const cand: CandidateInfo = {
|
||||
id: 1, name: "Ana", color: "#000", role: "Estilista",
|
||||
specialties: ["Cabello"], efficiency_score: 80, empWh: null,
|
||||
};
|
||||
const startMs = Date.parse("2026-09-01T01:00:00Z"); // lunes 31/08 19:00 México
|
||||
const endMs = startMs + 60 * 60000;
|
||||
const busy = new Map<number, BusyWindow[]>();
|
||||
const best = pickBestSlotEmployee(
|
||||
[cand], busy, bizWh, { name: "Corte", category: "Cabello" },
|
||||
startMs, endMs, "America/Mexico_City"
|
||||
);
|
||||
assert.equal(best?.id, 1);
|
||||
});
|
||||
|
||||
+59
-18
@@ -2,6 +2,8 @@
|
||||
// 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
|
||||
|
||||
@@ -39,24 +41,39 @@ export function isoDayOfWeek(date: Date): number {
|
||||
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 esa fecha. Devuelve ms o null si es día cerrado. */
|
||||
/**
|
||||
* 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,
|
||||
date: Date
|
||||
dateIso: string,
|
||||
tz: string
|
||||
): { startMs: number; endMs: number } | null {
|
||||
const dow = isoDayOfWeek(date);
|
||||
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 = new Date(date.getFullYear(), date.getMonth(), date.getDate(), sh, sm).getTime();
|
||||
const endMs = new Date(date.getFullYear(), date.getMonth(), date.getDate(), eh, em).getTime();
|
||||
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 };
|
||||
}
|
||||
@@ -156,11 +173,24 @@ export function getCandidates(db: DatabaseSync, businessId: number, serviceId: n
|
||||
* del empleado (además ayuda a los sobre-agendados a recuperarse). */
|
||||
export const BUSY_STATUSES = ["scheduled", "completed"] as const;
|
||||
|
||||
export function getExistingBusy(db: DatabaseSync, employeeIds: number[], dateIso: string): Map<number, BusyWindow[]> {
|
||||
/**
|
||||
* 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 startOfDay = `${dateIso}T00:00:00`;
|
||||
const endOfDay = `${dateIso}T23:59:59`;
|
||||
const { start: startOfDay, end: endOfDay } = bizDayBoundsIsoFor(tz || DEFAULT_TZ, dateIso);
|
||||
const statusPh = BUSY_STATUSES.map(() => "?").join(",");
|
||||
const rows = db
|
||||
.prepare(
|
||||
@@ -177,9 +207,16 @@ export function getExistingBusy(db: DatabaseSync, employeeIds: number[], dateIso
|
||||
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) ?? [];
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -245,13 +282,14 @@ export function pickBestSlotEmployee(
|
||||
bizWh: WorkingHoursMap | null,
|
||||
service: { name: string; category: string },
|
||||
startMs: number,
|
||||
endMs: number
|
||||
endMs: number,
|
||||
tz: string
|
||||
): { id: number; name: string } | null {
|
||||
const date = new Date(startMs);
|
||||
const dateIso = isoDateStr(date);
|
||||
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, date);
|
||||
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;
|
||||
@@ -278,17 +316,20 @@ export interface AutoAssignCtx {
|
||||
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 dateIso = isoDateStr(new Date(ctx.startMs));
|
||||
const busyMap = getExistingBusy(db, candidates.map((c) => c.id), dateIso);
|
||||
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, new Date(ctx.startMs));
|
||||
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;
|
||||
|
||||
@@ -115,3 +115,34 @@ export function wallToUtcISO(
|
||||
): string {
|
||||
return wallToUtcDate(tz, y, mo, d, hh, mm, ss).toISOString();
|
||||
}
|
||||
|
||||
/** Zona horaria por defecto de los negocios (México). Única definición. */
|
||||
export const DEFAULT_TZ = "America/Mexico_City";
|
||||
|
||||
/**
|
||||
* Día ISO de la semana (1=Lun … 7=Dom) de una fecha natural "YYYY-MM-DD".
|
||||
* Deriva del string, nunca de un `Date` local, así que es independiente de
|
||||
* la tz del proceso (en un contenedor UTC `new Date("…").getDay()` puede
|
||||
* caer en el día anterior).
|
||||
*/
|
||||
export function isoDowFromDateStr(dateIso: string): number {
|
||||
const [y, m, d] = dateIso.split("-").map(Number);
|
||||
const j = new Date(Date.UTC(y, m - 1, d)).getUTCDay(); // 0=Dom..6=Sáb
|
||||
return j === 0 ? 7 : j;
|
||||
}
|
||||
|
||||
/**
|
||||
* Límites UTC del día natural `dateIso` **en la tz del negocio**, en formato
|
||||
* ISO-Z. Emparéjalo con `start_at >= ? AND start_at <= ?` (comparación
|
||||
* lexicográfica sobre el formato canónico almacenado).
|
||||
*
|
||||
* A diferencia de `bizDayBoundsIso`, que trabaja con desplazamientos respecto
|
||||
* de "hoy", este acepta la fecha explícita que pide el cliente.
|
||||
*/
|
||||
export function bizDayBoundsIsoFor(tz: string, dateIso: string): { start: string; end: string } {
|
||||
const [y, m, d] = dateIso.split("-").map(Number);
|
||||
return {
|
||||
start: toIsoUtc(wallToUtcDate(tz || DEFAULT_TZ, y, m, d, 0, 0, 0)),
|
||||
end: toIsoUtc(wallToUtcDate(tz || DEFAULT_TZ, y, m, d, 23, 59, 59)),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user