feat(autocall): окно самовывоза 3 дня (сегодня/завтра/послезавтра), даты из диалога, гео-исключения как у SMS

This commit is contained in:
Hermes 2026-09-11 11:29:49 +02:00
parent feb3fc1b07
commit f62a6b525b
9 changed files with 295 additions and 56 deletions

View File

@ -65,6 +65,12 @@ getvar GROUP_ID; GROUP_ID=$VAR
getvar ATTEMPT; ATTEMPT=$VAR
getvar TEST_MODE; TEST_MODE=$VAR
getvar CUSTOMER_PHONE; CUSTOMER_PHONE=$VAR
# Даты самовывоза (заполняет планировщик по окну «готовность + 0..2 дня»)
getvar PICKUP_DAY1; PICKUP_DAY1=$VAR
getvar PICKUP_DAY2; PICKUP_DAY2=$VAR
getvar PICKUP_DAY3; PICKUP_DAY3=$VAR
getvar PICKUP_DAY1_FIRST; PICKUP_DAY1_FIRST=$VAR
CHOSEN_DATE=""
# Пишем состояние ПОСЛЕ КАЖДОГО шага: если клиент положит трубку,
# выбор всё равно сохранён (AGI умирает молча, финальной записи не будет).
@ -105,6 +111,7 @@ send_result () {
\"attempt_no\":${ATTEMPT:-1},
\"scenario\":\"$SCENARIO\",
\"call_type\":\"${TYPE:-dlv}\",
\"chosen_date\":\"${CHOSEN_DATE:-}\",
\"call_started_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",
\"answered_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",
\"duration_sec\":0,
@ -156,6 +163,70 @@ if [ "$SCENARIO" = "C" ]; then
fi
fi
# ─── Самовывоз: даты приходят из call-файла (PICKUP_DAY1..3) ─────────
# Робот называет конкретные дни, окно считает планировщик:
# готов сегодня → сегодня/завтра/послезавтра, вчера → сегодня/завтра, позавчера → только сегодня.
# Отвечаем выбранной датой (chosen_date), а не абстрактным «день 1/2».
if [ "$TYPE" = "pkp" ] && [ -n "$PICKUP_DAY1" ]; then
play "ru/ss_pkp_intro"
DAYS=1
[ -n "$PICKUP_DAY2" ] && DAYS=2
[ -n "$PICKUP_DAY3" ] && DAYS=3
while [ -z "$OUTCOME" ]; do
digit "ru/ss_pkp_day${DAYS}" 9000
CHOSEN_DAY=$DIGIT
case "$CHOSEN_DAY" in
9) play "ru/ss_optout"; OUTCOME=opt_out; break ;;
0) play "ru/ss_operator"; OUTCOME=operator; break ;;
1|2|3) [ "$CHOSEN_DAY" -le "$DAYS" ] || CHOSEN_DAY="" ;;
*) CHOSEN_DAY="" ;;
esac
if [ -z "$CHOSEN_DAY" ]; then
digit "ru/ss_pkp_day${DAYS}" 9000
CHOSEN_DAY=$DIGIT
case "$CHOSEN_DAY" in
1|2|3) [ "$CHOSEN_DAY" -le "$DAYS" ] || CHOSEN_DAY="" ;;
*) CHOSEN_DAY="" ;;
esac
fi
if [ -z "$CHOSEN_DAY" ]; then
if [ -z "$ANY_DIGIT" ] && [ "$RING_SEC" -ge 20 ]; then
OUTCOME=no_answer; break
fi
play "ru/ss_no_input"; OUTCOME=no_input_day; break
fi
case "$CHOSEN_DAY" in
1) CHOSEN_DATE="$PICKUP_DAY1" ;;
2) CHOSEN_DATE="$PICKUP_DAY2" ;;
3) CHOSEN_DATE="$PICKUP_DAY3" ;;
esac
DAY=$CHOSEN_DAY
save
# Половина дня: если осталось только «сегодня» и первая половина уже прошла — не спрашиваем.
if [ "$CHOSEN_DAY" = "1" ] && [ "$PICKUP_DAY1_FIRST" != "1" ]; then
HALF=2
play "ru/ss_ok_second"
else
digit "ru/ss_half_pkp" 9000
HALF=$DIGIT
if [ "$HALF" != "1" ] && [ "$HALF" != "2" ]; then
digit "ru/ss_half_pkp" 9000
HALF=$DIGIT
fi
if [ "$HALF" != "1" ] && [ "$HALF" != "2" ]; then
if [ -z "$ANY_DIGIT" ] && [ "$RING_SEC" -ge 20 ]; then
OUTCOME=no_answer; break
fi
play "ru/ss_no_input"; OUTCOME=no_input_half; break
fi
[ "$HALF" = "1" ] && play "ru/ss_ok_first" || play "ru/ss_ok_second"
fi
CONF_DAY=$CHOSEN_DAY; CONF_HALF=$HALF
OUTCOME=confirmed
play "ru/ss_tail_pkp"
done
fi
# ─── выбор дня и времени, с возможностью переделать ──────────────────
while [ -z "$OUTCOME" ]; do

