feat(sms): текст SMS по типу отгрузки — свои шаблоны для доставки и самовывоза

Раньше текст был зашит в кампании и всегда говорил про доставку; настраиваемый
в админке sms_text_template не читался скриптами вообще. Сначала выяснилось,
что всем 909 pickup-группам уходило «Согласуйте дату доставки по ссылке».

- sms_campaign_settings.sms_text_template_pickup (новая колонка) + значения
  sms_text_template = «…дату доставки…», pickup = «…дату самовывоза…»
  (supabase/sms_text_templates_pickup.sql = миграция с фактическими текстами)
- sms_first_campaign / sms_second_campaign / sms_paid_storage_campaign:
  build_sms_text(group, settings) — шаблон выбирается по og.delivery_type,
  {link} подставляется ссылкой; приоритет профиль города (режим «Уведомление»)
  → шаблон кампании → дефолт скрипта
- вторая SMS и платное хранение тоже получили pickup-вариант (кампании были
  выключены, поэтому в текстах жил только «доставка»)
- SmsCampaignPanel: два поля ввода (📦 Доставка / 🏪 Самовывоз) + длины и
  предпросмотр с подставленной ссылкой + пояснение приоритета
- service-worker v117 (обязательный бамп при деплое фронта)
This commit is contained in:
Hermes 2026-09-28 10:50:32 +02:00
parent ece302d512
commit cf5bf31008
6 changed files with 188 additions and 27 deletions

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-v116"; const STATIC_CACHE = "construction-delivery-static-v117";
const RUNTIME_CACHE = "construction-delivery-runtime-v116"; const RUNTIME_CACHE = "construction-delivery-runtime-v117";
const APP_SHELL_URLS = ["/", "/client", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"]; const APP_SHELL_URLS = ["/", "/client", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
// Only these are treated as cacheable static assets. Everything else // Only these are treated as cacheable static assets. Everything else

View File

@ -64,6 +64,49 @@ DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"}
# Коды превышения лимитов (нельзя повторять отправку — заблокируют) # Коды превышения лимитов (нельзя повторять отправку — заблокируют)
LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"} LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"}
# ─── Тексты SMS ──────────────────────────────────────────────────────────────
# Шаблоны живут в админке (sms_campaign_settings):
# sms_text_template — текст для ДОСТАВКИ
# sms_text_template_pickup — текст для САМОВЫВОЗА
# {link} подставляется ссылкой согласования. Пустой шаблон → DEFAULT_TEXT_*.
DEFAULT_TEXT_DELIVERY = "Ваш заказ готов. Согласуйте дату доставки по ссылке: {link}"
DEFAULT_TEXT_PICKUP = "Ваш заказ готов. Согласуйте дату самовывоза по ссылке: {link}"
DEFAULT_TEXT_NOTIFICATION = "Ваш заказ готов."
def is_pickup_group(group):
return (group.get("delivery_type") or "delivery").strip().lower() == "pickup"
def build_sms_text(group, settings):
"""Текст SMS по типу отгрузки: самовывоз → pickup-шаблон, доставка → обычный.
Приоритет:
1. текст профиля города (режим «Уведомление», sms_city_profiles) — как было;
2. шаблон кампании из админки для этого типа отгрузки;
3. дефолт по типу отгрузки.
"""
link = group.get("delivery_link") or ""
mode = (group.get("sms_mode") or "link").strip().lower()
profile_text = (group.get("sms_text_custom") or "").strip()
pickup = is_pickup_group(group)
template = (settings.get(
"sms_text_template_pickup" if pickup else "sms_text_template"
) or "").strip()
if not template:
template = DEFAULT_TEXT_PICKUP if pickup else DEFAULT_TEXT_DELIVERY
if mode == "notification":
if profile_text:
return profile_text
# уведомление без ссылки: шаблон годится, только если в нём нет {link}
if template and "{link}" not in template:
return template
return DEFAULT_TEXT_NOTIFICATION
return template.replace("{link}", link).strip()
# ─── Логирование ───────────────────────────────────────────────────────────── # ─── Логирование ─────────────────────────────────────────────────────────────
logging.basicConfig( logging.basicConfig(
@ -369,15 +412,12 @@ 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 по режиму # Формируем текст SMS по типу отгрузки (доставка / самовывоз) и режиму
sms_text = build_sms_text(group, settings)
if sms_mode == "notification": if sms_mode == "notification":
# Уведомление без ссылки на согласование log.info(f"Group {group_id}: NOTIFICATION mode (town={town}, type={delivery_type}): {sms_text!r}")
sms_text = custom_text if custom_text else "Ваш заказ готов."
log.info(f"Group {group_id}: NOTIFICATION mode (town={town}, type={delivery_type}), no link")
else: else:
# Стандартный флоу — ссылка на согласование log.info(f"Group {group_id}: LINK mode (town={town}, type={delivery_type}): {sms_text!r}")
sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}"
log.info(f"Group {group_id}: LINK mode (town={town}, type={delivery_type})")
# ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый # ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый
send_phone = phone send_phone = phone

View File

@ -51,6 +51,33 @@ DELIVERED_CODE = "103"
DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"} DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"}
LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"} LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"}
# ─── Тексты SMS ──────────────────────────────────────────────────────────────
# Шаблоны живут в админке (sms_campaign_settings):
# sms_text_template — текст для ДОСТАВКИ
# sms_text_template_pickup — текст для САМОВЫВОЗА
# {link} подставляется ссылкой согласования. Пустой шаблон → DEFAULT_TEXT_*.
DEFAULT_TEXT_DELIVERY = (
"Ваш заказ переведён на платное хранение. Стоимость: 300 ₽/день. "
"Заберите заказ или согласуйте доставку: {link}"
)
DEFAULT_TEXT_PICKUP = (
"Ваш заказ переведён на платное хранение. Стоимость: 300 ₽/день. "
"Заберите заказ со склада или согласуйте дату самовывоза: {link}"
)
def build_sms_text(group, settings):
"""Текст SMS о платном хранении по типу отгрузки."""
link = group.get("delivery_link") or ""
pickup = (group.get("delivery_type") or "delivery").strip().lower() == "pickup"
template = (settings.get(
"sms_text_template_pickup" if pickup else "sms_text_template"
) or "").strip()
if not template:
template = DEFAULT_TEXT_PICKUP if pickup else DEFAULT_TEXT_DELIVERY
return template.replace("{link}", link).strip()
# ─── Логирование ───────────────────────────────────────────────────────────── # ─── Логирование ─────────────────────────────────────────────────────────────
logging.basicConfig( logging.basicConfig(
@ -195,7 +222,7 @@ def get_groups_to_send(conn):
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.paid_storage_at og.paid_storage_at, og.delivery_type, og.town
FROM order_groups og FROM order_groups og
LEFT JOIN sms_city_profiles scp ON scp.city = og.town LEFT JOIN sms_city_profiles scp ON scp.city = og.town
WHERE og.delivery_status = 'paid_storage' WHERE og.delivery_status = 'paid_storage'
@ -321,11 +348,8 @@ def step_send_new(conn, settings):
log.info(f"Group {group_id}: already has recent paid_storage SMS, skipping") log.info(f"Group {group_id}: already has recent paid_storage SMS, skipping")
continue continue
sms_text = ( sms_text = build_sms_text(group, settings)
f"Ваш заказ переведён на платное хранение. " log.info(f"Group {group_id}: paid_storage SMS ({group.get('delivery_type') or 'delivery'}): {sms_text!r}")
f"Стоимость: 300 ₽/день. "
f"Заберите заказ или согласуйте доставку: {delivery_link}"
)
send_phone = phone send_phone = phone
if settings.get("test_mode", True): if settings.get("test_mode", True):

View File

@ -63,6 +63,27 @@ DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"}
# Коды превышения лимитов (нельзя повторять отправку — заблокируют) # Коды превышения лимитов (нельзя повторять отправку — заблокируют)
LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"} LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"}
# ─── Тексты SMS ──────────────────────────────────────────────────────────────
# Шаблоны живут в админке (sms_campaign_settings):
# sms_text_template — текст для ДОСТАВКИ
# sms_text_template_pickup — текст для САМОВЫВОЗА
# {link} подставляется ссылкой согласования. Пустой шаблон → DEFAULT_TEXT_*.
DEFAULT_TEXT_DELIVERY = "Ваш заказ готов к доставке. Выберите дату доставки по ссылке: {link}"
DEFAULT_TEXT_PICKUP = "Ваш заказ готов к самовывозу. Выберите дату самовывоза по ссылке: {link}"
def build_sms_text(group, settings):
"""Текст второй SMS по типу отгрузки: самовывоз → pickup-шаблон, иначе обычный."""
link = group.get("delivery_link") or ""
pickup = (group.get("delivery_type") or "delivery").strip().lower() == "pickup"
template = (settings.get(
"sms_text_template_pickup" if pickup else "sms_text_template"
) or "").strip()
if not template:
template = DEFAULT_TEXT_PICKUP if pickup else DEFAULT_TEXT_DELIVERY
return template.replace("{link}", link).strip()
# ─── Логирование ───────────────────────────────────────────────────────────── # ─── Логирование ─────────────────────────────────────────────────────────────
logging.basicConfig( logging.basicConfig(
@ -215,7 +236,8 @@ def get_groups_to_send(conn, max_attempts):
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.delivery_type, og.town
FROM order_groups og FROM order_groups og
LEFT JOIN sms_city_profiles scp ON scp.city = og.town LEFT JOIN sms_city_profiles scp ON scp.city = og.town
WHERE og.delivery_status = 'pending_confirmation' WHERE og.delivery_status = 'pending_confirmation'
@ -401,7 +423,8 @@ def step_send_new(conn, settings):
log.info(f"Group {group_id}: already has recent second SMS in log, skipping") log.info(f"Group {group_id}: already has recent second SMS in log, skipping")
continue continue
sms_text = f"Ваш заказ готов к доставке. Выберите дату доставки по ссылке: {delivery_link}" sms_text = build_sms_text(group, settings)
log.info(f"Group {group_id}: second SMS ({group.get('delivery_type') or 'delivery'}), town={group.get('town') or '—'}: {sms_text!r}")
# ТЕСТОВЫЙ РЕЖИМ # ТЕСТОВЫЙ РЕЖИМ
send_phone = phone send_phone = phone

View File

@ -22,6 +22,15 @@ const CAMPAIGNS = [
const HAS_SETTINGS = ["first_sms", "second_sms", "manual", "paid_storage"]; const HAS_SETTINGS = ["first_sms", "second_sms", "manual", "paid_storage"];
// ── SMS text templates (доставка / самовывоз) ────────────────────────────────
const SMS_LINK_PREVIEW = "https://dost.supersamsev.ru/d/XXXXXX";
const renderSmsPreview = (template, emptyLabel) => {
const text = (template || "").trim();
if (!text) return emptyLabel;
return text.replace("{link}", SMS_LINK_PREVIEW);
};
// ── Days of week ───────────────────────────────────────────────────────────── // ── Days of week ─────────────────────────────────────────────────────────────
const DAYS = [ const DAYS = [
{ num: 1, short: "Пн" }, { num: 1, short: "Пн" },
@ -488,19 +497,43 @@ export const SmsCampaignPanel = () => {
</div> </div>
)} )}
{/* ── SMS text template ──────────────────────────────────────────── */} {/* ── SMS text templates: доставка / самовывоз ───────────────────── */}
{!isManualCampaign && ( {!isManualCampaign && (
<div className="mb-4 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3"> <div className="mb-4 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
<div className="mb-2 text-xs font-semibold text-[var(--color-text)]">📝 Текст SMS</div> <div className="mb-2 text-xs font-semibold text-[var(--color-text)]">📝 Текст SMS</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<div>
<div className="mb-1 text-[11px] font-semibold text-[var(--color-text-muted)]">📦 Доставка</div>
<textarea <textarea
value={settings.sms_text_template || ""} value={settings.sms_text_template || ""}
onChange={(e) => updateSetting("sms_text_template", e.target.value)} onChange={(e) => updateSetting("sms_text_template", e.target.value)}
rows={3} rows={3}
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none resize-y" className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none resize-y"
placeholder="Текст SMS. Используйте {link} для подстановки ссылки" placeholder="Ваш заказ готов. Согласуйте дату доставки по ссылке: {link}"
/> />
<div className="mt-1 text-[10px] text-[var(--color-text-muted)]"> <div className="mt-1 text-[10px] text-[var(--color-text-muted)]">
{"{link}"} будет заменён на ссылку доставки. Текущая длина: {(settings.sms_text_template || "").replace("{link}", "https://dost.supersamsev.ru/d/XXXXXX").length} символов Текущая длина: {(settings.sms_text_template || "").replace("{link}", SMS_LINK_PREVIEW).length} символов
</div>
</div>
<div>
<div className="mb-1 text-[11px] font-semibold text-[var(--color-text-muted)]">🏪 Самовывоз</div>
<textarea
value={settings.sms_text_template_pickup || ""}
onChange={(e) => updateSetting("sms_text_template_pickup", e.target.value)}
rows={3}
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none resize-y"
placeholder="Ваш заказ готов. Согласуйте дату самовывоза по ссылке: {link}"
/>
<div className="mt-1 text-[10px] text-[var(--color-text-muted)]">
Текущая длина: {(settings.sms_text_template_pickup || "").replace("{link}", SMS_LINK_PREVIEW).length} символов
</div>
</div>
</div>
<div className="mt-2 rounded-lg bg-[var(--color-surface-strong)] px-3 py-2 text-[10px] text-[var(--color-text-muted)]">
Текст выбирается по типу отгрузки: самовывоз → правый шаблон, доставка → левый. {"{link}"} заменяется ссылкой согласования.
<div className="mt-1">📦 Доставка: {renderSmsPreview(settings.sms_text_template, "— пусто, уйдёт стандартный текст кампании")}</div>
<div>🏪 Самовывоз: {renderSmsPreview(settings.sms_text_template_pickup, "— пусто, уйдёт стандартный текст кампании")}</div>
<div className="mt-1">Если у города в «SMS по городам» задан свой текст в режиме «Уведомление» — уходит он, и тогда шаблон применяется только если в нём нет {"{link}"}.</div>
</div> </div>
</div> </div>
)} )}

View File

@ -0,0 +1,41 @@
-- SMS-шаблоны по типу отгрузки: доставка / самовывоз
-- 2026-09-28
--
-- Раньше текст SMS был зашит в python-кампании и упоминал только доставку,
-- а редактируемый в админке sms_text_template не читался скриптами вообще.
--
-- Теперь:
-- sms_text_template — текст SMS для ДОСТАВКИ
-- sms_text_template_pickup — текст SMS для САМОВЫВОЗА
-- {link} подставляется ссылкой согласования; пустой шаблон → дефолт скрипта.
-- Приоритет: текст профиля города (режим «Уведомление», sms_city_profiles)
-- → шаблон кампании по типу отгрузки → дефолт.
ALTER TABLE sms_campaign_settings
ADD COLUMN IF NOT EXISTS sms_text_template_pickup text;
COMMENT ON COLUMN sms_campaign_settings.sms_text_template IS
'Текст SMS для доставки. {link} — ссылка согласования.';
COMMENT ON COLUMN sms_campaign_settings.sms_text_template_pickup IS
'Текст SMS для самовывоза. {link} — ссылка согласования.';
-- Значения = фактически отправляемые тексты на момент миграции
-- (до этого в first_sms/month лежал текст, который никуда не попадал)
UPDATE sms_campaign_settings SET
sms_text_template = 'Ваш заказ готов. Согласуйте дату доставки по ссылке: {link}',
sms_text_template_pickup = 'Ваш заказ готов. Согласуйте дату самовывоза по ссылке: {link}'
WHERE campaign_type = 'first_sms';
UPDATE sms_campaign_settings SET
sms_text_template = 'Ваш заказ готов к доставке. Выберите дату доставки по ссылке: {link}',
sms_text_template_pickup = 'Ваш заказ готов к самовывозу. Выберите дату самовывоза по ссылке: {link}'
WHERE campaign_type = 'second_sms';
UPDATE sms_campaign_settings SET
sms_text_template = 'Ваш заказ переведён на платное хранение. Стоимость: 300 ₽/день. Заберите заказ или согласуйте доставку: {link}',
sms_text_template_pickup = 'Ваш заказ переведён на платное хранение. Стоимость: 300 ₽/день. Заберите заказ со склада или согласуйте дату самовывоза: {link}'
WHERE campaign_type = 'paid_storage';
-- Проверка:
-- SELECT campaign_type, sms_text_template, sms_text_template_pickup
-- FROM sms_campaign_settings ORDER BY campaign_type;