fix: anchor growth forecasts to real Tehran date

This commit is contained in:
2026-08-21 18:27:40 +08:00
parent 7140213857
commit 8507b5d017
9 changed files with 306 additions and 6 deletions

View File

@@ -0,0 +1,137 @@
package eventcatalog
import (
"errors"
"fmt"
"strings"
"time"
)
type EventType string
type Tone string
type CommercialPosture string
type Recurrence string
const (
TypePublicHoliday EventType = "PUBLIC_HOLIDAY"
TypeReligious EventType = "RELIGIOUS"
TypeNational EventType = "NATIONAL"
TypeCultural EventType = "CULTURAL"
TypeCommercial EventType = "COMMERCIAL"
TypeSeasonal EventType = "SEASONAL"
TypeEducation EventType = "EDUCATION"
TypeEconomic EventType = "ECONOMIC"
TypeEmergency EventType = "EMERGENCY_CLOSURE"
ToneCelebratory Tone = "CELEBRATORY"
ToneJoyful Tone = "JOYFUL"
ToneNeutral Tone = "NEUTRAL"
ToneSolemn Tone = "SOLEMN"
ToneMourning Tone = "MOURNING"
ToneMemorial Tone = "MEMORIAL"
PosturePromote CommercialPosture = "PROMOTE"
PostureCaution CommercialPosture = "CAUTION"
PostureNoPromotion CommercialPosture = "NO_PROMOTION"
RecurrenceBlackFriday Recurrence = "BLACK_FRIDAY"
RecurrenceCyberMonday Recurrence = "CYBER_MONDAY"
)
type Event struct {
ID string `json:"id"`
TitleFA string `json:"titleFa"`
TitleEN string `json:"titleEn,omitempty"`
StartDate string `json:"startDate"`
EndDate string `json:"endDate"`
Recurrence Recurrence `json:"recurrence,omitempty"`
Type EventType `json:"eventType"`
Tone Tone `json:"tone"`
CommercialPosture CommercialPosture `json:"commercialPosture"`
OfficialHoliday bool `json:"officialHoliday"`
SourceURI string `json:"sourceUri"`
SourceVersion string `json:"sourceVersion"`
SourceChecksum string `json:"sourceChecksum,omitempty"`
InclusionRationale string `json:"inclusionRationale"`
GeographicScope string `json:"geographicScope"`
MerchantCategories []string `json:"merchantCategories,omitempty"`
BehavioralChannels []string `json:"behavioralChannels,omitempty"`
LeadDays int `json:"leadDays"`
LagDays int `json:"lagDays"`
RelevanceBPS int `json:"relevanceBps"`
ConfidenceBPS int `json:"confidenceBps"`
}
func ResolveRecurrence(rule Recurrence, year int, location *time.Location) (time.Time, error) {
if year < 1900 || year > 2200 {
return time.Time{}, errors.New("event catalog: recurrence year out of range")
}
thanksgiving := nthWeekday(year, time.November, time.Thursday, 4, location)
switch rule {
case RecurrenceBlackFriday:
return thanksgiving.AddDate(0, 0, 1), nil
case RecurrenceCyberMonday:
return thanksgiving.AddDate(0, 0, 4), nil
default:
return time.Time{}, fmt.Errorf("event catalog: unsupported recurrence %q", rule)
}
}
func ValidateEvents(events []Event) error {
seen := make(map[string]struct{}, len(events))
for _, event := range events {
if strings.TrimSpace(event.ID) == "" || strings.TrimSpace(event.TitleFA) == "" || strings.TrimSpace(event.SourceURI) == "" || strings.TrimSpace(event.SourceVersion) == "" || strings.TrimSpace(event.InclusionRationale) == "" || strings.TrimSpace(event.GeographicScope) == "" {
return fmt.Errorf("event catalog: event %q is incomplete", event.ID)
}
if _, exists := seen[event.ID]; exists {
return fmt.Errorf("event catalog: duplicate event %q", event.ID)
}
seen[event.ID] = struct{}{}
start, err := time.Parse(time.DateOnly, event.StartDate)
if err != nil {
return fmt.Errorf("event catalog: event %q start date: %w", event.ID, err)
}
end, err := time.Parse(time.DateOnly, event.EndDate)
if err != nil || end.Before(start) {
return fmt.Errorf("event catalog: event %q has invalid end date", event.ID)
}
if !validType(event.Type) || !validTone(event.Tone) || !validPosture(event.CommercialPosture) {
return fmt.Errorf("event catalog: event %q has invalid classification", event.ID)
}
if (event.Tone == ToneMourning || event.Tone == ToneMemorial) && event.CommercialPosture == PosturePromote {
return fmt.Errorf("event catalog: event %q cannot promote during mourning or memorial", event.ID)
}
if event.LeadDays < 0 || event.LagDays < 0 || event.RelevanceBPS < 0 || event.RelevanceBPS > 10_000 || event.ConfidenceBPS < 0 || event.ConfidenceBPS > 10_000 {
return fmt.Errorf("event catalog: event %q has invalid impact metadata", event.ID)
}
}
return nil
}
func nthWeekday(year int, month time.Month, weekday time.Weekday, occurrence int, location *time.Location) time.Time {
first := time.Date(year, month, 1, 0, 0, 0, 0, location)
offset := (int(weekday) - int(first.Weekday()) + 7) % 7
return first.AddDate(0, 0, offset+7*(occurrence-1))
}
func validType(value EventType) bool {
switch value {
case TypePublicHoliday, TypeReligious, TypeNational, TypeCultural, TypeCommercial, TypeSeasonal, TypeEducation, TypeEconomic, TypeEmergency:
return true
default:
return false
}
}
func validTone(value Tone) bool {
switch value {
case ToneCelebratory, ToneJoyful, ToneNeutral, ToneSolemn, ToneMourning, ToneMemorial:
return true
default:
return false
}
}
func validPosture(value CommercialPosture) bool {
return value == PosturePromote || value == PostureCaution || value == PostureNoPromotion
}

