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 }