feat(delivery): видно, чем согласовали заказ — пуш / SMS / звонок / логист

Владелец: «нужно видеть, согласование прошло через пуш/смс/звонок/вручную через
менеджера» и «в колонке с именем клиента пишется просто «Согласовано» — непонятно
как и кем». Плюс в карточке «завтра/послезавтра» читалось как дата заказа.

Источник правды — 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.
This commit is contained in:
Hermes 2026-09-29 11:54:04 +02:00
parent c3df1fc0e1
commit 20e942235a
13 changed files with 394 additions and 8 deletions

View File

@ -1,7 +1,7 @@
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1"; const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
if (!isLocalhost) { if (!isLocalhost) {
const STATIC_CACHE = "construction-delivery-static-v127"; const STATIC_CACHE = "construction-delivery-static-v128";
const RUNTIME_CACHE = "construction-delivery-runtime-v119"; const RUNTIME_CACHE = "construction-delivery-runtime-v119";
const APP_SHELL_URLS = ["/", "/client", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"]; const APP_SHELL_URLS = ["/", "/client", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];

View File

@ -8,6 +8,7 @@
import React from 'react'; import React from 'react';
import { FunnelChart, Funnel, LabelList, Tooltip, ResponsiveContainer } from 'recharts'; import { FunnelChart, Funnel, LabelList, Tooltip, ResponsiveContainer } from 'recharts';
import { Panel } from '../../UI/Panel'; import { Panel } from '../../UI/Panel';
import { CONFIRM_CHANNEL_META, CONFIRM_CHANNEL_ORDER } from '../../../services/orderGroupViews';
// Цвета этапов: от «всех» к «прошли всю цепочку» — холодный → зелёный // Цвета этапов: от «всех» к «прошли всю цепочку» — холодный → зелёный
const STAGE_COLORS = { const STAGE_COLORS = {
@ -17,6 +18,16 @@ const STAGE_COLORS = {
done: '#22c55e', done: '#22c55e',
}; };
const CHANNEL_COLORS = {
push: '#8b5cf6',
sms: '#22c55e',
call: '#6366f1',
link: '#06b6d4',
messenger: '#0ea5e9',
manual: '#eab308',
unknown: '#64748b',
};
const SOURCE_LABELS = { const SOURCE_LABELS = {
manual: 'Менеджер', manual: 'Менеджер',
autocall: 'Робот-звонок', autocall: 'Робот-звонок',
@ -83,6 +94,12 @@ export const OrderPathFunnelBlock = ({ funnel, isLoading, mobile = false, period
fill: STAGE_COLORS[s.key] || '#3b82f6', 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 bypass = path?.bypass || {};
const split = path?.split || {}; const split = path?.split || {};
const sources = path?.sources || []; const sources = path?.sources || [];
@ -161,6 +178,42 @@ export const OrderPathFunnelBlock = ({ funnel, isLoading, mobile = false, period
</div> </div>
</div> </div>
{/* ── Как согласовали дату: пуша / SMS-ссылка / звонок / логист ─────── */}
{channelRows.length > 0 && (
<div style={{ marginTop: '0.75rem' }}>
<div style={{
fontSize: fontSize.xs, textTransform: 'uppercase', letterSpacing: '0.06em',
color: 'var(--color-text-muted)', marginBottom: '0.35rem',
}}>
Как согласовали дату
</div>
<div style={{ display: 'grid', gap: '0.3rem' }}>
{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 (
<div key={row.channel} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: fontSize.s }}>
<span style={{ width: mobile ? '6.5rem' : '8.5rem', flex: '0 0 auto', color: 'var(--color-text-muted)' }}>
<span aria-hidden="true">{meta.icon}</span> {meta.label}
</span>
<span style={{
flex: '1 1 auto', height: '8px', borderRadius: '4px',
background: 'var(--color-surface-strong)', overflow: 'hidden', minWidth: '40px',
}}>
<span style={{
display: 'block', height: '100%', width: `${Math.max(share, 2)}%`,
background: CHANNEL_COLORS[row.channel] || '#64748b',
}} />
</span>
<strong style={{ color: 'var(--color-text)', fontSize: fontSize.s }}>{row.count}</strong>
<span style={{ color: 'var(--color-text-muted)', fontSize: fontSize.xs, width: '2.6rem', textAlign: 'right' }}>{share}%</span>
</div>
);
})}
</div>
</div>
)}
{/* ── Откуда взялась дата + тип заказа ─────────────────────────────── */} {/* ── Откуда взялась дата + тип заказа ─────────────────────────────── */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
{sources.map((s) => ( {sources.map((s) => (

View File

@ -14,6 +14,11 @@ const funnel = {
], ],
bypass: { delivered: 199, paid_storage: 3, cancelled: 0 }, bypass: { delivered: 199, paid_storage: 3, cancelled: 0 },
split: { pickup: 235, delivery: 161 }, split: { pickup: 235, delivery: 161 },
channels: [
{ channel: "manual", count: 135 },
{ channel: "sms", count: 20 },
{ channel: "call", count: 7 },
],
sources: [ sources: [
{ source: "manual", count: 135 }, { source: "manual", count: 135 },
{ source: "autocall", count: 6 }, { source: "autocall", count: 6 },
@ -71,4 +76,13 @@ describe("OrderPathFunnelBlock", () => {
expect(l).toContain("Путь заказа"); expect(l).toContain("Путь заказа");
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");
});
}); });

View File

@ -21,6 +21,7 @@ import {
getOrderGroupDisplayStatusValue, getOrderGroupDisplayStatusValue,
getOrderGroupStatusTone, getOrderGroupStatusTone,
getSmsProblemTone, getSmsProblemTone,
confirmChannelMeta,
ORDER_GROUP_DISPLAY_STATUS_OPTIONS, ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
} from "../../services/orderGroupViews"; } from "../../services/orderGroupViews";
import { Badge } from "../UI/Badge"; import { Badge } from "../UI/Badge";
@ -186,6 +187,7 @@ const SMS_TAG = {
const renderRow = (g, onSelectSet) => { const renderRow = (g, onSelectSet) => {
const statusTone = getOrderGroupStatusTone(g); const statusTone = getOrderGroupStatusTone(g);
const confirmChannel = confirmChannelMeta(g);
const stale = isStale(g); const stale = isStale(g);
const databaseAge = getDatabaseAge(g); const databaseAge = getDatabaseAge(g);
const smsTone = getSmsProblemTone(g); const smsTone = getSmsProblemTone(g);
@ -241,6 +243,18 @@ const renderRow = (g, onSelectSet) => {
<div className="px-3 py-2.5 text-sm text-[var(--color-text-muted)]">{g.assignedDriverName || "—"}</div> <div className="px-3 py-2.5 text-sm text-[var(--color-text-muted)]">{g.assignedDriverName || "—"}</div>
<div className="px-3 py-2.5"> <div className="px-3 py-2.5">
<Badge tone={statusTone}>{getOrderGroupDisplayStatusLabel(g)}</Badge> <Badge tone={statusTone}>{getOrderGroupDisplayStatusLabel(g)}</Badge>
{/* Как и кем согласовано: пуш / SMS-ссылка / звонок робота / логист.
Владелец: «в колонке с именем клиента пишется просто «Согласовано» и
не понятно, как и кем оно согласовано». */}
{confirmChannel ? (
<div
className="mt-1 inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-[var(--color-surface-strong)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]"
title={`Согласовано: ${confirmChannel.label}`}
>
<span aria-hidden="true">{confirmChannel.icon}</span>
{confirmChannel.label}
</div>
) : null}
</div> </div>
<div className="px-3 py-2.5 text-xs text-[var(--color-text-muted)]">{formatDateTime(g.updatedAt)}</div> <div className="px-3 py-2.5 text-xs text-[var(--color-text-muted)]">{formatDateTime(g.updatedAt)}</div>
<div className={`px-3 py-2.5 text-xs ${databaseAge.textClass}`}> <div className={`px-3 py-2.5 text-xs ${databaseAge.textClass}`}>

View File

@ -316,8 +316,10 @@ export const AutocallStatusCard = ({ order, userRole }) => {
{c.attempt_no > 1 && <span>Попытка {c.attempt_no}</span>} {c.attempt_no > 1 && <span>Попытка {c.attempt_no}</span>}
{slot && <span>Выбрано: {slot}</span>} {slot && <span>Выбрано: {slot}</span>}
{c.duration_sec > 0 && <span>{Math.round(c.duration_sec)} сек</span>} {c.duration_sec > 0 && <span>{Math.round(c.duration_sec)} сек</span>}
{c.scenario === "B" && <span>только завтра</span>} {/* Это описание того, что робот ПРЕДЛАГАЛ в этом звонке, а не дата
{c.scenario === "A" && <span>завтра/послезавтра</span>} заказа. Без пометки строка читалась как «заказ на завтра/послезавтра». */}
{c.scenario === "B" && <span>робот предлагал: только завтра</span>}
{c.scenario === "A" && <span>робот предлагал: завтра или послезавтра</span>}
{c.changed && <span className="text-[var(--color-warning)]">клиент правил выбор</span>} {c.changed && <span className="text-[var(--color-warning)]">клиент правил выбор</span>}
</div> </div>
)} )}

View File

@ -72,6 +72,7 @@ import {
getOrderGroupStatusTone, getOrderGroupStatusTone,
DELIVERY_GROUP_STATUS_LABELS, DELIVERY_GROUP_STATUS_LABELS,
} from "../../services/orderGroupViews"; } from "../../services/orderGroupViews";
import { confirmChannelMeta as resolveOrderConfirmChannel } from "../../services/orderGroupViews";
import { import {
getErrorMessage, getErrorMessage,
normalizeNom, normalizeNom,
@ -824,6 +825,9 @@ export const OrderDetailPanel = ({
|| (deliveryType === "delivery" && !isPickupOrder) || (deliveryType === "delivery" && !isPickupOrder)
); );
const canEditDelivery = canManageDelivery && ["admin", "mega_admin", "logistician"].includes(userRole); const canEditDelivery = canManageDelivery && ["admin", "mega_admin", "logistician"].includes(userRole);
// Чем согласовали дату: push / SMS-ссылка / звонок робота / логист.
const orderConfirmChannel = resolveOrderConfirmChannel(order);
const agreedDeliveryLabel = (() => { const agreedDeliveryLabel = (() => {
if (isPickupOrder) { if (isPickupOrder) {
const parts = [ const parts = [
@ -978,7 +982,18 @@ export const OrderDetailPanel = ({
{agreedDeliveryLabel || "Дата не указана — нажмите «Изменить дату»"} {agreedDeliveryLabel || "Дата не указана — нажмите «Изменить дату»"}
</p> </p>
</div> </div>
<Badge tone="accent">Согласовано</Badge> <div className="flex flex-col items-start gap-1.5 sm:items-end">
<Badge tone="accent">Согласовано</Badge>
{orderConfirmChannel ? (
<span
className="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-[var(--color-surface)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]"
title={`Как согласовано: ${orderConfirmChannel.label}`}
>
<span aria-hidden="true">{orderConfirmChannel.icon}</span>
{orderConfirmChannel.label}
</span>
) : null}
</div>
</div> </div>
</div> </div>
{canEditDelivery ? ( {canEditDelivery ? (

View File

@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { useParams } from "react-router-dom"; import { useParams, useLocation } from "react-router-dom";
import { DeliveryChoiceFlow } from "../components/client/DeliveryChoiceFlow"; import { DeliveryChoiceFlow } from "../components/client/DeliveryChoiceFlow";
import { DeliverySlotsPicker } from "../components/client/DeliverySlotsPicker"; import { DeliverySlotsPicker } from "../components/client/DeliverySlotsPicker";
import { PickupSlotsPicker } from "../components/client/PickupSlotsPicker"; import { PickupSlotsPicker } from "../components/client/PickupSlotsPicker";
@ -326,6 +326,19 @@ const TAB_PICKUP = "pickup";
export const ClientDeliveryPage = () => { export const ClientDeliveryPage = () => {
const { token } = useParams(); 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 [invitation, setInvitation] = React.useState(null);
const [loading, setLoading] = React.useState(Boolean(token)); const [loading, setLoading] = React.useState(Boolean(token));
const [error, setError] = React.useState(""); const [error, setError] = React.useState("");
@ -446,6 +459,7 @@ export const ClientDeliveryPage = () => {
try { try {
await confirmDeliveryChoice({ await confirmDeliveryChoice({
token, token,
src: invitationSrc,
deliveryTime: effectiveSelectedSlot.time, deliveryTime: effectiveSelectedSlot.time,
deliveryDate: effectiveSelectedSlot.date, deliveryDate: effectiveSelectedSlot.date,
deliveryType: activeTab, deliveryType: activeTab,

View File

@ -304,6 +304,7 @@ export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime,
p_pickup_date: pickupDate || null, p_pickup_date: pickupDate || null,
p_pickup_time_slot: pickupTimeSlot || null, p_pickup_time_slot: pickupTimeSlot || null,
p_delivery_address: deliveryAddress || null, p_delivery_address: deliveryAddress || null,
p_src: src || null,
}); });
}; };

View File

@ -435,6 +435,66 @@ export const resolveAutocallStatus = (calls = [], { optOut = false, exclusionRea
return "not_called"; 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 = {}) => { export const filterOrderGroups = (groups, filters = {}) => {
const query = normalizeDate(filters.query).trim().toLowerCase(); const query = normalizeDate(filters.query).trim().toLowerCase();
const status = filters.status || "all"; const status = filters.status || "all";

View File

@ -11,6 +11,8 @@ import {
getOrderGroupDeliveryHalfDay, getOrderGroupDeliveryHalfDay,
getOrderGroupStatusTone, getOrderGroupStatusTone,
ORDER_GROUP_DISPLAY_STATUS_OPTIONS, ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
resolveConfirmChannel,
confirmChannelMeta,
groupOrderGroupsByDate, groupOrderGroupsByDate,
isOrderGroupAgreedForDelivery, isOrderGroupAgreedForDelivery,
isOrderGroupVisibleToDriver, isOrderGroupVisibleToDriver,
@ -177,3 +179,32 @@ describe("orderGroupViews", () => {
expect(grouped[0].date).toBe("2026-09-30"); 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();
});
});

View File

@ -256,6 +256,7 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
called_at: row.called_at || null, called_at: row.called_at || null,
calledComment: row.called_comment || null, calledComment: row.called_comment || null,
called_comment: row.called_comment || null, called_comment: row.called_comment || null,
invitationConfirmedAt: row.delivery_invitation?.confirmed_at || null,
invitationOpenedAt: row.delivery_invitation?.opened_at || null, invitationOpenedAt: row.delivery_invitation?.opened_at || null,
invitationAccessCount: row.delivery_invitation?.access_count || 0, invitationAccessCount: row.delivery_invitation?.access_count || 0,
invitationLastAccessedAt: row.delivery_invitation?.last_accessed_at || null, 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)`; 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`; 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. // Delivery statuses treated as "finished" — hidden by default in the board.
const COMPLETED_DELIVERY_STATUSES = ["delivered", "picked_up", "cancelled"]; const COMPLETED_DELIVERY_STATUSES = ["delivered", "picked_up", "cancelled"];

View File

@ -85,6 +85,24 @@ path as (
count(*) filter (where delivery_type = 'delivery') as delivery_cnt count(*) filter (where delivery_type = 'delivery') as delivery_cnt
from cohort 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 ( path_sources as (
select coalesce(delivery_date_source, 'none') as source, count(*) as cnt select coalesce(delivery_date_source, 'none') as source, count(*) as cnt
from cohort 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), '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), '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) '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; from stages s, pct p, path pp;

View File

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