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/AuthContext.jsx` — OTP-аутентификация через Supabase Auth и загрузка профиля пользователя с ролью. Неизвестный email показывает подсказку обратиться к администратору.
- `src/context/ThemeContext.jsx` — управление светлой и тёмной темой через `data-theme`. - `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/usePwaStatus.js` — клиентское состояние PWA: online/offline, install prompt, standalone и offline readiness.
- `src/hooks/useOrders.js` — локальный state заказов, истории, чатов, фильтров, действий и **сгруппированных наборов доставки** (deliverySetBuckets). - `src/hooks/useOrders.js` — локальный state заказов, истории, чатов, фильтров, действий и **сгруппированных наборов доставки** (deliverySetBuckets).
- `src/hooks/useOrderGroups.js` — работа с группами заказов: загрузка, обновление статусов, ручное согласование доставки и самовывоза. Метод `saveManualDeliveryChoice` принимает `deliveryType`, `pickupDate`, `pickupTimeSlot`. - `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). - Docker-сборка через `docker-compose.app.yml` (multi-stage: Node.js build → Caddy serve).
- Автодеплой: Gitea webhook → systemd `supersam-webhook``deploy.sh` (git pull + docker build). - Автодеплой: Gitea webhook → systemd `supersam-webhook``deploy.sh` (git pull + docker build).
- Домен: `https://dost.supersamsev.ru/` - Домен: `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"; const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
if (!isLocalhost) { if (!isLocalhost) {
const STATIC_CACHE = "construction-delivery-static-v61"; const STATIC_CACHE = "construction-delivery-static-v49";
const RUNTIME_CACHE = "construction-delivery-runtime-v61"; const RUNTIME_CACHE = "construction-delivery-runtime-v49";
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"]; const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {

View File

@ -28,8 +28,6 @@ import os
import sys import sys
import json import json
import logging import logging
import time
import fcntl
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
import requests import requests
@ -106,37 +104,12 @@ def load_settings(conn):
# ─── SMS API ───────────────────────────────────────────────────────────────── # ─── SMS API ─────────────────────────────────────────────────────────────────
def normalize_phone(phone): def normalize_phone(phone):
"""Нормализует телефон: только цифры, начинается с 7. Возвращает None если пустой. """Нормализует телефон: только цифры, начинается с 7"""
Обрабатляет:
- +7XXX, 7XXX, 8XXX, XXX (без кода страны) 7XXX
- Текст/имена в поле (Екатерина +7978...) извлекает только цифры
- Два номера в одном поле (XXX / YYY) берёт первый (обрезает до 11 цифр)
- Минимум 10 цифр (без 7), максимум 11 (с 7)
"""
clean = "".join(c for c in str(phone) if c.isdigit()) clean = "".join(c for c in str(phone) if c.isdigit())
if len(clean) < 10: if clean.startswith("8"):
return None
# If 8 prefix (Russian landline style) → replace with 7
if clean.startswith("8") and len(clean) == 11:
clean = "7" + clean[1:] 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"): elif not clean.startswith("7"):
clean = "7" + clean clean = "7" + clean
return clean return clean
def send_sms(phone, message, api_id): def send_sms(phone, message, api_id):
@ -163,7 +136,7 @@ def send_sms(phone, message, api_id):
return None, str(e), "error" return None, str(e), "error"
def check_sms_status(sms_id, api_id): def check_sms_status(sms_id, api_id):
"""Проверяет статус, возвращает (sms_status_code, raw_response, api_code)""" """Проверяет статус, возвращает (code, raw_response)"""
try: try:
resp = requests.post(SMS_STATUS_URL, params={ resp = requests.post(SMS_STATUS_URL, params={
"api_id": api_id, "api_id": api_id,
@ -180,23 +153,22 @@ def check_sms_status(sms_id, api_id):
log.error(f"SMS status check error: {e}") log.error(f"SMS status check error: {e}")
return None, str(e), "error" 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 ──────────────────────────────────────────────────────────────── # ─── Telegram ────────────────────────────────────────────────────────────────
def send_telegram(message, chat_id): 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 ─────────────────────────────────────────────────────────── # ─── State Machine ───────────────────────────────────────────────────────────
@ -205,8 +177,7 @@ def get_groups_to_send(conn):
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(""" cur.execute("""
SELECT og.id, og.group_key, og.customer_name, og.customer_phone, SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
og.customer_phone_normalized, og.delivery_link, og.notification_status, og.customer_phone_normalized, og.delivery_link, og.notification_status
og.sms_attempts
FROM order_groups og FROM order_groups og
WHERE og.status = 'ready_to_launch' WHERE og.status = 'ready_to_launch'
AND og.delivery_status = 'pending_confirmation' 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()] return [dict(r) for r in cur.fetchall()]
def get_sms_expired(conn, max_duration_min): def get_sms_expired(conn, max_duration_min):
"""SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены). """SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены)"""
Также считаем общее количество failed попыток для группы.
"""
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(""" cur.execute("""
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone, SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
scl.attempts, scl.created_at, scl.attempts, scl.created_at,
og.customer_name, og.group_key, og.delivery_link, 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
FROM sms_campaign_log scl FROM sms_campaign_log scl
JOIN order_groups og ON og.id = scl.order_group_id JOIN order_groups og ON og.id = scl.order_group_id
WHERE scl.campaign_type = 'first_sms' WHERE scl.campaign_type = 'first_sms'
@ -310,62 +274,16 @@ def update_order_group(conn, group_id, fields):
cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values) cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values)
conn.commit() 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 новым группам""" """Шаг 1: Отправка SMS новым группам"""
api_id = settings.get("sms_api_id", SMS_API_ID) api_id = settings.get("sms_api_id", SMS_API_ID)
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID) tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
max_attempts = settings.get("max_attempts", 2) max_attempts = settings.get("max_attempts", 2)
second_sms_delay = settings.get("second_sms_delay_hours", 3) second_sms_delay = settings.get("second_sms_delay_hours", 3)
if test_send: groups = get_groups_to_send(conn)
# 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") log.info(f"Step 1: {len(groups)} groups to send SMS")
sent_count = 0 sent_count = 0
@ -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") log.info(f"Group {group_id}: already has recent SMS in log, skipping")
continue continue
sms_text_template = settings.get("sms_text_template", "Ваш заказ готов. Согласуйте дату доставки по ссылке: {link}") sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}"
sms_text = sms_text_template.replace("{link}", delivery_link + "?src=sms")
# ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый # ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый
send_phone = phone 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) sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
if sms_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, log_id = insert_sms_log(conn,
campaign_type="first_sms", campaign_type="first_sms",
order_group_id=group_id, order_group_id=group_id,
customer_phone=phone, customer_phone=phone,
sms_id=sms_id, sms_id=sms_id,
sms_text=sms_text, sms_text=sms_text,
sent_to=send_phone,
status="sent", status="sent",
sms_code=code, sms_code=code,
attempts=current_attempts, attempts=1,
was_test_mode=bool(settings.get("test_mode", True)),
) )
# ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status, # ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status,
# чтобы get_groups_to_send не нашёл эту группу при следующем запуске # чтобы get_groups_to_send не нашёл эту группу при следующем запуске
update_order_group(conn, group_id, { update_order_group(conn, group_id, {
"notification_status": "sms_sending", "notification_status": "sms_sending",
"sms_sent_at": "NOW()", "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") log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}, log_id={log_id}, notification_status→sms_sending")
sent_count += 1 sent_count += 1
time.sleep(settings.get("send_interval_seconds", 15)) # Configurable rate limit
else: else:
# Ошибка отправки # Ошибка отправки
error = raw[:500] if raw else "Unknown error" 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, order_group_id=group_id,
customer_phone=phone, customer_phone=phone,
sms_text=sms_text, sms_text=sms_text,
sent_to=send_phone if "send_phone" in dir() else phone,
status="send_failed", status="send_failed",
sms_code=code, sms_code=code,
attempts=1, attempts=1,
error_message=error, 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]}") 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}") error_message=f"Delivery error: {code}")
# Переход к следующей попытке отправки (если есть) # Переход к следующей попытке отправки (если есть)
if attempts < max_attempts: 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") update_sms_log(conn, log_id, status="expired")
# Сбрасываем notification_status → link_ready для повторной отправки
update_order_group(conn, group_id, { update_order_group(conn, group_id, {
"notification_status": "link_ready", "notification_status": "link_ready",
"sms_attempts": attempts,
}) })
else: else:
update_order_group(conn, group_id, { update_order_group(conn, group_id, {
@ -554,29 +463,22 @@ def step_handle_expired(conn, settings):
phone = item.get("customer_phone", "") phone = item.get("customer_phone", "")
attempts = item.get("attempts", 1) attempts = item.get("attempts", 1)
delivery_link = item.get("delivery_link", "") delivery_link = item.get("delivery_link", "")
total_failed = item.get("total_failed", 0)
# Use total_failed across ALL attempts, not just this one log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}")
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}")
update_sms_log(conn, log_id, status="expired", update_sms_log(conn, log_id, status="expired",
error_message=f"Not delivered in {max_duration} minutes") error_message=f"Not delivered in {max_duration} minutes")
if effective_attempts < max_attempts: if attempts < max_attempts:
# Повторная отправка ТЕКУЩЕЙ SMS (не откат назад) # Сбрасываем notification_status → link_ready для повторной отправки
update_order_group(conn, group_id, { update_order_group(conn, group_id, {
"notification_status": "link_ready", "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: else:
# Все попытки исчерпаны # Все попытки исчерпаны
update_order_group(conn, group_id, { update_order_group(conn, group_id, {
"notification_status": "manual_required", "notification_status": "manual_required",
"delivery_status": "manual_confirmation_required",
"last_sms_error": f"Not delivered after {max_attempts} attempts", "last_sms_error": f"Not delivered after {max_attempts} attempts",
"status": "manual_required",
}) })
send_telegram( send_telegram(
f"🔧 Требуется ручное управление: {name} ({phone})\n" f"🔧 Требуется ручное управление: {name} ({phone})\n"
@ -587,49 +489,12 @@ def step_handle_expired(conn, settings):
# ─── Main ──────────────────────────────────────────────────────────────────── # ─── Main ────────────────────────────────────────────────────────────────────
def 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("=" * 60)
log.info("SMS First Campaign — START") log.info("SMS First Campaign — START")
conn = get_db_conn() conn = get_db_conn()
try: try:
settings = load_settings(conn) 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, " log.info(f"Settings: wait={settings.get('wait_between_checks_seconds')}s, "
f"max_duration={settings.get('max_check_duration_minutes')}min, " f"max_duration={settings.get('max_check_duration_minutes')}min, "
f"max_attempts={settings.get('max_attempts')}") f"max_attempts={settings.get('max_attempts')}")
@ -639,15 +504,7 @@ def main():
return return
# State machine — каждый шаг быстрый, без blocking # State machine — каждый шаг быстрый, без blocking
# Отправка только в рабочие часы sent = step_send_new(conn, settings)
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")
# Проверка статусов работает всегда
delivered = step_check_status(conn, settings) delivered = step_check_status(conn, settings)
step_handle_expired(conn, settings) step_handle_expired(conn, settings)
@ -659,25 +516,10 @@ def main():
settings.get("telegram_chat_id", TELEGRAM_CHAT_ID), 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: except Exception as e:
log.error(f"Fatal error: {e}", exc_info=True) log.error(f"Fatal error: {e}", exc_info=True)
finally: finally:
conn.close() conn.close()
fcntl.flock(lock_file, fcntl.LOCK_UN)
lock_file.close()
log.info("SMS First Campaign — END") log.info("SMS First Campaign — END")
log.info("=" * 60) 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): def is_within_work_hours(settings):
now_msk = datetime.now(timezone(timedelta(hours=3))) now_msk = datetime.now(timezone(timedelta(hours=3)))
today_num = now_msk.weekday() + 1 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.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.first_sms_sent_at < NOW() - INTERVAL '3 hours'
AND og.second_sms_sent_at IS NOT NULL) AND og.second_sms_sent_at IS NOT NULL)
-- 3. link_ready + next_check в прошлом + SMS уже отправлялась хотя бы раз -- 3. link_ready + next_check в прошлом + нет активных SMS в логе
-- ВАЖНО: если SMS никогда не отправлялась (нет записи в логе), не переводить в manual --
-- возможно first campaign ещё не успел отправить (БД была недоступна и т.п.)
OR (COALESCE(og.notification_status, '') = 'link_ready' OR (COALESCE(og.notification_status, '') = 'link_ready'
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW()) 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 ( AND NOT EXISTS (
SELECT 1 FROM sms_campaign_log scl SELECT 1 FROM sms_campaign_log scl
WHERE scl.order_group_id = og.id WHERE scl.order_group_id = og.id
@ -233,8 +210,6 @@ def main():
try: try:
settings = load_settings(conn) settings = load_settings(conn)
# Override work_days with business_schedule sms_days
settings["work_days"] = get_sms_days(conn)
# Update last_run_at timestamp # Update last_run_at timestamp
with conn.cursor() as cur: with conn.cursor() as cur:

View File

@ -21,8 +21,6 @@ SMS текст: "Ваш заказ переведён на платное хра
import os import os
import sys import sys
import logging import logging
import time
import fcntl
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
import requests import requests
@ -89,36 +87,16 @@ def load_settings(conn):
# ─── SMS API ───────────────────────────────────────────────────────────────── # ─── SMS API ─────────────────────────────────────────────────────────────────
def normalize_phone(phone): 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()) clean = "".join(c for c in str(phone) if c.isdigit())
if len(clean) < 10: if clean.startswith("8"):
return None
if len(clean) > 11:
clean = clean[:10]
if clean.startswith("8") and len(clean) == 11:
clean = "7" + clean[1:] clean = "7" + clean[1:]
elif len(clean) == 10: elif not clean.startswith("7"):
clean = "7" + clean clean = "7" + clean
elif not clean.startswith("7") and len(clean) == 11:
clean = "7" + clean[1:]
return clean return clean
def send_sms(phone, message, api_id): def send_sms(phone, message, api_id):
try: try:
clean_phone = normalize_phone(phone) 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}, resp = requests.post(SMS_SEND_URL, params={"api_id": api_id, "to": clean_phone},
data={"msg": message}, timeout=30) data={"msg": message}, timeout=30)
text = resp.text 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): def is_within_work_hours(settings):
now_msk = datetime.now(timezone(timedelta(hours=3))) now_msk = datetime.now(timezone(timedelta(hours=3)))
today_num = now_msk.weekday() + 1 today_num = now_msk.weekday() + 1
@ -305,20 +267,11 @@ 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) api_id = settings.get("sms_api_id", SMS_API_ID)
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID) tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
if test_send: groups = get_groups_to_send(conn)
# 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") log.info(f"Step 1: {len(groups)} groups to send paid_storage SMS")
sent_count = 0 sent_count = 0
@ -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") log.info(f"Group {group_id}: already has recent paid_storage SMS, skipping")
continue continue
sms_text_template = settings.get("sms_text_template", "Ваш заказ переведён на платное хранение. Стоимость: 300 ₽/день. Заберите заказ или согласуйте доставку: {link}") sms_text = (
sms_text = sms_text_template.replace("{link}", delivery_link) f"Ваш заказ переведён на платное хранение. "
f"Стоимость: 300 ₽/день. "
f"Заберите заказ или согласуйте доставку: {delivery_link}"
)
send_phone = phone send_phone = phone
if settings.get("test_mode", True): if settings.get("test_mode", True):
@ -357,11 +313,9 @@ def step_send_new(conn, settings, test_send=False):
customer_phone=phone, customer_phone=phone,
sms_id=sms_id, sms_id=sms_id,
sms_text=sms_text, sms_text=sms_text,
sent_to=send_phone,
status="sent", status="sent",
sms_code=code, sms_code=code,
attempts=1, attempts=1,
was_test_mode=bool(settings.get("test_mode", True)),
) )
update_order_group(conn, group_id, { update_order_group(conn, group_id, {
"notification_status": "paid_storage_sending", "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") log.info(f"Group {group_id}: paid_storage SMS sent, sms_id={sms_id}, notification_status→paid_storage_sending")
sent_count += 1 sent_count += 1
time.sleep(settings.get("send_interval_seconds", 15)) # Configurable rate limit
else: else:
error = raw[:500] if raw else "Unknown error" error = raw[:500] if raw else "Unknown error"
insert_sms_log(conn, insert_sms_log(conn,
@ -377,12 +330,10 @@ def step_send_new(conn, settings, test_send=False):
order_group_id=group_id, order_group_id=group_id,
customer_phone=phone, customer_phone=phone,
sms_text=sms_text, sms_text=sms_text,
sent_to=send_phone if "send_phone" in dir() else phone,
status="send_failed", status="send_failed",
sms_code=code, sms_code=code,
attempts=1, attempts=1,
error_message=error, 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]}") 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]}) 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: elif code in DELIVERY_ERROR_CODES:
log.error(f"Group {group_id}: delivery error (code={code})") 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}") update_sms_log(conn, log_id, status="error", sms_code=code, error_message=f"Delivery error: {code}")
attempts = item.get("attempts", 1) update_sms_log(conn, log_id, status="expired")
max_attempts = settings.get("max_attempts", 2) # Сброс для retry
if attempts < max_attempts: update_order_group(conn, group_id, {"notification_status": "manual_required"})
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)
elif code in LIMIT_ERROR_CODES: elif code in LIMIT_ERROR_CODES:
log.error(f"Group {group_id}: limit exceeded (code={code})") 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}") 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") 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") update_sms_log(conn, log_id, status="expired", error_message=f"Not delivered in {max_duration} min")
attempts = item.get("attempts", 1) update_order_group(conn, group_id, {"notification_status": "paid_storage_sent"})
max_attempts = settings.get("max_attempts", 2) send_telegram(f"⚠️ SMS платное хранение не доставлена: {name} ({phone})", tg_chat)
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)
# ─── Main ──────────────────────────────────────────────────────────────────── # ─── Main ────────────────────────────────────────────────────────────────────
def 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("=" * 60)
log.info("Paid Storage Campaign — START") log.info("Paid Storage Campaign — START")
conn = get_db_conn() conn = get_db_conn()
try: try:
settings = load_settings(conn) 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')}, " 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')}") f"days={settings.get('work_days')}, test={settings.get('test_mode')}")
@ -534,7 +427,7 @@ def main():
work_hours = is_within_work_hours(settings) work_hours = is_within_work_hours(settings)
sent = 0 sent = 0
if work_hours: if work_hours:
sent = step_send_new(conn, settings, test_send=test_send_requested) sent = step_send_new(conn, settings)
else: else:
log.info("Outside work hours, skipping new SMS sends") log.info("Outside work hours, skipping new SMS sends")
@ -548,7 +441,6 @@ def main():
send_telegram( send_telegram(
f"📦 <b>Платное хранение</b>\nОтправлено: {sent}\nДоставлено: {delivered}", f"📦 <b>Платное хранение</b>\nОтправлено: {sent}\nДоставлено: {delivered}",
settings.get("telegram_chat_id", TELEGRAM_CHAT_ID), 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) log.error(f"Fatal error: {e}", exc_info=True)
finally: finally:
conn.close() conn.close()
fcntl.flock(lock_file, fcntl.LOCK_UN)
lock_file.close()
log.info("Paid Storage Campaign — END") log.info("Paid Storage Campaign — END")
log.info("=" * 60) log.info("=" * 60)

View File

@ -29,8 +29,6 @@ import os
import sys import sys
import json import json
import logging import logging
import time
import fcntl
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
import requests import requests
@ -111,46 +109,18 @@ def load_settings(conn):
# ─── SMS API ───────────────────────────────────────────────────────────────── # ─── SMS API ─────────────────────────────────────────────────────────────────
def normalize_phone(phone): def normalize_phone(phone):
"""Нормализует телефон: только цифры, начинается с 7. Возвращает None если пустой. """Нормализует телефон: только цифры, начинается с 7"""
Обрабатляет:
- +7XXX, 7XXX, 8XXX, XXX (без кода страны) 7XXX
- Текст/имена в поле (Екатерина +7978...) извлекает только цифры
- Два номера в одном поле (XXX / YYY) берёт первый (обрезает до 11 цифр)
- Минимум 10 цифр (без 7), максимум 11 (с 7)
"""
clean = "".join(c for c in str(phone) if c.isdigit()) clean = "".join(c for c in str(phone) if c.isdigit())
if len(clean) < 10: if clean.startswith("8"):
return None
# If 8 prefix (Russian landline style) → replace with 7
if clean.startswith("8") and len(clean) == 11:
clean = "7" + clean[1:] 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"): elif not clean.startswith("7"):
clean = "7" + clean clean = "7" + clean
return clean return clean
def send_sms(phone, message, api_id): def send_sms(phone, message, api_id):
"""Отправляет SMS, возвращает (sms_id, raw_response, code)""" """Отправляет SMS, возвращает (sms_id, raw_response, code)"""
try: try:
clean_phone = normalize_phone(phone) 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={ resp = requests.post(SMS_SEND_URL, params={
"api_id": api_id, "api_id": api_id,
"to": clean_phone, "to": clean_phone,
@ -204,7 +174,19 @@ def fetch_balance(api_id):
# ─── Telegram ──────────────────────────────────────────────────────────────── # ─── Telegram ────────────────────────────────────────────────────────────────
def send_telegram(message, chat_id): 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 ─────────────────────────────────────────────────────────── # ─── State Machine ───────────────────────────────────────────────────────────
@ -216,8 +198,7 @@ def get_groups_to_send(conn):
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(""" cur.execute("""
SELECT og.id, og.group_key, og.customer_name, og.customer_phone, SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
og.customer_phone_normalized, og.delivery_link, og.notification_status, og.customer_phone_normalized, og.delivery_link, og.notification_status
og.sms_attempts
FROM order_groups og FROM order_groups og
WHERE og.delivery_status = 'pending_confirmation' WHERE og.delivery_status = 'pending_confirmation'
AND og.delivery_link IS NOT NULL 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()] return [dict(r) for r in cur.fetchall()]
def get_sms_expired(conn, max_duration_min): def get_sms_expired(conn, max_duration_min):
"""Вторые SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены). """Вторые SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены)"""
Также считаем общее количество failed попыток для группы.
"""
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(""" cur.execute("""
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone, SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
scl.attempts, scl.created_at, scl.attempts, scl.created_at,
og.customer_name, og.group_key, og.delivery_link, 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
FROM sms_campaign_log scl FROM sms_campaign_log scl
JOIN order_groups og ON og.id = scl.order_group_id JOIN order_groups og ON og.id = scl.order_group_id
WHERE scl.campaign_type = 'second_sms' WHERE scl.campaign_type = 'second_sms'
@ -284,6 +258,7 @@ def get_sms_expired(conn, max_duration_min):
ORDER BY scl.created_at ASC ORDER BY scl.created_at ASC
""" % max_duration_min) """ % max_duration_min)
return [dict(r) for r in cur.fetchall()] return [dict(r) for r in cur.fetchall()]
def insert_sms_log(conn, **kwargs): def insert_sms_log(conn, **kwargs):
with conn.cursor() as cur: with conn.cursor() as cur:
cols = ", ".join(kwargs.keys()) 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): def is_within_work_hours(settings):
"""Проверка: сейчас рабочие часы. """Проверка: сейчас рабочие часы.
settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5') settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5')
@ -362,21 +321,12 @@ def is_within_work_hours(settings):
# ─── Основная логика ───────────────────────────────────────────────────────── # ─── Основная логика ─────────────────────────────────────────────────────────
def step_send_new(conn, settings, test_send=False): def step_send_new(conn, settings):
"""Шаг 1: Отправка второй SMS группам, где первая доставлена, но нет согласования""" """Шаг 1: Отправка второй SMS группам, где первая доставлена, но нет согласования"""
api_id = settings.get("sms_api_id", SMS_API_ID) api_id = settings.get("sms_api_id", SMS_API_ID)
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID) tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
if test_send: groups = get_groups_to_send(conn)
# 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") log.info(f"Step 1: {len(groups)} groups to send second SMS")
sent_count = 0 sent_count = 0
@ -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") log.info(f"Group {group_id}: already has recent second SMS in log, skipping")
continue continue
sms_text_template = settings.get("sms_text_template", "Ваш заказ готов к доставке. Выберите дату доставки по ссылке: {link}") sms_text = f"Ваш заказ готов к доставке. Выберите дату доставки по ссылке: {delivery_link}"
sms_text = sms_text_template.replace("{link}", delivery_link + "?src=sms")
# ТЕСТОВЫЙ РЕЖИМ # ТЕСТОВЫЙ РЕЖИМ
send_phone = phone 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) sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
if sms_id: if sms_id:
current_attempts = (group.get("sms_attempts") or 0) + 1
log_id = insert_sms_log(conn, log_id = insert_sms_log(conn,
campaign_type=CAMPAIGN_TYPE, campaign_type=CAMPAIGN_TYPE,
order_group_id=group_id, order_group_id=group_id,
customer_phone=phone, customer_phone=phone,
sms_id=sms_id, sms_id=sms_id,
sms_text=sms_text, sms_text=sms_text,
sent_to=send_phone,
status="sent", status="sent",
sms_code=code, sms_code=code,
attempts=current_attempts, attempts=1,
was_test_mode=bool(settings.get("test_mode", True)),
) )
# ДВОЙНАЯ ЗАЩИТА: notification_status → second_sms_sending # ДВОЙНАЯ ЗАЩИТА: notification_status → second_sms_sending
update_order_group(conn, group_id, { update_order_group(conn, group_id, {
"notification_status": "second_sms_sending", "notification_status": "second_sms_sending",
"sms_sent_at": "NOW()", "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") 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 sent_count += 1
time.sleep(settings.get("send_interval_seconds", 15)) # Configurable rate limit
else: else:
error = raw[:500] if raw else "Unknown error" error = raw[:500] if raw else "Unknown error"
log_id = insert_sms_log(conn, log_id = insert_sms_log(conn,
@ -441,12 +385,10 @@ def step_send_new(conn, settings, test_send=False):
order_group_id=group_id, order_group_id=group_id,
customer_phone=phone, customer_phone=phone,
sms_text=sms_text, sms_text=sms_text,
sent_to=send_phone if "send_phone" in dir() else phone,
status="send_failed", status="send_failed",
sms_code=code, sms_code=code,
attempts=1, attempts=1,
error_message=error, 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]}") log.error(f"Group {group_id}: second SMS send failed (code={code}): {error[:200]}")
update_order_group(conn, group_id, { 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, update_sms_log(conn, log_id, status="error", sms_code=code,
error_message=f"Delivery error: {code}") error_message=f"Delivery error: {code}")
if attempts < max_attempts: 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") update_sms_log(conn, log_id, status="expired")
# Сбрасываем на first_sms_sent для повторной отправки
update_order_group(conn, group_id, { update_order_group(conn, group_id, {
"notification_status": "first_sms_sent", "notification_status": "first_sms_sent",
"sms_attempts": attempts,
"second_sms_sent_at": None,
"next_notification_check_at": None,
}) })
else: else:
update_order_group(conn, group_id, { 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", "") name = item.get("customer_name") or item.get("group_key", "")
phone = item.get("customer_phone", "") phone = item.get("customer_phone", "")
attempts = item.get("attempts", 1) 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}")
log.warning(f"Group {group_id}: second SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}, total_failed={total_failed}")
update_sms_log(conn, log_id, status="expired", update_sms_log(conn, log_id, status="expired",
error_message=f"Not delivered in {max_duration} minutes") error_message=f"Not delivered in {max_duration} minutes")
if effective_attempts < max_attempts: if attempts < max_attempts:
# Повторная отправка 2й SMS (остаёмся в текущем шаге) # Сбрасываем на first_sms_sent для повторной отправки
# second_sms_sent_at = NULL чтобы get_groups_to_send нашёл группу
update_order_group(conn, group_id, { update_order_group(conn, group_id, {
"notification_status": "first_sms_sent", "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: else:
# Все попытки исчерпаны → ручное управление # Все попытки исчерпаны → ручное управление
update_order_group(conn, group_id, { update_order_group(conn, group_id, {
"notification_status": "manual_required", "notification_status": "manual_required",
"delivery_status": "manual_confirmation_required",
"last_sms_error": f"Second SMS not delivered after {max_attempts} attempts", "last_sms_error": f"Second SMS not delivered after {max_attempts} attempts",
"status": "manual_required",
}) })
send_telegram( send_telegram(
f"🔧 Требуется ручное управление (2-я SMS): {name} ({phone})\n" f"🔧 Требуется ручное управление (2-я SMS): {name} ({phone})\n"
@ -594,50 +525,12 @@ def step_handle_expired(conn, settings):
# ─── Main ──────────────────────────────────────────────────────────────────── # ─── Main ────────────────────────────────────────────────────────────────────
def 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("=" * 60)
log.info("SMS Second Campaign — START") log.info("SMS Second Campaign — START")
conn = get_db_conn() conn = get_db_conn()
try: try:
settings = load_settings(conn) 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, " log.info(f"Settings: wait={settings.get('wait_between_checks_seconds')}s, "
f"max_duration={settings.get('max_check_duration_minutes')}min, " f"max_duration={settings.get('max_check_duration_minutes')}min, "
f"max_attempts={settings.get('max_attempts')}") f"max_attempts={settings.get('max_attempts')}")
@ -651,7 +544,7 @@ def main():
work_hours = is_within_work_hours(settings) work_hours = is_within_work_hours(settings)
sent = 0 sent = 0
if work_hours: if work_hours:
sent = step_send_new(conn, settings, test_send=test_send_requested) sent = step_send_new(conn, settings)
else: else:
log.info("Outside work hours (8-21 MSK, Mon-Fri), skipping new SMS sends") 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) log.error(f"Fatal error: {e}", exc_info=True)
finally: finally:
conn.close() conn.close()
fcntl.flock(lock_file, fcntl.LOCK_UN)
lock_file.close()
log.info("SMS Second Campaign — END") log.info("SMS Second Campaign — END")
log.info("=" * 60) log.info("=" * 60)

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@ import {
isOrderGroupVisibleToDriver, isOrderGroupVisibleToDriver,
groupOrderGroupsByDate, groupOrderGroupsByDate,
parseGroupDate, parseGroupDate,
ORDER_GROUP_DELIVERY_HALF_DAY_OPTIONS,
} from "../../services/orderGroupViews"; } from "../../services/orderGroupViews";
import { Badge } from "../UI/Badge"; import { Badge } from "../UI/Badge";
import { Button } from "../UI/Button"; import { Button } from "../UI/Button";
@ -110,13 +111,29 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
selectedDate: "", selectedDate: "",
deliveryStatus: "all", deliveryStatus: "all",
selectedCity: "", selectedCity: "",
searchQuery: "",
halfDay: "all",
}); });
const [collapsedDates, setCollapsedDates] = React.useState({}); 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) => { const toggleDate = (date) => {
setCollapsedDates((prev) => ({ ...prev, [date]: !prev[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( const driverOrderGroups = React.useMemo(
() => orderGroups.filter((group) => { () => orderGroups.filter((group) => {
const isVisible = isOrderGroupVisibleToDriver(group); const isVisible = isOrderGroupVisibleToDriver(group);
@ -158,6 +175,19 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
}); });
}, [cityDeliveryMap]); }, [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(() => { const filteredOrderGroups = React.useMemo(() => {
let result = [...driverOrderGroups]; let result = [...driverOrderGroups];
if (filters.selectedDate) { if (filters.selectedDate) {
@ -172,8 +202,39 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
return city === filters.selectedCity; 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; return result;
}, [driverOrderGroups, filters.selectedDate, filters.deliveryStatus, filters.selectedCity]); }, [driverOrderGroups, filters.selectedDate, filters.deliveryStatus, filters.selectedCity, filters.searchQuery, filters.halfDay]);
const groupedOrderGroups = React.useMemo( const groupedOrderGroups = React.useMemo(
() => groupOrderGroupsByDate(filteredOrderGroups), () => groupOrderGroupsByDate(filteredOrderGroups),
@ -245,6 +306,49 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
</label> </label>
</div> </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 */} {/* Date pills */}
{sortedDeliveryDates.length > 0 && ( {sortedDeliveryDates.length > 0 && (
<div className="flex flex-wrap gap-2 pt-2"> <div className="flex flex-wrap gap-2 pt-2">
@ -401,31 +505,61 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
<Badge tone={tone}>{label}</Badge> <Badge tone={tone}>{label}</Badge>
<span className="text-xs text-[var(--color-text-muted)]">{items.length} {pluralGroups(items.length)}</span> <span className="text-xs text-[var(--color-text-muted)]">{items.length} {pluralGroups(items.length)}</span>
</div> </div>
<div className="grid gap-3"> {/* Table layout — № | Фамилия | Телефон | Город | Часть дня */}
{items.map((item) => ( <div className="overflow-x-auto">
<Button <div className="min-w-[640px]">
key={item.id} <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)]">
variant="secondary" <div className="px-3 py-1.5 font-medium"> счёта</div>
className="rounded-[24px] p-4 text-left" <div className="px-3 py-1.5 font-medium">Клиент</div>
onClick={() => onOpenOrder?.(item.id)} <div className="px-3 py-1.5 font-medium">Телефон</div>
> <div className="px-3 py-1.5 font-medium">Город</div>
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="px-3 py-1.5 font-medium">Часть дня</div>
<div> </div>
<div className="font-medium text-[var(--color-text)]"> {items.map((item) => {
{item.displayTitle || item.customerName || item.groupKey} const hasProblem = item.hasDeliveryProblem || item.has_delivery_problem;
</div> const phoneTel = normalizePhoneForTel(item.customerPhone);
<div className="mt-1 text-sm text-[var(--color-text-muted)]"> const halfDayLabel = getOrderGroupDeliveryHalfDay(item);
{item.customerDate} · {item.customerPhone} const city = normalizeCity(item.deliveryAddress || item.delivery_address);
{getOrderGroupDeliveryHalfDay(item) ? ` · ${getOrderGroupDeliveryHalfDay(item)}` : ""} const orderNum = (Array.isArray(item.orderNumbers) && item.orderNumbers.length > 0)
</div> ? item.orderNumbers.join(", ")
: (item.groupKey || "—");
return (
<button
key={item.id}
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="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>
</div> <div className="px-3 py-2 text-xs font-medium text-[var(--color-text)]">
{item.customerName || item.displayTitle || item.groupKey}
<div className="mt-3 text-sm text-[var(--color-text-muted)]"> </div>
{item.deliveryAddress || item.delivery_address || "Адрес не указан"} <div className="px-3 py-2 text-xs">
</div> {item.customerPhone && phoneTel ? (
</Button> <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="px-3 py-2 text-xs text-[var(--color-text-muted)]">
{halfDayLabel || "—"}
</div>
</button>
);
})}
</div>
</div> </div>
</div> </div>
))} ))}

View File

@ -89,11 +89,12 @@ const parseOrderItems = (order) => {
return []; return [];
}; };
export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, isSavingShipment }) => { export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, isSavingShipment, onResetStatus, isSavingStatusChange }) => {
const [stopWords, setStopWords] = React.useState([]); const [stopWords, setStopWords] = React.useState([]);
const [scopeActive, setScopeActive] = React.useState(true); const [scopeActive, setScopeActive] = React.useState(true);
const [savedShipment, setSavedShipment] = React.useState([]); const [savedShipment, setSavedShipment] = React.useState([]);
const [justSaved, setJustSaved] = React.useState(false); const [justSaved, setJustSaved] = React.useState(false);
const [showResetConfirm, setShowResetConfirm] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
if (!supabase) return; if (!supabase) return;
@ -168,11 +169,27 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
setComments({}); setComments({});
}; };
const currentDeliveryStatus = order?.deliveryStatus || order?.delivery_status;
const isStatusFinal = ["delivered", "problem", "picked_up"].includes(currentDeliveryStatus);
const unshipAll = () => { const unshipAll = () => {
if (isStatusFinal && onResetStatus) {
setShowResetConfirm(true);
return;
}
setShippedItems(new Set()); setShippedItems(new Set());
setComments({}); setComments({});
}; };
const confirmResetAll = () => {
setShippedItems(new Set());
setComments({});
setShowResetConfirm(false);
if (onResetStatus) {
onResetStatus();
}
};
const shippedCount = items.filter((i) => shippedItems.has(i.id)).length; const shippedCount = items.filter((i) => shippedItems.has(i.id)).length;
const unshippedCount = items.length - shippedCount; const unshippedCount = items.length - shippedCount;
const allShipped = items.length > 0 && shippedCount === items.length; 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 variant="secondary" size="sm" onClick={shipAll} disabled={allShipped}>
Отгрузить всё Отгрузить всё
</Button> </Button>
<Button variant="ghost" size="sm" onClick={unshipAll} disabled={shippedCount === 0}> <Button variant="ghost" size="sm" onClick={unshipAll} disabled={shippedCount === 0 && !isStatusFinal}>
Сбросить Сбросить
</Button> </Button>
</div> </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"> <div className="space-y-2">
{items.map((item) => { {items.map((item) => {
const isShipped = shippedItems.has(item.id); 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>} {group.assignedDriverName || <span className="text-[var(--color-text-muted)]"></span>}
</div> </div>
<div className="px-3 py-1.5"> <div className="px-3 py-1.5">
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge> <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>
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]"> <div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
{formatDateTime(group.updatedAt)} {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 }) => { export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => {
const FILTERS_KEY = "logistics-filters"; const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" });
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 [collapsedSections, setCollapsedSections] = React.useState(() => loadCollapsedSections()); const [collapsedSections, setCollapsedSections] = React.useState(() => loadCollapsedSections());
const [sectionOrder, setSectionOrder] = React.useState(() => { const [sectionOrder, setSectionOrder] = React.useState(() => {
const custom = loadCustomOrder(); const custom = loadCustomOrder();

View File

@ -68,6 +68,14 @@ const fmtTime = (ts) => {
} catch { return "—"; } } 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 DELIVERY_TIME_OPTIONS = ["Первая половина дня", "Вторая половина дня"];
const STATUS_LABELS = DELIVERY_GROUP_STATUS_LABELS; const STATUS_LABELS = DELIVERY_GROUP_STATUS_LABELS;
@ -1093,7 +1101,27 @@ export const OrderDetailPanel = ({
) : null} ) : null}
{userRole === "driver" && order ? ( {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} ) : null}
{userRole === "driver" && order && onChangeDeliveryStatus ? ( {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) => { return statusOptions.map((statusOption) => {
const isSelected = pendingStatus?.value === statusOption.value; 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> </div>
{pendingStatus ? ( {pendingStatus ? (
<div className="flex items-center gap-3 mt-2"> <div className="flex items-center gap-3 mt-2">

View File

@ -21,7 +21,6 @@ const NOTIF_LABELS = {
manual_required: "Требуется ручное управление", manual_required: "Требуется ручное управление",
paid_storage_sending: "Отправляется…", paid_storage_sending: "Отправляется…",
paid_storage_sent: "Платное хранение: отправлено", paid_storage_sent: "Платное хранение: отправлено",
completed: "✅ Завершено",
draft: "Черновик", draft: "Черновик",
}; };
@ -36,7 +35,6 @@ const NOTIF_TONES = {
manual_required: "warning", manual_required: "warning",
paid_storage_sending: "info", paid_storage_sending: "info",
paid_storage_sent: "accent", paid_storage_sent: "accent",
completed: "accent",
draft: "neutral", draft: "neutral",
}; };
@ -46,7 +44,6 @@ const fmtTime = (ts) => {
try { try {
return new Date(ts).toLocaleString("ru-RU", { return new Date(ts).toLocaleString("ru-RU", {
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
timeZone: "Europe/Moscow",
}); });
} catch { return ts; } } catch { return ts; }
}; };
@ -67,9 +64,6 @@ const fmtCountdown = (targetTs) => {
// Component // Component
export const SmsStatusCard = ({ order, userRole }) => { 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 [restarting, setRestarting] = useState(false);
const [restartDone, setRestartDone] = useState(false); const [restartDone, setRestartDone] = useState(false);
const [now, setNow] = useState(Date.now()); const [now, setNow] = useState(Date.now());
@ -108,7 +102,6 @@ export const SmsStatusCard = ({ order, userRole }) => {
const canManage = ["mega_admin", "admin"].includes(userRole); const canManage = ["mega_admin", "admin"].includes(userRole);
const notifStatus = order.notificationStatus || order.notification_status || "not_started"; 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 nextCheck = order.nextNotificationCheckAt || order.next_notification_check_at;
const firstSmsAt = order.firstSmsSentAt || order.first_sms_sent_at; const firstSmsAt = order.firstSmsSentAt || order.first_sms_sent_at;
const secondSmsAt = order.secondSmsSentAt || order.second_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 smsAttempts = order.smsAttempts ?? order.sms_attempts ?? 0;
const lastError = order.lastSmsError || order.last_sms_error; 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 // Restart: reset this group's notification_status to link_ready
const handleRestart = async () => { const handleRestart = async () => {
if (!order?.id) return; if (!order?.id) return;
@ -196,7 +181,7 @@ export const SmsStatusCard = ({ order, userRole }) => {
</div> </div>
{/* Timeline */} {/* Timeline */}
<div className="space-y-2.5 text-sm"> <div className="space-y-2 text-xs">
{/* 1st SMS */} {/* 1st SMS */}
<div className="flex items-start gap-2"> <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)]"}`} /> <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 */} {/* 2nd SMS */}
<div className="flex items-start gap-2"> <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="flex-1">
<div className="text-[var(--color-text)]">2-е SMS</div> <div className="text-[var(--color-text)]">2-е SMS</div>
{hasSecondSms ? ( {hasSecondSms ? (
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsAt)} доставлено</div> <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>
) : notifStatus === "first_sms_sent" && countdown ? ( ) : notifStatus === "first_sms_sent" && countdown ? (
<div className="text-[var(--color-text-muted)]"> <div className="text-[var(--color-text-muted)]">
отправка через <span className="font-mono text-[var(--color-accent)]">{countdown}</span> отправка через <span className="font-mono text-[var(--color-accent)]">{countdown}</span>
</div> </div>
) : notifStatus === "first_sms_sent" ? (
<div className="text-[var(--color-text-muted)]">ожидает отправки</div>
) : ( ) : (
<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> </div>
{/* Next check countdown */} {/* 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"> <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="text-[var(--color-text-muted)]"> Следующая проверка:</span>
<span className="font-mono text-[var(--color-accent)]">{countdown}</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 */} {/* SMS log for this group */}
{smsLog.length > 0 && ( {smsLog.length > 0 && (
<div className="mt-3 border-t border-[var(--color-border)] pt-3"> <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="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">История SMS</div>
<div className="space-y-2"> <div className="space-y-1.5">
{smsLog.map((log) => ( {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> <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"}> <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} {log.status === "delivered" ? "доставлено" : log.status === "sent" ? "отправлено" : log.status === "checking" ? "проверка" : log.status === "expired" ? "истёк" : log.status}
@ -281,8 +260,8 @@ export const SmsStatusCard = ({ order, userRole }) => {
</div> </div>
)} )}
{/* Restart buttons — hidden when delivery is terminal (picked_up/delivered/cancelled) */} {/* Restart buttons */}
{canManage && !isTerminal && ( {canManage && (
<div className="mt-3 flex gap-2 border-t border-[var(--color-border)] pt-3"> <div className="mt-3 flex gap-2 border-t border-[var(--color-border)] pt-3">
<button <button
type="button" type="button"

View File

@ -14,7 +14,6 @@ import {
confirmDeliveryChoice, confirmDeliveryChoice,
fetchDeliveryInvitation, fetchDeliveryInvitation,
} from "../services/deliveryInvitationApi"; } from "../services/deliveryInvitationApi";
import { supabase } from "../supabaseClient";
const DELIVERY_TIMEZONE = "Europe/Simferopol"; const DELIVERY_TIMEZONE = "Europe/Simferopol";
@ -43,27 +42,15 @@ const addDaysToDateKey = (dateKey, amount) => {
return baseDate.toISOString().slice(0, 10); return baseDate.toISOString().slice(0, 10);
}; };
// Default: Mon-Fri (1-5). Overridden by business_schedule_settings from DB. const isSundayKey = (dateKey) => {
let _deliveryDays = [1, 2, 3, 4, 5]; if (!dateKey) return true;
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 d = new Date(`${dateKey}T12:00:00Z`); const d = new Date(`${dateKey}T12:00:00Z`);
// getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat return d.getUTCDay() === 0;
// _deliveryDays uses 1=Mon ... 7=Sun
const dayNum = d.getUTCDay() === 0 ? 7 : d.getUTCDay();
return _deliveryDays.includes(dayNum);
}; };
const getNextDeliveryWorkdayKey = (dateKey) => { const getNextWorkdayKey = (dateKey) => {
let next = addDaysToDateKey(dateKey, 1); let next = addDaysToDateKey(dateKey, 1);
while (!isAllowedDeliveryDay(next)) { while (isSundayKey(next)) {
next = addDaysToDateKey(next, 1); next = addDaysToDateKey(next, 1);
} }
return next; return next;
@ -71,10 +58,8 @@ const getNextDeliveryWorkdayKey = (dateKey) => {
const getAllowedDeliveryDateKeys = (referenceDate = new Date()) => { const getAllowedDeliveryDateKeys = (referenceDate = new Date()) => {
const todayKey = getBusinessTodayKey(referenceDate); const todayKey = getBusinessTodayKey(referenceDate);
// If today is a delivery day, include it; otherwise start from next workday const firstWorkday = getNextWorkdayKey(todayKey);
const startKey = isAllowedDeliveryDay(todayKey) ? todayKey : getNextDeliveryWorkdayKey(todayKey); const secondWorkday = getNextWorkdayKey(firstWorkday);
const firstWorkday = isAllowedDeliveryDay(todayKey) ? todayKey : getNextDeliveryWorkdayKey(todayKey);
const secondWorkday = getNextDeliveryWorkdayKey(firstWorkday);
return new Set([firstWorkday, secondWorkday].filter(Boolean)); return new Set([firstWorkday, secondWorkday].filter(Boolean));
}; };
@ -96,10 +81,10 @@ export const groupSlotsFromInvitation = (invitation, referenceDate = new Date())
const deliveryTime = invitation.deliveryTime; const deliveryTime = invitation.deliveryTime;
if (!rawSlots.length && !deliveryDate) { 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 todayKey = getBusinessTodayKey(referenceDate);
const firstWorkday = isAllowedDeliveryDay(todayKey) ? todayKey : getNextDeliveryWorkdayKey(todayKey); const firstWorkday = getNextWorkdayKey(todayKey);
const secondWorkday = getNextDeliveryWorkdayKey(firstWorkday); const secondWorkday = getNextWorkdayKey(firstWorkday);
return [ return [
{ id: `slot-${firstWorkday}-first`, date: firstWorkday, time: "Первая половина дня" }, { id: `slot-${firstWorkday}-first`, date: firstWorkday, time: "Первая половина дня" },
{ id: `slot-${firstWorkday}-second`, 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) => { .map((raw, index) => {
if (typeof raw === "string") { if (typeof raw === "string") {
const parts = raw.split(","); const parts = raw.split(",");
@ -167,21 +152,6 @@ export const groupSlotsFromInvitation = (invitation, referenceDate = new Date())
return null; return null;
}) })
.filter(Boolean); .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 = ({ export const buildDeliveryConfirmationPayload = ({
@ -251,36 +221,10 @@ export const ClientDeliveryPage = () => {
const [choiceSaved, setChoiceSaved] = React.useState(false); const [choiceSaved, setChoiceSaved] = React.useState(false);
const [activeTab, setActiveTab] = React.useState(TAB_DELIVERY); const [activeTab, setActiveTab] = React.useState(TAB_DELIVERY);
const [deliveryAddress, setDeliveryAddress] = React.useState(""); 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( const referenceDate = React.useMemo(
() => (invitation?.smsSentAt ? new Date(invitation.smsSentAt) : new Date()), () => (invitation?.smsSentAt ? new Date(invitation.smsSentAt) : new Date()),
[token, invitation?.smsSentAt], [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(() => { React.useEffect(() => {
let cancelled = false; let cancelled = false;
@ -327,7 +271,7 @@ export const ClientDeliveryPage = () => {
}; };
}, [token]); }, [token]);
const slots = groupSlotsFromInvitation(invitation, nowForSlots); const slots = groupSlotsFromInvitation(invitation, referenceDate);
const invitationState = invitation?.state || "awaiting_choice"; const invitationState = invitation?.state || "awaiting_choice";
const isActiveState = ["awaiting_choice", "opened", "reminder_sent"].includes(invitationState); const isActiveState = ["awaiting_choice", "opened", "reminder_sent"].includes(invitationState);
@ -374,7 +318,7 @@ export const ClientDeliveryPage = () => {
setSelectedSlot( setSelectedSlot(
buildSelectedSlotFromInvitation( buildSelectedSlotFromInvitation(
loadedInvitation, loadedInvitation,
groupSlotsFromInvitation(loadedInvitation, nowForSlots), groupSlotsFromInvitation(loadedInvitation, referenceDate),
) || effectiveSelectedSlot, ) || effectiveSelectedSlot,
); );
setChoiceSaved(true); setChoiceSaved(true);
@ -561,7 +505,6 @@ export const ClientDeliveryPage = () => {
onSelectSlot={handleSlotSelect} onSelectSlot={handleSlotSelect}
selectedSlotId={selectedSlotId} selectedSlotId={selectedSlotId}
referenceDate={referenceDate} referenceDate={referenceDate}
pickupDays={scheduleDays.pickupDays}
/> />
) : null} ) : 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("; ") ? shipmentData.filter((i) => !i.shipped).map((i) => `${i.name}${i.quantity ? ` (${i.quantity}${i.unit ? ` ${i.unit}` : ""})` : ""}${i.comment ? `${i.comment}` : ""}`).join("; ")
: null; : null;
// Any shipment data saved = delivered status. Problems go into has_delivery_problem/delivery_problem_note columns. // If all shipped → delivered. If partial (some shipped, some not) → problem.
const newDeliveryStatus = hasAnyShipped ? "delivered" : undefined; // 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 = { const updatePayload = {
driver_shipment_data: shipmentData, driver_shipment_data: shipmentData,