fix(pickup): окно самовывоза в рабочих днях (пятница → пт, пн, вт) у робота и в ссылке; тесты окна
This commit is contained in:
parent
11167516a3
commit
1a310e5f5a
|
|
@ -294,15 +294,14 @@ def load_work_hours(conn):
|
||||||
def pickup_window(sms_sent_at, work_days=None, now=None, cutoff_minutes=None):
|
def pickup_window(sms_sent_at, work_days=None, now=None, cutoff_minutes=None):
|
||||||
"""Даты, на которые предлагаем самовывоз.
|
"""Даты, на которые предлагаем самовывоз.
|
||||||
|
|
||||||
Правило: забрать можно в день готовности (день попадания в базу — он же
|
Правило владельца: окно считается **рабочими днями** — день готовности
|
||||||
день отправки ссылки) и два следующих дня, то есть максимум три дня
|
плюс два следующих рабочих дня (выходные и нерабочие дни не сокращают
|
||||||
включая сегодняшний. Выходные и нерабочие дни пропускаем, отсчёт ведём
|
окно, а пропускаются):
|
||||||
от сегодня:
|
• готов в понедельник → пн, вт, ср
|
||||||
• готов сегодня → сегодня, завтра, послезавтра (3 рабочих дня)
|
• готов в пятницу → пт, пн, вт (сб и вс склад закрыт)
|
||||||
• готов вчера → сегодня, завтра (2)
|
• готов в субботу → пн, вт (сам выходной не предлагаем)
|
||||||
• готов позавчера → сегодня (1)
|
• готов вчера → остаток окна начиная с сегодня
|
||||||
• готов раньше → окно закрыто, звонить не о чем (0)
|
• окно прошло → пусто, звонить не о чем
|
||||||
• пятница, готов сегодня → только пятница: сб и вс склад закрыт
|
|
||||||
|
|
||||||
cutoff_minutes — время, после которого «сегодня» уже не предлагаем
|
cutoff_minutes — время, после которого «сегодня» уже не предлагаем
|
||||||
(склад не успеет собрать заказ к выдаче, настройка админки).
|
(склад не успеет собрать заказ к выдаче, настройка админки).
|
||||||
|
|
@ -313,20 +312,22 @@ def pickup_window(sms_sent_at, work_days=None, now=None, cutoff_minutes=None):
|
||||||
local_now = now.astimezone(tz)
|
local_now = now.astimezone(tz)
|
||||||
today = local_now.date()
|
today = local_now.date()
|
||||||
ready = (sms_sent_at or now).astimezone(tz).date()
|
ready = (sms_sent_at or now).astimezone(tz).date()
|
||||||
last_allowed = ready + timedelta(days=2)
|
|
||||||
if last_allowed < today:
|
# День готовности + два следующих рабочих дня.
|
||||||
return []
|
window = []
|
||||||
|
day = ready
|
||||||
|
guard = 0
|
||||||
|
while len(window) < 3 and guard < 30:
|
||||||
|
if day.isoweekday() in work_days:
|
||||||
|
window.append(day)
|
||||||
|
day += timedelta(days=1)
|
||||||
|
guard += 1
|
||||||
|
|
||||||
first_day = today
|
first_day = today
|
||||||
# Поздно для сегодняшнего забора — окно начинается со следующего дня.
|
# Поздно для сегодняшнего забора — окно начинается со следующего дня.
|
||||||
if cutoff_minutes is not None and local_now.hour * 60 + local_now.minute >= cutoff_minutes:
|
if cutoff_minutes is not None and local_now.hour * 60 + local_now.minute >= cutoff_minutes:
|
||||||
first_day = today + timedelta(days=1)
|
first_day = today + timedelta(days=1)
|
||||||
dates = []
|
return [d.isoformat() for d in window if d >= first_day]
|
||||||
day = first_day
|
|
||||||
while day <= last_allowed and len(dates) < 3:
|
|
||||||
if day.isoweekday() in work_days:
|
|
||||||
dates.append(day)
|
|
||||||
day += timedelta(days=1)
|
|
||||||
return [d.isoformat() for d in dates]
|
|
||||||
|
|
||||||
|
|
||||||
def is_first_half_available(now=None, half_end_minutes=None):
|
def is_first_half_available(now=None, half_end_minutes=None):
|
||||||
|
|
|
||||||
|
|
@ -81,22 +81,31 @@ const getNextPickupWorkday = (dateKey, allowedDays = [1,2,3,4,5]) => {
|
||||||
* максимум три дня включая сегодняшний. Отсчёт в списке — от фактического
|
* максимум три дня включая сегодняшний. Отсчёт в списке — от фактического
|
||||||
* сегодня: если заказ готов вчера, останутся только «Сегодня» и «Завтра».
|
* сегодня: если заказ готов вчера, останутся только «Сегодня» и «Завтра».
|
||||||
*/
|
*/
|
||||||
const getPickupSlots = (
|
export const getPickupSlots = (
|
||||||
referenceDate = new Date(),
|
referenceDate = new Date(),
|
||||||
pickupDays = [1,2,3,4,5],
|
pickupDays = [1, 2, 3, 4, 5],
|
||||||
now = new Date(),
|
now = new Date(),
|
||||||
schedule = {},
|
schedule = {},
|
||||||
) => {
|
) => {
|
||||||
const todayKey = getCrimeaTodayKey(now);
|
const todayKey = getCrimeaTodayKey(now);
|
||||||
const readyKey = getCrimeaTodayKey(referenceDate);
|
const readyKey = getCrimeaTodayKey(referenceDate);
|
||||||
const lastAllowedKey = addDaysKey(readyKey, 2);
|
|
||||||
// Часы склада из админки («График работы»): граница половины дня и отсечка
|
// Часы склада из админки («График работы»): граница половины дня и отсечка
|
||||||
// «сегодня». Дефолты — прежнее поведение (12:00 и «весь день»).
|
// «сегодня». Дефолты — прежнее поведение (12:00 и «весь день»).
|
||||||
const halfEndMinutes = timeToMinutes(schedule.halfEnd, 12 * 60);
|
const halfEndMinutes = timeToMinutes(schedule.halfEnd, 12 * 60);
|
||||||
const cutoffMinutes = timeToMinutes(schedule.sameDayCutoff, 24 * 60);
|
const cutoffMinutes = timeToMinutes(schedule.sameDayCutoff, 24 * 60);
|
||||||
const nowMinutes = getCrimeaMinutes(now);
|
const nowMinutes = getCrimeaMinutes(now);
|
||||||
// Дата попадает в окно: не раньше сегодня и не позже «готовность + 2 дня».
|
// Окно считается рабочими днями: день готовности + два следующих рабочих дня
|
||||||
const isInWindow = (dateKey) => Boolean(dateKey) && dateKey >= todayKey && dateKey <= lastAllowedKey;
|
// (пятница → пт, пн, вт). Прошедшие дни не показываем.
|
||||||
|
const windowKeys = [];
|
||||||
|
let windowDay = readyKey;
|
||||||
|
let guard = 0;
|
||||||
|
while (windowKeys.length < 3 && guard < 30) {
|
||||||
|
if (isAllowedPickupDay(windowDay, pickupDays)) windowKeys.push(windowDay);
|
||||||
|
windowDay = addDaysKey(windowDay, 1);
|
||||||
|
guard += 1;
|
||||||
|
}
|
||||||
|
const allowedKeys = windowKeys.filter((key) => key >= todayKey);
|
||||||
|
const PICKUP_TYPES = ["today", "tomorrow", "dayAfter"];
|
||||||
|
|
||||||
const slots = [];
|
const slots = [];
|
||||||
|
|
||||||
|
|
@ -110,32 +119,22 @@ const getPickupSlots = (
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Сегодня: первая половина — до границы половины дня, вторая — до отсечки
|
allowedKeys.forEach((dateKey, index) => {
|
||||||
// «сегодня» (после неё склад не успеет собрать заказ, поэтому сегодня не предлагаем).
|
const isToday = dateKey === todayKey;
|
||||||
if (
|
const label = isToday
|
||||||
isAllowedPickupDay(todayKey, pickupDays)
|
? "Сегодня"
|
||||||
&& isInWindow(todayKey)
|
: (getDeliveryRelativeDayLabel(dateKey, now) || `День ${index + 1}`);
|
||||||
&& nowMinutes < cutoffMinutes
|
if (isToday) {
|
||||||
) {
|
// После отсечки «сегодня» склад не успеет собрать заказ — вариант не показываем,
|
||||||
if (nowMinutes < halfEndMinutes) addSlot(todayKey, "first", "Сегодня", "today");
|
// первая половина доступна только до границы половины дня.
|
||||||
addSlot(todayKey, "second", "Сегодня", "today");
|
if (nowMinutes >= cutoffMinutes) return;
|
||||||
}
|
if (nowMinutes < halfEndMinutes) addSlot(dateKey, "first", label, "today");
|
||||||
|
addSlot(dateKey, "second", label, "today");
|
||||||
const tomorrow = addDaysKey(todayKey, 1);
|
return;
|
||||||
const tomorrowWorkday = isAllowedPickupDay(tomorrow, pickupDays) ? tomorrow : getNextPickupWorkday(todayKey, pickupDays);
|
|
||||||
if (isInWindow(tomorrowWorkday)) {
|
|
||||||
const label = getDeliveryRelativeDayLabel(tomorrowWorkday, now) || "Завтра";
|
|
||||||
addSlot(tomorrowWorkday, "first", label, "tomorrow");
|
|
||||||
addSlot(tomorrowWorkday, "second", label, "tomorrow");
|
|
||||||
}
|
|
||||||
|
|
||||||
const dayAfter = addDaysKey(tomorrowWorkday, 1);
|
|
||||||
const dayAfterWorkday = isAllowedPickupDay(dayAfter, pickupDays) ? dayAfter : getNextPickupWorkday(dayAfter, pickupDays);
|
|
||||||
if (isInWindow(dayAfterWorkday)) {
|
|
||||||
const label = getDeliveryRelativeDayLabel(dayAfterWorkday, now) || "Послезавтра";
|
|
||||||
addSlot(dayAfterWorkday, "first", label, "dayAfter");
|
|
||||||
addSlot(dayAfterWorkday, "second", label, "dayAfter");
|
|
||||||
}
|
}
|
||||||
|
addSlot(dateKey, "first", label, PICKUP_TYPES[index] || "dayAfter");
|
||||||
|
addSlot(dateKey, "second", label, PICKUP_TYPES[index] || "dayAfter");
|
||||||
|
});
|
||||||
|
|
||||||
return slots;
|
return slots;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { getPickupSlots } from "./PickupSlotsPicker";
|
||||||
|
|
||||||
|
// Склад: пн–пт, половина дня до 13:00, после 15:30 «сегодня» не предлагаем.
|
||||||
|
const SCHEDULE = { halfEnd: "13:00", sameDayCutoff: "15:30" };
|
||||||
|
const DAYS = [1, 2, 3, 4, 5];
|
||||||
|
|
||||||
|
const crimeaDate = (iso, hour, minute = 0) => new Date(`${iso}T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00+03:00`);
|
||||||
|
|
||||||
|
// Пятница 18.09.2026, понедельник 21.09.2026, вторник 22.09.2026.
|
||||||
|
const FRIDAY = "2026-09-18";
|
||||||
|
const MONDAY = "2026-09-21";
|
||||||
|
const TUESDAY = "2026-09-22";
|
||||||
|
|
||||||
|
const datesOf = (slots) => [...new Set(slots.map((s) => s.date))];
|
||||||
|
|
||||||
|
describe("PickupSlotsPicker: окно самовывоза рабочими днями", () => {
|
||||||
|
it("готов в пятницу утром → пятница, понедельник, вторник", () => {
|
||||||
|
const slots = getPickupSlots(crimeaDate(FRIDAY, 9), DAYS, crimeaDate(FRIDAY, 9), SCHEDULE);
|
||||||
|
expect(datesOf(slots)).toEqual([FRIDAY, MONDAY, TUESDAY]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("готов в понедельник → понедельник, вторник, среда", () => {
|
||||||
|
const slots = getPickupSlots(crimeaDate(MONDAY, 10), DAYS, crimeaDate(MONDAY, 10), SCHEDULE);
|
||||||
|
expect(datesOf(slots)).toEqual([MONDAY, TUESDAY, "2026-09-23"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("после отсечки 15:30 сегодня не предлагаем — остаются следующие дни окна", () => {
|
||||||
|
const slots = getPickupSlots(crimeaDate(MONDAY, 16), DAYS, crimeaDate(MONDAY, 16), SCHEDULE);
|
||||||
|
expect(datesOf(slots)).toEqual([TUESDAY, "2026-09-23"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("после 13:00 первая половина сегодняшнего дня недоступна", () => {
|
||||||
|
const slots = getPickupSlots(crimeaDate(MONDAY, 14), DAYS, crimeaDate(MONDAY, 14), SCHEDULE);
|
||||||
|
const today = slots.filter((s) => s.date === MONDAY);
|
||||||
|
expect(today.map((s) => s.time)).toEqual(["Вторая половина дня"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("готов в субботу (склад закрыт) → три рабочих дня с понедельника", () => {
|
||||||
|
const saturday = "2026-09-19";
|
||||||
|
const slots = getPickupSlots(crimeaDate(saturday, 11), DAYS, crimeaDate(saturday, 11), SCHEDULE);
|
||||||
|
expect(datesOf(slots)).toEqual([MONDAY, TUESDAY, "2026-09-23"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("окно прошло (готов неделю назад) → предложить нечего", () => {
|
||||||
|
const slots = getPickupSlots(crimeaDate("2026-09-10", 10), DAYS, crimeaDate(FRIDAY, 10), SCHEDULE);
|
||||||
|
expect(slots).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue