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

340 lines
20 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"
"errors"
"fmt"
"math"
"sort"
"time"
"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 && module == ModuleTransactions {
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,
"latest30Days": latest, "previousYearEquivalent": previous,
"time": timeContext, "privacy": map[string]any{"aggregateOnly": true, "peerSuppressionApplied": module == ModulePeers},
}
if period != nil && module == ModuleTransactions {
evidenceMap["selectedPeriod"] = map[string]any{"periodId": period.PeriodID, "periodType": period.PeriodType, "startAt": period.StartAt, "endAt": period.EndAt, "jalaliLabelFa": period.JalaliLabelFA}
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),
}
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 && module == ModuleTransactions {
copy = deterministicTransactionCopy(*period, 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, 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 := "برای بازهٔ بلافاصله قبل دادهٔ کافی برای مقایسه وجود ندارد."
if comparison {
pastBody = fmt.Sprintf("در بازهٔ بلافاصله قبل %d تراکنش و %d پرداخت موفق بانکی ثبت شده بود؛ این مقایسه فقط تغییر مشاهده‌شده را نشان می‌دهد و علت آن را مشخص نمی‌کند.", 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 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
}