Files

143 lines
5.8 KiB
Go

package growth
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"time"
)
type FingerprintInput struct {
MerchantID int64 `json:"merchantId"`
TerminalID string `json:"terminalId,omitempty"`
DatasetWatermark string `json:"datasetWatermark"`
ForecastStart time.Time `json:"forecastStart"`
HorizonDays int `json:"horizonDays"`
CatalogVersion string `json:"catalogVersion"`
AnalyticsVersion string `json:"analyticsVersion"`
PromptVersion string `json:"promptVersion"`
SchemaVersion string `json:"schemaVersion"`
Model string `json:"model"`
ModelConfigVersion string `json:"modelConfigVersion"`
}
func Fingerprint(input FingerprintInput) string {
payload, err := json.Marshal(struct {
FingerprintInput
ForecastDate string `json:"forecastDate"`
}{FingerprintInput: FingerprintInput{
MerchantID: input.MerchantID, TerminalID: input.TerminalID,
DatasetWatermark: input.DatasetWatermark, HorizonDays: input.HorizonDays,
CatalogVersion: input.CatalogVersion, AnalyticsVersion: input.AnalyticsVersion,
PromptVersion: input.PromptVersion, SchemaVersion: input.SchemaVersion, Model: input.Model,
ModelConfigVersion: input.ModelConfigVersion,
}, ForecastDate: input.ForecastStart.In(tehranLocation()).Format(time.DateOnly)})
if err != nil {
return ""
}
digest := sha256.Sum256(payload)
return hex.EncodeToString(digest[:])
}
type ReportInput struct {
History []DailyPoint `json:"history"`
Catalog []Event `json:"catalog"`
ForecastStart time.Time `json:"forecastStart"`
HorizonDays int `json:"horizonDays"`
Category string `json:"category,omitempty"`
Peer *PeerInput `json:"peer,omitempty"`
}
type DeterministicReport struct {
Forecast Forecast `json:"forecast"`
HistoricalWins []EventEffect `json:"historicalWins"`
Opportunities []Opportunity `json:"opportunities"`
Risks []Risk `json:"risks"`
Peer *PeerResult `json:"peer,omitempty"`
FallbackBriefs []CampaignBrief `json:"fallbackBriefs"`
}
func BuildDeterministicReport(input ReportInput) DeterministicReport {
// Adapters provide an already-reviewed, scope-relevant catalog. Filtering it
// again by exact category discards broadly applicable commercial events.
catalog := input.Catalog
forecast := BuildForecast(input.History, catalog, input.ForecastStart, input.HorizonDays)
wins := historicalWins(input.History, catalog, input.ForecastStart)
opportunities := buildOpportunities(wins, catalog, forecast, input.ForecastStart, 3)
risks := DetectRisks(forecast, 3)
if wins == nil {
wins = []EventEffect{}
}
if opportunities == nil {
opportunities = []Opportunity{}
}
if risks == nil {
risks = []Risk{}
}
bundle := EvidenceBundle{Opportunities: opportunities, Risks: risks}
report := DeterministicReport{Forecast: forecast, HistoricalWins: wins, Opportunities: opportunities, Risks: risks, FallbackBriefs: BuildFallback(bundle)}
if input.Peer != nil {
peer := EvaluatePeer(*input.Peer)
report.Peer = &peer
}
return report
}
func TehranDay(value time.Time) string {
return value.In(tehranLocation()).Format(time.DateOnly)
}
func NextRefreshAt(lastRefresh time.Time) time.Time {
local := lastRefresh.In(tehranLocation())
nextLocal := time.Date(local.Year(), local.Month(), local.Day()+1, 0, 0, 0, 0, tehranLocation())
return nextLocal.UTC()
}
func CanManualRefresh(lastRefresh, now time.Time) bool {
if lastRefresh.IsZero() {
return true
}
return !now.Before(NextRefreshAt(lastRefresh))
}
func BuildFallback(bundle EvidenceBundle) []CampaignBrief {
briefs := make([]CampaignBrief, 0, len(bundle.Opportunities)+len(bundle.Risks))
for _, opportunity := range bundle.Opportunities {
briefs = append(briefs, CampaignBrief{
HeadlineFA: "یک فرصت مناسب در راه است: " + opportunity.EventTitleFA,
GuidanceFA: "داده‌های قبلی شما نشان می‌دهد این مناسبت می‌تواند برای برنامه‌ریزی فروش ارزشمند باشد.",
OfferFA: "پیشنهاد خود را متناسب با نیاز مشتریان این مناسبت آماده کنید.",
MessageFA: "برای این مناسبت یک پیشنهاد ویژه برای شما آماده کرده‌ایم.",
PreparationFA: "موجودی، پیام کمپین و مسیر پرداخت را پیش از شروع مناسبت بررسی کنید.",
LimitationFA: "این پیشنهاد بر پایه الگوهای گذشته است و نتیجه قطعی نیست.",
EvidenceIDs: []string{opportunity.EvidenceID},
})
}
for _, risk := range bundle.Risks {
briefs = append(briefs, CampaignBrief{
HeadlineFA: "برای یک بازه آرام‌تر آماده باشید",
GuidanceFA: "الگوی گذشته احتمال کاهش فعالیت را در این بازه نشان می‌دهد.",
OfferFA: "یک پیشنهاد سبک برای حفظ ارتباط با مشتریان در نظر بگیرید.",
MessageFA: "این روزها هم کنار شما هستیم؛ پیشنهادهای تازه را ببینید.",
PreparationFA: "پیام یادآوری و تجربه پرداخت را پیش از این بازه بازبینی کنید.",
LimitationFA: "این هشدار بر پایه الگوهای گذشته است و نتیجه قطعی نیست.",
EvidenceIDs: []string{risk.EvidenceID},
})
}
return briefs
}
func EvidenceID(kind, stableKey string) string {
digest := sha256.Sum256([]byte(kind + "\x00" + stableKey))
return fmt.Sprintf("%s_%s", kind, hex.EncodeToString(digest[:8]))
}
func tehranLocation() *time.Location {
location, err := time.LoadLocation("Asia/Tehran")
if err != nil {
return time.FixedZone("Asia/Tehran", 3*60*60+30*60)
}
return location
}