feat(autocall): исключения автодозвона — таблица, UI в настройках, проверка в планировщике, бейдж в реестре/карточке
This commit is contained in:
parent
95af771596
commit
5dbeae84f4
|
|
@ -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 ==="
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 =====
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
; ===== SuperSam автодозвон: автозагрузка модулей (добавлено 2026-09-01) =====
|
||||||
|
; Без этого ночной reboot 03:33 сносит pbx_spool (call-файлы не подхватываются).
|
||||||
|
load = pbx_spool.so
|
||||||
|
load = res_agi.so
|
||||||
|
; ===== конец =====
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
|
@ -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
|
||||||
|
|
@ -136,6 +136,23 @@ def normalize_phone(phone):
|
||||||
clean = "7" + clean
|
clean = "7" + clean
|
||||||
return 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):
|
def build_call_file(group, settings, attempt_no, is_test_call=False):
|
||||||
"""Формирует содержимое call-файла."""
|
"""Формирует содержимое call-файла."""
|
||||||
phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
|
phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
|
||||||
|
|
@ -249,6 +266,12 @@ def process_queue(conn, settings):
|
||||||
if not phone:
|
if not phone:
|
||||||
log.warning(f"Queue item {q['queue_id']} has no phone, skip")
|
log.warning(f"Queue item {q['queue_id']} has no phone, skip")
|
||||||
continue
|
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 not in attempt_map:
|
||||||
if group_id:
|
if group_id:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
|
|
@ -305,6 +328,12 @@ def main():
|
||||||
|
|
||||||
pushed = 0
|
pushed = 0
|
||||||
for group in groups:
|
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
|
attempt_no = (group.get("attempt_count") or 0) + 1
|
||||||
if push_call_file(group, settings, attempt_no):
|
if push_call_file(group, settings, attempt_no):
|
||||||
pushed += 1
|
pushed += 1
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
*/
|
*/
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
// Приоритет: отказ > неверный номер > менеджеру > не нажимает > не дозвонились > слот от робота
|
// Приоритет: отказ > исключение > неверный номер > менеджеру > не нажимает > не дозвонились > слот от робота
|
||||||
export const resolveCallBadge = (g) => {
|
export const resolveCallBadge = (g) => {
|
||||||
if (!g) return null;
|
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: "Клиент отказался от автозвонков" };
|
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") {
|
if (g.deliveryDateSource === "autocall") {
|
||||||
return { label: "🤖 слот от робота", cls: "bg-[rgba(59,130,246,0.14)] text-[var(--color-accent)]", title: "Слот согласован роботом" };
|
return { label: "🤖 слот от робота", cls: "bg-[rgba(59,130,246,0.14)] text-[var(--color-accent)]", title: "Слот согласован роботом" };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { Badge } from "../../UI/Badge";
|
||||||
import { supabase } from "../../../supabaseClient";
|
import { supabase } from "../../../supabaseClient";
|
||||||
import { useAuth } from "../../../context/AuthContext";
|
import { useAuth } from "../../../context/AuthContext";
|
||||||
import { TestCallPanel } from "./TestCallPanel";
|
import { TestCallPanel } from "./TestCallPanel";
|
||||||
|
import { AutocallExclusionsPanel } from "./AutocallExclusionsPanel";
|
||||||
|
|
||||||
// ── Кампании звонков ─────────────────────────────────────────────────────────
|
// ── Кампании звонков ─────────────────────────────────────────────────────────
|
||||||
const CAMPAIGNS = [
|
const CAMPAIGNS = [
|
||||||
|
|
@ -346,6 +347,9 @@ export const AutocallChannelTab = () => {
|
||||||
{/* ── Тестовый звонок (только мегаадмин) ───────────────────────────── */}
|
{/* ── Тестовый звонок (только мегаадмин) ───────────────────────────── */}
|
||||||
{isMegaAdmin && <TestCallPanel />}
|
{isMegaAdmin && <TestCallPanel />}
|
||||||
|
|
||||||
|
{/* ── Исключения автодозвона ─────────────────────────────────────── */}
|
||||||
|
<AutocallExclusionsPanel />
|
||||||
|
|
||||||
{/* ── Журнал звонков ─────────────────────────────────────────────── */}
|
{/* ── Журнал звонков ─────────────────────────────────────────────── */}
|
||||||
<Panel className="p-4">
|
<Panel className="p-4">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<Panel className="p-5">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--color-text)]">🚫 Исключения автодозвона</h3>
|
||||||
|
<p className="mt-0.5 text-xs text-[var(--color-text-muted)]">
|
||||||
|
Номера и префиксы, на которые робот не звонит. Отключите или удалите правило — звонки возобновятся.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canManage && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={load}
|
||||||
|
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)]"
|
||||||
|
>
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-3 rounded-xl border border-[var(--color-danger)] bg-[var(--color-surface-strong)] p-2.5 text-xs text-[var(--color-danger)]">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Добавление */}
|
||||||
|
{canManage && (
|
||||||
|
<div className="mb-4 flex flex-wrap items-end gap-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
|
||||||
|
<div>
|
||||||
|
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">Тип</span>
|
||||||
|
<select
|
||||||
|
value={ruleType}
|
||||||
|
onChange={(e) => setRuleType(e.target.value)}
|
||||||
|
className="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"
|
||||||
|
>
|
||||||
|
<option value="prefix">Префикс (все номера с этих цифр)</option>
|
||||||
|
<option value="phone">Точный номер</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[140px] flex-1">
|
||||||
|
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||||
|
{ruleType === "prefix" ? "Префикс (напр. 7918)" : "Номер (напр. 79189521296)"}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[160px] flex-1">
|
||||||
|
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">Причина (для карточки)</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={saving}
|
||||||
|
className="rounded-xl bg-[var(--color-accent)] px-4 py-1.5 text-xs font-semibold text-white hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? "…" : "+ Добавить"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Список */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="animate-pulse text-xs text-[var(--color-text-muted)]">Загрузка…</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-[var(--color-border)] p-4 text-center text-xs text-[var(--color-text-muted)]">
|
||||||
|
Исключений нет — робот звонит на все номера.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs ${
|
||||||
|
item.active ? "" : "opacity-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleToggle(item)}
|
||||||
|
disabled={!canManage}
|
||||||
|
className={`relative h-5 w-9 shrink-0 rounded-full transition ${item.active ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"}`}
|
||||||
|
title={item.active ? "Отключить правило" : "Включить правило"}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition ${item.active ? "left-[18px]" : "left-0.5"}`} />
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold text-[var(--color-text)]">
|
||||||
|
{item.rule_type === "prefix" ? `📡 ${item.value}…` : `📞 ${fmtPhone(item.value)}`}
|
||||||
|
</span>
|
||||||
|
<Badge tone={item.active ? "warning" : "neutral"}>
|
||||||
|
{item.active ? "не звоним" : "выключено"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{item.reason && (
|
||||||
|
<div className="mt-0.5 truncate text-[11px] text-[var(--color-text-muted)]">{item.reason}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{canManage && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(item)}
|
||||||
|
className="shrink-0 rounded-lg px-2 py-1 text-[11px] text-[var(--color-danger)] hover:bg-[var(--color-surface-strong)]"
|
||||||
|
title="Удалить правило"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -50,6 +50,7 @@ export const AutocallStatusCard = ({ order, userRole }) => {
|
||||||
const canManage = ["mega_admin", "logistician"].includes(userRole);
|
const canManage = ["mega_admin", "logistician"].includes(userRole);
|
||||||
const optOut = order.autocall_opt_out ?? order.autocallOptOut ?? false;
|
const optOut = order.autocall_opt_out ?? order.autocallOptOut ?? false;
|
||||||
const isAutocall = order.delivery_date_source === "autocall" || order.deliveryDateSource === "autocall";
|
const isAutocall = order.delivery_date_source === "autocall" || order.deliveryDateSource === "autocall";
|
||||||
|
const exclusionReason = order.autocall_exclusion_reason || order.autocallExclusionReason || null;
|
||||||
|
|
||||||
const loadCalls = useCallback(async () => {
|
const loadCalls = useCallback(async () => {
|
||||||
if (!order?.id) return;
|
if (!order?.id) return;
|
||||||
|
|
@ -122,6 +123,8 @@ export const AutocallStatusCard = ({ order, userRole }) => {
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
{optOut ? (
|
{optOut ? (
|
||||||
<Badge tone="danger">🚫 Отказ от звонков</Badge>
|
<Badge tone="danger">🚫 Отказ от звонков</Badge>
|
||||||
|
) : exclusionReason ? (
|
||||||
|
<Badge tone="warning">🚫 Не звоним (исключение)</Badge>
|
||||||
) : isAutocall ? (
|
) : isAutocall ? (
|
||||||
<Badge tone="accent">🤖 Согласовано роботом</Badge>
|
<Badge tone="accent">🤖 Согласовано роботом</Badge>
|
||||||
) : calls.length > 0 ? (
|
) : calls.length > 0 ? (
|
||||||
|
|
@ -132,6 +135,13 @@ export const AutocallStatusCard = ({ order, userRole }) => {
|
||||||
{lastSlot && <span className="text-xs text-[var(--color-text)]">{lastSlot}</span>}
|
{lastSlot && <span className="text-xs text-[var(--color-text)]">{lastSlot}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Пояснение исключения */}
|
||||||
|
{exclusionReason && !optOut && (
|
||||||
|
<div className="mb-3 rounded-lg bg-[rgba(191,123,33,0.08)] px-2.5 py-1.5 text-[11px] text-[var(--color-warning)]">
|
||||||
|
🚫 Робот не звонит на этот номер по настройке: {exclusionReason}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Попытки */}
|
{/* Попытки */}
|
||||||
{calls.length > 0 ? (
|
{calls.length > 0 ? (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|
|
||||||
|
|
@ -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)`;
|
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.
|
// Delivery statuses treated as "finished" — hidden by default in the board.
|
||||||
const COMPLETED_DELIVERY_STATUSES = ["delivered", "picked_up", "cancelled"];
|
const COMPLETED_DELIVERY_STATUSES = ["delivered", "picked_up", "cancelled"];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
-- ============================================================
|
||||||
|
-- SuperSam: Автодозвон — исключения номеров/префиксов (Фаза 8)
|
||||||
|
-- 08.09.2026. Аддитивная, безопасна для прод-базы.
|
||||||
|
-- Применять: docker exec supabase-db psql -U supabase_admin -d postgres -f <file>
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 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;
|
||||||
|
|
@ -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;
|
||||||
Loading…
Reference in New Issue