212 lines
8.1 KiB
Go
212 lines
8.1 KiB
Go
package storage
|
||
|
||
import (
|
||
"context"
|
||
"time"
|
||
)
|
||
|
||
// Статистика заявок строится только по тому, что сайт действительно сохраняет:
|
||
// дата, форма, статус, страница, UTM/источник, услуга, врач, филиал, телефон.
|
||
// Дни считаются по московскому времени (Махачкала — тот же пояс).
|
||
|
||
type StatsFilter struct {
|
||
From time.Time // включительно, полночь по Москве
|
||
To time.Time // не включительно
|
||
BranchID int64 // 0 — все филиалы
|
||
}
|
||
|
||
type StatCount struct {
|
||
Key string `json:"key"`
|
||
Label string `json:"label"`
|
||
Count int `json:"count"`
|
||
}
|
||
|
||
type StatDay struct {
|
||
Date string `json:"date"`
|
||
Count int `json:"count"`
|
||
}
|
||
|
||
type StatTop struct {
|
||
ID int64 `json:"id"`
|
||
Name string `json:"name"`
|
||
Count int `json:"count"`
|
||
Done int `json:"done"`
|
||
}
|
||
|
||
type Stats struct {
|
||
Total int `json:"total"` // без спама
|
||
PrevTotal int `json:"prev_total"` // за такой же прошлый период
|
||
UniquePhones int `json:"unique_phones"` // разные номера телефонов
|
||
PrevUnique int `json:"prev_unique"`
|
||
Done int `json:"done"`
|
||
InProgress int `json:"in_progress"`
|
||
New int `json:"new"`
|
||
Spam int `json:"spam"`
|
||
Repeat int `json:"repeat"` // номера, оставившие больше одной заявки
|
||
ByDay []StatDay `json:"by_day"`
|
||
ByForm []StatCount `json:"by_form"`
|
||
BySource []StatCount `json:"by_source"`
|
||
BySection []StatCount `json:"by_section"`
|
||
ByBranch []StatCount `json:"by_branch"`
|
||
TopServices []StatTop `json:"top_services"`
|
||
TopDoctors []StatTop `json:"top_doctors"`
|
||
WithUTM int `json:"with_utm"` // сколько заявок пришли с метками или внешнего сайта
|
||
}
|
||
|
||
// leadsInRange — общий фильтр: период, филиал; спам отдельно.
|
||
const leadsInRange = `
|
||
WITH l AS (
|
||
SELECT *, regexp_replace(regexp_replace(page_url, '^https?://[^/]+', ''), '[?#].*$', '') AS path
|
||
FROM leads
|
||
WHERE created_at >= $1 AND created_at < $2 AND ($3 = 0 OR branch_id = $3)
|
||
)`
|
||
|
||
func (s *Storage) Stats(ctx context.Context, f StatsFilter) (*Stats, error) {
|
||
// пустые списки, а не null: страница статистики рисует их без проверок
|
||
st := &Stats{ByDay: []StatDay{}, ByForm: []StatCount{}, BySource: []StatCount{}, BySection: []StatCount{},
|
||
ByBranch: []StatCount{}, TopServices: []StatTop{}, TopDoctors: []StatTop{}}
|
||
prevFrom := f.From.Add(-f.To.Sub(f.From))
|
||
|
||
if err := s.pool.QueryRow(ctx, leadsInRange+`
|
||
SELECT count(*) FILTER (WHERE status <> 'spam'),
|
||
count(DISTINCT regexp_replace(phone, '\D', '', 'g')) FILTER (WHERE status <> 'spam'),
|
||
count(*) FILTER (WHERE status = 'done'),
|
||
count(*) FILTER (WHERE status = 'in_progress'),
|
||
count(*) FILTER (WHERE status = 'new'),
|
||
count(*) FILTER (WHERE status = 'spam'),
|
||
count(*) FILTER (WHERE status <> 'spam' AND (utm <> '{}'::jsonb))
|
||
FROM l`, f.From, f.To, f.BranchID).Scan(
|
||
&st.Total, &st.UniquePhones, &st.Done, &st.InProgress, &st.New, &st.Spam, &st.WithUTM); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.pool.QueryRow(ctx, leadsInRange+`
|
||
SELECT count(*) FILTER (WHERE status <> 'spam'),
|
||
count(DISTINCT regexp_replace(phone, '\D', '', 'g')) FILTER (WHERE status <> 'spam')
|
||
FROM l`, prevFrom, f.From, f.BranchID).Scan(&st.PrevTotal, &st.PrevUnique); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.pool.QueryRow(ctx, leadsInRange+`
|
||
SELECT count(*) FROM (
|
||
SELECT regexp_replace(phone, '\D', '', 'g') FROM l WHERE status <> 'spam'
|
||
GROUP BY 1 HAVING count(*) > 1) r`, f.From, f.To, f.BranchID).Scan(&st.Repeat); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// по дням — все дни периода, в том числе нулевые
|
||
rows, err := s.pool.Query(ctx, leadsInRange+`
|
||
SELECT to_char(d, 'YYYY-MM-DD'),
|
||
(SELECT count(*) FROM l WHERE status <> 'spam'
|
||
AND (created_at AT TIME ZONE 'Europe/Moscow')::date = d::date)
|
||
FROM generate_series(($1::timestamptz AT TIME ZONE 'Europe/Moscow')::date,
|
||
(($2::timestamptz - interval '1 second') AT TIME ZONE 'Europe/Moscow')::date,
|
||
interval '1 day') d
|
||
ORDER BY d`, f.From, f.To, f.BranchID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for rows.Next() {
|
||
var d StatDay
|
||
if err := rows.Scan(&d.Date, &d.Count); err != nil {
|
||
rows.Close()
|
||
return nil, err
|
||
}
|
||
st.ByDay = append(st.ByDay, d)
|
||
}
|
||
rows.Close()
|
||
|
||
counts := []struct {
|
||
dst *[]StatCount
|
||
sql string
|
||
}{
|
||
{&st.ByForm, `
|
||
SELECT COALESCE(fm.code, 'other'), COALESCE(fm.title, 'Без формы'), count(*)
|
||
FROM l LEFT JOIN forms fm ON fm.id = l.form_id
|
||
WHERE status <> 'spam' GROUP BY 1, 2 ORDER BY 3 DESC`},
|
||
// источник: utm_source, иначе внешний сайт, иначе «прямой заход / не определён»
|
||
{&st.BySource, `
|
||
SELECT src, src, count(*) FROM (
|
||
SELECT COALESCE(NULLIF(lower(utm->>'utm_source'), ''),
|
||
NULLIF(regexp_replace(lower(utm->>'referrer'), '^www\.', ''), ''),
|
||
'') AS src
|
||
FROM l WHERE status <> 'spam') x
|
||
GROUP BY 1 ORDER BY 3 DESC`},
|
||
{&st.BySection, `
|
||
SELECT sec, sec, count(*) FROM (
|
||
SELECT CASE
|
||
WHEN path IN ('', '/') THEN 'home'
|
||
WHEN path LIKE '/services/%' THEN 'services'
|
||
WHEN path LIKE '/company/staff/%' THEN 'doctors'
|
||
WHEN path LIKE '/checkup%' THEN 'checkups'
|
||
WHEN path LIKE '/sales%' THEN 'promos'
|
||
WHEN path LIKE '/price%' THEN 'prices'
|
||
WHEN path LIKE '/contacts%' THEN 'contacts'
|
||
WHEN path LIKE '/company/vacancy%' THEN 'vacancy'
|
||
ELSE 'other' END AS sec
|
||
FROM l WHERE status <> 'spam') x
|
||
GROUP BY 1 ORDER BY 3 DESC`},
|
||
{&st.ByBranch, `
|
||
SELECT COALESCE(b.id::text, ''), COALESCE(b.address, ''), count(*)
|
||
FROM l LEFT JOIN branches b ON b.id = l.branch_id
|
||
WHERE status <> 'spam' GROUP BY 1, 2 ORDER BY 3 DESC`},
|
||
}
|
||
for _, c := range counts {
|
||
rows, err := s.pool.Query(ctx, leadsInRange+c.sql, f.From, f.To, f.BranchID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for rows.Next() {
|
||
var x StatCount
|
||
if err := rows.Scan(&x.Key, &x.Label, &x.Count); err != nil {
|
||
rows.Close()
|
||
return nil, err
|
||
}
|
||
*c.dst = append(*c.dst, x)
|
||
}
|
||
rows.Close()
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
// услуги: выбранная в форме, иначе страница услуги, с которой отправили заявку
|
||
tops := []struct {
|
||
dst *[]StatTop
|
||
sql string
|
||
}{
|
||
{&st.TopServices, `
|
||
SELECT sv.id, sv.name, count(*), count(*) FILTER (WHERE l.status = 'done')
|
||
FROM l JOIN services sv ON sv.id = COALESCE(l.service_id,
|
||
(SELECT x.id FROM services x WHERE l.path LIKE '/services/%'
|
||
AND x.path = trim(both '/' from substr(l.path, 11)) AND x.deleted_at IS NULL))
|
||
WHERE l.status <> 'spam'
|
||
GROUP BY sv.id, sv.name ORDER BY 3 DESC, 2 LIMIT 10`},
|
||
{&st.TopDoctors, `
|
||
SELECT d.id, concat_ws(' ', d.last_name, d.first_name, NULLIF(d.middle_name, '')), count(*),
|
||
count(*) FILTER (WHERE l.status = 'done')
|
||
FROM l JOIN doctors d ON d.id = COALESCE(l.doctor_id,
|
||
(SELECT x.id FROM doctors x WHERE l.path LIKE '/company/staff/%/%'
|
||
AND x.slug = split_part(trim(both '/' from l.path), '/', 4) AND x.deleted_at IS NULL))
|
||
WHERE l.status <> 'spam'
|
||
GROUP BY d.id ORDER BY 3 DESC, 2 LIMIT 10`},
|
||
}
|
||
for _, t := range tops {
|
||
rows, err := s.pool.Query(ctx, leadsInRange+t.sql, f.From, f.To, f.BranchID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for rows.Next() {
|
||
var x StatTop
|
||
if err := rows.Scan(&x.ID, &x.Name, &x.Count, &x.Done); err != nil {
|
||
rows.Close()
|
||
return nil, err
|
||
}
|
||
*t.dst = append(*t.dst, x)
|
||
}
|
||
rows.Close()
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
return st, nil
|
||
}
|