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:
parent
c3df1fc0e1
commit
20e942235a
|
|
@ -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"];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) => (
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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}`}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
<div className="flex flex-col items-start gap-1.5 sm:items-end">
|
||||||
<Badge tone="accent">Согласовано</Badge>
|
<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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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"];
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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$;
|
||||||
Loading…
Reference in New Issue