Files
zarinpal-analytics-backend/internal/insight/builder.go

184 lines
9.5 KiB
Go
Raw 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 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
}