merge: dev → main — driver table layout, filters, status reset, SMS campaigns, font zones

Merges dev branch into main:
- Driver delivery list: table layout (№, Client, Phone, City, Half-day)
- Driver filters: search, half-day filter, problem sorting
- DriverShipmentPanel: reset status with confirmation
- SmsCampaignPanel: campaigns refactor
- LogisticsReadinessBoard: unified table view
- Font zone classes (fs-zone-card) on all driver components
- SMS campaign scripts updates
This commit is contained in:
root 2026-07-13 11:24:56 +00:00
commit 0821bcc747
16 changed files with 1558 additions and 2252 deletions

View File

@ -4,6 +4,7 @@
- `src/context/AuthContext.jsx` — OTP-аутентификация через Supabase Auth и загрузка профиля пользователя с ролью. Неизвестный email показывает подсказку обратиться к администратору.
- `src/context/ThemeContext.jsx` — управление светлой и тёмной темой через `data-theme`.
- `src/context/FontSettingsContext.jsx` — настройки размера шрифтов (6 категорий: таблицы, карточки, меню, заголовки, основной текст, мелкий текст). Сохранение в `localStorage` (ключ `supersam-font-settings`). Применяет CSS-переменные `--fs-scale-*` на `:root`.
- `src/hooks/usePwaStatus.js` — клиентское состояние PWA: online/offline, install prompt, standalone и offline readiness.
- `src/hooks/useOrders.js` — локальный state заказов, истории, чатов, фильтров, действий и **сгруппированных наборов доставки** (deliverySetBuckets).
- `src/hooks/useOrderGroups.js` — работа с группами заказов: загрузка, обновление статусов, ручное согласование доставки и самовывоза. Метод `saveManualDeliveryChoice` принимает `deliveryType`, `pickupDate`, `pickupTimeSlot`.
@ -101,3 +102,25 @@ Edge function `confirm-delivery-choice` передаёт `p_delivery_type`, `p_p
- Docker-сборка через `docker-compose.app.yml` (multi-stage: Node.js build → Caddy serve).
- Автодеплой: Gitea webhook → systemd `supersam-webhook``deploy.sh` (git pull + docker build).
- Домен: `https://dost.supersamsev.ru/`
- Dev: `https://dev.mkn8n.ru/`
- SW инвалидация: `construction-delivery-static-vNN``--no-cache` rebuild.
- **Важно**: `sed` для bump SW версии — только `construction-delivery-static-vNN` и `runtime-vNN`, НЕ `v[0-9]*` (ломает `addEventListener`).
## SMS-кампании
См. `docs/sms-campaigns.md` — полная документация.
4 systemd timer+service+Python скрипта, сервер 217.114.5.8.
## Настройки интерфейса
- `src/pages/SettingsPage.jsx` — UI настроек (пресеты + слайдеры).
- `src/styles/fontSettings.css` — CSS-зоны `.fs-zone-*` оверрайдят Tailwind `text-*`.
- Порядок CSS: body FIRST, затем table/card/nav/heading/small — специфичные зоны побеждают.
- Arbitrary px sizes (`text-[10px]` — `text-[14px]`) требуют явных оверрайдов.
- `localStorage` ключ: `supersam-font-settings`.
## Stale подсветка
- `isStale(group)``updatedAt > 24ч` и статус не в AGREED_STATUSES.
- Жёлтый фон `bg-[rgba(191,123,33,0.06)]` на строке таблицы.
- `isLinkOpened(group)` — 👁 иконка если клиент открывал ссылку.

87
docs/sms-campaigns.md Normal file
View File

@ -0,0 +1,87 @@
# SMS Campaigns — Архитектура
## Обзор
4 сценария SMS-рассылок, каждый — systemd timer + service + Python скрипт.
Сервер: `217.114.5.8`, скрипты в `/opt/supersam/scripts/`.
## Сценарии
| Сценарий | Timer | Скрипт | Триггер |
|----------|-------|--------|---------|
| 1-я SMS | `sms-first-campaign.timer` | `sms_first_campaign.py` | `notification_status=link_ready` + `delivery_status=pending_confirmation` |
| 2-я SMS | `sms-second-campaign.timer` | `sms_second_campaign.py` | `notification_status=first_sms_sent` + время прошло |
| Ручное управление | `sms-manual-campaign.timer` | `sms_manual_campaign.py` | `second_sms_sent` + expired, или `link_ready` без SMS > 3ч, или `sms_sending` > 3ч |
| Платное хранение | `sms-paid-storage-campaign.timer` | `sms_paid_storage_campaign.py` | `notification_status=paid_storage` + `paid_storage_at` |
## Timer конфигурация
- `OnUnitActiveSec=600` — каждые 10 минут
- `sms_timer_manager.sh` — управляет `timer_active` + `run_requested` + `needs_check`
- `fcntl.flock` во всех скриптах — защита от параллельного запуска
- `send_telegram` = no-op (pass)
## Логика retry
- 2 попытки отправки SMS с интервалом ~90 мин
- Если код SMS.ru не 103 (доставлено) после 2 попыток → `manual_required`
- `effective_attempts = max(sms_attempts, total_failed)` — total_failed из всей истории `sms_campaign_log`
- Код 231 (лимит одинаковых) → `send_failed`, НЕ повторять
## Рабочие часы
| Параметр | 1/2 SMS | Manual | Paid Storage |
|----------|---------|--------|-------------|
| Часы | 821 | 821 | 821 |
| Дни | Пн–Пт (1-5) | Пн–Сб (1-6) | Пн–Пт |
| Проверка статусов | 24/7 | 24/7 | 24/7 |
## Настройки (БД `sms_campaign_settings`)
- `test_mode` — false (1/2 SMS), true (manual/paid_storage)
- `test_phone` — 79788382260
- `timer_active` — управляет включением systemd timer
- `send_interval_seconds` — пауза между отправками (15с)
- `work_hours_start/end` — 8/21
- `work_days` — "1,2,3,4,5" или "1,2,3,4,5,6"
- `last_run_at` — обновляется при каждом запуске скрипта
- `last_balance` — баланс sms.ru после последней отправки
## SMS.ru
- Провайдер: sms.ru
- Коды: 100 (в очереди), 102 (в пути), 103 (доставлено), 104-108 (ошибка), 231/232 (лимит)
- API ключ хранится в `sms_campaign_settings`
- Баланс отображается в UI (SmsCampaignPanel)
## UI (SmsCampaignPanel.jsx)
- `getRunnerStatus(settings)` — показывает статус последнего запуска:
- 🟢 Работает (Xм назад) — < 10 мин
- 🟡 Xм назад — < 60 мин
- 🔴 Xч назад — < 24ч
- 🔴 Xд назад — > 24ч
- `🔴 Xд назад` = `last_run_at` был более 24 часов назад
- 4 вкладки кампаний, переключатель test/боевой, таймер on/off
- Логи отправок, фильтры (дата, открытие ссылки), bulk delete
- Edge function `check-sms-status` — мгновенная проверка SMS через sms.ru API
## State Machine
```
not_started → link_ready → sms_sending → checking → first_sms_sent
→ second_sms_sending → second_sms_sent → manual_required → confirmed
→ address_required → agreed → driver_assigned → loaded → on_route → delivered/picked_up
```
## Ссылки
- SMS ссылка содержит `?src=sms` — отличает открытие клиентом от админа
- `opened_at` ставится только при `p_src='sms'` в RPC `get_delivery_invitation_by_token`
- `access_count` растёт при любом открытии
## Деплой
- Скрипты: напрямую в `/opt/supersam/scripts/` (не в Docker)
- UI: SCP → `docker compose -f docker-compose.app.yml build --no-cache && up -d`
- SW bump: `sed -i 's/construction-delivery-static-v[0-9]*/vNN/g' public/service-worker.js`

View File

