143 lines
5.8 KiB
Go
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
|
|
}
|