View File

@@ -0,0 +1,39 @@
package eventcatalog
import (
"testing"
"time"
)
func TestResolveBlackFridayUsesRecurrenceRule(t *testing.T) {
t.Parallel()
got, err := ResolveRecurrence(RecurrenceBlackFriday, 2026, time.UTC)
if err != nil {
t.Fatal(err)
}
if want := "2026-11-27"; got.Format(time.DateOnly) != want {
t.Fatalf("Black Friday = %s, want %s", got.Format(time.DateOnly), want)
}
}
func TestValidateEventsDistinguishesCelebrationAndMourning(t *testing.T) {
t.Parallel()
events := []Event{
{ID: "nowruz-1405", TitleFA: "نوروز", StartDate: "2026-03-21", EndDate: "2026-03-24", Type: TypeCultural, Tone: ToneCelebratory, CommercialPosture: PosturePromote, SourceURI: "https://calendar.ut.ac.ir/", SourceVersion: "1405", InclusionRationale: "تقویم رسمی", GeographicScope: "IR", RelevanceBPS: 10000, ConfidenceBPS: 10000},
{ID: "ashura-1405", TitleFA: "عاشورای حسینی", StartDate: "2026-06-25", EndDate: "2026-06-25", Type: TypeReligious, Tone: ToneMourning, CommercialPosture: PostureNoPromotion, SourceURI: "https://calendar.ut.ac.ir/", SourceVersion: "1405", InclusionRationale: "تقویم رسمی", GeographicScope: "IR", RelevanceBPS: 10000, ConfidenceBPS: 10000},
}
if err := ValidateEvents(events); err != nil {
t.Fatal(err)
}
}
func TestValidateEventsRejectsPromotionalMourningEvent(t *testing.T) {
t.Parallel()
event := Event{ID: "mourning", TitleFA: "یادبود", StartDate: "2026-01-01", EndDate: "2026-01-01", Type: TypeCultural, Tone: ToneMourning, CommercialPosture: PosturePromote, SourceURI: "https://example.test", SourceVersion: "v1", InclusionRationale: "reviewed", GeographicScope: "IR", RelevanceBPS: 10000, ConfidenceBPS: 10000}
if err := ValidateEvents([]Event{event}); err == nil {
t.Fatal("expected mourning promotion validation error")
}
}