View File

@ -97,7 +97,7 @@ def get_groups_to_call(conn, settings):
cur.execute("""
SELECT og.id, og.customer_phone, og.customer_phone_normalized,
og.notification_status, og.delivery_status, og.sms_sent_at,
og.autocall_opt_out, og.created_at, og.delivery_type,
og.autocall_opt_out, og.created_at, og.delivery_type, og.town,
(SELECT count(*) FROM autocall_log l
WHERE l.order_group_id = og.id) as attempt_count,
(SELECT count(*) FROM autocall_log l
@ -121,6 +121,20 @@ def get_groups_to_call(conn, settings):
-- Не превышен лимит попыток
AND (SELECT count(*) FROM autocall_log l
WHERE l.order_group_id = og.id) < %s
-- Гео-исключения: те же, что у SMS (город исключён целиком
-- или канал для города выключен в профиле).
AND NOT EXISTS (
SELECT 1 FROM sms_city_rules r
WHERE r.city = og.town
AND r.enabled = false
AND r.campaign_type IN ('*', 'autocall_slot')
AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE)
AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE)
)
AND NOT EXISTS (
SELECT 1 FROM sms_city_profiles p
WHERE p.city = og.town AND p.sms_enabled = false
)
-- Самовывоз звоним только если разрешено настройкой
AND (COALESCE(og.delivery_type, 'delivery') <> 'pickup' OR %s)
ORDER BY og.sms_sent_at ASC
@ -151,6 +165,46 @@ def normalize_phone(phone):
clean = "7" + clean
return clean
def get_city_exclusion(conn, city):
"""Гео-исключение — общее для SMS и звонков.
Источник тот же, что у SMS: правила города (sms_city_rules) и общий
выключатель канала в профиле города (sms_city_profiles.sms_enabled).
Правило с campaign_type='*' действует на все каналы, 'autocall_slot' —
только на звонки.
Исключения по номеру (например, «не звонить на МТС», autocall_exclusions)
живут отдельно и на SMS не влияют — они про телефонию, а не про город.
"""
if not city:
return None
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
SELECT city, campaign_type, note
FROM sms_city_rules
WHERE city = %s
AND enabled = false
AND campaign_type IN ('*', 'autocall_slot')
AND (valid_from IS NULL OR valid_from <= CURRENT_DATE)
AND (valid_until IS NULL OR valid_until >= CURRENT_DATE)
LIMIT 1
""", (city,))
rule = cur.fetchone()
if rule:
return {
"source": "city_rule",
"reason": (rule.get("note") or "").strip() or f"город {city} исключён из рассылок",
}
cur.execute("SELECT sms_enabled, note FROM sms_city_profiles WHERE city = %s LIMIT 1", (city,))
profile = cur.fetchone()
if profile is not None and profile.get("sms_enabled") is False:
return {
"source": "city_profile",
"reason": (profile.get("note") or "").strip() or f"город {city}: канал выключен",
}
return None
def get_exclusion_match(conn, phone):
"""Возвращает правило исключения, если на этот номер звонить нельзя, иначе None."""
phone = normalize_phone(phone)
@ -168,6 +222,40 @@ def get_exclusion_match(conn, phone):
""", (phone, phone))
return cur.fetchone()
def pickup_window(sms_sent_at, now=None):
"""Даты, на которые предлагаем самовывоз.
Правило: забрать можно в день готовности (день попадания в базу — он же
день отправки ссылки) и два следующих дня, то есть максимум три дня
включая сегодняшний. Окно отсчитывается от дня готовности, а список
начинается с сегодня:
• готов сегодня → сегодня, завтра, послезавтра (3)
• готов вчера → сегодня, завтра (2)
• готов позавчера → только сегодня (1)
• готов раньше → окно закрыто, звонить не о чем (0)
"""
now = now or datetime.now(timezone.utc)
tz = timezone(timedelta(hours=3))
today = now.astimezone(tz).date()
ready = (sms_sent_at or now).astimezone(tz).date()
last_allowed = ready + timedelta(days=2)
if last_allowed < today:
return []
dates = []
day = today
while day <= last_allowed and len(dates) < 3:
dates.append(day)
day += timedelta(days=1)
return [d.isoformat() for d in dates]
def is_today_before_noon(now=None):
"""До 12:00 по Симферополю — первую половину дня ещё можно предлагать."""
now = now or datetime.now(timezone.utc)
local = now.astimezone(timezone(timedelta(hours=3)))
return local.hour < 12
def resolve_call_type(group):
"""Тип звонка: pkp (самовывоз) или dlv (доставка).
@ -202,6 +290,15 @@ def build_call_file(group, settings, attempt_no, is_test_call=False):
scenario = group.get("scenario") or resolve_scenario(group.get("created_at"))
call_type = resolve_call_type(group)
# Самовывоз: конкретные даты передаём в диалог явно — робот называет их
# голосом и возвращает выбранную дату, а не абстрактный «день 1/2».
pickup_days = []
if call_type == "pkp":
pickup_days = pickup_window(group.get("sms_sent_at") or group.get("created_at"))
if not pickup_days:
# Окно самовывоза закрыто (заказ готов более двух дней назад).
return None
lines = [
f"Channel: PJSIP/{dial}@mmedia",
f"CallerID: {CALLER_ID}",
@ -216,6 +313,10 @@ def build_call_file(group, settings, attempt_no, is_test_call=False):
f"Setvar: GROUP_ID={group.get('id') or ''}",
f"Setvar: ATTEMPT={attempt_no}",
f"Setvar: CUSTOMER_PHONE={phone}",
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: API_URL={API_URL}",
f"Setvar: API_KEY={API_KEY}",
f"Setvar: TEST_MODE={1 if test_mode else 0}",
@ -226,6 +327,10 @@ def build_call_file(group, settings, attempt_no, is_test_call=False):
def push_call_file(group, settings, attempt_no, is_test_call=False):
"""Кладёт call-файл на АТС через SSH."""
content = build_call_file(group, settings, attempt_no, is_test_call)
if content is None:
# Звонить не о чем (например, окно самовывоза закрыто) — тихо пропускаем.
log.info(f"Skip group {group.get('id')}: нет доступных вариантов для звонка")
return False
gid = str(group.get("id") or "test")[:8]
filename = f"ss-{gid}-{int(time.time())}.call"
# Пишем файл на АТС через sshpass + cat
@ -264,7 +369,7 @@ def get_queued_calls(conn):
if ids:
cur.execute("""
SELECT og.id, og.customer_phone, og.customer_phone_normalized,
og.created_at, og.delivery_type
og.created_at, og.delivery_type, og.town
FROM order_groups og WHERE og.id IN %s
""", (ids,))
og = {r["id"]: r for r in cur.fetchall()}
@ -275,6 +380,7 @@ def get_queued_calls(conn):
r.setdefault("customer_phone", g.get("customer_phone"))
r.setdefault("created_at", g.get("created_at"))
r.setdefault("delivery_type", g.get("delivery_type"))
r.setdefault("town", g.get("town"))
return rows
@ -300,6 +406,12 @@ def process_queue(conn, settings):
if not phone:
log.warning(f"Queue item {q['queue_id']} has no phone, skip")
continue
# Гео-исключение (как для SMS): город исключён целиком
city_excl = get_city_exclusion(conn, q.get("town"))
if city_excl:
log.info(f"Queue item {q['queue_id']} skipped by city rule ({q.get('town')}): {city_excl['reason']}")
mark_queue_processed(conn, q["queue_id"])
continue
# Исключение: на этот номер не звоним (например, МТС-диапазон)
excl = get_exclusion_match(conn, phone)
if excl:
@ -379,7 +491,8 @@ def backfill_no_answer(conn, settings):
if gid:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
SELECT customer_phone_normalized, customer_phone, created_at, delivery_type
SELECT customer_phone_normalized, customer_phone, created_at,
delivery_type, town
FROM order_groups WHERE id = %s
""", (gid,))
grow = cur.fetchone() or {}
@ -392,6 +505,9 @@ def backfill_no_answer(conn, settings):
# (статус «исключение» и так показывается из настроек).
if get_exclusion_match(conn, phone):
continue
# Гео-исключение: город, по которому звонки не делаем вовсе
if get_city_exclusion(conn, q.get("town")):
continue
scenario = resolve_scenario(group_created) if gid else "A"
with conn.cursor() as cur:
if gid:
@ -453,6 +569,11 @@ def main():
pushed = 0
for group in groups:
phone = group.get("customer_phone_normalized") or group.get("customer_phone") or ""
# Гео-исключение (то же, что для SMS): город исключён целиком
city_excl = get_city_exclusion(conn, group.get("town"))
if city_excl:
log.info(f"Group {group['id']} skipped by city rule ({group.get('town')}): {city_excl['reason']}")
continue
# Исключение: на этот номер не звоним (например, МТС-диапазон)
excl = get_exclusion_match(conn, phone)
if excl:

View File

@ -210,6 +210,16 @@ def get_groups_to_send(conn):
AND COALESCE(og.notification_status, '') = 'link_ready'
-- Профиль sms_enabled (замена старого sms_allowed_for_city)
AND COALESCE(scp.sms_enabled, true) = true
-- Гео-исключения — общие для SMS и звонков: правило города
-- (campaign_type '*' — все каналы) блокирует и отправку SMS.
AND NOT EXISTS (
SELECT 1 FROM sms_city_rules r
WHERE r.city = og.town
AND r.enabled = false
AND r.campaign_type IN ('*', 'first_sms')
AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE)
AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE)
)
-- Нет активной SMS в логе (sent/checking) за последние 2 часа
AND NOT EXISTS (
SELECT 1 FROM sms_campaign_log scl

View File

@ -112,6 +112,15 @@ def get_groups_to_manual(conn):
og.sms_attempts
FROM order_groups og
WHERE og.delivery_status = 'pending_confirmation'
-- Гео-исключения — общие для SMS и звонков
AND NOT EXISTS (
SELECT 1 FROM sms_city_rules r
WHERE r.city = og.town
AND r.enabled = false
AND r.campaign_type IN ('*', 'manual')
AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE)
AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE)
)
AND (
-- 1. second_sms_sent + срок истёк
(COALESCE(og.notification_status, '') = 'second_sms_sent'

View File

@ -183,6 +183,15 @@ def get_groups_to_send(conn):
LEFT JOIN sms_city_profiles scp ON scp.city = og.town
WHERE og.delivery_status = 'paid_storage'
AND og.paid_storage_at IS NOT NULL
-- Гео-исключения — общие для SMS и звонков
AND NOT EXISTS (
SELECT 1 FROM sms_city_rules r
WHERE r.city = og.town
AND r.enabled = false
AND r.campaign_type IN ('*', 'paid_storage')
AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE)
AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE)
)
AND COALESCE(og.notification_status, '') NOT IN ('paid_storage_sending', 'paid_storage_sent')
AND og.delivery_link IS NOT NULL
AND og.delivery_link != ''

View File

@ -209,6 +209,15 @@ def get_groups_to_send(conn):
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
-- Профиль: second_sms_enabled (если false — 2-я SMS не отправляется)
AND COALESCE(scp.second_sms_enabled, true) = true
-- Гео-исключения — общие для SMS и звонков
AND NOT EXISTS (
SELECT 1 FROM sms_city_rules r
WHERE r.city = og.town
AND r.enabled = false
AND r.campaign_type IN ('*', 'second_sms')
AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE)
AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE)
)
-- Нет активной второй SMS в логе
AND NOT EXISTS (
SELECT 1 FROM sms_campaign_log scl

View File

@ -53,65 +53,55 @@ const getNextPickupWorkday = (dateKey, allowedDays = [1,2,3,4,5]) => {
return next;
};
const getPickupSlots = (referenceDate = new Date(), pickupDays = [1,2,3,4,5]) => {
const todayKey = getCrimeaTodayKey(referenceDate);
const hour = getCrimeaHour(referenceDate);
const isTodayWorkday = isAllowedPickupDay(todayKey, pickupDays);
/**
* Слоты самовывоза.
*
* Окно считаем от дня готовности (referenceDate — день попадания в базу, он же
* день отправки ссылки): забрать можно в день готовности и два следующих дня,
* максимум три дня включая сегодняшний. Отсчёт в списке — от фактического
* сегодня: если заказ готов вчера, останутся только «Сегодня» и «Завтра».
*/
const getPickupSlots = (referenceDate = new Date(), pickupDays = [1,2,3,4,5], now = new Date()) => {
const todayKey = getCrimeaTodayKey(now);
const hour = getCrimeaHour(now);
const readyKey = getCrimeaTodayKey(referenceDate);
const lastAllowedKey = addDaysKey(readyKey, 2);
// Дата попадает в окно: не раньше сегодня и не позже «готовность + 2 дня».
const isInWindow = (dateKey) => Boolean(dateKey) && dateKey >= todayKey && dateKey <= lastAllowedKey;
const slots = [];
if (isTodayWorkday) {
if (hour < 12) {
slots.push({
id: `pickup-${todayKey}-first`,
date: todayKey,
time: "Первая половина дня",
label: "Сегодня",
pickupType: "today",
});
}
const addSlot = (dateKey, half, label, pickupType) => {
slots.push({
id: `pickup-${todayKey}-second`,
date: todayKey,
time: "Вторая половина дня",
label: "Сегодня",
pickupType: "today",
id: `pickup-${dateKey}-${half}`,
date: dateKey,
time: half === "first" ? "Первая половина дня" : "Вторая половина дня",
label,
pickupType,
});
};
// Сегодня: первая половина — только до 12:00, вторая — в течение дня.
if (isAllowedPickupDay(todayKey, pickupDays) && isInWindow(todayKey)) {
if (hour < 12) addSlot(todayKey, "first", "Сегодня", "today");
addSlot(todayKey, "second", "Сегодня", "today");
}
const tomorrow = addDaysKey(todayKey, 1);
const tomorrowWorkday = isAllowedPickupDay(tomorrow, pickupDays) ? tomorrow : getNextPickupWorkday(todayKey, pickupDays);
slots.push({
id: `pickup-${tomorrowWorkday}-first`,
date: tomorrowWorkday,
time: "Первая половина дня",
label: getDeliveryRelativeDayLabel(tomorrowWorkday, referenceDate) || "Завтра",
pickupType: "tomorrow",
});
slots.push({
id: `pickup-${tomorrowWorkday}-second`,
date: tomorrowWorkday,
time: "Вторая половина дня",
label: getDeliveryRelativeDayLabel(tomorrowWorkday, referenceDate) || "Завтра",
pickupType: "tomorrow",
});
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);
slots.push({
id: `pickup-${dayAfterWorkday}-first`,
date: dayAfterWorkday,
time: "Первая половина дня",
label: getDeliveryRelativeDayLabel(dayAfterWorkday, referenceDate) || "Послезавтра",
pickupType: "dayAfter",
});
slots.push({
id: `pickup-${dayAfterWorkday}-second`,
date: dayAfterWorkday,
time: "Вторая половина дня",
label: getDeliveryRelativeDayLabel(dayAfterWorkday, referenceDate) || "Послезавтра",
pickupType: "dayAfter",
});
if (isInWindow(dayAfterWorkday)) {
const label = getDeliveryRelativeDayLabel(dayAfterWorkday, now) || "Послезавтра";
addSlot(dayAfterWorkday, "first", label, "dayAfter");
addSlot(dayAfterWorkday, "second", label, "dayAfter");
}
return slots;
};

View File

@ -30,6 +30,7 @@ type AutocallBody = {
attempt_no?: number;
scenario?: string;
call_type?: string;
chosen_date?: string;
call_started_at?: string;
answered_at?: string | null;
duration_sec?: number | null;
@ -51,6 +52,16 @@ function addDays(date: Date, days: number): string {
return d.toISOString().slice(0, 10);
}
/** День по-человечески: сегодня / завтра / послезавтра, иначе дата дд.мм.гггг. */
function relativeDayLabel(dateKey: string): string {
const today = new Date();
for (const [offset, label] of [[0, "сегодня"], [1, "завтра"], [2, "послезавтра"]] as const) {
if (addDays(today, offset) === dateKey) return label;
}
const [y, m, d] = dateKey.split("-");
return y && m && d ? `${d}.${m}.${y}` : dateKey;
}
Deno.serve(async (request) => {
if (request.method === "OPTIONS") {
const cors = getCorsHeaders(request, "private");
@ -151,27 +162,36 @@ Deno.serve(async (request) => {
// ── confirmed*: записываем слот ──
if (["confirmed", "confirmed_kept_previous", "confirmed_silent"].includes(body.outcome)) {
const dayOffset = body.dtmf_day === 2 ? 2 : 1;
const deliveryDate = addDays(new Date(), dayOffset);
// День называет сам диалог (chosen_date) — робот перечисляет конкретные
// дни окна самовывоза, поэтому «1/2/3» само по себе дату не задаёт.
// Если дата не пришла (старые call-файлы) — считаем по цифре дня.
const chosenDate = /^\d{4}-\d{2}-\d{2}$/.test(body.chosen_date || "")
? (body.chosen_date as string)
: null;
const dayOffset = body.dtmf_day === 3 ? 2 : body.dtmf_day === 2 ? 1 : 0;
const deliveryDate = chosenDate || addDays(new Date(), body.dtmf_day ? dayOffset : 1);
const timeSlot = body.dtmf_half ? HALF_LABEL[body.dtmf_half] : null;
const dayLabel = body.dtmf_day ? DAY_LABEL[body.dtmf_day] : "завтра";
const dayLabel = chosenDate
? relativeDayLabel(chosenDate)
: body.dtmf_day
? DAY_LABEL[body.dtmf_day]
: "завтра";
const comment = `Согласовано роботом: ${dayLabel}${timeSlot ? ", " + timeSlot.toLowerCase() : ""}`;
const update: Record<string, unknown> = {
delivery_date_source: "autocall",
delivery_status: "agreed",
notification_status: "confirmed",
delivery_date: deliveryDate,
called: true,
called_at: new Date().toISOString(),
called_comment: comment,
next_notification_check_at: null,
};
if (timeSlot) update.delivery_time = timeSlot;
if (isPickup) {
update.pickup_date = deliveryDate;
if (timeSlot) update.pickup_time_slot = timeSlot;
} else {
update.delivery_date = deliveryDate;
if (timeSlot) update.delivery_time = timeSlot;
}
await supabase.from("order_groups").update(update).eq("id", orderGroupId);
}