dana-medical-site/internal/storage/stats.go
2026-10-14 13:33:13 +00:00

212 lines
8.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}