feat(analytics): «Путь заказа» — воронка по ступеням обработки
Владелец: «а путь заказа можно сдать какой-то красивой воронкой?» В admin_comms_funnel добавлен блок order_path: этапы (групп в работе → первое SMS → дата согласована → выдано по цепочке), честная плашка «мимо цепочки» (выдано без даты, платное хранение, отмены), источники даты (менеджер/робот/без даты) и разбивка самовывоз/доставка. Считается так, что водитель без даты не едет: последний этап — это выданные С датой, поэтому воронка монотонная. OrderPathFunnelBlock рисует воронку штатным recharts FunnelChart (градиент по этапам, подписи «значение · %», тултип с конверсией шага), под ней — легенда теми же цифрами (на телефоне воронку читать тяжело), чипы конверсий между ступенями, плашка «мимо цепочки» и чипы источников/типа заказа. Блок стоит над воронкой коммуникаций. За 30 дней: 396 → первое SMS 340 (85.9%) → дата 161 (47.4% от SMS) → выдано по цепочке 134 (83.2%); мимо цепочки 204; дата от менеджера 135, от робота 6; самовывоз 235, доставка 161. Тест: OrderPathFunnelBlock.test.jsx (6 проверок). SW v127.
This commit is contained in:
parent
48fb7ad662
commit
c3df1fc0e1
|
|
@ -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-v126";
|
const STATIC_CACHE = "construction-delivery-static-v127";
|
||||||
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"];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { useAdminStats } from '../../hooks/useAdminStats';
|
||||||
import { usePickupStats } from '../../hooks/usePickupStats';
|
import { usePickupStats } from '../../hooks/usePickupStats';
|
||||||
import { PickupStatsPanel } from './PickupStatsPanel';
|
import { PickupStatsPanel } from './PickupStatsPanel';
|
||||||
import { CommsFunnelBlock } from './analytics/CommsFunnelBlock';
|
import { CommsFunnelBlock } from './analytics/CommsFunnelBlock';
|
||||||
|
import { OrderPathFunnelBlock } from './analytics/OrderPathFunnelBlock';
|
||||||
|
|
||||||
// ── Mobile Detection Hook ───────────────────────────────────────────────────
|
// ── Mobile Detection Hook ───────────────────────────────────────────────────
|
||||||
const useIsMobile = () => {
|
const useIsMobile = () => {
|
||||||
|
|
@ -239,6 +240,9 @@ export const AdminDashboard = () => {
|
||||||
<KpiCard label="% доставки" value={sv.delivery_rate != null ? sv.delivery_rate + '%' : '—'} color="var(--color-text)" mobile={mobile} />
|
<KpiCard label="% доставки" value={sv.delivery_rate != null ? sv.delivery_rate + '%' : '—'} color="var(--color-text)" mobile={mobile} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Путь заказа: воронка по ступеням обработки ─────────────────────── */}
|
||||||
|
<OrderPathFunnelBlock funnel={funnel} isLoading={isLoading} mobile={mobile} periodLabel={periodLabel} />
|
||||||
|
|
||||||
{/* ── Воронка коммуникаций: push → SMS → звонки → согласование ───────── */}
|
{/* ── Воронка коммуникаций: push → SMS → звонки → согласование ───────── */}
|
||||||
<CommsFunnelBlock funnel={funnel} isLoading={isLoading} mobile={mobile} periodLabel={periodLabel} />
|
<CommsFunnelBlock funnel={funnel} isLoading={isLoading} mobile={mobile} periodLabel={periodLabel} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
/**
|
||||||
|
* @file OrderPathFunnelBlock.jsx
|
||||||
|
* @description «Путь заказа» — красивая воронка по странице «Аналитика» (mega_admin/admin).
|
||||||
|
* Этапы цепочки: группы в работе → первое SMS → согласована дата → выдано по цепочке.
|
||||||
|
* Плюс честная картина того, что прошло МИМО цепочки (в обход, без даты), источники даты
|
||||||
|
* и разбивка самовывоз/доставка. Данные — блок order_path из admin_comms_funnel.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { FunnelChart, Funnel, LabelList, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
|
import { Panel } from '../../UI/Panel';
|
||||||
|
|
||||||
|
// Цвета этапов: от «всех» к «прошли всю цепочку» — холодный → зелёный
|
||||||
|
const STAGE_COLORS = {
|
||||||
|
created: '#64748b',
|
||||||
|
sms: '#3b82f6',
|
||||||
|
dated: '#8b5cf6',
|
||||||
|
done: '#22c55e',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOURCE_LABELS = {
|
||||||
|
manual: 'Менеджер',
|
||||||
|
autocall: 'Робот-звонок',
|
||||||
|
link: 'Ссылка из SMS',
|
||||||
|
automation: 'Автоматически',
|
||||||
|
none: 'В обход (без даты)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const FunnelTooltip = ({ active, payload }) => {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const d = payload[0].payload;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--color-surface, #1e293d)',
|
||||||
|
border: '1px solid var(--color-border, #334155)',
|
||||||
|
borderRadius: '12px', padding: '8px 12px', fontSize: '0.8rem',
|
||||||
|
color: 'var(--color-text, #e2e8f0)',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: 600 }}>{d.name}</div>
|
||||||
|
<div>{d.value} групп · {d.pct}% от всех</div>
|
||||||
|
{d.convPct != null && <div style={{ color: '#94a3b8' }}>шаг: {d.convPct}%</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Chip = ({ label, value, color, fontSize }) => (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'baseline', gap: '0.35rem', padding: '0.3rem 0.55rem',
|
||||||
|
border: '1px solid var(--color-border, #334155)', borderRadius: '10px',
|
||||||
|
fontSize: fontSize.xs, color: 'var(--color-text-muted)', whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
<span style={{ width: '8px', height: '8px', borderRadius: '50%', background: color, display: 'inline-block' }} />
|
||||||
|
<span>{label}</span>
|
||||||
|
<strong style={{ color: 'var(--color-text)', fontSize: fontSize.s }}>{value}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const OrderPathFunnelBlock = ({ funnel, isLoading, mobile = false, periodLabel = '' }) => {
|
||||||
|
const fontSize = mobile
|
||||||
|
? { xs: '0.6rem', s: '0.7rem', m: '0.78rem', l: '0.85rem' }
|
||||||
|
: { xs: '0.72rem', s: '0.78rem', m: '0.85rem', l: '0.95rem' };
|
||||||
|
|
||||||
|
const path = funnel?.order_path;
|
||||||
|
const stages = path?.stages || [];
|
||||||
|
|
||||||
|
if (isLoading || !funnel) {
|
||||||
|
return (
|
||||||
|
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem' }}>
|
||||||
|
<h3 style={{ fontSize: mobile ? '0.9rem' : '1rem', fontWeight: 700, color: 'var(--color-text)' }}>Путь заказа</h3>
|
||||||
|
<div style={{ color: 'var(--color-text-muted)', fontSize: fontSize.s, padding: '1rem 0' }}>Загружаю…</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = stages[0]?.value || 0;
|
||||||
|
const data = stages.map((s, i) => ({
|
||||||
|
name: s.label,
|
||||||
|
caption: `${s.value} · ${s.pct}%`,
|
||||||
|
value: Number(s.value) || 0,
|
||||||
|
pct: s.pct,
|
||||||
|
convPct: i === 0 || !stages[i - 1]?.value
|
||||||
|
? null
|
||||||
|
: Math.round((Number(s.value) / Number(stages[i - 1].value)) * 1000) / 10,
|
||||||
|
fill: STAGE_COLORS[s.key] || '#3b82f6',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const bypass = path?.bypass || {};
|
||||||
|
const split = path?.split || {};
|
||||||
|
const sources = path?.sources || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem' }}>
|
||||||
|
<div style={{ marginBottom: '0.6rem' }}>
|
||||||
|
<h3 style={{ fontSize: mobile ? '0.9rem' : '1rem', fontWeight: 700, color: 'var(--color-text)', marginBottom: '0.1rem' }}>
|
||||||
|
Путь заказа
|
||||||
|
</h3>
|
||||||
|
<div style={{ fontSize: mobile ? '0.65rem' : '0.72rem', color: 'var(--color-text-muted)' }}>
|
||||||
|
{periodLabel ? `${periodLabel} · ` : ''}сколько заказов проходит каждую ступень
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Сама воронка ─────────────────────────────────────────────────── */}
|
||||||
|
{total === 0 ? (
|
||||||
|
<div style={{ color: 'var(--color-text-muted)', fontSize: fontSize.s, padding: '1.5rem', textAlign: 'center' }}>
|
||||||
|
За период заказов нет
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ width: '100%', height: mobile ? 200 : 240 }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<FunnelChart margin={{ top: 4, right: mobile ? 4 : 24, bottom: 4, left: mobile ? 4 : 24 }}>
|
||||||
|
<Tooltip content={<FunnelTooltip />} />
|
||||||
|
<Funnel dataKey="value" nameKey="name" data={data} isAnimationActive={false} stroke="none">
|
||||||
|
<LabelList
|
||||||
|
position="inside"
|
||||||
|
fill="#ffffff"
|
||||||
|
stroke="none"
|
||||||
|
dataKey="caption"
|
||||||
|
style={{ fontSize: mobile ? '0.62rem' : '0.75rem', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
</Funnel>
|
||||||
|
</FunnelChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Легенда: те же цифры списком (воронку читать на телефоне тяжело) ─ */}
|
||||||
|
{total > 0 && (
|
||||||
|
<div style={{ display: 'grid', gap: '0.2rem', marginTop: '0.4rem' }}>
|
||||||
|
{data.map((d) => (
|
||||||
|
<div key={d.name} style={{ display: 'flex', alignItems: 'baseline', gap: '0.45rem', fontSize: fontSize.s }}>
|
||||||
|
<span style={{ width: '9px', height: '9px', borderRadius: '3px', background: d.fill, display: 'inline-block' }} />
|
||||||
|
<span style={{ flex: '1 1 auto', color: 'var(--color-text-muted)' }}>{d.name}</span>
|
||||||
|
<strong style={{ color: 'var(--color-text)', fontSize: fontSize.s }}>{d.caption}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Конверсии между ступенями ────────────────────────────────────── */}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', margin: '0.5rem 0 0.75rem' }}>
|
||||||
|
{data.slice(1).map((d, i) => (
|
||||||
|
<Chip
|
||||||
|
key={d.name}
|
||||||
|
label={`${data[i].name} → ${d.name}`}
|
||||||
|
value={d.convPct != null ? `${d.convPct}%` : '—'}
|
||||||
|
color={d.fill}
|
||||||
|
fontSize={fontSize}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Мимо цепочки ─────────────────────────────────────────────────── */}
|
||||||
|
<div style={{
|
||||||
|
padding: '0.5rem 0.65rem', borderRadius: '10px', marginBottom: '0.7rem',
|
||||||
|
background: 'rgba(234,179,8,0.10)', border: '1px solid rgba(234,179,8,0.35)',
|
||||||
|
fontSize: fontSize.s, color: 'var(--color-text)',
|
||||||
|
}}>
|
||||||
|
<strong>Мимо цепочки (без даты)</strong>: выдано {bypass.delivered ?? 0} · платное хранение {bypass.paid_storage ?? 0} ·
|
||||||
|
{' '}отменено {bypass.cancelled ?? 0}
|
||||||
|
<div style={{ color: 'var(--color-text-muted)', fontSize: fontSize.xs, marginTop: '0.15rem' }}>
|
||||||
|
Эти заказы довели до выдачи без согласования даты — воронка выше их не считает.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Откуда взялась дата + тип заказа ─────────────────────────────── */}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
|
||||||
|
{sources.map((s) => (
|
||||||
|
<Chip
|
||||||
|
key={s.source}
|
||||||
|
label={SOURCE_LABELS[s.source] || s.source}
|
||||||
|
value={s.count}
|
||||||
|
color={s.source === 'manual' ? '#eab308' : s.source === 'autocall' ? '#6366f1' : '#06b6d4'}
|
||||||
|
fontSize={fontSize}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Chip label="Самовывоз" value={split.pickup ?? 0} color="#f59e0b" fontSize={fontSize} />
|
||||||
|
<Chip label="Доставка" value={split.delivery ?? 0} color="#6366f1" fontSize={fontSize} />
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OrderPathFunnelBlock;
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
import React from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { OrderPathFunnelBlock } from "./OrderPathFunnelBlock";
|
||||||
|
|
||||||
|
// Фикстура — блок order_path из admin_comms_funnel (реальные цифры за 30 дней на 29.09.2026).
|
||||||
|
const funnel = {
|
||||||
|
order_path: {
|
||||||
|
stages: [
|
||||||
|
{ key: "created", label: "Групп в работе", value: 396, pct: 100 },
|
||||||
|
{ key: "sms", label: "Первое SMS", value: 340, pct: 85.9 },
|
||||||
|
{ key: "dated", label: "Дата согласована", value: 161, pct: 40.7 },
|
||||||
|
{ key: "done", label: "Выдано по цепочке", value: 134, pct: 33.8 },
|
||||||
|
],
|
||||||
|
bypass: { delivered: 199, paid_storage: 3, cancelled: 0 },
|
||||||
|
split: { pickup: 235, delivery: 161 },
|
||||||
|
sources: [
|
||||||
|
{ source: "manual", count: 135 },
|
||||||
|
{ source: "autocall", count: 6 },
|
||||||
|
{ source: "none", count: 20 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const empty = { order_path: { stages: [], bypass: {}, split: {}, sources: [] } };
|
||||||
|
|
||||||
|
describe("OrderPathFunnelBlock", () => {
|
||||||
|
const html = renderToStaticMarkup(<OrderPathFunnelBlock funnel={funnel} mobile={false} periodLabel="30 дней" />);
|
||||||
|
|
||||||
|
it("подписывает этапы со значениями и процентами", () => {
|
||||||
|
for (const label of ["Групп в работе", "Первое SMS", "Дата согласована", "Выдано по цепочке"]) {
|
||||||
|
expect(html).toContain(label);
|
||||||
|
}
|
||||||
|
expect(html).toContain("396 · 100%");
|
||||||
|
expect(html).toContain("340 · 85.9%");
|
||||||
|
expect(html).toContain("134 · 33.8%");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("считает конверсию между ступенями", () => {
|
||||||
|
expect(html).toContain("Групп в работе → Первое SMS");
|
||||||
|
expect(html).toContain("85.9%");
|
||||||
|
expect(html).toContain("Первое SMS → Дата согласована");
|
||||||
|
expect(html).toContain("47.4%"); // 161/340
|
||||||
|
expect(html).toContain("Дата согласована → Выдано по цепочке");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("честно показывает то, что прошло мимо цепочки", () => {
|
||||||
|
expect(html).toContain("Мимо цепочки (без даты)");
|
||||||
|
expect(html).toContain("выдано 199");
|
||||||
|
expect(html).toContain("платное хранение 3");
|
||||||
|
expect(html).toContain("воронка выше их не считает");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("показывает источники даты и разбивку по типу заказа", () => {
|
||||||
|
expect(html).toContain("Менеджер");
|
||||||
|
expect(html).toContain("Робот-звонок");
|
||||||
|
expect(html).toContain("В обход (без даты)");
|
||||||
|
expect(html).toContain("Самовывоз");
|
||||||
|
expect(html).toContain("235");
|
||||||
|
expect(html).toContain("Доставка");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("на пустом периоде пишет заглушку", () => {
|
||||||
|
const e = renderToStaticMarkup(<OrderPathFunnelBlock funnel={empty} mobile periodLabel="Сегодня" />);
|
||||||
|
expect(e).toContain("За период заказов нет");
|
||||||
|
expect(e).toContain("Путь заказа");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("показывает заглушку загрузки, пока данных нет", () => {
|
||||||
|
const l = renderToStaticMarkup(<OrderPathFunnelBlock funnel={null} isLoading mobile={false} />);
|
||||||
|
expect(l).toContain("Путь заказа");
|
||||||
|
expect(l).toContain("Загружаю…");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -70,6 +70,27 @@ pct as (
|
||||||
case when groups_cnt > 0 then round(silent_cnt::numeric / groups_cnt * 100, 1) else 0 end as silent_pct
|
case when groups_cnt > 0 then round(silent_cnt::numeric / groups_cnt * 100, 1) else 0 end as silent_pct
|
||||||
from stages
|
from stages
|
||||||
),
|
),
|
||||||
|
path as (
|
||||||
|
select
|
||||||
|
count(*) as created,
|
||||||
|
count(*) filter (where first_sms_sent_at is not null) as sms,
|
||||||
|
count(*) filter (where delivery_date is not null or pickup_date is not null) as dated,
|
||||||
|
count(*) filter (where (delivery_date is not null or pickup_date is not null)
|
||||||
|
and delivery_status in ('delivered', 'picked_up')) as chain_done,
|
||||||
|
count(*) filter (where delivery_status in ('delivered', 'picked_up')
|
||||||
|
and delivery_date is null and pickup_date is null) as bypass_done,
|
||||||
|
count(*) filter (where delivery_status = 'paid_storage') as paid_storage,
|
||||||
|
count(*) filter (where delivery_status = 'cancelled') as cancelled,
|
||||||
|
count(*) filter (where delivery_type = 'pickup') as pickup_cnt,
|
||||||
|
count(*) filter (where delivery_type = 'delivery') as delivery_cnt
|
||||||
|
from cohort
|
||||||
|
),
|
||||||
|
path_sources as (
|
||||||
|
select coalesce(delivery_date_source, 'none') as source, count(*) as cnt
|
||||||
|
from cohort
|
||||||
|
where delivery_date is not null or pickup_date is not null
|
||||||
|
group by 1
|
||||||
|
),
|
||||||
attempt_rows as (
|
attempt_rows as (
|
||||||
select attempt_no,
|
select attempt_no,
|
||||||
count(*) as total,
|
count(*) as total,
|
||||||
|
|
@ -140,9 +161,25 @@ select jsonb_build_object(
|
||||||
'last_call', n.last_call, 'status', n.status)) from no_answer_rows n), '[]'::jsonb),
|
'last_call', n.last_call, 'status', n.status)) from no_answer_rows n), '[]'::jsonb),
|
||||||
'opened_no_confirm', coalesce((select jsonb_agg(jsonb_build_object(
|
'opened_no_confirm', coalesce((select jsonb_agg(jsonb_build_object(
|
||||||
'num', o.num, 'name', o.name, 'phone', o.phone,
|
'num', o.num, 'name', o.name, 'phone', o.phone,
|
||||||
'opened', o.opened, 'opens', o.opens)) from opened_rows o), '[]'::jsonb)
|
'opened', o.opened, 'opens', o.opens)) from opened_rows o), '[]'::jsonb),
|
||||||
|
-- Путь заказа: воронка по цепочке (без даты водитель не едет) + то, что прошло мимо неё.
|
||||||
|
'order_path', jsonb_build_object(
|
||||||
|
'stages', jsonb_build_array(
|
||||||
|
jsonb_build_object('key', 'created', 'label', 'Групп в работе', 'value', pp.created, 'pct', 100),
|
||||||
|
jsonb_build_object('key', 'sms', 'label', 'Первое SMS', 'value', pp.sms,
|
||||||
|
'pct', case when pp.created > 0 then round(pp.sms::numeric / pp.created * 100, 1) else 0 end),
|
||||||
|
jsonb_build_object('key', 'dated', 'label', 'Дата согласована', 'value', pp.dated,
|
||||||
|
'pct', case when pp.created > 0 then round(pp.dated::numeric / pp.created * 100, 1) else 0 end),
|
||||||
|
jsonb_build_object('key', 'done', 'label', 'Выдано по цепочке', 'value', pp.chain_done,
|
||||||
|
'pct', case when pp.created > 0 then round(pp.chain_done::numeric / pp.created * 100, 1) else 0 end)
|
||||||
|
),
|
||||||
|
'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)
|
||||||
)
|
)
|
||||||
from stages s, pct p;
|
)
|
||||||
|
from stages s, pct p, path pp;
|
||||||
$function$;
|
$function$;
|
||||||
|
|
||||||
comment on function public.admin_comms_funnel(integer, timestamp with time zone, timestamp with time zone) is
|
comment on function public.admin_comms_funnel(integer, timestamp with time zone, timestamp with time zone) is
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue