feat: add persisted page insight platform
This commit is contained in:
183
internal/insight/builder.go
Normal file
183
internal/insight/builder.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package insight
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const AnalyticsVersion = "2.0.0"
|
||||
|
||||
type Analysis struct {
|
||||
Time TimeContext
|
||||
Confidence Confidence
|
||||
Evidence json.RawMessage
|
||||
Copy Copy
|
||||
Calendar []CalendarContext
|
||||
}
|
||||
|
||||
type Builder interface {
|
||||
Build(context.Context, int64, string, Module) (Analysis, error)
|
||||
}
|
||||
|
||||
type PostgreSQLBuilder struct {
|
||||
pool *pgxpool.Pool
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewPostgreSQLBuilder(pool *pgxpool.Pool) *PostgreSQLBuilder {
|
||||
return &PostgreSQLBuilder{pool: pool, now: time.Now}
|
||||
}
|
||||
|
||||
type aggregate struct {
|
||||
Sessions, Paid, Failed, Amount, Fees int64
|
||||
AverageLatencyMS int64
|
||||
}
|
||||
|
||||
func (b *PostgreSQLBuilder) Build(ctx context.Context, merchantID int64, terminalID string, module Module) (Analysis, error) {
|
||||
if terminalID != "" {
|
||||
var exists bool
|
||||
if err := b.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM terminals WHERE merchant_id=$1 AND source_key=$2)`, merchantID, terminalID).Scan(&exists); err != nil {
|
||||
return Analysis{}, fmt.Errorf("validating insight terminal: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return Analysis{}, ErrUnknownTerminal
|
||||
}
|
||||
}
|
||||
var dataThrough time.Time
|
||||
if err := b.pool.QueryRow(ctx, `SELECT max(created_at AT TIME ZONE 'Asia/Tehran')::date FROM payment_sessions`).Scan(&dataThrough); err != nil {
|
||||
return Analysis{}, fmt.Errorf("loading insight watermark: %w", err)
|
||||
}
|
||||
timeContext := BuildTimeContext(b.now(), dataThrough)
|
||||
latest, err := b.aggregate(ctx, merchantID, terminalID, dataThrough.AddDate(0, 0, -29), dataThrough)
|
||||
if err != nil {
|
||||
return Analysis{}, err
|
||||
}
|
||||
previous, err := b.aggregate(ctx, merchantID, terminalID, dataThrough.AddDate(-1, 0, -29), dataThrough.AddDate(-1, 0, 0))
|
||||
if err != nil {
|
||||
return Analysis{}, err
|
||||
}
|
||||
confidence := ConfidenceMedium
|
||||
if timeContext.Freshness == FreshnessStale {
|
||||
confidence = ConfidenceLow
|
||||
}
|
||||
comparisonAvailable := previous.Sessions > 0
|
||||
if !comparisonAvailable || latest.Sessions == 0 {
|
||||
confidence = ConfidenceInsufficientData
|
||||
}
|
||||
evidenceMap := map[string]any{
|
||||
"schemaVersion": "1.0.0", "module": module, "comparisonAvailable": comparisonAvailable,
|
||||
"latest30Days": latest, "previousYearEquivalent": previous,
|
||||
"time": timeContext, "privacy": map[string]any{"aggregateOnly": true, "peerSuppressionApplied": module == ModulePeers},
|
||||
}
|
||||
forecastStart, err := time.Parse(time.DateOnly, timeContext.ForecastStartDate)
|
||||
if err != nil {
|
||||
return Analysis{}, fmt.Errorf("parsing forecast start: %w", err)
|
||||
}
|
||||
calendar, err := b.calendar(ctx, forecastStart, horizon(module))
|
||||
if err != nil {
|
||||
return Analysis{}, err
|
||||
}
|
||||
evidenceMap["calendarContext"] = calendar
|
||||
encoded, err := json.Marshal(evidenceMap)
|
||||
if err != nil {
|
||||
return Analysis{}, fmt.Errorf("encoding insight evidence: %w", err)
|
||||
}
|
||||
copy := deterministicCopy(module, latest, previous, comparisonAvailable, timeContext, confidence)
|
||||
return Analysis{Time: timeContext, Confidence: confidence, Evidence: encoded, Copy: copy, Calendar: calendar}, nil
|
||||
}
|
||||
|
||||
func (b *PostgreSQLBuilder) aggregate(ctx context.Context, merchantID int64, terminalID string, start, end time.Time) (aggregate, error) {
|
||||
var value aggregate
|
||||
err := b.pool.QueryRow(ctx, `SELECT count(*),count(*) FILTER(WHERE ps.status IN ('Paid','Verified')),count(*) FILTER(WHERE ps.status NOT IN ('Paid','Verified')),
|
||||
coalesce(sum(ps.amount_rial) FILTER(WHERE ps.status IN ('Paid','Verified')),0),coalesce(sum(ps.fee_amount_rial),0),
|
||||
coalesce(avg(coalesce(pa.verify_time_ms,pa.init_time_ms,0)),0)::bigint
|
||||
FROM payment_sessions ps LEFT JOIN LATERAL (SELECT init_time_ms,verify_time_ms FROM payment_attempts WHERE session_id=ps.id ORDER BY sequence DESC LIMIT 1) pa ON true
|
||||
WHERE ps.merchant_id=$1 AND ($2='' OR ps.terminal_id=(SELECT id FROM terminals WHERE merchant_id=$1 AND source_key=$2))
|
||||
AND (ps.created_at AT TIME ZONE 'Asia/Tehran')::date BETWEEN $3 AND $4`, merchantID, terminalID, start, end).
|
||||
Scan(&value.Sessions, &value.Paid, &value.Failed, &value.Amount, &value.Fees, &value.AverageLatencyMS)
|
||||
if err != nil {
|
||||
return aggregate{}, fmt.Errorf("loading aggregate insight evidence: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (b *PostgreSQLBuilder) calendar(ctx context.Context, start time.Time, days int) ([]CalendarContext, error) {
|
||||
rows, err := b.pool.Query(ctx, `SELECT e.event_key,e.title_fa,e.start_date,e.end_date,e.tone,e.commercial_posture,e.official_holiday
|
||||
FROM calendar_events e JOIN calendar_catalog_versions v ON v.id=e.catalog_version_id AND v.status='ACTIVE'
|
||||
WHERE e.end_date+e.lag_days >= $1 AND e.start_date-e.lead_days <= $2 ORDER BY e.start_date,e.event_key`, start, start.AddDate(0, 0, days-1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading active calendar context: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []CalendarContext
|
||||
for rows.Next() {
|
||||
var item CalendarContext
|
||||
var eventStart, eventEnd time.Time
|
||||
if err := rows.Scan(&item.EventKey, &item.TitleFA, &eventStart, &eventEnd, &item.Tone, &item.CommercialPosture, &item.OfficialHoliday); err != nil {
|
||||
return nil, fmt.Errorf("scanning calendar context: %w", err)
|
||||
}
|
||||
item.StartDate, item.EndDate = eventStart.Format(time.DateOnly), eventEnd.Format(time.DateOnly)
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func horizon(module Module) int {
|
||||
if module == ModuleGrowth {
|
||||
return 90
|
||||
}
|
||||
return 30
|
||||
}
|
||||
|
||||
func deterministicCopy(module Module, latest, previous aggregate, comparison bool, tc TimeContext, confidence Confidence) Copy {
|
||||
label := map[Module]string{ModuleOverview: "وضعیت کلی کسبوکار", ModulePayments: "عملکرد پرداخت", ModuleFees: "کارمزدها", ModulePeers: "مقایسه همگروه", ModuleTransactions: "الگوی تراکنشها", ModuleGrowth: "رشد"}[module]
|
||||
change := 0.0
|
||||
if previous.Paid > 0 {
|
||||
change = float64(latest.Paid-previous.Paid) / float64(previous.Paid) * 100
|
||||
}
|
||||
pastBody := "برای مقایسه همدوره سال قبل داده کافی وجود ندارد؛ نتیجه تاریخی ساخته نشده است."
|
||||
if comparison {
|
||||
pastBody = fmt.Sprintf("در ۳۰ روز کامل اخیر، تعداد پرداختهای موفق نسبت به بازه همارز سال قبل %.1f درصد تغییر کرده است.", change)
|
||||
}
|
||||
latestBody := fmt.Sprintf("آخرین وضعیت مشاهدهشده تا %s شامل %d نشست و %d پرداخت موفق است. این تاریخ امروز نیست.", tc.DataThroughDate, latest.Sessions, latest.Paid)
|
||||
uncertainty := "چون داده با تاریخ واقعی امروز فاصله دارد، دامنه عدمقطعیت گسترده و سطح اطمینان پایین نگه داشته شده است."
|
||||
if tc.Freshness != FreshnessStale {
|
||||
uncertainty = "برآورد با داده نسبتاً تازه ساخته شده، اما همچنان یک سناریوی تحلیلی است و قطعیت علّی ندارد."
|
||||
}
|
||||
findings := []Finding{
|
||||
{EvidenceID: "latest-volume", Title: "حجم مشاهدهشده", Summary: latestBody, ScoreBPS: score(latest.Amount, confidence), Confidence: confidence},
|
||||
{EvidenceID: "payment-health", Title: "سلامت پرداخت", Summary: fmt.Sprintf("%d مورد ناموفق در برابر %d پرداخت موفق ثبت شده است.", latest.Failed, latest.Paid), ScoreBPS: score(latest.Failed, confidence), Confidence: confidence},
|
||||
}
|
||||
if module == ModuleFees {
|
||||
findings = append(findings, Finding{EvidenceID: "fee-driver", Title: "محرک کارمزد", Summary: fmt.Sprintf("کارمزد ثبتشده در ۳۰ روز آخر %d ریال است.", latest.Fees), ScoreBPS: score(latest.Fees, confidence), Confidence: confidence})
|
||||
}
|
||||
sort.SliceStable(findings, func(i, j int) bool { return findings[i].ScoreBPS > findings[j].ScoreBPS })
|
||||
if len(findings) > 3 {
|
||||
findings = findings[:3]
|
||||
}
|
||||
copy := Copy{
|
||||
Past: Narrative{Headline: "گذشتهٔ قابل مقایسه", Body: pastBody, EvidenceID: []string{"previous-year-equivalent"}},
|
||||
LatestObserved: Narrative{Headline: "آخرین وضعیت مشاهدهشده", Body: latestBody, EvidenceID: []string{"latest-volume", "payment-health"}},
|
||||
Future7Days: Narrative{Headline: "چشمانداز ۷ روزه", Body: uncertainty + " سناریو از تاریخ واقعی امروز آغاز میشود.", EvidenceID: []string{"latest-volume"}},
|
||||
Future30Days: Narrative{Headline: "چشمانداز ۳۰ روزه", Body: uncertainty + " رویدادهای تقویمی فقط وقتی جهت مالی میگیرند که شواهد پذیرنده آن را پشتیبانی کند.", EvidenceID: []string{"latest-volume"}},
|
||||
Findings: findings,
|
||||
Options: []Option{{Title: "بررسی جزئیات", Description: "قبل از اقدام، روند روزانه و خوشههای خطا را کنار این جمعبندی بررسی کنید.", EvidenceID: []string{"payment-health"}}},
|
||||
}
|
||||
if module == ModuleGrowth {
|
||||
copy.Future90Days = &Narrative{Headline: "چشمانداز ۹۰ روزه", Body: uncertainty + " همپوشانی رویدادها مانع نسبتدادن اثر به یک علت واحد میشود.", EvidenceID: []string{"latest-volume"}}
|
||||
}
|
||||
copy.LatestObserved.Headline = label + " — آخرین وضعیت مشاهدهشده"
|
||||
return copy
|
||||
}
|
||||
|
||||
func score(impact int64, confidence Confidence) int {
|
||||
base := int(math.Min(8000, math.Log10(float64(max(int64(1), impact))+1)*1200))
|
||||
weight := map[Confidence]int{ConfidenceHigh: 10000, ConfidenceMedium: 7500, ConfidenceLow: 4500, ConfidenceInsufficientData: 2000}[confidence]
|
||||
return base * weight / 10000
|
||||
}
|
||||
Reference in New Issue
Block a user