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:
root 2026-07-01 16:16:22 +00:00
parent 581239cc73
commit b78bdc48bb
23 changed files with 3000 additions and 413 deletions

3
.gitignore vendored
View File

@ -8,3 +8,6 @@ dist
.superpowers .superpowers
.ruff_cache .ruff_cache
volumes/db/data/ volumes/db/data/
__pycache__/
*.pyc
*.bak

56
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -1,8 +1,8 @@
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1"; const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
if (!isLocalhost) { if (!isLocalhost) {
const STATIC_CACHE = "construction-delivery-static-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) => {

View File

@ -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,6 +264,9 @@ 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():
if v == "NOW()":
set_parts.append(f"{k} = NOW()")
else:
set_parts.append(f"{k} = %s") set_parts.append(f"{k} = %s")
values.append(v) values.append(v)
values.append(log_id) values.append(log_id)
@ -253,15 +287,45 @@ 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)
if test_send:
# Test mode: send ONE SMS to test phone, pick first available group
all_groups = get_groups_to_send(conn)
if not all_groups:
log.info("Test send: no groups available in queue")
return 0
groups = [all_groups[0]] # Only first group
log.info(f"TEST SEND: sending 1 SMS to test phone (skipping {len(all_groups)-1} others)")
else:
groups = get_groups_to_send(conn) groups = get_groups_to_send(conn)
log.info(f"Step 1: {len(groups)} groups to send SMS") log.info(f"Step 1: {len(groups)} groups to send SMS")
@ -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)

View File

@ -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()

View File

@ -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()

View File

@ -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()

84
scripts/sms_timer_manager.sh Executable file
View File

@ -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"

View File

@ -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>
);

View File

@ -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) => ({

View File

@ -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)]">

View File

@ -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,6 +268,8 @@ 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>
: group.updatedAt
? <span>{formatDate(group.updatedAt)}</span>
: <span className="text-[var(--color-text-muted)]"></span> : <span className="text-[var(--color-text-muted)]"></span>
} }
</div> </div>

View File

@ -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 = (() => {
if (isPickupOrder) {
const parts = [
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), formatDeliveryDateDisplay(order.deliveryDate),
order.deliveryTime || order.deliveryHalfDay, order.deliveryTime || order.deliveryHalfDay,
].filter((value) => value && value !== "Нет данных").join(" · "); ].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 {
// Primary button matches delivery type, secondary requires confirmation
if (isPickup) {
statusOptions = [ statusOptions = [
{ value: "delivered", label: "Доставлено" }, { value: "picked_up", label: "Вывезено", mismatch: false },
{ value: "picked_up", label: "Вывезено" }, { value: "delivered", label: "Доставлено", mismatch: true },
{ value: "problem", label: "Проблема" }, { 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);

View File

@ -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,183 +18,110 @@ 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 parts.join(" · ");
};
const renderOrderNumbers = (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 `${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 (
<>
{visible.join(", ")}
<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>
</>
);
};
export const OrdersTable = ({
orderGroups = [],
selectedOrderGroupId,
onOpenOrder,
filters,
setFilters,
statusOptions,
cities = [],
isLoading = false,
}) => {
if (isLoading) {
return <SkeletonTable rows={5} cols={5} />;
}
return ( return (
<div className="border-b border-[var(--color-border)] last:border-b-0"> <Panel className="p-0">
{/* Section header */} <div className="space-y-4 border-b border-[var(--color-border)] px-5 py-4">
<button <div className="flex items-start justify-between gap-4">
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> <div>
{groups.map((group) => { <h2 className="text-lg font-semibold">Группы доставки</h2>
<p className="text-sm text-[var(--color-text-muted)]">
Поиск по группе, клиенту, телефону и дате доставки.
</p>
</div>
<Badge tone="neutral">{orderGroups.length}</Badge>
</div>
{filters && setFilters ? (
<OrderFilters filters={filters} setFilters={setFilters} statusOptions={statusOptions} cities={cities} />
) : null}
</div>
<div className="space-y-3 p-4 md:hidden">
{!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>
) : null}
{orderGroups.map((group) => {
const hasProblem = group.hasDeliveryProblem; 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 MobileStatusSection = ({ statusKey, groups, isOpen, onToggle, selectedOrderGroupId, onOpenOrder }) => {
const label = getOrderGroupDisplayStatusLabel(groups[0]);
const tone = getOrderGroupStatusTone(groups[0]);
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");
return (
<div className="mb-3">
<button
type="button"
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 baseClass = "w-full rounded-[22px] border text-left transition";
const selectedClass = selectedOrderGroupId === group.id const selectedClass = selectedOrderGroupId === group.id
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]" ? "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 : hasProblem
? "border-[var(--color-danger)] bg-[rgba(201,61,61,0.1)]" ? "border-[var(--color-danger)] bg-[rgba(201,61,61,0.1)]"
: "border-[var(--color-border)] bg-[var(--color-surface-strong)]"; : "border-[var(--color-border)] bg-[var(--color-surface-strong)]";
@ -246,106 +170,7 @@ const MobileStatusSection = ({ statusKey, groups, isOpen, onToggle, selectedOrde
); );
})} })}
</div> </div>
)}
</div>
);
};
export const OrdersTable = ({
orderGroups = [],
selectedOrderGroupId,
onOpenOrder,
filters,
setFilters,
statusOptions,
cities = [],
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) {
return <SkeletonTable rows={5} cols={5} />;
}
return (
<Panel className="p-0">
<div className="space-y-4 border-b border-[var(--color-border)] px-5 py-4">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold">Группы доставки</h2>
<p className="text-sm text-[var(--color-text-muted)]">
Поиск по группе, клиенту, телефону и дате доставки.
</p>
</div>
<Badge tone="neutral">{orderGroups.length}</Badge>
</div>
{filters && setFilters ? (
<OrderFilters filters={filters} setFilters={setFilters} statusOptions={statusOptions} cities={cities} />
) : null}
</div>
{/* Mobile: collapsible sections */}
<div className="space-y-2 p-4 md:hidden">
{!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>
) : null}
{grouped.map(({ key, groups }) => (
<MobileStatusSection
key={key}
statusKey={key}
groups={groups}
isOpen={!effectiveCollapsed[key]}
onToggle={() => toggleSection(key)}
selectedOrderGroupId={selectedOrderGroupId}
onOpenOrder={onOpenOrder}
/>
))}
</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>
); );
}; };

View File

@ -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>
);
};

View File

@ -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}
> >

View File

@ -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;

View File

@ -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,25 +192,12 @@ 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 ( return (
<div className="space-y-6 xl:space-y-8"> <div className="space-y-6 xl:space-y-8">
<LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupPage} statusOptions={statusOptions} isLoading={isLoading} /> <LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupPage} statusOptions={statusOptions} isLoading={isLoading} />
</div> </div>
); );
}
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} isLoading={isLoading} />
</div>
);
}; };
// Layout // Layout

View File

@ -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);
} }

View File

@ -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(),
}) })

View File

@ -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 });
}
});

54
webhook-deploy.py Executable file
View File

@ -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)