feat(autocall): исключения автодозвона — таблица, UI в настройках, проверка в планировщике, бейдж в реестре/карточке

This commit is contained in:
Hermes 2026-09-08 11:16:16 +02:00
parent 95af771596
commit 5dbeae84f4
14 changed files with 599 additions and 3 deletions

54
ats-patch/apply.sh Normal file
View File

@ -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 ==="

27
ats-patch/ss-agi-curl.txt Normal file
View File

@ -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

View File

@ -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 =====

6
ats-patch/ss-modules.txt Normal file
View File

@ -0,0 +1,6 @@
; ===== SuperSam автодозвон: автозагрузка модулей (добавлено 2026-09-01) =====
; Без этого ночной reboot 03:33 сносит pbx_spool (call-файлы не подхватываются).
load = pbx_spool.so
load = res_agi.so
; ===== конец =====

86
no8-logic.js Normal file
View File

@ -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",
},
}];

View File

@ -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

View File

@ -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

View File

@ -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: "Слот согласован роботом" };
}

View File

@ -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">

View File

@ -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>
);
};

View File

@ -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">

View File

@ -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"];

View File

@ -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;

View File

@ -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;