feat: ground competitive insights in peer metrics
This commit is contained in:
@@ -7,14 +7,16 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/analytics"
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/peercomparison"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const AnalyticsVersion = "2.0.0"
|
||||
const AnalyticsVersion = "2.1.0"
|
||||
|
||||
type Analysis struct {
|
||||
Time TimeContext
|
||||
@@ -99,6 +101,14 @@ func (b *PostgreSQLBuilder) build(ctx context.Context, merchantID int64, termina
|
||||
if err != nil {
|
||||
return Analysis{}, err
|
||||
}
|
||||
var peerResult *peercomparison.Result
|
||||
if module == ModulePeers {
|
||||
result, peerErr := b.peerComparison(ctx, merchantID, terminalID, period, dataThrough)
|
||||
if peerErr != nil {
|
||||
return Analysis{}, peerErr
|
||||
}
|
||||
peerResult = &result
|
||||
}
|
||||
confidence := ConfidenceMedium
|
||||
if timeContext.Freshness == FreshnessStale {
|
||||
confidence = ConfidenceLow
|
||||
@@ -108,7 +118,7 @@ func (b *PostgreSQLBuilder) build(ctx context.Context, merchantID int64, termina
|
||||
confidence = ConfidenceInsufficientData
|
||||
}
|
||||
evidenceMap := map[string]any{
|
||||
"schemaVersion": "1.0.0", "module": module, "comparisonAvailable": comparisonAvailable,
|
||||
"schemaVersion": "2.0.0", "module": module, "comparisonAvailable": comparisonAvailable,
|
||||
"time": timeContext, "privacy": map[string]any{"aggregateOnly": true, "peerSuppressionApplied": module == ModulePeers},
|
||||
}
|
||||
precedingPeriodLabel := ""
|
||||
@@ -124,6 +134,9 @@ func (b *PostgreSQLBuilder) build(ctx context.Context, merchantID int64, termina
|
||||
evidenceMap["latest30Days"] = latest
|
||||
evidenceMap["previousYearEquivalent"] = previous
|
||||
}
|
||||
if peerResult != nil {
|
||||
evidenceMap["peerComparison"] = peerResult
|
||||
}
|
||||
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,
|
||||
@@ -151,9 +164,9 @@ func (b *PostgreSQLBuilder) build(ctx context.Context, merchantID int64, termina
|
||||
if err != nil {
|
||||
return Analysis{}, fmt.Errorf("encoding insight evidence: %w", err)
|
||||
}
|
||||
copy := deterministicCopy(module, latest, previous, comparisonAvailable, timeContext, confidence)
|
||||
copy := deterministicCopy(module, latest, previous, comparisonAvailable, timeContext, confidence, peerResult)
|
||||
if period != nil {
|
||||
copy = deterministicPeriodCopy(module, *period, precedingPeriodLabel, latest, previous, comparisonAvailable, timeContext, confidence)
|
||||
copy = deterministicPeriodCopy(module, *period, precedingPeriodLabel, latest, previous, comparisonAvailable, timeContext, confidence, peerResult)
|
||||
}
|
||||
return Analysis{Time: timeContext, Confidence: confidence, Evidence: encoded, Copy: copy, Calendar: calendar}, nil
|
||||
}
|
||||
@@ -188,6 +201,59 @@ func (b *PostgreSQLBuilder) aggregateRange(ctx context.Context, merchantID int64
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (b *PostgreSQLBuilder) peerComparison(ctx context.Context, merchantID int64, terminalID string, period *PeriodScope, dataThrough time.Time) (peercomparison.Result, error) {
|
||||
if terminalID != "" {
|
||||
return peercomparison.Calculate(merchantID, true, nil), nil
|
||||
}
|
||||
start, end := dataThrough.AddDate(0, 0, -29), dataThrough.AddDate(0, 0, 1)
|
||||
if period != nil {
|
||||
start, end = period.StartAt, period.EndAt
|
||||
}
|
||||
query := `WITH session_stats AS (
|
||||
SELECT ps.merchant_id,ps.id,ps.status,ps.amount_rial,ps.fee_amount_rial,count(pa.id) attempts
|
||||
FROM payment_sessions ps LEFT JOIN payment_attempts pa ON pa.session_id=ps.id
|
||||
JOIN merchants m ON m.id=ps.merchant_id
|
||||
WHERE m.category_id=(SELECT category_id FROM merchants WHERE id=$1) AND ps.created_at >= $2 AND ps.created_at < $3
|
||||
GROUP BY ps.id
|
||||
), aggregate AS (
|
||||
SELECT merchant_id,count(*) session_count,
|
||||
100.0*count(*) FILTER(WHERE status IN ('Paid','Verified'))/nullif(count(*),0) success,
|
||||
100.0*count(*) FILTER(WHERE status='Verified')/nullif(count(*) FILTER(WHERE status IN ('Paid','Verified')),0) verification,
|
||||
100.0*count(*) FILTER(WHERE attempts>1 AND status IN ('Paid','Verified'))/nullif(count(*) FILTER(WHERE attempts>1),0) recovery,
|
||||
100.0*sum(fee_amount_rial) FILTER(WHERE status IN ('Paid','Verified'))/nullif(sum(amount_rial) FILTER(WHERE status IN ('Paid','Verified')),0) fee
|
||||
FROM session_stats GROUP BY merchant_id
|
||||
), timing AS (
|
||||
SELECT ps.merchant_id,
|
||||
percentile_disc(.5) within group(order by pa.init_time_ms)::float8 ip50,
|
||||
percentile_disc(.95) within group(order by pa.init_time_ms)::float8 ip95,
|
||||
percentile_disc(.5) within group(order by pa.verify_time_ms)::float8 vp50,
|
||||
percentile_disc(.95) within group(order by pa.verify_time_ms)::float8 vp95
|
||||
FROM payment_sessions ps JOIN payment_attempts pa ON pa.session_id=ps.id JOIN merchants m ON m.id=ps.merchant_id
|
||||
WHERE m.category_id=(SELECT category_id FROM merchants WHERE id=$1) AND ps.created_at >= $2 AND ps.created_at < $3
|
||||
GROUP BY ps.merchant_id
|
||||
)
|
||||
SELECT a.merchant_id,a.session_count,a.success::float8,a.verification::float8,a.recovery::float8,
|
||||
t.ip50,t.ip95,t.vp50,t.vp95,a.fee::float8
|
||||
FROM aggregate a LEFT JOIN timing t USING(merchant_id)`
|
||||
rows, err := b.pool.Query(ctx, query, merchantID, start, end)
|
||||
if err != nil {
|
||||
return peercomparison.Result{}, fmt.Errorf("loading peer insight evidence: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
merchants := make([]peercomparison.MerchantValues, 0)
|
||||
for rows.Next() {
|
||||
var row peercomparison.MerchantValues
|
||||
if err := rows.Scan(&row.MerchantID, &row.SessionCount, &row.Values[0], &row.Values[1], &row.Values[2], &row.Values[3], &row.Values[4], &row.Values[5], &row.Values[6], &row.Values[7]); err != nil {
|
||||
return peercomparison.Result{}, fmt.Errorf("scanning peer insight evidence: %w", err)
|
||||
}
|
||||
merchants = append(merchants, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return peercomparison.Result{}, fmt.Errorf("iterating peer insight evidence: %w", err)
|
||||
}
|
||||
return peercomparison.Calculate(merchantID, false, merchants), nil
|
||||
}
|
||||
|
||||
func ratio(numerator, denominator int64) any {
|
||||
if denominator == 0 {
|
||||
return nil
|
||||
@@ -284,8 +350,11 @@ func horizon(module Module) int {
|
||||
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]
|
||||
func deterministicCopy(module Module, latest, previous aggregate, comparison bool, tc TimeContext, confidence Confidence, peer ...*peercomparison.Result) Copy {
|
||||
label := map[Module]string{ModuleOverview: "وضعیت کلی کسبوکار", ModulePayments: "عملکرد پرداخت", ModuleFees: "کارمزدها", ModulePeers: "جایگاه رقابتی", ModuleTransactions: "الگوی تراکنشها", ModuleGrowth: "رشد"}[module]
|
||||
if module == ModulePeers && len(peer) > 0 && peer[0] != nil {
|
||||
return deterministicPeerCopy(peer[0], tc.DataThroughDate)
|
||||
}
|
||||
change := 0.0
|
||||
if previous.Paid > 0 {
|
||||
change = float64(latest.Paid-previous.Paid) / float64(previous.Paid) * 100
|
||||
@@ -346,10 +415,13 @@ func deterministicTransactionCopy(period PeriodScope, precedingPeriodLabel strin
|
||||
}
|
||||
}
|
||||
|
||||
func deterministicPeriodCopy(module Module, period PeriodScope, precedingPeriodLabel string, latest, previous aggregate, comparison bool, tc TimeContext, confidence Confidence) Copy {
|
||||
func deterministicPeriodCopy(module Module, period PeriodScope, precedingPeriodLabel string, latest, previous aggregate, comparison bool, tc TimeContext, confidence Confidence, peer ...*peercomparison.Result) Copy {
|
||||
if module == ModuleTransactions {
|
||||
return deterministicTransactionCopy(period, precedingPeriodLabel, latest, previous, comparison, tc, confidence)
|
||||
}
|
||||
if module == ModulePeers && len(peer) > 0 && peer[0] != nil {
|
||||
return deterministicPeerCopy(peer[0], period.JalaliLabelFA)
|
||||
}
|
||||
|
||||
latestBody := fmt.Sprintf("در %s، %d تراکنش ثبت شد و %d پرداخت با موفقیت انجام شد.", period.JalaliLabelFA, latest.Sessions, latest.Paid)
|
||||
switch module {
|
||||
@@ -378,6 +450,56 @@ func deterministicPeriodCopy(module Module, period PeriodScope, precedingPeriodL
|
||||
return copy
|
||||
}
|
||||
|
||||
var peerMetricLabels = map[string]string{
|
||||
"payment_success_rate": "موفقیت پرداخت", "verification_completion_rate": "تکمیل تأیید", "retry_recovery_rate": "بازیابی تلاش مجدد",
|
||||
"init_time_ms_p50": "میانه زمان شروع", "init_time_ms_p95": "زمان شروع پرتکرار", "verify_time_ms_p50": "میانه زمان تأیید", "verify_time_ms_p95": "زمان تأیید پرتکرار", "paid_fee_rate": "نرخ کارمزد پرداخت موفق",
|
||||
}
|
||||
|
||||
func deterministicPeerCopy(result *peercomparison.Result, periodLabel string) Copy {
|
||||
if result.State != peercomparison.StateReady || len(result.Metrics) == 0 {
|
||||
body := "برای مقایسه جایگاه رقابتی، هنوز دادهٔ تجمیعی کافی از کسبوکارهای مشابه وجود ندارد."
|
||||
return Copy{Past: Narrative{Headline: "مقایسه با گروه مقایسه", Body: body, EvidenceID: []string{"peer-comparison"}}, LatestObserved: Narrative{Headline: "جایگاه رقابتی", Body: body, EvidenceID: []string{"peer-comparison"}}, Future7Days: Narrative{Headline: "برداشت کوتاهمدت", Body: body, EvidenceID: []string{"peer-comparison"}}, Future30Days: Narrative{Headline: "اقدام پیشنهادی", Body: "با تکمیل دادهٔ گروه مقایسه، این بخش جایگاه شما را دقیقتر توضیح میدهد.", EvidenceID: []string{"peer-comparison"}}, Findings: []Finding{{EvidenceID: "peer-comparison", Title: "دادهٔ مقایسه", Summary: body}}, Options: []Option{{Title: "بررسی دوباره", Description: "پس از تکمیل دادهٔ گروه مقایسه، این تحلیل را دوباره بررسی کنید.", EvidenceID: []string{"peer-comparison"}}}}
|
||||
}
|
||||
strongest, weakest := result.Metrics[0], result.Metrics[0]
|
||||
total := 0.0
|
||||
for _, metric := range result.Metrics {
|
||||
value := parsePercent(metric.MerchantPerformancePercentile)
|
||||
total += value
|
||||
if value > parsePercent(strongest.MerchantPerformancePercentile) {
|
||||
strongest = metric
|
||||
}
|
||||
if value < parsePercent(weakest.MerchantPerformancePercentile) {
|
||||
weakest = metric
|
||||
}
|
||||
}
|
||||
overall := total / float64(len(result.Metrics))
|
||||
strongLabel, weakLabel := peerMetricLabels[strongest.MetricID], peerMetricLabels[weakest.MetricID]
|
||||
if strongLabel == "" {
|
||||
strongLabel = strongest.MetricID
|
||||
}
|
||||
if weakLabel == "" {
|
||||
weakLabel = weakest.MetricID
|
||||
}
|
||||
periodPrefix := ""
|
||||
if periodLabel != "" {
|
||||
periodPrefix = fmt.Sprintf("در %s، ", periodLabel)
|
||||
}
|
||||
position := fmt.Sprintf("%sمیانگین جایگاه شما در هشت شاخص گروه مقایسه صدک %.2f است؛ صدک بالاتر یعنی عملکرد بهتر در این مقایسه.", periodPrefix, overall)
|
||||
strongBody := fmt.Sprintf("قویترین دستهٔ شما «%s» با صدک %s است.", strongLabel, strongest.MerchantPerformancePercentile)
|
||||
weakBody := fmt.Sprintf("ضعیفترین دستهٔ شما «%s» با صدک %s است؛ بهبود همین دسته بیشترین فرصت رشد را دارد.", weakLabel, weakest.MerchantPerformancePercentile)
|
||||
action := fmt.Sprintf("روند «%s» را در بازههای بعد بررسی کنید و برای بهبود آن یک اقدام مشخص در فرایند پرداخت تعریف کنید.", weakLabel)
|
||||
latest := position + " " + strongBody + " " + weakBody
|
||||
return Copy{Past: Narrative{Headline: "مقایسه با گروه مقایسه", Body: position, EvidenceID: []string{"peer-position"}}, LatestObserved: Narrative{Headline: "جایگاه رقابتی", Body: latest, EvidenceID: []string{"peer-position", "peer-strongest", "peer-weakest"}}, Future7Days: Narrative{Headline: "برداشت کوتاهمدت", Body: strongBody, EvidenceID: []string{"peer-strongest"}}, Future30Days: Narrative{Headline: "اقدام پیشنهادی", Body: action, EvidenceID: []string{"peer-weakest", "peer-action"}}, Findings: []Finding{{EvidenceID: "peer-position", Title: "جایگاه کلی", Summary: position, ScoreBPS: int(overall * 100), Confidence: ConfidenceMedium}, {EvidenceID: "peer-strongest", Title: "قویترین دسته", Summary: strongBody, ScoreBPS: int(parsePercent(strongest.MerchantPerformancePercentile) * 100), Confidence: ConfidenceMedium}, {EvidenceID: "peer-weakest", Title: "فرصت رشد", Summary: weakBody, ScoreBPS: int(parsePercent(weakest.MerchantPerformancePercentile) * 100), Confidence: ConfidenceMedium}}, Options: []Option{{Title: "تمرکز بر فرصت رشد", Description: action, EvidenceID: []string{"peer-weakest", "peer-action"}}}}
|
||||
}
|
||||
|
||||
func parsePercent(value string) float64 {
|
||||
result, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user