diff --git a/ats-patch/apply.sh b/ats-patch/apply.sh new file mode 100644 index 0000000..800b19d --- /dev/null +++ b/ats-patch/apply.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# Применение правок автодозвона SuperSam на АТС. Идемпотентно, с проверкой. +set -e +cd /root/ats-patch + +echo "=== 1. extensions.conf: добавить контекст SS-Autocall-Slot ===" +if grep -q '^\[SS-Autocall-Slot\]' /etc/asterisk/extensions.conf; then + echo " уже есть, пропускаю" +else + cat ss-autocall-context.txt >> /etc/asterisk/extensions.conf + echo " добавлен" +fi + +echo "=== 2. modules.conf: автозагрузка pbx_spool + res_agi ===" +if grep -q '^load = pbx_spool.so' /etc/asterisk/modules.conf; then + echo " pbx_spool уже есть" +else + # вставить после 'load = pbx_config.so' в секции Core/PBX + sed -i '/^load = pbx_config.so/a load = pbx_spool.so\nload = res_agi.so' /etc/asterisk/modules.conf + echo " добавлены pbx_spool + res_agi" +fi + +echo "=== 3. ss-dialog.agi: вставить curl результата перед HANGUP ===" +if grep -q 'отправка результата в БД' /var/lib/asterisk/agi-bin/ss-dialog.agi; then + echo " curl уже есть" +else + # вставить блок перед строкой 'say "HANGUP"' + python3 - <<'PY' +import re +p = '/var/lib/asterisk/agi-bin/ss-dialog.agi' +src = open(p).read() +block = open('/root/ats-patch/ss-agi-curl.txt').read() +if 'say "HANGUP"' in src and 'отправка результата в БД' not in src: + src = src.replace('say "HANGUP"', block + '\nsay "HANGUP"', 1) + open(p, 'w').write(src) + print(' curl вставлен') +else: + print(' пропуск (HANGUP не найден или уже есть)') +PY +fi + +echo "=== 4. проверка синтаксиса ===" +asterisk -rx "dialplan reload" 2>&1 | tail -3 +asterisk -rx "module load pbx_spool.so" 2>&1 | tail -1 +asterisk -rx "module load res_agi.so" 2>&1 | tail -1 +echo "--- dialplan show SS-Autocall-Slot ---" +asterisk -rx "dialplan show SS-Autocall-Slot" 2>&1 | head -10 +echo "--- module show like pbx_spool ---" +asterisk -rx "module show like pbx_spool" 2>&1 | grep -i running +echo "--- module show like res_agi ---" +asterisk -rx "module show like res_agi" 2>&1 | grep -i running +echo "--- bash syntax ss-dialog.agi ---" +bash -n /var/lib/asterisk/agi-bin/ss-dialog.agi && echo " bash -n OK" +echo "=== DONE ===" diff --git a/ats-patch/ss-agi-curl.txt b/ats-patch/ss-agi-curl.txt new file mode 100644 index 0000000..9434ca8 --- /dev/null +++ b/ats-patch/ss-agi-curl.txt @@ -0,0 +1,27 @@ + +# ─── отправка результата в БД (edge function autocall-result) ─────── +# Переменные приходят из call-файла (Setvar). При потере сети звонок не +# валится: curl с таймаутом, результат всё равно остался в $OUT. +if [ -n "$API_URL" ] && [ -n "$API_KEY" ]; then + curl -s --max-time 5 -X POST "$API_URL" \ + -H "Content-Type: application/json" \ + -H "x-webhook-secret: $API_KEY" \ + -d "{ + \"asterisk_uniqueid\":\"$UNIQ\", + \"order_group_id\":\"${GROUP_ID:-}\", + \"campaign_type\":\"autocall_slot\", + \"customer_phone\":\"${CUSTOMER_PHONE:-}\", + \"attempt_no\":${ATTEMPT:-1}, + \"scenario\":\"$SCENARIO\", + \"call_started_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", + \"answered_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", + \"duration_sec\":0, + \"sip_code\":200, + \"channel_state\":\"Up\", + \"dtmf_day\":${CONF_DAY:-null}, + \"dtmf_half\":${CONF_HALF:-null}, + \"changed\":\"$CHANGED\", + \"outcome\":\"$OUTCOME\", + \"was_test_mode\":${TEST_MODE:-0} + }" >/dev/null 2>&1 || echo "curl result failed (network?)" >> "$LOG" +fi diff --git a/ats-patch/ss-autocall-context.txt b/ats-patch/ss-autocall-context.txt new file mode 100644 index 0000000..1ffccd7 --- /dev/null +++ b/ats-patch/ss-autocall-context.txt @@ -0,0 +1,13 @@ + +; ===== SS-Autocall-Slot (SuperSam автодозвон) — добавлено 2026-09-01 ===== +[SS-Autocall-Slot] +; Автодозвон SuperSam: робот (Vivienne) опрашивает клиента по DTMF. +; Вызывается из call-файла планировщика (Context: SS-Autocall-Slot). +; Переменные SCENARIO/CALLTYPE/GROUP_ID/ATTEMPT/API_URL/API_KEY/TEST_MODE +; приходят из call-файла через Setvar. +exten => s,1,NoOp(SS-Autocall-Slot: scenario=${SCENARIO} group=${GROUP_ID} attempt=${ATTEMPT}) + same => n,Answer() + same => n,Wait(1) + same => n,AGI(ss-dialog.agi,${SCENARIO},${CALLTYPE}) + same => n,Hangup() +; ===== конец SS-Autocall-Slot ===== diff --git a/ats-patch/ss-modules.txt b/ats-patch/ss-modules.txt new file mode 100644 index 0000000..03c3cad --- /dev/null +++ b/ats-patch/ss-modules.txt @@ -0,0 +1,6 @@ + +; ===== SuperSam автодозвон: автозагрузка модулей (добавлено 2026-09-01) ===== +; Без этого ночной reboot 03:33 сносит pbx_spool (call-файлы не подхватываются). +load = pbx_spool.so +load = res_agi.so +; ===== конец ===== diff --git a/no8-logic.js b/no8-logic.js new file mode 100644 index 0000000..98a00ee --- /dev/null +++ b/no8-logic.js @@ -0,0 +1,86 @@ +// n8n workflow: No8 — «Смена статуса на Доставлено (запрос из 1С)» +// Payload от 1С: { Value: "<секрет>", dost: "<номер заказа>" } +// Цель: найти order_group по номеру, delivery_status → delivered, лог-событие. + +const NO8_SECRET = "energy7066-stood-Sight-nation569"; + +// helper вызова Supabase REST +async function supa(method, path, body) { + return this.helpers.request({ + method, + url: `${"https://supa.supersamsev.ru"}/rest/v1${path}`, + headers: { + apikey: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc0ODc4Nzk4LCJleHAiOjE5MzI1NTg3OTh9.GAhB_V3js6KzpcBxoY0971-x6oR0WjYnj6wakvPPtIg", + Authorization: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc0ODc4Nzk4LCJleHAiOjE5MzI1NTg3OTh9.GAhB_V3js6KzpcBxoY0971-x6oR0WjYnj6wakvPPtIg", + "Content-Type": "application/json", + Prefer: "return=minimal", + }, + body: ["POST", "PATCH", "PUT"].includes(method) ? JSON.stringify(body) : undefined, + }); +} + +// === вход: $json.body из вебхука (уже пришёл из 1С) === +const body = $json.body || {}; + +// === check secret === +if (body.Value !== NO8_SECRET) { + return [{ json: { ok: false, error: "unauthorized" } },]; +} + +// === номер заказа === +const dostRaw = String(body.dost || "").replace(/\D/g, ""); +if (!dostRaw) { + return [{ json: { ok: false, error: "dost (номер заказа) не передан или не содержит цифр" } }]; +} +const candidates = [`ТП-${dostRaw}`, dostRaw]; + +console.log("No8: ищем заказ:", candidates.join(", ")); + +// === 1) найти order_group === +let orderGroup = null; +for (const num of candidates) { + const found = await supa.call(this, "GET", + `/order_groups?select=id,user_id,order_number,customer_phone,customer_phone_normalized,notification_status,delivery_type,delivery_status&order_number=eq.${encodeURIComponent(num)}&limit=1`); + if (Array.isArray(found) && found.length) { + orderGroup = found[0]; + orderGroup.order_number = num; + break; + } +} +if (!orderGroup) { + return [{ json: { ok: false, error: `Заказ ${body.dost} не найден (пробовали ${candidates.join(" / ")})` } }]; +} +console.log("No8: найден заказ:", orderGroup.order_number, "group.id:", orderGroup.id); + +// === 2) обновить статус delivery_status='delivered' + called_at === +const now = new Date().toISOString(); +await supa.call(this, "PATCH", `/order_groups?id=eq.${orderGroup.id}`, { + delivery_status: "delivered", + called_at: now, + updated_at: now, +}); +console.log("No8: выставлен delivered для group:", orderGroup.id); + +// === 3) событие логистики (action_log) === +try { + await supa.call(this, "POST", "/action_log", { + user_id: orderGroup.user_id || null, + order_group_id: orderGroup.id, + action_type: "delivered_by_robot", + comment: `Доставлено по сигналу No8 из 1С. Заказ ${orderGroup.order_number}`, + created_at: now, + }); + console.log("No8: событие записано в action_log"); +} catch (e) { + console.warn("No8: action_log не принял (может отсутствовать колонка):", e.message || e); +} + +// === выход === +return [{ + json: { + ok: true, + order_group_id: orderGroup.id, + order_number: orderGroup.order_number, + delivery_status: "delivered", + }, +}]; diff --git a/scripts/autocall-scheduler.service b/scripts/autocall-scheduler.service new file mode 100644 index 0000000..961d641 --- /dev/null +++ b/scripts/autocall-scheduler.service @@ -0,0 +1,10 @@ +[Unit] +Description=SuperSam Autocall Scheduler +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/opt/supersam/scripts/sms_run_with_db.sh /opt/supersam/scripts/autocall_scheduler.py +WorkingDirectory=/opt/supersam +TimeoutStartSec=120 diff --git a/scripts/autocall_scheduler.py b/scripts/autocall_scheduler.py index 157a5b6..8bbc84b 100644 --- a/scripts/autocall_scheduler.py +++ b/scripts/autocall_scheduler.py @@ -136,6 +136,23 @@ def normalize_phone(phone): clean = "7" + clean return clean +def get_exclusion_match(conn, phone): + """Возвращает правило исключения, если на этот номер звонить нельзя, иначе None.""" + phone = normalize_phone(phone) + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(""" + SELECT rule_type, value, reason + FROM autocall_exclusions + WHERE active = true + AND ( + (rule_type = 'phone' AND %s = value) + OR + (rule_type = 'prefix' AND %s LIKE value || '%%') + ) + LIMIT 1 + """, (phone, phone)) + return cur.fetchone() + def build_call_file(group, settings, attempt_no, is_test_call=False): """Формирует содержимое call-файла.""" phone = group.get("customer_phone_normalized") or group.get("customer_phone", "") @@ -249,6 +266,12 @@ def process_queue(conn, settings): if not phone: log.warning(f"Queue item {q['queue_id']} has no phone, skip") continue + # Исключение: на этот номер не звоним (например, МТС-диапазон) + excl = get_exclusion_match(conn, phone) + if excl: + log.info(f"Queue item {q['queue_id']} skipped by exclusion ({excl['rule_type']}={excl['value']}): {excl.get('reason') or 'no reason'}") + mark_queue_processed(conn, q["queue_id"]) + continue if group_id not in attempt_map: if group_id: with conn.cursor() as cur: @@ -305,6 +328,12 @@ def main(): pushed = 0 for group in groups: + phone = group.get("customer_phone_normalized") or group.get("customer_phone") or "" + # Исключение: на этот номер не звоним (например, МТС-диапазон) + excl = get_exclusion_match(conn, phone) + if excl: + log.info(f"Group {group['id']} skipped by exclusion ({excl['rule_type']}={excl['value']}): {excl.get('reason') or 'no reason'}") + continue attempt_no = (group.get("attempt_count") or 0) + 1 if push_call_file(group, settings, attempt_no): pushed += 1 diff --git a/src/components/UI/CallStatusBadge.jsx b/src/components/UI/CallStatusBadge.jsx index 9307ae9..d96b856 100644 --- a/src/components/UI/CallStatusBadge.jsx +++ b/src/components/UI/CallStatusBadge.jsx @@ -6,7 +6,7 @@ */ import React from "react"; -// Приоритет: отказ > неверный номер > менеджеру > не нажимает > не дозвонились > слот от робота +// Приоритет: отказ > исключение > неверный номер > менеджеру > не нажимает > не дозвонились > слот от робота export const resolveCallBadge = (g) => { if (!g) return null; @@ -15,7 +15,12 @@ export const resolveCallBadge = (g) => { return { label: "🚫 отказ от звонков", cls: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]", title: "Клиент отказался от автозвонков" }; } - // 2. Слот от робота (информационный, самый низкий приоритет — показываем если нет проблем) + // 2. Исключение по настройке (префикс/номер) — не звоним по условию + if (g.autocall_exclusion_reason) { + return { label: "🚫 не звоним (исключение)", cls: "bg-[rgba(191,123,33,0.15)] text-[var(--color-warning)]", title: `Исключение автодозвона: ${g.autocall_exclusion_reason}` }; + } + + // 3. Слот от робота (информационный, самый низкий приоритет — показываем если нет проблем) if (g.deliveryDateSource === "autocall") { return { label: "🤖 слот от робота", cls: "bg-[rgba(59,130,246,0.14)] text-[var(--color-accent)]", title: "Слот согласован роботом" }; } diff --git a/src/components/admin/autocall/AutocallChannelTab.jsx b/src/components/admin/autocall/AutocallChannelTab.jsx index 5e1df6e..f792a65 100644 --- a/src/components/admin/autocall/AutocallChannelTab.jsx +++ b/src/components/admin/autocall/AutocallChannelTab.jsx @@ -10,6 +10,7 @@ import { Badge } from "../../UI/Badge"; import { supabase } from "../../../supabaseClient"; import { useAuth } from "../../../context/AuthContext"; import { TestCallPanel } from "./TestCallPanel"; +import { AutocallExclusionsPanel } from "./AutocallExclusionsPanel"; // ── Кампании звонков ───────────────────────────────────────────────────────── const CAMPAIGNS = [ @@ -346,6 +347,9 @@ export const AutocallChannelTab = () => { {/* ── Тестовый звонок (только мегаадмин) ───────────────────────────── */} {isMegaAdmin && } + {/* ── Исключения автодозвона ─────────────────────────────────────── */} + + {/* ── Журнал звонков ─────────────────────────────────────────────── */}
diff --git a/src/components/admin/autocall/AutocallExclusionsPanel.jsx b/src/components/admin/autocall/AutocallExclusionsPanel.jsx new file mode 100644 index 0000000..aa7faee --- /dev/null +++ b/src/components/admin/autocall/AutocallExclusionsPanel.jsx @@ -0,0 +1,226 @@ +/** + * @file AutocallExclusionsPanel.jsx + * @description Панель «Исключения автодозвона» в настройках коммуникаций. + * Логист/админ добавляет префиксы или точные номера, на которые робот не звонит + * (например, МТС-диапазон, пока не починят DTMF). Правило можно отключить галочкой + * или удалить — и звонки по нему возобновятся. + */ +import React, { useState, useEffect, useCallback } from "react"; +import { Panel } from "../../UI/Panel"; +import { Badge } from "../../UI/Badge"; +import { supabase } from "../../../supabaseClient"; +import { useAuth } from "../../../context/AuthContext"; + +const fmtPhone = (phone) => { + if (!phone) return "—"; + const d = String(phone).replace(/\D/g, ""); + if (d.length === 11 && d.startsWith("7")) { + return `+7 (${d.slice(1, 4)}) ${d.slice(4, 7)}-${d.slice(7, 9)}-${d.slice(9)}`; + } + return phone; +}; + +export const AutocallExclusionsPanel = () => { + const { user } = useAuth(); + const canManage = ["mega_admin", "admin", "logistician"].includes(user?.role); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [ruleType, setRuleType] = useState("prefix"); + const [value, setValue] = useState(""); + const [reason, setReason] = useState(""); + const [saving, setSaving] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const { data, error } = await supabase + .from("autocall_exclusions") + .select("*") + .order("created_at", { ascending: false }); + if (error) throw error; + setItems(data || []); + } catch (e) { + setError(e.message || String(e)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + const handleAdd = async () => { + const v = String(value || "").replace(/\D/g, ""); + if (!v) { setError("Введите номер или префикс (только цифры)"); return; } + setSaving(true); + setError(null); + try { + const { error } = await supabase.from("autocall_exclusions").insert({ + rule_type: ruleType, + value: v, + reason: reason.trim(), + active: true, + created_by: user?.email || null, + }); + if (error) throw error; + setValue(""); setReason(""); + await load(); + } catch (e) { + setError(e.message || String(e)); + } finally { + setSaving(false); + } + }; + + const handleToggle = async (item) => { + try { + const { error } = await supabase + .from("autocall_exclusions") + .update({ active: !item.active }) + .eq("id", item.id); + if (error) throw error; + setItems((prev) => prev.map((i) => (i.id === item.id ? { ...i, active: !item.active } : i))); + } catch (e) { + setError(e.message || String(e)); + } + }; + + const handleDelete = async (item) => { + try { + const { error } = await supabase.from("autocall_exclusions").delete().eq("id", item.id); + if (error) throw error; + setItems((prev) => prev.filter((i) => i.id !== item.id)); + } catch (e) { + setError(e.message || String(e)); + } + }; + + return ( + +
+
+

🚫 Исключения автодозвона

+

+ Номера и префиксы, на которые робот не звонит. Отключите или удалите правило — звонки возобновятся. +

+
+ {canManage && ( + + )} +
+ + {error && ( +
+ {error} +
+ )} + + {/* Добавление */} + {canManage && ( +
+
+ Тип + +
+
+ + {ruleType === "prefix" ? "Префикс (напр. 7918)" : "Номер (напр. 79189521296)"} + + setValue(e.target.value)} + placeholder={ruleType === "prefix" ? "7918" : "79189521296"} + className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none" + /> +
+
+ Причина (для карточки) + setReason(e.target.value)} + placeholder="МТС — кнопки не проходят" + className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none" + /> +
+ +
+ )} + + {/* Список */} + {loading ? ( +
Загрузка…
+ ) : items.length === 0 ? ( +
+ Исключений нет — робот звонит на все номера. +
+ ) : ( +
+ {items.map((item) => ( +
+ +
+
+ + {item.rule_type === "prefix" ? `📡 ${item.value}…` : `📞 ${fmtPhone(item.value)}`} + + + {item.active ? "не звоним" : "выключено"} + +
+ {item.reason && ( +
{item.reason}
+ )} +
+ {canManage && ( + + )} +
+ ))} +
+ )} +
+ ); +}; diff --git a/src/components/orders/AutocallStatusCard.jsx b/src/components/orders/AutocallStatusCard.jsx index a853bc7..5275116 100644 --- a/src/components/orders/AutocallStatusCard.jsx +++ b/src/components/orders/AutocallStatusCard.jsx @@ -50,6 +50,7 @@ export const AutocallStatusCard = ({ order, userRole }) => { const canManage = ["mega_admin", "logistician"].includes(userRole); const optOut = order.autocall_opt_out ?? order.autocallOptOut ?? false; const isAutocall = order.delivery_date_source === "autocall" || order.deliveryDateSource === "autocall"; + const exclusionReason = order.autocall_exclusion_reason || order.autocallExclusionReason || null; const loadCalls = useCallback(async () => { if (!order?.id) return; @@ -122,6 +123,8 @@ export const AutocallStatusCard = ({ order, userRole }) => {
{optOut ? ( 🚫 Отказ от звонков + ) : exclusionReason ? ( + 🚫 Не звоним (исключение) ) : isAutocall ? ( 🤖 Согласовано роботом ) : calls.length > 0 ? ( @@ -132,6 +135,13 @@ export const AutocallStatusCard = ({ order, userRole }) => { {lastSlot && {lastSlot}}
+ {/* Пояснение исключения */} + {exclusionReason && !optOut && ( +
+ 🚫 Робот не звонит на этот номер по настройке: {exclusionReason} +
+ )} + {/* Попытки */} {calls.length > 0 ? (
diff --git a/src/services/supabase/orderGroupRepository.js b/src/services/supabase/orderGroupRepository.js index 64dd5dd..148865c 100644 --- a/src/services/supabase/orderGroupRepository.js +++ b/src/services/supabase/orderGroupRepository.js @@ -294,7 +294,7 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => { }; const ORDER_GROUP_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, delivery_link_code, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, autocall_opt_out, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, delivery_invitation:delivery_invitations!order_groups_delivery_invitation_id_fkey(opened_at, access_count, last_accessed_at), called, called_at, called_comment, order_history(id, action, old_status, new_status, user_id, metadata, created_at)`; -export const ORDER_GROUP_LIST_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, notification_status, sms_attempts, last_sms_error, first_sms_sent_at, second_sms_sent_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, autocall_opt_out, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, called, called_at, called_comment`; +export const ORDER_GROUP_LIST_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, notification_status, sms_attempts, last_sms_error, first_sms_sent_at, second_sms_sent_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, autocall_opt_out, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, called, called_at, called_comment, autocall_exclusion_reason`; // Delivery statuses treated as "finished" — hidden by default in the board. const COMPLETED_DELIVERY_STATUSES = ["delivered", "picked_up", "cancelled"]; diff --git a/supabase/migrations/20260908_autocall_exclusions.sql b/supabase/migrations/20260908_autocall_exclusions.sql new file mode 100644 index 0000000..caca3a6 --- /dev/null +++ b/supabase/migrations/20260908_autocall_exclusions.sql @@ -0,0 +1,84 @@ +-- ============================================================ +-- SuperSam: Автодозвон — исключения номеров/префиксов (Фаза 8) +-- 08.09.2026. Аддитивная, безопасна для прод-базы. +-- Применять: docker exec supabase-db psql -U supabase_admin -d postgres -f +-- ============================================================ + +-- 1) Таблица исключений +create table if not exists public.autocall_exclusions ( + id bigint generated always as identity primary key, + rule_type text not null check (rule_type in ('prefix','phone')), + value text not null, -- '7918' или '79189521296' + reason text not null default '', -- 'МТС — кнопки не проходят' + active boolean not null default true, + created_by text, -- email логиста/админа, кто добавил + created_at timestamptz not null default now(), + unique (rule_type, value) +); +create index if not exists idx_autocall_excl_active on public.autocall_exclusions(active) where active; +comment on table public.autocall_exclusions is 'Исключения автодозвона: префиксы/номера, на которые робот не звонит'; + +grant select on public.autocall_exclusions to authenticated; +-- запись только для админов/логистов через RLS (включаем RLS) +alter table public.autocall_exclusions enable row level security; + +-- политики: читать все залогиненные; писать только admin/mega_admin/logistician +do $$ +begin + if not exists (select 1 from pg_policies where tablename='autocall_exclusions' and policyname='autocall_excl_select') then + create policy autocall_excl_select on public.autocall_exclusions + for select to authenticated using (true); + end if; + if not exists (select 1 from pg_policies where tablename='autocall_exclusions' and policyname='autocall_excl_write') then + create policy autocall_excl_write on public.autocall_exclusions + for all to authenticated + using ( + exists ( + select 1 from public.users u + where u.id = auth.uid() + and u.role in ('mega_admin','admin','logistician') + ) + ) + with check ( + exists ( + select 1 from public.users u + where u.id = auth.uid() + and u.role in ('mega_admin','admin','logistician') + ) + ); + end if; +end $$; + +-- 2) RPC: проверка номера по исключениям (нормализуем телефон до 11 цифр без +7/8) +create or replace function public.autocall_exclusion_match(p_phone text) +returns table(rule_type text, value text, reason text) +language sql stable security definer +set search_path = public +as $$ + with norm as ( + select case + when length(regexp_replace(coalesce(p_phone,''), '\D', '', 'g')) = 11 + then regexp_replace(coalesce(p_phone,''), '\D', '', 'g') + when length(regexp_replace(coalesce(p_phone,''), '\D', '', 'g')) = 10 + then '7' || regexp_replace(coalesce(p_phone,''), '\D', '', 'g') + else regexp_replace(coalesce(p_phone,''), '\D', '', 'g') + end as phone + ) + select e.rule_type, e.value, e.reason + from public.autocall_exclusions e, norm n + where e.active + and ( + (e.rule_type = 'phone' and n.phone = e.value) + or + (e.rule_type = 'prefix' and n.phone like e.value || '%') + ) + limit 1; +$$; + +grant execute on function public.autocall_exclusion_match(text) to authenticated; +grant execute on function public.autocall_exclusion_match(text) to service_role; + +-- 3) Стартовое правило — МТС диапазон, который мы точно знаем (неактивно по умолчанию? ставим активным — пользователь просил) +insert into public.autocall_exclusions (rule_type, value, reason, active, created_by) +values ('prefix', '7918952', 'МТС — DTMF не проходит (Miranda)', true, 'system') +on conflict do nothing; diff --git a/supabase/migrations/20260908_autocall_exclusions_view.sql b/supabase/migrations/20260908_autocall_exclusions_view.sql new file mode 100644 index 0000000..0f8f46f --- /dev/null +++ b/supabase/migrations/20260908_autocall_exclusions_view.sql @@ -0,0 +1,42 @@ +-- ============================================================ +-- SuperSam: Автодозвон — исключения в view реестра (Фаза 8b) +-- 08.09.2026. Аддитивная. Дополняет order_group_comms_flags полями +-- autocall_excluded / autocall_exclusion_reason, чтобы фронт показывал +-- бейдж «не звоним (исключение)» без отдельного запроса. +-- ============================================================ + +drop view if exists public.order_group_comms_flags; +create view public.order_group_comms_flags as +select + og.id, + og.autocall_opt_out, + og.delivery_date_source, + og.notification_status, + og.delivery_status, + og.customer_phone_normalized, + (select l.outcome from public.autocall_log l where l.order_group_id = og.id order by l.created_at desc limit 1) as last_call_outcome, + (select l.changed from public.autocall_log l where l.order_group_id = og.id order by l.created_at desc limit 1) as last_call_changed, + (select l.created_at from public.autocall_log l where l.order_group_id = og.id order by l.created_at desc limit 1) as last_call_at, + (select count(*) from public.autocall_log l where l.order_group_id = og.id) as call_attempts, + (select count(*) from public.autocall_log l where l.order_group_id = og.id + and l.outcome in ('no_input_day','no_input_half')) as no_input_count, + -- Исключение автодозвона (префикс/номер) + (select e.reason from public.autocall_exclusions e + where e.active + and ( + (e.rule_type = 'phone' and coalesce(og.customer_phone_normalized, regexp_replace(coalesce(og.customer_phone,''),'\D','','g')) = e.value) + or + (e.rule_type = 'prefix' and coalesce(og.customer_phone_normalized, regexp_replace(coalesce(og.customer_phone,''),'\D','','g')) like e.value || '%') + ) + limit 1) as autocall_exclusion_reason, + (select e.value from public.autocall_exclusions e + where e.active + and ( + (e.rule_type = 'phone' and coalesce(og.customer_phone_normalized, regexp_replace(coalesce(og.customer_phone,''),'\D','','g')) = e.value) + or + (e.rule_type = 'prefix' and coalesce(og.customer_phone_normalized, regexp_replace(coalesce(og.customer_phone,''),'\D','','g')) like e.value || '%') + ) + limit 1) as autocall_exclusion_value +from public.order_groups og; + +grant select on public.order_group_comms_flags to authenticated;