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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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: "Слот согласован роботом" };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 && <TestCallPanel />}
|
||||
|
||||
{/* ── Исключения автодозвона ─────────────────────────────────────── */}
|
||||
<AutocallExclusionsPanel />
|
||||
|
||||
{/* ── Журнал звонков ─────────────────────────────────────────────── */}
|
||||
<Panel className="p-4">
|
||||
<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 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 }) => {
|
|||
<div className="mb-3 flex items-center gap-2">
|
||||
{optOut ? (
|
||||
<Badge tone="danger">🚫 Отказ от звонков</Badge>
|
||||
) : exclusionReason ? (
|
||||
<Badge tone="warning">🚫 Не звоним (исключение)</Badge>
|
||||
) : isAutocall ? (
|
||||
<Badge tone="accent">🤖 Согласовано роботом</Badge>
|
||||
) : calls.length > 0 ? (
|
||||
|
|
@ -132,6 +135,13 @@ export const AutocallStatusCard = ({ order, userRole }) => {
|
|||
{lastSlot && <span className="text-xs text-[var(--color-text)]">{lastSlot}</span>}
|
||||
</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 ? (
|
||||
<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)`;
|
||||
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"];
|
||||
|
|
|
|||
|
|
@ -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