From 484f9efcba0127cae3ffc4ef1df7e414308e8f81 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 21 Jul 2026 19:55:51 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20SMS=20city=20profiles=20=E2=80=94=20?= =?UTF-8?q?=D0=B3=D0=B8=D0=B1=D0=BA=D0=B0=D1=8F=20=D0=BA=D0=BE=D0=BD=D1=84?= =?UTF-8?q?=D0=B8=D0=B3=D1=83=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20SMS=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=B3=D0=BE=D1=80=D0=BE=D0=B4=D0=B0=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB: sms_city_profiles (delivery_mode, pickup_mode, second_sms_enabled, paid_storage_enabled, sms_enabled) - Python first_sms: link mode (согласование) vs notification mode (без ссылки, без 2-й SMS) - Python second_sms: проверка second_sms_enabled из профиля - Python paid_storage: проверка paid_storage_enabled из профиля - Frontend: SmsCityProfilesPanel — выбор режима, кастомный текст, toggle'ы - Дефолты: Севастополь=link, Ялта=notification без ссылки - Замена sms_city_rules на sms_city_profiles --- scripts/sms_first_campaign.py | 75 +++- scripts/sms_paid_storage_campaign.py | 3 + scripts/sms_second_campaign.py | 4 +- src/components/admin/SmsCampaignPanel.jsx | 6 +- src/components/admin/SmsCityProfilesPanel.jsx | 416 ++++++++++++++++++ 5 files changed, 486 insertions(+), 18 deletions(-) create mode 100644 src/components/admin/SmsCityProfilesPanel.jsx diff --git a/scripts/sms_first_campaign.py b/scripts/sms_first_campaign.py index f495ace..13cd80f 100644 --- a/scripts/sms_first_campaign.py +++ b/scripts/sms_first_campaign.py @@ -173,19 +173,44 @@ def send_telegram(message, chat_id): # ─── State Machine ─────────────────────────────────────────────────────────── def get_groups_to_send(conn): - """Группы, которым ещё не отправили SMS (или отправили очень давно)""" + """Группы, которым ещё не отправили SMS. + + Режимы (из sms_city_profiles): + - link: отправка ссылки на согласование (старый флоу) + - notification: уведомление без ссылки (для доставки/самовывоза без согласования) + """ 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.customer_phone_normalized, og.delivery_link, og.notification_status, + og.delivery_type, og.town, + -- Профиль города (fallback на дефолтные значения если нет записи) + COALESCE(scp.delivery_mode, 'link') as delivery_mode, + COALESCE(scp.delivery_text, '') as delivery_text, + COALESCE(scp.pickup_mode, 'link') as pickup_mode, + COALESCE(scp.pickup_text, '') as pickup_text, + COALESCE(scp.sms_enabled, true) as sms_enabled, + -- Выбор режима и текста по типу доставки + CASE + WHEN og.delivery_type = 'pickup' + THEN COALESCE(scp.pickup_mode, 'link') + ELSE + COALESCE(scp.delivery_mode, 'link') + END as sms_mode, + CASE + WHEN og.delivery_type = 'pickup' + THEN COALESCE(scp.pickup_text, '') + ELSE + COALESCE(scp.delivery_text, '') + END as sms_text_custom FROM order_groups og + LEFT JOIN sms_city_profiles scp ON scp.city = og.town WHERE og.status = 'ready_to_launch' AND og.delivery_status = 'pending_confirmation' - AND og.delivery_link IS NOT NULL - AND og.delivery_link != '' AND COALESCE(og.notification_status, '') = 'link_ready' - AND sms_allowed_for_city(og.town, 'first_sms') - -- Нет активной SMS в логе (sent/checking) за последние max_check_duration минут + -- Профиль sms_enabled (замена старого sms_allowed_for_city) + AND COALESCE(scp.sms_enabled, true) = true + -- Нет активной SMS в логе (sent/checking) за последние 2 часа AND NOT EXISTS ( SELECT 1 FROM sms_campaign_log scl WHERE scl.order_group_id = og.id @@ -293,6 +318,10 @@ def step_send_new(conn, settings): 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", "") + delivery_type = group.get("delivery_type") or "delivery" + town = group.get("town") or "—" + sms_mode = group.get("sms_mode") or "link" + custom_text = group.get("sms_text_custom") or "" # Проверяем: была ли уже SMS этой группе сегодня (защита от повторной отправки) with conn.cursor() as cur: @@ -307,7 +336,15 @@ def step_send_new(conn, settings): log.info(f"Group {group_id}: already has recent SMS in log, skipping") continue - sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}" + # Формируем текст SMS по режиму + if sms_mode == "notification": + # Уведомление без ссылки на согласование + sms_text = custom_text if custom_text else "Ваш заказ готов." + log.info(f"Group {group_id}: NOTIFICATION mode (town={town}, type={delivery_type}), no link") + else: + # Стандартный флоу — ссылка на согласование + sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}" + log.info(f"Group {group_id}: LINK mode (town={town}, type={delivery_type})") # ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый send_phone = phone @@ -329,13 +366,23 @@ def step_send_new(conn, settings): sms_code=code, attempts=1, ) - # ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status, - # чтобы get_groups_to_send не нашёл эту группу при следующем запуске - update_order_group(conn, group_id, { - "notification_status": "sms_sending", - "sms_sent_at": "NOW()", - }) - log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}, log_id={log_id}, notification_status→sms_sending") + # ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status + if sms_mode == "notification": + # Notification: нет 2-й SMS, сразу first_sms_sent + update_order_group(conn, group_id, { + "notification_status": "first_sms_sent", + "sms_sent_at": "NOW()", + "first_sms_sent_at": "NOW()", + "status": "first_sms_sent", + }) + log.info(f"Group {group_id}: NOTIFICATION SMS sent, status→first_sms_sent (no second SMS)") + else: + # Link: ждём доставки SMS, потом 2-я SMS + update_order_group(conn, group_id, { + "notification_status": "sms_sending", + "sms_sent_at": "NOW()", + }) + log.info(f"Group {group_id}: LINK SMS sent, status→sms_sending") sent_count += 1 else: # Ошибка отправки diff --git a/scripts/sms_paid_storage_campaign.py b/scripts/sms_paid_storage_campaign.py index 4abb2e9..d15e15b 100644 --- a/scripts/sms_paid_storage_campaign.py +++ b/scripts/sms_paid_storage_campaign.py @@ -180,11 +180,14 @@ def get_groups_to_send(conn): og.customer_phone_normalized, og.delivery_link, og.notification_status, og.paid_storage_at FROM order_groups og + LEFT JOIN sms_city_profiles scp ON scp.city = og.town 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 != '' + -- Профиль: paid_storage_enabled (если false — SMS о платном хранении не отправляется) + AND COALESCE(scp.paid_storage_enabled, true) = true AND NOT EXISTS ( SELECT 1 FROM sms_campaign_log scl WHERE scl.order_group_id = og.id diff --git a/scripts/sms_second_campaign.py b/scripts/sms_second_campaign.py index e746318..da4bde4 100644 --- a/scripts/sms_second_campaign.py +++ b/scripts/sms_second_campaign.py @@ -200,13 +200,15 @@ def get_groups_to_send(conn): SELECT og.id, og.group_key, og.customer_name, og.customer_phone, og.customer_phone_normalized, og.delivery_link, og.notification_status FROM order_groups og + LEFT JOIN sms_city_profiles scp ON scp.city = og.town 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()) - AND sms_allowed_for_city(og.town, 'second_sms') + -- Профиль: second_sms_enabled (если false — 2-я SMS не отправляется) + AND COALESCE(scp.second_sms_enabled, true) = true -- Нет активной второй SMS в логе AND NOT EXISTS ( SELECT 1 FROM sms_campaign_log scl diff --git a/src/components/admin/SmsCampaignPanel.jsx b/src/components/admin/SmsCampaignPanel.jsx index e2ea79e..51e46b6 100644 --- a/src/components/admin/SmsCampaignPanel.jsx +++ b/src/components/admin/SmsCampaignPanel.jsx @@ -9,7 +9,7 @@ import { Panel } from "../UI/Panel"; import { Badge } from "../UI/Badge"; import { supabase } from "../../supabaseClient"; import { SmsCampaignStats } from "./SmsCampaignStats"; -import { SmsCityRulesPanel } from "./SmsCityRulesPanel"; +import { SmsCityProfilesPanel } from "./SmsCityProfilesPanel"; // ── Campaigns ─────────────────────────────────────────────────────────────── const CAMPAIGNS = [ @@ -423,8 +423,8 @@ export const SmsCampaignPanel = () => { - {/* ── City rules panel ───────────────────────────────────────────── */} - {showCityRules && } + {/* ── City profiles panel ────────────────────────────────────────── */} + {showCityRules && } {error && ( diff --git a/src/components/admin/SmsCityProfilesPanel.jsx b/src/components/admin/SmsCityProfilesPanel.jsx new file mode 100644 index 0000000..43d9671 --- /dev/null +++ b/src/components/admin/SmsCityProfilesPanel.jsx @@ -0,0 +1,416 @@ +/** + * @file SmsCityProfilesPanel.jsx + * @description Профили SMS по городам. + * Для каждого города настраивается: + * - Доставка: ссылка на согласование | уведомление без ссылки + кастомный текст + * - Самовывоз: ссылка на согласование | уведомление без ссылки + кастомный текст + * - 2-я SMS: вкл/выкл + * - Платное хранение: вкл/выкл + * - SMS вообще: вкл/выкл (мастер-выключатель) + */ +import React, { useState, useEffect, useCallback } from "react"; +import { Panel } from "../UI/Panel"; +import { supabase } from "../../supabaseClient"; +import { CRIMEAN_CITIES } from "../../constants/cities.js"; + +export const SmsCityProfilesPanel = () => { + const [profiles, setProfiles] = useState([]); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(""); + + // Form state + const [formCity, setFormCity] = useState("Ялта"); + const [formDeliveryMode, setFormDeliveryMode] = useState("notification"); + const [formDeliveryText, setFormDeliveryText] = useState(""); + const [formPickupMode, setFormPickupMode] = useState("notification"); + const [formPickupText, setFormPickupText] = useState(""); + const [formSecondSms, setFormSecondSms] = useState(true); + const [formPaidStorage, setFormPaidStorage] = useState(true); + const [formSmsEnabled, setFormSmsEnabled] = useState(true); + const [formNote, setFormNote] = useState(""); + + // Edit state + const [editingId, setEditingId] = useState(null); + + const fetchProfiles = useCallback(async () => { + setLoading(true); + try { + const { data, error } = await supabase + .from("sms_city_profiles") + .select("*") + .order("city", { ascending: true }); + if (error) throw error; + setProfiles(data || []); + } catch (e) { + console.error("fetch sms_city_profiles error", e); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { fetchProfiles(); }, [fetchProfiles]); + + const resetForm = () => { + setFormCity("Ялта"); + setFormDeliveryMode("notification"); + setFormDeliveryText(""); + setFormPickupMode("notification"); + setFormPickupText(""); + setFormSecondSms(true); + setFormPaidStorage(true); + setFormSmsEnabled(true); + setFormNote(""); + setEditingId(null); + }; + + const handleSave = async () => { + setSaving(true); + try { + const payload = { + city: formCity, + delivery_mode: formDeliveryMode, + delivery_text: formDeliveryText || null, + pickup_mode: formPickupMode, + pickup_text: formPickupText || null, + second_sms_enabled: formSecondSms, + paid_storage_enabled: formPaidStorage, + sms_enabled: formSmsEnabled, + note: formNote || "", + }; + if (editingId) { + const { error } = await supabase + .from("sms_city_profiles") + .update(payload) + .eq("id", editingId); + if (error) throw error; + setMessage("✅ Профиль обновлён"); + } else { + const { error } = await supabase + .from("sms_city_profiles") + .insert(payload); + if (error) throw error; + setMessage("✅ Профиль добавлен"); + } + setShowForm(false); + resetForm(); + fetchProfiles(); + setTimeout(() => setMessage(""), 3000); + } catch (e) { + setMessage("❌ Ошибка: " + e.message); + } finally { + setSaving(false); + } + }; + + const handleEdit = (profile) => { + setEditingId(profile.id); + setFormCity(profile.city); + setFormDeliveryMode(profile.delivery_mode || "link"); + setFormDeliveryText(profile.delivery_text || ""); + setFormPickupMode(profile.pickup_mode || "link"); + setFormPickupText(profile.pickup_text || ""); + setFormSecondSms(profile.second_sms_enabled ?? true); + setFormPaidStorage(profile.paid_storage_enabled ?? true); + setFormSmsEnabled(profile.sms_enabled ?? true); + setFormNote(profile.note || ""); + setShowForm(true); + }; + + const handleDelete = async (id) => { + try { + const { error } = await supabase + .from("sms_city_profiles") + .delete() + .eq("id", id); + if (error) throw error; + fetchProfiles(); + } catch (e) { + console.error("delete error", e); + } + }; + + const handleQuickToggle = async (profile, field) => { + try { + const { error } = await supabase + .from("sms_city_profiles") + .update({ [field]: !profile[field] }) + .eq("id", profile.id); + if (error) throw error; + fetchProfiles(); + } catch (e) { + console.error("toggle error", e); + } + }; + + // ── UI helpers ────────────────────────────────────────────────────────── + const ModeBadge = ({ mode }) => { + if (mode === "notification") { + return Уведомление; + } + return Ссылка; + }; + + const Toggle = ({ checked, onChange, label }) => ( +
+ + {label} +
+ ); + + const modeLabel = (mode) => mode === "notification" ? "Уведомление без ссылки" : "Согласование по ссылке"; + + return ( +
+ {/* Header */} + +
+
+ 🏙️ +
+

+ Профили SMS по городам +

+

+ Настройка поведения SMS для каждого города: согласование по ссылке или уведомление. +

+
+
+ +
+ {message && {message}} +
+ + {/* Add/Edit form */} + {showForm && ( + +

+ {editingId ? "Редактировать профиль" : "Новый профиль города"} +

+ + {/* City */} +
+ + +
+ + {/* SMS enabled master toggle */} +
+ setFormSmsEnabled(!formSmsEnabled)} label="SMS включены для этого города" /> + {!formSmsEnabled && ( +

⚠ Все SMS в этот город отключены

+ )} +
+ + {/* Delivery */} +
+
📦 Доставка
+
+ + +
+ {formDeliveryMode === "notification" && ( +