fix(pickup): окно самовывоза в рабочих днях (пятница → пт, пн, вт) у робота и в ссылке; тесты окна

This commit is contained in:
Hermes 2026-09-14 13:59:13 +02:00
parent 11167516a3
commit 1a310e5f5a
3 changed files with 99 additions and 50 deletions

View File

@ -294,15 +294,14 @@ def load_work_hours(conn):
def pickup_window(sms_sent_at, work_days=None, now=None, cutoff_minutes=None):
"""Даты, на которые предлагаем самовывоз.
Правило: забрать можно в день готовности (день попадания в базу — он же
день отправки ссылки) и два следующих дня, то есть максимум три дня
включая сегодняшний. Выходные и нерабочие дни пропускаем, отсчёт ведём
от сегодня:
• готов сегодня → сегодня, завтра, послезавтра (3 рабочих дня)
• готов вчера → сегодня, завтра (2)
• готов позавчера → сегодня (1)
• готов раньше → окно закрыто, звонить не о чем (0)
• пятница, готов сегодня → только пятница: сб и вс склад закрыт
Правило владельца: окно считается **рабочими днями** — день готовности
плюс два следующих рабочих дня (выходные и нерабочие дни не сокращают
окно, а пропускаются):
• готов в понедельник → пн, вт, ср
• готов в пятницу → пт, пн, вт (сб и вс склад закрыт)
• готов в субботу → пн, вт (сам выходной не предлагаем)
• готов вчера → остаток окна начиная с сегодня
• окно прошло → пусто, звонить не о чем
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)
today = local_now.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
# Поздно для сегодняшнего забора — окно начинается со следующего дня.
if cutoff_minutes is not None and local_now.hour * 60 + local_now.minute >= cutoff_minutes:
first_day = today + timedelta(days=1)
dates = []
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]
return [d.isoformat() for d in window if d >= first_day]
def is_first_half_available(now=None, half_end_minutes=None):

View File

@ -81,22 +81,31 @@ const getNextPickupWorkday = (dateKey, allowedDays = [1,2,3,4,5]) => {
* максимум три дня включая сегодняшний. Отсчёт в списке — от фактического
* сегодня: если заказ готов вчера, останутся только «Сегодня» и «Завтра».
*/
const getPickupSlots = (
export const getPickupSlots = (
referenceDate = new Date(),
pickupDays = [1,2,3,4,5],
pickupDays = [1, 2, 3, 4, 5],
now = new Date(),
schedule = {},
) => {
const todayKey = getCrimeaTodayKey(now);
const readyKey = getCrimeaTodayKey(referenceDate);
const lastAllowedKey = addDaysKey(readyKey, 2);
// Часы склада из админки («График работы»): граница половины дня и отсечка
// «сегодня». Дефолты — прежнее поведение (12:00 и «весь день»).
const halfEndMinutes = timeToMinutes(schedule.halfEnd, 12 * 60);
const cutoffMinutes = timeToMinutes(schedule.sameDayCutoff, 24 * 60);
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 = [];
@ -110,32 +119,22 @@ const getPickupSlots = (
});
};
// Сегодня: первая половина — до границы половины дня, вторая — до отсечки
// «сегодня» (после неё склад не успеет собрать заказ, поэтому сегодня не предлагаем).
if (
isAllowedPickupDay(todayKey, pickupDays)
&& isInWindow(todayKey)
&& nowMinutes < cutoffMinutes
) {
if (nowMinutes < halfEndMinutes) addSlot(todayKey, "first", "Сегодня", "today");
addSlot(todayKey, "second", "Сегодня", "today");
}
const tomorrow = addDaysKey(todayKey, 1);
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");
allowedKeys.forEach((dateKey, index) => {
const isToday = dateKey === todayKey;
const label = isToday
? "Сегодня"
: (getDeliveryRelativeDayLabel(dateKey, now) || `День ${index + 1}`);
if (isToday) {
// После отсечки «сегодня» склад не успеет собрать заказ — вариант не показываем,
// первая половина доступна только до границы половины дня.
if (nowMinutes >= cutoffMinutes) return;
if (nowMinutes < halfEndMinutes) addSlot(dateKey, "first", label, "today");
addSlot(dateKey, "second", label, "today");
return;
}
addSlot(dateKey, "first", label, PICKUP_TYPES[index] || "dayAfter");
addSlot(dateKey, "second", label, PICKUP_TYPES[index] || "dayAfter");
});
return slots;
};

View File

@ -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([]);
});
});