package insight import ( "context" "encoding/json" "errors" "fmt" "math" "sort" "time" "git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/analytics" "github.com/jackc/pgx/v5" "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, Verified, Unverified, Failed, Reversed, Amount, Fees int64 AverageLatencyMS int64 } type businessContext struct { Industry string `json:"industry"` ProfileVersion int `json:"profileVersion,omitempty"` Description string `json:"businessDescription,omitempty"` Niches []string `json:"niches,omitempty"` SalesChannels []string `json:"salesChannels,omitempty"` PrimaryGoal string `json:"primaryGoal,omitempty"` Constraints string `json:"constraints,omitempty"` ChangedFields []string `json:"changedFields,omitempty"` ChangedValues map[string]businessFieldChange `json:"changedValues,omitempty"` } type businessFieldChange struct { Before any `json:"before"` After any `json:"after"` } func (b *PostgreSQLBuilder) Build(ctx context.Context, merchantID int64, terminalID string, module Module) (Analysis, error) { return b.build(ctx, merchantID, terminalID, module, nil) } func (b *PostgreSQLBuilder) BuildForPeriod(ctx context.Context, merchantID int64, terminalID string, module Module, period PeriodScope) (Analysis, error) { return b.build(ctx, merchantID, terminalID, module, &period) } func (b *PostgreSQLBuilder) build(ctx context.Context, merchantID int64, terminalID string, module Module, period *PeriodScope) (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) var latest, previous aggregate var err error if period != nil { latest, err = b.aggregateRange(ctx, merchantID, terminalID, period.StartAt, period.EndAt) if err == nil { duration := period.EndAt.Sub(period.StartAt) previous, err = b.aggregateRange(ctx, merchantID, terminalID, period.StartAt.Add(-duration), period.StartAt) } } else { latest, err = b.aggregate(ctx, merchantID, terminalID, dataThrough.AddDate(0, 0, -29), dataThrough) if err == nil { 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, "time": timeContext, "privacy": map[string]any{"aggregateOnly": true, "peerSuppressionApplied": module == ModulePeers}, } precedingPeriodLabel := "" if period != nil { precedingPeriodLabel, _ = analytics.PrecedingPeriodLabel(period.PeriodType, period.StartAt) evidenceMap["selectedPeriod"] = map[string]any{"periodId": period.PeriodID, "periodType": period.PeriodType, "startAt": period.StartAt, "endAt": period.EndAt, "jalaliLabelFa": period.JalaliLabelFA} evidenceMap["selectedPeriodAggregate"] = latest if comparisonAvailable { evidenceMap["precedingPeriod"] = map[string]any{"jalaliLabelFa": precedingPeriodLabel} evidenceMap["precedingPeriodAggregate"] = previous } } else { evidenceMap["latest30Days"] = latest evidenceMap["previousYearEquivalent"] = previous } if period != nil && module == ModuleTransactions { evidenceMap["statusDistribution"] = map[string]any{ "total": latest.Sessions, "paidAwaitingVerification": latest.Unverified, "verified": latest.Verified, "failed": latest.Failed, "reversed": latest.Reversed, "bankSuccessRate": ratio(latest.Paid, latest.Sessions), "verificationCompletionRate": ratio(latest.Verified, latest.Paid), "failureRate": ratio(latest.Failed, latest.Sessions), } if comparisonAvailable { evidenceMap["precedingPeriodStatus"] = previous } } business, err := b.businessContext(ctx, merchantID, terminalID) if err != nil { return Analysis{}, err } evidenceMap["businessContextUntrustedData"] = business 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, business, 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) if period != nil { copy = deterministicPeriodCopy(module, *period, precedingPeriodLabel, 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='Verified'),count(*) FILTER(WHERE ps.status='Paid'),count(*) FILTER(WHERE ps.status='Failed'),count(*) FILTER(WHERE ps.status='Reversed'), 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.Verified, &value.Unverified, &value.Failed, &value.Reversed, &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) aggregateRange(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='Verified'),count(*) FILTER(WHERE ps.status='Paid'),count(*) FILTER(WHERE ps.status='Failed'),count(*) FILTER(WHERE ps.status='Reversed'), 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 >= $3 AND ps.created_at < $4`, merchantID, terminalID, start, end). Scan(&value.Sessions, &value.Paid, &value.Verified, &value.Unverified, &value.Failed, &value.Reversed, &value.Amount, &value.Fees, &value.AverageLatencyMS) if err != nil { return aggregate{}, fmt.Errorf("loading period-scoped insight evidence: %w", err) } return value, nil } func ratio(numerator, denominator int64) any { if denominator == 0 { return nil } return fmt.Sprintf("%.2f", float64(numerator)*100/float64(denominator)) } func ratioText(numerator, denominator int64) string { value := ratio(numerator, denominator) if value == nil { return "ناموجود" } return fmt.Sprintf("%v درصد", value) } func (b *PostgreSQLBuilder) businessContext(ctx context.Context, merchantID int64, terminalID string) (businessContext, error) { var result businessContext if err := b.pool.QueryRow(ctx, `SELECT category_title FROM merchants WHERE id=$1`, merchantID).Scan(&result.Industry); err != nil { return businessContext{}, fmt.Errorf("loading insight business industry: %w", err) } if terminalID == "" { return result, nil } var niches, channels, changed []byte err := b.pool.QueryRow(ctx, `SELECT version,business_description,niches,sales_channels,primary_goal,constraints_text,changed_fields FROM growth_coach_profiles WHERE merchant_id=$1 AND terminal_source_key=$2 AND is_current`, merchantID, terminalID).Scan(&result.ProfileVersion, &result.Description, &niches, &channels, &result.PrimaryGoal, &result.Constraints, &changed) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return businessContext{}, fmt.Errorf("loading insight business profile: %w", err) } if err == nil { _ = json.Unmarshal(niches, &result.Niches) _ = json.Unmarshal(channels, &result.SalesChannels) _ = json.Unmarshal(changed, &result.ChangedFields) if result.ProfileVersion > 1 && len(result.ChangedFields) > 0 { var previous businessContext var previousNiches, previousChannels []byte previousErr := b.pool.QueryRow(ctx, `SELECT business_description,niches,sales_channels,primary_goal,constraints_text FROM growth_coach_profiles WHERE merchant_id=$1 AND terminal_source_key=$2 AND version=$3`, merchantID, terminalID, result.ProfileVersion-1).Scan(&previous.Description, &previousNiches, &previousChannels, &previous.PrimaryGoal, &previous.Constraints) if previousErr != nil && !errors.Is(previousErr, pgx.ErrNoRows) { return businessContext{}, fmt.Errorf("loading previous insight business profile: %w", previousErr) } if previousErr == nil { _ = json.Unmarshal(previousNiches, &previous.Niches) _ = json.Unmarshal(previousChannels, &previous.SalesChannels) result.ChangedValues = make(map[string]businessFieldChange, len(result.ChangedFields)) for _, field := range result.ChangedFields { switch field { case "businessDescription": result.ChangedValues[field] = businessFieldChange{Before: previous.Description, After: result.Description} case "niches": result.ChangedValues[field] = businessFieldChange{Before: previous.Niches, After: result.Niches} case "salesChannels": result.ChangedValues[field] = businessFieldChange{Before: previous.SalesChannels, After: result.SalesChannels} case "primaryGoal": result.ChangedValues[field] = businessFieldChange{Before: previous.PrimaryGoal, After: result.PrimaryGoal} case "constraints": result.ChangedValues[field] = businessFieldChange{Before: previous.Constraints, After: result.Constraints} } } } } } return result, nil } func (b *PostgreSQLBuilder) calendar(ctx context.Context, business businessContext, start time.Time, days int) ([]CalendarContext, error) { terms := append([]string{business.Industry}, business.Niches...) terms = append(terms, business.SalesChannels...) 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 AND (e.event_type IN('COMMERCIAL','SEASONAL','EDUCATION','ECONOMIC','EMERGENCY_CLOSURE') OR e.merchant_categories ?| $3::text[] OR e.behavioral_channels ?| $3::text[]) AND (NOT e.official_holiday OR e.relevance_bps>=7000 OR e.merchant_categories ?| $3::text[] OR e.behavioral_channels ?| $3::text[]) ORDER BY (e.merchant_categories ?| $3::text[]) DESC,(e.behavioral_channels ?| $3::text[]) DESC,e.relevance_bps DESC,e.start_date,e.event_key LIMIT 12`, start, start.AddDate(0, 0, days-1), terms) 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 deterministicTransactionCopy(period PeriodScope, precedingPeriodLabel string, latest, previous aggregate, comparison bool, _ TimeContext, confidence Confidence) Copy { latestBody := fmt.Sprintf("در %s، %d تراکنش ثبت شده است: %d پرداخت‌شده و در انتظار تأیید، %d تأیید نهایی، %d ناموفق و %d برگشت‌خورده. نرخ موفقیت بانکی %s، تکمیل تأیید %s و نرخ ناموفق %s است.", period.JalaliLabelFA, latest.Sessions, latest.Unverified, latest.Verified, latest.Failed, latest.Reversed, ratioText(latest.Paid, latest.Sessions), ratioText(latest.Verified, latest.Paid), ratioText(latest.Failed, latest.Sessions)) pastBody := fmt.Sprintf("این خلاصه فقط وضعیت %s را نشان می‌دهد و با داده‌های موجود دربارهٔ افزایش یا کاهش نسبت به دوره‌ای دیگر نتیجه‌گیری نمی‌کند.", period.JalaliLabelFA) if comparison { pastBody = fmt.Sprintf("در %s، %d تراکنش و %d پرداخت موفق بانکی ثبت شده بود؛ این مقایسه فقط تغییر مشاهده‌شده را نشان می‌دهد و علت آن را مشخص نمی‌کند.", precedingPeriodLabel, previous.Sessions, previous.Paid) } uncertainty := "این جمع‌بندی فقط الگوی مشاهده‌شده در نمودارها را توصیف می‌کند و علت تغییر وضعیت‌ها را تعیین نمی‌کند." return Copy{ Past: Narrative{Headline: "مقایسه با بازهٔ قبل", Body: pastBody, EvidenceID: []string{"period-comparison"}}, LatestObserved: Narrative{Headline: "خلاصهٔ وضعیت تراکنش‌ها", Body: latestBody, EvidenceID: []string{"status-distribution", "verification-health", "failure-health"}}, Future7Days: Narrative{Headline: "برداشت کوتاه‌مدت", Body: uncertainty, EvidenceID: []string{"status-distribution"}}, Future30Days: Narrative{Headline: "برداشت روند", Body: uncertainty + " برای تصمیم‌گیری، ترکیب وضعیت‌ها را در چند بازهٔ پیاپی بررسی کنید.", EvidenceID: []string{"period-comparison"}}, Findings: []Finding{ {EvidenceID: "status-distribution", Title: "ترکیب وضعیت‌ها", Summary: latestBody, ScoreBPS: score(latest.Sessions, confidence), Confidence: confidence}, {EvidenceID: "verification-health", Title: "تکمیل تأیید", Summary: fmt.Sprintf("از %d پرداخت موفق بانکی، %d مورد تأیید نهایی شده و %d مورد در انتظار تأیید است.", latest.Paid, latest.Verified, latest.Unverified), ScoreBPS: score(latest.Unverified, confidence), Confidence: confidence}, {EvidenceID: "failure-health", Title: "تراکنش‌های ناموفق", Summary: fmt.Sprintf("در این بازه %d تراکنش ناموفق و %d تراکنش برگشت‌خورده ثبت شده است.", latest.Failed, latest.Reversed), ScoreBPS: score(latest.Failed+latest.Reversed, confidence), Confidence: confidence}, }, Options: []Option{{Title: "بررسی روند وضعیت‌ها", Description: "نمودار ترکیب وضعیت‌ها را برای پیدا کردن تغییرهای پایدار در چند بازهٔ پیاپی بررسی کنید.", EvidenceID: []string{"status-distribution", "period-comparison"}}}, } } func deterministicPeriodCopy(module Module, period PeriodScope, precedingPeriodLabel string, latest, previous aggregate, comparison bool, tc TimeContext, confidence Confidence) Copy { if module == ModuleTransactions { return deterministicTransactionCopy(period, precedingPeriodLabel, latest, previous, comparison, tc, confidence) } latestBody := fmt.Sprintf("در %s، %d تراکنش ثبت شد و %d پرداخت با موفقیت انجام شد.", period.JalaliLabelFA, latest.Sessions, latest.Paid) switch module { case ModulePayments: latestBody = fmt.Sprintf("در %s، از %d تراکنش ثبت‌شده، %d پرداخت موفق و %d پرداخت ناموفق بود.", period.JalaliLabelFA, latest.Sessions, latest.Paid, latest.Failed) case ModuleFees: latestBody = fmt.Sprintf("در %s، مجموع کارمزد ثبت‌شده %d ریال بود و %d پرداخت موفق انجام شد.", period.JalaliLabelFA, latest.Fees, latest.Paid) case ModulePeers: latestBody = fmt.Sprintf("در %s، کسب‌وکار شما %d پرداخت موفق از %d تراکنش ثبت‌شده داشت.", period.JalaliLabelFA, latest.Paid, latest.Sessions) } pastBody := fmt.Sprintf("این خلاصه فقط وضعیت %s را نشان می‌دهد و با داده‌های موجود دربارهٔ افزایش یا کاهش نسبت به دوره‌ای دیگر نتیجه‌گیری نمی‌کند.", period.JalaliLabelFA) if comparison { direction := "تقریباً مشابه" if latest.Paid > previous.Paid { direction = "بیشتر" } else if latest.Paid < previous.Paid { direction = "کمتر" } pastBody = fmt.Sprintf("تعداد پرداخت‌های موفق در %s نسبت به %s %s بوده است؛ بهتر است همین روند را در دورهٔ بعد هم بررسی کنید.", period.JalaliLabelFA, precedingPeriodLabel, direction) } copy := deterministicCopy(module, latest, previous, comparison, tc, confidence) copy.LatestObserved = Narrative{Headline: "خلاصهٔ این بازه", Body: latestBody, EvidenceID: []string{"latest-volume", "payment-health"}} copy.Past = Narrative{Headline: "مقایسه با بازهٔ قبل", Body: pastBody, EvidenceID: []string{"previous-year-equivalent"}} 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 }