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

508 lines
32 KiB
Go
Raw Permalink 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"
"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.1.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
}
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
}
comparisonAvailable := previous.Sessions > 0
if !comparisonAvailable || latest.Sessions == 0 {
confidence = ConfidenceInsufficientData
}
evidenceMap := map[string]any{
"schemaVersion": "2.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 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,
"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, peerResult)
if period != nil {
copy = deterministicPeriodCopy(module, *period, precedingPeriodLabel, latest, previous, comparisonAvailable, timeContext, confidence, peerResult)
}
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 (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
}
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, 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
}
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, 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 {
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
}
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]
return base * weight / 10000
}