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