From 4cf5c0ffabc2ff84f858d69665bb7c51be82640a Mon Sep 17 00:00:00 2001 From: Hermes Date: Tue, 29 Sep 2026 17:40:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(autocall):=20=D0=BB=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D0=B1=D0=B5=D0=B7=20=D0=B7=D0=B2=D0=BE?= =?UTF-8?q?=D0=BD=D0=BA=D0=BE=D0=B2=20=D0=BF=D0=BE=D0=BC=D0=B5=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=C2=AB=D0=9D=D0=B5=20=D0=B7=D0=B2=D0=BE=D0=BD?= =?UTF-8?q?=D0=B8=D0=BC=20(=D0=B3=D0=BE=D1=80=D0=BE=D0=B4)=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Заказ из города, выключенного для звонков (Настройки → Коммуникации → Автодозвон → 📍 Локации), больше не выглядит как «звонка не было»: - autocall_status(og) отдаёт новый статус city_disabled (порядок: ручной запрет → локация выключена → исключение по номеру → журнал звонков); - бейдж в реестре: 🚫 не звоним, виден и в завершённом заказе; - фильтр «Статус автодозвона»: «Не звоним (город)»; - карточка «Автодозвон»: пояснение «Не звоним — <город>», кнопка «Позвонить сейчас» заблокирована (планировщик такие заявки пропускает); - сводка заказа и шаг «Звонок» в цепочке: «не звоним (локация выключена)»; - в детальную выборку группы добавлены autocall_status/exclusion_reason; - SW-кэш static-v129. На SMS выключение локации не влияет — уведомления продолжают уходить. --- public/service-worker.js | 2 +- ...26-09-29_autocall_status_city_disabled.sql | 57 +++++++++++++++++++ src/components/UI/CallStatusBadge.jsx | 2 + src/components/UI/CallStatusBadge.test.jsx | 8 +++ src/components/orders/AutocallStatusCard.jsx | 30 +++++++--- .../orders/OrderHistoryTimeline.jsx | 16 +++++- src/services/orderGroupViews.js | 19 ++++++- src/services/orderGroupViews.test.js | 26 +++++++++ src/services/supabase/orderGroupRepository.js | 2 +- 9 files changed, 148 insertions(+), 14 deletions(-) create mode 100644 scripts/sql/2026-09-29_autocall_status_city_disabled.sql diff --git a/public/service-worker.js b/public/service-worker.js index a374282..965fabf 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,7 +1,7 @@ const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1"; if (!isLocalhost) { - const STATIC_CACHE = "construction-delivery-static-v128"; + const STATIC_CACHE = "construction-delivery-static-v129"; const RUNTIME_CACHE = "construction-delivery-runtime-v119"; const APP_SHELL_URLS = ["/", "/client", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"]; diff --git a/scripts/sql/2026-09-29_autocall_status_city_disabled.sql b/scripts/sql/2026-09-29_autocall_status_city_disabled.sql new file mode 100644 index 0000000..f74a4e2 --- /dev/null +++ b/scripts/sql/2026-09-29_autocall_status_city_disabled.sql @@ -0,0 +1,57 @@ +-- Заказ из локации, где звонки выключены (Настройки -> Коммуникации -> Автодозвон -> Локации), +-- помечается статусом city_disabled = «Не звоним (город)». На SMS это не влияет. +-- Порядок: ручной запрет -> локация выключена -> исключение по номеру -> журнал звонков. + +CREATE OR REPLACE FUNCTION public.autocall_status(og public.order_groups) + RETURNS text + LANGUAGE sql + STABLE SECURITY DEFINER + SET search_path TO 'public' +AS $function$ + select case + when og.autocall_opt_out then 'opt_out' + when exists ( + select 1 from public.autocall_city_rules c + where btrim(c.city) = btrim(coalesce(og.town, '')) + and c.calls_enabled = false + ) then 'city_disabled' + when exists ( + select 1 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 || '%') + ) + ) then 'excluded' + when not exists (select 1 from public.autocall_log l where l.order_group_id = og.id) then 'not_called' + when exists ( + select 1 from public.autocall_log l + where l.order_group_id = og.id + and l.outcome in ('confirmed','confirmed_silent','confirmed_kept_previous') + ) then 'confirmed' + else ( + select case + when l.outcome = 'no_answer' then 'no_answer' + when l.outcome in ('no_input_day','no_input_half') then + case + when exists (select 1 from public.autocall_log l2 + where l2.order_group_id = og.id and l2.outcome = 'no_input_day') + and exists (select 1 from public.autocall_log l3 + where l3.order_group_id = og.id and l3.outcome = 'no_input_half') + then 'no_input_both' + when l.outcome = 'no_input_half' then 'no_input_half' + else 'no_input_day' + end + when l.outcome in ('operator','error','invalid_number') then 'manual' + else 'not_called' + end + from public.autocall_log l + where l.order_group_id = og.id + order by l.created_at desc + limit 1 + ) + end; +$function$; + +NOTIFY pgrst, 'reload schema'; diff --git a/src/components/UI/CallStatusBadge.jsx b/src/components/UI/CallStatusBadge.jsx index ba4f6b4..6133530 100644 --- a/src/components/UI/CallStatusBadge.jsx +++ b/src/components/UI/CallStatusBadge.jsx @@ -27,6 +27,7 @@ const STYLES = { manual: "bg-[rgba(245,158,11,0.14)] text-[var(--color-warning)]", opt_out: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]", excluded: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]", + city_disabled: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]", not_called: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]", }; @@ -39,6 +40,7 @@ const TITLES = { manual: "Нужен менеджер: неверный номер или ошибка звонка", opt_out: "Звонки по этому клиенту запрещены вручную", excluded: "Исключение: по этому номеру или городу не звоним", + city_disabled: "Локация выключена для звонков: робот по этому городу не набирает (SMS продолжают уходить)", not_called: "Автодозвона по заказу ещё не было", }; diff --git a/src/components/UI/CallStatusBadge.test.jsx b/src/components/UI/CallStatusBadge.test.jsx index 0b6665d..eb913dd 100644 --- a/src/components/UI/CallStatusBadge.test.jsx +++ b/src/components/UI/CallStatusBadge.test.jsx @@ -57,6 +57,14 @@ describe("resolveCallBadge — что видно в строке", () => { expect(excluded.title).toContain("МТС-диапазон"); }); + it("выключенная локация помечена «не звоним» и видна в завершённом заказе", () => { + const badge = resolveCallBadge(group({ autocallStatus: "city_disabled", town: "Ялта", deliveryStatus: "delivered" })); + expect(badge.label).toContain("не звоним"); + expect(badge.title).toContain("Локация выключена для звонков"); + expect(AUTOCALL_STATUS_META.city_disabled.label).toBe("Не звоним (город)"); + expect(AUTOCALL_STATUS_OPTIONS.map((o) => o.value)).toContain("city_disabled"); + }); + it("неизвестный статус не рисуем", () => { expect(resolveCallBadge(group({ autocallStatus: "какой-то-старый" }))).toBeNull(); }); diff --git a/src/components/orders/AutocallStatusCard.jsx b/src/components/orders/AutocallStatusCard.jsx index 6120438..8dd62f9 100644 --- a/src/components/orders/AutocallStatusCard.jsx +++ b/src/components/orders/AutocallStatusCard.jsx @@ -6,7 +6,7 @@ import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Badge } from "../UI/Badge"; import { supabase } from "../../supabaseClient"; -import { resolveAutocallStatus, AUTOCALL_STATUS_META } from "../../services/orderGroupViews"; +import { resolveAutocallStatus, AUTOCALL_STATUS_META, isCityCallsDisabled } from "../../services/orderGroupViews"; import { describeCallProcess, isOrderAgreed } from "../../services/autocallProcess"; import { isAutocallPossible } from "../../services/autocallAvailability"; import { autocallSlotText } from "./autocallSlot"; @@ -43,6 +43,10 @@ export const AutocallStatusCard = ({ order, 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; + // Локация выключена для звонков (админка автодозвона) — робот по ней не набирает, + // ни массово, ни по заявке «Позвонить сейчас». Клиент получает SMS со ссылкой. + const cityDisabled = isCityCallsDisabled(order); + const town = order.town || order.city || ""; const loadCalls = useCallback(async () => { if (!order?.id) return; @@ -230,7 +234,7 @@ export const AutocallStatusCard = ({ order, userRole }) => { const lastSlot = lastCall ? autocallSlotText(lastCall, order) : null; // Единый человеческий статус (по аналогии со статусом SMS) - const statusKey = resolveAutocallStatus(calls, { optOut, exclusionReason }); + const statusKey = resolveAutocallStatus(calls, { optOut, exclusionReason, cityDisabled }); const statusMeta = AUTOCALL_STATUS_META[statusKey] || AUTOCALL_STATUS_META.not_called; return ( @@ -252,6 +256,14 @@ export const AutocallStatusCard = ({ order, userRole }) => { )} + {/* Локация выключена для звонков — почему звонка не было и не будет */} + {cityDisabled && ( +
+ 🚫 Не звоним{town ? ` — ${town}` : ""}: локация выключена для звонков. Уведомления по ней уходят + SMS со ссылкой. Включается в Настройки → Коммуникации → Автодозвон → 📍 Локации. +
+ )} + {/* Счётчик попыток — как у SMS «Попыток отправки: N» */} {calls.length > 0 && (
@@ -356,13 +368,15 @@ export const AutocallStatusCard = ({ order, userRole }) => {