feat(schedule): рабочие часы склада и доставки в админке — открытие/закрытие, границы половин, отсечка «сегодня»; робот и ссылка клиента читают часы

This commit is contained in:
Hermes 2026-09-14 13:15:49 +02:00
parent 794c3ce5e3
commit 11167516a3
6 changed files with 226 additions and 28 deletions

View File

@ -256,7 +256,42 @@ def load_work_days(conn, field="pickup_days"):
return default
def pickup_window(sms_sent_at, work_days=None, now=None):
def load_work_hours(conn):
"""Рабочие часы склада и доставки из «Графика работы» (админка).
Возвращает минуты от полуночи — сравнивать часы строками нельзя.
Дефолты повторяют прежнее поведение кода: первая половина до 12:00,
«сегодня» предлагалось весь день.
"""
defaults = {
"pickup_half_end": 12 * 60,
"pickup_same_day_cutoff": 24 * 60,
"pickup_close": 18 * 60,
"delivery_half_end": 15 * 60,
"delivery_close": 21 * 60,
}
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
SELECT pickup_half_end, pickup_same_day_cutoff, pickup_close,
delivery_half_end, delivery_close
FROM business_schedule_settings WHERE id = 1
""")
row = cur.fetchone() or {}
hours = {}
for key, fallback in defaults.items():
value = row.get(key)
if value is None:
hours[key] = fallback
continue
hours[key] = value.hour * 60 + value.minute
return hours
except Exception as exc: # noqa: BLE001 — часы не критичны, звоним по дефолту
log.warning("Не удалось прочитать рабочие часы, беру значения по умолчанию: %s", exc)
return dict(defaults)
def pickup_window(sms_sent_at, work_days=None, now=None, cutoff_minutes=None):
"""Даты, на которые предлагаем самовывоз.
Правило: забрать можно в день готовности (день попадания в базу — он же
@ -267,18 +302,26 @@ def pickup_window(sms_sent_at, work_days=None, now=None):
• готов вчера → сегодня, завтра (2)
• готов позавчера → сегодня (1)
• готов раньше → окно закрыто, звонить не о чем (0)
• в пятницу готов → пятница и суббота (воскресенье закрыто)
• пятница, готов сегодня → только пятница: сб и вс склад закрыт
cutoff_minutes — время, после которого «сегодня» уже не предлагаем
(склад не успеет собрать заказ к выдаче, настройка админки).
"""
now = now or datetime.now(timezone.utc)
work_days = work_days or [1, 2, 3, 4, 5]
tz = timezone(timedelta(hours=3))
today = now.astimezone(tz).date()
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 []
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 = today
day = first_day
while day <= last_allowed and len(dates) < 3:
if day.isoweekday() in work_days:
dates.append(day)
@ -286,11 +329,13 @@ def pickup_window(sms_sent_at, work_days=None, now=None):
return [d.isoformat() for d in dates]
def is_today_before_noon(now=None):
"""До 12:00 по Симферополю — первую половину дня ещё можно предлагать."""
def is_first_half_available(now=None, half_end_minutes=None):
"""До границы половины дня (настройка склада, по умолчанию 12:00)
первую половину дня ещё можно предлагать клиенту."""
now = now or datetime.now(timezone.utc)
local = now.astimezone(timezone(timedelta(hours=3)))
return local.hour < 12
limit = 12 * 60 if half_end_minutes is None else half_end_minutes
return local.hour * 60 + local.minute < limit
def resolve_call_type(group):
@ -334,6 +379,7 @@ def build_call_file(group, settings, attempt_no, is_test_call=False):
pickup_days = pickup_window(
group.get("sms_sent_at") or group.get("created_at"),
settings.get("pickup_work_days"),
cutoff_minutes=(settings.get("work_hours") or {}).get("pickup_same_day_cutoff"),
)
if not pickup_days:
# Окно самовывоза закрыто (заказ готов более двух дней назад).
@ -356,7 +402,7 @@ def build_call_file(group, settings, attempt_no, is_test_call=False):
f"Setvar: PICKUP_DAY1={pickup_days[0] if len(pickup_days) > 0 else ''}",
f"Setvar: PICKUP_DAY2={pickup_days[1] if len(pickup_days) > 1 else ''}",
f"Setvar: PICKUP_DAY3={pickup_days[2] if len(pickup_days) > 2 else ''}",
f"Setvar: PICKUP_DAY1_FIRST={1 if (pickup_days and is_today_before_noon()) else 0}",
f"Setvar: PICKUP_DAY1_FIRST={1 if (pickup_days and is_first_half_available(now=None, half_end_minutes=settings.get('work_hours', {}).get('pickup_half_end'))) else 0}",
f"Setvar: API_URL={API_URL}",
f"Setvar: API_KEY={API_KEY}",
f"Setvar: TEST_MODE={1 if test_mode else 0}",
@ -575,8 +621,9 @@ def main():
conn = get_db_conn()
try:
settings = load_settings(conn)
# Рабочие дни для окна самовывоза (выходные робот не предлагает)
# Рабочие дни и часы для окна самовывоза (выходные/закрытие склада робот не предлагает)
settings["pickup_work_days"] = load_work_days(conn)
settings["work_hours"] = load_work_hours(conn)
# Добор «не взял трубку»: заявки, по которым сеть так и не ответила
# (истёкший call-файл, AGI не запускался). Только работа с БД.

View File

@ -23,8 +23,41 @@ const DEFAULT_SCHEDULE = {
deliveryDays: [1, 2, 3, 4, 5],
pickupDays: [1, 2, 3, 4, 5],
smsDays: [1, 2, 3, 4, 5],
// Рабочие часы: склад (самовывоз) и доставка.
pickupOpen: "09:00",
pickupClose: "17:30",
pickupHalfEnd: "13:00",
pickupSameDayCutoff: "15:30",
deliveryOpen: "09:00",
deliveryClose: "21:00",
deliveryHalfEnd: "15:00",
};
const TIME_FIELDS = [
"pickupOpen",
"pickupClose",
"pickupHalfEnd",
"pickupSameDayCutoff",
"deliveryOpen",
"deliveryClose",
"deliveryHalfEnd",
];
const TimeField = ({ label, hint, value, onChange }) => (
<label className="flex flex-col gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
{label}
</span>
<input
type="time"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
className="rounded-[14px] border-2 border-[var(--color-border)] bg-[var(--color-surface-strong)] px-3 py-2.5 text-sm font-semibold text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
/>
{hint ? <span className="text-xs text-[var(--color-text-muted)]">{hint}</span> : null}
</label>
);
const DayPills = ({ selectedDays, onToggle, accentColor }) => {
const toggle = (num) => {
if (selectedDays.includes(num)) {
@ -79,6 +112,13 @@ export const BusinessSchedulePanel = () => {
deliveryDays: data.deliveryDays || DEFAULT_SCHEDULE.deliveryDays,
pickupDays: data.pickupDays || DEFAULT_SCHEDULE.pickupDays,
smsDays: data.smsDays || DEFAULT_SCHEDULE.smsDays,
pickupOpen: data.pickupOpen || DEFAULT_SCHEDULE.pickupOpen,
pickupClose: data.pickupClose || DEFAULT_SCHEDULE.pickupClose,
pickupHalfEnd: data.pickupHalfEnd || DEFAULT_SCHEDULE.pickupHalfEnd,
pickupSameDayCutoff: data.pickupSameDayCutoff || DEFAULT_SCHEDULE.pickupSameDayCutoff,
deliveryOpen: data.deliveryOpen || DEFAULT_SCHEDULE.deliveryOpen,
deliveryClose: data.deliveryClose || DEFAULT_SCHEDULE.deliveryClose,
deliveryHalfEnd: data.deliveryHalfEnd || DEFAULT_SCHEDULE.deliveryHalfEnd,
};
setSchedule(loaded);
setOriginal(loaded);
@ -97,7 +137,8 @@ export const BusinessSchedulePanel = () => {
const hasChanges =
JSON.stringify(schedule.deliveryDays) !== JSON.stringify(original.deliveryDays) ||
JSON.stringify(schedule.pickupDays) !== JSON.stringify(original.pickupDays) ||
JSON.stringify(schedule.smsDays) !== JSON.stringify(original.smsDays);
JSON.stringify(schedule.smsDays) !== JSON.stringify(original.smsDays) ||
TIME_FIELDS.some((field) => schedule[field] !== original[field]);
const handleSave = async () => {
if (!hasChanges) return;
@ -115,6 +156,13 @@ export const BusinessSchedulePanel = () => {
p_delivery_days: schedule.deliveryDays,
p_pickup_days: schedule.pickupDays,
p_sms_days: schedule.smsDays,
p_pickup_open: schedule.pickupOpen,
p_pickup_close: schedule.pickupClose,
p_pickup_half_end: schedule.pickupHalfEnd,
p_pickup_same_day_cutoff: schedule.pickupSameDayCutoff,
p_delivery_open: schedule.deliveryOpen,
p_delivery_close: schedule.deliveryClose,
p_delivery_half_end: schedule.deliveryHalfEnd,
});
if (rpcError) throw rpcError;
if (data?.ok) {
@ -183,6 +231,26 @@ export const BusinessSchedulePanel = () => {
<p className="text-xs text-[var(--color-text-muted)]">
Выбрано: {formatDays(schedule.deliveryDays) || "—"}
</p>
<div className="grid gap-4 sm:grid-cols-3">
<TimeField
label="Начало доставки"
hint="с этого времени возим заказы"
value={schedule.deliveryOpen}
onChange={(v) => setSchedule((s) => ({ ...s, deliveryOpen: v }))}
/>
<TimeField
label="Окончание доставки"
hint="позже не доставляем"
value={schedule.deliveryClose}
onChange={(v) => setSchedule((s) => ({ ...s, deliveryClose: v }))}
/>
<TimeField
label="Первая половина — до"
hint="граница между половинами дня"
value={schedule.deliveryHalfEnd}
onChange={(v) => setSchedule((s) => ({ ...s, deliveryHalfEnd: v }))}
/>
</div>
</Panel>
{/* Pickup days */}
@ -206,6 +274,32 @@ export const BusinessSchedulePanel = () => {
<p className="text-xs text-[var(--color-text-muted)]">
Выбрано: {formatDays(schedule.pickupDays) || "—"}
</p>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<TimeField
label="Открытие склада"
hint="с этого времени выдаём заказы"
value={schedule.pickupOpen}
onChange={(v) => setSchedule((s) => ({ ...s, pickupOpen: v }))}
/>
<TimeField
label="Закрытие склада"
hint="позже заказы не выдаём"
value={schedule.pickupClose}
onChange={(v) => setSchedule((s) => ({ ...s, pickupClose: v }))}
/>
<TimeField
label="Первая половина — до"
hint="граница между половинами дня"
value={schedule.pickupHalfEnd}
onChange={(v) => setSchedule((s) => ({ ...s, pickupHalfEnd: v }))}
/>
<TimeField
label="Сегодня предлагаем до"
hint="позже робот и ссылка предложат только следующие дни — склад не успеет собрать заказ"
value={schedule.pickupSameDayCutoff}
onChange={(v) => setSchedule((s) => ({ ...s, pickupSameDayCutoff: v }))}
/>
</div>
</Panel>
{/* SMS days */}
@ -217,7 +311,7 @@ export const BusinessSchedulePanel = () => {
SMS-приглашения
</h2>
<p className="text-xs text-[var(--color-text-muted)]">
Дни, в которые отправляются SMS-приглашения клиентам. В нерабочие дни SMS не отправляются (проверка статусов работает круглосуточно).
Дни, в которые отправляются SMS-приглашения клиентам.
</p>
</div>
</div>

View File

@ -36,6 +36,26 @@ const getCrimeaHour = (referenceDate = new Date()) => {
);
};
// Минуты от полуночи по Симферополю — чтобы сравнивать с настройками склада.
const getCrimeaMinutes = (referenceDate = new Date()) => {
const parts = new Intl.DateTimeFormat("ru-RU", {
timeZone: DELIVERY_TIMEZONE,
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).formatToParts(referenceDate);
const hour = parseInt(parts.find((p) => p.type === "hour")?.value || "0", 10);
const minute = parseInt(parts.find((p) => p.type === "minute")?.value || "0", 10);
return hour * 60 + minute;
};
// "15:30" → 930
const timeToMinutes = (value, fallback) => {
const match = /^(\d{1,2}):(\d{2})$/.exec(String(value || "").trim());
if (!match) return fallback;
return Number(match[1]) * 60 + Number(match[2]);
};
const isAllowedPickupDay = (dateKey, allowedDays = [1,2,3,4,5]) => {
if (!dateKey) return false;
const d = new Date(`${dateKey}T12:00:00Z`);
@ -61,11 +81,20 @@ const getNextPickupWorkday = (dateKey, allowedDays = [1,2,3,4,5]) => {
* максимум три дня включая сегодняшний. Отсчёт в списке — от фактического
* сегодня: если заказ готов вчера, останутся только «Сегодня» и «Завтра».
*/
const getPickupSlots = (referenceDate = new Date(), pickupDays = [1,2,3,4,5], now = new Date()) => {
const getPickupSlots = (
referenceDate = new Date(),
pickupDays = [1,2,3,4,5],
now = new Date(),
schedule = {},
) => {
const todayKey = getCrimeaTodayKey(now);
const hour = getCrimeaHour(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;
@ -81,9 +110,14 @@ const getPickupSlots = (referenceDate = new Date(), pickupDays = [1,2,3,4,5], no
});
};
// Сегодня: первая половина — только до 12:00, вторая — в течение дня.
if (isAllowedPickupDay(todayKey, pickupDays) && isInWindow(todayKey)) {
if (hour < 12) addSlot(todayKey, "first", "Сегодня", "today");
// Сегодня: первая половина — до границы половины дня, вторая — до отсечки
// «сегодня» (после неё склад не успеет собрать заказ, поэтому сегодня не предлагаем).
if (
isAllowedPickupDay(todayKey, pickupDays)
&& isInWindow(todayKey)
&& nowMinutes < cutoffMinutes
) {
if (nowMinutes < halfEndMinutes) addSlot(todayKey, "first", "Сегодня", "today");
addSlot(todayKey, "second", "Сегодня", "today");
}
@ -146,8 +180,14 @@ export const PickupSlotsPicker = ({
selectedSlotId,
referenceDate = new Date(),
pickupDays = [1, 2, 3, 4, 5],
schedule = {},
}) => {
const slots = React.useMemo(() => getPickupSlots(referenceDate, pickupDays), [referenceDate, pickupDays]);
const halfEnd = schedule?.halfEnd;
const sameDayCutoff = schedule?.sameDayCutoff;
const slots = React.useMemo(
() => getPickupSlots(referenceDate, pickupDays, new Date(), { halfEnd, sameDayCutoff }),
[referenceDate, pickupDays, halfEnd, sameDayCutoff],
);
if (!slots.length) {
return (

View File

@ -14,9 +14,8 @@ import { Skeleton } from "../components/UI/Loading";
import { formatDeliveryDate } from "../components/client/deliveryDateFormatting";
import {
confirmDeliveryChoice,
fetchBusinessSchedule,
fetchDeliveryInvitation,
fetchDeliveryWorkDays,
fetchPickupWorkDays,
} from "../services/deliveryInvitationApi";
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || "";
@ -329,19 +328,21 @@ export const ClientDeliveryPage = () => {
[token, invitation?.smsSentAt],
);
// Рабочие дни — из графика компании, те же, что называет робот.
// Рабочие дни и часы — из графика компании, те же, что использует робот.
const [pickupWorkDays, setPickupWorkDays] = React.useState([1, 2, 3, 4, 5]);
const [deliveryWorkDays, setDeliveryWorkDays] = React.useState(DEFAULT_DELIVERY_DAYS);
const [pickupSchedule, setPickupSchedule] = React.useState({
halfEnd: "13:00",
sameDayCutoff: "15:30",
});
React.useEffect(() => {
let cancelled = false;
fetchDeliveryWorkDays()
.then((days) => {
if (!cancelled) setDeliveryWorkDays(days);
})
.catch(() => {});
fetchPickupWorkDays()
.then((days) => {
if (!cancelled) setPickupWorkDays(days);
fetchBusinessSchedule()
.then((s) => {
if (cancelled) return;
setPickupWorkDays(s.pickupDays);
setDeliveryWorkDays(s.deliveryDays);
setPickupSchedule({ halfEnd: s.pickupHalfEnd, sameDayCutoff: s.pickupSameDayCutoff });
})
.catch(() => {});
return () => {
@ -687,6 +688,7 @@ export const ClientDeliveryPage = () => {
selectedSlotId={selectedSlotId}
referenceDate={referenceDate}
pickupDays={pickupWorkDays}
schedule={pickupSchedule}
/>
) : null}

View File

@ -44,6 +44,7 @@ describe("ClientDeliveryPage helpers", () => {
searchDate: "2026-04-14",
}),
).toEqual({
deliveryType: "delivery",
deliveryDate: "2026-04-16",
deliveryTime: "После обеда",
});

View File

@ -193,6 +193,13 @@ const SCHEDULE_FALLBACK = {
deliveryDays: [1, 2, 3, 4, 5, 6],
pickupDays: [1, 2, 3, 4, 5],
smsDays: [1, 2, 3, 4, 5],
pickupOpen: "09:00",
pickupClose: "17:30",
pickupHalfEnd: "13:00",
pickupSameDayCutoff: "15:30",
deliveryOpen: "09:00",
deliveryClose: "21:00",
deliveryHalfEnd: "15:00",
};
const normalizeDays = (days, fallback) => {
@ -213,6 +220,13 @@ export const fetchBusinessSchedule = async () => {
deliveryDays: normalizeDays(data?.deliveryDays, SCHEDULE_FALLBACK.deliveryDays),
pickupDays: normalizeDays(data?.pickupDays, SCHEDULE_FALLBACK.pickupDays),
smsDays: normalizeDays(data?.smsDays, SCHEDULE_FALLBACK.smsDays),
pickupOpen: data?.pickupOpen || SCHEDULE_FALLBACK.pickupOpen,
pickupClose: data?.pickupClose || SCHEDULE_FALLBACK.pickupClose,
pickupHalfEnd: data?.pickupHalfEnd || SCHEDULE_FALLBACK.pickupHalfEnd,
pickupSameDayCutoff: data?.pickupSameDayCutoff || SCHEDULE_FALLBACK.pickupSameDayCutoff,
deliveryOpen: data?.deliveryOpen || SCHEDULE_FALLBACK.deliveryOpen,
deliveryClose: data?.deliveryClose || SCHEDULE_FALLBACK.deliveryClose,
deliveryHalfEnd: data?.deliveryHalfEnd || SCHEDULE_FALLBACK.deliveryHalfEnd,
};
} catch (e) {
console.warn("Не удалось получить график работы, беру значения по умолчанию", e);