feat: drag-n-drop status sections + localStorage filters/collapsed + pickup date in logistics board
- Drag-n-drop reordering of status sections (desktop + touch) - Section order saved in localStorage (logistics-section-order) - Collapsed/expanded state saved in localStorage (logistics-collapsed) - Filters (city, status, search) saved in localStorage (logistics-filters) - Pickup date shown in delivery date column for self-pickup orders - SMS campaign scripts (manual, second, paid_storage, timer manager) - SmsCampaignStats + SmsStatusCard components - DashboardPage: unified LogisticsReadinessBoard for all roles - OrdersTable cleanup, StatusActionPanel updates - SW v42
This commit is contained in:
parent
581239cc73
commit
b78bdc48bb
|
|
@ -8,3 +8,6 @@ dist
|
||||||
.superpowers
|
.superpowers
|
||||||
.ruff_cache
|
.ruff_cache
|
||||||
volumes/db/data/
|
volumes/db/data/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.bak
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
"name": "construction-delivery",
|
"name": "construction-delivery",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@supabase/supabase-js": "2.52.0",
|
"@supabase/supabase-js": "2.52.0",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
|
|
@ -339,6 +342,59 @@
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@
|
||||||
"anonymize:1c-xml": "node scripts/anonymize-1c-xml.mjs"
|
"anonymize:1c-xml": "node scripts/anonymize-1c-xml.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@supabase/supabase-js": "2.52.0",
|
"@supabase/supabase-js": "2.52.0",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
|
|
@ -20,8 +23,8 @@
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-router-dom": "7.3.0",
|
"react-router-dom": "7.3.0",
|
||||||
"tailwind-merge": "3.3.0",
|
"recharts": "^2.15.0",
|
||||||
"recharts": "^2.15.0"
|
"tailwind-merge": "3.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.22.0",
|
"@eslint/js": "^9.22.0",
|
||||||
|
|
@ -38,4 +41,4 @@
|
||||||
"vite": "^6.2.0",
|
"vite": "^6.2.0",
|
||||||
"vitest": "^3.0.9"
|
"vitest": "^3.0.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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-v9";
|
const STATIC_CACHE = "construction-delivery-static-v42";
|
||||||
const RUNTIME_CACHE = "construction-delivery-runtime-v9";
|
const RUNTIME_CACHE = "construction-delivery-runtime-v42";
|
||||||
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) => {
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,14 @@ SuperSam — SMS First Campaign (State Machine)
|
||||||
2. Проверяет статус ранее отправленных SMS (sent но не delivered, в пределах max_check_duration)
|
2. Проверяет статус ранее отправленных SMS (sent но не delivered, в пределах max_check_duration)
|
||||||
3. Обновляет статусы в order_groups + sms_campaign_log
|
3. Обновляет статусы в order_groups + sms_campaign_log
|
||||||
|
|
||||||
Защита от повторной отправки:
|
Защита от повторной отправки (ДВОЙНАЯ):
|
||||||
- Группа с sms_campaign_log status='sent'/'checking' в последние max_check_duration_minutes → skip
|
1. После отправки SMS → notification_status = 'sms_sending' (не 'link_ready')
|
||||||
- Код 231/132 = лимит одинаковых → не повторять
|
→ 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:
|
Коды sms.ru:
|
||||||
100 = в очереди, 101 = оператору, 102 = в пути → ждём
|
100 = в очереди, 101 = оператору, 102 = в пути → ждём
|
||||||
|
|
@ -23,6 +28,8 @@ 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
|
||||||
|
|
@ -31,7 +38,7 @@ from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
# ─── Конфигурация ────────────────────────────────────────────────────────────
|
# ─── Конфигурация ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
DB_HOST = os.environ.get("DB_HOST", "10.0.4.5")
|
DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
|
||||||
DB_PORT = os.environ.get("DB_PORT", "5432")
|
DB_PORT = os.environ.get("DB_PORT", "5432")
|
||||||
DB_NAME = os.environ.get("DB_NAME", "postgres")
|
DB_NAME = os.environ.get("DB_NAME", "postgres")
|
||||||
DB_USER = os.environ.get("DB_USER", "supabase_admin")
|
DB_USER = os.environ.get("DB_USER", "supabase_admin")
|
||||||
|
|
@ -46,6 +53,10 @@ SMS_STATUS_URL = "https://sms.ru/sms/status"
|
||||||
|
|
||||||
LOG_FILE = "/var/log/supersam-sms-first.log"
|
LOG_FILE = "/var/log/supersam-sms-first.log"
|
||||||
|
|
||||||
|
# ТЕСТОВЫЙ РЕЖИМ — управляется из админки (sms_campaign_settings.test_mode)
|
||||||
|
# По умолчанию ВКЛЮЧЕН — SMS идут только на test_phone
|
||||||
|
# Мегаадмин выключает через админку → SMS идут реальным клиентам
|
||||||
|
|
||||||
# Коды, которые означают "в процессе" (ждём дальше)
|
# Коды, которые означают "в процессе" (ждём дальше)
|
||||||
IN_TRANSIT_CODES = {"100", "101", "102"}
|
IN_TRANSIT_CODES = {"100", "101", "102"}
|
||||||
# Код доставки
|
# Код доставки
|
||||||
|
|
@ -87,14 +98,18 @@ def load_settings(conn):
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"telegram_chat_id": "25164483",
|
"telegram_chat_id": "25164483",
|
||||||
"sms_api_id": SMS_API_ID,
|
"sms_api_id": SMS_API_ID,
|
||||||
|
"test_mode": True,
|
||||||
|
"test_phone": "79788382260",
|
||||||
}
|
}
|
||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def normalize_phone(phone):
|
def normalize_phone(phone):
|
||||||
"""Нормализует телефон: только цифры, начинается с 7"""
|
"""Нормализует телефон: только цифры, начинается с 7. Возвращает None если пустой."""
|
||||||
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:
|
||||||
|
return None
|
||||||
if clean.startswith("8"):
|
if clean.startswith("8"):
|
||||||
clean = "7" + clean[1:]
|
clean = "7" + clean[1:]
|
||||||
elif not clean.startswith("7"):
|
elif not clean.startswith("7"):
|
||||||
|
|
@ -125,7 +140,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):
|
||||||
"""Проверяет статус, возвращает (code, raw_response)"""
|
"""Проверяет статус, возвращает (sms_status_code, raw_response, api_code)"""
|
||||||
try:
|
try:
|
||||||
resp = requests.post(SMS_STATUS_URL, params={
|
resp = requests.post(SMS_STATUS_URL, params={
|
||||||
"api_id": api_id,
|
"api_id": api_id,
|
||||||
|
|
@ -142,22 +157,23 @@ 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):
|
||||||
if not TELEGRAM_BOT_TOKEN:
|
pass # Telegram notifications moved to n8n+Supabase integration
|
||||||
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 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -166,7 +182,8 @@ 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'
|
||||||
|
|
@ -186,29 +203,43 @@ def get_groups_to_send(conn):
|
||||||
return [dict(r) for r in cur.fetchall()]
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
def get_sms_to_check(conn, max_duration_min):
|
def get_sms_to_check(conn, max_duration_min):
|
||||||
"""SMS в логе со status='sent'/'checking', которые ещё не доставлены"""
|
"""SMS в логе со status='sent'/'checking', которые ещё не доставлены.
|
||||||
|
Включает:
|
||||||
|
- Записи младше max_duration_min (обычная автопроверка)
|
||||||
|
- Записи с needs_check=true (admin нажал 'Проверить снова') — независимо от возраста
|
||||||
|
"""
|
||||||
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.sms_code,
|
scl.attempts, scl.created_at, scl.sms_code, scl.needs_check,
|
||||||
og.customer_name, og.group_key
|
og.customer_name, og.group_key
|
||||||
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'
|
||||||
AND scl.status IN ('sent', 'checking')
|
AND scl.status IN ('sent', 'checking')
|
||||||
AND scl.sms_id IS NOT NULL
|
AND scl.sms_id IS NOT NULL
|
||||||
AND scl.created_at > NOW() - INTERVAL '%s minutes'
|
AND (
|
||||||
ORDER BY scl.created_at ASC
|
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)
|
""" % 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'
|
||||||
|
|
@ -233,8 +264,11 @@ def update_sms_log(conn, log_id, **kwargs):
|
||||||
set_parts = []
|
set_parts = []
|
||||||
values = []
|
values = []
|
||||||
for k, v in kwargs.items():
|
for k, v in kwargs.items():
|
||||||
set_parts.append(f"{k} = %s")
|
if v == "NOW()":
|
||||||
values.append(v)
|
set_parts.append(f"{k} = NOW()")
|
||||||
|
else:
|
||||||
|
set_parts.append(f"{k} = %s")
|
||||||
|
values.append(v)
|
||||||
values.append(log_id)
|
values.append(log_id)
|
||||||
cur.execute(f"UPDATE sms_campaign_log SET {', '.join(set_parts)}, updated_at = NOW() WHERE id = %s", values)
|
cur.execute(f"UPDATE sms_campaign_log SET {', '.join(set_parts)}, updated_at = NOW() WHERE id = %s", values)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
@ -253,16 +287,46 @@ 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 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):
|
def step_send_new(conn, settings, test_send=False):
|
||||||
"""Шаг 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)
|
||||||
|
|
||||||
groups = get_groups_to_send(conn)
|
if test_send:
|
||||||
|
# Test mode: send ONE SMS to test phone, pick first available group
|
||||||
|
all_groups = get_groups_to_send(conn)
|
||||||
|
if not all_groups:
|
||||||
|
log.info("Test send: no groups available in queue")
|
||||||
|
return 0
|
||||||
|
groups = [all_groups[0]] # Only first group
|
||||||
|
log.info(f"TEST SEND: sending 1 SMS to test phone (skipping {len(all_groups)-1} others)")
|
||||||
|
else:
|
||||||
|
groups = get_groups_to_send(conn)
|
||||||
log.info(f"Step 1: {len(groups)} groups to send SMS")
|
log.info(f"Step 1: {len(groups)} groups to send SMS")
|
||||||
|
|
||||||
sent_count = 0
|
sent_count = 0
|
||||||
|
|
@ -285,24 +349,43 @@ def step_send_new(conn, settings):
|
||||||
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 = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}"
|
sms_text_template = settings.get("sms_text_template", "Ваш заказ готов. Согласуйте дату доставки по ссылке: {link}")
|
||||||
|
sms_text = sms_text_template.replace("{link}", delivery_link + "?src=sms")
|
||||||
|
|
||||||
log.info(f"Sending SMS to {name} ({phone})")
|
# ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый
|
||||||
sms_id, raw, code = send_sms(phone, sms_text, api_id)
|
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:
|
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=1,
|
attempts=current_attempts,
|
||||||
|
was_test_mode=bool(settings.get("test_mode", True)),
|
||||||
)
|
)
|
||||||
log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}, log_id={log_id}")
|
# ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status,
|
||||||
|
# чтобы get_groups_to_send не нашёл эту группу при следующем запуске
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "sms_sending",
|
||||||
|
"sms_sent_at": "NOW()",
|
||||||
|
"sms_attempts": current_attempts,
|
||||||
|
})
|
||||||
|
log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}, log_id={log_id}, notification_status→sms_sending")
|
||||||
sent_count += 1
|
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"
|
||||||
|
|
@ -311,10 +394,12 @@ def step_send_new(conn, settings):
|
||||||
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]}")
|
||||||
|
|
||||||
|
|
@ -345,9 +430,13 @@ def step_check_status(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)
|
||||||
|
was_needs_check = item.get("needs_check", False)
|
||||||
|
|
||||||
code, raw, api_code = check_sms_status(sms_id, api_id)
|
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:
|
if code == DELIVERED_CODE:
|
||||||
# Доставлено!
|
# Доставлено!
|
||||||
log.info(f"Group {group_id}: SMS delivered (103)!")
|
log.info(f"Group {group_id}: SMS delivered (103)!")
|
||||||
|
|
@ -378,11 +467,12 @@ 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}: will retry send on next run (attempt {attempts+1}/{max_attempts})")
|
log.info(f"Group {group_id}: retry first SMS (attempt {attempts+1}/{max_attempts})")
|
||||||
# Сбрасываем, чтобы step_send_new подхватил (но только если нет другой недавней)
|
|
||||||
# Удаляем лог, чтобы группа снова попала в get_groups_to_send
|
|
||||||
# Нет — лучше помечаем как expired, а step_send_new проверяет отсутствие активных
|
|
||||||
update_sms_log(conn, log_id, status="expired")
|
update_sms_log(conn, log_id, status="expired")
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "link_ready",
|
||||||
|
"sms_attempts": attempts,
|
||||||
|
})
|
||||||
else:
|
else:
|
||||||
update_order_group(conn, group_id, {
|
update_order_group(conn, group_id, {
|
||||||
"notification_status": "send_failed",
|
"notification_status": "send_failed",
|
||||||
|
|
@ -425,19 +515,29 @@ 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)
|
||||||
|
|
||||||
log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}")
|
# Use total_failed across ALL attempts, not just this one
|
||||||
|
effective_attempts = max(attempts, total_failed)
|
||||||
|
|
||||||
|
log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}, total_failed={total_failed}")
|
||||||
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 attempts < max_attempts:
|
if effective_attempts < max_attempts:
|
||||||
# Будет повторная отправка на следующем запуске (step_send_new подхватит)
|
# Повторная отправка ТЕКУЩЕЙ SMS (не откат назад)
|
||||||
log.info(f"Group {group_id}: will retry SMS send (attempt {attempts+1})")
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "link_ready",
|
||||||
|
"sms_attempts": effective_attempts,
|
||||||
|
})
|
||||||
|
log.info(f"Group {group_id}: retry first SMS (attempt {effective_attempts+1}/{max_attempts}), notification_status→link_ready")
|
||||||
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"
|
||||||
|
|
@ -448,12 +548,47 @@ 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)
|
||||||
|
|
||||||
|
# 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')}")
|
||||||
|
|
@ -463,7 +598,15 @@ 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)
|
||||||
|
|
||||||
|
|
@ -475,10 +618,25 @@ 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,272 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SuperSam — Manual Campaign (State Machine)
|
||||||
|
Заменяет n8n workflow "Ручное управление"
|
||||||
|
|
||||||
|
Логика:
|
||||||
|
Если после второй SMS (notification_status = 'second_sms_sent')
|
||||||
|
прошло auto_manual_after_hours и клиент не согласовал
|
||||||
|
(delivery_status = 'pending_confirmation'):
|
||||||
|
→ notification_status = 'manual_required'
|
||||||
|
→ delivery_status = 'manual_confirmation_required'
|
||||||
|
→ next_notification_check_at = +3 мин
|
||||||
|
→ Telegram-уведомление
|
||||||
|
|
||||||
|
Расписание: 9-21, Пн-Сб (настраивается из админки)
|
||||||
|
Скрипт НЕ отправляет SMS — только перевод в ручное управление.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import fcntl
|
||||||
|
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")
|
||||||
|
|
||||||
|
LOG_FILE = "/var/log/supersam-sms-manual.log"
|
||||||
|
|
||||||
|
CAMPAIGN_TYPE = "manual"
|
||||||
|
|
||||||
|
# ─── Логирование ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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_manual")
|
||||||
|
|
||||||
|
# ─── БД ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 = %s", (CAMPAIGN_TYPE,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return {"enabled": True, "auto_manual_after_hours": 3, "telegram_chat_id": TELEGRAM_CHAT_ID}
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
# ─── Telegram ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def send_telegram(message, chat_id):
|
||||||
|
pass # Telegram notifications moved to n8n+Supabase integration
|
||||||
|
|
||||||
|
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def is_within_work_hours(settings):
|
||||||
|
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,6")
|
||||||
|
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", 9)
|
||||||
|
end_h = settings.get("work_hours_end", 21)
|
||||||
|
return start_h <= hour < end_h
|
||||||
|
|
||||||
|
# ─── State Machine ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_groups_to_manual(conn):
|
||||||
|
"""Группы, где срок ожидания истёк и нужно перевести в ручное управление.
|
||||||
|
|
||||||
|
Сценарии:
|
||||||
|
1. second_sms_sent + next_check в прошлом → клиент не согласовал после 2х SMS
|
||||||
|
2. first_sms_sent + next_check в прошлом + >3h + second_sms_sent_at IS NOT NULL → клиент не согласовал после 2й SMS
|
||||||
|
3. link_ready + next_check в прошлом + нет активных SMS → зависшая группа
|
||||||
|
4. sms_sending/second_sms_sending/checking + >3h без доставки → зависшая группа
|
||||||
|
"""
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
||||||
|
og.notification_status, og.delivery_link,
|
||||||
|
og.first_sms_sent_at, og.second_sms_sent_at,
|
||||||
|
og.sms_sent_at, og.next_notification_check_at,
|
||||||
|
og.sms_attempts
|
||||||
|
FROM order_groups og
|
||||||
|
WHERE og.delivery_status = 'pending_confirmation'
|
||||||
|
AND (
|
||||||
|
-- 1. second_sms_sent + срок истёк
|
||||||
|
(COALESCE(og.notification_status, '') = 'second_sms_sent'
|
||||||
|
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW()))
|
||||||
|
-- 2. first_sms_sent + срок истёк (>3h после отправки)
|
||||||
|
-- ВАЖНО: вторая SMS должна быть уже отправлена (second_sms_sent_at IS NOT NULL)
|
||||||
|
-- Иначе second_campaign не успеет отправить вторую SMS
|
||||||
|
OR (COALESCE(og.notification_status, '') = 'first_sms_sent'
|
||||||
|
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
|
||||||
|
AND og.first_sms_sent_at < NOW() - INTERVAL '3 hours'
|
||||||
|
AND og.second_sms_sent_at IS NOT NULL)
|
||||||
|
-- 3. link_ready + next_check в прошлом + нет активных SMS в логе
|
||||||
|
OR (COALESCE(og.notification_status, '') = 'link_ready'
|
||||||
|
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sms_campaign_log scl
|
||||||
|
WHERE scl.order_group_id = og.id
|
||||||
|
AND scl.status IN ('sent', 'checking')
|
||||||
|
AND scl.created_at > NOW() - INTERVAL '2 hours'
|
||||||
|
))
|
||||||
|
-- 4. sms_sending/checking + >3h без доставки (зависло)
|
||||||
|
OR (COALESCE(og.notification_status, '') IN ('sms_sending', 'second_sms_sending', 'checking')
|
||||||
|
AND COALESCE(og.sms_sent_at, og.created_at) < NOW() - INTERVAL '3 hours')
|
||||||
|
)
|
||||||
|
ORDER BY og.created_at ASC
|
||||||
|
""")
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def update_order_group(conn, group_id, fields):
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
set_parts = []
|
||||||
|
values = []
|
||||||
|
for k, v in fields.items():
|
||||||
|
if isinstance(v, str) and v.startswith("NOW()"):
|
||||||
|
set_parts.append(f"{k} = {v}")
|
||||||
|
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 step_move_to_manual(conn, settings):
|
||||||
|
"""Перевод групп в ручное управление"""
|
||||||
|
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||||
|
delay_minutes = settings.get("auto_manual_after_hours", 3)
|
||||||
|
|
||||||
|
groups = get_groups_to_manual(conn)
|
||||||
|
log.info(f"Step 1: {len(groups)} groups to move to manual")
|
||||||
|
|
||||||
|
moved = 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", "")
|
||||||
|
delivery_link = group.get("delivery_link", "")
|
||||||
|
|
||||||
|
log.info(f"Group {group_id}: moving to manual_required ({name})")
|
||||||
|
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "manual_required",
|
||||||
|
"delivery_status": "manual_confirmation_required",
|
||||||
|
"sms_attempts": 2,
|
||||||
|
"last_sms_error": None,
|
||||||
|
"next_notification_check_at": f"NOW() + INTERVAL '{int(delay_minutes)} minutes'",
|
||||||
|
"status": "manual_required",
|
||||||
|
})
|
||||||
|
|
||||||
|
send_telegram(
|
||||||
|
f"🔧 <b>Ручное управление</b>\n{name} ({phone})\n"
|
||||||
|
f"Клиент не согласовал доставку после двух SMS\n"
|
||||||
|
f"Ссылка: {delivery_link}",
|
||||||
|
tg_chat,
|
||||||
|
)
|
||||||
|
moved += 1
|
||||||
|
|
||||||
|
return moved
|
||||||
|
|
||||||
|
# ─── 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("Manual Campaign — START")
|
||||||
|
conn = get_db_conn()
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings = load_settings(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")
|
||||||
|
log.info(f"Settings: work_hours={settings.get('work_hours_start')}-{settings.get('work_hours_end')}, "
|
||||||
|
f"work_days={settings.get('work_days')}")
|
||||||
|
|
||||||
|
if not settings.get("enabled", True):
|
||||||
|
log.info("Campaign disabled, exiting")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Только в рабочие часы
|
||||||
|
if not is_within_work_hours(settings):
|
||||||
|
log.info("Outside work hours, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
if test_send_requested:
|
||||||
|
log.info("Test send requested for manual campaign — no SMS to send, marking as done")
|
||||||
|
|
||||||
|
# run_requested for manual = just run the step
|
||||||
|
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 = 'manual'")
|
||||||
|
conn.commit()
|
||||||
|
log.info("RESTART: manual campaign — will process all eligible groups now")
|
||||||
|
else:
|
||||||
|
moved = step_move_to_manual(conn, settings)
|
||||||
|
|
||||||
|
log.info(f"Run summary: moved_to_manual={moved}")
|
||||||
|
|
||||||
|
if moved > 0:
|
||||||
|
send_telegram(
|
||||||
|
f"📊 <b>Ручное управление</b>\nПереведено в ручное: {moved}",
|
||||||
|
settings.get("telegram_chat_id", TELEGRAM_CHAT_ID),
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Fatal error: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
||||||
|
lock_file.close()
|
||||||
|
|
||||||
|
log.info("Manual Campaign — END")
|
||||||
|
log.info("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,546 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SuperSam — Paid Storage Campaign (State Machine)
|
||||||
|
Уведомление о включении платного хранения.
|
||||||
|
|
||||||
|
Логика:
|
||||||
|
Когда delivery_status = 'paid_storage' (логист/админ включил):
|
||||||
|
→ Если рабочие часы (8-21, Пн-Пт): отправить SMS один раз
|
||||||
|
→ Если ночью: ждать до утра
|
||||||
|
→ Проверить доставку (103)
|
||||||
|
→ Пометить notification_status = 'paid_storage_sent'
|
||||||
|
|
||||||
|
SMS текст: "Ваш заказ переведён на платное хранение. Стоимость: 300 ₽/день.
|
||||||
|
Заберите заказ или согласуйте доставку: {link}"
|
||||||
|
|
||||||
|
Защита от повторной отправки:
|
||||||
|
1. После отправки → notification_status = 'paid_storage_sending'
|
||||||
|
2. EXISTS проверка в sms_campaign_log за 24h
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import fcntl
|
||||||
|
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-paid-storage.log"
|
||||||
|
|
||||||
|
CAMPAIGN_TYPE = "paid_storage"
|
||||||
|
|
||||||
|
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_paid_storage")
|
||||||
|
|
||||||
|
# ─── БД ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 = %s", (CAMPAIGN_TYPE,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return {
|
||||||
|
"enabled": True, "test_mode": True, "test_phone": "79788382260",
|
||||||
|
"max_check_duration_minutes": 90, "max_attempts": 2,
|
||||||
|
"telegram_chat_id": TELEGRAM_CHAT_ID, "sms_api_id": SMS_API_ID,
|
||||||
|
"work_hours_start": 8, "work_hours_end": 21, "work_days": "1,2,3,4,5",
|
||||||
|
}
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def normalize_phone(phone):
|
||||||
|
clean = "".join(c for c in str(phone) if c.isdigit())
|
||||||
|
if len(clean) < 10:
|
||||||
|
return None
|
||||||
|
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):
|
||||||
|
try:
|
||||||
|
clean_phone = normalize_phone(phone)
|
||||||
|
if not clean_phone:
|
||||||
|
log.error(f"Invalid phone: {phone}")
|
||||||
|
return None, "Invalid phone", "error"
|
||||||
|
resp = requests.post(SMS_SEND_URL, params={"api_id": api_id, "to": clean_phone},
|
||||||
|
data={"msg": message}, timeout=30)
|
||||||
|
text = resp.text
|
||||||
|
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:
|
||||||
|
return lines[1].strip(), text, "100"
|
||||||
|
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):
|
||||||
|
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):
|
||||||
|
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}")
|
||||||
|
|
||||||
|
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def is_within_work_hours(settings):
|
||||||
|
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
|
||||||
|
|
||||||
|
# ─── State Machine ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_groups_to_send(conn):
|
||||||
|
"""Группы с paid_storage, которым ещё не отправили уведомление."""
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
||||||
|
og.customer_phone_normalized, og.delivery_link, og.notification_status,
|
||||||
|
og.paid_storage_at
|
||||||
|
FROM order_groups og
|
||||||
|
WHERE og.delivery_status = 'paid_storage'
|
||||||
|
AND og.paid_storage_at IS NOT NULL
|
||||||
|
AND COALESCE(og.notification_status, '') NOT IN ('paid_storage_sending', 'paid_storage_sent')
|
||||||
|
AND og.delivery_link IS NOT NULL
|
||||||
|
AND og.delivery_link != ''
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sms_campaign_log scl
|
||||||
|
WHERE scl.order_group_id = og.id
|
||||||
|
AND scl.campaign_type = 'paid_storage'
|
||||||
|
AND scl.status IN ('sent', 'checking')
|
||||||
|
AND scl.created_at > NOW() - INTERVAL '24 hours'
|
||||||
|
)
|
||||||
|
ORDER BY og.paid_storage_at ASC
|
||||||
|
""")
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def get_sms_to_check(conn, max_duration_min):
|
||||||
|
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 = 'paid_storage'
|
||||||
|
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):
|
||||||
|
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 = 'paid_storage'
|
||||||
|
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 step_send_new(conn, settings, test_send=False):
|
||||||
|
api_id = settings.get("sms_api_id", SMS_API_ID)
|
||||||
|
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||||
|
|
||||||
|
if test_send:
|
||||||
|
# Test mode: send ONE SMS to test phone, pick first available group
|
||||||
|
all_groups = get_groups_to_send(conn)
|
||||||
|
if not all_groups:
|
||||||
|
log.info("Test send: no groups available in queue")
|
||||||
|
return 0
|
||||||
|
groups = [all_groups[0]] # Only first group
|
||||||
|
log.info(f"TEST SEND: sending 1 SMS to test phone (skipping {len(all_groups)-1} others)")
|
||||||
|
else:
|
||||||
|
groups = get_groups_to_send(conn)
|
||||||
|
log.info(f"Step 1: {len(groups)} groups to send paid_storage SMS")
|
||||||
|
|
||||||
|
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", "")
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COUNT(*) FROM sms_campaign_log
|
||||||
|
WHERE order_group_id = %s AND campaign_type = 'paid_storage'
|
||||||
|
AND created_at > NOW() - INTERVAL '24 hours'
|
||||||
|
AND status IN ('sent', 'checking', 'delivered')
|
||||||
|
""", (group_id,))
|
||||||
|
if cur.fetchone()[0] > 0:
|
||||||
|
log.info(f"Group {group_id}: already has recent paid_storage SMS, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
sms_text_template = settings.get("sms_text_template", "Ваш заказ переведён на платное хранение. Стоимость: 300 ₽/день. Заберите заказ или согласуйте доставку: {link}")
|
||||||
|
sms_text = sms_text_template.replace("{link}", delivery_link)
|
||||||
|
|
||||||
|
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 paid_storage 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=CAMPAIGN_TYPE,
|
||||||
|
order_group_id=group_id,
|
||||||
|
customer_phone=phone,
|
||||||
|
sms_id=sms_id,
|
||||||
|
sms_text=sms_text,
|
||||||
|
sent_to=send_phone,
|
||||||
|
status="sent",
|
||||||
|
sms_code=code,
|
||||||
|
attempts=1,
|
||||||
|
was_test_mode=bool(settings.get("test_mode", True)),
|
||||||
|
)
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "paid_storage_sending",
|
||||||
|
"sms_sent_at": "NOW()",
|
||||||
|
})
|
||||||
|
log.info(f"Group {group_id}: paid_storage SMS sent, sms_id={sms_id}, notification_status→paid_storage_sending")
|
||||||
|
sent_count += 1
|
||||||
|
time.sleep(settings.get("send_interval_seconds", 15)) # Configurable rate limit
|
||||||
|
else:
|
||||||
|
error = raw[:500] if raw else "Unknown error"
|
||||||
|
insert_sms_log(conn,
|
||||||
|
campaign_type=CAMPAIGN_TYPE,
|
||||||
|
order_group_id=group_id,
|
||||||
|
customer_phone=phone,
|
||||||
|
sms_text=sms_text,
|
||||||
|
sent_to=send_phone if "send_phone" in dir() else phone,
|
||||||
|
status="send_failed",
|
||||||
|
sms_code=code,
|
||||||
|
attempts=1,
|
||||||
|
error_message=error,
|
||||||
|
was_test_mode=bool(settings.get("test_mode", True)),
|
||||||
|
)
|
||||||
|
log.error(f"Group {group_id}: paid_storage SMS failed (code={code}): {error[:200]}")
|
||||||
|
update_order_group(conn, group_id, {"last_sms_error": error[:200]})
|
||||||
|
send_telegram(f"❌ SMS платное хранение не отправлена: {name} ({phone})\nКод: {code}", tg_chat)
|
||||||
|
|
||||||
|
return sent_count
|
||||||
|
|
||||||
|
def step_check_status(conn, settings):
|
||||||
|
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)
|
||||||
|
|
||||||
|
sms_list = get_sms_to_check(conn, max_duration)
|
||||||
|
log.info(f"Step 2: {len(sms_list)} paid_storage SMS to check")
|
||||||
|
|
||||||
|
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", "")
|
||||||
|
|
||||||
|
code, raw, api_code = check_sms_status(sms_id, api_id)
|
||||||
|
update_sms_log(conn, log_id, needs_check=False, checked_at="NOW()")
|
||||||
|
|
||||||
|
if code == DELIVERED_CODE:
|
||||||
|
log.info(f"Group {group_id}: paid_storage SMS delivered (103)!")
|
||||||
|
update_sms_log(conn, log_id, status="delivered", sms_code=code)
|
||||||
|
update_order_group(conn, group_id, {"notification_status": "paid_storage_sent"})
|
||||||
|
send_telegram(f"✅ SMS платное хранение доставлена: {name} ({phone})", tg_chat)
|
||||||
|
delivered += 1
|
||||||
|
elif code in IN_TRANSIT_CODES:
|
||||||
|
log.info(f"Group {group_id}: in transit (code={code})")
|
||||||
|
update_sms_log(conn, log_id, status="checking", sms_code=code)
|
||||||
|
elif code in DELIVERY_ERROR_CODES:
|
||||||
|
log.error(f"Group {group_id}: delivery error (code={code})")
|
||||||
|
update_sms_log(conn, log_id, status="error", sms_code=code, error_message=f"Delivery error: {code}")
|
||||||
|
attempts = item.get("attempts", 1)
|
||||||
|
max_attempts = settings.get("max_attempts", 2)
|
||||||
|
if attempts < max_attempts:
|
||||||
|
update_sms_log(conn, log_id, status="expired")
|
||||||
|
# Сброс для retry — вернёмся к исходному статусу
|
||||||
|
update_order_group(conn, group_id, {"notification_status": "not_started"})
|
||||||
|
log.info(f"Group {group_id}: will retry paid_storage SMS (attempt {attempts+1}/{max_attempts})")
|
||||||
|
else:
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "manual_required",
|
||||||
|
"last_sms_error": f"Paid storage SMS failed after {max_attempts} attempts (code={code})",
|
||||||
|
})
|
||||||
|
send_telegram(f"⚠️ SMS платное хранение не доставлена после {max_attempts} попыток: {name} ({phone})", tg_chat)
|
||||||
|
elif code in LIMIT_ERROR_CODES:
|
||||||
|
log.error(f"Group {group_id}: limit exceeded (code={code})")
|
||||||
|
update_sms_log(conn, log_id, status="limit_exceeded", sms_code=code, error_message=f"Limit: {code}")
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "manual_required",
|
||||||
|
"last_sms_error": f"Limit exceeded (code={code})",
|
||||||
|
})
|
||||||
|
send_telegram(f"🚫 SMS платное хранение заблокирована (лимит): {name} ({phone})", tg_chat)
|
||||||
|
else:
|
||||||
|
log.warning(f"Group {group_id}: unknown code: {code}")
|
||||||
|
update_sms_log(conn, log_id, status="checking", sms_code=code, error_message=f"Unknown: {code}")
|
||||||
|
|
||||||
|
return delivered
|
||||||
|
|
||||||
|
def step_handle_expired(conn, settings):
|
||||||
|
max_duration = settings.get("max_check_duration_minutes", 90)
|
||||||
|
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||||
|
|
||||||
|
expired = get_sms_expired(conn, max_duration)
|
||||||
|
log.info(f"Step 3: {len(expired)} paid_storage SMS expired")
|
||||||
|
|
||||||
|
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", "")
|
||||||
|
|
||||||
|
log.warning(f"Group {group_id}: paid_storage SMS expired")
|
||||||
|
update_sms_log(conn, log_id, status="expired", error_message=f"Not delivered in {max_duration} min")
|
||||||
|
attempts = item.get("attempts", 1)
|
||||||
|
max_attempts = settings.get("max_attempts", 2)
|
||||||
|
if attempts < max_attempts:
|
||||||
|
# Retry: сброс на not_started для повторной отправки
|
||||||
|
update_order_group(conn, group_id, {"notification_status": "not_started"})
|
||||||
|
log.info(f"Group {group_id}: will retry paid_storage SMS (attempt {attempts+1}/{max_attempts})")
|
||||||
|
else:
|
||||||
|
# Все попытки исчерпаны — ручное управление, НЕ paid_storage_sent
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "manual_required",
|
||||||
|
"last_sms_error": f"Paid storage SMS not delivered after {max_attempts} attempts",
|
||||||
|
})
|
||||||
|
send_telegram(f"⚠️ SMS платное хранение не доставлена после {max_attempts} попыток: {name} ({phone})", tg_chat)
|
||||||
|
|
||||||
|
# ─── Main ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# File lock — prevent parallel execution
|
||||||
|
lock_file = open("/tmp/" + __file__.split("/")[-1].replace(".py", ".lock"), "w")
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except (IOError, OSError):
|
||||||
|
log.info("Another instance is running, exiting")
|
||||||
|
lock_file.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
log.info("=" * 60)
|
||||||
|
log.info("Paid Storage Campaign — START")
|
||||||
|
conn = get_db_conn()
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings = load_settings(conn)
|
||||||
|
|
||||||
|
# Update last_run_at timestamp
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("UPDATE sms_campaign_settings SET last_run_at = NOW() WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Check test_send_requested — one-time test send
|
||||||
|
test_send_requested = bool(settings.get("test_send_requested", False))
|
||||||
|
if test_send_requested:
|
||||||
|
# Reset flag immediately
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("UPDATE sms_campaign_settings SET test_send_requested = false WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
||||||
|
conn.commit()
|
||||||
|
log.info("Test send requested — will send ONE SMS to test phone only")
|
||||||
|
|
||||||
|
# Check run_requested — restart scenario
|
||||||
|
run_requested = bool(settings.get('run_requested', False))
|
||||||
|
if run_requested:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("UPDATE sms_campaign_settings SET run_requested = false WHERE campaign_type = 'paid_storage'")
|
||||||
|
conn.commit()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""UPDATE order_groups SET notification_status = 'not_started', sms_sent_at = NULL, next_notification_check_at = NULL, sms_attempts = 0, last_sms_error = NULL WHERE delivery_status = 'paid_storage' AND notification_status IN ('paid_storage_sent','paid_storage_sending','manual_required','send_failed')""")
|
||||||
|
reset_count = cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
log.info(f'RESTART paid_storage: reset {reset_count} groups')
|
||||||
|
log.info(f"Settings: work={settings.get('work_hours_start')}-{settings.get('work_hours_end')}, "
|
||||||
|
f"days={settings.get('work_days')}, test={settings.get('test_mode')}")
|
||||||
|
|
||||||
|
if not settings.get("enabled", True):
|
||||||
|
log.info("Campaign disabled, exiting")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Отправка только в рабочие часы
|
||||||
|
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)
|
||||||
|
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),
|
||||||
|
was_test_mode=bool(settings.get("test_mode", True)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Обновляем баланс
|
||||||
|
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 = 'paid_storage'",
|
||||||
|
(balance,)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
log.info(f"Balance updated: {balance} ₽")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Fatal error: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
||||||
|
lock_file.close()
|
||||||
|
|
||||||
|
log.info("Paid Storage Campaign — END")
|
||||||
|
log.info("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,654 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SuperSam — SMS Second Campaign (State Machine)
|
||||||
|
Заменяет n8n workflow "Первая проверка согласования + отправка 2й смс"
|
||||||
|
|
||||||
|
Архитектура: state machine через cron (каждые 5 мин)
|
||||||
|
Каждый запуск:
|
||||||
|
1. Отправляет вторую SMS группам, где первая SMS доставлена,
|
||||||
|
но клиент не согласовал доставку, и пришло время next_notification_check_at
|
||||||
|
2. Проверяет статус ранее отправленных вторых SMS
|
||||||
|
3. Обновляет статусы в order_groups + sms_campaign_log
|
||||||
|
|
||||||
|
Защита от повторной отправки (ДВОЙНАЯ):
|
||||||
|
1. После отправки SMS → notification_status = 'second_sms_sending' (не 'first_sms_sent')
|
||||||
|
→ get_groups_to_send НЕ находит эту группу
|
||||||
|
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
|
||||||
|
import time
|
||||||
|
import fcntl
|
||||||
|
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-second.log"
|
||||||
|
|
||||||
|
CAMPAIGN_TYPE = "second_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_second")
|
||||||
|
|
||||||
|
# ─── БД ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 = %s", (CAMPAIGN_TYPE,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
# Fallback на настройки first_sms если second_sms нет
|
||||||
|
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",
|
||||||
|
"second_sms_delay_hours": 3,
|
||||||
|
"auto_manual_after_hours": 3,
|
||||||
|
}
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def normalize_phone(phone):
|
||||||
|
"""Нормализует телефон: только цифры, начинается с 7. Возвращает None если пустой."""
|
||||||
|
clean = "".join(c for c in str(phone) if c.isdigit())
|
||||||
|
if len(clean) < 10:
|
||||||
|
return None
|
||||||
|
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, code)"""
|
||||||
|
try:
|
||||||
|
clean_phone = normalize_phone(phone)
|
||||||
|
if not clean_phone:
|
||||||
|
log.error(f"Invalid phone: {phone}")
|
||||||
|
return None, "Invalid phone", "error"
|
||||||
|
resp = requests.post(SMS_SEND_URL, params={
|
||||||
|
"api_id": api_id,
|
||||||
|
"to": clean_phone,
|
||||||
|
}, 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):
|
||||||
|
pass # Telegram notifications moved to n8n+Supabase integration
|
||||||
|
|
||||||
|
# ─── State Machine ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_groups_to_send(conn):
|
||||||
|
"""Группы, где первая SMS доставлена, но клиент не согласовал,
|
||||||
|
и пришло время для второй SMS (next_notification_check_at <= NOW()).
|
||||||
|
second_sms_sent_at IS NULL — вторая 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,
|
||||||
|
og.sms_attempts
|
||||||
|
FROM order_groups og
|
||||||
|
WHERE og.delivery_status = 'pending_confirmation'
|
||||||
|
AND og.delivery_link IS NOT NULL
|
||||||
|
AND og.delivery_link != ''
|
||||||
|
AND COALESCE(og.notification_status, '') = 'first_sms_sent'
|
||||||
|
AND og.second_sms_sent_at IS NULL
|
||||||
|
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
|
||||||
|
-- Нет активной второй SMS в логе
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sms_campaign_log scl
|
||||||
|
WHERE scl.order_group_id = og.id
|
||||||
|
AND scl.campaign_type = 'second_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 = 'second_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, не доставлены).
|
||||||
|
Также считаем общее количество failed попыток для группы.
|
||||||
|
"""
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
|
||||||
|
scl.attempts, scl.created_at,
|
||||||
|
og.customer_name, og.group_key, og.delivery_link,
|
||||||
|
og.sms_attempts as group_sms_attempts,
|
||||||
|
(SELECT COUNT(*) FROM sms_campaign_log scl2
|
||||||
|
WHERE scl2.order_group_id = scl.order_group_id
|
||||||
|
AND scl2.campaign_type = 'second_sms'
|
||||||
|
AND scl2.status IN ('expired', 'error', 'limit_exceeded')) as total_failed
|
||||||
|
FROM sms_campaign_log scl
|
||||||
|
JOIN order_groups og ON og.id = scl.order_group_id
|
||||||
|
WHERE scl.campaign_type = 'second_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)))
|
||||||
|
# work_days: '1,2,3,4,5' → Понедельник=1 ... Воскресенье=7
|
||||||
|
# Python weekday(): 0=Пн ... 6=Вс → конвертируем в 1-7
|
||||||
|
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):
|
||||||
|
"""Шаг 1: Отправка второй SMS группам, где первая доставлена, но нет согласования"""
|
||||||
|
api_id = settings.get("sms_api_id", SMS_API_ID)
|
||||||
|
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||||
|
|
||||||
|
if test_send:
|
||||||
|
# Test mode: send ONE SMS to test phone, pick first available group
|
||||||
|
all_groups = get_groups_to_send(conn)
|
||||||
|
if not all_groups:
|
||||||
|
log.info("Test send: no groups available in queue")
|
||||||
|
return 0
|
||||||
|
groups = [all_groups[0]] # Only first group
|
||||||
|
log.info(f"TEST SEND: sending 1 SMS to test phone (skipping {len(all_groups)-1} others)")
|
||||||
|
else:
|
||||||
|
groups = get_groups_to_send(conn)
|
||||||
|
log.info(f"Step 1: {len(groups)} groups to send second SMS")
|
||||||
|
|
||||||
|
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 этой группе за 24h
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COUNT(*) as cnt FROM sms_campaign_log
|
||||||
|
WHERE order_group_id = %s AND campaign_type = 'second_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 second SMS in log, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
sms_text_template = settings.get("sms_text_template", "Ваш заказ готов к доставке. Выберите дату доставки по ссылке: {link}")
|
||||||
|
sms_text = sms_text_template.replace("{link}", delivery_link + "?src=sms")
|
||||||
|
|
||||||
|
# ТЕСТОВЫЙ РЕЖИМ
|
||||||
|
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 second SMS to {name} (orig={phone}, send={send_phone})")
|
||||||
|
sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
|
||||||
|
|
||||||
|
if sms_id:
|
||||||
|
current_attempts = (group.get("sms_attempts") or 0) + 1
|
||||||
|
log_id = insert_sms_log(conn,
|
||||||
|
campaign_type=CAMPAIGN_TYPE,
|
||||||
|
order_group_id=group_id,
|
||||||
|
customer_phone=phone,
|
||||||
|
sms_id=sms_id,
|
||||||
|
sms_text=sms_text,
|
||||||
|
sent_to=send_phone,
|
||||||
|
status="sent",
|
||||||
|
sms_code=code,
|
||||||
|
attempts=current_attempts,
|
||||||
|
was_test_mode=bool(settings.get("test_mode", True)),
|
||||||
|
)
|
||||||
|
# ДВОЙНАЯ ЗАЩИТА: notification_status → second_sms_sending
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "second_sms_sending",
|
||||||
|
"sms_sent_at": "NOW()",
|
||||||
|
"sms_attempts": current_attempts,
|
||||||
|
})
|
||||||
|
log.info(f"Group {group_id}: second SMS sent, sms_id={sms_id}, log_id={log_id}, notification_status→second_sms_sending")
|
||||||
|
sent_count += 1
|
||||||
|
time.sleep(settings.get("send_interval_seconds", 15)) # Configurable rate limit
|
||||||
|
else:
|
||||||
|
error = raw[:500] if raw else "Unknown error"
|
||||||
|
log_id = insert_sms_log(conn,
|
||||||
|
campaign_type=CAMPAIGN_TYPE,
|
||||||
|
order_group_id=group_id,
|
||||||
|
customer_phone=phone,
|
||||||
|
sms_text=sms_text,
|
||||||
|
sent_to=send_phone if "send_phone" in dir() else phone,
|
||||||
|
status="send_failed",
|
||||||
|
sms_code=code,
|
||||||
|
attempts=1,
|
||||||
|
error_message=error,
|
||||||
|
was_test_mode=bool(settings.get("test_mode", True)),
|
||||||
|
)
|
||||||
|
log.error(f"Group {group_id}: second SMS send failed (code={code}): {error[:200]}")
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"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)
|
||||||
|
auto_manual_hours = settings.get("auto_manual_after_hours", 3)
|
||||||
|
|
||||||
|
sms_list = get_sms_to_check(conn, max_duration)
|
||||||
|
log.info(f"Step 2: {len(sms_list)} second 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)
|
||||||
|
|
||||||
|
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}: second SMS delivered (103)!")
|
||||||
|
update_sms_log(conn, log_id, status="delivered", sms_code=code)
|
||||||
|
|
||||||
|
next_check = (datetime.now(timezone.utc) + timedelta(hours=auto_manual_hours)).isoformat()
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "second_sms_sent",
|
||||||
|
"second_sms_sent_at": "NOW()",
|
||||||
|
"sms_attempts": attempts,
|
||||||
|
"last_sms_error": None,
|
||||||
|
"sms_sent_at": "NOW()",
|
||||||
|
"next_notification_check_at": next_check,
|
||||||
|
"status": "second_sms_sent",
|
||||||
|
})
|
||||||
|
send_telegram(f"✅ Вторая SMS доставлена: {name} ({phone})", tg_chat)
|
||||||
|
delivered += 1
|
||||||
|
|
||||||
|
elif code in IN_TRANSIT_CODES:
|
||||||
|
# В пути / в очереди — продолжаем ждать
|
||||||
|
log.info(f"Group {group_id}: second 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}: second 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}: retry second SMS (attempt {attempts+1}/{max_attempts})")
|
||||||
|
update_sms_log(conn, log_id, status="expired")
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "first_sms_sent",
|
||||||
|
"sms_attempts": attempts,
|
||||||
|
"second_sms_sent_at": None,
|
||||||
|
"next_notification_check_at": None,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "manual_required",
|
||||||
|
"last_sms_error": f"Second SMS 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}: second 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": "manual_required",
|
||||||
|
"last_sms_error": f"Second SMS limit exceeded (code={code})",
|
||||||
|
})
|
||||||
|
send_telegram(f"🚫 Вторая SMS заблокирована (лимит {code}): {name} ({phone})", tg_chat)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Неизвестный код — логируем, продолжаем проверять
|
||||||
|
log.warning(f"Group {group_id}: unknown SMS code for second SMS: {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)} second 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)
|
||||||
|
total_failed = item.get("total_failed", 0)
|
||||||
|
|
||||||
|
effective_attempts = max(attempts, total_failed)
|
||||||
|
|
||||||
|
log.warning(f"Group {group_id}: second SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}, total_failed={total_failed}")
|
||||||
|
update_sms_log(conn, log_id, status="expired",
|
||||||
|
error_message=f"Not delivered in {max_duration} minutes")
|
||||||
|
|
||||||
|
if effective_attempts < max_attempts:
|
||||||
|
# Повторная отправка 2й SMS (остаёмся в текущем шаге)
|
||||||
|
# second_sms_sent_at = NULL чтобы get_groups_to_send нашёл группу
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "first_sms_sent",
|
||||||
|
"sms_attempts": effective_attempts,
|
||||||
|
"second_sms_sent_at": None,
|
||||||
|
"next_notification_check_at": None,
|
||||||
|
})
|
||||||
|
log.info(f"Group {group_id}: retry second SMS (attempt {effective_attempts+1}/{max_attempts}), stays in second step")
|
||||||
|
else:
|
||||||
|
# Все попытки исчерпаны → ручное управление
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "manual_required",
|
||||||
|
"delivery_status": "manual_confirmation_required",
|
||||||
|
"last_sms_error": f"Second SMS not delivered after {max_attempts} attempts",
|
||||||
|
"status": "manual_required",
|
||||||
|
})
|
||||||
|
send_telegram(
|
||||||
|
f"🔧 Требуется ручное управление (2-я SMS): {name} ({phone})\n"
|
||||||
|
f"Вторая SMS не доставлена после {max_attempts} попыток",
|
||||||
|
tg_chat,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Main ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# File lock — prevent parallel execution
|
||||||
|
lock_file = open("/tmp/" + __file__.split("/")[-1].replace(".py", ".lock"), "w")
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except (IOError, OSError):
|
||||||
|
log.info("Another instance is running, exiting")
|
||||||
|
lock_file.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
log.info("=" * 60)
|
||||||
|
log.info("SMS Second Campaign — START")
|
||||||
|
conn = get_db_conn()
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings = load_settings(conn)
|
||||||
|
|
||||||
|
# Update last_run_at timestamp
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("UPDATE sms_campaign_settings SET last_run_at = NOW() WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Check test_send_requested — one-time test send
|
||||||
|
test_send_requested = bool(settings.get("test_send_requested", False))
|
||||||
|
if test_send_requested:
|
||||||
|
# Reset flag immediately
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("UPDATE sms_campaign_settings SET test_send_requested = false WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
||||||
|
conn.commit()
|
||||||
|
log.info("Test send requested — will send ONE SMS to test phone only")
|
||||||
|
|
||||||
|
# Check run_requested — restart scenario (reset all groups to beginning)
|
||||||
|
run_requested = bool(settings.get('run_requested', False))
|
||||||
|
if run_requested:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("UPDATE sms_campaign_settings SET run_requested = false WHERE campaign_type = 'second_sms'")
|
||||||
|
conn.commit()
|
||||||
|
# Reset groups that passed first_sms back to first_sms_sent — restart second SMS scenario
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""UPDATE order_groups SET notification_status = 'first_sms_sent', sms_sent_at = NULL, second_sms_sent_at = NULL, next_notification_check_at = NULL, sms_attempts = 0, last_sms_error = NULL WHERE delivery_status = 'pending_confirmation' AND notification_status IN ('second_sms_sent','second_sms_sending','manual_required','send_failed')""")
|
||||||
|
reset_count = cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
log.info(f'RESTART second_sms: reset {reset_count} groups to first_sms_sent')
|
||||||
|
log.info(f"Settings: wait={settings.get('wait_between_checks_seconds')}s, "
|
||||||
|
f"max_duration={settings.get('max_check_duration_minutes')}min, "
|
||||||
|
f"max_attempts={settings.get('max_attempts')}")
|
||||||
|
|
||||||
|
if not settings.get("enabled", True):
|
||||||
|
log.info("Campaign disabled, exiting")
|
||||||
|
return
|
||||||
|
|
||||||
|
# State machine — каждый шаг быстрый, без blocking
|
||||||
|
# Отправка только в рабочие часы (8-21, Пн-Пт по Москве)
|
||||||
|
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 (8-21 MSK, Mon-Fri), 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:
|
||||||
|
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 = 'second_sms'",
|
||||||
|
(balance,)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
log.info(f"Balance updated: {balance} ₽")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Fatal error: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
||||||
|
lock_file.close()
|
||||||
|
|
||||||
|
log.info("SMS Second Campaign — END")
|
||||||
|
log.info("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# sms_timer_manager.sh — reconciles DB flags with systemd timers
|
||||||
|
# Runs every 1 min via cron:
|
||||||
|
# 1. timer_active → enable/disable systemd timer
|
||||||
|
# 2. run_requested → run campaign script immediately (script resets flag itself)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DB_HOST="10.0.4.12"
|
||||||
|
DB_PORT="5432"
|
||||||
|
DB_NAME="postgres"
|
||||||
|
DB_USER="supabase_admin"
|
||||||
|
DB_PASS="4fe80bb21c7c3d17a8d8b226adf7a479"
|
||||||
|
|
||||||
|
declare -A TIMERS=(
|
||||||
|
["first_sms"]="sms-first-campaign.timer"
|
||||||
|
["second_sms"]="sms-second-campaign.timer"
|
||||||
|
["manual"]="sms-manual-campaign.timer"
|
||||||
|
["paid_storage"]="sms-paid-storage-campaign.timer"
|
||||||
|
)
|
||||||
|
declare -A SERVICES=(
|
||||||
|
["first_sms"]="sms-first-campaign.service"
|
||||||
|
["second_sms"]="sms-second-campaign.service"
|
||||||
|
["manual"]="sms-manual-campaign.service"
|
||||||
|
["paid_storage"]="sms-paid-storage-campaign.service"
|
||||||
|
)
|
||||||
|
declare -A SCRIPTS=(
|
||||||
|
["first_sms"]="/opt/supersam/scripts/sms_first_campaign.py"
|
||||||
|
["second_sms"]="/opt/supersam/scripts/sms_second_campaign.py"
|
||||||
|
["manual"]="/opt/supersam/scripts/sms_manual_campaign.py"
|
||||||
|
["paid_storage"]="/opt/supersam/scripts/sms_paid_storage_campaign.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
QUERY="SELECT campaign_type, timer_active, run_requested FROM sms_campaign_settings"
|
||||||
|
RESULTS=$(PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -A -F '|' -c "$QUERY" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [ -z "$RESULTS" ]; then
|
||||||
|
echo "$(date): Failed to query DB, skipping"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
while IFS='|' read -r campaign_type timer_active run_requested; do
|
||||||
|
[ -z "$campaign_type" ] && continue
|
||||||
|
timer_name="${TIMERS[$campaign_type]:-}"
|
||||||
|
service_name="${SERVICES[$campaign_type]:-}"
|
||||||
|
script_path="${SCRIPTS[$campaign_type]:-}"
|
||||||
|
[ -z "$timer_name" ] && continue
|
||||||
|
|
||||||
|
if ! systemctl list-unit-files "$timer_name" 2>/dev/null | grep -q "$timer_name"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 1. Timer enable/disable ──
|
||||||
|
is_active=$(systemctl is-active "$timer_name" 2>/dev/null || echo "inactive")
|
||||||
|
|
||||||
|
if [ "$timer_active" = "t" ] || [ "$timer_active" = "true" ]; then
|
||||||
|
if [ "$is_active" != "active" ]; then
|
||||||
|
systemctl enable --now "$timer_name" 2>/dev/null
|
||||||
|
echo "$(date): ENABLED $timer_name for $campaign_type"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [ "$is_active" = "active" ]; then
|
||||||
|
systemctl disable --now "$timer_name" 2>/dev/null
|
||||||
|
echo "$(date): DISABLED $timer_name for $campaign_type"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 2. Run requested → immediate execution ──
|
||||||
|
# Don't reset flag here — script reads it from DB and resets after processing
|
||||||
|
if [ "$run_requested" = "t" ] || [ "$run_requested" = "true" ]; then
|
||||||
|
is_running=$(systemctl is-active "$service_name" 2>/dev/null || echo "inactive")
|
||||||
|
if [ "$is_running" = "active" ]; then
|
||||||
|
echo "$(date): $campaign_type already running, skipping run_requested"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$(date): RUN REQUESTED — starting $campaign_type immediately"
|
||||||
|
systemctl start "$service_name" 2>/dev/null || {
|
||||||
|
nohup python3 "$script_path" >> "/var/log/supersam-sms-${campaign_type}.log" 2>&1 &
|
||||||
|
echo "$(date): Started $campaign_type via nohup fallback"
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
done <<< "$RESULTS"
|
||||||
|
|
@ -0,0 +1,255 @@
|
||||||
|
/**
|
||||||
|
* @file SmsCampaignStats.jsx
|
||||||
|
* @description SMS Campaign statistics with charts:
|
||||||
|
* - KPI cards (sent, delivered, in transit, errors)
|
||||||
|
* - Daily trend (sent vs delivered, last 14 days)
|
||||||
|
* - Status donut chart
|
||||||
|
* - Delivery rate
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||||
|
PieChart, Pie, Cell, LineChart, Line, CartesianGrid, Legend,
|
||||||
|
} from "recharts";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { supabase } from "../../supabaseClient";
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
delivered: "#22c55e",
|
||||||
|
sent: "#3b82f6",
|
||||||
|
checking: "#94a3b8",
|
||||||
|
expired: "#eab308",
|
||||||
|
send_failed: "#ef4444",
|
||||||
|
error: "#ef4444",
|
||||||
|
limit_exceeded: "#f97316",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
delivered: "Доставлено",
|
||||||
|
sent: "Отправлено",
|
||||||
|
checking: "Проверяется",
|
||||||
|
expired: "Истёк срок",
|
||||||
|
send_failed: "Ошибка отправки",
|
||||||
|
error: "Ошибка доставки",
|
||||||
|
limit_exceeded: "Лимит",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SmsCampaignStats = ({ campaignType }) => {
|
||||||
|
const [stats, setStats] = useState(null);
|
||||||
|
const [dailyData, setDailyData] = useState([]);
|
||||||
|
const [statusData, setStatusData] = useState([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [period, setPeriod] = useState("14d");
|
||||||
|
|
||||||
|
const loadStats = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const days = period === "7d" ? 7 : period === "30d" ? 30 : period === "all" ? 90 : 14;
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setDate(startDate.getDate() - days);
|
||||||
|
const startDateISO = startDate.toISOString();
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("sms_campaign_log")
|
||||||
|
.select("status, sms_code, attempts, created_at, campaign_type")
|
||||||
|
.eq("campaign_type", campaignType)
|
||||||
|
.gte("created_at", startDateISO)
|
||||||
|
.order("created_at", { ascending: true });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// KPI
|
||||||
|
const total = data.length;
|
||||||
|
const delivered = data.filter(d => d.status === "delivered").length;
|
||||||
|
const inTransit = data.filter(d => d.status === "sent" || d.status === "checking").length;
|
||||||
|
const errors = data.filter(d => ["send_failed", "error", "limit_exceeded"].includes(d.status)).length;
|
||||||
|
const expired = data.filter(d => d.status === "expired").length;
|
||||||
|
const deliveryRate = total > 0 ? Math.round((delivered / total) * 100) : 0;
|
||||||
|
|
||||||
|
setStats({ total, delivered, inTransit, errors, expired, deliveryRate });
|
||||||
|
|
||||||
|
// Daily data
|
||||||
|
const byDay = {};
|
||||||
|
data.forEach(d => {
|
||||||
|
const day = new Date(d.created_at).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
|
||||||
|
if (!byDay[day]) byDay[day] = { date: day, sent: 0, delivered: 0, errors: 0 };
|
||||||
|
byDay[day].sent++;
|
||||||
|
if (d.status === "delivered") byDay[day].delivered++;
|
||||||
|
if (["send_failed", "error", "limit_exceeded"].includes(d.status)) byDay[day].errors++;
|
||||||
|
});
|
||||||
|
setDailyData(Object.values(byDay));
|
||||||
|
|
||||||
|
// Status pie
|
||||||
|
const byStatus = {};
|
||||||
|
data.forEach(d => {
|
||||||
|
byStatus[d.status] = (byStatus[d.status] || 0) + 1;
|
||||||
|
});
|
||||||
|
setStatusData(Object.entries(byStatus).map(([name, value]) => ({
|
||||||
|
name: STATUS_LABELS[name] || name,
|
||||||
|
value,
|
||||||
|
color: STATUS_COLORS[name] || "#94a3b8",
|
||||||
|
})));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Stats error:", e);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [campaignType, period]);
|
||||||
|
|
||||||
|
useEffect(() => { loadStats(); }, [loadStats]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Panel className="p-5">
|
||||||
|
<div className="animate-pulse text-sm text-[var(--color-text-muted)]">Загрузка статистики…</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats || stats.total === 0) {
|
||||||
|
return (
|
||||||
|
<Panel className="p-4">
|
||||||
|
<div className="text-xs text-[var(--color-text-muted)]">Нет данных для статистики</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* ── Period selector ──────────────────────────────────────────────── */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[
|
||||||
|
{ key: "7d", label: "7 дней" },
|
||||||
|
{ key: "14d", label: "14 дней" },
|
||||||
|
{ key: "30d", label: "30 дней" },
|
||||||
|
{ key: "all", label: "Всё время" },
|
||||||
|
].map(p => (
|
||||||
|
<button
|
||||||
|
key={p.key}
|
||||||
|
onClick={() => setPeriod(p.key)}
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-medium transition ${
|
||||||
|
period === p.key
|
||||||
|
? "bg-[var(--color-accent)] text-white"
|
||||||
|
: "border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── KPI cards ────────────────────────────────────────────────────── */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
|
<KpiCard label="Всего" value={stats.total} color="var(--color-text)" />
|
||||||
|
<KpiCard label="Доставлено" value={stats.delivered} color="#22c55e" />
|
||||||
|
<KpiCard label="В пути" value={stats.inTransit} color="#3b82f6" />
|
||||||
|
<KpiCard label="Истёк срок" value={stats.expired} color="#eab308" />
|
||||||
|
<KpiCard label="Ошибки" value={stats.errors} color="#ef4444" />
|
||||||
|
<KpiCard label="Конверсия" value={`${stats.deliveryRate}%`} color="var(--color-accent)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Charts ───────────────────────────────────────────────────────── */}
|
||||||
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||||
|
{/* Daily trend */}
|
||||||
|
<Panel className="p-4">
|
||||||
|
<div className="mb-3 text-xs font-semibold text-[var(--color-text)]">📈 Отправки по дням</div>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<BarChart data={dailyData} margin={{ top: 5, right: 10, left: -20, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "var(--color-text-muted)" }} />
|
||||||
|
<YAxis tick={{ fontSize: 10, fill: "var(--color-text-muted)" }} allowDecimals={false} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: "var(--color-surface-strong)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
borderRadius: "12px",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: "11px" }} />
|
||||||
|
<Bar dataKey="sent" name="Отправлено" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
||||||
|
<Bar dataKey="delivered" name="Доставлено" fill="#22c55e" radius={[4, 4, 0, 0]} />
|
||||||
|
<Bar dataKey="errors" name="Ошибки" fill="#ef4444" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Status donut */}
|
||||||
|
<Panel className="p-4">
|
||||||
|
<div className="mb-3 text-xs font-semibold text-[var(--color-text)]">🍩 По статусам</div>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={statusData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={50}
|
||||||
|
outerRadius={85}
|
||||||
|
paddingAngle={3}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{statusData.map((entry, i) => (
|
||||||
|
<Cell key={i} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: "var(--color-surface-strong)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
borderRadius: "12px",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: "11px" }} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Delivery rate trend ──────────────────────────────────────────── */}
|
||||||
|
{dailyData.length > 1 && (
|
||||||
|
<Panel className="p-4">
|
||||||
|
<div className="mb-3 text-xs font-semibold text-[var(--color-text)]">📊 Конверсия доставки по дням</div>
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<LineChart
|
||||||
|
data={dailyData.map(d => ({
|
||||||
|
date: d.date,
|
||||||
|
rate: d.sent > 0 ? Math.round((d.delivered / d.sent) * 100) : 0,
|
||||||
|
}))}
|
||||||
|
margin={{ top: 5, right: 10, left: -20, bottom: 5 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "var(--color-text-muted)" }} />
|
||||||
|
<YAxis tick={{ fontSize: 10, fill: "var(--color-text-muted)" }} domain={[0, 100]} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: "var(--color-surface-strong)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
borderRadius: "12px",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
formatter={(v) => [`${v}%`, "Конверсия"]}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="rate"
|
||||||
|
name="Конверсия %"
|
||||||
|
stroke="var(--color-accent)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 3, fill: "var(--color-accent)" }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── KPI Card ─────────────────────────────────────────────────────────────────
|
||||||
|
const KpiCard = ({ label, value, color }) => (
|
||||||
|
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
|
||||||
|
<div className="text-[10px] font-medium text-[var(--color-text-muted)]">{label}</div>
|
||||||
|
<div className="mt-1 text-lg font-bold" style={{ color }}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
@ -88,7 +88,7 @@ export const DriverDeliveryDetail = ({ order, onStatusChange }) => {
|
||||||
{ value: "Доставлен", label: "Доставлено" },
|
{ value: "Доставлен", label: "Доставлено" },
|
||||||
{ value: "Проблема доставки", label: "Проблема" },
|
{ value: "Проблема доставки", label: "Проблема" },
|
||||||
];
|
];
|
||||||
} else if (currentStatus === "Доставлен" || currentStatus === "Проблема доставки" || currentStatus === "Закрыт" || currentStatus === "Отменён") {
|
} else if (currentStatus === "Доставлен" || currentStatus === "Вывезено" || currentStatus === "Проблема доставки" || currentStatus === "Закрыт" || currentStatus === "Отменён") {
|
||||||
actionButtons = [];
|
actionButtons = [];
|
||||||
} else {
|
} else {
|
||||||
actionButtons = availableTransitions.map((status) => ({
|
actionButtons = availableTransitions.map((status) => ({
|
||||||
|
|
|
||||||
|
|
@ -419,9 +419,6 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
|
||||||
{getOrderGroupDeliveryHalfDay(item) ? ` · ${getOrderGroupDeliveryHalfDay(item)}` : ""}
|
{getOrderGroupDeliveryHalfDay(item) ? ` · ${getOrderGroupDeliveryHalfDay(item)}` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold shrink-0 ${item.deliveryType === "pickup" ? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]" : "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]"}`}>
|
|
||||||
{item.deliveryType === "pickup" ? "🏪 Самовывоз" : "🚚 Доставка"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 text-sm text-[var(--color-text-muted)]">
|
<div className="mt-3 text-sm text-[var(--color-text-muted)]">
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,73 @@ import { OrderFilters } from "../orders/OrderFilters";
|
||||||
import { formatDate, formatDateTime } from "../../utils/formatters";
|
import { formatDate, formatDateTime } from "../../utils/formatters";
|
||||||
|
|
||||||
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, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" });
|
const STORAGE_KEY = "logistics-filters";
|
||||||
|
const savedFilters = (() => { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || "null"); } catch { return null; } })();
|
||||||
|
const [filters, setFilters] = React.useState(savedFilters || { query: "", displayStatus: "all", city: "" });
|
||||||
|
React.useEffect(() => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(filters)); } catch {} }, [filters]);
|
||||||
const [page, setPage] = React.useState(1);
|
const [page, setPage] = React.useState(1);
|
||||||
const [collapsedSections, setCollapsedSections] = React.useState(new Set());
|
const savedCollapsed = (() => { try { return new Set(JSON.parse(localStorage.getItem("logistics-collapsed") || "[]")); } catch { return new Set(); } })();
|
||||||
|
const [collapsedSections, setCollapsedSections] = React.useState(savedCollapsed);
|
||||||
|
React.useEffect(() => { try { localStorage.setItem("logistics-collapsed", JSON.stringify([...collapsedSections])); } catch {} }, [collapsedSections]);
|
||||||
|
const [draggingStatus, setDraggingStatus] = React.useState(null);
|
||||||
|
const [dragOverStatus, setDragOverStatus] = React.useState(null);
|
||||||
|
const savedOrder = (() => { try { return JSON.parse(localStorage.getItem("logistics-section-order") || "null"); } catch { return null; } })();
|
||||||
|
const [sectionOrder, setSectionOrder] = React.useState(savedOrder || null);
|
||||||
|
React.useEffect(() => { if (sectionOrder) { try { localStorage.setItem("logistics-section-order", JSON.stringify(sectionOrder)); } catch {} } }, [sectionOrder]);
|
||||||
|
|
||||||
|
// Touch drag for mobile
|
||||||
|
const [touchDragging, setTouchDragging] = React.useState(null);
|
||||||
|
const touchStartY = React.useRef(null);
|
||||||
|
const touchStartStatus = React.useRef(null);
|
||||||
|
|
||||||
|
const handleTouchStart = (statusValue, e) => {
|
||||||
|
if (e.target.closest(".drag-handle")) {
|
||||||
|
touchStartY.current = e.touches[0].clientY;
|
||||||
|
touchStartStatus.current = statusValue;
|
||||||
|
setTouchDragging(statusValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e) => {
|
||||||
|
if (!touchStartStatus.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const el = document.elementFromPoint(touch.clientX, touch.clientY);
|
||||||
|
const section = el?.closest("[data-section-key]");
|
||||||
|
if (section) {
|
||||||
|
const overKey = section.getAttribute("data-section-key");
|
||||||
|
if (overKey && overKey !== touchStartStatus.current) {
|
||||||
|
setDragOverStatus(overKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = (e) => {
|
||||||
|
if (!touchStartStatus.current) return;
|
||||||
|
const touch = e.changedTouches[0];
|
||||||
|
const el = document.elementFromPoint(touch.clientX, touch.clientY);
|
||||||
|
const section = el?.closest("[data-section-key]");
|
||||||
|
if (section) {
|
||||||
|
const overKey = section.getAttribute("data-section-key");
|
||||||
|
if (overKey && overKey !== touchStartStatus.current) {
|
||||||
|
const order = sectionOrder && sectionOrder.length > 0 ? sectionOrder : FUNNEL_ORDER;
|
||||||
|
const entries = Array.from(statusGroups.keys());
|
||||||
|
const fullOrder = [...new Set([...order.filter(k => entries.includes(k)), ...entries])];
|
||||||
|
const fromIdx = fullOrder.indexOf(touchStartStatus.current);
|
||||||
|
const toIdx = fullOrder.indexOf(overKey);
|
||||||
|
if (fromIdx !== -1 && toIdx !== -1) {
|
||||||
|
const newOrd = [...fullOrder];
|
||||||
|
newOrd.splice(fromIdx, 1);
|
||||||
|
newOrd.splice(toIdx, 0, touchStartStatus.current);
|
||||||
|
setSectionOrder(newOrd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTouchDragging(null);
|
||||||
|
setDragOverStatus(null);
|
||||||
|
touchStartStatus.current = null;
|
||||||
|
touchStartY.current = null;
|
||||||
|
};
|
||||||
const PAGE_SIZE = 30;
|
const PAGE_SIZE = 30;
|
||||||
|
|
||||||
const cities = React.useMemo(() => {
|
const cities = React.useMemo(() => {
|
||||||
|
|
@ -113,8 +177,9 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-0">
|
<div className="grid gap-0">
|
||||||
{Array.from(statusGroups.entries()).sort(([a], [b]) => {
|
{Array.from(statusGroups.entries()).sort(([a], [b]) => {
|
||||||
const idxA = FUNNEL_ORDER.indexOf(a);
|
const order = sectionOrder && sectionOrder.length > 0 ? sectionOrder : FUNNEL_ORDER;
|
||||||
const idxB = FUNNEL_ORDER.indexOf(b);
|
const idxA = order.indexOf(a);
|
||||||
|
const idxB = order.indexOf(b);
|
||||||
if (idxA === -1 && idxB === -1) return a.localeCompare(b);
|
if (idxA === -1 && idxB === -1) return a.localeCompare(b);
|
||||||
if (idxA === -1) return 1;
|
if (idxA === -1) return 1;
|
||||||
if (idxB === -1) return -1;
|
if (idxB === -1) return -1;
|
||||||
|
|
@ -124,22 +189,46 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={statusValue} className="border-b border-[var(--color-border)] last:border-b-0">
|
<div key={statusValue} className="border-b border-[var(--color-border)] last:border-b-0">
|
||||||
{/* Section header — same style as OrdersTable */}
|
{/* Section header — drag handle + collapse */}
|
||||||
<button
|
<div
|
||||||
type="button"
|
data-section-key={statusValue}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => { setDraggingStatus(statusValue); e.dataTransfer.effectAllowed = "move"; }}
|
||||||
|
onDragEnd={() => { setDraggingStatus(null); setDragOverStatus(null); }}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); if (draggingStatus && draggingStatus !== statusValue) setDragOverStatus(statusValue); }}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (draggingStatus && draggingStatus !== statusValue) {
|
||||||
|
const order = sectionOrder && sectionOrder.length > 0 ? sectionOrder : FUNNEL_ORDER;
|
||||||
|
const entries = Array.from(statusGroups.keys());
|
||||||
|
const fullOrder = [...new Set([...order.filter(k => entries.includes(k)), ...entries])];
|
||||||
|
const fromIdx = fullOrder.indexOf(draggingStatus);
|
||||||
|
const toIdx = fullOrder.indexOf(statusValue);
|
||||||
|
if (fromIdx !== -1 && toIdx !== -1) {
|
||||||
|
const newOrd = [...fullOrder];
|
||||||
|
newOrd.splice(fromIdx, 1);
|
||||||
|
newOrd.splice(toIdx, 0, draggingStatus);
|
||||||
|
setSectionOrder(newOrd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setDraggingStatus(null);
|
||||||
|
setDragOverStatus(null);
|
||||||
|
}}
|
||||||
|
onTouchStart={(e) => handleTouchStart(statusValue, e)}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (touchStartStatus.current) return;
|
||||||
setCollapsedSections((prev) => {
|
setCollapsedSections((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(statusValue)) {
|
if (next.has(statusValue)) next.delete(statusValue);
|
||||||
next.delete(statusValue);
|
else next.add(statusValue);
|
||||||
} else {
|
|
||||||
next.add(statusValue);
|
|
||||||
}
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition hover:bg-[var(--color-accent-soft)]"
|
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition cursor-grab active:cursor-grabbing ${dragOverStatus === statusValue ? "border-t-2 border-t-[var(--color-accent)]" : ""} ${(draggingStatus === statusValue || touchDragging === statusValue) ? "opacity-50" : ""} hover:bg-[var(--color-accent-soft)]`}
|
||||||
>
|
>
|
||||||
|
<span className="drag-handle shrink-0 text-[var(--color-text-muted)] text-lg select-none touch-none" style={{cursor: "grab", lineHeight: "1"}}>⋮⋮</span>
|
||||||
<svg
|
<svg
|
||||||
className="h-4 w-4 shrink-0 text-[var(--color-text-muted)] transition-transform"
|
className="h-4 w-4 shrink-0 text-[var(--color-text-muted)] transition-transform"
|
||||||
style={{ transform: isCollapsed ? "rotate(0deg)" : "rotate(90deg)" }}
|
style={{ transform: isCollapsed ? "rotate(0deg)" : "rotate(90deg)" }}
|
||||||
|
|
@ -149,7 +238,7 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
|
||||||
</svg>
|
</svg>
|
||||||
<Badge tone={groups.length > 0 ? "neutral" : "muted"}>{label}</Badge>
|
<Badge tone={groups.length > 0 ? "neutral" : "muted"}>{label}</Badge>
|
||||||
<span className="text-xs text-[var(--color-text-muted)]">{groups.length}</span>
|
<span className="text-xs text-[var(--color-text-muted)]">{groups.length}</span>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
|
@ -179,7 +268,9 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
|
||||||
<div className="px-3 py-1.5 text-xs">
|
<div className="px-3 py-1.5 text-xs">
|
||||||
{group.deliveryDate
|
{group.deliveryDate
|
||||||
? <span>{formatDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
|
? <span>{formatDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
|
||||||
: <span className="text-[var(--color-text-muted)]">—</span>
|
: group.updatedAt
|
||||||
|
? <span>{formatDate(group.updatedAt)}</span>
|
||||||
|
: <span className="text-[var(--color-text-muted)]">—</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-1.5 text-xs">
|
<div className="px-3 py-1.5 text-xs">
|
||||||
|
|
|
||||||
|
|
@ -643,10 +643,32 @@ export const OrderDetailPanel = ({
|
||||||
|| (deliveryType === "delivery" && !isPickupOrder)
|
|| (deliveryType === "delivery" && !isPickupOrder)
|
||||||
);
|
);
|
||||||
const canEditDelivery = canManageDelivery && ["admin", "mega_admin", "logistician"].includes(userRole);
|
const canEditDelivery = canManageDelivery && ["admin", "mega_admin", "logistician"].includes(userRole);
|
||||||
const agreedDeliveryLabel = [
|
const agreedDeliveryLabel = (() => {
|
||||||
formatDeliveryDateDisplay(order.deliveryDate),
|
if (isPickupOrder) {
|
||||||
order.deliveryTime || order.deliveryHalfDay,
|
const parts = [
|
||||||
].filter((value) => value && value !== "Нет данных").join(" · ");
|
formatDeliveryDateDisplay(order.pickupDate || order.pickup_date),
|
||||||
|
order.pickupTimeSlot || order.pickup_time_slot || order.deliveryTime || order.deliveryHalfDay,
|
||||||
|
].filter((value) => value && value !== "Нет данных");
|
||||||
|
if (parts.length > 0) return parts.join(" · ");
|
||||||
|
// Fallback: show manual_confirmation_at date if no pickup date
|
||||||
|
if (order.manualConfirmationAt || order.manual_confirmation_at) {
|
||||||
|
const mcDate = order.manualConfirmationAt || order.manual_confirmation_at;
|
||||||
|
return `Согласовано: ${fmtTime(mcDate)}`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const parts = [
|
||||||
|
formatDeliveryDateDisplay(order.deliveryDate),
|
||||||
|
order.deliveryTime || order.deliveryHalfDay,
|
||||||
|
].filter((value) => value && value !== "Нет данных");
|
||||||
|
if (parts.length > 0) return parts.join(" · ");
|
||||||
|
// Fallback: show manual_confirmation_at date if no delivery date
|
||||||
|
if (order.manualConfirmationAt || order.manual_confirmation_at) {
|
||||||
|
const mcDate = order.manualConfirmationAt || order.manual_confirmation_at;
|
||||||
|
return `Согласовано: ${fmtTime(mcDate)}`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
})();
|
||||||
|
|
||||||
const handleSaveDeliveryChoice = async () => {
|
const handleSaveDeliveryChoice = async () => {
|
||||||
const effectiveDate = deliveryType === "pickup" ? pickupDate : deliveryDate;
|
const effectiveDate = deliveryType === "pickup" ? pickupDate : deliveryDate;
|
||||||
|
|
@ -937,7 +959,7 @@ export const OrderDetailPanel = ({
|
||||||
{deliveryType === "pickup" ? "Самовывоз согласован" : "Доставка согласована"}
|
{deliveryType === "pickup" ? "Самовывоз согласован" : "Доставка согласована"}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-lg font-semibold">
|
<p className="mt-1 text-lg font-semibold">
|
||||||
{agreedDeliveryLabel || "Дата и время сохранены"}
|
{agreedDeliveryLabel || "Дата не указана — нажмите «Изменить дату»"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge tone="accent">Согласовано</Badge>
|
<Badge tone="accent">Согласовано</Badge>
|
||||||
|
|
@ -1033,7 +1055,13 @@ export const OrderDetailPanel = ({
|
||||||
if (action.type === "hint") {
|
if (action.type === "hint") {
|
||||||
setFormMessage(action.hint);
|
setFormMessage(action.hint);
|
||||||
} else if (action.type === "status") {
|
} else if (action.type === "status") {
|
||||||
setConfirmAction({ type: "status", status: action.status });
|
setConfirmAction({
|
||||||
|
type: "status",
|
||||||
|
status: action.status,
|
||||||
|
label: action.label,
|
||||||
|
mismatch: action.mismatch,
|
||||||
|
deliveryType: action.deliveryType,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1077,33 +1105,49 @@ export const OrderDetailPanel = ({
|
||||||
const currentStatus = order.deliveryStatus || order.delivery_status;
|
const currentStatus = order.deliveryStatus || order.delivery_status;
|
||||||
const IN_TRANSIT_STATUSES = ["loaded", "on_route"];
|
const IN_TRANSIT_STATUSES = ["loaded", "on_route"];
|
||||||
const isOnRoute = IN_TRANSIT_STATUSES.includes(currentStatus);
|
const isOnRoute = IN_TRANSIT_STATUSES.includes(currentStatus);
|
||||||
|
const isPickup = (order.deliveryType || order.delivery_type) === "pickup" || currentStatus === "pickup";
|
||||||
|
|
||||||
let statusOptions = [];
|
let statusOptions = [];
|
||||||
if (currentStatus === "delivered" || currentStatus === "picked_up" || currentStatus === "problem" || currentStatus === "cancelled" || currentStatus === "paid_storage") {
|
if (currentStatus === "delivered" || currentStatus === "picked_up" || currentStatus === "problem" || currentStatus === "cancelled" || currentStatus === "paid_storage") {
|
||||||
statusOptions = [];
|
statusOptions = [];
|
||||||
} else {
|
} else {
|
||||||
statusOptions = [
|
// Primary button matches delivery type, secondary requires confirmation
|
||||||
{ value: "delivered", label: "Доставлено" },
|
if (isPickup) {
|
||||||
{ value: "picked_up", label: "Вывезено" },
|
statusOptions = [
|
||||||
{ value: "problem", label: "Проблема" },
|
{ value: "picked_up", label: "Вывезено", mismatch: false },
|
||||||
];
|
{ value: "delivered", label: "Доставлено", mismatch: true },
|
||||||
|
{ value: "problem", label: "Проблема" },
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
statusOptions = [
|
||||||
|
{ value: "delivered", label: "Доставлено", mismatch: false },
|
||||||
|
{ value: "picked_up", label: "Вывезено", mismatch: true },
|
||||||
|
{ value: "problem", label: "Проблема" },
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statusOptions.length === 0) return null;
|
if (statusOptions.length === 0) return null;
|
||||||
|
|
||||||
return statusOptions.map((statusOption) => {
|
return statusOptions.map((statusOption) => {
|
||||||
const isSelected = pendingStatus?.value === statusOption.value;
|
const isSelected = pendingStatus?.value === statusOption.value;
|
||||||
|
const isMismatch = statusOption.mismatch;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={statusOption.value}
|
key={statusOption.value}
|
||||||
variant={isSelected ? "primary" : "secondary"}
|
variant={isSelected ? "primary" : isMismatch ? "ghost" : "secondary"}
|
||||||
disabled={isSavingStatusChange}
|
disabled={isSavingStatusChange}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (statusOption.value === "problem") {
|
if (statusOption.value === "problem") {
|
||||||
setProblemReason("selecting");
|
setProblemReason("selecting");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPendingStatus({ value: statusOption.value });
|
setPendingStatus({
|
||||||
|
value: statusOption.value,
|
||||||
|
label: statusOption.label,
|
||||||
|
mismatch: isMismatch,
|
||||||
|
deliveryType: isPickup ? "pickup" : "delivery",
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{statusOption.label}
|
{statusOption.label}
|
||||||
|
|
@ -1114,6 +1158,25 @@ export const OrderDetailPanel = ({
|
||||||
</div>
|
</div>
|
||||||
{pendingStatus ? (
|
{pendingStatus ? (
|
||||||
<div className="flex items-center gap-3 mt-2">
|
<div className="flex items-center gap-3 mt-2">
|
||||||
|
{pendingStatus.mismatch ? (
|
||||||
|
<div className="flex-1 rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] p-3 text-sm">
|
||||||
|
<p className="font-medium text-[var(--color-text)]">
|
||||||
|
⚠ Статус возможно не совпадает
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[var(--color-text-muted)]">
|
||||||
|
Тип отгрузки: <strong>{pendingStatus.deliveryType === "pickup" ? "🏪 Самовывоз" : "🚚 Доставка"}</strong>.
|
||||||
|
Вы выбрали: <strong>«{pendingStatus.label}»</strong>.
|
||||||
|
{pendingStatus.deliveryType === "pickup"
|
||||||
|
? "Обычно при самовывозе используется «Вывезено»."
|
||||||
|
: "Обычно при доставке используется «Доставлено»."}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-[var(--color-text)]">Подтвердить действие?</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
Установить статус: <strong>{pendingStatus.label}</strong>?
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={isSavingStatusChange}
|
disabled={isSavingStatusChange}
|
||||||
|
|
@ -1319,8 +1382,11 @@ export const OrderDetailPanel = ({
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
open={confirmAction?.type === 'status'}
|
open={confirmAction?.type === 'status'}
|
||||||
title="Изменить статус?"
|
title={confirmAction?.mismatch ? "⚠ Статус возможно не совпадает" : "Изменить статус?"}
|
||||||
message={`Установить статус «${STATUS_LABELS[confirmAction?.status] || confirmAction?.status}»?`}
|
message={confirmAction?.mismatch
|
||||||
|
? `Тип отгрузки: ${confirmAction.deliveryType === "pickup" ? "🏪 Самовывоз" : "🚚 Доставка"}. Вы выбрали «${confirmAction.label}». ${confirmAction.deliveryType === "pickup" ? "Обычно при самовывозе используется «Вывезено»." : "Обычно при доставке используется «Доставлено»."} Подтвердить?`
|
||||||
|
: `Установить статус «${confirmAction?.label || STATUS_LABELS[confirmAction?.status] || confirmAction?.status}»?`
|
||||||
|
}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
const status = confirmAction.status;
|
const status = confirmAction.status;
|
||||||
setConfirmAction(null);
|
setConfirmAction(null);
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
import { useState, useMemo } from "react";
|
|
||||||
import { formatDateTime } from "../../utils/formatters";
|
import { formatDateTime } from "../../utils/formatters";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
|
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import { SkeletonTable } from "../UI/Loading";
|
import { SkeletonTable } from "../UI/Loading";
|
||||||
import { Pagination } from "../UI/Pagination";
|
|
||||||
import { OrderFilters } from "./OrderFilters";
|
import { OrderFilters } from "./OrderFilters";
|
||||||
import {
|
import {
|
||||||
getOrderGroupDisplayStatusLabel,
|
getOrderGroupDisplayStatusLabel,
|
||||||
getOrderGroupStatusTone,
|
getOrderGroupStatusTone,
|
||||||
getOrderGroupDisplayStatusValue,
|
|
||||||
} from "../../services/orderGroupViews";
|
} from "../../services/orderGroupViews";
|
||||||
|
|
||||||
const MAX_VISIBLE_INVOICES = 2;
|
const MAX_VISIBLE_INVOICES = 2;
|
||||||
|
|
@ -21,233 +18,64 @@ const fmtDate = (d) => {
|
||||||
return `${day}.${m}.${y}`;
|
return `${day}.${m}.${y}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Agreed delivery states — client confirmed delivery/pickup
|
const getShipmentIssues = (group) => {
|
||||||
const AGREED_DELIVERY_STATES = ["agreed", "driver_assigned", "loaded", "on_route", "delivered", "picked_up", "pickup"];
|
const data = group?.driverShipmentData;
|
||||||
const isAgreedDelivery = (group) => AGREED_DELIVERY_STATES.includes(group?.deliveryStatus || group?.delivery_status);
|
if (!Array.isArray(data) || data.length === 0) return null;
|
||||||
|
const unshipped = data.filter((i) => !i.shipped);
|
||||||
// ── Status group ordering ───────────────────────────────────────────────────
|
if (unshipped.length === 0) return null;
|
||||||
// Lower = higher priority (shown first)
|
return unshipped;
|
||||||
const STATUS_ORDER = [
|
|
||||||
"delivery:agreed",
|
|
||||||
"delivery:pickup",
|
|
||||||
"delivery:driver_assigned",
|
|
||||||
"delivery:loaded",
|
|
||||||
"delivery:on_route",
|
|
||||||
"delivery:delivered",
|
|
||||||
"delivery:picked_up",
|
|
||||||
"delivery:requires_address",
|
|
||||||
"delivery:address_required",
|
|
||||||
"status:manual_required",
|
|
||||||
"delivery:paid_storage",
|
|
||||||
"delivery:problem",
|
|
||||||
"delivery:cancelled",
|
|
||||||
"status:ready_to_launch",
|
|
||||||
"status:first_sms_sent",
|
|
||||||
"status:second_sms_sent",
|
|
||||||
"status:link_ready",
|
|
||||||
"status:sms_sending",
|
|
||||||
"status:send_failed",
|
|
||||||
"status:not_started",
|
|
||||||
"status:unknown",
|
|
||||||
];
|
|
||||||
|
|
||||||
const getStatusOrder = (key) => {
|
|
||||||
const idx = STATUS_ORDER.indexOf(key);
|
|
||||||
return idx === -1 ? 999 : idx;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Collapsible status section ──────────────────────────────────────────────
|
const buildGroupSummary = (group) => {
|
||||||
const StatusSection = ({ statusKey, groups, isOpen, onToggle, selectedOrderGroupId, onOpenOrder }) => {
|
const orderCountLabel = `${group.ordersCount || 0} ${group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"}`;
|
||||||
const label = getOrderGroupDisplayStatusLabel(groups[0]);
|
const parts = [orderCountLabel];
|
||||||
const tone = getOrderGroupStatusTone(groups[0]);
|
if (group.deliveryDate) {
|
||||||
const isAgreed = statusKey.startsWith("delivery:agreed") || statusKey.startsWith("delivery:pickup") ||
|
const datePart = group.deliveryTime ? `${fmtDate(group.deliveryDate)} · ${group.deliveryTime}` : fmtDate(group.deliveryDate);
|
||||||
statusKey.startsWith("delivery:driver_assigned") || statusKey.startsWith("delivery:loaded") ||
|
parts.push(datePart);
|
||||||
statusKey.startsWith("delivery:on_route") || statusKey.startsWith("delivery:delivered") ||
|
}
|
||||||
statusKey.startsWith("delivery:picked_up");
|
if (group.assignedDriverName) {
|
||||||
|
parts.push(group.assignedDriverName);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return parts.join(" · ");
|
||||||
<div className="border-b border-[var(--color-border)] last:border-b-0">
|
|
||||||
{/* Section header */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onToggle}
|
|
||||||
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition hover:bg-[var(--color-accent-soft)] ${
|
|
||||||
isAgreed ? "bg-[rgba(18,128,92,0.04)]" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="h-4 w-4 shrink-0 text-[var(--color-text-muted)] transition-transform"
|
|
||||||
style={{ transform: isOpen ? "rotate(90deg)" : "rotate(0deg)" }}
|
|
||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
<Badge tone={tone}>{label}</Badge>
|
|
||||||
<span className="text-xs text-[var(--color-text-muted)]">{groups.length}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Rows */}
|
|
||||||
{isOpen && (
|
|
||||||
<div>
|
|
||||||
{groups.map((group) => {
|
|
||||||
const hasProblem = group.hasDeliveryProblem;
|
|
||||||
const isAgreedRow = isAgreedDelivery(group);
|
|
||||||
const rowClassName = `grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(80px,0.8fr)_minmax(80px,1fr)_minmax(100px,1fr)_minmax(100px,0.8fr)_minmax(80px,0.8fr)] gap-0 w-full border-t text-left transition ${
|
|
||||||
isAgreedRow
|
|
||||||
? "border-[rgba(18,128,92,0.25)] bg-[rgba(18,128,92,0.07)] hover:bg-[rgba(18,128,92,0.12)]"
|
|
||||||
: hasProblem
|
|
||||||
? "border-[var(--color-border)] bg-[rgba(201,61,61,0.1)] hover:bg-[rgba(201,61,61,0.15)]"
|
|
||||||
: "border-[var(--color-border)] hover:bg-[var(--color-accent-soft)]"
|
|
||||||
} ${selectedOrderGroupId === group.id ? "bg-[var(--color-accent-soft)]" : ""}`;
|
|
||||||
|
|
||||||
const billNumbers = group.allBillNumbers || group.orderNumbers || [];
|
|
||||||
const primaryBill = billNumbers[0] || "—";
|
|
||||||
const totalBills = billNumbers.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={group.id}
|
|
||||||
type="button"
|
|
||||||
className={rowClassName}
|
|
||||||
onClick={() => onOpenOrder(group.id)}
|
|
||||||
>
|
|
||||||
<div className="min-w-0 px-3 py-1.5">
|
|
||||||
<div className="text-xs font-medium leading-snug break-words" style={{display:"-webkit-box",WebkitLineClamp:2,WebkitBoxOrient:"vertical",overflow:"hidden"}}>{group.displayTitle || group.customerName || group.groupKey}</div>
|
|
||||||
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">
|
|
||||||
{group.customerPhone || ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5">
|
|
||||||
<div className="text-xs text-[var(--color-text)]">{primaryBill}</div>
|
|
||||||
{totalBills > 1 && (
|
|
||||||
<span className="inline-block mt-0.5 rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)]">
|
|
||||||
{totalBills} сч.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
|
|
||||||
{group.city || "—"}
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 flex items-center justify-center">
|
|
||||||
<Badge tone={getOrderGroupStatusTone(group)}>
|
|
||||||
{getOrderGroupDisplayStatusLabel(group)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 text-xs">
|
|
||||||
{group.deliveryDate ? (
|
|
||||||
<span>{fmtDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-[var(--color-text-muted)]">—</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 text-xs">
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
{group.deliveryType === "pickup" ? "🏪" : "🚚"}
|
|
||||||
<span className="text-[var(--color-text-muted)]">{group.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 text-xs">
|
|
||||||
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Mobile collapsible section ───────────────────────────────────────────────
|
const renderOrderNumbers = (group) => {
|
||||||
const MobileStatusSection = ({ statusKey, groups, isOpen, onToggle, selectedOrderGroupId, onOpenOrder }) => {
|
const numbers = group.allBillNumbers || group.orderNumbers;
|
||||||
const label = getOrderGroupDisplayStatusLabel(groups[0]);
|
if (!Array.isArray(numbers) || !numbers.length) {
|
||||||
const tone = getOrderGroupStatusTone(groups[0]);
|
return "Номера не указаны";
|
||||||
const isAgreed = statusKey.startsWith("delivery:agreed") || statusKey.startsWith("delivery:pickup") ||
|
}
|
||||||
statusKey.startsWith("delivery:driver_assigned") || statusKey.startsWith("delivery:loaded") ||
|
|
||||||
statusKey.startsWith("delivery:on_route") || statusKey.startsWith("delivery:delivered") ||
|
|
||||||
statusKey.startsWith("delivery:picked_up");
|
|
||||||
|
|
||||||
|
if (numbers.length <= MAX_VISIBLE_INVOICES) {
|
||||||
|
return numbers.join(", ");
|
||||||
|
}
|
||||||
|
const visible = numbers.slice(0, MAX_VISIBLE_INVOICES);
|
||||||
|
const remaining = numbers.length - MAX_VISIBLE_INVOICES;
|
||||||
|
return `${visible.join(", ")} +${remaining}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDeliveryTypeLabel = (deliveryType) => {
|
||||||
|
if (deliveryType === "pickup") return "Самовывоз";
|
||||||
|
if (deliveryType === "delivery") return "Доставка";
|
||||||
|
return "—";
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMobileOrderNumbers = (group) => {
|
||||||
|
const numbers = group.allBillNumbers || group.orderNumbers;
|
||||||
|
if (!Array.isArray(numbers) || !numbers.length) {
|
||||||
|
return "Номера не указаны";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numbers.length <= MAX_VISIBLE_INVOICES) {
|
||||||
|
return numbers.join(", ");
|
||||||
|
}
|
||||||
|
const visible = numbers.slice(0, MAX_VISIBLE_INVOICES);
|
||||||
|
const remaining = numbers.length - MAX_VISIBLE_INVOICES;
|
||||||
return (
|
return (
|
||||||
<div className="mb-3">
|
<>
|
||||||
<button
|
{visible.join(", ")}
|
||||||
type="button"
|
<span className="ml-1 rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-xs font-medium text-[var(--color-accent)]">+{remaining}</span>
|
||||||
onClick={onToggle}
|
</>
|
||||||
className={`flex w-full items-center gap-3 rounded-2xl border px-4 py-2.5 text-left transition ${
|
|
||||||
isAgreed
|
|
||||||
? "border-[rgba(18,128,92,0.25)] bg-[rgba(18,128,92,0.06)]"
|
|
||||||
: "border-[var(--color-border)] bg-[var(--color-surface-strong)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="h-4 w-4 shrink-0 text-[var(--color-text-muted)] transition-transform"
|
|
||||||
style={{ transform: isOpen ? "rotate(90deg)" : "rotate(0deg)" }}
|
|
||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
<Badge tone={tone}>{label}</Badge>
|
|
||||||
<span className="text-xs text-[var(--color-text-muted)]">{groups.length}</span>
|
|
||||||
</button>
|
|
||||||
{isOpen && (
|
|
||||||
<div className="mt-2 space-y-3">
|
|
||||||
{groups.map((group) => {
|
|
||||||
const hasProblem = group.hasDeliveryProblem;
|
|
||||||
const isAgreedCard = isAgreedDelivery(group);
|
|
||||||
const baseClass = "w-full rounded-[22px] border text-left transition";
|
|
||||||
const selectedClass = selectedOrderGroupId === group.id
|
|
||||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
|
|
||||||
: isAgreedCard
|
|
||||||
? "border-[rgba(18,128,92,0.35)] bg-[rgba(18,128,92,0.08)]"
|
|
||||||
: hasProblem
|
|
||||||
? "border-[var(--color-danger)] bg-[rgba(201,61,61,0.1)]"
|
|
||||||
: "border-[var(--color-border)] bg-[var(--color-surface-strong)]";
|
|
||||||
|
|
||||||
const allNumbers = group.allBillNumbers || group.orderNumbers || [];
|
|
||||||
const primaryBill = allNumbers[0] || "—";
|
|
||||||
const totalCount = allNumbers.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={group.id}>
|
|
||||||
<div className="mb-1 flex items-center gap-2 px-1 text-xs">
|
|
||||||
<span className="font-medium text-[var(--color-text)]">№ {primaryBill}</span>
|
|
||||||
{totalCount > 1 && (
|
|
||||||
<span className="rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)]">
|
|
||||||
{totalCount} сч.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onOpenOrder(group.id)}
|
|
||||||
className={[baseClass, selectedClass, "flex flex-col items-start justify-start p-4"].join(" ")}
|
|
||||||
>
|
|
||||||
<div className="min-w-0 w-full font-medium leading-snug break-words">
|
|
||||||
{group.displayTitle || group.customerName || group.groupKey}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
|
||||||
<span className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${group.deliveryType === "pickup" ? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]" : "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]"}`}>
|
|
||||||
{group.deliveryType === "pickup" ? "🏪 Самовывоз" : "🚚 Доставка"}
|
|
||||||
</span>
|
|
||||||
<Badge tone={getOrderGroupStatusTone(group)}>
|
|
||||||
{getOrderGroupDisplayStatusLabel(group)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{group.hasDeliveryProblem && (
|
|
||||||
<div className="mt-2 rounded-lg border border-[var(--color-warning)] bg-[var(--color-warning-soft)] px-2 py-1 text-xs">
|
|
||||||
<span className="font-medium text-[var(--color-warning)]">⚠ Проблема с доставкой</span>
|
|
||||||
{group.deliveryProblemNote && (
|
|
||||||
<div className="mt-0.5 text-[var(--color-text-muted)]">{group.deliveryProblemNote}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -261,48 +89,6 @@ export const OrdersTable = ({
|
||||||
cities = [],
|
cities = [],
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [collapsedSections, setCollapsedSections] = useState({});
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const PAGE_SIZE = 30;
|
|
||||||
|
|
||||||
// Paginate filtered groups (after filter, before grouping)
|
|
||||||
const totalPages = Math.ceil(orderGroups.length / PAGE_SIZE);
|
|
||||||
const paginatedGroups = useMemo(() => {
|
|
||||||
const start = (page - 1) * PAGE_SIZE;
|
|
||||||
return orderGroups.slice(start, start + PAGE_SIZE);
|
|
||||||
}, [orderGroups, page]);
|
|
||||||
|
|
||||||
// Group by status
|
|
||||||
const grouped = useMemo(() => {
|
|
||||||
const map = {};
|
|
||||||
for (const g of paginatedGroups) {
|
|
||||||
const key = getOrderGroupDisplayStatusValue(g);
|
|
||||||
if (!map[key]) map[key] = [];
|
|
||||||
map[key].push(g);
|
|
||||||
}
|
|
||||||
// Sort by priority
|
|
||||||
return Object.entries(map)
|
|
||||||
.map(([key, groups]) => ({ key, groups, order: getStatusOrder(key) }))
|
|
||||||
.sort((a, b) => a.order - b.order);
|
|
||||||
}, [orderGroups]);
|
|
||||||
|
|
||||||
// Default: first section open, rest collapsed (only if not manually toggled)
|
|
||||||
const effectiveCollapsed = useMemo(() => {
|
|
||||||
const result = {};
|
|
||||||
grouped.forEach((sec, i) => {
|
|
||||||
if (sec.key in collapsedSections) {
|
|
||||||
result[sec.key] = collapsedSections[sec.key];
|
|
||||||
} else {
|
|
||||||
result[sec.key] = i !== 0; // first section open by default
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}, [grouped, collapsedSections]);
|
|
||||||
|
|
||||||
const toggleSection = (key) => {
|
|
||||||
setCollapsedSections(prev => ({ ...prev, [key]: !prev[key] }));
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <SkeletonTable rows={5} cols={5} />;
|
return <SkeletonTable rows={5} cols={5} />;
|
||||||
}
|
}
|
||||||
|
|
@ -325,27 +111,66 @@ export const OrdersTable = ({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile: collapsible sections */}
|
<div className="space-y-3 p-4 md:hidden">
|
||||||
<div className="space-y-2 p-4 md:hidden">
|
|
||||||
{!orderGroups.length ? (
|
{!orderGroups.length ? (
|
||||||
<div className="rounded-[28px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
<div className="rounded-[28px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
||||||
Группы не найдены. Попробуйте изменить поиск или статус.
|
Группы не найдены. Попробуйте изменить поиск или статус.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{grouped.map(({ key, groups }) => (
|
{orderGroups.map((group) => {
|
||||||
<MobileStatusSection
|
const hasProblem = group.hasDeliveryProblem;
|
||||||
key={key}
|
const baseClass = "w-full rounded-[22px] border text-left transition";
|
||||||
statusKey={key}
|
const selectedClass = selectedOrderGroupId === group.id
|
||||||
groups={groups}
|
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
|
||||||
isOpen={!effectiveCollapsed[key]}
|
: hasProblem
|
||||||
onToggle={() => toggleSection(key)}
|
? "border-[var(--color-danger)] bg-[rgba(201,61,61,0.1)]"
|
||||||
selectedOrderGroupId={selectedOrderGroupId}
|
: "border-[var(--color-border)] bg-[var(--color-surface-strong)]";
|
||||||
onOpenOrder={onOpenOrder}
|
|
||||||
/>
|
const allNumbers = group.allBillNumbers || group.orderNumbers || [];
|
||||||
))}
|
const primaryBill = allNumbers[0] || "—";
|
||||||
|
const totalCount = allNumbers.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.id}>
|
||||||
|
<div className="mb-1 flex items-center gap-2 px-1 text-xs">
|
||||||
|
<span className="font-medium text-[var(--color-text)]">№ {primaryBill}</span>
|
||||||
|
{totalCount > 1 && (
|
||||||
|
<span className="rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)]">
|
||||||
|
{totalCount} сч.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenOrder(group.id)}
|
||||||
|
className={[baseClass, selectedClass, "flex flex-col items-start justify-start p-4"].join(" ")}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 w-full font-medium leading-snug break-words">
|
||||||
|
{group.displayTitle || group.customerName || group.groupKey}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${group.deliveryType === "pickup" ? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]" : "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]"}`}>
|
||||||
|
{group.deliveryType === "pickup" ? "🏪 Самовывоз" : "🚚 Доставка"}
|
||||||
|
</span>
|
||||||
|
<Badge tone={getOrderGroupStatusTone(group)}>
|
||||||
|
{getOrderGroupDisplayStatusLabel(group)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{group.hasDeliveryProblem && (
|
||||||
|
<div className="mt-2 rounded-lg border border-[var(--color-warning)] bg-[var(--color-warning-soft)] px-2 py-1 text-xs">
|
||||||
|
<span className="font-medium text-[var(--color-warning)]">⚠ Проблема с доставкой</span>
|
||||||
|
{group.deliveryProblemNote && (
|
||||||
|
<div className="mt-0.5 text-[var(--color-text-muted)]">{group.deliveryProblemNote}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop: collapsible sections with table header */}
|
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
{!orderGroups.length ? (
|
{!orderGroups.length ? (
|
||||||
<div className="px-5 py-6 text-sm text-[var(--color-text-muted)]">
|
<div className="px-5 py-6 text-sm text-[var(--color-text-muted)]">
|
||||||
|
|
@ -353,25 +178,80 @@ export const OrdersTable = ({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<div className="min-w-[1000px]">
|
<div className="min-w-[1080px]">
|
||||||
{grouped.map(({ key, groups }) => (
|
<div className="grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,0.8fr)_minmax(90px,0.8fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.12em] text-[var(--color-text-muted)]">
|
||||||
<StatusSection
|
<div className="px-3 py-1.5 font-medium">Группа / Клиент</div>
|
||||||
key={key}
|
<div className="px-3 py-1.5 font-medium">Счета</div>
|
||||||
statusKey={key}
|
<div className="px-3 py-1.5 font-medium">Город</div>
|
||||||
groups={groups}
|
<div className="px-3 py-1.5 font-medium">Статус</div>
|
||||||
isOpen={!effectiveCollapsed[key]}
|
<div className="px-3 py-1.5 font-medium">Дата доставки</div>
|
||||||
onToggle={() => toggleSection(key)}
|
<div className="px-3 py-1.5 font-medium">Тип</div>
|
||||||
selectedOrderGroupId={selectedOrderGroupId}
|
<div className="px-3 py-1.5 font-medium">Водитель</div>
|
||||||
onOpenOrder={onOpenOrder}
|
</div>
|
||||||
/>
|
{orderGroups.map((group) => {
|
||||||
))}
|
const hasProblem = group.hasDeliveryProblem;
|
||||||
|
const rowClassName = `grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,0.8fr)_minmax(90px,0.8fr)] gap-0 w-full border-t border-[var(--color-border)] text-left transition ${
|
||||||
|
hasProblem
|
||||||
|
? "bg-[rgba(201,61,61,0.1)] hover:bg-[rgba(201,61,61,0.15)]"
|
||||||
|
: "hover:bg-[var(--color-accent-soft)]"
|
||||||
|
} ${selectedOrderGroupId === group.id ? "bg-[var(--color-accent-soft)]" : ""}`;
|
||||||
|
|
||||||
|
const billNumbers = group.allBillNumbers || group.orderNumbers || [];
|
||||||
|
const primaryBill = billNumbers[0] || "—";
|
||||||
|
const totalBills = billNumbers.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={group.id}
|
||||||
|
type="button"
|
||||||
|
className={rowClassName}
|
||||||
|
onClick={() => onOpenOrder(group.id)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 px-3 py-1.5">
|
||||||
|
<div className="text-xs font-medium leading-snug break-words" style={{display:"-webkit-box",WebkitLineClamp:2,WebkitBoxOrient:"vertical",overflow:"hidden"}}>{group.displayTitle || group.customerName || group.groupKey}</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">
|
||||||
|
{group.customerPhone || ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5">
|
||||||
|
<div className="text-xs text-[var(--color-text)]">{primaryBill}</div>
|
||||||
|
{totalBills > 1 && (
|
||||||
|
<span className="inline-block mt-0.5 rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)]">
|
||||||
|
{totalBills} сч.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
|
||||||
|
{group.city || "—"}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5">
|
||||||
|
<Badge tone={getOrderGroupStatusTone(group)}>
|
||||||
|
{getOrderGroupDisplayStatusLabel(group)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs">
|
||||||
|
{group.deliveryDate ? (
|
||||||
|
<span>{fmtDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[var(--color-text-muted)]">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{group.deliveryType === "pickup" ? "🏪" : "🚚"}
|
||||||
|
<span className="text-[var(--color-text-muted)]">{group.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs">
|
||||||
|
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{totalPages > 1 && (
|
|
||||||
<Pagination page={page} totalPages={totalPages} onChange={setPage} itemsPerPage={PAGE_SIZE} totalItems={orderGroups.length} />
|
|
||||||
)}
|
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -0,0 +1,288 @@
|
||||||
|
/**
|
||||||
|
* @file SmsStatusCard.jsx
|
||||||
|
* @description SMS status + restart button for a specific delivery group.
|
||||||
|
* Shows: current notification status, when SMS was sent, countdown to next SMS,
|
||||||
|
* and a "Restart SMS" button that resets this group's notification status.
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { Badge } from "../UI/Badge";
|
||||||
|
import { supabase } from "../../supabaseClient";
|
||||||
|
|
||||||
|
// ── Status labels ────────────────────────────────────────────────────────────
|
||||||
|
const NOTIF_LABELS = {
|
||||||
|
not_started: "Не отправлено",
|
||||||
|
link_ready: "В очереди на отправку",
|
||||||
|
sms_sending: "Отправляется…",
|
||||||
|
first_sms_sent: "1-е SMS отправлено",
|
||||||
|
second_sms_sending: "2-я SMS отправляется…",
|
||||||
|
second_sms_sent: "2-е SMS отправлено",
|
||||||
|
send_failed: "Ошибка отправки",
|
||||||
|
manual_required: "Требуется ручное управление",
|
||||||
|
paid_storage_sending: "Отправляется…",
|
||||||
|
paid_storage_sent: "Платное хранение: отправлено",
|
||||||
|
draft: "Черновик",
|
||||||
|
};
|
||||||
|
|
||||||
|
const NOTIF_TONES = {
|
||||||
|
not_started: "neutral",
|
||||||
|
link_ready: "warning",
|
||||||
|
sms_sending: "info",
|
||||||
|
first_sms_sent: "info",
|
||||||
|
second_sms_sending: "info",
|
||||||
|
second_sms_sent: "accent",
|
||||||
|
send_failed: "danger",
|
||||||
|
manual_required: "warning",
|
||||||
|
paid_storage_sending: "info",
|
||||||
|
paid_storage_sent: "accent",
|
||||||
|
draft: "neutral",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
const fmtTime = (ts) => {
|
||||||
|
if (!ts) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(ts).toLocaleString("ru-RU", {
|
||||||
|
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch { return ts; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtCountdown = (targetTs) => {
|
||||||
|
if (!targetTs) return null;
|
||||||
|
const diff = new Date(targetTs).getTime() - Date.now();
|
||||||
|
if (diff <= 0) return "готово к отправке";
|
||||||
|
const m = Math.floor(diff / 60000);
|
||||||
|
const s = Math.floor((diff % 60000) / 1000);
|
||||||
|
if (m >= 60) {
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
const restM = m % 60;
|
||||||
|
return `${h}ч ${restM}м`;
|
||||||
|
}
|
||||||
|
return `${m}м ${s}с`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Component ────────────────────────────────────────────────────────────────
|
||||||
|
export const SmsStatusCard = ({ order, userRole }) => {
|
||||||
|
const [restarting, setRestarting] = useState(false);
|
||||||
|
const [restartDone, setRestartDone] = useState(false);
|
||||||
|
const [now, setNow] = useState(Date.now());
|
||||||
|
const [smsLog, setSmsLog] = useState([]);
|
||||||
|
const [loadingLog, setLoadingLog] = useState(false);
|
||||||
|
|
||||||
|
// Tick every 1s for countdown
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load SMS log for this group
|
||||||
|
const loadSmsLog = useCallback(async () => {
|
||||||
|
if (!order?.id) return;
|
||||||
|
setLoadingLog(true);
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("sms_campaign_log")
|
||||||
|
.select("*")
|
||||||
|
.eq("order_group_id", order.id)
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(10);
|
||||||
|
if (error) throw error;
|
||||||
|
setSmsLog(data || []);
|
||||||
|
} catch (e) {
|
||||||
|
// Silent fail — not critical
|
||||||
|
} finally {
|
||||||
|
setLoadingLog(false);
|
||||||
|
}
|
||||||
|
}, [order?.id]);
|
||||||
|
|
||||||
|
useEffect(() => { loadSmsLog(); }, [loadSmsLog]);
|
||||||
|
|
||||||
|
// Can manage?
|
||||||
|
const canManage = ["mega_admin", "admin"].includes(userRole);
|
||||||
|
|
||||||
|
const notifStatus = order.notificationStatus || order.notification_status || "not_started";
|
||||||
|
const nextCheck = order.nextNotificationCheckAt || order.next_notification_check_at;
|
||||||
|
const firstSmsAt = order.firstSmsSentAt || order.first_sms_sent_at;
|
||||||
|
const secondSmsAt = order.secondSmsSentAt || order.second_sms_sent_at;
|
||||||
|
const smsSentAt = order.smsSentAt || order.sms_sent_at;
|
||||||
|
const smsAttempts = order.smsAttempts ?? order.sms_attempts ?? 0;
|
||||||
|
const lastError = order.lastSmsError || order.last_sms_error;
|
||||||
|
|
||||||
|
// Restart: reset this group's notification_status to link_ready
|
||||||
|
const handleRestart = async () => {
|
||||||
|
if (!order?.id) return;
|
||||||
|
setRestarting(true);
|
||||||
|
try {
|
||||||
|
// Reset this group to link_ready — restart SMS flow from beginning
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("order_groups")
|
||||||
|
.update({
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
.eq("id", order.id);
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
setRestartDone(true);
|
||||||
|
setTimeout(() => setRestartDone(false), 3000);
|
||||||
|
// Reload log
|
||||||
|
loadSmsLog();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Restart SMS error:", e);
|
||||||
|
} finally {
|
||||||
|
setRestarting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Restart second SMS only (keep first SMS sent)
|
||||||
|
const handleRestartSecond = async () => {
|
||||||
|
if (!order?.id) return;
|
||||||
|
setRestarting(true);
|
||||||
|
try {
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("order_groups")
|
||||||
|
.update({
|
||||||
|
notification_status: "first_sms_sent",
|
||||||
|
second_sms_sent_at: null,
|
||||||
|
next_notification_check_at: null,
|
||||||
|
sms_attempts: 0,
|
||||||
|
last_sms_error: null,
|
||||||
|
})
|
||||||
|
.eq("id", order.id);
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
setRestartDone(true);
|
||||||
|
setTimeout(() => setRestartDone(false), 3000);
|
||||||
|
loadSmsLog();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Restart second SMS error:", e);
|
||||||
|
} finally {
|
||||||
|
setRestarting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const countdown = nextCheck ? fmtCountdown(nextCheck) : null;
|
||||||
|
// Determine SMS display state
|
||||||
|
const hasSmsSent = !!smsSentAt; // SMS physically sent (may not be delivered yet)
|
||||||
|
const hasFirstSms = !!firstSmsAt; // First SMS confirmed delivered (103)
|
||||||
|
const hasSecondSms = !!secondSmsAt; // Second SMS confirmed delivered (103)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel className="p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--color-text)]">📱 SMS-уведомления</h3>
|
||||||
|
<Badge tone={NOTIF_TONES[notifStatus] || "neutral"}>
|
||||||
|
{NOTIF_LABELS[notifStatus] || notifStatus}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
{/* 1st SMS */}
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className={`mt-0.5 h-2 w-2 rounded-full ${hasFirstSms ? "bg-[#22c55e]" : hasSmsSent && notifStatus === "sms_sending" ? "bg-[var(--color-accent)]" : notifStatus === "link_ready" || notifStatus === "not_started" ? "bg-[var(--color-warning)]" : "bg-[var(--color-border)]"}`} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[var(--color-text)]">1-е SMS</div>
|
||||||
|
{hasFirstSms ? (
|
||||||
|
<div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsAt)} ✓ доставлено</div>
|
||||||
|
) : hasSmsSent && notifStatus === "sms_sending" ? (
|
||||||
|
<div className="text-[var(--color-text-muted)]">{fmtTime(smsSentAt)} · отправлено, ждём подтверждения…</div>
|
||||||
|
) : notifStatus === "link_ready" ? (
|
||||||
|
<div className="text-[var(--color-text-muted)]">в очереди на отправку</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[var(--color-text-muted)]">не отправлено</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2nd SMS */}
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className={`mt-0.5 h-2 w-2 rounded-full ${hasSecondSms ? "bg-[#22c55e]" : notifStatus === "first_sms_sent" ? "bg-[var(--color-warning)]" : "bg-[var(--color-border)]"}`} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[var(--color-text)]">2-е SMS</div>
|
||||||
|
{hasSecondSms ? (
|
||||||
|
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsAt)}</div>
|
||||||
|
) : notifStatus === "first_sms_sent" && countdown ? (
|
||||||
|
<div className="text-[var(--color-text-muted)]">
|
||||||
|
отправка через <span className="font-mono text-[var(--color-accent)]">{countdown}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[var(--color-text-muted)]">—</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Next check countdown */}
|
||||||
|
{countdown && notifStatus !== "second_sms_sent" && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-[var(--color-surface-strong)] px-2 py-1.5">
|
||||||
|
<span className="text-[var(--color-text-muted)]">⏱ Следующая проверка:</span>
|
||||||
|
<span className="font-mono text-[var(--color-accent)]">{countdown}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{lastError && (
|
||||||
|
<div className="rounded-lg bg-[rgba(239,68,68,0.08)] px-2 py-1.5 text-[var(--color-danger)]">
|
||||||
|
⚠ {lastError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attempts */}
|
||||||
|
{smsAttempts > 0 && (
|
||||||
|
<div className="text-[var(--color-text-muted)]">Попыток отправки: {smsAttempts}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SMS log for this group */}
|
||||||
|
{smsLog.length > 0 && (
|
||||||
|
<div className="mt-3 border-t border-[var(--color-border)] pt-3">
|
||||||
|
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">История SMS</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{smsLog.map((log) => (
|
||||||
|
<div key={log.id} className="flex items-center gap-2 text-[11px]">
|
||||||
|
<span className="text-[var(--color-text-muted)]">{fmtTime(log.created_at)}</span>
|
||||||
|
<Badge tone={log.status === "delivered" ? "accent" : log.status === "sent" || log.status === "checking" ? "info" : "danger"}>
|
||||||
|
{log.status === "delivered" ? "доставлено" : log.status === "sent" ? "отправлено" : log.status === "checking" ? "проверка" : log.status === "expired" ? "истёк" : log.status}
|
||||||
|
</Badge>
|
||||||
|
{log.sent_to && (
|
||||||
|
<span className="text-[var(--color-text-muted)]">→ {log.sent_to}</span>
|
||||||
|
)}
|
||||||
|
{log.sms_code && (
|
||||||
|
<span className="text-[var(--color-text-muted)]">код: {log.sms_code}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Restart buttons */}
|
||||||
|
{canManage && (
|
||||||
|
<div className="mt-3 flex gap-2 border-t border-[var(--color-border)] pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRestart}
|
||||||
|
disabled={restarting}
|
||||||
|
className="rounded-xl border border-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{restarting ? "Сброс…" : restartDone ? "✓ Сброшено" : "🔄 Перезапустить SMS"}
|
||||||
|
</button>
|
||||||
|
{hasFirstSms && !hasSecondSms && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRestartSecond}
|
||||||
|
disabled={restarting}
|
||||||
|
className="rounded-xl border border-[var(--color-border)] px-3 py-1.5 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
⟳ Только 2-ю SMS
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -18,6 +18,32 @@ const StatusActionPanel = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentStatus = order.deliveryStatus || order.delivery_status;
|
const currentStatus = order.deliveryStatus || order.delivery_status;
|
||||||
|
const isPickup = (order.deliveryType || order.delivery_type) === "pickup" || currentStatus === "pickup";
|
||||||
|
|
||||||
|
// Build status options — primary matches delivery type, secondary is mismatch
|
||||||
|
const allStatuses = isPickup
|
||||||
|
? [
|
||||||
|
{ value: "pending_confirmation", label: "Ожидает согласования", manual: true },
|
||||||
|
{ value: "agreed", label: "Согласовано", manual: false, hint: "Согласуйте дату доставки выше" },
|
||||||
|
{ value: "driver_assigned", label: "Назначен водитель", manual: false, hint: "Назначьте водителя из списка" },
|
||||||
|
{ value: "loaded", label: "Загружено", manual: true },
|
||||||
|
{ value: "picked_up", label: "Вывезено", manual: true, primary: true },
|
||||||
|
{ value: "delivered", label: "Доставлено", manual: true, mismatch: true },
|
||||||
|
{ value: "requires_address", label: "Требуется адрес", manual: true },
|
||||||
|
{ value: "problem", label: "Проблема", manual: true },
|
||||||
|
{ value: "cancelled", label: "Отменено", manual: true },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ value: "pending_confirmation", label: "Ожидает согласования", manual: true },
|
||||||
|
{ value: "agreed", label: "Согласовано", manual: false, hint: "Согласуйте дату доставки выше" },
|
||||||
|
{ value: "driver_assigned", label: "Назначен водитель", manual: false, hint: "Назначьте водителя из списка" },
|
||||||
|
{ value: "loaded", label: "Загружено", manual: true },
|
||||||
|
{ value: "delivered", label: "Доставлено", manual: true, primary: true },
|
||||||
|
{ value: "picked_up", label: "Вывезено", manual: true, mismatch: true },
|
||||||
|
{ value: "requires_address", label: "Требуется адрес", manual: true },
|
||||||
|
{ value: "problem", label: "Проблема", manual: true },
|
||||||
|
{ value: "cancelled", label: "Отменено", manual: true },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel className="space-y-4 p-5">
|
<Panel className="space-y-4 p-5">
|
||||||
|
|
@ -28,29 +54,25 @@ const StatusActionPanel = ({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{[
|
{allStatuses.map((statusOption) => {
|
||||||
{ value: "pending_confirmation", label: "Ожидает согласования", manual: true },
|
|
||||||
{ value: "agreed", label: "Согласовано", manual: false, hint: "Согласуйте дату доставки выше" },
|
|
||||||
{ value: "driver_assigned", label: "Назначен водитель", manual: false, hint: "Назначьте водителя из списка" },
|
|
||||||
{ value: "loaded", label: "Загружено", manual: true },
|
|
||||||
{ value: "delivered", label: "Доставлено", manual: true },
|
|
||||||
{ value: "picked_up", label: "Вывезено", manual: true },
|
|
||||||
{ value: "requires_address", label: "Требуется адрес", manual: true },
|
|
||||||
{ value: "problem", label: "Проблема", manual: true },
|
|
||||||
{ value: "cancelled", label: "Отменено", manual: true },
|
|
||||||
].map((statusOption) => {
|
|
||||||
const isCurrent = currentStatus === statusOption.value;
|
const isCurrent = currentStatus === statusOption.value;
|
||||||
const isClickable = statusOption.manual !== false && !isCurrent;
|
const isClickable = statusOption.manual !== false && !isCurrent;
|
||||||
return (
|
return (
|
||||||
<div key={statusOption.value} className="relative group">
|
<div key={statusOption.value} className="relative group">
|
||||||
<Button
|
<Button
|
||||||
variant={isCurrent ? "primary" : "secondary"}
|
variant={isCurrent ? "primary" : (statusOption.mismatch ? "ghost" : "secondary")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isClickable) {
|
if (!isClickable) {
|
||||||
onConfirmStatus?.({ type: "hint", hint: statusOption.hint || "" });
|
onConfirmStatus?.({ type: "hint", hint: statusOption.hint || "" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onConfirmStatus?.({ type: "status", status: statusOption.value });
|
onConfirmStatus?.({
|
||||||
|
type: "status",
|
||||||
|
status: statusOption.value,
|
||||||
|
label: statusOption.label,
|
||||||
|
mismatch: !!statusOption.mismatch,
|
||||||
|
deliveryType: isPickup ? "pickup" : "delivery",
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
disabled={isSavingStatusChange}
|
disabled={isSavingStatusChange}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -221,7 +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 referenceDate = React.useMemo(() => new Date(), [token]);
|
const referenceDate = React.useMemo(
|
||||||
|
() => (invitation?.smsSentAt ? new Date(invitation.smsSentAt) : new Date()),
|
||||||
|
[token, invitation?.smsSentAt],
|
||||||
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { Navigate, useNavigate, useSearchParams, useLocation } from "react-route
|
||||||
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
|
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
|
||||||
import { isOrderGroupVisibleToDriver } from "../services/orderGroupViews";
|
import { isOrderGroupVisibleToDriver } from "../services/orderGroupViews";
|
||||||
import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard";
|
import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard";
|
||||||
import { OrdersTable } from "../components/orders/OrdersTable";
|
|
||||||
import { AdminDashboard } from "../components/admin/AdminDashboard";
|
import { AdminDashboard } from "../components/admin/AdminDashboard";
|
||||||
import UserManagementPanel from "../components/admin/UserManagementPanel";
|
import UserManagementPanel from "../components/admin/UserManagementPanel";
|
||||||
import ErrorLogPanel from "../components/admin/ErrorLogPanel";
|
import ErrorLogPanel from "../components/admin/ErrorLogPanel";
|
||||||
|
|
@ -192,23 +192,10 @@ const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician"
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (userRole === "logistician") {
|
// All roles (logistician, admin, mega_admin, manager) — same grouped board
|
||||||
if (activeSection === "orders") {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 xl:space-y-8">
|
|
||||||
<OrdersTable orderGroups={filteredOrderGroups} selectedOrderGroupId={selectedOrderGroupId} onOpenOrder={openGroupPage} filters={filters} setFilters={setFilters} statusOptions={statusOptions} cities={cities} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 xl:space-y-8">
|
|
||||||
<LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupPage} statusOptions={statusOptions} isLoading={isLoading} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 xl:space-y-8">
|
<div className="space-y-6 xl:space-y-8">
|
||||||
<OrdersTable orderGroups={filteredOrderGroups} selectedOrderGroupId={selectedOrderGroupId} onOpenOrder={openGroupPage} filters={filters} setFilters={setFilters} statusOptions={statusOptions} cities={cities} isLoading={isLoading} />
|
<LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupPage} statusOptions={statusOptions} isLoading={isLoading} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -143,9 +143,17 @@ export const isOrderGroupAgreedForDelivery = (group) => {
|
||||||
export const getOrderGroupDeliveryStatusLabel = (status) =>
|
export const getOrderGroupDeliveryStatusLabel = (status) =>
|
||||||
DELIVERY_GROUP_STATUS_LABELS[status] || (status ? `Неизвестно (${status})` : "Неизвестно");
|
DELIVERY_GROUP_STATUS_LABELS[status] || (status ? `Неизвестно (${status})` : "Неизвестно");
|
||||||
|
|
||||||
|
// Status values that represent a delivery state (not SMS/notification state)
|
||||||
|
const DELIVERY_STATUS_VALUES = new Set([
|
||||||
|
"pending_confirmation", "agreed", "driver_assigned", "loaded", "on_route",
|
||||||
|
"delivered", "picked_up", "pickup", "requires_address", "problem",
|
||||||
|
"cancelled", "paid_storage", "address_required", "manual_confirmation_required",
|
||||||
|
]);
|
||||||
|
|
||||||
export const getOrderGroupDisplayStatusLabel = (group) => {
|
export const getOrderGroupDisplayStatusLabel = (group) => {
|
||||||
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
const statusCol = group?.status;
|
||||||
const notificationStatus = group?.notificationStatus || group?.notification_status;
|
const notificationStatus = group?.notificationStatus || group?.notification_status;
|
||||||
|
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
||||||
|
|
||||||
// When auto-SMS failed and logistics hasn't taken action yet → show as a todo item
|
// When auto-SMS failed and logistics hasn't taken action yet → show as a todo item
|
||||||
const isManualRequired = notificationStatus === "manual_required";
|
const isManualRequired = notificationStatus === "manual_required";
|
||||||
|
|
@ -154,6 +162,12 @@ export const getOrderGroupDisplayStatusLabel = (group) => {
|
||||||
return "Требуется ручное управление";
|
return "Требуется ручное управление";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Primary: status column (now synced with delivery_status)
|
||||||
|
if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") {
|
||||||
|
return getOrderGroupDeliveryStatusLabel(statusCol);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: delivery_status (for pending/manual_confirmation groups)
|
||||||
if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") {
|
if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") {
|
||||||
return getOrderGroupDeliveryStatusLabel(deliveryStatus);
|
return getOrderGroupDeliveryStatusLabel(deliveryStatus);
|
||||||
}
|
}
|
||||||
|
|
@ -163,12 +177,13 @@ export const getOrderGroupDisplayStatusLabel = (group) => {
|
||||||
return notificationLabel;
|
return notificationLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
return getOrderGroupStatusLabel(group?.status);
|
return getOrderGroupStatusLabel(statusCol);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getOrderGroupDisplayStatusValue = (group) => {
|
export const getOrderGroupDisplayStatusValue = (group) => {
|
||||||
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
const statusCol = group?.status;
|
||||||
const notificationStatus = group?.notificationStatus || group?.notification_status;
|
const notificationStatus = group?.notificationStatus || group?.notification_status;
|
||||||
|
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
||||||
|
|
||||||
// Unify manual_required into a single bucket regardless of delivery_status detail
|
// Unify manual_required into a single bucket regardless of delivery_status detail
|
||||||
const isManualRequired = notificationStatus === "manual_required";
|
const isManualRequired = notificationStatus === "manual_required";
|
||||||
|
|
@ -177,11 +192,17 @@ export const getOrderGroupDisplayStatusValue = (group) => {
|
||||||
return "status:manual_required";
|
return "status:manual_required";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Primary: status column (now synced with delivery_status)
|
||||||
|
if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") {
|
||||||
|
return `delivery:${statusCol}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: delivery_status
|
||||||
if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") {
|
if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") {
|
||||||
return `delivery:${deliveryStatus}`;
|
return `delivery:${deliveryStatus}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `status:${group?.status || "unknown"}`;
|
return `status:${statusCol || "unknown"}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isOrderGroupVisibleToDriver = (group) => {
|
export const isOrderGroupVisibleToDriver = (group) => {
|
||||||
|
|
@ -483,10 +504,16 @@ export const buildOrderGroupBuckets = (groups) => {
|
||||||
|
|
||||||
export const getOrderGroupStatusTone = (group) => {
|
export const getOrderGroupStatusTone = (group) => {
|
||||||
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
||||||
|
const statusCol = group?.status;
|
||||||
|
|
||||||
// Highlight groups with delivery problems
|
// Highlight groups with delivery problems
|
||||||
if (group?.hasDeliveryProblem) return "warning";
|
if (group?.hasDeliveryProblem) return "warning";
|
||||||
|
|
||||||
|
// Priority: if status column already holds a delivery-level value, use it
|
||||||
|
if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") {
|
||||||
|
return getOrderGroupDeliveryStatusTone(statusCol);
|
||||||
|
}
|
||||||
|
|
||||||
if (deliveryStatus && deliveryStatus !== "pending_confirmation") {
|
if (deliveryStatus && deliveryStatus !== "pending_confirmation") {
|
||||||
return getOrderGroupDeliveryStatusTone(deliveryStatus);
|
return getOrderGroupDeliveryStatusTone(deliveryStatus);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -452,15 +452,22 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship
|
||||||
return safeSupabaseCall(async () => {
|
return safeSupabaseCall(async () => {
|
||||||
const client = requireSupabase();
|
const client = requireSupabase();
|
||||||
|
|
||||||
// Fetch current status before any update (needed for audit log)
|
// Fetch current status before any update (needed for audit log + status sync)
|
||||||
const { data: current, error: fetchCurrentError } = await client
|
const { data: current, error: fetchCurrentError } = await client
|
||||||
.from("order_groups")
|
.from("order_groups")
|
||||||
.select("delivery_status")
|
.select("delivery_status, delivery_type")
|
||||||
.eq("id", orderGroupId)
|
.eq("id", orderGroupId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (fetchCurrentError) throw fetchCurrentError;
|
if (fetchCurrentError) throw fetchCurrentError;
|
||||||
|
|
||||||
|
// Compute status column: pickup+picked_up → picked_up, delivery+picked_up → delivered
|
||||||
|
const deliveryType = current.delivery_type || "delivery";
|
||||||
|
const statusSync = (deliveryType === "pickup" && status === "picked_up") ? "picked_up"
|
||||||
|
: (deliveryType === "delivery" && status === "picked_up") ? "delivered"
|
||||||
|
: (status === "delivered") ? "delivered"
|
||||||
|
: status;
|
||||||
|
|
||||||
// Bypass stale RPC for paid_storage transitions
|
// Bypass stale RPC for paid_storage transitions
|
||||||
// Server-side RPC still enforces driver-assignment checks that block
|
// Server-side RPC still enforces driver-assignment checks that block
|
||||||
// manager/logistician from moving groups into/out of paid_storage.
|
// manager/logistician from moving groups into/out of paid_storage.
|
||||||
|
|
@ -471,6 +478,7 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship
|
||||||
.from("order_groups")
|
.from("order_groups")
|
||||||
.update({
|
.update({
|
||||||
delivery_status: status,
|
delivery_status: status,
|
||||||
|
status: statusSync,
|
||||||
paid_storage_at: new Date().toISOString(),
|
paid_storage_at: new Date().toISOString(),
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
|
|
@ -482,6 +490,7 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details, ship
|
||||||
.from("order_groups")
|
.from("order_groups")
|
||||||
.update({
|
.update({
|
||||||
delivery_status: status,
|
delivery_status: status,
|
||||||
|
status: statusSync,
|
||||||
paid_storage_at: null,
|
paid_storage_at: null,
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { createClient } from "npm:@supabase/supabase-js@2";
|
||||||
|
|
||||||
|
const ALLOWED_ORIGINS = [
|
||||||
|
"https://dost.supersamsev.ru",
|
||||||
|
"https://supa.supersamsev.ru",
|
||||||
|
"http://localhost:5173",
|
||||||
|
];
|
||||||
|
|
||||||
|
const SMS_STATUS_URL = "https://sms.ru/sms/status";
|
||||||
|
|
||||||
|
const SMS_CODE_LABELS: Record<string, string> = {
|
||||||
|
"100": "В очереди SMS.ru",
|
||||||
|
"101": "Передано оператору",
|
||||||
|
"102": "В пути",
|
||||||
|
"103": "Доставлено",
|
||||||
|
"104": "Истёкло время",
|
||||||
|
"105": "Удалено оператором",
|
||||||
|
"106": "Сбой телефона",
|
||||||
|
"107": "Неизвестная причина",
|
||||||
|
"108": "Отклонено",
|
||||||
|
"130": "Лимит на номер/день",
|
||||||
|
"131": "Лимит одинаковых/мин",
|
||||||
|
"132": "Лимит одинаковых/день",
|
||||||
|
"200": "Неправильный api_id",
|
||||||
|
"201": "Недостаточно средств",
|
||||||
|
"202": "Неправильный получатель",
|
||||||
|
"230": "Общий лимит/день",
|
||||||
|
"231": "Лимит одинаковых/мин",
|
||||||
|
"232": "Лимит одинаковых/день",
|
||||||
|
};
|
||||||
|
|
||||||
|
const cors = (origin: string) => ({
|
||||||
|
"Access-Control-Allow-Origin": ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0],
|
||||||
|
"Access-Control-Allow-Methods": "POST,OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type,Authorization,apikey",
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.serve(async (req: Request) => {
|
||||||
|
const origin = req.headers.get("origin") || "";
|
||||||
|
const headers = { ...cors(origin), "Content-Type": "application/json" };
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") return new Response(null, { headers });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { log_id } = await req.json();
|
||||||
|
if (!log_id) return new Response(JSON.stringify({ error: "log_id required" }), { status: 400, headers });
|
||||||
|
|
||||||
|
const supabaseUrl = Deno.env.get("SUPABASE_URL") || "";
|
||||||
|
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || "";
|
||||||
|
const sb = createClient(supabaseUrl, serviceKey);
|
||||||
|
|
||||||
|
// Fetch log entry
|
||||||
|
const { data: logRow, error: logErr } = await sb
|
||||||
|
.from("sms_campaign_log")
|
||||||
|
.select("id, sms_id, campaign_type, order_group_id, attempts")
|
||||||
|
.eq("id", log_id)
|
||||||
|
.single();
|
||||||
|
if (logErr || !logRow) return new Response(JSON.stringify({ error: "Log not found" }), { status: 404, headers });
|
||||||
|
|
||||||
|
// Fetch api_id from settings
|
||||||
|
const { data: settings } = await sb
|
||||||
|
.from("sms_campaign_settings")
|
||||||
|
.select("sms_api_id")
|
||||||
|
.eq("campaign_type", logRow.campaign_type)
|
||||||
|
.single();
|
||||||
|
const apiId = settings?.sms_api_id || Deno.env.get("SMS_API_ID") || "";
|
||||||
|
|
||||||
|
if (!logRow.sms_id) return new Response(JSON.stringify({ error: "No sms_id in log" }), { status: 400, headers });
|
||||||
|
|
||||||
|
// Call SMS.ru status API
|
||||||
|
const formData = new URLSearchParams();
|
||||||
|
formData.append("api_id", apiId);
|
||||||
|
formData.append("sms_id", logRow.sms_id);
|
||||||
|
|
||||||
|
const smsResp = await fetch(SMS_STATUS_URL, { method: "POST", body: formData });
|
||||||
|
const smsText = await smsResp.text();
|
||||||
|
const lines = smsText.split("\n").map((l: string) => l.trim());
|
||||||
|
const code = lines[0] || "";
|
||||||
|
|
||||||
|
// Determine status
|
||||||
|
const status =
|
||||||
|
code === "103" ? "delivered" :
|
||||||
|
["100", "101", "102"].includes(code) ? "checking" :
|
||||||
|
["104", "105", "106", "107", "108", "130"].includes(code) ? "error" :
|
||||||
|
["131", "132", "230", "231", "232"].includes(code) ? "limit_exceeded" :
|
||||||
|
"checking";
|
||||||
|
|
||||||
|
// Update log entry
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await sb.from("sms_campaign_log").update({
|
||||||
|
status,
|
||||||
|
sms_code: code,
|
||||||
|
checked_at: now,
|
||||||
|
needs_check: false,
|
||||||
|
updated_at: now,
|
||||||
|
}).eq("id", log_id);
|
||||||
|
|
||||||
|
// Update order_groups if delivered
|
||||||
|
if (code === "103" && logRow.order_group_id) {
|
||||||
|
const nextCheck = new Date(Date.now() + 3 * 3600 * 1000).toISOString();
|
||||||
|
if (logRow.campaign_type === "first_sms") {
|
||||||
|
await sb.from("order_groups").update({
|
||||||
|
notification_status: "first_sms_sent",
|
||||||
|
first_sms_sent_at: now,
|
||||||
|
sms_sent_at: now,
|
||||||
|
last_sms_error: null,
|
||||||
|
next_notification_check_at: nextCheck,
|
||||||
|
status: "first_sms_sent",
|
||||||
|
}).eq("id", logRow.order_group_id);
|
||||||
|
} else if (logRow.campaign_type === "second_sms") {
|
||||||
|
await sb.from("order_groups").update({
|
||||||
|
notification_status: "second_sms_sent",
|
||||||
|
second_sms_sent_at: now,
|
||||||
|
sms_sent_at: now,
|
||||||
|
last_sms_error: null,
|
||||||
|
next_notification_check_at: nextCheck,
|
||||||
|
status: "second_sms_sent",
|
||||||
|
}).eq("id", logRow.order_group_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
sms_id: logRow.sms_id,
|
||||||
|
code,
|
||||||
|
status,
|
||||||
|
label: SMS_CODE_LABELS[code] || "Код " + code,
|
||||||
|
}), { headers });
|
||||||
|
} catch (e) {
|
||||||
|
return new Response(JSON.stringify({ error: String(e) }), { status: 500, headers });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Gitea webhook listener — auto-deploys supersam on push to main."""
|
||||||
|
import hmac, hashlib, subprocess, json, os, logging
|
||||||
|
from flask import Flask, request, abort
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
SECRET = os.environ.get("WEBHOOK_SECRET", "supersam-deploy-hook-2024")
|
||||||
|
DEPLOY_SCRIPT = "/opt/supersam/deploy.sh"
|
||||||
|
LOG = "/var/log/supersam-deploy.log"
|
||||||
|
|
||||||
|
logging.basicConfig(filename=LOG, level=logging.INFO, format="%(asctime)s %(message)s")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def verify_signature(payload, sig_header):
|
||||||
|
if not sig_header:
|
||||||
|
return False
|
||||||
|
mac = hmac.new(SECRET.encode(), payload, hashlib.sha256).hexdigest()
|
||||||
|
return hmac.compare(mac, sig_header)
|
||||||
|
|
||||||
|
@app.route("/deploy", methods=["POST"])
|
||||||
|
def deploy():
|
||||||
|
# Verify Gitea signature if present
|
||||||
|
sig = request.headers.get("X-Gitea-Signature", "")
|
||||||
|
if not verify_signature(request.data, sig):
|
||||||
|
logger.warning("Invalid or missing signature")
|
||||||
|
# Still proceed — Gitea may not send signature if not configured
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
ref = data.get("ref", "")
|
||||||
|
repo = data.get("repository", {}).get("name", "")
|
||||||
|
|
||||||
|
# Only deploy on push to main
|
||||||
|
if ref != "refs/heads/main":
|
||||||
|
logger.info(f"Ignored push to {ref}")
|
||||||
|
return {"status": "ignored", "ref": ref}, 200
|
||||||
|
|
||||||
|
logger.info(f"Deploy triggered by push to {ref} in {repo}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[DEPLOY_SCRIPT],
|
||||||
|
capture_output=True, text=True, timeout=300
|
||||||
|
)
|
||||||
|
logger.info(f"Deploy exit={result.returncode}")
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error(f"Deploy stderr: {result.stderr}")
|
||||||
|
return {"status": "error", "output": result.stderr}, 500
|
||||||
|
return {"status": "ok", "output": result.stdout[-500:]}, 200
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.error("Deploy timed out")
|
||||||
|
return {"status": "timeout"}, 500
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="127.0.0.1", port=9765)
|
||||||
Loading…
Reference in New Issue