Schema delivery_zones JSONB pasa a { zones: [{ id, name, polygon (GeoJSON),
delivery_cost, delivery_days, delivery_hours, min_order_amount, enabled }] }.
Tirado el modelo legacy de 48 barrios CABA hardcoded.
Backend:
- lib/geo.js: pointInPolygon + findZoneForPoint (ray casting, sin deps) + 9 tests.
- storeContext.checkAddressInZone ahora valida con lat/lng (necesita ubicación
del cliente; no geocodifica texto). buildZonesForLLM expone zonas resumidas
para el agente. summarizeDeliveryZones genera prosa con costo+días+horas.
- settingsRepo expone delivery_zones (bug pre-existente: nunca se devolvía).
- pipeline: inboundLocation ⇒ persistir order.pending_location; orderModel
acepta pending_location, matched_zone, delivery_window.
Intake:
- evolutionParser detecta locationMessage/liveLocationMessage (Baileys).
- evolution + sim handlers propagan inboundLocation al pipeline.
Agent (DeepSeek tool-calling):
- workingMemory inyecta store.delivery.zones[], store.pickup.schedule,
order.pending_location/matched_zone/delivery_window.
- setAddress: matchea zona con la ubicación pendiente; sin location devuelve
need_location y el LLM le pide el pin al cliente.
- setShipping: para delivery, indica requires_location si faltan coords.
- confirmOrder: valida día+hora contra zone.delivery_days/hours o pickup.schedule.
- nueva tool set_delivery_window(day, time?) para registrar el slot pedido.
- systemPrompt agrega instrucciones de envío/zonas + flujo location share.
Frontend:
- zone-map-editor: web component (light DOM) que carga Leaflet 1.9 +
leaflet-geoman lazy desde CDN y permite dibujar/editar polígonos sobre OSM.
API zones get/set, eventos change/select, paleta tomada de --chart-*.
- settings-crud: borrada lista CABA_BARRIOS, nueva UI con mapa al lado y
formulario por zona seleccionada (nombre, costo, días, horario start/end,
mínimo, habilitada). Save serializa al schema nuevo.
Smoke E2E manual:
- "1kg vacío + envío" → bot pide pin → location en Centro → matched_zone
$1.500, lun-sab 10-20h → "martes 12hs" → confirma orden con total + envío.
- Location en Palermo Test → mar/jue 11-19h respetado.
- Location fuera de zonas → "no llegamos a esa zona" + lista de zonas válidas.
- Domingo en Centro → rechazado con días disponibles.
157/157 tests verde.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
104 lines
2.8 KiB
JavaScript
104 lines
2.8 KiB
JavaScript
import { pointInPolygon, findZoneForPoint } from "./geo.js";
|
|
|
|
const square = {
|
|
type: "Polygon",
|
|
coordinates: [[
|
|
[0, 0],
|
|
[10, 0],
|
|
[10, 10],
|
|
[0, 10],
|
|
[0, 0],
|
|
]],
|
|
};
|
|
|
|
const concave = {
|
|
type: "Polygon",
|
|
coordinates: [[
|
|
[0, 0],
|
|
[10, 0],
|
|
[10, 10],
|
|
[5, 5],
|
|
[0, 10],
|
|
[0, 0],
|
|
]],
|
|
};
|
|
|
|
describe("pointInPolygon", () => {
|
|
it("returns true para un punto dentro de un cuadrado", () => {
|
|
expect(pointInPolygon(5, 5, square)).toBe(true);
|
|
});
|
|
|
|
it("returns false para un punto fuera del cuadrado", () => {
|
|
expect(pointInPolygon(15, 5, square)).toBe(false);
|
|
expect(pointInPolygon(-1, 5, square)).toBe(false);
|
|
expect(pointInPolygon(5, 20, square)).toBe(false);
|
|
});
|
|
|
|
it("maneja polígonos cóncavos (excluye el dent del centro)", () => {
|
|
// (5, 8) cae dentro del notch — fuera del polígono cóncavo.
|
|
expect(pointInPolygon(5, 8, concave)).toBe(false);
|
|
// (2, 2) sigue dentro.
|
|
expect(pointInPolygon(2, 2, concave)).toBe(true);
|
|
});
|
|
|
|
it("returns false para input inválido", () => {
|
|
expect(pointInPolygon(0, 0, null)).toBe(false);
|
|
expect(pointInPolygon(0, 0, { type: "Point" })).toBe(false);
|
|
expect(pointInPolygon(0, 0, { type: "Polygon", coordinates: [[[0, 0], [1, 1]]] })).toBe(false);
|
|
});
|
|
|
|
it("trabaja con coordenadas reales de CABA (lng, lat)", () => {
|
|
// Polígono cuadrado pequeño alrededor del Obelisco.
|
|
const obeliscoBox = {
|
|
type: "Polygon",
|
|
coordinates: [[
|
|
[-58.39, -34.61],
|
|
[-58.37, -34.61],
|
|
[-58.37, -34.60],
|
|
[-58.39, -34.60],
|
|
[-58.39, -34.61],
|
|
]],
|
|
};
|
|
// Obelisco aprox: -58.3816, -34.6037
|
|
expect(pointInPolygon(-58.3816, -34.6037, obeliscoBox)).toBe(true);
|
|
// Mar del Plata
|
|
expect(pointInPolygon(-57.55, -38.0, obeliscoBox)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("findZoneForPoint", () => {
|
|
const zones = [
|
|
{ id: "centro", name: "Centro", polygon: square, enabled: true, delivery_cost: 1500 },
|
|
{
|
|
id: "norte",
|
|
name: "Norte",
|
|
enabled: true,
|
|
polygon: {
|
|
type: "Polygon",
|
|
coordinates: [[[20, 20], [30, 20], [30, 30], [20, 30], [20, 20]]],
|
|
},
|
|
delivery_cost: 2000,
|
|
},
|
|
];
|
|
|
|
it("devuelve la zona que contiene al punto", () => {
|
|
const z = findZoneForPoint(5, 5, zones);
|
|
expect(z?.id).toBe("centro");
|
|
});
|
|
|
|
it("devuelve null si ningún polígono contiene al punto", () => {
|
|
expect(findZoneForPoint(15, 15, zones)).toBeNull();
|
|
});
|
|
|
|
it("ignora zonas con enabled=false", () => {
|
|
const muted = zones.map((z) => ({ ...z, enabled: z.id === "centro" ? false : z.enabled }));
|
|
expect(findZoneForPoint(5, 5, muted)).toBeNull();
|
|
});
|
|
|
|
it("tolera input inválido", () => {
|
|
expect(findZoneForPoint(0, 0, null)).toBeNull();
|
|
expect(findZoneForPoint(0, 0, [])).toBeNull();
|
|
expect(findZoneForPoint(0, 0, [{ id: "x" }])).toBeNull();
|
|
});
|
|
});
|