From 20e942235aeb2ec26ff9cdb00770dfdadc523e5c Mon Sep 17 00:00:00 2001 From: Hermes Date: Tue, 29 Sep 2026 11:54:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(delivery):=20=D0=B2=D0=B8=D0=B4=D0=BD?= =?UTF-8?q?=D0=BE,=20=D1=87=D0=B5=D0=BC=20=D1=81=D0=BE=D0=B3=D0=BB=D0=B0?= =?UTF-8?q?=D1=81=D0=BE=D0=B2=D0=B0=D0=BB=D0=B8=20=D0=B7=D0=B0=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=20=E2=80=94=20=D0=BF=D1=83=D1=88=20/=20SMS=20/=20=D0=B7?= =?UTF-8?q?=D0=B2=D0=BE=D0=BD=D0=BE=D0=BA=20/=20=D0=BB=D0=BE=D0=B3=D0=B8?= =?UTF-8?q?=D1=81=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Владелец: «нужно видеть, согласование прошло через пуш/смс/звонок/вручную через менеджера» и «в колонке с именем клиента пишется просто «Согласовано» — непонятно как и кем». Плюс в карточке «завтра/послезавтра» читалось как дата заказа. Источник правды — order_groups.delivery_date_source: manual (логист), autocall (робот), и теперь push/sms/messenger/client от клиентской страницы. Раньше клиентское подтверждение канал не писало вовсе — такие записи разбираются по confirmed_at приглашения (канал «link» → «По ссылке»). - миграция 20260929_confirmation_channel.sql: confirm_delivery_choice_by_token принимает p_src и сохраняет канал в delivery_date_source + action_logs.details; - клиентская страница читает ?src= из ссылки и прокидывает в RPC (SMS-ссылка уже содержит src=sms); - orderGroupViews: resolveConfirmChannel/confirmChannelMeta — единый источник подписей канала, плюс invitationConfirmedAt в репозитории (и в списке, и в карточке); - реестр доставок/самовывозов: под статусом чип «как согласовано» (📞 звонок робота, ✉️ SMS-ссылка, 🔔 push, 🧑💼 логист, 🔗 по ссылке); - карточка заказа: рядом с «Согласовано» тот же чип; - AutocallStatusCard: «робот предлагал: завтра или послезавтра» вместо голого «завтра/послезавтра» (читалось как дата заказа); - Аналитика: в «Пути заказа» блок «Как согласовали дату» — полосы по каналам (за 30 дней: логист 135, по ссылке 20, звонок робота 7, push 0). Тесты: orderGroupViews +4, OrderPathFunnelBlock +1 (37 зелёных в этих файлах). SW v128. --- public/service-worker.js | 2 +- .../admin/analytics/OrderPathFunnelBlock.jsx | 53 ++++++ .../analytics/OrderPathFunnelBlock.test.jsx | 14 ++ .../logistics/LogisticsReadinessBoard.jsx | 14 ++ src/components/orders/AutocallStatusCard.jsx | 6 +- src/components/orders/OrderDetailPanel.jsx | 17 +- src/pages/ClientDeliveryPage.jsx | 16 +- src/services/deliveryInvitationApi.js | 1 + src/services/orderGroupViews.js | 60 +++++++ src/services/orderGroupViews.test.js | 31 ++++ src/services/supabase/orderGroupRepository.js | 5 +- supabase/admin_comms_funnel.sql | 23 ++- .../20260929_confirmation_channel.sql | 160 ++++++++++++++++++ 13 files changed, 394 insertions(+), 8 deletions(-) create mode 100644 supabase/migrations/20260929_confirmation_channel.sql diff --git a/public/service-worker.js b/public/service-worker.js index 93f6c0c..a374282 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-v127"; + const STATIC_CACHE = "construction-delivery-static-v128"; 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/src/components/admin/analytics/OrderPathFunnelBlock.jsx b/src/components/admin/analytics/OrderPathFunnelBlock.jsx index 7801791..2dbfc88 100644 --- a/src/components/admin/analytics/OrderPathFunnelBlock.jsx +++ b/src/components/admin/analytics/OrderPathFunnelBlock.jsx @@ -8,6 +8,7 @@ import React from 'react'; import { FunnelChart, Funnel, LabelList, Tooltip, ResponsiveContainer } from 'recharts'; import { Panel } from '../../UI/Panel'; +import { CONFIRM_CHANNEL_META, CONFIRM_CHANNEL_ORDER } from '../../../services/orderGroupViews'; // Цвета этапов: от «всех» к «прошли всю цепочку» — холодный → зелёный const STAGE_COLORS = { @@ -17,6 +18,16 @@ const STAGE_COLORS = { done: '#22c55e', }; +const CHANNEL_COLORS = { + push: '#8b5cf6', + sms: '#22c55e', + call: '#6366f1', + link: '#06b6d4', + messenger: '#0ea5e9', + manual: '#eab308', + unknown: '#64748b', +}; + const SOURCE_LABELS = { manual: 'Менеджер', autocall: 'Робот-звонок', @@ -83,6 +94,12 @@ export const OrderPathFunnelBlock = ({ funnel, isLoading, mobile = false, period fill: STAGE_COLORS[s.key] || '#3b82f6', })); + const channels = path?.channels || []; + const datedTotal = channels.reduce((sum, c) => sum + (Number(c.count) || 0), 0); + const channelRows = [...channels] + .filter((c) => c.channel !== 'unknown') + .sort((a, b) => (CONFIRM_CHANNEL_ORDER.indexOf(a.channel) - CONFIRM_CHANNEL_ORDER.indexOf(b.channel))); + const bypass = path?.bypass || {}; const split = path?.split || {}; const sources = path?.sources || []; @@ -161,6 +178,42 @@ export const OrderPathFunnelBlock = ({ funnel, isLoading, mobile = false, period + {/* ── Как согласовали дату: пуша / SMS-ссылка / звонок / логист ─────── */} + {channelRows.length > 0 && ( +
+
+ Как согласовали дату +
+
+ {channelRows.map((row) => { + const meta = CONFIRM_CHANNEL_META[row.channel] || { label: row.channel, icon: '•', tone: 'neutral' }; + const share = datedTotal > 0 ? Math.round((row.count / datedTotal) * 100) : 0; + return ( +
+ + {meta.label} + + + + + {row.count} + {share}% +
+ ); + })} +
+
+ )} + {/* ── Откуда взялась дата + тип заказа ─────────────────────────────── */}
{sources.map((s) => ( diff --git a/src/components/admin/analytics/OrderPathFunnelBlock.test.jsx b/src/components/admin/analytics/OrderPathFunnelBlock.test.jsx index 19c9005..5d73e7b 100644 --- a/src/components/admin/analytics/OrderPathFunnelBlock.test.jsx +++ b/src/components/admin/analytics/OrderPathFunnelBlock.test.jsx @@ -14,6 +14,11 @@ const funnel = { ], bypass: { delivered: 199, paid_storage: 3, cancelled: 0 }, split: { pickup: 235, delivery: 161 }, + channels: [ + { channel: "manual", count: 135 }, + { channel: "sms", count: 20 }, + { channel: "call", count: 7 }, + ], sources: [ { source: "manual", count: 135 }, { source: "autocall", count: 6 }, @@ -71,4 +76,13 @@ describe("OrderPathFunnelBlock", () => { expect(l).toContain("Путь заказа"); expect(l).toContain("Загружаю…"); }); + + it("показывает, каким каналом согласовали дату", () => { + expect(html).toContain("Как согласовали дату"); + expect(html).toContain("Логист"); + expect(html).toContain("SMS-ссылка"); + expect(html).toContain("Звонок робота"); + expect(html).toContain("135"); + expect(html).toContain("20"); + }); }); diff --git a/src/components/logistics/LogisticsReadinessBoard.jsx b/src/components/logistics/LogisticsReadinessBoard.jsx index 5b9fc18..bdaac28 100644 --- a/src/components/logistics/LogisticsReadinessBoard.jsx +++ b/src/components/logistics/LogisticsReadinessBoard.jsx @@ -21,6 +21,7 @@ import { getOrderGroupDisplayStatusValue, getOrderGroupStatusTone, getSmsProblemTone, + confirmChannelMeta, ORDER_GROUP_DISPLAY_STATUS_OPTIONS, } from "../../services/orderGroupViews"; import { Badge } from "../UI/Badge"; @@ -186,6 +187,7 @@ const SMS_TAG = { const renderRow = (g, onSelectSet) => { const statusTone = getOrderGroupStatusTone(g); + const confirmChannel = confirmChannelMeta(g); const stale = isStale(g); const databaseAge = getDatabaseAge(g); const smsTone = getSmsProblemTone(g); @@ -241,6 +243,18 @@ const renderRow = (g, onSelectSet) => {
{g.assignedDriverName || "—"}
{getOrderGroupDisplayStatusLabel(g)} + {/* Как и кем согласовано: пуш / SMS-ссылка / звонок робота / логист. + Владелец: «в колонке с именем клиента пишется просто «Согласовано» и + не понятно, как и кем оно согласовано». */} + {confirmChannel ? ( +
+ + {confirmChannel.label} +
+ ) : null}
{formatDateTime(g.updatedAt)}
diff --git a/src/components/orders/AutocallStatusCard.jsx b/src/components/orders/AutocallStatusCard.jsx index e327d23..6120438 100644 --- a/src/components/orders/AutocallStatusCard.jsx +++ b/src/components/orders/AutocallStatusCard.jsx @@ -316,8 +316,10 @@ export const AutocallStatusCard = ({ order, userRole }) => { {c.attempt_no > 1 && Попытка {c.attempt_no}} {slot && Выбрано: {slot}} {c.duration_sec > 0 && {Math.round(c.duration_sec)} сек} - {c.scenario === "B" && только завтра} - {c.scenario === "A" && завтра/послезавтра} + {/* Это описание того, что робот ПРЕДЛАГАЛ в этом звонке, а не дата + заказа. Без пометки строка читалась как «заказ на завтра/послезавтра». */} + {c.scenario === "B" && робот предлагал: только завтра} + {c.scenario === "A" && робот предлагал: завтра или послезавтра} {c.changed && клиент правил выбор}
)} diff --git a/src/components/orders/OrderDetailPanel.jsx b/src/components/orders/OrderDetailPanel.jsx index c06fd12..073ee63 100644 --- a/src/components/orders/OrderDetailPanel.jsx +++ b/src/components/orders/OrderDetailPanel.jsx @@ -72,6 +72,7 @@ import { getOrderGroupStatusTone, DELIVERY_GROUP_STATUS_LABELS, } from "../../services/orderGroupViews"; +import { confirmChannelMeta as resolveOrderConfirmChannel } from "../../services/orderGroupViews"; import { getErrorMessage, normalizeNom, @@ -824,6 +825,9 @@ export const OrderDetailPanel = ({ || (deliveryType === "delivery" && !isPickupOrder) ); const canEditDelivery = canManageDelivery && ["admin", "mega_admin", "logistician"].includes(userRole); + // Чем согласовали дату: push / SMS-ссылка / звонок робота / логист. + const orderConfirmChannel = resolveOrderConfirmChannel(order); + const agreedDeliveryLabel = (() => { if (isPickupOrder) { const parts = [ @@ -978,7 +982,18 @@ export const OrderDetailPanel = ({ {agreedDeliveryLabel || "Дата не указана — нажмите «Изменить дату»"}

- Согласовано +
+ Согласовано + {orderConfirmChannel ? ( + + + {orderConfirmChannel.label} + + ) : null} +
{canEditDelivery ? ( diff --git a/src/pages/ClientDeliveryPage.jsx b/src/pages/ClientDeliveryPage.jsx index c2daa3f..be72c1a 100644 --- a/src/pages/ClientDeliveryPage.jsx +++ b/src/pages/ClientDeliveryPage.jsx @@ -1,5 +1,5 @@ import React from "react"; -import { useParams } from "react-router-dom"; +import { useParams, useLocation } from "react-router-dom"; import { DeliveryChoiceFlow } from "../components/client/DeliveryChoiceFlow"; import { DeliverySlotsPicker } from "../components/client/DeliverySlotsPicker"; import { PickupSlotsPicker } from "../components/client/PickupSlotsPicker"; @@ -326,6 +326,19 @@ const TAB_PICKUP = "pickup"; export const ClientDeliveryPage = () => { const { token } = useParams(); + const location = useLocation(); + // Канал, по которому клиент пришёл: /delivery/{hash}?src=sms|push|messenger. + // Владелец просил видеть, чем согласовали заказ — пишем канал в order_groups + // (delivery_date_source) через confirm_delivery_choice_by_token. + const invitationSrc = React.useMemo(() => { + try { + const raw = new URLSearchParams(location.search || "").get("src"); + const value = String(raw || "").trim().toLowerCase(); + return ["push", "sms", "messenger"].includes(value) ? value : ""; + } catch { + return ""; + } + }, [location.search]); const [invitation, setInvitation] = React.useState(null); const [loading, setLoading] = React.useState(Boolean(token)); const [error, setError] = React.useState(""); @@ -446,6 +459,7 @@ export const ClientDeliveryPage = () => { try { await confirmDeliveryChoice({ token, + src: invitationSrc, deliveryTime: effectiveSelectedSlot.time, deliveryDate: effectiveSelectedSlot.date, deliveryType: activeTab, diff --git a/src/services/deliveryInvitationApi.js b/src/services/deliveryInvitationApi.js index 4dfa387..e041924 100644 --- a/src/services/deliveryInvitationApi.js +++ b/src/services/deliveryInvitationApi.js @@ -304,6 +304,7 @@ export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime, p_pickup_date: pickupDate || null, p_pickup_time_slot: pickupTimeSlot || null, p_delivery_address: deliveryAddress || null, + p_src: src || null, }); }; diff --git a/src/services/orderGroupViews.js b/src/services/orderGroupViews.js index f27153b..1272a23 100644 --- a/src/services/orderGroupViews.js +++ b/src/services/orderGroupViews.js @@ -435,6 +435,66 @@ export const resolveAutocallStatus = (calls = [], { optOut = false, exclusionRea return "not_called"; }; +// ─── Как согласовали дату: канал подтверждения ────────────────────────────── +// Владелец: «нужно видеть, согласование прошло через пуш / смс / звонок / вручную +// через менеджера» и «в колонке с именем клиента пишется просто «Согласовано» — +// непонятно, как и кем». Это единственный источник подписей канала: их берут +// таблица реестра, карточка заказа и воронка на «Аналитике». +// +// Источник правды — order_groups.delivery_date_source. Его пишут: +// manual — логист в админке (orderGroupRepository.assignDeliveryDate); +// autocall — робот, когда клиент выбрал слот в звонке (edge fn autocall-result); +// push/sms/messenger/client — клиент на странице /delivery/{hash}?src=... +// (до 29.09.2026 канал клиента НЕ записывался — такие строки разбираем по +// отметке приглашения: confirmed_at, ключ 'link'). +export const CONFIRM_CHANNEL_META = { + push: { label: "Push", short: "push", tone: "accent", icon: "🔔" }, + sms: { label: "SMS-ссылка", short: "SMS", tone: "ok", icon: "✉️" }, + call: { label: "Звонок робота", short: "звонок", tone: "info", icon: "📞" }, + manual: { label: "Логист", short: "логист", tone: "accent", icon: "🧑💼" }, + messenger: { label: "Мессенджер", short: "мессенджер", tone: "info", icon: "💬" }, + link: { label: "По ссылке", short: "ссылка", tone: "ok", icon: "🔗" }, +}; + +// Порядок для легенды и воронки: от автоматических каналов к ручным. +export const CONFIRM_CHANNEL_ORDER = ["push", "sms", "call", "link", "messenger", "manual"]; + +/** + * Каким каналом согласовали дату. null — дата не проставлена или канал неизвестен. + * Принимает строку группы (camelCase из orderGroupRepository) или snake_case из БД. + */ +export const resolveConfirmChannel = (group = {}) => { + if (!group) return null; + // Без даты канала быть не может: иначе реестр напишет «согласовано логистом» + // там, где даты ещё нет вовсе. + const hasDate = !!( + group.deliveryDate || group.delivery_date || group.pickupDate || group.pickup_date + ); + if (!hasDate) return null; + const source = String(group.deliveryDateSource ?? group.delivery_date_source ?? "").trim().toLowerCase(); + if (source === "autocall") return "call"; + if (source === "manual") return "manual"; + if (source === "push") return "push"; + if (source === "sms") return "sms"; + if (source === "messenger" || source === "client") return "messenger"; + // Источник пуст → старые записи (канал клиента не писался). Смотрим отметку приглашения. + const invited = group.invitationConfirmedAt ?? group.invitation_confirmed_at + ?? group.deliveryInvitationConfirmedAt ?? group.delivery_invitation_confirmed_at + ?? group.deliveryInvitation?.confirmedAt; + if (invited) return "link"; + const opened = group.invitationOpenedAt ?? group.invitation_opened_at; + if (opened) return "link"; + if (group.manualConfirmationAt ?? group.manual_confirmation_at) return "manual"; + if (group.called ?? group.called_at) return "call"; + return null; +}; + +/** Подпись канала для интерфейса; null, если канал неизвестен. */ +export const confirmChannelMeta = (group = {}) => { + const key = resolveConfirmChannel(group); + return key ? { key, ...CONFIRM_CHANNEL_META[key] } : null; +}; + export const filterOrderGroups = (groups, filters = {}) => { const query = normalizeDate(filters.query).trim().toLowerCase(); const status = filters.status || "all"; diff --git a/src/services/orderGroupViews.test.js b/src/services/orderGroupViews.test.js index b00a59e..12e3033 100644 --- a/src/services/orderGroupViews.test.js +++ b/src/services/orderGroupViews.test.js @@ -11,6 +11,8 @@ import { getOrderGroupDeliveryHalfDay, getOrderGroupStatusTone, ORDER_GROUP_DISPLAY_STATUS_OPTIONS, + resolveConfirmChannel, + confirmChannelMeta, groupOrderGroupsByDate, isOrderGroupAgreedForDelivery, isOrderGroupVisibleToDriver, @@ -177,3 +179,32 @@ describe("orderGroupViews", () => { expect(grouped[0].date).toBe("2026-09-30"); }); }); + +describe("resolveConfirmChannel — как согласовали дату", () => { + it("читает явный источник даты", () => { + expect(resolveConfirmChannel({ deliveryDateSource: "autocall", deliveryDate: "2026-09-29" })).toBe("call"); + expect(resolveConfirmChannel({ deliveryDateSource: "manual", deliveryDate: "2026-09-29" })).toBe("manual"); + expect(resolveConfirmChannel({ deliveryDateSource: "push", deliveryDate: "2026-09-29" })).toBe("push"); + expect(resolveConfirmChannel({ deliveryDateSource: "sms", pickupDate: "2026-09-30" })).toBe("sms"); + expect(resolveConfirmChannel({ deliveryDateSource: "messenger", deliveryDate: "2026-09-29" })).toBe("messenger"); + }); + + it("старые записи (источник не пишется) разбирает по отметке приглашения", () => { + expect(resolveConfirmChannel({ deliveryDate: "2026-09-29", invitationConfirmedAt: "2026-09-28T10:00:00Z" })).toBe("link"); + expect(resolveConfirmChannel({ pickupDate: "2026-09-29", invitationOpenedAt: "2026-09-28T09:00:00Z" })).toBe("link"); + }); + + it("без даты канала нет — иначе реестр покажет «согласовано» там, где нечего согласовывать", () => { + expect(resolveConfirmChannel({ deliveryDateSource: "manual" })).toBeNull(); + expect(resolveConfirmChannel({})).toBeNull(); + expect(resolveConfirmChannel(null)).toBeNull(); + }); + + it("подписи канала — человеческие", () => { + const manual = confirmChannelMeta({ deliveryDateSource: "manual", deliveryDate: "2026-09-29" }); + expect(manual).toMatchObject({ key: "manual", label: "Логист" }); + expect(manual.icon).toBeTruthy(); + expect(confirmChannelMeta({ deliveryDate: "2026-09-29", invitationConfirmedAt: "2026-09-28T10:00:00Z" }).label).toBe("По ссылке"); + expect(confirmChannelMeta({})).toBeNull(); + }); +}); diff --git a/src/services/supabase/orderGroupRepository.js b/src/services/supabase/orderGroupRepository.js index a7c8dfc..0a22c8b 100644 --- a/src/services/supabase/orderGroupRepository.js +++ b/src/services/supabase/orderGroupRepository.js @@ -256,6 +256,7 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => { called_at: row.called_at || null, calledComment: row.called_comment || null, called_comment: row.called_comment || null, + invitationConfirmedAt: row.delivery_invitation?.confirmed_at || null, invitationOpenedAt: row.delivery_invitation?.opened_at || null, invitationAccessCount: row.delivery_invitation?.access_count || 0, invitationLastAccessedAt: row.delivery_invitation?.last_accessed_at || null, @@ -299,8 +300,8 @@ 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, autocall_exclusion_reason, autocall_status`; +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(confirmed_at, 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, autocall_exclusion_reason, autocall_status, delivery_invitation:delivery_invitations!order_groups_delivery_invitation_id_fkey(confirmed_at)`; // Delivery statuses treated as "finished" — hidden by default in the board. const COMPLETED_DELIVERY_STATUSES = ["delivered", "picked_up", "cancelled"]; diff --git a/supabase/admin_comms_funnel.sql b/supabase/admin_comms_funnel.sql index 6d9d1d2..c885975 100644 --- a/supabase/admin_comms_funnel.sql +++ b/supabase/admin_comms_funnel.sql @@ -85,6 +85,24 @@ path as ( count(*) filter (where delivery_type = 'delivery') as delivery_cnt from cohort ), +path_channels as ( + select + case + when c.delivery_date_source = 'autocall' then 'call' + when c.delivery_date_source = 'manual' then 'manual' + when c.delivery_date_source = 'push' then 'push' + when c.delivery_date_source in ('sms', 'client', 'messenger') then 'sms' + when exists (select 1 from delivery_invitations di + where di.order_group_id = c.id and di.confirmed_at is not null) then 'sms' + when c.manual_confirmation_at is not null then 'manual' + when c.called then 'call' + else 'unknown' + end as channel, + count(*) as cnt + from cohort c + where c.delivery_date is not null or c.pickup_date is not null + group by 1 +), path_sources as ( select coalesce(delivery_date_source, 'none') as source, count(*) as cnt from cohort @@ -176,7 +194,10 @@ select jsonb_build_object( 'bypass', jsonb_build_object('delivered', pp.bypass_done, 'paid_storage', pp.paid_storage, 'cancelled', pp.cancelled), 'split', jsonb_build_object('pickup', pp.pickup_cnt, 'delivery', pp.delivery_cnt), 'sources', coalesce((select jsonb_agg(jsonb_build_object('source', ps.source, 'count', ps.cnt) - order by ps.cnt desc) from path_sources ps), '[]'::jsonb) + order by ps.cnt desc) from path_sources ps), '[]'::jsonb), + -- Каким каналом согласовали дату: push / sms / звонок робота / вручную (логист). + 'channels', coalesce((select jsonb_agg(jsonb_build_object('channel', pc.channel, 'count', pc.cnt) + order by pc.cnt desc) from path_channels pc), '[]'::jsonb) ) ) from stages s, pct p, path pp; diff --git a/supabase/migrations/20260929_confirmation_channel.sql b/supabase/migrations/20260929_confirmation_channel.sql new file mode 100644 index 0000000..ff46f21 --- /dev/null +++ b/supabase/migrations/20260929_confirmation_channel.sql @@ -0,0 +1,160 @@ +-- 20260929_confirmation_channel.sql +-- Каким каналом клиент согласовал доставку/самовывоз. +-- +-- Владелец: «нужно видеть, согласование прошло через пуш / смс / звонок / вручную +-- через менеджера», потому что в реестре и карточке было просто «Согласовано». +-- +-- Источник правды — order_groups.delivery_date_source. Его уже пишут: +-- manual — логист в админке; +-- autocall — робот (edge fn autocall-result). +-- Здесь добавляем клиентские каналы: страница /delivery/{hash}?src=<канал> передаёт +-- src в RPC, а RPC сохраняет его в delivery_date_source и в action_logs.details. + +DROP FUNCTION IF EXISTS public.confirm_delivery_choice_by_token( + text, text, text, text, text, text, text +); + +CREATE OR REPLACE FUNCTION public.confirm_delivery_choice_by_token( + p_token text, + p_delivery_date text, + p_delivery_time text, + p_delivery_type text DEFAULT 'delivery'::text, + p_pickup_date text DEFAULT NULL::text, + p_pickup_time_slot text DEFAULT NULL::text, + p_delivery_address text DEFAULT NULL::text, + p_src text DEFAULT NULL::text +) + RETURNS jsonb + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO 'public', 'extensions' +AS $function$ +DECLARE + v_invitation record; + v_order_group_id uuid; + v_current_status text; + v_delivery_status text; + v_notification_status text; + v_delivery_date_val date; + v_existing_address text; + v_effective_address text; + v_delivery_days int[]; + v_dow int; + v_day_num int; + v_channel text; +BEGIN + -- Канал подтверждения: push / sms / messenger из ссылки, иначе «клиент» (прямой заход). + v_channel := CASE + WHEN p_src IN ('push', 'sms', 'messenger') THEN p_src + ELSE 'client' + END; + + SELECT * INTO v_invitation + FROM delivery_invitations + WHERE token_hash = encode(digest(p_token, 'sha256'), 'hex') + AND state IN ('awaiting_choice', 'sent') + LIMIT 1; + + IF NOT found THEN + RETURN jsonb_build_object('ok', false, 'error', 'Invitation not found or not active'); + END IF; + + v_order_group_id := v_invitation.order_group_id; + + SELECT delivery_status, customer_address INTO v_current_status, v_existing_address + FROM order_groups + WHERE id = v_order_group_id; + + IF v_existing_address IS NOT NULL AND UPPER(TRIM(v_existing_address)) = 'САМОВЫВОЗ' THEN + v_existing_address := NULL; + END IF; + + IF v_current_status NOT IN ('pending_confirmation', 'manual_confirmation_required') THEN + RETURN jsonb_build_object('ok', false, 'error', 'Order is not pending confirmation'); + END IF; + + v_effective_address := COALESCE(NULLIF(p_delivery_address, ''), v_existing_address); + + IF p_delivery_type = 'pickup' THEN + v_delivery_status := 'pickup'; + v_notification_status := 'confirmed'; + ELSIF v_effective_address IS NOT NULL AND v_effective_address != '' THEN + v_delivery_status := 'agreed'; + v_notification_status := 'confirmed'; + ELSE + v_delivery_status := 'requires_address'; + v_notification_status := 'address_required'; + END IF; + + v_delivery_date_val := CASE WHEN p_delivery_date IS NOT NULL AND p_delivery_date != '' THEN p_delivery_date::date ELSE NULL END; + + -- Check delivery_days from business_schedule_settings (only for delivery type) + IF p_delivery_type = 'delivery' AND v_delivery_date_val IS NOT NULL THEN + SELECT delivery_days INTO v_delivery_days FROM business_schedule_settings WHERE id = 1; + IF v_delivery_days IS NULL THEN + v_delivery_days := ARRAY[1,2,3,4,5]; + END IF; + -- extract(dow): 0=Sunday, 1=Monday, ..., 6=Saturday + -- business_schedule uses 1=Monday ... 7=Sunday + v_dow := extract(dow from v_delivery_date_val)::int; + v_day_num := CASE WHEN v_dow = 0 THEN 7 ELSE v_dow END; + IF NOT (v_day_num = ANY(v_delivery_days)) THEN + RETURN jsonb_build_object('ok', false, 'error', 'Delivery is not available on this day'); + END IF; + END IF; + + UPDATE delivery_invitations SET + state = 'agreed', + delivery_date = COALESCE(v_delivery_date_val, delivery_date), + delivery_time = COALESCE(NULLIF(p_delivery_time, ''), delivery_time), + confirmed_at = now(), + access_count = COALESCE(access_count, 0) + 1, + last_accessed_at = now() + WHERE id = v_invitation.id; + + UPDATE order_groups SET + delivery_status = v_delivery_status, + delivery_date = COALESCE(v_delivery_date_val, delivery_date), + delivery_time = COALESCE(NULLIF(p_delivery_time, ''), delivery_time), + delivery_type = p_delivery_type, + notification_status = v_notification_status, + delivery_date_source = v_channel, + pickup_date = CASE WHEN p_delivery_type = 'pickup' THEN COALESCE(NULLIF(p_pickup_date, '')::date, v_delivery_date_val) ELSE NULL END, + pickup_time_slot = CASE WHEN p_delivery_type = 'pickup' THEN COALESCE(NULLIF(p_pickup_time_slot, ''), p_delivery_time) ELSE NULL END, + delivery_address = CASE WHEN p_delivery_type = 'delivery' AND v_effective_address IS NOT NULL AND v_effective_address != '' THEN v_effective_address ELSE delivery_address END, + customer_address = CASE WHEN p_delivery_type = 'delivery' AND v_effective_address IS NOT NULL AND v_effective_address != '' THEN v_effective_address ELSE customer_address END, + assigned_driver_id = CASE WHEN p_delivery_type = 'pickup' THEN NULL ELSE assigned_driver_id END, + status = CASE + WHEN p_delivery_type = 'pickup' AND v_delivery_status = 'pickup' THEN 'pickup' + WHEN v_delivery_status = 'agreed' THEN 'agreed' + WHEN v_delivery_status = 'requires_address' THEN 'requires_address' + ELSE status + END, + updated_at = now() + WHERE id = v_order_group_id; + + INSERT INTO action_logs (order_group_id, action, old_value, new_value, details) + VALUES ( + v_order_group_id, + 'client_confirmed', + v_current_status, + v_delivery_status, + jsonb_build_object( + 'delivery_date', p_delivery_date, + 'delivery_time', p_delivery_time, + 'delivery_type', p_delivery_type, + 'pickup_date', p_pickup_date, + 'pickup_time_slot', p_pickup_time_slot, + 'source', 'auto', + 'channel', v_channel + ) + ); + + RETURN jsonb_build_object( + 'ok', true, + 'orderGroupId', v_order_group_id, + 'deliveryStatus', v_delivery_status, + 'channel', v_channel + ); +END; +$function$;