@ -1,8 +1,8 @@
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
if (!isLocalhost) {
const STATIC_CACHE = "construction-delivery-static-v61";
const RUNTIME_CACHE = "construction-delivery-runtime-v61";
const STATIC_CACHE = "construction-delivery-static-v49";
const RUNTIME_CACHE = "construction-delivery-runtime-v49";
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
self.addEventListener("install", (event) => {

View File

@ -28,8 +28,6 @@ import os
import sys
import json
import logging
import time
import fcntl
from datetime import datetime, timezone, timedelta
import requests
@ -106,37 +104,12 @@ def load_settings(conn):
# ─── SMS API ─────────────────────────────────────────────────────────────────
def normalize_phone(phone):
"""Нормализует телефон: только цифры, начинается с 7. Возвращает None если пустой.
Обрабатляет:
- +7XXX, 7XXX, 8XXX, XXX (без кода страны) 7XXX
- Текст/имена в поле (Екатерина +7978...) извлекает только цифры
- Два номера в одном поле (XXX / YYY) берёт первый (обрезает до 11 цифр)
- Минимум 10 цифр (без 7), максимум 11 (с 7)
"""
"""Нормализует телефон: только цифры, начинается с 7"""
clean = "".join(c for c in str(phone) if c.isdigit())
if len(clean) < 10:
return None
# If 8 prefix (Russian landline style) → replace with 7
if clean.startswith("8") and len(clean) == 11:
if clean.startswith("8"):
clean = "7" + clean[1:]
# If starts with 7 and is 11 digits → ok
elif clean.startswith("7") and len(clean) == 11:
pass
# If 10 digits (no country code) → prepend 7
elif len(clean) == 10:
clean = "7" + clean
# If too long (multiple numbers concatenated) → take first 10 digits + prepend 7
elif len(clean) > 11:
# Try to extract first number: look for 10-digit sequence starting with 9
# Common case: "9242377967 / 89783225219" → "9242377967" (10 digits)
clean10 = clean[:10]
clean = "7" + clean10
# If starts with 7 but wrong length → prepend 7 to first 10 digits
elif not clean.startswith("7"):
clean = "7" + clean
return clean
def send_sms(phone, message, api_id):
@ -163,7 +136,7 @@ def send_sms(phone, message, api_id):
return None, str(e), "error"
def check_sms_status(sms_id, api_id):
"""Проверяет статус, возвращает (sms_status_code, raw_response, api_code)"""
"""Проверяет статус, возвращает (code, raw_response)"""
try:
resp = requests.post(SMS_STATUS_URL, params={
"api_id": api_id,
@ -180,23 +153,22 @@ def check_sms_status(sms_id, api_id):
log.error(f"SMS status check error: {e}")
return None, str(e), "error"
def fetch_balance(api_id):
"""Получает баланс sms.ru, возвращает (balance_float, raw)"""
try:
resp = requests.get("https://sms.ru/my/balance", params={"api_id": api_id}, timeout=15)
text = resp.text
lines = text.strip().split("\n")
if lines[0].strip() == "100" and len(lines) >= 2:
return float(lines[1].strip()), text
return None, text
except Exception as e:
log.error(f"Balance fetch error: {e}")
return None, str(e)
# ─── Telegram ────────────────────────────────────────────────────────────────
def send_telegram(message, chat_id):
pass # Telegram notifications moved to n8n+Supabase integration
if not TELEGRAM_BOT_TOKEN:
log.warning("TELEGRAM_BOT_TOKEN not set, skipping Telegram")
return
try:
resp = requests.post(
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"},
timeout=10,
)
if resp.status_code != 200:
log.warning(f"Telegram error: {resp.text[:200]}")
except Exception as e:
log.warning(f"Telegram failed: {e}")
# ─── State Machine ───────────────────────────────────────────────────────────
@ -205,8 +177,7 @@ def get_groups_to_send(conn):
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
og.customer_phone_normalized, og.delivery_link, og.notification_status,
og.sms_attempts
og.customer_phone_normalized, og.delivery_link, og.notification_status
FROM order_groups og
WHERE og.status = 'ready_to_launch'
AND og.delivery_status = 'pending_confirmation'
@ -250,19 +221,12 @@ def get_sms_to_check(conn, max_duration_min):
return [dict(r) for r in cur.fetchall()]
def get_sms_expired(conn, max_duration_min):
"""SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены).
Также считаем общее количество failed попыток для группы.
"""
"""SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены)"""
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
scl.attempts, scl.created_at,
og.customer_name, og.group_key, og.delivery_link,
og.sms_attempts as group_sms_attempts,
(SELECT COUNT(*) FROM sms_campaign_log scl2
WHERE scl2.order_group_id = scl.order_group_id
AND scl2.campaign_type = 'first_sms'
AND scl2.status IN ('expired', 'error', 'limit_exceeded')) as total_failed
og.customer_name, og.group_key, og.delivery_link
FROM sms_campaign_log scl
JOIN order_groups og ON og.id = scl.order_group_id
WHERE scl.campaign_type = 'first_sms'
@ -310,61 +274,15 @@ def update_order_group(conn, group_id, fields):
cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values)
conn.commit()
# ─── Проверка рабочего времени ────────────────────────────────────────────────
def get_sms_days(conn):
"""Read sms_days from business_schedule_settings. Returns comma-separated string."""
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1")
row = cur.fetchone()
if row and row.get("sms_days"):
days = row["sms_days"]
if isinstance(days, list):
return ",".join(str(d) for d in days)
return str(days)
except Exception:
pass
return "1,2,3,4,5" # Default: Mon-Fri
def is_within_work_hours(settings):
"""Проверка: сейчас рабочие часы.
settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5')
"""
now_msk = datetime.now(timezone(timedelta(hours=3)))
today_num = now_msk.weekday() + 1
allowed_days = set()
work_days_str = settings.get("work_days", "1,2,3,4,5")
for part in str(work_days_str).split(","):
part = part.strip()
if part.isdigit():
allowed_days.add(int(part))
if today_num not in allowed_days:
return False
hour = now_msk.hour
start_h = settings.get("work_hours_start", 8)
end_h = settings.get("work_hours_end", 21)
return start_h <= hour < end_h
# ─── Основная логика ─────────────────────────────────────────────────────────
def step_send_new(conn, settings, test_send=False):
def step_send_new(conn, settings):
"""Шаг 1: Отправка SMS новым группам"""
api_id = settings.get("sms_api_id", SMS_API_ID)
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
max_attempts = settings.get("max_attempts", 2)
second_sms_delay = settings.get("second_sms_delay_hours", 3)
if test_send:
# Test mode: send ONE SMS to test phone, pick first available group
all_groups = get_groups_to_send(conn)
if not all_groups:
log.info("Test send: no groups available in queue")
return 0
groups = [all_groups[0]] # Only first group
log.info(f"TEST SEND: sending 1 SMS to test phone (skipping {len(all_groups)-1} others)")
else:
groups = get_groups_to_send(conn)
log.info(f"Step 1: {len(groups)} groups to send SMS")
@ -388,8 +306,7 @@ def step_send_new(conn, settings, test_send=False):
log.info(f"Group {group_id}: already has recent SMS in log, skipping")
continue
sms_text_template = settings.get("sms_text_template", "Ваш заказ готов. Согласуйте дату доставки по ссылке: {link}")
sms_text = sms_text_template.replace("{link}", delivery_link + "?src=sms")
sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}"
# ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый
send_phone = phone
@ -401,30 +318,24 @@ def step_send_new(conn, settings, test_send=False):
sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
if sms_id:
# Use sms_attempts from order_groups (incremented on retry by step_handle_expired)
current_attempts = (group.get("sms_attempts") or 0) + 1
log_id = insert_sms_log(conn,
campaign_type="first_sms",
order_group_id=group_id,
customer_phone=phone,
sms_id=sms_id,
sms_text=sms_text,
sent_to=send_phone,
status="sent",
sms_code=code,
attempts=current_attempts,
was_test_mode=bool(settings.get("test_mode", True)),
attempts=1,
)
# ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status,
# чтобы get_groups_to_send не нашёл эту группу при следующем запуске
update_order_group(conn, group_id, {
"notification_status": "sms_sending",
"sms_sent_at": "NOW()",
"sms_attempts": current_attempts,
})
log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}, log_id={log_id}, notification_status→sms_sending")
sent_count += 1
time.sleep(settings.get("send_interval_seconds", 15)) # Configurable rate limit
else:
# Ошибка отправки
error = raw[:500] if raw else "Unknown error"
@ -433,12 +344,10 @@ def step_send_new(conn, settings, test_send=False):
order_group_id=group_id,
customer_phone=phone,
sms_text=sms_text,
sent_to=send_phone if "send_phone" in dir() else phone,
status="send_failed",
sms_code=code,
attempts=1,
error_message=error,
was_test_mode=bool(settings.get("test_mode", True)),
)
log.error(f"Group {group_id}: SMS send failed (code={code}): {error[:200]}")
@ -506,11 +415,11 @@ def step_check_status(conn, settings):
error_message=f"Delivery error: {code}")
# Переход к следующей попытке отправки (если есть)
if attempts < max_attempts:
log.info(f"Group {group_id}: retry first SMS (attempt {attempts+1}/{max_attempts})")
log.info(f"Group {group_id}: will retry send on next run (attempt {attempts+1}/{max_attempts})")
update_sms_log(conn, log_id, status="expired")
# Сбрасываем notification_status → link_ready для повторной отправки
update_order_group(conn, group_id, {
"notification_status": "link_ready",
"sms_attempts": attempts,
})
else:
update_order_group(conn, group_id, {
@ -554,29 +463,22 @@ def step_handle_expired(conn, settings):
phone = item.get("customer_phone", "")
attempts = item.get("attempts", 1)
delivery_link = item.get("delivery_link", "")
total_failed = item.get("total_failed", 0)
# Use total_failed across ALL attempts, not just this one
effective_attempts = max(attempts, total_failed)
log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}, total_failed={total_failed}")
log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}")
update_sms_log(conn, log_id, status="expired",
error_message=f"Not delivered in {max_duration} minutes")
if effective_attempts < max_attempts:
# Повторная отправка ТЕКУЩЕЙ SMS (не откат назад)
if attempts < max_attempts:
# Сбрасываем notification_status → link_ready для повторной отправки
update_order_group(conn, group_id, {
"notification_status": "link_ready",
"sms_attempts": effective_attempts,
})
log.info(f"Group {group_id}: retry first SMS (attempt {effective_attempts+1}/{max_attempts}), notification_status→link_ready")
log.info(f"Group {group_id}: will retry SMS send (attempt {attempts+1}), notification_status→link_ready")
else:
# Все попытки исчерпаны
update_order_group(conn, group_id, {
"notification_status": "manual_required",
"delivery_status": "manual_confirmation_required",
"last_sms_error": f"Not delivered after {max_attempts} attempts",
"status": "manual_required",
})
send_telegram(
f"🔧 Требуется ручное управление: {name} ({phone})\n"
@ -587,49 +489,12 @@ def step_handle_expired(conn, settings):
# ─── Main ────────────────────────────────────────────────────────────────────
def main():
# File lock — prevent parallel execution
lock_file = open("/tmp/" + __file__.split("/")[-1].replace(".py", ".lock"), "w")
try:
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
except (IOError, OSError):
log.info("Another instance is running, exiting")
lock_file.close()
return
log.info("=" * 60)
log.info("SMS First Campaign — START")
conn = get_db_conn()
try:
settings = load_settings(conn)
# Override work_days with business_schedule sms_days
settings["work_days"] = get_sms_days(conn)
# Update last_run_at timestamp
with conn.cursor() as cur:
cur.execute("UPDATE sms_campaign_settings SET last_run_at = NOW() WHERE campaign_type = 'first_sms'", )
conn.commit()
# Check test_send_requested — one-time test send
test_send_requested = bool(settings.get("test_send_requested", False))
if test_send_requested:
# Reset flag immediately
with conn.cursor() as cur:
cur.execute("UPDATE sms_campaign_settings SET test_send_requested = false WHERE campaign_type = 'first_sms'", )
conn.commit()
log.info("Test send requested — will send ONE SMS to test phone only")
# Check run_requested — restart scenario (reset all groups to beginning)
run_requested = bool(settings.get('run_requested', False))
if run_requested:
with conn.cursor() as cur:
cur.execute("UPDATE sms_campaign_settings SET run_requested = false WHERE campaign_type = 'first_sms'")
conn.commit()
with conn.cursor() as cur:
cur.execute("""UPDATE order_groups SET notification_status = 'link_ready', sms_sent_at = NULL, first_sms_sent_at = NULL, second_sms_sent_at = NULL, next_notification_check_at = NULL, sms_attempts = 0, last_sms_error = NULL WHERE delivery_status = 'pending_confirmation' AND notification_status IN ('first_sms_sent','sms_sending','send_failed','second_sms_sent','second_sms_sending','manual_required','not_started')""")
reset_count = cur.rowcount
conn.commit()
log.info(f'RESTART first_sms: reset {reset_count} groups to link_ready')
log.info(f"Settings: wait={settings.get('wait_between_checks_seconds')}s, "
f"max_duration={settings.get('max_check_duration_minutes')}min, "
f"max_attempts={settings.get('max_attempts')}")
@ -639,15 +504,7 @@ def main():
return
# State machine — каждый шаг быстрый, без blocking
# Отправка только в рабочие часы
work_hours = is_within_work_hours(settings)
sent = 0
if work_hours:
sent = step_send_new(conn, settings, test_send=test_send_requested)
else:
log.info("Outside work hours, skipping new SMS sends")
# Проверка статусов работает всегда
sent = step_send_new(conn, settings)
delivered = step_check_status(conn, settings)
step_handle_expired(conn, settings)
@ -659,25 +516,10 @@ def main():
settings.get("telegram_chat_id", TELEGRAM_CHAT_ID),
)
# Обновляем баланс sms.ru
if sent > 0:
api_id = settings.get("sms_api_id", SMS_API_ID)
balance, raw = fetch_balance(api_id)
if balance is not None:
with conn.cursor() as cur:
cur.execute(
"UPDATE sms_campaign_settings SET last_balance = %s WHERE campaign_type = 'first_sms'",
(balance,)
)
conn.commit()
log.info(f"Balance updated: {balance}")
except Exception as e:
log.error(f"Fatal error: {e}", exc_info=True)
finally:
conn.close()
fcntl.flock(lock_file, fcntl.LOCK_UN)
lock_file.close()
log.info("SMS First Campaign — END")
log.info("=" * 60)

View File

