Compare commits
3 Commits
2067ff7d89
...
3a4cceb9a4
| Author | SHA1 | Date |
|---|---|---|
|
|
3a4cceb9a4 | |
|
|
462c2b4892 | |
|
|
004050c0ff |
|
|
@ -37,6 +37,17 @@ ATS_PASS = os.environ.get("ATS_PASS", "tigrissoro")
|
||||||
ATS_SPOOL = "/var/spool/asterisk/outgoing"
|
ATS_SPOOL = "/var/spool/asterisk/outgoing"
|
||||||
CALLER_ID = "2565551100"
|
CALLER_ID = "2565551100"
|
||||||
|
|
||||||
|
# --- Транк обзвона -----------------------------------------------------------
|
||||||
|
# 'gsm' — GSM-модуль клиента (DBL GoIP-4 на LAN АТС, эндпоинт goip5), основной;
|
||||||
|
# 'mmedia' — SIP-транк Miranda (прежний путь).
|
||||||
|
# Переключается настройкой autocall_trunk в sms_campaign_settings, без правки кода.
|
||||||
|
AUTOCALL_TRUNK_DEFAULT = "gsm"
|
||||||
|
GSM_ENDPOINT = os.environ.get("AUTOCALL_GSM_ENDPOINT", "goip5")
|
||||||
|
# Префикс линии модуля в single-server mode: линия 1 → "1" (SIM стоит только в ней).
|
||||||
|
GSM_LINE_PREFIX = os.environ.get("AUTOCALL_GSM_PREFIX", "1")
|
||||||
|
# Сколько одновременных звонков держит модуль: одна SIM = один звонок.
|
||||||
|
GSM_TRUNK_LINES_DEFAULT = 1
|
||||||
|
|
||||||
# Edge function для приёма результата
|
# Edge function для приёма результата
|
||||||
API_URL = os.environ.get("AUTOCALL_API_URL", "https://supa.supersamsev.ru/functions/v1/autocall-result")
|
API_URL = os.environ.get("AUTOCALL_API_URL", "https://supa.supersamsev.ru/functions/v1/autocall-result")
|
||||||
API_KEY = os.environ.get("AUTOCALL_API_KEY", "a7e1a7e9b543d22a93b29cb1291cbf7b3dd2ec67f1a4dae2")
|
API_KEY = os.environ.get("AUTOCALL_API_KEY", "a7e1a7e9b543d22a93b29cb1291cbf7b3dd2ec67f1a4dae2")
|
||||||
|
|
@ -415,6 +426,63 @@ def resolve_call_type(group):
|
||||||
return "pkp" if (group.get("delivery_type") or "").strip().lower() == "pickup" else "dlv"
|
return "pkp" if (group.get("delivery_type") or "").strip().lower() == "pickup" else "dlv"
|
||||||
|
|
||||||
|
|
||||||
|
def trunk_name(settings):
|
||||||
|
"""Транк обзвона. Настройка autocall_trunk из sms_campaign_settings (админка).
|
||||||
|
Пустое/неизвестное значение трактуем как основной транк — GSM-модуль."""
|
||||||
|
return (settings.get("autocall_trunk") or AUTOCALL_TRUNK_DEFAULT).strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def trunk_channel(settings, dial):
|
||||||
|
"""Канал для call-файла.
|
||||||
|
|
||||||
|
dial — «0» + 10 цифр номера (формат SIP-транка Miranda).
|
||||||
|
GoIP в single-server mode ждёт <префикс линии><номер в формате 8XXXXXXXXXX>:
|
||||||
|
для линии 1 и 10-значного dial это «18» + dial[1:] (тот же формат, что в
|
||||||
|
рабочем диалплане клиента: Goto(GSM-Out,1${EXTEN},1) для _89XXXXXXXXX).
|
||||||
|
"""
|
||||||
|
if trunk_name(settings) == "gsm":
|
||||||
|
return f"PJSIP/{GSM_LINE_PREFIX}8{dial[1:]}@{GSM_ENDPOINT}"
|
||||||
|
return f"PJSIP/{dial}@mmedia"
|
||||||
|
|
||||||
|
|
||||||
|
def trunk_capacity(settings):
|
||||||
|
"""Сколько одновременных звонков держит транк. None — не ограничено (SIP-транк)."""
|
||||||
|
if trunk_name(settings) != "gsm":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return max(1, int(settings.get("autocall_trunk_lines") or GSM_TRUNK_LINES_DEFAULT))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return GSM_TRUNK_LINES_DEFAULT
|
||||||
|
|
||||||
|
|
||||||
|
def trunk_active_calls(settings):
|
||||||
|
"""Сколько каналов транка занято на АТС прямо сейчас (и исходящие, и входящие)."""
|
||||||
|
pattern = GSM_ENDPOINT if trunk_name(settings) == "gsm" else "mmedia"
|
||||||
|
cmd = [
|
||||||
|
"sshpass", "-p", ATS_PASS,
|
||||||
|
"ssh", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null",
|
||||||
|
"-o", "ConnectTimeout=10", "-q",
|
||||||
|
f"{ATS_USER}@{ATS_HOST}",
|
||||||
|
f'asterisk -rx "core show channels concise" | grep -c "PJSIP/{pattern}-" || true',
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(cmd, capture_output=True, timeout=25)
|
||||||
|
lines = [l for l in proc.stdout.decode().splitlines() if l.strip()]
|
||||||
|
return int(lines[-1]) if lines else 0
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Не смог узнать занятость транка {pattern}: {e}")
|
||||||
|
# Не роняем обзвон из-за разовой сетевой ошибки: считаем линию свободной.
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def trunk_free_slots(settings):
|
||||||
|
"""Сколько звонков можно поставить в спул прямо сейчас. None — без ограничения."""
|
||||||
|
cap = trunk_capacity(settings)
|
||||||
|
if cap is None:
|
||||||
|
return None
|
||||||
|
return cap - trunk_active_calls(settings)
|
||||||
|
|
||||||
|
|
||||||
def build_call_file(group, settings, attempt_no, is_test_call=False, manual=False):
|
def build_call_file(group, settings, attempt_no, is_test_call=False, manual=False):
|
||||||
"""Формирует содержимое call-файла.
|
"""Формирует содержимое call-файла.
|
||||||
|
|
||||||
|
|
@ -466,7 +534,7 @@ def build_call_file(group, settings, attempt_no, is_test_call=False, manual=Fals
|
||||||
)
|
)
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
f"Channel: PJSIP/{dial}@mmedia",
|
f"Channel: {trunk_channel(settings, dial)}",
|
||||||
f"CallerID: {CALLER_ID}",
|
f"CallerID: {CALLER_ID}",
|
||||||
"Context: SS-Autocall-Slot",
|
"Context: SS-Autocall-Slot",
|
||||||
"Extension: s",
|
"Extension: s",
|
||||||
|
|
@ -570,6 +638,9 @@ def process_queue(conn, settings):
|
||||||
# Номера попыток для ручных звонков — с учётом уже сделанных автозвонков
|
# Номера попыток для ручных звонков — с учётом уже сделанных автозвонков
|
||||||
attempt_map = {}
|
attempt_map = {}
|
||||||
pushed = 0
|
pushed = 0
|
||||||
|
# Ручные звонки идут через тот же транк обзвона: если у модуля занята линия,
|
||||||
|
# заявку из очереди не выкидываем — она дождётся следующего тика.
|
||||||
|
free = trunk_free_slots(settings)
|
||||||
for q in queued:
|
for q in queued:
|
||||||
group_id = q["order_group_id"]
|
group_id = q["order_group_id"]
|
||||||
is_test = bool(q.get("is_test_call"))
|
is_test = bool(q.get("is_test_call"))
|
||||||
|
|
@ -619,9 +690,17 @@ def process_queue(conn, settings):
|
||||||
# Для теста не подставляем customer_phone_normalized (нет заказа)
|
# Для теста не подставляем customer_phone_normalized (нет заказа)
|
||||||
if is_test:
|
if is_test:
|
||||||
g["customer_phone_normalized"] = None
|
g["customer_phone_normalized"] = None
|
||||||
|
if free is not None and free <= 0:
|
||||||
|
log.info(
|
||||||
|
f"Свободных линий транка {trunk_name(settings)} нет — "
|
||||||
|
"ручные звонки ждут следующего тика"
|
||||||
|
)
|
||||||
|
break
|
||||||
if push_call_file(g, settings, attempt_no, is_test_call=is_test, manual=True):
|
if push_call_file(g, settings, attempt_no, is_test_call=is_test, manual=True):
|
||||||
mark_queue_processed(conn, q["queue_id"])
|
mark_queue_processed(conn, q["queue_id"])
|
||||||
pushed += 1
|
pushed += 1
|
||||||
|
if free is not None:
|
||||||
|
free -= 1
|
||||||
else:
|
else:
|
||||||
# Звонить не о чем (окно звонка закрыто). Заявку НЕ закрываем —
|
# Звонить не о чем (окно звонка закрыто). Заявку НЕ закрываем —
|
||||||
# по правилу владельца заявка живёт, пока окно не откроется или
|
# по правилу владельца заявка живёт, пока окно не откроется или
|
||||||
|
|
@ -767,6 +846,18 @@ def main():
|
||||||
groups = get_groups_to_call(conn, settings)
|
groups = get_groups_to_call(conn, settings)
|
||||||
log.info(f"Found {len(groups)} groups to call")
|
log.info(f"Found {len(groups)} groups to call")
|
||||||
|
|
||||||
|
# Транк с ограничением по линиям (GSM-модуль: одна SIM = один звонок в момент).
|
||||||
|
# Больше, чем есть свободных линий, в спул не ставим: иначе часть звонков
|
||||||
|
# отвалится «занято», AGI не запустится и заявка уедет в «не ответил».
|
||||||
|
# Не успевшие просто ждут следующего тика таймера.
|
||||||
|
free = trunk_free_slots(settings)
|
||||||
|
if free is not None and free <= 0:
|
||||||
|
log.info(
|
||||||
|
f"Транк {trunk_name(settings)} занят (лимит {trunk_capacity(settings)} звонков) — "
|
||||||
|
"переносим звонки на следующий тик"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
pushed = 0
|
pushed = 0
|
||||||
for group in groups:
|
for group in groups:
|
||||||
phone = group.get("customer_phone_normalized") or group.get("customer_phone") or ""
|
phone = group.get("customer_phone_normalized") or group.get("customer_phone") or ""
|
||||||
|
|
@ -781,8 +872,16 @@ def main():
|
||||||
log.info(f"Group {group['id']} skipped by exclusion ({excl['rule_type']}={excl['value']}): {excl.get('reason') or 'no reason'}")
|
log.info(f"Group {group['id']} skipped by exclusion ({excl['rule_type']}={excl['value']}): {excl.get('reason') or 'no reason'}")
|
||||||
continue
|
continue
|
||||||
attempt_no = (group.get("attempt_count") or 0) + 1
|
attempt_no = (group.get("attempt_count") or 0) + 1
|
||||||
|
if free is not None and free <= 0:
|
||||||
|
log.info(
|
||||||
|
f"Свободных линий транка {trunk_name(settings)} нет — "
|
||||||
|
"остальные звонки на следующий тик"
|
||||||
|
)
|
||||||
|
break
|
||||||
if push_call_file(group, settings, attempt_no):
|
if push_call_file(group, settings, attempt_no):
|
||||||
pushed += 1
|
pushed += 1
|
||||||
|
if free is not None:
|
||||||
|
free -= 1
|
||||||
time.sleep(1) # пауза между звонками
|
time.sleep(1) # пауза между звонками
|
||||||
|
|
||||||
log.info(f"Pushed {pushed} call files")
|
log.info(f"Pushed {pushed} call files")
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { useAuth } from "../../../context/AuthContext";
|
||||||
import { TestCallPanel } from "./TestCallPanel";
|
import { TestCallPanel } from "./TestCallPanel";
|
||||||
import { AutocallExclusionsPanel } from "./AutocallExclusionsPanel";
|
import { AutocallExclusionsPanel } from "./AutocallExclusionsPanel";
|
||||||
import { AutocallLocationsPanel } from "./AutocallLocationsPanel";
|
import { AutocallLocationsPanel } from "./AutocallLocationsPanel";
|
||||||
|
import { autocallSlotText } from "../../orders/autocallSlot";
|
||||||
|
|
||||||
// ── Кампании звонков ─────────────────────────────────────────────────────────
|
// ── Кампании звонков ─────────────────────────────────────────────────────────
|
||||||
const CAMPAIGNS = [
|
const CAMPAIGNS = [
|
||||||
|
|
@ -33,9 +34,6 @@ const OUTCOME_META = {
|
||||||
error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
|
error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const DAY_LABEL = { 1: "завтра", 2: "послезавтра" };
|
|
||||||
const HALF_LABEL = { 1: "первая половина дня", 2: "вторая половина дня" };
|
|
||||||
|
|
||||||
const fmtTime = (ts) => {
|
const fmtTime = (ts) => {
|
||||||
if (!ts) return "—";
|
if (!ts) return "—";
|
||||||
try {
|
try {
|
||||||
|
|
@ -55,14 +53,9 @@ const fmtPhone = (phone) => {
|
||||||
return phone;
|
return phone;
|
||||||
};
|
};
|
||||||
|
|
||||||
const slotText = (l) => {
|
// Слот — общей функцией из карточки заказа: она показывает реальную дату,
|
||||||
if (!l) return null;
|
// а не «завтра» (цифра дня — это первый рабочий день окна, а не завтрашний день).
|
||||||
const day = DAY_LABEL[l.dtmf_day];
|
const slotText = (l) => autocallSlotText(l, null);
|
||||||
const half = HALF_LABEL[l.dtmf_half];
|
|
||||||
if (day && half) return `${day}, ${half}`;
|
|
||||||
if (day) return day;
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SettingField = ({ label, value, onChange, hint }) => (
|
const SettingField = ({ label, value, onChange, hint }) => (
|
||||||
<label className="block" title={hint || undefined}>
|
<label className="block" title={hint || undefined}>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { supabase } from "../../../supabaseClient";
|
import { supabase } from "../../../supabaseClient";
|
||||||
import { Panel } from "../../UI/Panel";
|
import { Panel } from "../../UI/Panel";
|
||||||
import { Badge } from "../../UI/Badge";
|
import { Badge } from "../../UI/Badge";
|
||||||
|
import { autocallSlotText } from "../../orders/autocallSlot";
|
||||||
|
|
||||||
const OUTCOME_META = {
|
const OUTCOME_META = {
|
||||||
in_progress: { label: "Идёт звонок…", tone: "neutral", icon: "🔔" },
|
in_progress: { label: "Идёт звонок…", tone: "neutral", icon: "🔔" },
|
||||||
|
|
@ -24,9 +25,6 @@ const OUTCOME_META = {
|
||||||
error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
|
error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const DAY_LABEL = { 1: "завтра", 2: "послезавтра" };
|
|
||||||
const HALF_LABEL = { 1: "первая половина дня", 2: "вторая половина дня" };
|
|
||||||
|
|
||||||
const fmtPhone = (phone) => {
|
const fmtPhone = (phone) => {
|
||||||
if (!phone) return "—";
|
if (!phone) return "—";
|
||||||
const d = String(phone).replace(/\D/g, "");
|
const d = String(phone).replace(/\D/g, "");
|
||||||
|
|
@ -138,9 +136,7 @@ export const TestCallPanel = () => {
|
||||||
useEffect(() => () => stopPolling(), [stopPolling]);
|
useEffect(() => () => stopPolling(), [stopPolling]);
|
||||||
|
|
||||||
const m = OUTCOME_META[result?.outcome] || OUTCOME_META.in_progress;
|
const m = OUTCOME_META[result?.outcome] || OUTCOME_META.in_progress;
|
||||||
const slotText = result?.dtmf_day || result?.dtmf_half
|
const slotText = autocallSlotText(result, null) || null;
|
||||||
? `${DAY_LABEL[result.dtmf_day] || ""}${result.dtmf_day && result.dtmf_half ? ", " : ""}${HALF_LABEL[result.dtmf_half] || ""}`.trim() || "—"
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel className="p-4">
|
<Panel className="p-4">
|
||||||
|
|
@ -244,9 +240,7 @@ export const TestCallPanel = () => {
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{lastTests.map((t) => {
|
{lastTests.map((t) => {
|
||||||
const tm = OUTCOME_META[t.outcome] || OUTCOME_META.error;
|
const tm = OUTCOME_META[t.outcome] || OUTCOME_META.error;
|
||||||
const slot = t.dtmf_day || t.dtmf_half
|
const slot = autocallSlotText(t, null) || null;
|
||||||
? `${DAY_LABEL[t.dtmf_day] || ""}${t.dtmf_day && t.dtmf_half ? ", " : ""}${HALF_LABEL[t.dtmf_half] || ""}`.trim() || "—"
|
|
||||||
: null;
|
|
||||||
return (
|
return (
|
||||||
<div key={t.id} className="flex flex-wrap items-center gap-x-3 gap-y-0.5 rounded-lg bg-[var(--color-surface)] px-2 py-1.5 text-[11px]">
|
<div key={t.id} className="flex flex-wrap items-center gap-x-3 gap-y-0.5 rounded-lg bg-[var(--color-surface)] px-2 py-1.5 text-[11px]">
|
||||||
<span className="font-medium text-[var(--color-text)]">{fmtPhone(t.customer_phone)}</span>
|
<span className="font-medium text-[var(--color-text)]">{fmtPhone(t.customer_phone)}</span>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { autocallSlotText } from "./autocallSlot";
|
||||||
|
|
||||||
|
const order = { pickup_date: "2026-09-28", delivery_date: "2026-09-28" };
|
||||||
|
|
||||||
|
describe("autocallSlotText — слот автодозвона в карточке", () => {
|
||||||
|
it("показывает дату, а не слово «завтра» (день 1 в пятницу = понедельник)", () => {
|
||||||
|
const row = { outcome: "confirmed", dtmf_day: 1, dtmf_half: 2 };
|
||||||
|
expect(autocallSlotText(row, order)).toBe("28.09, вторая половина дня");
|
||||||
|
expect(autocallSlotText(row, order)).not.toContain("завтра");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("берёт дату из строки лога, если она там есть", () => {
|
||||||
|
const row = { outcome: "confirmed", dtmf_day: 2, dtmf_half: 1, chosen_date: "2026-09-29" };
|
||||||
|
expect(autocallSlotText(row, order)).toBe("29.09, первая половина дня");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("не выдумывает день у старой строки без даты — остаётся только половина дня", () => {
|
||||||
|
expect(autocallSlotText({ outcome: "no_input_half", dtmf_day: 1, dtmf_half: 2 }, {}))
|
||||||
|
.toBe("вторая половина дня");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("возвращает null, если в звонке не выбрано ничего", () => {
|
||||||
|
expect(autocallSlotText({ outcome: "no_answer" }, order)).toBeNull();
|
||||||
|
expect(autocallSlotText(null, order)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("понимает camelCase-поля", () => {
|
||||||
|
expect(autocallSlotText(
|
||||||
|
{ outcome: "confirmed", dtmf_day: 2, dtmf_half: 1 },
|
||||||
|
{ pickupDate: "2026-09-29" },
|
||||||
|
)).toBe("29.09, первая половина дня");
|
||||||
|
expect(autocallSlotText(
|
||||||
|
{ outcome: "confirmed", dtmfDay: 1, dtmfHalf: 1, chosenDate: "2026-09-30" },
|
||||||
|
order,
|
||||||
|
)).toBe("30.09, первая половина дня");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -9,6 +9,7 @@ import { supabase } from "../../supabaseClient";
|
||||||
import { resolveAutocallStatus, AUTOCALL_STATUS_META } from "../../services/orderGroupViews";
|
import { resolveAutocallStatus, AUTOCALL_STATUS_META } from "../../services/orderGroupViews";
|
||||||
import { describeCallProcess, isOrderAgreed } from "../../services/autocallProcess";
|
import { describeCallProcess, isOrderAgreed } from "../../services/autocallProcess";
|
||||||
import { isAutocallPossible } from "../../services/autocallAvailability";
|
import { isAutocallPossible } from "../../services/autocallAvailability";
|
||||||
|
import { autocallSlotText } from "./autocallSlot";
|
||||||
|
|
||||||
const OUTCOME_META = {
|
const OUTCOME_META = {
|
||||||
confirmed: { label: "Согласовал", tone: "accent", icon: "✅" },
|
confirmed: { label: "Согласовал", tone: "accent", icon: "✅" },
|
||||||
|
|
@ -21,9 +22,6 @@ const OUTCOME_META = {
|
||||||
error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
|
error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const DAY_LABEL = { 1: "завтра", 2: "послезавтра" };
|
|
||||||
const HALF_LABEL = { 1: "первая половина дня", 2: "вторая половина дня" };
|
|
||||||
|
|
||||||
const fmtTime = (ts) => {
|
const fmtTime = (ts) => {
|
||||||
if (!ts) return "—";
|
if (!ts) return "—";
|
||||||
try {
|
try {
|
||||||
|
|
@ -33,15 +31,6 @@ const fmtTime = (ts) => {
|
||||||
} catch { return ts; }
|
} catch { return ts; }
|
||||||
};
|
};
|
||||||
|
|
||||||
const slotText = (l) => {
|
|
||||||
if (!l) return null;
|
|
||||||
const day = DAY_LABEL[l.dtmf_day];
|
|
||||||
const half = HALF_LABEL[l.dtmf_half];
|
|
||||||
if (day && half) return `${day}, ${half}`;
|
|
||||||
if (day) return day;
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AutocallStatusCard = ({ order, userRole }) => {
|
export const AutocallStatusCard = ({ order, userRole }) => {
|
||||||
const [calls, setCalls] = useState([]);
|
const [calls, setCalls] = useState([]);
|
||||||
const [queueItem, setQueueItem] = useState(null);
|
const [queueItem, setQueueItem] = useState(null);
|
||||||
|
|
@ -238,7 +227,7 @@ export const AutocallStatusCard = ({ order, userRole }) => {
|
||||||
|
|
||||||
const lastCall = calls[0];
|
const lastCall = calls[0];
|
||||||
const lastMeta = lastCall ? OUTCOME_META[lastCall.outcome] : null;
|
const lastMeta = lastCall ? OUTCOME_META[lastCall.outcome] : null;
|
||||||
const lastSlot = lastCall ? slotText(lastCall) : null;
|
const lastSlot = lastCall ? autocallSlotText(lastCall, order) : null;
|
||||||
|
|
||||||
// Единый человеческий статус (по аналогии со статусом SMS)
|
// Единый человеческий статус (по аналогии со статусом SMS)
|
||||||
const statusKey = resolveAutocallStatus(calls, { optOut, exclusionReason });
|
const statusKey = resolveAutocallStatus(calls, { optOut, exclusionReason });
|
||||||
|
|
@ -297,7 +286,7 @@ export const AutocallStatusCard = ({ order, userRole }) => {
|
||||||
{(OUTCOME_META[callAfter.outcome] || {}).icon || "•"} {(OUTCOME_META[callAfter.outcome] || {}).label || callAfter.outcome}
|
{(OUTCOME_META[callAfter.outcome] || {}).icon || "•"} {(OUTCOME_META[callAfter.outcome] || {}).label || callAfter.outcome}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-[10px] opacity-80">{fmtTime(callAfter.created_at)}</span>
|
<span className="text-[10px] opacity-80">{fmtTime(callAfter.created_at)}</span>
|
||||||
{slotText(callAfter) && <span className="text-[10px] opacity-80">{slotText(callAfter)}</span>}
|
{autocallSlotText(callAfter, order) && <span className="text-[10px] opacity-80">{autocallSlotText(callAfter, order)}</span>}
|
||||||
{callAfter.duration_sec > 0 && <span className="text-[10px] opacity-80">{Math.round(callAfter.duration_sec)} сек</span>}
|
{callAfter.duration_sec > 0 && <span className="text-[10px] opacity-80">{Math.round(callAfter.duration_sec)} сек</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -312,7 +301,7 @@ export const AutocallStatusCard = ({ order, userRole }) => {
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{calls.map((c, idx) => {
|
{calls.map((c, idx) => {
|
||||||
const m = OUTCOME_META[c.outcome] || { label: c.outcome, tone: "neutral", icon: "•" };
|
const m = OUTCOME_META[c.outcome] || { label: c.outcome, tone: "neutral", icon: "•" };
|
||||||
const slot = slotText(c);
|
const slot = autocallSlotText(c, order);
|
||||||
const attemptNo = calls.length - idx;
|
const attemptNo = calls.length - idx;
|
||||||
return (
|
return (
|
||||||
<div key={c.id} className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1.5">
|
<div key={c.id} className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1.5">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
/**
|
||||||
|
* @file autocallSlot.js
|
||||||
|
* @description Слот, который согласовал робот автодозвона, — по-человечески.
|
||||||
|
*
|
||||||
|
* Вынесено из AutocallStatusCard.jsx отдельным модулем: логика чистая
|
||||||
|
* (ни React, ни supabase), поэтому её видно и можно покрыть юнит-тестами.
|
||||||
|
*
|
||||||
|
* ВАЖНО про цифру дня (dtmf_day): это НЕ «завтра». Робот перечисляет конкретные
|
||||||
|
* дни окна (у самовывоза — рабочие дни склада), поэтому «день 1» в пятницу может
|
||||||
|
* быть понедельником: 25.09 (пт) → день 1 = 28.09 (пн), а 26.09 — суббота, когда
|
||||||
|
* компания не работает. Слово «завтра» в карточке вводило менеджера в заблуждение.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const HALF_LABEL = { 1: "первая половина дня", 2: "вторая половина дня" };
|
||||||
|
|
||||||
|
// Итоги, при которых слот считается согласованным (тогда дату можно взять у заказа).
|
||||||
|
export const CONFIRMED_OUTCOMES = ["confirmed", "confirmed_kept_previous", "confirmed_silent"];
|
||||||
|
|
||||||
|
/** Дата в виде ДД.ММ (принимает ISO-строку или дату). */
|
||||||
|
export const fmtAutocallDay = (value) => {
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(value || ""));
|
||||||
|
return m ? `${m[3]}.${m[2]}` : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Текст слота для строки лога автодозвона.
|
||||||
|
* Дату берём из строки лога (chosen_date), а для старых строк — из даты заказа,
|
||||||
|
* если слот согласовал именно этот звонок. Если даты нет — показываем только
|
||||||
|
* половину дня: лучше меньше, чем выдуманный день.
|
||||||
|
*/
|
||||||
|
export const autocallSlotText = (logRow, order) => {
|
||||||
|
if (!logRow) return null;
|
||||||
|
const dtmfDay = logRow.dtmf_day ?? logRow.dtmfDay;
|
||||||
|
const dtmfHalf = logRow.dtmf_half ?? logRow.dtmfHalf;
|
||||||
|
const half = HALF_LABEL[dtmfHalf];
|
||||||
|
if (!dtmfDay && !half) return null;
|
||||||
|
|
||||||
|
const orderDate = CONFIRMED_OUTCOMES.includes(logRow.outcome)
|
||||||
|
? fmtAutocallDay(order?.pickup_date || order?.pickupDate || order?.delivery_date || order?.deliveryDate)
|
||||||
|
: null;
|
||||||
|
const day = fmtAutocallDay(logRow.chosen_date || logRow.chosenDate) || orderDate;
|
||||||
|
|
||||||
|
if (day && half) return `${day}, ${half}`;
|
||||||
|
if (day) return day;
|
||||||
|
return half || null;
|
||||||
|
};
|
||||||
|
|
@ -94,6 +94,13 @@ Deno.serve(async (request) => {
|
||||||
const scenario = body.scenario || "A";
|
const scenario = body.scenario || "A";
|
||||||
const callType = body.call_type === "pkp" ? "pkp" : "dlv";
|
const callType = body.call_type === "pkp" ? "pkp" : "dlv";
|
||||||
const wasTest = body.was_test_mode ?? false;
|
const wasTest = body.was_test_mode ?? false;
|
||||||
|
// Дата, которую робот реально назвал в разговоре (AGI передаёт её в chosen_date).
|
||||||
|
// Нужна и для заказа, и для строки лога: в карточке автодозвона показываем дату,
|
||||||
|
// а не «завтра/послезавтра» — «день 1» это первый рабочий день окна, который
|
||||||
|
// в пятницу оказывается понедельником.
|
||||||
|
const chosenDate = /^\d{4}-\d{2}-\d{2}$/.test(body.chosen_date || "")
|
||||||
|
? (body.chosen_date as string)
|
||||||
|
: null;
|
||||||
|
|
||||||
// ── Идемпотентность: повторный curl с тем же uniqueid обновляет строку ──
|
// ── Идемпотентность: повторный curl с тем же uniqueid обновляет строку ──
|
||||||
const { data: existing } = await supabase
|
const { data: existing } = await supabase
|
||||||
|
|
@ -108,6 +115,7 @@ Deno.serve(async (request) => {
|
||||||
outcome: body.outcome,
|
outcome: body.outcome,
|
||||||
dtmf_day: body.dtmf_day ?? null,
|
dtmf_day: body.dtmf_day ?? null,
|
||||||
dtmf_half: body.dtmf_half ?? null,
|
dtmf_half: body.dtmf_half ?? null,
|
||||||
|
chosen_date: chosenDate,
|
||||||
changed: body.changed ?? false,
|
changed: body.changed ?? false,
|
||||||
was_test_mode: body.was_test_mode ?? false,
|
was_test_mode: body.was_test_mode ?? false,
|
||||||
}).eq("id", existing.id);
|
}).eq("id", existing.id);
|
||||||
|
|
@ -122,6 +130,7 @@ Deno.serve(async (request) => {
|
||||||
attempt_no: attemptNo,
|
attempt_no: attemptNo,
|
||||||
scenario,
|
scenario,
|
||||||
call_type: callType,
|
call_type: callType,
|
||||||
|
chosen_date: chosenDate,
|
||||||
call_started_at: body.call_started_at || new Date().toISOString(),
|
call_started_at: body.call_started_at || new Date().toISOString(),
|
||||||
answered_at: body.answered_at || null,
|
answered_at: body.answered_at || null,
|
||||||
duration_sec: body.duration_sec ?? null,
|
duration_sec: body.duration_sec ?? null,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue