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:
commit
0821bcc747
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
- `src/context/AuthContext.jsx` — OTP-аутентификация через Supabase Auth и загрузка профиля пользователя с ролью. Неизвестный email показывает подсказку обратиться к администратору.
|
||||
- `src/context/ThemeContext.jsx` — управление светлой и тёмной темой через `data-theme`.
|
||||
- `src/context/FontSettingsContext.jsx` — настройки размера шрифтов (6 категорий: таблицы, карточки, меню, заголовки, основной текст, мелкий текст). Сохранение в `localStorage` (ключ `supersam-font-settings`). Применяет CSS-переменные `--fs-scale-*` на `:root`.
|
||||
- `src/hooks/usePwaStatus.js` — клиентское состояние PWA: online/offline, install prompt, standalone и offline readiness.
|
||||
- `src/hooks/useOrders.js` — локальный state заказов, истории, чатов, фильтров, действий и **сгруппированных наборов доставки** (deliverySetBuckets).
|
||||
- `src/hooks/useOrderGroups.js` — работа с группами заказов: загрузка, обновление статусов, ручное согласование доставки и самовывоза. Метод `saveManualDeliveryChoice` принимает `deliveryType`, `pickupDate`, `pickupTimeSlot`.
|
||||
|
|
@ -101,3 +102,25 @@ Edge function `confirm-delivery-choice` передаёт `p_delivery_type`, `p_p
|
|||
- Docker-сборка через `docker-compose.app.yml` (multi-stage: Node.js build → Caddy serve).
|
||||
- Автодеплой: Gitea webhook → systemd `supersam-webhook` → `deploy.sh` (git pull + docker build).
|
||||
- Домен: `https://dost.supersamsev.ru/`
|
||||
- Dev: `https://dev.mkn8n.ru/`
|
||||
- SW инвалидация: `construction-delivery-static-vNN` → `--no-cache` rebuild.
|
||||
- **Важно**: `sed` для bump SW версии — только `construction-delivery-static-vNN` и `runtime-vNN`, НЕ `v[0-9]*` (ломает `addEventListener`).
|
||||
|
||||
## SMS-кампании
|
||||
|
||||
См. `docs/sms-campaigns.md` — полная документация.
|
||||
4 systemd timer+service+Python скрипта, сервер 217.114.5.8.
|
||||
|
||||
## Настройки интерфейса
|
||||
|
||||
- `src/pages/SettingsPage.jsx` — UI настроек (пресеты + слайдеры).
|
||||
- `src/styles/fontSettings.css` — CSS-зоны `.fs-zone-*` оверрайдят Tailwind `text-*`.
|
||||
- Порядок CSS: body FIRST, затем table/card/nav/heading/small — специфичные зоны побеждают.
|
||||
- Arbitrary px sizes (`text-[10px]` — `text-[14px]`) требуют явных оверрайдов.
|
||||
- `localStorage` ключ: `supersam-font-settings`.
|
||||
|
||||
## Stale подсветка
|
||||
|
||||
- `isStale(group)` — `updatedAt > 24ч` и статус не в AGREED_STATUSES.
|
||||
- Жёлтый фон `bg-[rgba(191,123,33,0.06)]` на строке таблицы.
|
||||
- `isLinkOpened(group)` — 👁 иконка если клиент открывал ссылку.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|----------|---------|--------|-------------|
|
||||
| Часы | 8–21 | 8–21 | 8–21 |
|
||||
| Дни | Пн–Пт (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`
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
|
||||
|
||||
if (!isLocalhost) {
|
||||
const STATIC_CACHE = "construction-delivery-static-v61";
|
||||
const RUNTIME_CACHE = "construction-delivery-runtime-v61";
|
||||
const STATIC_CACHE = "construction-delivery-static-v49";
|
||||
const RUNTIME_CACHE = "construction-delivery-runtime-v49";
|
||||
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
|
|
|
|||
|
|
@ -28,8 +28,6 @@ import os
|
|||
import sys
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import fcntl
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
import requests
|
||||
|
|
@ -106,37 +104,12 @@ def load_settings(conn):
|
|||
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def normalize_phone(phone):
|
||||
"""Нормализует телефон: только цифры, начинается с 7. Возвращает None если пустой.
|
||||
|
||||
Обрабатляет:
|
||||
- +7XXX, 7XXX, 8XXX, XXX (без кода страны) → 7XXX
|
||||
- Текст/имена в поле (Екатерина +7978...) → извлекает только цифры
|
||||
- Два номера в одном поле (XXX / YYY) → берёт первый (обрезает до 11 цифр)
|
||||
- Минимум 10 цифр (без 7), максимум 11 (с 7)
|
||||
"""
|
||||
"""Нормализует телефон: только цифры, начинается с 7"""
|
||||
clean = "".join(c for c in str(phone) if c.isdigit())
|
||||
if len(clean) < 10:
|
||||
return None
|
||||
|
||||
# If 8 prefix (Russian landline style) → replace with 7
|
||||
if clean.startswith("8") and len(clean) == 11:
|
||||
if clean.startswith("8"):
|
||||
clean = "7" + clean[1:]
|
||||
# If starts with 7 and is 11 digits → ok
|
||||
elif clean.startswith("7") and len(clean) == 11:
|
||||
pass
|
||||
# If 10 digits (no country code) → prepend 7
|
||||
elif len(clean) == 10:
|
||||
clean = "7" + clean
|
||||
# If too long (multiple numbers concatenated) → take first 10 digits + prepend 7
|
||||
elif len(clean) > 11:
|
||||
# Try to extract first number: look for 10-digit sequence starting with 9
|
||||
# Common case: "9242377967 / 89783225219" → "9242377967" (10 digits)
|
||||
clean10 = clean[:10]
|
||||
clean = "7" + clean10
|
||||
# If starts with 7 but wrong length → prepend 7 to first 10 digits
|
||||
elif not clean.startswith("7"):
|
||||
clean = "7" + clean
|
||||
|
||||
return clean
|
||||
|
||||
def send_sms(phone, message, api_id):
|
||||
|
|
@ -163,7 +136,7 @@ def send_sms(phone, message, api_id):
|
|||
return None, str(e), "error"
|
||||
|
||||
def check_sms_status(sms_id, api_id):
|
||||
"""Проверяет статус, возвращает (sms_status_code, raw_response, api_code)"""
|
||||
"""Проверяет статус, возвращает (code, raw_response)"""
|
||||
try:
|
||||
resp = requests.post(SMS_STATUS_URL, params={
|
||||
"api_id": api_id,
|
||||
|
|
@ -180,23 +153,22 @@ def check_sms_status(sms_id, api_id):
|
|||
log.error(f"SMS status check error: {e}")
|
||||
return None, str(e), "error"
|
||||
|
||||
def fetch_balance(api_id):
|
||||
"""Получает баланс sms.ru, возвращает (balance_float, raw)"""
|
||||
try:
|
||||
resp = requests.get("https://sms.ru/my/balance", params={"api_id": api_id}, timeout=15)
|
||||
text = resp.text
|
||||
lines = text.strip().split("\n")
|
||||
if lines[0].strip() == "100" and len(lines) >= 2:
|
||||
return float(lines[1].strip()), text
|
||||
return None, text
|
||||
except Exception as e:
|
||||
log.error(f"Balance fetch error: {e}")
|
||||
return None, str(e)
|
||||
|
||||
# ─── Telegram ────────────────────────────────────────────────────────────────
|
||||
|
||||
def send_telegram(message, chat_id):
|
||||
pass # Telegram notifications moved to n8n+Supabase integration
|
||||
if not TELEGRAM_BOT_TOKEN:
|
||||
log.warning("TELEGRAM_BOT_TOKEN not set, skipping Telegram")
|
||||
return
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||
json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
log.warning(f"Telegram error: {resp.text[:200]}")
|
||||
except Exception as e:
|
||||
log.warning(f"Telegram failed: {e}")
|
||||
|
||||
# ─── State Machine ───────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -205,8 +177,7 @@ def get_groups_to_send(conn):
|
|||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("""
|
||||
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
||||
og.customer_phone_normalized, og.delivery_link, og.notification_status,
|
||||
og.sms_attempts
|
||||
og.customer_phone_normalized, og.delivery_link, og.notification_status
|
||||
FROM order_groups og
|
||||
WHERE og.status = 'ready_to_launch'
|
||||
AND og.delivery_status = 'pending_confirmation'
|
||||
|
|
@ -250,19 +221,12 @@ def get_sms_to_check(conn, max_duration_min):
|
|||
return [dict(r) for r in cur.fetchall()]
|
||||
|
||||
def get_sms_expired(conn, max_duration_min):
|
||||
"""SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены).
|
||||
Также считаем общее количество failed попыток для группы.
|
||||
"""
|
||||
"""SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены)"""
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("""
|
||||
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
|
||||
scl.attempts, scl.created_at,
|
||||
og.customer_name, og.group_key, og.delivery_link,
|
||||
og.sms_attempts as group_sms_attempts,
|
||||
(SELECT COUNT(*) FROM sms_campaign_log scl2
|
||||
WHERE scl2.order_group_id = scl.order_group_id
|
||||
AND scl2.campaign_type = 'first_sms'
|
||||
AND scl2.status IN ('expired', 'error', 'limit_exceeded')) as total_failed
|
||||
og.customer_name, og.group_key, og.delivery_link
|
||||
FROM sms_campaign_log scl
|
||||
JOIN order_groups og ON og.id = scl.order_group_id
|
||||
WHERE scl.campaign_type = 'first_sms'
|
||||
|
|
@ -310,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)
|
||||
conn.commit()
|
||||
|
||||
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_sms_days(conn):
|
||||
"""Read sms_days from business_schedule_settings. Returns comma-separated string."""
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1")
|
||||
row = cur.fetchone()
|
||||
if row and row.get("sms_days"):
|
||||
days = row["sms_days"]
|
||||
if isinstance(days, list):
|
||||
return ",".join(str(d) for d in days)
|
||||
return str(days)
|
||||
except Exception:
|
||||
pass
|
||||
return "1,2,3,4,5" # Default: Mon-Fri
|
||||
|
||||
def is_within_work_hours(settings):
|
||||
"""Проверка: сейчас рабочие часы.
|
||||
settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5')
|
||||
"""
|
||||
now_msk = datetime.now(timezone(timedelta(hours=3)))
|
||||
today_num = now_msk.weekday() + 1
|
||||
allowed_days = set()
|
||||
work_days_str = settings.get("work_days", "1,2,3,4,5")
|
||||
for part in str(work_days_str).split(","):
|
||||
part = part.strip()
|
||||
if part.isdigit():
|
||||
allowed_days.add(int(part))
|
||||
if today_num not in allowed_days:
|
||||
return False
|
||||
hour = now_msk.hour
|
||||
start_h = settings.get("work_hours_start", 8)
|
||||
end_h = settings.get("work_hours_end", 21)
|
||||
return start_h <= hour < end_h
|
||||
|
||||
# ─── Основная логика ─────────────────────────────────────────────────────────
|
||||
|
||||
def step_send_new(conn, settings, test_send=False):
|
||||
def step_send_new(conn, settings):
|
||||
"""Шаг 1: Отправка SMS новым группам"""
|
||||
api_id = settings.get("sms_api_id", SMS_API_ID)
|
||||
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||
max_attempts = settings.get("max_attempts", 2)
|
||||
second_sms_delay = settings.get("second_sms_delay_hours", 3)
|
||||
|
||||
if test_send:
|
||||
# Test mode: send ONE SMS to test phone, pick first available group
|
||||
all_groups = get_groups_to_send(conn)
|
||||
if not all_groups:
|
||||
log.info("Test send: no groups available in queue")
|
||||
return 0
|
||||
groups = [all_groups[0]] # Only first group
|
||||
log.info(f"TEST SEND: sending 1 SMS to test phone (skipping {len(all_groups)-1} others)")
|
||||
else:
|
||||
groups = get_groups_to_send(conn)
|
||||
groups = get_groups_to_send(conn)
|
||||
log.info(f"Step 1: {len(groups)} groups to send SMS")
|
||||
|
||||
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")
|
||||
continue
|
||||
|
||||
sms_text_template = settings.get("sms_text_template", "Ваш заказ готов. Согласуйте дату доставки по ссылке: {link}")
|
||||
sms_text = sms_text_template.replace("{link}", delivery_link + "?src=sms")
|
||||
sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}"
|
||||
|
||||
# ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый
|
||||
send_phone = phone
|
||||
|
|
@ -401,30 +318,24 @@ def step_send_new(conn, settings, test_send=False):
|
|||
sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
|
||||
|
||||
if sms_id:
|
||||
# Use sms_attempts from order_groups (incremented on retry by step_handle_expired)
|
||||
current_attempts = (group.get("sms_attempts") or 0) + 1
|
||||
log_id = insert_sms_log(conn,
|
||||
campaign_type="first_sms",
|
||||
order_group_id=group_id,
|
||||
customer_phone=phone,
|
||||
sms_id=sms_id,
|
||||
sms_text=sms_text,
|
||||
sent_to=send_phone,
|
||||
status="sent",
|
||||
sms_code=code,
|
||||
attempts=current_attempts,
|
||||
was_test_mode=bool(settings.get("test_mode", True)),
|
||||
attempts=1,
|
||||
)
|
||||
# ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status,
|
||||
# чтобы get_groups_to_send не нашёл эту группу при следующем запуске
|
||||
update_order_group(conn, group_id, {
|
||||
"notification_status": "sms_sending",
|
||||
"sms_sent_at": "NOW()",
|
||||
"sms_attempts": current_attempts,
|
||||
})
|
||||
log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}, log_id={log_id}, notification_status→sms_sending")
|
||||
sent_count += 1
|
||||
time.sleep(settings.get("send_interval_seconds", 15)) # Configurable rate limit
|
||||
else:
|
||||
# Ошибка отправки
|
||||
error = raw[:500] if raw else "Unknown error"
|
||||
|
|
@ -433,12 +344,10 @@ def step_send_new(conn, settings, test_send=False):
|
|||
order_group_id=group_id,
|
||||
customer_phone=phone,
|
||||
sms_text=sms_text,
|
||||
sent_to=send_phone if "send_phone" in dir() else phone,
|
||||
status="send_failed",
|
||||
sms_code=code,
|
||||
attempts=1,
|
||||
error_message=error,
|
||||
was_test_mode=bool(settings.get("test_mode", True)),
|
||||
)
|
||||
log.error(f"Group {group_id}: SMS send failed (code={code}): {error[:200]}")
|
||||
|
||||
|
|
@ -506,11 +415,11 @@ def step_check_status(conn, settings):
|
|||
error_message=f"Delivery error: {code}")
|
||||
# Переход к следующей попытке отправки (если есть)
|
||||
if attempts < max_attempts:
|
||||
log.info(f"Group {group_id}: retry first SMS (attempt {attempts+1}/{max_attempts})")
|
||||
log.info(f"Group {group_id}: will retry send on next run (attempt {attempts+1}/{max_attempts})")
|
||||
update_sms_log(conn, log_id, status="expired")
|
||||
# Сбрасываем notification_status → link_ready для повторной отправки
|
||||
update_order_group(conn, group_id, {
|
||||
"notification_status": "link_ready",
|
||||
"sms_attempts": attempts,
|
||||
})
|
||||
else:
|
||||
update_order_group(conn, group_id, {
|
||||
|
|
@ -554,29 +463,22 @@ def step_handle_expired(conn, settings):
|
|||
phone = item.get("customer_phone", "")
|
||||
attempts = item.get("attempts", 1)
|
||||
delivery_link = item.get("delivery_link", "")
|
||||
total_failed = item.get("total_failed", 0)
|
||||
|
||||
# Use total_failed across ALL attempts, not just this one
|
||||
effective_attempts = max(attempts, total_failed)
|
||||
|
||||
log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}, total_failed={total_failed}")
|
||||
log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}")
|
||||
update_sms_log(conn, log_id, status="expired",
|
||||
error_message=f"Not delivered in {max_duration} minutes")
|
||||
|
||||
if effective_attempts < max_attempts:
|
||||
# Повторная отправка ТЕКУЩЕЙ SMS (не откат назад)
|
||||
if attempts < max_attempts:
|
||||
# Сбрасываем notification_status → link_ready для повторной отправки
|
||||
update_order_group(conn, group_id, {
|
||||
"notification_status": "link_ready",
|
||||
"sms_attempts": effective_attempts,
|
||||
})
|
||||
log.info(f"Group {group_id}: retry first SMS (attempt {effective_attempts+1}/{max_attempts}), notification_status→link_ready")
|
||||
log.info(f"Group {group_id}: will retry SMS send (attempt {attempts+1}), notification_status→link_ready")
|
||||
else:
|
||||
# Все попытки исчерпаны
|
||||
update_order_group(conn, group_id, {
|
||||
"notification_status": "manual_required",
|
||||
"delivery_status": "manual_confirmation_required",
|
||||
"last_sms_error": f"Not delivered after {max_attempts} attempts",
|
||||
"status": "manual_required",
|
||||
})
|
||||
send_telegram(
|
||||
f"🔧 Требуется ручное управление: {name} ({phone})\n"
|
||||
|
|
@ -587,49 +489,12 @@ def step_handle_expired(conn, settings):
|
|||
# ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
# File lock — prevent parallel execution
|
||||
lock_file = open("/tmp/" + __file__.split("/")[-1].replace(".py", ".lock"), "w")
|
||||
try:
|
||||
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except (IOError, OSError):
|
||||
log.info("Another instance is running, exiting")
|
||||
lock_file.close()
|
||||
return
|
||||
|
||||
log.info("=" * 60)
|
||||
log.info("SMS First Campaign — START")
|
||||
conn = get_db_conn()
|
||||
|
||||
try:
|
||||
settings = load_settings(conn)
|
||||
# Override work_days with business_schedule sms_days
|
||||
settings["work_days"] = get_sms_days(conn)
|
||||
|
||||
# Update last_run_at timestamp
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("UPDATE sms_campaign_settings SET last_run_at = NOW() WHERE campaign_type = 'first_sms'", )
|
||||
conn.commit()
|
||||
|
||||
# Check test_send_requested — one-time test send
|
||||
test_send_requested = bool(settings.get("test_send_requested", False))
|
||||
if test_send_requested:
|
||||
# Reset flag immediately
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("UPDATE sms_campaign_settings SET test_send_requested = false WHERE campaign_type = 'first_sms'", )
|
||||
conn.commit()
|
||||
log.info("Test send requested — will send ONE SMS to test phone only")
|
||||
|
||||
# Check run_requested — restart scenario (reset all groups to beginning)
|
||||
run_requested = bool(settings.get('run_requested', False))
|
||||
if run_requested:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("UPDATE sms_campaign_settings SET run_requested = false WHERE campaign_type = 'first_sms'")
|
||||
conn.commit()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""UPDATE order_groups SET notification_status = 'link_ready', sms_sent_at = NULL, first_sms_sent_at = NULL, second_sms_sent_at = NULL, next_notification_check_at = NULL, sms_attempts = 0, last_sms_error = NULL WHERE delivery_status = 'pending_confirmation' AND notification_status IN ('first_sms_sent','sms_sending','send_failed','second_sms_sent','second_sms_sending','manual_required','not_started')""")
|
||||
reset_count = cur.rowcount
|
||||
conn.commit()
|
||||
log.info(f'RESTART first_sms: reset {reset_count} groups to link_ready')
|
||||
log.info(f"Settings: wait={settings.get('wait_between_checks_seconds')}s, "
|
||||
f"max_duration={settings.get('max_check_duration_minutes')}min, "
|
||||
f"max_attempts={settings.get('max_attempts')}")
|
||||
|
|
@ -639,15 +504,7 @@ def main():
|
|||
return
|
||||
|
||||
# State machine — каждый шаг быстрый, без blocking
|
||||
# Отправка только в рабочие часы
|
||||
work_hours = is_within_work_hours(settings)
|
||||
sent = 0
|
||||
if work_hours:
|
||||
sent = step_send_new(conn, settings, test_send=test_send_requested)
|
||||
else:
|
||||
log.info("Outside work hours, skipping new SMS sends")
|
||||
|
||||
# Проверка статусов работает всегда
|
||||
sent = step_send_new(conn, settings)
|
||||
delivered = step_check_status(conn, settings)
|
||||
step_handle_expired(conn, settings)
|
||||
|
||||
|
|
@ -659,25 +516,10 @@ def main():
|
|||
settings.get("telegram_chat_id", TELEGRAM_CHAT_ID),
|
||||
)
|
||||
|
||||
# Обновляем баланс sms.ru
|
||||
if sent > 0:
|
||||
api_id = settings.get("sms_api_id", SMS_API_ID)
|
||||
balance, raw = fetch_balance(api_id)
|
||||
if balance is not None:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE sms_campaign_settings SET last_balance = %s WHERE campaign_type = 'first_sms'",
|
||||
(balance,)
|
||||
)
|
||||
conn.commit()
|
||||
log.info(f"Balance updated: {balance} ₽")
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Fatal error: {e}", exc_info=True)
|
||||
finally:
|
||||
conn.close()
|
||||
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
||||
lock_file.close()
|
||||
|
||||
log.info("SMS First Campaign — END")
|
||||
log.info("=" * 60)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -76,22 +76,6 @@ def send_telegram(message, chat_id):
|
|||
|
||||
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_sms_days(conn):
|
||||
"""Read sms_days from business_schedule_settings. Returns comma-separated string."""
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1")
|
||||
row = cur.fetchone()
|
||||
if row and row.get("sms_days"):
|
||||
days = row["sms_days"]
|
||||
if isinstance(days, list):
|
||||
return ",".join(str(d) for d in days)
|
||||
return str(days)
|
||||
except Exception:
|
||||
pass
|
||||
return "1,2,3,4,5" # Default: Mon-Fri
|
||||
|
||||
def is_within_work_hours(settings):
|
||||
now_msk = datetime.now(timezone(timedelta(hours=3)))
|
||||
today_num = now_msk.weekday() + 1
|
||||
|
|
@ -139,16 +123,9 @@ def get_groups_to_manual(conn):
|
|||
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
|
||||
AND og.first_sms_sent_at < NOW() - INTERVAL '3 hours'
|
||||
AND og.second_sms_sent_at IS NOT NULL)
|
||||
-- 3. link_ready + next_check в прошлом + SMS уже отправлялась хотя бы раз
|
||||
-- ВАЖНО: если SMS никогда не отправлялась (нет записи в логе), не переводить в manual --
|
||||
-- возможно first campaign ещё не успел отправить (БД была недоступна и т.п.)
|
||||
-- 3. link_ready + next_check в прошлом + нет активных SMS в логе
|
||||
OR (COALESCE(og.notification_status, '') = 'link_ready'
|
||||
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM sms_campaign_log scl
|
||||
WHERE scl.order_group_id = og.id
|
||||
AND scl.status IN ('sent', 'checking', 'delivered')
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sms_campaign_log scl
|
||||
WHERE scl.order_group_id = og.id
|
||||
|
|
@ -233,8 +210,6 @@ def main():
|
|||
|
||||
try:
|
||||
settings = load_settings(conn)
|
||||
# Override work_days with business_schedule sms_days
|
||||
settings["work_days"] = get_sms_days(conn)
|
||||
|
||||
# Update last_run_at timestamp
|
||||
with conn.cursor() as cur:
|
||||
|
|
|
|||
|
|
@ -21,8 +21,6 @@ SMS текст: "Ваш заказ переведён на платное хра
|
|||
import os
|
||||
import sys
|
||||
import logging
|
||||
import time
|
||||
import fcntl
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
import requests
|
||||
|
|
@ -89,36 +87,16 @@ def load_settings(conn):
|
|||
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def normalize_phone(phone):
|
||||
"""Нормализует телефон: только цифры, начинается с 7. Возвращает None если пустой.
|
||||
|
||||
Обрабатывает:
|
||||
- +7XXX, 7XXX, 8XXX, XXX (без кода страны) → 7XXX
|
||||
- Текст/имена в поле (Екатерина +7978...) → извлекает только цифры
|
||||
- Два номера в одном поле (XXX / YYY) → берёт первый
|
||||
- Минимум 10 цифр, максимум 11
|
||||
"""
|
||||
clean = "".join(c for c in str(phone) if c.isdigit())
|
||||
if len(clean) < 10:
|
||||
return None
|
||||
|
||||
if len(clean) > 11:
|
||||
clean = clean[:10]
|
||||
|
||||
if clean.startswith("8") and len(clean) == 11:
|
||||
if clean.startswith("8"):
|
||||
clean = "7" + clean[1:]
|
||||
elif len(clean) == 10:
|
||||
elif not clean.startswith("7"):
|
||||
clean = "7" + clean
|
||||
elif not clean.startswith("7") and len(clean) == 11:
|
||||
clean = "7" + clean[1:]
|
||||
|
||||
return clean
|
||||
|
||||
def send_sms(phone, message, api_id):
|
||||
try:
|
||||
clean_phone = normalize_phone(phone)
|
||||
if not clean_phone:
|
||||
log.error(f"Invalid phone: {phone}")
|
||||
return None, "Invalid phone", "error"
|
||||
resp = requests.post(SMS_SEND_URL, params={"api_id": api_id, "to": clean_phone},
|
||||
data={"msg": message}, timeout=30)
|
||||
text = resp.text
|
||||
|
|
@ -176,22 +154,6 @@ def send_telegram(message, chat_id):
|
|||
|
||||
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_sms_days(conn):
|
||||
"""Read sms_days from business_schedule_settings. Returns comma-separated string."""
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1")
|
||||
row = cur.fetchone()
|
||||
if row and row.get("sms_days"):
|
||||
days = row["sms_days"]
|
||||
if isinstance(days, list):
|
||||
return ",".join(str(d) for d in days)
|
||||
return str(days)
|
||||
except Exception:
|
||||
pass
|
||||
return "1,2,3,4,5" # Default: Mon-Fri
|
||||
|
||||
def is_within_work_hours(settings):
|
||||
now_msk = datetime.now(timezone(timedelta(hours=3)))
|
||||
today_num = now_msk.weekday() + 1
|
||||
|
|
@ -305,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)
|
||||
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||
|
||||
if test_send:
|
||||
# Test mode: send ONE SMS to test phone, pick first available group
|
||||
all_groups = get_groups_to_send(conn)
|
||||
if not all_groups:
|
||||
log.info("Test send: no groups available in queue")
|
||||
return 0
|
||||
groups = [all_groups[0]] # Only first group
|
||||
log.info(f"TEST SEND: sending 1 SMS to test phone (skipping {len(all_groups)-1} others)")
|
||||
else:
|
||||
groups = get_groups_to_send(conn)
|
||||
groups = get_groups_to_send(conn)
|
||||
log.info(f"Step 1: {len(groups)} groups to send paid_storage SMS")
|
||||
|
||||
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")
|
||||
continue
|
||||
|
||||
sms_text_template = settings.get("sms_text_template", "Ваш заказ переведён на платное хранение. Стоимость: 300 ₽/день. Заберите заказ или согласуйте доставку: {link}")
|
||||
sms_text = sms_text_template.replace("{link}", delivery_link)
|
||||
sms_text = (
|
||||
f"Ваш заказ переведён на платное хранение. "
|
||||
f"Стоимость: 300 ₽/день. "
|
||||
f"Заберите заказ или согласуйте доставку: {delivery_link}"
|
||||
)
|
||||
|
||||
send_phone = phone
|
||||
if settings.get("test_mode", True):
|
||||
|
|
@ -357,11 +313,9 @@ def step_send_new(conn, settings, test_send=False):
|
|||
customer_phone=phone,
|
||||
sms_id=sms_id,
|
||||
sms_text=sms_text,
|
||||
sent_to=send_phone,
|
||||
status="sent",
|
||||
sms_code=code,
|
||||
attempts=1,
|
||||
was_test_mode=bool(settings.get("test_mode", True)),
|
||||
)
|
||||
update_order_group(conn, group_id, {
|
||||
"notification_status": "paid_storage_sending",
|
||||
|
|
@ -369,7 +323,6 @@ def step_send_new(conn, settings, test_send=False):
|
|||
})
|
||||
log.info(f"Group {group_id}: paid_storage SMS sent, sms_id={sms_id}, notification_status→paid_storage_sending")
|
||||
sent_count += 1
|
||||
time.sleep(settings.get("send_interval_seconds", 15)) # Configurable rate limit
|
||||
else:
|
||||
error = raw[:500] if raw else "Unknown error"
|
||||
insert_sms_log(conn,
|
||||
|
|
@ -377,12 +330,10 @@ def step_send_new(conn, settings, test_send=False):
|
|||
order_group_id=group_id,
|
||||
customer_phone=phone,
|
||||
sms_text=sms_text,
|
||||
sent_to=send_phone if "send_phone" in dir() else phone,
|
||||
status="send_failed",
|
||||
sms_code=code,
|
||||
attempts=1,
|
||||
error_message=error,
|
||||
was_test_mode=bool(settings.get("test_mode", True)),
|
||||
)
|
||||
log.error(f"Group {group_id}: paid_storage SMS failed (code={code}): {error[:200]}")
|
||||
update_order_group(conn, group_id, {"last_sms_error": error[:200]})
|
||||
|
|
@ -421,19 +372,9 @@ def step_check_status(conn, settings):
|
|||
elif code in DELIVERY_ERROR_CODES:
|
||||
log.error(f"Group {group_id}: delivery error (code={code})")
|
||||
update_sms_log(conn, log_id, status="error", sms_code=code, error_message=f"Delivery error: {code}")
|
||||
attempts = item.get("attempts", 1)
|
||||
max_attempts = settings.get("max_attempts", 2)
|
||||
if attempts < max_attempts:
|
||||
update_sms_log(conn, log_id, status="expired")
|
||||
# Сброс для retry — вернёмся к исходному статусу
|
||||
update_order_group(conn, group_id, {"notification_status": "not_started"})
|
||||
log.info(f"Group {group_id}: will retry paid_storage SMS (attempt {attempts+1}/{max_attempts})")
|
||||
else:
|
||||
update_order_group(conn, group_id, {
|
||||
"notification_status": "manual_required",
|
||||
"last_sms_error": f"Paid storage SMS failed after {max_attempts} attempts (code={code})",
|
||||
})
|
||||
send_telegram(f"⚠️ SMS платное хранение не доставлена после {max_attempts} попыток: {name} ({phone})", tg_chat)
|
||||
update_sms_log(conn, log_id, status="expired")
|
||||
# Сброс для retry
|
||||
update_order_group(conn, group_id, {"notification_status": "manual_required"})
|
||||
elif code in LIMIT_ERROR_CODES:
|
||||
log.error(f"Group {group_id}: limit exceeded (code={code})")
|
||||
update_sms_log(conn, log_id, status="limit_exceeded", sms_code=code, error_message=f"Limit: {code}")
|
||||
|
|
@ -463,66 +404,18 @@ def step_handle_expired(conn, settings):
|
|||
|
||||
log.warning(f"Group {group_id}: paid_storage SMS expired")
|
||||
update_sms_log(conn, log_id, status="expired", error_message=f"Not delivered in {max_duration} min")
|
||||
attempts = item.get("attempts", 1)
|
||||
max_attempts = settings.get("max_attempts", 2)
|
||||
if attempts < max_attempts:
|
||||
# Retry: сброс на not_started для повторной отправки
|
||||
update_order_group(conn, group_id, {"notification_status": "not_started"})
|
||||
log.info(f"Group {group_id}: will retry paid_storage SMS (attempt {attempts+1}/{max_attempts})")
|
||||
else:
|
||||
# Все попытки исчерпаны — ручное управление, НЕ paid_storage_sent
|
||||
update_order_group(conn, group_id, {
|
||||
"notification_status": "manual_required",
|
||||
"last_sms_error": f"Paid storage SMS not delivered after {max_attempts} attempts",
|
||||
})
|
||||
send_telegram(f"⚠️ SMS платное хранение не доставлена после {max_attempts} попыток: {name} ({phone})", tg_chat)
|
||||
update_order_group(conn, group_id, {"notification_status": "paid_storage_sent"})
|
||||
send_telegram(f"⚠️ SMS платное хранение не доставлена: {name} ({phone})", tg_chat)
|
||||
|
||||
# ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
# File lock — prevent parallel execution
|
||||
lock_file = open("/tmp/" + __file__.split("/")[-1].replace(".py", ".lock"), "w")
|
||||
try:
|
||||
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except (IOError, OSError):
|
||||
log.info("Another instance is running, exiting")
|
||||
lock_file.close()
|
||||
return
|
||||
|
||||
log.info("=" * 60)
|
||||
log.info("Paid Storage Campaign — START")
|
||||
conn = get_db_conn()
|
||||
|
||||
try:
|
||||
settings = load_settings(conn)
|
||||
# Override work_days with business_schedule sms_days
|
||||
settings["work_days"] = get_sms_days(conn)
|
||||
|
||||
# Update last_run_at timestamp
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("UPDATE sms_campaign_settings SET last_run_at = NOW() WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
||||
conn.commit()
|
||||
|
||||
# Check test_send_requested — one-time test send
|
||||
test_send_requested = bool(settings.get("test_send_requested", False))
|
||||
if test_send_requested:
|
||||
# Reset flag immediately
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("UPDATE sms_campaign_settings SET test_send_requested = false WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
||||
conn.commit()
|
||||
log.info("Test send requested — will send ONE SMS to test phone only")
|
||||
|
||||
# Check run_requested — restart scenario
|
||||
run_requested = bool(settings.get('run_requested', False))
|
||||
if run_requested:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("UPDATE sms_campaign_settings SET run_requested = false WHERE campaign_type = 'paid_storage'")
|
||||
conn.commit()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""UPDATE order_groups SET notification_status = 'not_started', sms_sent_at = NULL, next_notification_check_at = NULL, sms_attempts = 0, last_sms_error = NULL WHERE delivery_status = 'paid_storage' AND notification_status IN ('paid_storage_sent','paid_storage_sending','manual_required','send_failed')""")
|
||||
reset_count = cur.rowcount
|
||||
conn.commit()
|
||||
log.info(f'RESTART paid_storage: reset {reset_count} groups')
|
||||
log.info(f"Settings: work={settings.get('work_hours_start')}-{settings.get('work_hours_end')}, "
|
||||
f"days={settings.get('work_days')}, test={settings.get('test_mode')}")
|
||||
|
||||
|
|
@ -534,7 +427,7 @@ def main():
|
|||
work_hours = is_within_work_hours(settings)
|
||||
sent = 0
|
||||
if work_hours:
|
||||
sent = step_send_new(conn, settings, test_send=test_send_requested)
|
||||
sent = step_send_new(conn, settings)
|
||||
else:
|
||||
log.info("Outside work hours, skipping new SMS sends")
|
||||
|
||||
|
|
@ -548,7 +441,6 @@ def main():
|
|||
send_telegram(
|
||||
f"📦 <b>Платное хранение</b>\nОтправлено: {sent}\nДоставлено: {delivered}",
|
||||
settings.get("telegram_chat_id", TELEGRAM_CHAT_ID),
|
||||
was_test_mode=bool(settings.get("test_mode", True)),
|
||||
)
|
||||
|
||||
# Обновляем баланс
|
||||
|
|
@ -568,8 +460,6 @@ def main():
|
|||
log.error(f"Fatal error: {e}", exc_info=True)
|
||||
finally:
|
||||
conn.close()
|
||||
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
||||
lock_file.close()
|
||||
|
||||
log.info("Paid Storage Campaign — END")
|
||||
log.info("=" * 60)
|
||||
|
|
|
|||
|
|
@ -29,8 +29,6 @@ import os
|
|||
import sys
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import fcntl
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
import requests
|
||||
|
|
@ -111,46 +109,18 @@ def load_settings(conn):
|
|||
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def normalize_phone(phone):
|
||||
"""Нормализует телефон: только цифры, начинается с 7. Возвращает None если пустой.
|
||||
|
||||
Обрабатляет:
|
||||
- +7XXX, 7XXX, 8XXX, XXX (без кода страны) → 7XXX
|
||||
- Текст/имена в поле (Екатерина +7978...) → извлекает только цифры
|
||||
- Два номера в одном поле (XXX / YYY) → берёт первый (обрезает до 11 цифр)
|
||||
- Минимум 10 цифр (без 7), максимум 11 (с 7)
|
||||
"""
|
||||
"""Нормализует телефон: только цифры, начинается с 7"""
|
||||
clean = "".join(c for c in str(phone) if c.isdigit())
|
||||
if len(clean) < 10:
|
||||
return None
|
||||
|
||||
# If 8 prefix (Russian landline style) → replace with 7
|
||||
if clean.startswith("8") and len(clean) == 11:
|
||||
if clean.startswith("8"):
|
||||
clean = "7" + clean[1:]
|
||||
# If starts with 7 and is 11 digits → ok
|
||||
elif clean.startswith("7") and len(clean) == 11:
|
||||
pass
|
||||
# If 10 digits (no country code) → prepend 7
|
||||
elif len(clean) == 10:
|
||||
clean = "7" + clean
|
||||
# If too long (multiple numbers concatenated) → take first 10 digits + prepend 7
|
||||
elif len(clean) > 11:
|
||||
# Try to extract first number: look for 10-digit sequence starting with 9
|
||||
# Common case: "9242377967 / 89783225219" → "9242377967" (10 digits)
|
||||
clean10 = clean[:10]
|
||||
clean = "7" + clean10
|
||||
# If starts with 7 but wrong length → prepend 7 to first 10 digits
|
||||
elif not clean.startswith("7"):
|
||||
clean = "7" + clean
|
||||
|
||||
return clean
|
||||
|
||||
def send_sms(phone, message, api_id):
|
||||
"""Отправляет SMS, возвращает (sms_id, raw_response, code)"""
|
||||
try:
|
||||
clean_phone = normalize_phone(phone)
|
||||
if not clean_phone:
|
||||
log.error(f"Invalid phone: {phone}")
|
||||
return None, "Invalid phone", "error"
|
||||
resp = requests.post(SMS_SEND_URL, params={
|
||||
"api_id": api_id,
|
||||
"to": clean_phone,
|
||||
|
|
@ -204,7 +174,19 @@ def fetch_balance(api_id):
|
|||
# ─── Telegram ────────────────────────────────────────────────────────────────
|
||||
|
||||
def send_telegram(message, chat_id):
|
||||
pass # Telegram notifications moved to n8n+Supabase integration
|
||||
if not TELEGRAM_BOT_TOKEN:
|
||||
log.warning("TELEGRAM_BOT_TOKEN not set, skipping Telegram")
|
||||
return
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||
json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
log.warning(f"Telegram error: {resp.text[:200]}")
|
||||
except Exception as e:
|
||||
log.warning(f"Telegram failed: {e}")
|
||||
|
||||
# ─── State Machine ───────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -216,8 +198,7 @@ def get_groups_to_send(conn):
|
|||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("""
|
||||
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
||||
og.customer_phone_normalized, og.delivery_link, og.notification_status,
|
||||
og.sms_attempts
|
||||
og.customer_phone_normalized, og.delivery_link, og.notification_status
|
||||
FROM order_groups og
|
||||
WHERE og.delivery_status = 'pending_confirmation'
|
||||
AND og.delivery_link IS NOT NULL
|
||||
|
|
@ -262,19 +243,12 @@ def get_sms_to_check(conn, max_duration_min):
|
|||
return [dict(r) for r in cur.fetchall()]
|
||||
|
||||
def get_sms_expired(conn, max_duration_min):
|
||||
"""Вторые SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены).
|
||||
Также считаем общее количество failed попыток для группы.
|
||||
"""
|
||||
"""Вторые SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены)"""
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("""
|
||||
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
|
||||
scl.attempts, scl.created_at,
|
||||
og.customer_name, og.group_key, og.delivery_link,
|
||||
og.sms_attempts as group_sms_attempts,
|
||||
(SELECT COUNT(*) FROM sms_campaign_log scl2
|
||||
WHERE scl2.order_group_id = scl.order_group_id
|
||||
AND scl2.campaign_type = 'second_sms'
|
||||
AND scl2.status IN ('expired', 'error', 'limit_exceeded')) as total_failed
|
||||
og.customer_name, og.group_key, og.delivery_link
|
||||
FROM sms_campaign_log scl
|
||||
JOIN order_groups og ON og.id = scl.order_group_id
|
||||
WHERE scl.campaign_type = 'second_sms'
|
||||
|
|
@ -284,6 +258,7 @@ def get_sms_expired(conn, max_duration_min):
|
|||
ORDER BY scl.created_at ASC
|
||||
""" % max_duration_min)
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
|
||||
def insert_sms_log(conn, **kwargs):
|
||||
with conn.cursor() as cur:
|
||||
cols = ", ".join(kwargs.keys())
|
||||
|
|
@ -323,22 +298,6 @@ def update_order_group(conn, group_id, fields):
|
|||
|
||||
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_sms_days(conn):
|
||||
"""Read sms_days from business_schedule_settings. Returns comma-separated string."""
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1")
|
||||
row = cur.fetchone()
|
||||
if row and row.get("sms_days"):
|
||||
days = row["sms_days"]
|
||||
if isinstance(days, list):
|
||||
return ",".join(str(d) for d in days)
|
||||
return str(days)
|
||||
except Exception:
|
||||
pass
|
||||
return "1,2,3,4,5" # Default: Mon-Fri
|
||||
|
||||
def is_within_work_hours(settings):
|
||||
"""Проверка: сейчас рабочие часы.
|
||||
settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5')
|
||||
|
|
@ -362,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 группам, где первая доставлена, но нет согласования"""
|
||||
api_id = settings.get("sms_api_id", SMS_API_ID)
|
||||
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||
|
||||
if test_send:
|
||||
# Test mode: send ONE SMS to test phone, pick first available group
|
||||
all_groups = get_groups_to_send(conn)
|
||||
if not all_groups:
|
||||
log.info("Test send: no groups available in queue")
|
||||
return 0
|
||||
groups = [all_groups[0]] # Only first group
|
||||
log.info(f"TEST SEND: sending 1 SMS to test phone (skipping {len(all_groups)-1} others)")
|
||||
else:
|
||||
groups = get_groups_to_send(conn)
|
||||
groups = get_groups_to_send(conn)
|
||||
log.info(f"Step 1: {len(groups)} groups to send second SMS")
|
||||
|
||||
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")
|
||||
continue
|
||||
|
||||
sms_text_template = settings.get("sms_text_template", "Ваш заказ готов к доставке. Выберите дату доставки по ссылке: {link}")
|
||||
sms_text = sms_text_template.replace("{link}", delivery_link + "?src=sms")
|
||||
sms_text = f"Ваш заказ готов к доставке. Выберите дату доставки по ссылке: {delivery_link}"
|
||||
|
||||
# ТЕСТОВЫЙ РЕЖИМ
|
||||
send_phone = phone
|
||||
|
|
@ -412,28 +361,23 @@ def step_send_new(conn, settings, test_send=False):
|
|||
sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
|
||||
|
||||
if sms_id:
|
||||
current_attempts = (group.get("sms_attempts") or 0) + 1
|
||||
log_id = insert_sms_log(conn,
|
||||
campaign_type=CAMPAIGN_TYPE,
|
||||
order_group_id=group_id,
|
||||
customer_phone=phone,
|
||||
sms_id=sms_id,
|
||||
sms_text=sms_text,
|
||||
sent_to=send_phone,
|
||||
status="sent",
|
||||
sms_code=code,
|
||||
attempts=current_attempts,
|
||||
was_test_mode=bool(settings.get("test_mode", True)),
|
||||
attempts=1,
|
||||
)
|
||||
# ДВОЙНАЯ ЗАЩИТА: notification_status → second_sms_sending
|
||||
update_order_group(conn, group_id, {
|
||||
"notification_status": "second_sms_sending",
|
||||
"sms_sent_at": "NOW()",
|
||||
"sms_attempts": current_attempts,
|
||||
})
|
||||
log.info(f"Group {group_id}: second SMS sent, sms_id={sms_id}, log_id={log_id}, notification_status→second_sms_sending")
|
||||
sent_count += 1
|
||||
time.sleep(settings.get("send_interval_seconds", 15)) # Configurable rate limit
|
||||
else:
|
||||
error = raw[:500] if raw else "Unknown error"
|
||||
log_id = insert_sms_log(conn,
|
||||
|
|
@ -441,12 +385,10 @@ def step_send_new(conn, settings, test_send=False):
|
|||
order_group_id=group_id,
|
||||
customer_phone=phone,
|
||||
sms_text=sms_text,
|
||||
sent_to=send_phone if "send_phone" in dir() else phone,
|
||||
status="send_failed",
|
||||
sms_code=code,
|
||||
attempts=1,
|
||||
error_message=error,
|
||||
was_test_mode=bool(settings.get("test_mode", True)),
|
||||
)
|
||||
log.error(f"Group {group_id}: second SMS send failed (code={code}): {error[:200]}")
|
||||
update_order_group(conn, group_id, {
|
||||
|
|
@ -510,13 +452,11 @@ def step_check_status(conn, settings):
|
|||
update_sms_log(conn, log_id, status="error", sms_code=code,
|
||||
error_message=f"Delivery error: {code}")
|
||||
if attempts < max_attempts:
|
||||
log.info(f"Group {group_id}: retry second SMS (attempt {attempts+1}/{max_attempts})")
|
||||
log.info(f"Group {group_id}: will retry second SMS send (attempt {attempts+1}/{max_attempts})")
|
||||
update_sms_log(conn, log_id, status="expired")
|
||||
# Сбрасываем на first_sms_sent для повторной отправки
|
||||
update_order_group(conn, group_id, {
|
||||
"notification_status": "first_sms_sent",
|
||||
"sms_attempts": attempts,
|
||||
"second_sms_sent_at": None,
|
||||
"next_notification_check_at": None,
|
||||
})
|
||||
else:
|
||||
update_order_group(conn, group_id, {
|
||||
|
|
@ -559,31 +499,22 @@ def step_handle_expired(conn, settings):
|
|||
name = item.get("customer_name") or item.get("group_key", "—")
|
||||
phone = item.get("customer_phone", "")
|
||||
attempts = item.get("attempts", 1)
|
||||
total_failed = item.get("total_failed", 0)
|
||||
|
||||
effective_attempts = max(attempts, total_failed)
|
||||
|
||||
log.warning(f"Group {group_id}: second SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}, total_failed={total_failed}")
|
||||
log.warning(f"Group {group_id}: second SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}")
|
||||
update_sms_log(conn, log_id, status="expired",
|
||||
error_message=f"Not delivered in {max_duration} minutes")
|
||||
|
||||
if effective_attempts < max_attempts:
|
||||
# Повторная отправка 2й SMS (остаёмся в текущем шаге)
|
||||
# second_sms_sent_at = NULL чтобы get_groups_to_send нашёл группу
|
||||
if attempts < max_attempts:
|
||||
# Сбрасываем на first_sms_sent для повторной отправки
|
||||
update_order_group(conn, group_id, {
|
||||
"notification_status": "first_sms_sent",
|
||||
"sms_attempts": effective_attempts,
|
||||
"second_sms_sent_at": None,
|
||||
"next_notification_check_at": None,
|
||||
})
|
||||
log.info(f"Group {group_id}: retry second SMS (attempt {effective_attempts+1}/{max_attempts}), stays in second step")
|
||||
log.info(f"Group {group_id}: will retry second SMS send (attempt {attempts+1}), notification_status→first_sms_sent")
|
||||
else:
|
||||
# Все попытки исчерпаны → ручное управление
|
||||
update_order_group(conn, group_id, {
|
||||
"notification_status": "manual_required",
|
||||
"delivery_status": "manual_confirmation_required",
|
||||
"last_sms_error": f"Second SMS not delivered after {max_attempts} attempts",
|
||||
"status": "manual_required",
|
||||
})
|
||||
send_telegram(
|
||||
f"🔧 Требуется ручное управление (2-я SMS): {name} ({phone})\n"
|
||||
|
|
@ -594,50 +525,12 @@ def step_handle_expired(conn, settings):
|
|||
# ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
# File lock — prevent parallel execution
|
||||
lock_file = open("/tmp/" + __file__.split("/")[-1].replace(".py", ".lock"), "w")
|
||||
try:
|
||||
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except (IOError, OSError):
|
||||
log.info("Another instance is running, exiting")
|
||||
lock_file.close()
|
||||
return
|
||||
|
||||
log.info("=" * 60)
|
||||
log.info("SMS Second Campaign — START")
|
||||
conn = get_db_conn()
|
||||
|
||||
try:
|
||||
settings = load_settings(conn)
|
||||
# Override work_days with business_schedule sms_days
|
||||
settings["work_days"] = get_sms_days(conn)
|
||||
|
||||
# Update last_run_at timestamp
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("UPDATE sms_campaign_settings SET last_run_at = NOW() WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
||||
conn.commit()
|
||||
|
||||
# Check test_send_requested — one-time test send
|
||||
test_send_requested = bool(settings.get("test_send_requested", False))
|
||||
if test_send_requested:
|
||||
# Reset flag immediately
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("UPDATE sms_campaign_settings SET test_send_requested = false WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
||||
conn.commit()
|
||||
log.info("Test send requested — will send ONE SMS to test phone only")
|
||||
|
||||
# Check run_requested — restart scenario (reset all groups to beginning)
|
||||
run_requested = bool(settings.get('run_requested', False))
|
||||
if run_requested:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("UPDATE sms_campaign_settings SET run_requested = false WHERE campaign_type = 'second_sms'")
|
||||
conn.commit()
|
||||
# Reset groups that passed first_sms back to first_sms_sent — restart second SMS scenario
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""UPDATE order_groups SET notification_status = 'first_sms_sent', sms_sent_at = NULL, second_sms_sent_at = NULL, next_notification_check_at = NULL, sms_attempts = 0, last_sms_error = NULL WHERE delivery_status = 'pending_confirmation' AND notification_status IN ('second_sms_sent','second_sms_sending','manual_required','send_failed')""")
|
||||
reset_count = cur.rowcount
|
||||
conn.commit()
|
||||
log.info(f'RESTART second_sms: reset {reset_count} groups to first_sms_sent')
|
||||
log.info(f"Settings: wait={settings.get('wait_between_checks_seconds')}s, "
|
||||
f"max_duration={settings.get('max_check_duration_minutes')}min, "
|
||||
f"max_attempts={settings.get('max_attempts')}")
|
||||
|
|
@ -651,7 +544,7 @@ def main():
|
|||
work_hours = is_within_work_hours(settings)
|
||||
sent = 0
|
||||
if work_hours:
|
||||
sent = step_send_new(conn, settings, test_send=test_send_requested)
|
||||
sent = step_send_new(conn, settings)
|
||||
else:
|
||||
log.info("Outside work hours (8-21 MSK, Mon-Fri), skipping new SMS sends")
|
||||
|
||||
|
|
@ -684,8 +577,6 @@ def main():
|
|||
log.error(f"Fatal error: {e}", exc_info=True)
|
||||
finally:
|
||||
conn.close()
|
||||
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
||||
lock_file.close()
|
||||
|
||||
log.info("SMS Second Campaign — END")
|
||||
log.info("=" * 60)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -8,6 +8,7 @@ import {
|
|||
isOrderGroupVisibleToDriver,
|
||||
groupOrderGroupsByDate,
|
||||
parseGroupDate,
|
||||
ORDER_GROUP_DELIVERY_HALF_DAY_OPTIONS,
|
||||
} from "../../services/orderGroupViews";
|
||||
import { Badge } from "../UI/Badge";
|
||||
import { Button } from "../UI/Button";
|
||||
|
|
@ -110,13 +111,29 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
|
|||
selectedDate: "",
|
||||
deliveryStatus: "all",
|
||||
selectedCity: "",
|
||||
searchQuery: "",
|
||||
halfDay: "all",
|
||||
});
|
||||
const [collapsedDates, setCollapsedDates] = React.useState({});
|
||||
|
||||
const hasActiveFilters = filters.selectedDate || filters.deliveryStatus !== "all" || filters.selectedCity || filters.searchQuery || filters.halfDay !== "all";
|
||||
|
||||
const resetAllFilters = () => {
|
||||
setFilters({ selectedDate: "", deliveryStatus: "all", selectedCity: "", searchQuery: "", halfDay: "all" });
|
||||
};
|
||||
|
||||
const toggleDate = (date) => {
|
||||
setCollapsedDates((prev) => ({ ...prev, [date]: !prev[date] }));
|
||||
};
|
||||
|
||||
const normalizePhoneForTel = (phone) => {
|
||||
const cleaned = String(phone || "").trim();
|
||||
if (!cleaned) return "";
|
||||
if (cleaned.startsWith("+7")) return cleaned;
|
||||
if (cleaned.startsWith("8")) return "+7" + cleaned.slice(1);
|
||||
return "+7" + cleaned;
|
||||
};
|
||||
|
||||
const driverOrderGroups = React.useMemo(
|
||||
() => orderGroups.filter((group) => {
|
||||
const isVisible = isOrderGroupVisibleToDriver(group);
|
||||
|
|
@ -158,6 +175,19 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
|
|||
});
|
||||
}, [cityDeliveryMap]);
|
||||
|
||||
const getSearchHaystack = (group) => {
|
||||
return [
|
||||
group.groupKey,
|
||||
group.displayTitle,
|
||||
group.customerName,
|
||||
group.customerPhone,
|
||||
group.customerDate,
|
||||
Array.isArray(group.orderNumbers) ? group.orderNumbers.join(" ") : "",
|
||||
group.deliveryAddress || group.delivery_address || "",
|
||||
group.city || "",
|
||||
].filter(Boolean).join(" ").toLowerCase();
|
||||
};
|
||||
|
||||
const filteredOrderGroups = React.useMemo(() => {
|
||||
let result = [...driverOrderGroups];
|
||||
if (filters.selectedDate) {
|
||||
|
|
@ -172,8 +202,39 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
|
|||
return city === filters.selectedCity;
|
||||
});
|
||||
}
|
||||
if (filters.halfDay !== "all") {
|
||||
result = result.filter((group) => {
|
||||
const groupHalfDay = getOrderGroupDeliveryHalfDay(group);
|
||||
if (filters.halfDay === "unknown") {
|
||||
return !groupHalfDay;
|
||||
}
|
||||
const labelMap = { morning: "Первая половина дня", afternoon: "Вторая половина дня" };
|
||||
return groupHalfDay === labelMap[filters.halfDay];
|
||||
});
|
||||
}
|
||||
const query = (filters.searchQuery || "").trim().toLowerCase();
|
||||
if (query) {
|
||||
result = result.filter((group) => getSearchHaystack(group).includes(query));
|
||||
}
|
||||
// Sort: problems first, then by status priority
|
||||
const statusPriority = ["problem", "on_route", "loaded", "driver_assigned", "agreed", "delivered", "picked_up", "paid_storage"];
|
||||
result.sort((a, b) => {
|
||||
// has_delivery_problem always first
|
||||
const aProblem = a.hasDeliveryProblem || a.has_delivery_problem;
|
||||
const bProblem = b.hasDeliveryProblem || b.has_delivery_problem;
|
||||
if (aProblem && !bProblem) return -1;
|
||||
if (!aProblem && bProblem) return 1;
|
||||
const sa = a.deliveryStatus || a.delivery_status || "unknown";
|
||||
const sb = b.deliveryStatus || b.delivery_status || "unknown";
|
||||
const ia = statusPriority.indexOf(sa);
|
||||
const ib = statusPriority.indexOf(sb);
|
||||
if (ia === -1 && ib === -1) return 0;
|
||||
if (ia === -1) return 1;
|
||||
if (ib === -1) return -1;
|
||||
return ia - ib;
|
||||
});
|
||||
return result;
|
||||
}, [driverOrderGroups, filters.selectedDate, filters.deliveryStatus, filters.selectedCity]);
|
||||
}, [driverOrderGroups, filters.selectedDate, filters.deliveryStatus, filters.selectedCity, filters.searchQuery, filters.halfDay]);
|
||||
|
||||
const groupedOrderGroups = React.useMemo(
|
||||
() => groupOrderGroupsByDate(filteredOrderGroups),
|
||||
|
|
@ -245,6 +306,49 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
|
|||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<label className="flex min-w-0 flex-col gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||
Поиск
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Имя, адрес, телефон, номер заказа..."
|
||||
value={filters.searchQuery}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, searchQuery: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex min-w-0 flex-col gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||
Время дня
|
||||
</span>
|
||||
<Select
|
||||
value={filters.halfDay}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, halfDay: event.target.value }))}
|
||||
>
|
||||
{ORDER_GROUP_DELIVERY_HALF_DAY_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<div className="pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={resetAllFilters}
|
||||
className="text-xs text-[var(--color-text-muted)] hover:text-[var(--color-danger)]"
|
||||
>
|
||||
✕ Сбросить все фильтры
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date pills */}
|
||||
{sortedDeliveryDates.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
|
|
@ -401,31 +505,61 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
|
|||
<Badge tone={tone}>{label}</Badge>
|
||||
<span className="text-xs text-[var(--color-text-muted)]">{items.length} {pluralGroups(items.length)}</span>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{items.map((item) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant="secondary"
|
||||
className="rounded-[24px] p-4 text-left"
|
||||
onClick={() => onOpenOrder?.(item.id)}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-[var(--color-text)]">
|
||||
{item.displayTitle || item.customerName || item.groupKey}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
{item.customerDate} · {item.customerPhone}
|
||||
{getOrderGroupDeliveryHalfDay(item) ? ` · ${getOrderGroupDeliveryHalfDay(item)}` : ""}
|
||||
</div>
|
||||
{/* Table layout — № | Фамилия | Телефон | Город | Часть дня */}
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[640px]">
|
||||
<div className="grid grid-cols-[minmax(90px,1fr)_minmax(120px,1.5fr)_minmax(130px,1.2fr)_minmax(100px,1fr)_minmax(120px,1fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.1em] text-[var(--color-text-muted)]">
|
||||
<div className="px-3 py-1.5 font-medium">№ счёта</div>
|
||||
<div className="px-3 py-1.5 font-medium">Клиент</div>
|
||||
<div className="px-3 py-1.5 font-medium">Телефон</div>
|
||||
<div className="px-3 py-1.5 font-medium">Город</div>
|
||||
<div className="px-3 py-1.5 font-medium">Часть дня</div>
|
||||
</div>
|
||||
{items.map((item) => {
|
||||
const hasProblem = item.hasDeliveryProblem || item.has_delivery_problem;
|
||||
const phoneTel = normalizePhoneForTel(item.customerPhone);
|
||||
const halfDayLabel = getOrderGroupDeliveryHalfDay(item);
|
||||
const city = normalizeCity(item.deliveryAddress || item.delivery_address);
|
||||
const orderNum = (Array.isArray(item.orderNumbers) && item.orderNumbers.length > 0)
|
||||
? item.orderNumbers.join(", ")
|
||||
: (item.groupKey || "—");
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
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 className="mt-3 text-sm text-[var(--color-text-muted)]">
|
||||
{item.deliveryAddress || item.delivery_address || "Адрес не указан"}
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
<div className="px-3 py-2 text-xs font-medium text-[var(--color-text)]">
|
||||
{item.customerName || item.displayTitle || item.groupKey}
|
||||
</div>
|
||||
<div className="px-3 py-2 text-xs">
|
||||
{item.customerPhone && phoneTel ? (
|
||||
<a
|
||||
href={`tel:${phoneTel}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-[var(--color-accent)] hover:underline"
|
||||
>
|
||||
{item.customerPhone}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-[var(--color-text-muted)]">{item.customerPhone || "—"}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
|
||||
{city}
|
||||
</div>
|
||||
<div className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
|
||||
{halfDayLabel || "—"}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -89,11 +89,12 @@ const parseOrderItems = (order) => {
|
|||
return [];
|
||||
};
|
||||
|
||||
export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, isSavingShipment }) => {
|
||||
export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, isSavingShipment, onResetStatus, isSavingStatusChange }) => {
|
||||
const [stopWords, setStopWords] = React.useState([]);
|
||||
const [scopeActive, setScopeActive] = React.useState(true);
|
||||
const [savedShipment, setSavedShipment] = React.useState([]);
|
||||
const [justSaved, setJustSaved] = React.useState(false);
|
||||
const [showResetConfirm, setShowResetConfirm] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!supabase) return;
|
||||
|
|
@ -168,11 +169,27 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
|
|||
setComments({});
|
||||
};
|
||||
|
||||
const currentDeliveryStatus = order?.deliveryStatus || order?.delivery_status;
|
||||
const isStatusFinal = ["delivered", "problem", "picked_up"].includes(currentDeliveryStatus);
|
||||
|
||||
const unshipAll = () => {
|
||||
if (isStatusFinal && onResetStatus) {
|
||||
setShowResetConfirm(true);
|
||||
return;
|
||||
}
|
||||
setShippedItems(new Set());
|
||||
setComments({});
|
||||
};
|
||||
|
||||
const confirmResetAll = () => {
|
||||
setShippedItems(new Set());
|
||||
setComments({});
|
||||
setShowResetConfirm(false);
|
||||
if (onResetStatus) {
|
||||
onResetStatus();
|
||||
}
|
||||
};
|
||||
|
||||
const shippedCount = items.filter((i) => shippedItems.has(i.id)).length;
|
||||
const unshippedCount = items.length - shippedCount;
|
||||
const allShipped = items.length > 0 && shippedCount === items.length;
|
||||
|
|
@ -232,11 +249,36 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
|
|||
<Button variant="secondary" size="sm" onClick={shipAll} disabled={allShipped}>
|
||||
Отгрузить всё
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={unshipAll} disabled={shippedCount === 0}>
|
||||
<Button variant="ghost" size="sm" onClick={unshipAll} disabled={shippedCount === 0 && !isStatusFinal}>
|
||||
Сбросить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showResetConfirm && (
|
||||
<div className="rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] p-4 space-y-3">
|
||||
<p className="text-sm font-medium text-[var(--color-text)]">
|
||||
Сбросить отгрузку и вернуть статус?
|
||||
</p>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
Текущий статус («{currentDeliveryStatus === "delivered" ? "Доставлено" : currentDeliveryStatus === "problem" ? "Проблема" : "Вывезено"}») будет сброшен.
|
||||
Отгрузка будет очищена, логист увидит что доставка требует доработки.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={confirmResetAll}
|
||||
disabled={isSavingStatusChange}
|
||||
>
|
||||
{isSavingStatusChange ? "Сохраняем..." : "Да, сбросить"}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowResetConfirm(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => {
|
||||
const isShipped = shippedItems.has(item.id);
|
||||
|
|
|
|||
|
|
@ -158,7 +158,17 @@ const renderRow = (group, onSelectSet) => (
|
|||
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
|
||||
</div>
|
||||
<div className="px-3 py-1.5">
|
||||
<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 className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
|
||||
{formatDateTime(group.updatedAt)}
|
||||
|
|
@ -245,10 +255,7 @@ const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, on
|
|||
};
|
||||
|
||||
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => {
|
||||
const FILTERS_KEY = "logistics-filters";
|
||||
const savedFilters = (() => { try { return JSON.parse(localStorage.getItem(FILTERS_KEY) || "null"); } catch { return null; } })();
|
||||
const [filters, setFilters] = React.useState(savedFilters || { query: "", displayStatus: "all", city: "" });
|
||||
React.useEffect(() => { try { localStorage.setItem(FILTERS_KEY, JSON.stringify(filters)); } catch {} }, [filters]);
|
||||
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" });
|
||||
const [collapsedSections, setCollapsedSections] = React.useState(() => loadCollapsedSections());
|
||||
const [sectionOrder, setSectionOrder] = React.useState(() => {
|
||||
const custom = loadCustomOrder();
|
||||
|
|
|
|||
|
|
@ -68,6 +68,14 @@ const fmtTime = (ts) => {
|
|||
} catch { return "—"; }
|
||||
};
|
||||
|
||||
const fmtTime = (ts) => {
|
||||
if (!ts) return "—";
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||
} catch { return "—"; }
|
||||
};
|
||||
|
||||
const DELIVERY_TIME_OPTIONS = ["Первая половина дня", "Вторая половина дня"];
|
||||
const STATUS_LABELS = DELIVERY_GROUP_STATUS_LABELS;
|
||||
|
||||
|
|
@ -1093,7 +1101,27 @@ export const OrderDetailPanel = ({
|
|||
) : null}
|
||||
|
||||
{userRole === "driver" && order ? (
|
||||
<DriverShipmentPanel order={order} onShipmentChange={handleShipmentChange} onSaveShipment={handleSaveShipment} isSavingShipment={isSavingShipment} />
|
||||
<DriverShipmentPanel
|
||||
order={order}
|
||||
onShipmentChange={handleShipmentChange}
|
||||
onSaveShipment={handleSaveShipment}
|
||||
isSavingShipment={isSavingShipment}
|
||||
onResetStatus={() => {
|
||||
if (onChangeDeliveryStatus) {
|
||||
onChangeDeliveryStatus({
|
||||
orderGroupId: order.id,
|
||||
status: "loaded",
|
||||
}).then((response) => {
|
||||
if (!response.success) {
|
||||
setFormMessage(response.error || "Не удалось сбросить статус");
|
||||
} else {
|
||||
setFormMessage("Статус сброшен, отгрузка очищена");
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
isSavingStatusChange={isSavingStatusChange}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{userRole === "driver" && order && onChangeDeliveryStatus ? (
|
||||
|
|
@ -1140,7 +1168,10 @@ export const OrderDetailPanel = ({
|
|||
}
|
||||
}
|
||||
|
||||
if (statusOptions.length === 0) return null;
|
||||
// "Return to work" button for final statuses
|
||||
const canReturn = ["delivered", "picked_up", "problem"].includes(currentStatus);
|
||||
|
||||
if (statusOptions.length === 0 && !canReturn) return null;
|
||||
|
||||
return statusOptions.map((statusOption) => {
|
||||
const isSelected = pendingStatus?.value === statusOption.value;
|
||||
|
|
@ -1168,6 +1199,28 @@ export const OrderDetailPanel = ({
|
|||
);
|
||||
});
|
||||
})()}
|
||||
{(() => {
|
||||
const currentStatus = order.deliveryStatus || order.delivery_status;
|
||||
const canReturn = ["delivered", "picked_up", "problem"].includes(currentStatus);
|
||||
if (!canReturn) return null;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={isSavingStatusChange}
|
||||
onClick={() => {
|
||||
setPendingStatus({
|
||||
value: "loaded",
|
||||
label: "Вернуть в работу",
|
||||
mismatch: false,
|
||||
deliveryType: "delivery",
|
||||
});
|
||||
}}
|
||||
className="text-xs text-[var(--color-text-muted)] hover:text-[var(--color-warning)]"
|
||||
>
|
||||
↩ Вернуть в работу
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{pendingStatus ? (
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ const NOTIF_LABELS = {
|
|||
manual_required: "Требуется ручное управление",
|
||||
paid_storage_sending: "Отправляется…",
|
||||
paid_storage_sent: "Платное хранение: отправлено",
|
||||
completed: "✅ Завершено",
|
||||
draft: "Черновик",
|
||||
};
|
||||
|
||||
|
|
@ -36,7 +35,6 @@ const NOTIF_TONES = {
|
|||
manual_required: "warning",
|
||||
paid_storage_sending: "info",
|
||||
paid_storage_sent: "accent",
|
||||
completed: "accent",
|
||||
draft: "neutral",
|
||||
};
|
||||
|
||||
|
|
@ -46,7 +44,6 @@ const fmtTime = (ts) => {
|
|||
try {
|
||||
return new Date(ts).toLocaleString("ru-RU", {
|
||||
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
|
||||
timeZone: "Europe/Moscow",
|
||||
});
|
||||
} catch { return ts; }
|
||||
};
|
||||
|
|
@ -67,9 +64,6 @@ const fmtCountdown = (targetTs) => {
|
|||
|
||||
// ── Component ────────────────────────────────────────────────────────────────
|
||||
export const SmsStatusCard = ({ order, userRole }) => {
|
||||
// Only show to staff (not clients)
|
||||
const isStaff = ["mega_admin", "admin", "manager", "logistician", "driver"].includes(userRole);
|
||||
if (!isStaff) return null;
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [restartDone, setRestartDone] = useState(false);
|
||||
const [now, setNow] = useState(Date.now());
|
||||
|
|
@ -108,7 +102,6 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
|||
const canManage = ["mega_admin", "admin"].includes(userRole);
|
||||
|
||||
const notifStatus = order.notificationStatus || order.notification_status || "not_started";
|
||||
const deliveryStatus = order.deliveryStatus || order.delivery_status || "";
|
||||
const nextCheck = order.nextNotificationCheckAt || order.next_notification_check_at;
|
||||
const firstSmsAt = order.firstSmsSentAt || order.first_sms_sent_at;
|
||||
const secondSmsAt = order.secondSmsSentAt || order.second_sms_sent_at;
|
||||
|
|
@ -116,14 +109,6 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
|||
const smsAttempts = order.smsAttempts ?? order.sms_attempts ?? 0;
|
||||
const lastError = order.lastSmsError || order.last_sms_error;
|
||||
|
||||
// Terminal delivery states — SMS flow should be fully stopped
|
||||
const TERMINAL_STATUSES = ["picked_up", "delivered", "cancelled"];
|
||||
const isTerminal = TERMINAL_STATUSES.includes(deliveryStatus);
|
||||
const terminalLabel = deliveryStatus === "picked_up" ? "Самовывоз завершён"
|
||||
: deliveryStatus === "delivered" ? "Доставка завершена"
|
||||
: deliveryStatus === "cancelled" ? "Заказ отменён"
|
||||
: "";
|
||||
|
||||
// Restart: reset this group's notification_status to link_ready
|
||||
const handleRestart = async () => {
|
||||
if (!order?.id) return;
|
||||
|
|
@ -196,7 +181,7 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
|||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="space-y-2.5 text-sm">
|
||||
<div className="space-y-2 text-xs">
|
||||
{/* 1st SMS */}
|
||||
<div className="flex items-start gap-2">
|
||||
<span className={`mt-0.5 h-2 w-2 rounded-full ${hasFirstSms ? "bg-[#22c55e]" : hasSmsSent && notifStatus === "sms_sending" ? "bg-[var(--color-accent)]" : notifStatus === "link_ready" || notifStatus === "not_started" ? "bg-[var(--color-warning)]" : "bg-[var(--color-border)]"}`} />
|
||||
|
|
@ -216,21 +201,15 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
|||
|
||||
{/* 2nd SMS */}
|
||||
<div className="flex items-start gap-2">
|
||||
<span className={`mt-0.5 h-2 w-2 rounded-full ${hasSecondSms ? "bg-[#22c55e]" : isTerminal ? "bg-[var(--color-border)]" : notifStatus === "second_sms_sending" ? "bg-[var(--color-accent)]" : notifStatus === "first_sms_sent" ? "bg-[var(--color-warning)]" : "bg-[var(--color-border)]"}`} />
|
||||
<span className={`mt-0.5 h-2 w-2 rounded-full ${hasSecondSms ? "bg-[#22c55e]" : notifStatus === "first_sms_sent" ? "bg-[var(--color-warning)]" : "bg-[var(--color-border)]"}`} />
|
||||
<div className="flex-1">
|
||||
<div className="text-[var(--color-text)]">2-е SMS</div>
|
||||
{hasSecondSms ? (
|
||||
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsAt)} ✓ доставлено</div>
|
||||
) : isTerminal ? (
|
||||
<div className="text-[var(--color-text-muted)]">— {terminalLabel}, SMS не отправляется</div>
|
||||
) : notifStatus === "second_sms_sending" && hasSmsSent ? (
|
||||
<div className="text-[var(--color-text-muted)]">{fmtTime(smsSentAt)} · отправлено, ждём подтверждения…</div>
|
||||
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsAt)}</div>
|
||||
) : notifStatus === "first_sms_sent" && countdown ? (
|
||||
<div className="text-[var(--color-text-muted)]">
|
||||
отправка через <span className="font-mono text-[var(--color-accent)]">{countdown}</span>
|
||||
</div>
|
||||
) : notifStatus === "first_sms_sent" ? (
|
||||
<div className="text-[var(--color-text-muted)]">ожидает отправки</div>
|
||||
) : (
|
||||
<div className="text-[var(--color-text-muted)]">—</div>
|
||||
)}
|
||||
|
|
@ -238,7 +217,7 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
|||
</div>
|
||||
|
||||
{/* Next check countdown */}
|
||||
{countdown && notifStatus !== "second_sms_sent" && !isTerminal && (
|
||||
{countdown && notifStatus !== "second_sms_sent" && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-[var(--color-surface-strong)] px-2 py-1.5">
|
||||
<span className="text-[var(--color-text-muted)]">⏱ Следующая проверка:</span>
|
||||
<span className="font-mono text-[var(--color-accent)]">{countdown}</span>
|
||||
|
|
@ -261,10 +240,10 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
|||
{/* SMS log for this group */}
|
||||
{smsLog.length > 0 && (
|
||||
<div className="mt-3 border-t border-[var(--color-border)] pt-3">
|
||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">История SMS</div>
|
||||
<div className="space-y-2">
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">История SMS</div>
|
||||
<div className="space-y-1.5">
|
||||
{smsLog.map((log) => (
|
||||
<div key={log.id} className="flex items-center gap-2 text-xs">
|
||||
<div key={log.id} className="flex items-center gap-2 text-[11px]">
|
||||
<span className="text-[var(--color-text-muted)]">{fmtTime(log.created_at)}</span>
|
||||
<Badge tone={log.status === "delivered" ? "accent" : log.status === "sent" || log.status === "checking" ? "info" : "danger"}>
|
||||
{log.status === "delivered" ? "доставлено" : log.status === "sent" ? "отправлено" : log.status === "checking" ? "проверка" : log.status === "expired" ? "истёк" : log.status}
|
||||
|
|
@ -281,8 +260,8 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Restart buttons — hidden when delivery is terminal (picked_up/delivered/cancelled) */}
|
||||
{canManage && !isTerminal && (
|
||||
{/* Restart buttons */}
|
||||
{canManage && (
|
||||
<div className="mt-3 flex gap-2 border-t border-[var(--color-border)] pt-3">
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import {
|
|||
confirmDeliveryChoice,
|
||||
fetchDeliveryInvitation,
|
||||
} from "../services/deliveryInvitationApi";
|
||||
import { supabase } from "../supabaseClient";
|
||||
|
||||
const DELIVERY_TIMEZONE = "Europe/Simferopol";
|
||||
|
||||
|
|
@ -43,27 +42,15 @@ const addDaysToDateKey = (dateKey, amount) => {
|
|||
return baseDate.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
// Default: Mon-Fri (1-5). Overridden by business_schedule_settings from DB.
|
||||
let _deliveryDays = [1, 2, 3, 4, 5];
|
||||
let _pickupDays = [1, 2, 3, 4, 5];
|
||||
|
||||
export const setScheduleDaysExternal = ({ deliveryDays, pickupDays }) => {
|
||||
if (Array.isArray(deliveryDays) && deliveryDays.length) _deliveryDays = deliveryDays;
|
||||
if (Array.isArray(pickupDays) && pickupDays.length) _pickupDays = pickupDays;
|
||||
};
|
||||
|
||||
const isAllowedDeliveryDay = (dateKey) => {
|
||||
if (!dateKey) return false;
|
||||
const isSundayKey = (dateKey) => {
|
||||
if (!dateKey) return true;
|
||||
const d = new Date(`${dateKey}T12:00:00Z`);
|
||||
// getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
|
||||
// _deliveryDays uses 1=Mon ... 7=Sun
|
||||
const dayNum = d.getUTCDay() === 0 ? 7 : d.getUTCDay();
|
||||
return _deliveryDays.includes(dayNum);
|
||||
return d.getUTCDay() === 0;
|
||||
};
|
||||
|
||||
const getNextDeliveryWorkdayKey = (dateKey) => {
|
||||
const getNextWorkdayKey = (dateKey) => {
|
||||
let next = addDaysToDateKey(dateKey, 1);
|
||||
while (!isAllowedDeliveryDay(next)) {
|
||||
while (isSundayKey(next)) {
|
||||
next = addDaysToDateKey(next, 1);
|
||||
}
|
||||
return next;
|
||||
|
|
@ -71,10 +58,8 @@ const getNextDeliveryWorkdayKey = (dateKey) => {
|
|||
|
||||
const getAllowedDeliveryDateKeys = (referenceDate = new Date()) => {
|
||||
const todayKey = getBusinessTodayKey(referenceDate);
|
||||
// If today is a delivery day, include it; otherwise start from next workday
|
||||
const startKey = isAllowedDeliveryDay(todayKey) ? todayKey : getNextDeliveryWorkdayKey(todayKey);
|
||||
const firstWorkday = isAllowedDeliveryDay(todayKey) ? todayKey : getNextDeliveryWorkdayKey(todayKey);
|
||||
const secondWorkday = getNextDeliveryWorkdayKey(firstWorkday);
|
||||
const firstWorkday = getNextWorkdayKey(todayKey);
|
||||
const secondWorkday = getNextWorkdayKey(firstWorkday);
|
||||
return new Set([firstWorkday, secondWorkday].filter(Boolean));
|
||||
};
|
||||
|
||||
|
|
@ -96,10 +81,10 @@ export const groupSlotsFromInvitation = (invitation, referenceDate = new Date())
|
|||
const deliveryTime = invitation.deliveryTime;
|
||||
|
||||
if (!rawSlots.length && !deliveryDate) {
|
||||
// Fallback: generate default delivery slots (next 2 workdays, both halves, skip non-delivery days)
|
||||
// Fallback: generate default delivery slots (next 2 workdays, both halves, skip Sunday)
|
||||
const todayKey = getBusinessTodayKey(referenceDate);
|
||||
const firstWorkday = isAllowedDeliveryDay(todayKey) ? todayKey : getNextDeliveryWorkdayKey(todayKey);
|
||||
const secondWorkday = getNextDeliveryWorkdayKey(firstWorkday);
|
||||
const firstWorkday = getNextWorkdayKey(todayKey);
|
||||
const secondWorkday = getNextWorkdayKey(firstWorkday);
|
||||
return [
|
||||
{ id: `slot-${firstWorkday}-first`, date: firstWorkday, time: "Первая половина дня" },
|
||||
{ id: `slot-${firstWorkday}-second`, date: firstWorkday, time: "Вторая половина дня" },
|
||||
|
|
@ -118,7 +103,7 @@ export const groupSlotsFromInvitation = (invitation, referenceDate = new Date())
|
|||
];
|
||||
}
|
||||
|
||||
const result = rawSlots
|
||||
return rawSlots
|
||||
.map((raw, index) => {
|
||||
if (typeof raw === "string") {
|
||||
const parts = raw.split(",");
|
||||
|
|
@ -167,21 +152,6 @@ export const groupSlotsFromInvitation = (invitation, referenceDate = new Date())
|
|||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
// If all rawSlots were filtered out (dates passed), generate fresh slots
|
||||
if (!result.length && rawSlots.length) {
|
||||
const todayKey = getBusinessTodayKey(referenceDate);
|
||||
const firstWorkday = isAllowedDeliveryDay(todayKey) ? todayKey : getNextDeliveryWorkdayKey(todayKey);
|
||||
const secondWorkday = getNextDeliveryWorkdayKey(firstWorkday);
|
||||
return [
|
||||
{ id: `slot-${firstWorkday}-first`, date: firstWorkday, time: "Первая половина дня" },
|
||||
{ id: `slot-${firstWorkday}-second`, date: firstWorkday, time: "Вторая половина дня" },
|
||||
{ id: `slot-${secondWorkday}-first`, date: secondWorkday, time: "Первая половина дня" },
|
||||
{ id: `slot-${secondWorkday}-second`, date: secondWorkday, time: "Вторая половина дня" },
|
||||
].filter((s) => s.date);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const buildDeliveryConfirmationPayload = ({
|
||||
|
|
@ -251,36 +221,10 @@ export const ClientDeliveryPage = () => {
|
|||
const [choiceSaved, setChoiceSaved] = React.useState(false);
|
||||
const [activeTab, setActiveTab] = React.useState(TAB_DELIVERY);
|
||||
const [deliveryAddress, setDeliveryAddress] = React.useState("");
|
||||
const [scheduleDays, setScheduleDays] = React.useState({ deliveryDays: [1,2,3,4,5], pickupDays: [1,2,3,4,5] });
|
||||
const referenceDate = React.useMemo(
|
||||
() => (invitation?.smsSentAt ? new Date(invitation.smsSentAt) : new Date()),
|
||||
[token, invitation?.smsSentAt],
|
||||
);
|
||||
// For slot availability, always use current date — not SMS send date
|
||||
const nowForSlots = React.useMemo(() => new Date(), []);
|
||||
|
||||
// Fetch business schedule (delivery/pickup days) — no auth required
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
const loadSchedule = async () => {
|
||||
try {
|
||||
const { data, error: rpcError } = await supabase.rpc("get_business_schedule");
|
||||
if (rpcError) throw rpcError;
|
||||
if (!cancelled && data?.ok) {
|
||||
const days = {
|
||||
deliveryDays: data.deliveryDays || [1,2,3,4,5],
|
||||
pickupDays: data.pickupDays || [1,2,3,4,5],
|
||||
};
|
||||
setScheduleDays(days);
|
||||
setScheduleDaysExternal(days);
|
||||
}
|
||||
} catch {
|
||||
// Silent fallback — use defaults
|
||||
}
|
||||
};
|
||||
loadSchedule();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
|
@ -327,7 +271,7 @@ export const ClientDeliveryPage = () => {
|
|||
};
|
||||
}, [token]);
|
||||
|
||||
const slots = groupSlotsFromInvitation(invitation, nowForSlots);
|
||||
const slots = groupSlotsFromInvitation(invitation, referenceDate);
|
||||
|
||||
const invitationState = invitation?.state || "awaiting_choice";
|
||||
const isActiveState = ["awaiting_choice", "opened", "reminder_sent"].includes(invitationState);
|
||||
|
|
@ -374,7 +318,7 @@ export const ClientDeliveryPage = () => {
|
|||
setSelectedSlot(
|
||||
buildSelectedSlotFromInvitation(
|
||||
loadedInvitation,
|
||||
groupSlotsFromInvitation(loadedInvitation, nowForSlots),
|
||||
groupSlotsFromInvitation(loadedInvitation, referenceDate),
|
||||
) || effectiveSelectedSlot,
|
||||
);
|
||||
setChoiceSaved(true);
|
||||
|
|
@ -561,7 +505,6 @@ export const ClientDeliveryPage = () => {
|
|||
onSelectSlot={handleSlotSelect}
|
||||
selectedSlotId={selectedSlotId}
|
||||
referenceDate={referenceDate}
|
||||
pickupDays={scheduleDays.pickupDays}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -420,8 +420,16 @@ export const saveShipmentData = async ({ orderGroupId, shipmentData }) => {
|
|||
? shipmentData.filter((i) => !i.shipped).map((i) => `${i.name}${i.quantity ? ` (${i.quantity}${i.unit ? ` ${i.unit}` : ""})` : ""}${i.comment ? ` — ${i.comment}` : ""}`).join("; ")
|
||||
: null;
|
||||
|
||||
// Any shipment data saved = delivered status. Problems go into has_delivery_problem/delivery_problem_note columns.
|
||||
const newDeliveryStatus = hasAnyShipped ? "delivered" : undefined;
|
||||
// If all shipped → delivered. If partial (some shipped, some not) → problem.
|
||||
// If nothing shipped → keep current status (driver just clearing checkboxes).
|
||||
let newDeliveryStatus;
|
||||
if (hasAnyShipped && !hasProblem) {
|
||||
newDeliveryStatus = "delivered";
|
||||
} else if (hasAnyShipped && hasProblem) {
|
||||
newDeliveryStatus = "problem";
|
||||
} else {
|
||||
newDeliveryStatus = undefined;
|
||||
}
|
||||
|
||||
const updatePayload = {
|
||||
driver_shipment_data: shipmentData,
|
||||
|
|
|
|||
Loading…
Reference in New Issue