@ -0,0 +1,583 @@
#!/usr/bin/env python3
"""
SuperSam SMS First Campaign (State Machine)
Заменяет n8n workflow "Первая отправка согласования"
Архитектура: state machine через cron (каждые 5 мин)
Каждый запуск:
1. Отправляет SMS новым группам (ready_to_launch + pending_confirmation + нет недавней SMS)
2. Проверяет статус ранее отправленных SMS (sent но не delivered, в пределах max_check_duration)
3. Обновляет статусы в order_groups + sms_campaign_log
Защита от повторной отправки (ДВОЙНАЯ):
1. После отправки SMS notification_status = 'sms_sending' (не 'link_ready')
get_groups_to_send НЕ находит эту группу (фильтр по notification_status = 'link_ready')
2. Дополнительно: EXISTS проверка в sms_campaign_log (status sent/checking за последние 24h)
3. Код 231/132 = лимит одинаковых не повторять
needs_check=true admin нажал "Проверить снова" в UI
скрипт проверяет даже если запись старше max_check_duration
Коды sms.ru:
100 = в очереди, 101 = оператору, 102 = в пути ждём
103 = доставлено цель
104-108, 130-132, 230-232 = ошибки доставки / лимиты
"""
import os
import sys
import json
import logging
from datetime import datetime, timezone, timedelta
import requests
import psycopg2
from psycopg2.extras import RealDictCursor
# ─── Конфигурация ────────────────────────────────────────────────────────────
DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
DB_PORT = os.environ.get("DB_PORT", "5432")
DB_NAME = os.environ.get("DB_NAME", "postgres")
DB_USER = os.environ.get("DB_USER", "supabase_admin")
DB_PASS = os.environ.get("DB_PASS", "4fe80bb21c7c3d17a8d8b226adf7a479")
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "25164483")
SMS_API_ID = os.environ.get("SMS_API_ID", "C92063B3-95ED-8559-157B-1946EB5A2486")
SMS_SEND_URL = "https://sms.ru/sms/send"
SMS_STATUS_URL = "https://sms.ru/sms/status"
LOG_FILE = "/var/log/supersam-sms-first.log"
# ТЕСТОВЫЙ РЕЖИМ — управляется из админки (sms_campaign_settings.test_mode)
# По умолчанию ВКЛЮЧЕН — SMS идут только на test_phone
# Мегаадмин выключает через админку → SMS идут реальным клиентам
# Коды, которые означают "в процессе" (ждём дальше)
IN_TRANSIT_CODES = {"100", "101", "102"}
# Код доставки
DELIVERED_CODE = "103"
# Коды ошибок доставки (не временные, можно повторять отправку)
DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"}
# Коды превышения лимитов (нельзя повторять отправку — заблокируют)
LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"}
# ─── Логирование ─────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(LOG_FILE),
logging.StreamHandler(sys.stdout),
],
)
log = logging.getLogger("sms_first")
# ─── БД ──────────────────────────────────────────────────────────────────────
def get_db_conn():
return psycopg2.connect(
host=DB_HOST, port=DB_PORT, dbname=DB_NAME,
user=DB_USER, password=DB_PASS,
)
def load_settings(conn):
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = 'first_sms'")
row = cur.fetchone()
if not row:
return {
"wait_between_checks_seconds": 25,
"max_check_duration_minutes": 90,
"max_attempts": 2,
"enabled": True,
"telegram_chat_id": "25164483",
"sms_api_id": SMS_API_ID,
"test_mode": True,
"test_phone": "79788382260",
}
return dict(row)
# ─── SMS API ─────────────────────────────────────────────────────────────────
def normalize_phone(phone):
"""Нормализует телефон: только цифры, начинается с 7"""
clean = "".join(c for c in str(phone) if c.isdigit())
if clean.startswith("8"):
clean = "7" + clean[1:]
elif not clean.startswith("7"):
clean = "7" + clean
return clean
def send_sms(phone, message, api_id):
"""Отправляет SMS, возвращает (sms_id, raw_response)"""
try:
clean_phone = normalize_phone(phone)
resp = requests.post(SMS_SEND_URL, params={
"api_id": api_id,
"to": clean_phone,
}, data={"msg": message}, timeout=30)
text = resp.text
log.info(f"SMS send to {clean_phone}: {text[:200]}")
lines = text.strip().split("\n")
status_code = lines[0].strip() if lines else ""
if status_code == "100" and len(lines) >= 2:
sms_id = lines[1].strip()
return sms_id, text, "100"
else:
return None, text, status_code
except Exception as e:
log.error(f"SMS send error: {e}")
return None, str(e), "error"
def check_sms_status(sms_id, api_id):
"""Проверяет статус, возвращает (sms_status_code, raw_response, api_code)"""
try:
resp = requests.post(SMS_STATUS_URL, params={
"api_id": api_id,
"sms_id": sms_id,
}, timeout=30)
text = resp.text
lines = text.strip().split("\n")
status_code = lines[0].strip() if lines else ""
sms_status_code = lines[1].strip() if len(lines) >= 2 else None
log.info(f"SMS status for {sms_id}: code={status_code}, sms_status={sms_status_code}")
return sms_status_code, text, status_code
except Exception as e:
log.error(f"SMS status check error: {e}")
return None, str(e), "error"
def fetch_balance(api_id):
"""Получает баланс sms.ru, возвращает (balance_float, raw)"""
try:
resp = requests.get("https://sms.ru/my/balance", params={"api_id": api_id}, timeout=15)
text = resp.text
lines = text.strip().split("\n")
if lines[0].strip() == "100" and len(lines) >= 2:
return float(lines[1].strip()), text
return None, text
except Exception as e:
log.error(f"Balance fetch error: {e}")
return None, str(e)
# ─── Telegram ────────────────────────────────────────────────────────────────
def send_telegram(message, chat_id):
if not TELEGRAM_BOT_TOKEN:
log.warning("TELEGRAM_BOT_TOKEN not set, skipping Telegram")
return
try:
resp = requests.post(
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"},
timeout=10,
)
if resp.status_code != 200:
log.warning(f"Telegram error: {resp.text[:200]}")
except Exception as e:
log.warning(f"Telegram failed: {e}")
# ─── State Machine ───────────────────────────────────────────────────────────
def get_groups_to_send(conn):
"""Группы, которым ещё не отправили SMS (или отправили очень давно)"""
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
og.customer_phone_normalized, og.delivery_link, og.notification_status
FROM order_groups og
WHERE og.status = 'ready_to_launch'
AND og.delivery_status = 'pending_confirmation'
AND og.delivery_link IS NOT NULL
AND og.delivery_link != ''
AND COALESCE(og.notification_status, '') = 'link_ready'
-- Нет активной SMS в логе (sent/checking) за последние max_check_duration минут
AND NOT EXISTS (
SELECT 1 FROM sms_campaign_log scl
WHERE scl.order_group_id = og.id
AND scl.campaign_type = 'first_sms'
AND scl.status IN ('sent', 'checking')
AND scl.created_at > NOW() - INTERVAL '2 hours'
)
ORDER BY og.created_at ASC
""")
return [dict(r) for r in cur.fetchall()]
def get_sms_to_check(conn, max_duration_min):
"""SMS в логе со status='sent'/'checking', которые ещё не доставлены.
Включает:
- Записи младше max_duration_min (обычная автопроверка)
- Записи с needs_check=true (admin нажал 'Проверить снова') независимо от возраста
"""
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
scl.attempts, scl.created_at, scl.sms_code, scl.needs_check,
og.customer_name, og.group_key
FROM sms_campaign_log scl
JOIN order_groups og ON og.id = scl.order_group_id
WHERE scl.campaign_type = 'first_sms'
AND scl.status IN ('sent', 'checking')
AND scl.sms_id IS NOT NULL
AND (
scl.created_at > NOW() - INTERVAL '%s minutes'
OR scl.needs_check = true
)
ORDER BY scl.needs_check DESC, scl.created_at ASC
""" % max_duration_min)
return [dict(r) for r in cur.fetchall()]
def get_sms_expired(conn, max_duration_min):
"""SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены)"""
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
scl.attempts, scl.created_at,
og.customer_name, og.group_key, og.delivery_link
FROM sms_campaign_log scl
JOIN order_groups og ON og.id = scl.order_group_id
WHERE scl.campaign_type = 'first_sms'
AND scl.status IN ('sent', 'checking')
AND scl.sms_id IS NOT NULL
AND scl.created_at < NOW() - INTERVAL '%s minutes'
ORDER BY scl.created_at ASC
""" % max_duration_min)
return [dict(r) for r in cur.fetchall()]
def insert_sms_log(conn, **kwargs):
with conn.cursor() as cur:
cols = ", ".join(kwargs.keys())
placeholders = ", ".join(["%s"] * len(kwargs))
cur.execute(f"INSERT INTO sms_campaign_log ({cols}) VALUES ({placeholders}) RETURNING id", list(kwargs.values()))
log_id = cur.fetchone()[0]
conn.commit()
return log_id
def update_sms_log(conn, log_id, **kwargs):
with conn.cursor() as cur:
set_parts = []
values = []
for k, v in kwargs.items():
if v == "NOW()":
set_parts.append(f"{k} = NOW()")
else:
set_parts.append(f"{k} = %s")
values.append(v)
values.append(log_id)
cur.execute(f"UPDATE sms_campaign_log SET {', '.join(set_parts)}, updated_at = NOW() WHERE id = %s", values)
conn.commit()
def update_order_group(conn, group_id, fields):
with conn.cursor() as cur:
set_parts = []
values = []
for k, v in fields.items():
if v == "NOW()":
set_parts.append(f"{k} = NOW()")
else:
set_parts.append(f"{k} = %s")
values.append(v)
values.append(group_id)
cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values)
conn.commit()
# ─── Проверка рабочего времени ────────────────────────────────────────────────
def is_within_work_hours(settings):
"""Проверка: сейчас рабочие часы.
settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5')
"""
now_msk = datetime.now(timezone(timedelta(hours=3)))
today_num = now_msk.weekday() + 1
allowed_days = set()
work_days_str = settings.get("work_days", "1,2,3,4,5")
for part in str(work_days_str).split(","):
part = part.strip()
if part.isdigit():
allowed_days.add(int(part))
if today_num not in allowed_days:
return False
hour = now_msk.hour
start_h = settings.get("work_hours_start", 8)
end_h = settings.get("work_hours_end", 21)
return start_h <= hour < end_h
# ─── Основная логика ─────────────────────────────────────────────────────────
def step_send_new(conn, settings):
"""Шаг 1: Отправка SMS новым группам"""
api_id = settings.get("sms_api_id", SMS_API_ID)
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
max_attempts = settings.get("max_attempts", 2)
second_sms_delay = settings.get("second_sms_delay_hours", 3)
groups = get_groups_to_send(conn)
log.info(f"Step 1: {len(groups)} groups to send SMS")
sent_count = 0
for group in groups:
group_id = str(group["id"])
name = group.get("customer_name") or group.get("group_key", "")
phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
delivery_link = group.get("delivery_link", "")
# Проверяем: была ли уже SMS этой группе сегодня (защита от повторной отправки)
with conn.cursor() as cur:
cur.execute("""
SELECT COUNT(*) as cnt FROM sms_campaign_log
WHERE order_group_id = %s AND campaign_type = 'first_sms'
AND created_at > NOW() - INTERVAL '24 hours'
AND status IN ('sent', 'checking', 'delivered')
""", (group_id,))
recent_count = cur.fetchone()[0]
if recent_count > 0:
log.info(f"Group {group_id}: already has recent SMS in log, skipping")
continue
sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}"
# ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый
send_phone = phone
if settings.get("test_mode", True):
send_phone = settings.get("test_phone", "79788382260")
log.info(f"TEST MODE: sending to {send_phone} instead of {phone}")
log.info(f"Sending SMS to {name} (orig={phone}, send={send_phone})")
sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
if sms_id:
log_id = insert_sms_log(conn,
campaign_type="first_sms",
order_group_id=group_id,
customer_phone=phone,
sms_id=sms_id,
sms_text=sms_text,
status="sent",
sms_code=code,
attempts=1,
)
# ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status,
# чтобы get_groups_to_send не нашёл эту группу при следующем запуске
update_order_group(conn, group_id, {
"notification_status": "sms_sending",
"sms_sent_at": "NOW()",
})
log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}, log_id={log_id}, notification_status→sms_sending")
sent_count += 1
else:
# Ошибка отправки
error = raw[:500] if raw else "Unknown error"
log_id = insert_sms_log(conn,
campaign_type="first_sms",
order_group_id=group_id,
customer_phone=phone,
sms_text=sms_text,
status="send_failed",
sms_code=code,
attempts=1,
error_message=error,
)
log.error(f"Group {group_id}: SMS send failed (code={code}): {error[:200]}")
update_order_group(conn, group_id, {
"notification_status": "send_failed",
"last_sms_error": error[:200],
})
send_telegram(f"❌ SMS не отправлена: {name} ({phone})\nКод: {code}\nОшибка: {error[:200]}", tg_chat)
return sent_count
def step_check_status(conn, settings):
"""Шаг 2: Проверка статуса ранее отправленных SMS"""
api_id = settings.get("sms_api_id", SMS_API_ID)
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
max_duration = settings.get("max_check_duration_minutes", 90)
max_attempts = settings.get("max_attempts", 2)
second_sms_delay = settings.get("second_sms_delay_hours", 3)
sms_list = get_sms_to_check(conn, max_duration)
log.info(f"Step 2: {len(sms_list)} SMS to check status")
delivered = 0
for item in sms_list:
log_id = item["log_id"]
sms_id = item["sms_id"]
group_id = str(item["order_group_id"])
name = item.get("customer_name") or item.get("group_key", "")
phone = item.get("customer_phone", "")
attempts = item.get("attempts", 1)
was_needs_check = item.get("needs_check", False)
code, raw, api_code = check_sms_status(sms_id, api_id)
# Сбрасываем needs_check + ставим checked_at
update_sms_log(conn, log_id, needs_check=False, checked_at="NOW()")
if code == DELIVERED_CODE:
# Доставлено!
log.info(f"Group {group_id}: SMS delivered (103)!")
update_sms_log(conn, log_id, status="delivered", sms_code=code)
next_check = (datetime.now(timezone.utc) + timedelta(hours=second_sms_delay)).isoformat()
update_order_group(conn, group_id, {
"notification_status": "first_sms_sent",
"sms_attempts": attempts,
"first_sms_sent_at": "NOW()",
"last_sms_error": None,
"sms_sent_at": "NOW()",
"next_notification_check_at": next_check,
"status": "first_sms_sent",
})
send_telegram(f"✅ SMS доставлена: {name} ({phone})", tg_chat)
delivered += 1
elif code in IN_TRANSIT_CODES:
# В пути / в очереди — продолжаем ждать
log.info(f"Group {group_id}: SMS in transit (code={code}), will check again next run")
update_sms_log(conn, log_id, status="checking", sms_code=code)
elif code in DELIVERY_ERROR_CODES:
# Ошибка доставки — можно повторить отправку
log.error(f"Group {group_id}: SMS delivery error (code={code})")
update_sms_log(conn, log_id, status="error", sms_code=code,
error_message=f"Delivery error: {code}")
# Переход к следующей попытке отправки (если есть)
if attempts < max_attempts:
log.info(f"Group {group_id}: will retry send on next run (attempt {attempts+1}/{max_attempts})")
update_sms_log(conn, log_id, status="expired")
# Сбрасываем notification_status → link_ready для повторной отправки
update_order_group(conn, group_id, {
"notification_status": "link_ready",
})
else:
update_order_group(conn, group_id, {
"notification_status": "send_failed",
"last_sms_error": f"Delivery failed after {max_attempts} attempts (code={code})",
})
send_telegram(f"⚠️ SMS не доставлена после {max_attempts} попыток: {name} ({phone})\nКод: {code}", tg_chat)
elif code in LIMIT_ERROR_CODES:
# Превышен лимит — НЕ повторять отправку!
log.error(f"Group {group_id}: SMS limit exceeded (code={code}) — NOT retrying")
update_sms_log(conn, log_id, status="limit_exceeded", sms_code=code,
error_message=f"Limit exceeded: code={code}")
update_order_group(conn, group_id, {
"notification_status": "send_failed",
"last_sms_error": f"Limit exceeded (code={code}), sending blocked",
})
send_telegram(f"🚫 SMS заблокирована (лимит {code}): {name} ({phone})", tg_chat)
else:
# Неизвестный код — логируем, продолжаем проверять
log.warning(f"Group {group_id}: unknown SMS code: {code}")
update_sms_log(conn, log_id, status="checking", sms_code=code,
error_message=f"Unknown code: {code}")
return delivered
def step_handle_expired(conn, settings):
"""Шаг 3: Обработка SMS с истёкшим сроком проверки"""
max_duration = settings.get("max_check_duration_minutes", 90)
max_attempts = settings.get("max_attempts", 2)
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
expired = get_sms_expired(conn, max_duration)
log.info(f"Step 3: {len(expired)} SMS expired (older than {max_duration} min)")
for item in expired:
log_id = item["log_id"]
group_id = str(item["order_group_id"])
name = item.get("customer_name") or item.get("group_key", "")
phone = item.get("customer_phone", "")
attempts = item.get("attempts", 1)
delivery_link = item.get("delivery_link", "")
log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}")
update_sms_log(conn, log_id, status="expired",
error_message=f"Not delivered in {max_duration} minutes")
if attempts < max_attempts:
# Сбрасываем notification_status → link_ready для повторной отправки
update_order_group(conn, group_id, {
"notification_status": "link_ready",
})
log.info(f"Group {group_id}: will retry SMS send (attempt {attempts+1}), notification_status→link_ready")
else:
# Все попытки исчерпаны
update_order_group(conn, group_id, {
"notification_status": "manual_required",
"last_sms_error": f"Not delivered after {max_attempts} attempts",
})
send_telegram(
f"🔧 Требуется ручное управление: {name} ({phone})\n"
f"SMS не доставлена после {max_attempts} попыток",
tg_chat,
)
# ─── Main ────────────────────────────────────────────────────────────────────
def main():
log.info("=" * 60)
log.info("SMS First Campaign — START")
conn = get_db_conn()
try:
settings = load_settings(conn)
log.info(f"Settings: wait={settings.get('wait_between_checks_seconds')}s, "
f"max_duration={settings.get('max_check_duration_minutes')}min, "
f"max_attempts={settings.get('max_attempts')}")
if not settings.get("enabled", True):
log.info("Campaign disabled, exiting")
return
# State machine — каждый шаг быстрый, без blocking
# Отправка только в рабочие часы
work_hours = is_within_work_hours(settings)
sent = 0
if work_hours:
sent = step_send_new(conn, settings)
else:
log.info("Outside work hours, skipping new SMS sends")
# Проверка статусов работает всегда
delivered = step_check_status(conn, settings)
step_handle_expired(conn, settings)
log.info(f"Run summary: sent={sent}, delivered={delivered}")
if sent > 0 or delivered > 0:
send_telegram(
f"📊 <b>Первая отправка</b>\nОтправлено: {sent}\nДоставлено: {delivered}",
settings.get("telegram_chat_id", TELEGRAM_CHAT_ID),
)
# Обновляем баланс sms.ru
if sent > 0:
balance, raw = fetch_balance(api_id)
if balance is not None:
with conn.cursor() as cur:
cur.execute(
"UPDATE sms_campaign_settings SET last_balance = %s WHERE campaign_type = 'first_sms'",
(balance,)
)
conn.commit()
log.info(f"Balance updated: {balance}")
except Exception as e:
log.error(f"Fatal error: {e}", exc_info=True)
finally:
conn.close()
log.info("SMS First Campaign — END")
log.info("=" * 60)
if __name__ == "__main__":
main()

View File

@ -76,22 +76,6 @@ def send_telegram(message, chat_id):
# ─── Проверка рабочего времени ────────────────────────────────────────────────
def get_sms_days(conn):
"""Read sms_days from business_schedule_settings. Returns comma-separated string."""
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1")
row = cur.fetchone()
if row and row.get("sms_days"):
days = row["sms_days"]
if isinstance(days, list):
return ",".join(str(d) for d in days)
return str(days)
except Exception:
pass
return "1,2,3,4,5" # Default: Mon-Fri
def is_within_work_hours(settings):
now_msk = datetime.now(timezone(timedelta(hours=3)))
today_num = now_msk.weekday() + 1
@ -139,16 +123,9 @@ def get_groups_to_manual(conn):
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
AND og.first_sms_sent_at < NOW() - INTERVAL '3 hours'
AND og.second_sms_sent_at IS NOT NULL)
-- 3. link_ready + next_check в прошлом + SMS уже отправлялась хотя бы раз
-- ВАЖНО: если SMS никогда не отправлялась (нет записи в логе), не переводить в manual --
-- возможно first campaign ещё не успел отправить (БД была недоступна и т.п.)
-- 3. link_ready + next_check в прошлом + нет активных SMS в логе
OR (COALESCE(og.notification_status, '') = 'link_ready'
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
AND EXISTS (
SELECT 1 FROM sms_campaign_log scl
WHERE scl.order_group_id = og.id
AND scl.status IN ('sent', 'checking', 'delivered')
)
AND NOT EXISTS (
SELECT 1 FROM sms_campaign_log scl
WHERE scl.order_group_id = og.id
@ -233,8 +210,6 @@ def main():
try:
settings = load_settings(conn)
# Override work_days with business_schedule sms_days
settings["work_days"] = get_sms_days(conn)
# Update last_run_at timestamp
with conn.cursor() as cur:

View File

@ -21,8 +21,6 @@ SMS текст: "Ваш заказ переведён на платное хра
import os
import sys
import logging
import time
import fcntl
from datetime import datetime, timezone, timedelta
import requests
@ -89,36 +87,16 @@ def load_settings(conn):
# ─── SMS API ─────────────────────────────────────────────────────────────────
def normalize_phone(phone):
"""Нормализует телефон: только цифры, начинается с 7. Возвращает None если пустой.
Обрабатывает:
- +7XXX, 7XXX, 8XXX, XXX (без кода страны) 7XXX
- Текст/имена в поле (Екатерина +7978...) извлекает только цифры
- Два номера в одном поле (XXX / YYY) берёт первый
- Минимум 10 цифр, максимум 11
"""
clean = "".join(c for c in str(phone) if c.isdigit())
if len(clean) < 10:
return None
if len(clean) > 11:
clean = clean[:10]
if clean.startswith("8") and len(clean) == 11:
if clean.startswith("8"):
clean = "7" + clean[1:]
elif len(clean) == 10:
elif not clean.startswith("7"):
clean = "7" + clean
elif not clean.startswith("7") and len(clean) == 11:
clean = "7" + clean[1:]
return clean
def send_sms(phone, message, api_id):
try:
clean_phone = normalize_phone(phone)
if not clean_phone:
log.error(f"Invalid phone: {phone}")
return None, "Invalid phone", "error"
resp = requests.post(SMS_SEND_URL, params={"api_id": api_id, "to": clean_phone},
data={"msg": message}, timeout=30)
text = resp.text
@ -176,22 +154,6 @@ def send_telegram(message, chat_id):
# ─── Проверка рабочего времени ────────────────────────────────────────────────
def get_sms_days(conn):
"""Read sms_days from business_schedule_settings. Returns comma-separated string."""
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1")
row = cur.fetchone()
if row and row.get("sms_days"):
days = row["sms_days"]
if isinstance(days, list):
return ",".join(str(d) for d in days)
return str(days)
except Exception:
pass
return "1,2,3,4,5" # Default: Mon-Fri
def is_within_work_hours(settings):
now_msk = datetime.now(timezone(timedelta(hours=3)))
today_num = now_msk.weekday() + 1
@ -305,19 +267,10 @@ def update_order_group(conn, group_id, fields):
# ─── Основная логика ─────────────────────────────────────────────────────────
def step_send_new(conn, settings, test_send=False):
def step_send_new(conn, settings):
api_id = settings.get("sms_api_id", SMS_API_ID)
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
if test_send:
# Test mode: send ONE SMS to test phone, pick first available group
all_groups = get_groups_to_send(conn)
if not all_groups:
log.info("Test send: no groups available in queue")
return 0
groups = [all_groups[0]] # Only first group
log.info(f"TEST SEND: sending 1 SMS to test phone (skipping {len(all_groups)-1} others)")
else:
groups = get_groups_to_send(conn)
log.info(f"Step 1: {len(groups)} groups to send paid_storage SMS")
@ -339,8 +292,11 @@ def step_send_new(conn, settings, test_send=False):
log.info(f"Group {group_id}: already has recent paid_storage SMS, skipping")
continue
sms_text_template = settings.get("sms_text_template", "Ваш заказ переведён на платное хранение. Стоимость: 300 ₽/день. Заберите заказ или согласуйте доставку: {link}")
sms_text = sms_text_template.replace("{link}", delivery_link)
sms_text = (
f"Ваш заказ переведён на платное хранение. "
f"Стоимость: 300 ₽/день. "
f"Заберите заказ или согласуйте доставку: {delivery_link}"
)
send_phone = phone
if settings.get("test_mode", True):
@ -357,11 +313,9 @@ def step_send_new(conn, settings, test_send=False):
customer_phone=phone,
sms_id=sms_id,
sms_text=sms_text,
sent_to=send_phone,
status="sent",
sms_code=code,
attempts=1,
was_test_mode=bool(settings.get("test_mode", True)),
)
update_order_group(conn, group_id, {
"notification_status": "paid_storage_sending",
@ -369,7 +323,6 @@ def step_send_new(conn, settings, test_send=False):
})
log.info(f"Group {group_id}: paid_storage SMS sent, sms_id={sms_id}, notification_status→paid_storage_sending")
sent_count += 1
time.sleep(settings.get("send_interval_seconds", 15)) # Configurable rate limit
else:
error = raw[:500] if raw else "Unknown error"
insert_sms_log(conn,
@ -377,12 +330,10 @@ def step_send_new(conn, settings, test_send=False):
order_group_id=group_id,
customer_phone=phone,
sms_text=sms_text,
sent_to=send_phone if "send_phone" in dir() else phone,
status="send_failed",
sms_code=code,
attempts=1,
error_message=error,
was_test_mode=bool(settings.get("test_mode", True)),
)
log.error(f"Group {group_id}: paid_storage SMS failed (code={code}): {error[:200]}")
update_order_group(conn, group_id, {"last_sms_error": error[:200]})
@ -421,19 +372,9 @@ def step_check_status(conn, settings):
elif code in DELIVERY_ERROR_CODES:
log.error(f"Group {group_id}: delivery error (code={code})")
update_sms_log(conn, log_id, status="error", sms_code=code, error_message=f"Delivery error: {code}")
attempts = item.get("attempts", 1)
max_attempts = settings.get("max_attempts", 2)
if attempts < max_attempts:
update_sms_log(conn, log_id, status="expired")
# Сброс для retry — вернёмся к исходному статусу
update_order_group(conn, group_id, {"notification_status": "not_started"})
log.info(f"Group {group_id}: will retry paid_storage SMS (attempt {attempts+1}/{max_attempts})")
else:
update_order_group(conn, group_id, {
"notification_status": "manual_required",
"last_sms_error": f"Paid storage SMS failed after {max_attempts} attempts (code={code})",
})
send_telegram(f"⚠️ SMS платное хранение не доставлена после {max_attempts} попыток: {name} ({phone})", tg_chat)
# Сброс для retry
update_order_group(conn, group_id, {"notification_status": "manual_required"})
elif code in LIMIT_ERROR_CODES:
log.error(f"Group {group_id}: limit exceeded (code={code})")
update_sms_log(conn, log_id, status="limit_exceeded", sms_code=code, error_message=f"Limit: {code}")
@ -463,66 +404,18 @@ def step_handle_expired(conn, settings):
log.warning(f"Group {group_id}: paid_storage SMS expired")
update_sms_log(conn, log_id, status="expired", error_message=f"Not delivered in {max_duration} min")
attempts = item.get("attempts", 1)
max_attempts = settings.get("max_attempts", 2)
if attempts < max_attempts:
# Retry: сброс на not_started для повторной отправки
update_order_group(conn, group_id, {"notification_status": "not_started"})
log.info(f"Group {group_id}: will retry paid_storage SMS (attempt {attempts+1}/{max_attempts})")
else:
# Все попытки исчерпаны — ручное управление, НЕ paid_storage_sent
update_order_group(conn, group_id, {
"notification_status": "manual_required",
"last_sms_error": f"Paid storage SMS not delivered after {max_attempts} attempts",
})
send_telegram(f"⚠️ SMS платное хранение не доставлена после {max_attempts} попыток: {name} ({phone})", tg_chat)
update_order_group(conn, group_id, {"notification_status": "paid_storage_sent"})
send_telegram(f"⚠️ SMS платное хранение не доставлена: {name} ({phone})", tg_chat)
# ─── Main ────────────────────────────────────────────────────────────────────
def main():
# File lock — prevent parallel execution
lock_file = open("/tmp/" + __file__.split("/")[-1].replace(".py", ".lock"), "w")
try:
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
except (IOError, OSError):
log.info("Another instance is running, exiting")
lock_file.close()
return
log.info("=" * 60)
log.info("Paid Storage Campaign — START")
conn = get_db_conn()
try:
settings = load_settings(conn)
# Override work_days with business_schedule sms_days
settings["work_days"] = get_sms_days(conn)
# Update last_run_at timestamp
with conn.cursor() as cur:
cur.execute("UPDATE sms_campaign_settings SET last_run_at = NOW() WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
conn.commit()
# Check test_send_requested — one-time test send
test_send_requested = bool(settings.get("test_send_requested", False))
if test_send_requested:
# Reset flag immediately
with conn.cursor() as cur:
cur.execute("UPDATE sms_campaign_settings SET test_send_requested = false WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
conn.commit()
log.info("Test send requested — will send ONE SMS to test phone only")
# Check run_requested — restart scenario
run_requested = bool(settings.get('run_requested', False))
if run_requested:
with conn.cursor() as cur:
cur.execute("UPDATE sms_campaign_settings SET run_requested = false WHERE campaign_type = 'paid_storage'")
conn.commit()
with conn.cursor() as cur:
cur.execute("""UPDATE order_groups SET notification_status = 'not_started', sms_sent_at = NULL, next_notification_check_at = NULL, sms_attempts = 0, last_sms_error = NULL WHERE delivery_status = 'paid_storage' AND notification_status IN ('paid_storage_sent','paid_storage_sending','manual_required','send_failed')""")
reset_count = cur.rowcount
conn.commit()
log.info(f'RESTART paid_storage: reset {reset_count} groups')
log.info(f"Settings: work={settings.get('work_hours_start')}-{settings.get('work_hours_end')}, "
f"days={settings.get('work_days')}, test={settings.get('test_mode')}")
@ -534,7 +427,7 @@ def main():
work_hours = is_within_work_hours(settings)
sent = 0
if work_hours:
sent = step_send_new(conn, settings, test_send=test_send_requested)
sent = step_send_new(conn, settings)
else:
log.info("Outside work hours, skipping new SMS sends")
@ -548,7 +441,6 @@ def main():
send_telegram(
f"📦 <b>Платное хранение</b>\nОтправлено: {sent}\nДоставлено: {delivered}",
settings.get("telegram_chat_id", TELEGRAM_CHAT_ID),
was_test_mode=bool(settings.get("test_mode", True)),
)
# Обновляем баланс
@ -568,8 +460,6 @@ def main():
log.error(f"Fatal error: {e}", exc_info=True)
finally:
conn.close()
fcntl.flock(lock_file, fcntl.LOCK_UN)
lock_file.close()
log.info("Paid Storage Campaign — END")
log.info("=" * 60)

View File

@ -29,8 +29,6 @@ import os
import sys
import json
import logging
import time
import fcntl
from datetime import datetime, timezone, timedelta
import requests
@ -111,46 +109,18 @@ def load_settings(conn):
# ─── SMS API ─────────────────────────────────────────────────────────────────
def normalize_phone(phone):
"""Нормализует телефон: только цифры, начинается с 7. Возвращает None если пустой.
Обрабатляет:
- +7XXX, 7XXX, 8XXX, XXX (без кода страны) 7XXX
- Текст/имена в поле (Екатерина +7978...) извлекает только цифры
- Два номера в одном поле (XXX / YYY) берёт первый (обрезает до 11 цифр)
- Минимум 10 цифр (без 7), максимум 11 (с 7)
"""
"""Нормализует телефон: только цифры, начинается с 7"""
clean = "".join(c for c in str(phone) if c.isdigit())
if len(clean) < 10:
return None
# If 8 prefix (Russian landline style) → replace with 7
if clean.startswith("8") and len(clean) == 11:
if clean.startswith("8"):
clean = "7" + clean[1:]
# If starts with 7 and is 11 digits → ok
elif clean.startswith("7") and len(clean) == 11:
pass
# If 10 digits (no country code) → prepend 7
elif len(clean) == 10:
clean = "7" + clean
# If too long (multiple numbers concatenated) → take first 10 digits + prepend 7
elif len(clean) > 11:
# Try to extract first number: look for 10-digit sequence starting with 9
# Common case: "9242377967 / 89783225219" → "9242377967" (10 digits)
clean10 = clean[:10]
clean = "7" + clean10
# If starts with 7 but wrong length → prepend 7 to first 10 digits
elif not clean.startswith("7"):
clean = "7" + clean
return clean
def send_sms(phone, message, api_id):
"""Отправляет SMS, возвращает (sms_id, raw_response, code)"""
try:
clean_phone = normalize_phone(phone)
if not clean_phone:
log.error(f"Invalid phone: {phone}")
return None, "Invalid phone", "error"
resp = requests.post(SMS_SEND_URL, params={
"api_id": api_id,
"to": clean_phone,
@ -204,7 +174,19 @@ def fetch_balance(api_id):
# ─── Telegram ────────────────────────────────────────────────────────────────
def send_telegram(message, chat_id):
pass # Telegram notifications moved to n8n+Supabase integration
if not TELEGRAM_BOT_TOKEN:
log.warning("TELEGRAM_BOT_TOKEN not set, skipping Telegram")
return
try:
resp = requests.post(
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"},
timeout=10,
)
if resp.status_code != 200:
log.warning(f"Telegram error: {resp.text[:200]}")
except Exception as e:
log.warning(f"Telegram failed: {e}")
# ─── State Machine ───────────────────────────────────────────────────────────
@ -216,8 +198,7 @@ def get_groups_to_send(conn):
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
og.customer_phone_normalized, og.delivery_link, og.notification_status,
og.sms_attempts
og.customer_phone_normalized, og.delivery_link, og.notification_status
FROM order_groups og
WHERE og.delivery_status = 'pending_confirmation'
AND og.delivery_link IS NOT NULL
@ -262,19 +243,12 @@ def get_sms_to_check(conn, max_duration_min):
return [dict(r) for r in cur.fetchall()]
def get_sms_expired(conn, max_duration_min):
"""Вторые SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены).
Также считаем общее количество failed попыток для группы.
"""
"""Вторые SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены)"""
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
scl.attempts, scl.created_at,
og.customer_name, og.group_key, og.delivery_link,
og.sms_attempts as group_sms_attempts,
(SELECT COUNT(*) FROM sms_campaign_log scl2
WHERE scl2.order_group_id = scl.order_group_id
AND scl2.campaign_type = 'second_sms'
AND scl2.status IN ('expired', 'error', 'limit_exceeded')) as total_failed
og.customer_name, og.group_key, og.delivery_link
FROM sms_campaign_log scl
JOIN order_groups og ON og.id = scl.order_group_id
WHERE scl.campaign_type = 'second_sms'
@ -284,6 +258,7 @@ def get_sms_expired(conn, max_duration_min):
ORDER BY scl.created_at ASC
""" % max_duration_min)
return [dict(r) for r in cur.fetchall()]
def insert_sms_log(conn, **kwargs):
with conn.cursor() as cur:
cols = ", ".join(kwargs.keys())
@ -323,22 +298,6 @@ def update_order_group(conn, group_id, fields):
# ─── Проверка рабочего времени ────────────────────────────────────────────────
def get_sms_days(conn):
"""Read sms_days from business_schedule_settings. Returns comma-separated string."""
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1")
row = cur.fetchone()
if row and row.get("sms_days"):
days = row["sms_days"]
if isinstance(days, list):
return ",".join(str(d) for d in days)
return str(days)
except Exception:
pass
return "1,2,3,4,5" # Default: Mon-Fri
def is_within_work_hours(settings):
"""Проверка: сейчас рабочие часы.
settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5')
@ -362,20 +321,11 @@ def is_within_work_hours(settings):
# ─── Основная логика ─────────────────────────────────────────────────────────
def step_send_new(conn, settings, test_send=False):
def step_send_new(conn, settings):
"""Шаг 1: Отправка второй SMS группам, где первая доставлена, но нет согласования"""
api_id = settings.get("sms_api_id", SMS_API_ID)
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
if test_send:
# Test mode: send ONE SMS to test phone, pick first available group
all_groups = get_groups_to_send(conn)
if not all_groups:
log.info("Test send: no groups available in queue")
return 0
groups = [all_groups[0]] # Only first group
log.info(f"TEST SEND: sending 1 SMS to test phone (skipping {len(all_groups)-1} others)")
else:
groups = get_groups_to_send(conn)
log.info(f"Step 1: {len(groups)} groups to send second SMS")
@ -399,8 +349,7 @@ def step_send_new(conn, settings, test_send=False):
log.info(f"Group {group_id}: already has recent second SMS in log, skipping")
continue
sms_text_template = settings.get("sms_text_template", "Ваш заказ готов к доставке. Выберите дату доставки по ссылке: {link}")
sms_text = sms_text_template.replace("{link}", delivery_link + "?src=sms")
sms_text = f"Ваш заказ готов к доставке. Выберите дату доставки по ссылке: {delivery_link}"
# ТЕСТОВЫЙ РЕЖИМ
send_phone = phone
@ -412,28 +361,23 @@ def step_send_new(conn, settings, test_send=False):
sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
if sms_id:
current_attempts = (group.get("sms_attempts") or 0) + 1
log_id = insert_sms_log(conn,
campaign_type=CAMPAIGN_TYPE,
order_group_id=group_id,
customer_phone=phone,
sms_id=sms_id,
sms_text=sms_text,
sent_to=send_phone,
status="sent",
sms_code=code,
attempts=current_attempts,
was_test_mode=bool(settings.get("test_mode", True)),
attempts=1,
)
# ДВОЙНАЯ ЗАЩИТА: notification_status → second_sms_sending
update_order_group(conn, group_id, {
"notification_status": "second_sms_sending",
"sms_sent_at": "NOW()",
"sms_attempts": current_attempts,
})
log.info(f"Group {group_id}: second SMS sent, sms_id={sms_id}, log_id={log_id}, notification_status→second_sms_sending")
sent_count += 1
time.sleep(settings.get("send_interval_seconds", 15)) # Configurable rate limit
else:
error = raw[:500] if raw else "Unknown error"
log_id = insert_sms_log(conn,
@ -441,12 +385,10 @@ def step_send_new(conn, settings, test_send=False):
order_group_id=group_id,
customer_phone=phone,
sms_text=sms_text,
sent_to=send_phone if "send_phone" in dir() else phone,
status="send_failed",
sms_code=code,
attempts=1,
error_message=error,
was_test_mode=bool(settings.get("test_mode", True)),
)
log.error(f"Group {group_id}: second SMS send failed (code={code}): {error[:200]}")
update_order_group(conn, group_id, {
@ -510,13 +452,11 @@ def step_check_status(conn, settings):
update_sms_log(conn, log_id, status="error", sms_code=code,
error_message=f"Delivery error: {code}")
if attempts < max_attempts:
log.info(f"Group {group_id}: retry second SMS (attempt {attempts+1}/{max_attempts})")
log.info(f"Group {group_id}: will retry second SMS send (attempt {attempts+1}/{max_attempts})")
update_sms_log(conn, log_id, status="expired")
# Сбрасываем на first_sms_sent для повторной отправки
update_order_group(conn, group_id, {
"notification_status": "first_sms_sent",
"sms_attempts": attempts,
"second_sms_sent_at": None,
"next_notification_check_at": None,
})
else:
update_order_group(conn, group_id, {
@ -559,31 +499,22 @@ def step_handle_expired(conn, settings):
name = item.get("customer_name") or item.get("group_key", "")
phone = item.get("customer_phone", "")
attempts = item.get("attempts", 1)
total_failed = item.get("total_failed", 0)
effective_attempts = max(attempts, total_failed)
log.warning(f"Group {group_id}: second SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}, total_failed={total_failed}")
log.warning(f"Group {group_id}: second SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}")
update_sms_log(conn, log_id, status="expired",
error_message=f"Not delivered in {max_duration} minutes")
if effective_attempts < max_attempts:
# Повторная отправка 2й SMS (остаёмся в текущем шаге)
# second_sms_sent_at = NULL чтобы get_groups_to_send нашёл группу
if attempts < max_attempts:
# Сбрасываем на first_sms_sent для повторной отправки
update_order_group(conn, group_id, {
"notification_status": "first_sms_sent",
"sms_attempts": effective_attempts,
"second_sms_sent_at": None,
"next_notification_check_at": None,
})
log.info(f"Group {group_id}: retry second SMS (attempt {effective_attempts+1}/{max_attempts}), stays in second step")
log.info(f"Group {group_id}: will retry second SMS send (attempt {attempts+1}), notification_status→first_sms_sent")
else:
# Все попытки исчерпаны → ручное управление
update_order_group(conn, group_id, {
"notification_status": "manual_required",
"delivery_status": "manual_confirmation_required",
"last_sms_error": f"Second SMS not delivered after {max_attempts} attempts",
"status": "manual_required",
})
send_telegram(
f"🔧 Требуется ручное управление (2-я SMS): {name} ({phone})\n"
@ -594,50 +525,12 @@ def step_handle_expired(conn, settings):
# ─── Main ────────────────────────────────────────────────────────────────────
def main():
# File lock — prevent parallel execution
lock_file = open("/tmp/" + __file__.split("/")[-1].replace(".py", ".lock"), "w")
try:
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
except (IOError, OSError):
log.info("Another instance is running, exiting")
lock_file.close()
return
log.info("=" * 60)
log.info("SMS Second Campaign — START")
conn = get_db_conn()
try:
settings = load_settings(conn)
# Override work_days with business_schedule sms_days
settings["work_days"] = get_sms_days(conn)
# Update last_run_at timestamp
with conn.cursor() as cur:
cur.execute("UPDATE sms_campaign_settings SET last_run_at = NOW() WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
conn.commit()
# Check test_send_requested — one-time test send
test_send_requested = bool(settings.get("test_send_requested", False))
if test_send_requested:
# Reset flag immediately
with conn.cursor() as cur:
cur.execute("UPDATE sms_campaign_settings SET test_send_requested = false WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
conn.commit()
log.info("Test send requested — will send ONE SMS to test phone only")
# Check run_requested — restart scenario (reset all groups to beginning)
run_requested = bool(settings.get('run_requested', False))
if run_requested:
with conn.cursor() as cur:
cur.execute("UPDATE sms_campaign_settings SET run_requested = false WHERE campaign_type = 'second_sms'")
conn.commit()
# Reset groups that passed first_sms back to first_sms_sent — restart second SMS scenario
with conn.cursor() as cur:
cur.execute("""UPDATE order_groups SET notification_status = 'first_sms_sent', sms_sent_at = NULL, second_sms_sent_at = NULL, next_notification_check_at = NULL, sms_attempts = 0, last_sms_error = NULL WHERE delivery_status = 'pending_confirmation' AND notification_status IN ('second_sms_sent','second_sms_sending','manual_required','send_failed')""")
reset_count = cur.rowcount
conn.commit()
log.info(f'RESTART second_sms: reset {reset_count} groups to first_sms_sent')
log.info(f"Settings: wait={settings.get('wait_between_checks_seconds')}s, "
f"max_duration={settings.get('max_check_duration_minutes')}min, "
f"max_attempts={settings.get('max_attempts')}")
@ -651,7 +544,7 @@ def main():
work_hours = is_within_work_hours(settings)
sent = 0
if work_hours:
sent = step_send_new(conn, settings, test_send=test_send_requested)
sent = step_send_new(conn, settings)
else:
log.info("Outside work hours (8-21 MSK, Mon-Fri), skipping new SMS sends")
@ -684,8 +577,6 @@ def main():
log.error(f"Fatal error: {e}", exc_info=True)
finally:
conn.close()
fcntl.flock(lock_file, fcntl.LOCK_UN)
lock_file.close()
log.info("SMS Second Campaign — END")
log.info("=" * 60)

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@ import {
isOrderGroupVisibleToDriver,
groupOrderGroupsByDate,
parseGroupDate,
ORDER_GROUP_DELIVERY_HALF_DAY_OPTIONS,
} from "../../services/orderGroupViews";
import { Badge } from "../UI/Badge";
import { Button } from "../UI/Button";
@ -110,13 +111,29 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
selectedDate: "",
deliveryStatus: "all",
selectedCity: "",
searchQuery: "",
halfDay: "all",
});
const [collapsedDates, setCollapsedDates] = React.useState({});
const hasActiveFilters = filters.selectedDate || filters.deliveryStatus !== "all" || filters.selectedCity || filters.searchQuery || filters.halfDay !== "all";
const resetAllFilters = () => {
setFilters({ selectedDate: "", deliveryStatus: "all", selectedCity: "", searchQuery: "", halfDay: "all" });
};
const toggleDate = (date) => {
setCollapsedDates((prev) => ({ ...prev, [date]: !prev[date] }));
};
const normalizePhoneForTel = (phone) => {
const cleaned = String(phone || "").trim();
if (!cleaned) return "";
if (cleaned.startsWith("+7")) return cleaned;
if (cleaned.startsWith("8")) return "+7" + cleaned.slice(1);
return "+7" + cleaned;
};
const driverOrderGroups = React.useMemo(
() => orderGroups.filter((group) => {
const isVisible = isOrderGroupVisibleToDriver(group);
@ -158,6 +175,19 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
});
}, [cityDeliveryMap]);
const getSearchHaystack = (group) => {
return [
group.groupKey,
group.displayTitle,
group.customerName,
group.customerPhone,
group.customerDate,
Array.isArray(group.orderNumbers) ? group.orderNumbers.join(" ") : "",
group.deliveryAddress || group.delivery_address || "",
group.city || "",
].filter(Boolean).join(" ").toLowerCase();
};
const filteredOrderGroups = React.useMemo(() => {
let result = [...driverOrderGroups];
if (filters.selectedDate) {
@ -172,8 +202,39 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
return city === filters.selectedCity;
});
}
if (filters.halfDay !== "all") {
result = result.filter((group) => {
const groupHalfDay = getOrderGroupDeliveryHalfDay(group);
if (filters.halfDay === "unknown") {
return !groupHalfDay;
}
const labelMap = { morning: "Первая половина дня", afternoon: "Вторая половина дня" };
return groupHalfDay === labelMap[filters.halfDay];
});
}
const query = (filters.searchQuery || "").trim().toLowerCase();
if (query) {
result = result.filter((group) => getSearchHaystack(group).includes(query));
}
// Sort: problems first, then by status priority
const statusPriority = ["problem", "on_route", "loaded", "driver_assigned", "agreed", "delivered", "picked_up", "paid_storage"];
result.sort((a, b) => {
// has_delivery_problem always first
const aProblem = a.hasDeliveryProblem || a.has_delivery_problem;
const bProblem = b.hasDeliveryProblem || b.has_delivery_problem;
if (aProblem && !bProblem) return -1;
if (!aProblem && bProblem) return 1;
const sa = a.deliveryStatus || a.delivery_status || "unknown";
const sb = b.deliveryStatus || b.delivery_status || "unknown";
const ia = statusPriority.indexOf(sa);
const ib = statusPriority.indexOf(sb);
if (ia === -1 && ib === -1) return 0;
if (ia === -1) return 1;
if (ib === -1) return -1;
return ia - ib;
});
return result;
}, [driverOrderGroups, filters.selectedDate, filters.deliveryStatus, filters.selectedCity]);
}, [driverOrderGroups, filters.selectedDate, filters.deliveryStatus, filters.selectedCity, filters.searchQuery, filters.halfDay]);
const groupedOrderGroups = React.useMemo(
() => groupOrderGroupsByDate(filteredOrderGroups),
@ -245,6 +306,49 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
</label>
</div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<label className="flex min-w-0 flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Поиск
</span>
<Input
type="text"
placeholder="Имя, адрес, телефон, номер заказа..."
value={filters.searchQuery}
onChange={(event) => setFilters((current) => ({ ...current, searchQuery: event.target.value }))}
/>
</label>
<label className="flex min-w-0 flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Время дня
</span>
<Select
value={filters.halfDay}
onChange={(event) => setFilters((current) => ({ ...current, halfDay: event.target.value }))}
>
{ORDER_GROUP_DELIVERY_HALF_DAY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</label>
</div>
{hasActiveFilters && (
<div className="pt-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={resetAllFilters}
className="text-xs text-[var(--color-text-muted)] hover:text-[var(--color-danger)]"
>
Сбросить все фильтры
</Button>
</div>
)}
{/* Date pills */}
{sortedDeliveryDates.length > 0 && (
<div className="flex flex-wrap gap-2 pt-2">
@ -401,31 +505,61 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
<Badge tone={tone}>{label}</Badge>
<span className="text-xs text-[var(--color-text-muted)]">{items.length} {pluralGroups(items.length)}</span>
</div>
<div className="grid gap-3">
{items.map((item) => (
<Button
{/* Table layout — № | Фамилия | Телефон | Город | Часть дня */}
<div className="overflow-x-auto">
<div className="min-w-[640px]">
<div className="grid grid-cols-[minmax(90px,1fr)_minmax(120px,1.5fr)_minmax(130px,1.2fr)_minmax(100px,1fr)_minmax(120px,1fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.1em] text-[var(--color-text-muted)]">
<div className="px-3 py-1.5 font-medium"> счёта</div>
<div className="px-3 py-1.5 font-medium">Клиент</div>
<div className="px-3 py-1.5 font-medium">Телефон</div>
<div className="px-3 py-1.5 font-medium">Город</div>
<div className="px-3 py-1.5 font-medium">Часть дня</div>
</div>
{items.map((item) => {
const hasProblem = item.hasDeliveryProblem || item.has_delivery_problem;
const phoneTel = normalizePhoneForTel(item.customerPhone);
const halfDayLabel = getOrderGroupDeliveryHalfDay(item);
const city = normalizeCity(item.deliveryAddress || item.delivery_address);
const orderNum = (Array.isArray(item.orderNumbers) && item.orderNumbers.length > 0)
? item.orderNumbers.join(", ")
: (item.groupKey || "—");
return (
<button
key={item.id}
variant="secondary"
className="rounded-[24px] p-4 text-left"
type="button"
className={`grid grid-cols-[minmax(90px,1fr)_minmax(120px,1.5fr)_minmax(130px,1.2fr)_minmax(100px,1fr)_minmax(120px,1fr)] gap-0 w-full border-t border-[var(--color-border)] text-left transition hover:bg-[var(--color-accent-soft)] ${hasProblem ? "bg-[rgba(239,68,68,0.04)]" : ""}`}
onClick={() => onOpenOrder?.(item.id)}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="font-medium text-[var(--color-text)]">
{item.displayTitle || item.customerName || item.groupKey}
<div className="px-3 py-2 text-xs">
<span className="font-semibold text-[var(--color-accent)]">{orderNum}</span>
{hasProblem && <span className="ml-1 text-[10px] font-bold text-[var(--color-danger)]"></span>}
</div>
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
{item.customerDate} · {item.customerPhone}
{getOrderGroupDeliveryHalfDay(item) ? ` · ${getOrderGroupDeliveryHalfDay(item)}` : ""}
<div className="px-3 py-2 text-xs font-medium text-[var(--color-text)]">
{item.customerName || item.displayTitle || item.groupKey}
</div>
<div className="px-3 py-2 text-xs">
{item.customerPhone && phoneTel ? (
<a
href={`tel:${phoneTel}`}
onClick={(e) => e.stopPropagation()}
className="text-[var(--color-accent)] hover:underline"
>
{item.customerPhone}
</a>
) : (
<span className="text-[var(--color-text-muted)]">{item.customerPhone || "—"}</span>
)}
</div>
<div className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
{city}
</div>
<div className="mt-3 text-sm text-[var(--color-text-muted)]">
{item.deliveryAddress || item.delivery_address || "Адрес не указан"}
<div className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
{halfDayLabel || "—"}
</div>
</button>
);
})}
</div>
</Button>
))}
</div>
</div>
))}

View File

@ -89,11 +89,12 @@ const parseOrderItems = (order) => {
return [];
};
export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, isSavingShipment }) => {
export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, isSavingShipment, onResetStatus, isSavingStatusChange }) => {
const [stopWords, setStopWords] = React.useState([]);
const [scopeActive, setScopeActive] = React.useState(true);
const [savedShipment, setSavedShipment] = React.useState([]);
const [justSaved, setJustSaved] = React.useState(false);
const [showResetConfirm, setShowResetConfirm] = React.useState(false);
React.useEffect(() => {
if (!supabase) return;
@ -168,11 +169,27 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
setComments({});
};
const currentDeliveryStatus = order?.deliveryStatus || order?.delivery_status;
const isStatusFinal = ["delivered", "problem", "picked_up"].includes(currentDeliveryStatus);
const unshipAll = () => {
if (isStatusFinal && onResetStatus) {
setShowResetConfirm(true);
return;
}
setShippedItems(new Set());
setComments({});
};
const confirmResetAll = () => {
setShippedItems(new Set());
setComments({});
setShowResetConfirm(false);
if (onResetStatus) {
onResetStatus();
}
};
const shippedCount = items.filter((i) => shippedItems.has(i.id)).length;
const unshippedCount = items.length - shippedCount;
const allShipped = items.length > 0 && shippedCount === items.length;
@ -232,11 +249,36 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
<Button variant="secondary" size="sm" onClick={shipAll} disabled={allShipped}>
Отгрузить всё
</Button>
<Button variant="ghost" size="sm" onClick={unshipAll} disabled={shippedCount === 0}>
<Button variant="ghost" size="sm" onClick={unshipAll} disabled={shippedCount === 0 && !isStatusFinal}>
Сбросить
</Button>
</div>
{showResetConfirm && (
<div className="rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] p-4 space-y-3">
<p className="text-sm font-medium text-[var(--color-text)]">
Сбросить отгрузку и вернуть статус?
</p>
<p className="text-xs text-[var(--color-text-muted)]">
Текущий статус («{currentDeliveryStatus === "delivered" ? "Доставлено" : currentDeliveryStatus === "problem" ? "Проблема" : "Вывезено"}») будет сброшен.
Отгрузка будет очищена, логист увидит что доставка требует доработки.
</p>
<div className="flex gap-2">
<Button
variant="primary"
size="sm"
onClick={confirmResetAll}
disabled={isSavingStatusChange}
>
{isSavingStatusChange ? "Сохраняем..." : "Да, сбросить"}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowResetConfirm(false)}>
Отмена
</Button>
</div>
</div>
)}
<div className="space-y-2">
{items.map((item) => {
const isShipped = shippedItems.has(item.id);

View File

@ -158,7 +158,17 @@ const renderRow = (group, onSelectSet) => (
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]"></span>}
</div>
<div className="px-3 py-1.5">
<div className="flex items-center gap-1.5">
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
{(group.hasDeliveryProblem || group.has_delivery_problem) && (
<span
title={group.deliveryProblemNote || group.delivery_problem_note || "Есть проблемы с отгрузкой позиций"}
className="inline-flex items-center gap-0.5 rounded-full bg-[rgba(239,68,68,0.12)] px-1.5 py-0.5 text-[10px] font-bold text-[var(--color-danger)]"
>
Проблема
</span>
)}
</div>
</div>
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
{formatDateTime(group.updatedAt)}
@ -245,10 +255,7 @@ const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, on
};
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => {
const FILTERS_KEY = "logistics-filters";
const savedFilters = (() => { try { return JSON.parse(localStorage.getItem(FILTERS_KEY) || "null"); } catch { return null; } })();
const [filters, setFilters] = React.useState(savedFilters || { query: "", displayStatus: "all", city: "" });
React.useEffect(() => { try { localStorage.setItem(FILTERS_KEY, JSON.stringify(filters)); } catch {} }, [filters]);
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" });
const [collapsedSections, setCollapsedSections] = React.useState(() => loadCollapsedSections());
const [sectionOrder, setSectionOrder] = React.useState(() => {
const custom = loadCustomOrder();

View File

@ -68,6 +68,14 @@ const fmtTime = (ts) => {
} catch { return "—"; }
};
const fmtTime = (ts) => {
if (!ts) return "—";
try {
const d = new Date(ts);
return d.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
} catch { return "—"; }
};
const DELIVERY_TIME_OPTIONS = ["Первая половина дня", "Вторая половина дня"];
const STATUS_LABELS = DELIVERY_GROUP_STATUS_LABELS;
@ -1093,7 +1101,27 @@ export const OrderDetailPanel = ({
) : null}
{userRole === "driver" && order ? (
<DriverShipmentPanel order={order} onShipmentChange={handleShipmentChange} onSaveShipment={handleSaveShipment} isSavingShipment={isSavingShipment} />
<DriverShipmentPanel
order={order}
onShipmentChange={handleShipmentChange}
onSaveShipment={handleSaveShipment}
isSavingShipment={isSavingShipment}
onResetStatus={() => {
if (onChangeDeliveryStatus) {
onChangeDeliveryStatus({
orderGroupId: order.id,
status: "loaded",
}).then((response) => {
if (!response.success) {
setFormMessage(response.error || "Не удалось сбросить статус");
} else {
setFormMessage("Статус сброшен, отгрузка очищена");
}
});
}
}}
isSavingStatusChange={isSavingStatusChange}
/>
) : null}
{userRole === "driver" && order && onChangeDeliveryStatus ? (
@ -1140,7 +1168,10 @@ export const OrderDetailPanel = ({
}
}
if (statusOptions.length === 0) return null;
// "Return to work" button for final statuses
const canReturn = ["delivered", "picked_up", "problem"].includes(currentStatus);
if (statusOptions.length === 0 && !canReturn) return null;
return statusOptions.map((statusOption) => {
const isSelected = pendingStatus?.value === statusOption.value;
@ -1168,6 +1199,28 @@ export const OrderDetailPanel = ({
);
});
})()}
{(() => {
const currentStatus = order.deliveryStatus || order.delivery_status;
const canReturn = ["delivered", "picked_up", "problem"].includes(currentStatus);
if (!canReturn) return null;
return (
<Button
variant="ghost"
disabled={isSavingStatusChange}
onClick={() => {
setPendingStatus({
value: "loaded",
label: "Вернуть в работу",
mismatch: false,
deliveryType: "delivery",
});
}}
className="text-xs text-[var(--color-text-muted)] hover:text-[var(--color-warning)]"
>
Вернуть в работу
</Button>
);
})()}
</div>
{pendingStatus ? (
<div className="flex items-center gap-3 mt-2">

View File

@ -21,7 +21,6 @@ const NOTIF_LABELS = {
manual_required: "Требуется ручное управление",
paid_storage_sending: "Отправляется…",
paid_storage_sent: "Платное хранение: отправлено",
completed: "✅ Завершено",
draft: "Черновик",
};
@ -36,7 +35,6 @@ const NOTIF_TONES = {
manual_required: "warning",
paid_storage_sending: "info",
paid_storage_sent: "accent",
completed: "accent",
draft: "neutral",
};
@ -46,7 +44,6 @@ const fmtTime = (ts) => {
try {
return new Date(ts).toLocaleString("ru-RU", {
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
timeZone: "Europe/Moscow",
});
} catch { return ts; }
};
@ -67,9 +64,6 @@ const fmtCountdown = (targetTs) => {
// Component
export const SmsStatusCard = ({ order, userRole }) => {
// Only show to staff (not clients)
const isStaff = ["mega_admin", "admin", "manager", "logistician", "driver"].includes(userRole);
if (!isStaff) return null;
const [restarting, setRestarting] = useState(false);
const [restartDone, setRestartDone] = useState(false);
const [now, setNow] = useState(Date.now());
@ -108,7 +102,6 @@ export const SmsStatusCard = ({ order, userRole }) => {
const canManage = ["mega_admin", "admin"].includes(userRole);
const notifStatus = order.notificationStatus || order.notification_status || "not_started";
const deliveryStatus = order.deliveryStatus || order.delivery_status || "";
const nextCheck = order.nextNotificationCheckAt || order.next_notification_check_at;
const firstSmsAt = order.firstSmsSentAt || order.first_sms_sent_at;
const secondSmsAt = order.secondSmsSentAt || order.second_sms_sent_at;
@ -116,14 +109,6 @@ export const SmsStatusCard = ({ order, userRole }) => {
const smsAttempts = order.smsAttempts ?? order.sms_attempts ?? 0;
const lastError = order.lastSmsError || order.last_sms_error;
// Terminal delivery states SMS flow should be fully stopped
const TERMINAL_STATUSES = ["picked_up", "delivered", "cancelled"];
const isTerminal = TERMINAL_STATUSES.includes(deliveryStatus);
const terminalLabel = deliveryStatus === "picked_up" ? "Самовывоз завершён"
: deliveryStatus === "delivered" ? "Доставка завершена"
: deliveryStatus === "cancelled" ? "Заказ отменён"
: "";
// Restart: reset this group's notification_status to link_ready
const handleRestart = async () => {
if (!order?.id) return;
@ -196,7 +181,7 @@ export const SmsStatusCard = ({ order, userRole }) => {
</div>
{/* Timeline */}
<div className="space-y-2.5 text-sm">
<div className="space-y-2 text-xs">
{/* 1st SMS */}
<div className="flex items-start gap-2">
<span className={`mt-0.5 h-2 w-2 rounded-full ${hasFirstSms ? "bg-[#22c55e]" : hasSmsSent && notifStatus === "sms_sending" ? "bg-[var(--color-accent)]" : notifStatus === "link_ready" || notifStatus === "not_started" ? "bg-[var(--color-warning)]" : "bg-[var(--color-border)]"}`} />
@ -216,21 +201,15 @@ export const SmsStatusCard = ({ order, userRole }) => {
{/* 2nd SMS */}
<div className="flex items-start gap-2">
<span className={`mt-0.5 h-2 w-2 rounded-full ${hasSecondSms ? "bg-[#22c55e]" : isTerminal ? "bg-[var(--color-border)]" : notifStatus === "second_sms_sending" ? "bg-[var(--color-accent)]" : notifStatus === "first_sms_sent" ? "bg-[var(--color-warning)]" : "bg-[var(--color-border)]"}`} />
<span className={`mt-0.5 h-2 w-2 rounded-full ${hasSecondSms ? "bg-[#22c55e]" : notifStatus === "first_sms_sent" ? "bg-[var(--color-warning)]" : "bg-[var(--color-border)]"}`} />
<div className="flex-1">
<div className="text-[var(--color-text)]">2-е SMS</div>
{hasSecondSms ? (
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsAt)} доставлено</div>
) : isTerminal ? (
<div className="text-[var(--color-text-muted)]"> {terminalLabel}, SMS не отправляется</div>
) : notifStatus === "second_sms_sending" && hasSmsSent ? (
<div className="text-[var(--color-text-muted)]">{fmtTime(smsSentAt)} · отправлено, ждём подтверждения</div>
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsAt)}</div>
) : notifStatus === "first_sms_sent" && countdown ? (
<div className="text-[var(--color-text-muted)]">
отправка через <span className="font-mono text-[var(--color-accent)]">{countdown}</span>
</div>
) : notifStatus === "first_sms_sent" ? (
<div className="text-[var(--color-text-muted)]">ожидает отправки</div>
) : (
<div className="text-[var(--color-text-muted)]"></div>
)}
@ -238,7 +217,7 @@ export const SmsStatusCard = ({ order, userRole }) => {
</div>
{/* Next check countdown */}
{countdown && notifStatus !== "second_sms_sent" && !isTerminal && (
{countdown && notifStatus !== "second_sms_sent" && (
<div className="flex items-center gap-2 rounded-lg bg-[var(--color-surface-strong)] px-2 py-1.5">
<span className="text-[var(--color-text-muted)]"> Следующая проверка:</span>
<span className="font-mono text-[var(--color-accent)]">{countdown}</span>
@ -261,10 +240,10 @@ export const SmsStatusCard = ({ order, userRole }) => {
{/* SMS log for this group */}
{smsLog.length > 0 && (
<div className="mt-3 border-t border-[var(--color-border)] pt-3">
<div className="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">История SMS</div>
<div className="space-y-2">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">История SMS</div>
<div className="space-y-1.5">
{smsLog.map((log) => (
<div key={log.id} className="flex items-center gap-2 text-xs">
<div key={log.id} className="flex items-center gap-2 text-[11px]">
<span className="text-[var(--color-text-muted)]">{fmtTime(log.created_at)}</span>
<Badge tone={log.status === "delivered" ? "accent" : log.status === "sent" || log.status === "checking" ? "info" : "danger"}>
{log.status === "delivered" ? "доставлено" : log.status === "sent" ? "отправлено" : log.status === "checking" ? "проверка" : log.status === "expired" ? "истёк" : log.status}
@ -281,8 +260,8 @@ export const SmsStatusCard = ({ order, userRole }) => {
</div>
)}
{/* Restart buttons — hidden when delivery is terminal (picked_up/delivered/cancelled) */}
{canManage && !isTerminal && (
{/* Restart buttons */}
{canManage && (
<div className="mt-3 flex gap-2 border-t border-[var(--color-border)] pt-3">
<button
type="button"

View File

@ -14,7 +14,6 @@ import {
confirmDeliveryChoice,
fetchDeliveryInvitation,
} from "../services/deliveryInvitationApi";
import { supabase } from "../supabaseClient";
const DELIVERY_TIMEZONE = "Europe/Simferopol";
@ -43,27 +42,15 @@ const addDaysToDateKey = (dateKey, amount) => {
return baseDate.toISOString().slice(0, 10);
};
// Default: Mon-Fri (1-5). Overridden by business_schedule_settings from DB.
let _deliveryDays = [1, 2, 3, 4, 5];
let _pickupDays = [1, 2, 3, 4, 5];
export const setScheduleDaysExternal = ({ deliveryDays, pickupDays }) => {
if (Array.isArray(deliveryDays) && deliveryDays.length) _deliveryDays = deliveryDays;
if (Array.isArray(pickupDays) && pickupDays.length) _pickupDays = pickupDays;
};
const isAllowedDeliveryDay = (dateKey) => {
if (!dateKey) return false;
const isSundayKey = (dateKey) => {
if (!dateKey) return true;
const d = new Date(`${dateKey}T12:00:00Z`);
// getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
// _deliveryDays uses 1=Mon ... 7=Sun
const dayNum = d.getUTCDay() === 0 ? 7 : d.getUTCDay();
return _deliveryDays.includes(dayNum);
return d.getUTCDay() === 0;
};
const getNextDeliveryWorkdayKey = (dateKey) => {
const getNextWorkdayKey = (dateKey) => {
let next = addDaysToDateKey(dateKey, 1);
while (!isAllowedDeliveryDay(next)) {
while (isSundayKey(next)) {
next = addDaysToDateKey(next, 1);
}
return next;
@ -71,10 +58,8 @@ const getNextDeliveryWorkdayKey = (dateKey) => {
const getAllowedDeliveryDateKeys = (referenceDate = new Date()) => {
const todayKey = getBusinessTodayKey(referenceDate);
// If today is a delivery day, include it; otherwise start from next workday
const startKey = isAllowedDeliveryDay(todayKey) ? todayKey : getNextDeliveryWorkdayKey(todayKey);
const firstWorkday = isAllowedDeliveryDay(todayKey) ? todayKey : getNextDeliveryWorkdayKey(todayKey);
const secondWorkday = getNextDeliveryWorkdayKey(firstWorkday);
const firstWorkday = getNextWorkdayKey(todayKey);
const secondWorkday = getNextWorkdayKey(firstWorkday);
return new Set([firstWorkday, secondWorkday].filter(Boolean));
};
@ -96,10 +81,10 @@ export const groupSlotsFromInvitation = (invitation, referenceDate = new Date())
const deliveryTime = invitation.deliveryTime;
if (!rawSlots.length && !deliveryDate) {
// Fallback: generate default delivery slots (next 2 workdays, both halves, skip non-delivery days)
// Fallback: generate default delivery slots (next 2 workdays, both halves, skip Sunday)
const todayKey = getBusinessTodayKey(referenceDate);
const firstWorkday = isAllowedDeliveryDay(todayKey) ? todayKey : getNextDeliveryWorkdayKey(todayKey);
const secondWorkday = getNextDeliveryWorkdayKey(firstWorkday);
const firstWorkday = getNextWorkdayKey(todayKey);
const secondWorkday = getNextWorkdayKey(firstWorkday);
return [
{ id: `slot-${firstWorkday}-first`, date: firstWorkday, time: "Первая половина дня" },
{ id: `slot-${firstWorkday}-second`, date: firstWorkday, time: "Вторая половина дня" },
@ -118,7 +103,7 @@ export const groupSlotsFromInvitation = (invitation, referenceDate = new Date())
];
}
const result = rawSlots
return rawSlots
.map((raw, index) => {
if (typeof raw === "string") {
const parts = raw.split(",");
@ -167,21 +152,6 @@ export const groupSlotsFromInvitation = (invitation, referenceDate = new Date())
return null;
})
.filter(Boolean);
// If all rawSlots were filtered out (dates passed), generate fresh slots
if (!result.length && rawSlots.length) {
const todayKey = getBusinessTodayKey(referenceDate);
const firstWorkday = isAllowedDeliveryDay(todayKey) ? todayKey : getNextDeliveryWorkdayKey(todayKey);
const secondWorkday = getNextDeliveryWorkdayKey(firstWorkday);
return [
{ id: `slot-${firstWorkday}-first`, date: firstWorkday, time: "Первая половина дня" },
{ id: `slot-${firstWorkday}-second`, date: firstWorkday, time: "Вторая половина дня" },
{ id: `slot-${secondWorkday}-first`, date: secondWorkday, time: "Первая половина дня" },
{ id: `slot-${secondWorkday}-second`, date: secondWorkday, time: "Вторая половина дня" },
].filter((s) => s.date);
}
return result;
};
export const buildDeliveryConfirmationPayload = ({
@ -251,36 +221,10 @@ export const ClientDeliveryPage = () => {
const [choiceSaved, setChoiceSaved] = React.useState(false);
const [activeTab, setActiveTab] = React.useState(TAB_DELIVERY);
const [deliveryAddress, setDeliveryAddress] = React.useState("");
const [scheduleDays, setScheduleDays] = React.useState({ deliveryDays: [1,2,3,4,5], pickupDays: [1,2,3,4,5] });
const referenceDate = React.useMemo(
() => (invitation?.smsSentAt ? new Date(invitation.smsSentAt) : new Date()),
[token, invitation?.smsSentAt],
);
// For slot availability, always use current date not SMS send date
const nowForSlots = React.useMemo(() => new Date(), []);
// Fetch business schedule (delivery/pickup days) no auth required
React.useEffect(() => {
let cancelled = false;
const loadSchedule = async () => {
try {
const { data, error: rpcError } = await supabase.rpc("get_business_schedule");
if (rpcError) throw rpcError;
if (!cancelled && data?.ok) {
const days = {
deliveryDays: data.deliveryDays || [1,2,3,4,5],
pickupDays: data.pickupDays || [1,2,3,4,5],
};
setScheduleDays(days);
setScheduleDaysExternal(days);
}
} catch {
// Silent fallback use defaults
}
};
loadSchedule();
return () => { cancelled = true; };
}, []);
React.useEffect(() => {
let cancelled = false;
@ -327,7 +271,7 @@ export const ClientDeliveryPage = () => {
};
}, [token]);
const slots = groupSlotsFromInvitation(invitation, nowForSlots);
const slots = groupSlotsFromInvitation(invitation, referenceDate);
const invitationState = invitation?.state || "awaiting_choice";
const isActiveState = ["awaiting_choice", "opened", "reminder_sent"].includes(invitationState);
@ -374,7 +318,7 @@ export const ClientDeliveryPage = () => {
setSelectedSlot(
buildSelectedSlotFromInvitation(
loadedInvitation,
groupSlotsFromInvitation(loadedInvitation, nowForSlots),
groupSlotsFromInvitation(loadedInvitation, referenceDate),
) || effectiveSelectedSlot,
);
setChoiceSaved(true);
@ -561,7 +505,6 @@ export const ClientDeliveryPage = () => {
onSelectSlot={handleSlotSelect}
selectedSlotId={selectedSlotId}
referenceDate={referenceDate}
pickupDays={scheduleDays.pickupDays}
/>
) : null}

View File

@ -420,8 +420,16 @@ export const saveShipmentData = async ({ orderGroupId, shipmentData }) => {
? shipmentData.filter((i) => !i.shipped).map((i) => `${i.name}${i.quantity ? ` (${i.quantity}${i.unit ? ` ${i.unit}` : ""})` : ""}${i.comment ? `${i.comment}` : ""}`).join("; ")
: null;
// Any shipment data saved = delivered status. Problems go into has_delivery_problem/delivery_problem_note columns.
const newDeliveryStatus = hasAnyShipped ? "delivered" : undefined;
// If all shipped → delivered. If partial (some shipped, some not) → problem.
// If nothing shipped → keep current status (driver just clearing checkboxes).
let newDeliveryStatus;
if (hasAnyShipped && !hasProblem) {
newDeliveryStatus = "delivered";
} else if (hasAnyShipped && hasProblem) {
newDeliveryStatus = "problem";
} else {
newDeliveryStatus = undefined;
}
const updatePayload = {
driver_shipment_data: shipmentData,