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.icon} {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.icon}
+ {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.icon}
+ {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$;