View File

@@ -47,10 +47,13 @@ type Builder interface {
Build(ctx context.Context, merchantID int64, terminalID string) (Analysis, error)
}
type PostgreSQLBuilder struct{ pool *pgxpool.Pool }
type PostgreSQLBuilder struct {
pool *pgxpool.Pool
now func() time.Time
}
func NewPostgreSQLBuilder(pool *pgxpool.Pool) *PostgreSQLBuilder {
return &PostgreSQLBuilder{pool: pool}
return &PostgreSQLBuilder{pool: pool, now: time.Now}
}
func (b *PostgreSQLBuilder) Build(ctx context.Context, merchantID int64, terminalID string) (Analysis, error) {
@@ -88,7 +91,7 @@ func (b *PostgreSQLBuilder) Build(ctx context.Context, merchantID int64, termina
if err := b.pool.QueryRow(ctx, `SELECT max(created_at AT TIME ZONE 'Asia/Tehran')::date FROM payment_sessions`).Scan(&latest); err != nil {
return Analysis{}, fmt.Errorf("loading dataset watermark date: %w", err)
}
forecastStart := latest.AddDate(0, 0, 1)
forecastStart := tehranToday(b.now())
peer, err := b.loadPeer(ctx, merchantID, terminalID)
if err != nil {
return Analysis{}, err

View File

@@ -46,10 +46,11 @@ func (s *Service) Create(ctx context.Context, userID, merchantID int64, request
return Record{}, false, err
}
}
watermark, start, err := s.repository.DatasetMetadata(ctx)
watermark, _, err := s.repository.DatasetMetadata(ctx)
if err != nil {
return Record{}, false, err
}
start := tehranToday(now)
fingerprint := s.fingerprint(merchantID, request.TerminalID, watermark, start)
var refreshDay *time.Time
if request.Refresh {
@@ -91,10 +92,11 @@ func (s *Service) Latest(ctx context.Context, merchantID int64, terminalID strin
record, err := s.repository.Latest(ctx, merchantID, terminalID, growth.AnalyticsVersion)
if err == nil {
record.Cached = true
watermark, start, metadataErr := s.repository.DatasetMetadata(ctx)
watermark, _, metadataErr := s.repository.DatasetMetadata(ctx)
if metadataErr != nil {
record.Stale = true
} else {
start := tehranToday(s.now())
record.Stale = record.Fingerprint != s.fingerprint(merchantID, terminalID, watermark, start)
}
record.NextRefreshAt, _ = s.repository.NextRefreshAt(ctx, merchantID, terminalID, growth.AnalyticsVersion, s.now())

View File

@@ -295,7 +295,7 @@ func (r *Repository) DatasetMetadata(ctx context.Context) (string, time.Time, er
if err != nil {
return "", time.Time{}, fmt.Errorf("loading growth dataset metadata: %w", err)
}
return sha + ":" + latest.Format(time.DateOnly), latest.AddDate(0, 0, 1), nil
return sha + ":" + latest.Format(time.DateOnly), latest, nil
}
func (r *Repository) CleanupSuperseded(ctx context.Context, cutoff time.Time) (int64, error) {

View File

@@ -27,6 +27,16 @@ func TestNextTehranRefreshUsesCalendarBoundary(t *testing.T) {
}
}
func TestTehranTodayUsesClockInsteadOfDatasetWatermark(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.August, 21, 8, 30, 0, 0, time.UTC)
got := tehranToday(now)
if got.Format(time.DateOnly) != "2026-08-21" {
t.Fatalf("tehranToday() = %s", got.Format(time.DateOnly))
}
}
func TestEstimatedTokenReservationCoversEveryRetryAndTurn(t *testing.T) {
t.Parallel()
small := EstimatedTokenReservation([]byte("{}"))

View File

@@ -2,6 +2,15 @@ package growthreport
import "time"
func tehranToday(now time.Time) time.Time {
location, err := time.LoadLocation("Asia/Tehran")
if err != nil {
location = time.FixedZone("Asia/Tehran", 12600)
}
local := now.In(location)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
}
func NextTehranRefresh(now time.Time) time.Time {
location, err := time.LoadLocation("Asia/Tehran")
if err != nil {

49
internal/insight/time.go Normal file
View File

@@ -0,0 +1,49 @@
package insight
import "time"
type Freshness string
const (
FreshnessFresh Freshness = "FRESH"
FreshnessLagging Freshness = "LAGGING"
FreshnessStale Freshness = "STALE"
)
type TimeContext struct {
AsOfDate string `json:"asOfDate"`
DataThroughDate string `json:"dataThroughDate"`
ForecastStartDate string `json:"forecastStartDate"`
Freshness Freshness `json:"freshness"`
DataLagDays int `json:"dataLagDays"`
}
func BuildTimeContext(now, dataThrough time.Time) TimeContext {
location, err := time.LoadLocation("Asia/Tehran")
if err != nil {
location = time.FixedZone("Asia/Tehran", 3*60*60+30*60)
}
today := dateOnly(now.In(location), location)
through := dateOnly(dataThrough.In(location), location)
lag := max(0, int(today.Sub(through).Hours()/24))
return TimeContext{
AsOfDate: today.Format(time.DateOnly), DataThroughDate: through.Format(time.DateOnly),
ForecastStartDate: today.Format(time.DateOnly), Freshness: ClassifyFreshness(lag), DataLagDays: lag,
}
}
func ClassifyFreshness(lagDays int) Freshness {
switch {
case lagDays <= 1:
return FreshnessFresh
case lagDays <= 7:
return FreshnessLagging
default:
return FreshnessStale
}
}
func dateOnly(value time.Time, location *time.Location) time.Time {
year, month, day := value.In(location).Date()
return time.Date(year, month, day, 0, 0, 0, 0, location)
}

View File

@@ -0,0 +1,51 @@
package insight
import (
"testing"
"time"
)
func TestTimeContextUsesRealTehranTodayAndReportsDatasetLag(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.August, 21, 8, 30, 0, 0, time.UTC)
dataThrough := time.Date(2026, time.June, 30, 0, 0, 0, 0, time.UTC)
got := BuildTimeContext(now, dataThrough)
if got.AsOfDate != "2026-08-21" {
t.Fatalf("AsOfDate = %q, want real Tehran date", got.AsOfDate)
}
if got.DataThroughDate != "2026-06-30" {
t.Fatalf("DataThroughDate = %q", got.DataThroughDate)
}
if got.ForecastStartDate != got.AsOfDate {
t.Fatalf("ForecastStartDate = %q, want %q", got.ForecastStartDate, got.AsOfDate)
}
if got.Freshness != FreshnessStale || got.DataLagDays != 52 {
t.Fatalf("freshness = %s, lag = %d", got.Freshness, got.DataLagDays)
}
}
func TestClassifyFreshnessUsesProductionBoundaries(t *testing.T) {
t.Parallel()
tests := []struct {
name string
lag int
want Freshness
}{
{name: "same day", lag: 0, want: FreshnessFresh},
{name: "yesterday", lag: 1, want: FreshnessFresh},
{name: "two days", lag: 2, want: FreshnessLagging},
{name: "seven days", lag: 7, want: FreshnessLagging},
{name: "eight days", lag: 8, want: FreshnessStale},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ClassifyFreshness(tt.lag); got != tt.want {
t.Fatalf("ClassifyFreshness(%d) = %s, want %s", tt.lag, got, tt.want)
}
})
}
}