277 lines
11 KiB
Go
277 lines
11 KiB
Go
package eventcatalog
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var canonicalOccasionKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$`)
|
|
|
|
var jalaliBreaks = [...]int{-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178}
|
|
|
|
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"`
|
|
OccasionKey string `json:"occasionKey,omitempty"`
|
|
Variant string `json:"eventVariant,omitempty"`
|
|
JalaliYear int `json:"jalaliYear,omitempty"`
|
|
TitleFA string `json:"titleFa"`
|
|
TitleEN string `json:"titleEn,omitempty"`
|
|
Aliases []string `json:"aliases,omitempty"`
|
|
Themes []string `json:"themes,omitempty"`
|
|
CampaignTags []string `json:"campaignTags,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"`
|
|
ImportanceBPS int `json:"importanceBps,omitempty"`
|
|
}
|
|
|
|
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))
|
|
canonical := make(map[string]struct{}, len(events))
|
|
canonicalYears := 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{}{}
|
|
if !canonicalOccasionKeyPattern.MatchString(event.OccasionKey) {
|
|
return fmt.Errorf("event catalog: event %q has invalid canonical occasion key", event.ID)
|
|
}
|
|
if hasYearSegment(event.OccasionKey) {
|
|
return fmt.Errorf("event catalog: event %q has year-specific occasion key", event.ID)
|
|
}
|
|
if event.Variant == "" {
|
|
event.Variant = "PRIMARY"
|
|
}
|
|
if event.Variant != "PRIMARY" && event.Variant != "CAMPAIGN_WINDOW" && event.Variant != "OPERATIONAL_WINDOW" {
|
|
return fmt.Errorf("event catalog: event %q has invalid variant", event.ID)
|
|
}
|
|
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 || event.ImportanceBPS < 0 || event.ImportanceBPS > 10_000 {
|
|
return fmt.Errorf("event catalog: event %q has invalid impact metadata", event.ID)
|
|
}
|
|
if event.LeadDays > 90 || event.LagDays > 90 {
|
|
return fmt.Errorf("event catalog: event %q lead or lag window exceeds 90 days", event.ID)
|
|
}
|
|
if event.Recurrence != "" {
|
|
resolved, err := ResolveRecurrence(event.Recurrence, start.Year(), time.UTC)
|
|
if err != nil {
|
|
return fmt.Errorf("event catalog: event %q recurrence: %w", event.ID, err)
|
|
}
|
|
if !resolved.Equal(start) || !end.Equal(start) {
|
|
return fmt.Errorf("event catalog: event %q does not match its resolved recurrence", event.ID)
|
|
}
|
|
}
|
|
canonicalKey := strings.Join([]string{event.OccasionKey, event.Variant, event.StartDate, event.EndDate}, "\x00")
|
|
if _, exists := canonical[canonicalKey]; exists {
|
|
return fmt.Errorf("event catalog: duplicate canonical occurrence %q", event.ID)
|
|
}
|
|
canonical[canonicalKey] = struct{}{}
|
|
if event.JalaliYear != 0 {
|
|
canonicalYearKey := fmt.Sprintf("%s\x00%s\x00%d", event.OccasionKey, event.Variant, event.JalaliYear)
|
|
if _, exists := canonicalYears[canonicalYearKey]; exists {
|
|
return fmt.Errorf("event catalog: duplicate canonical occurrence in Jalali year %d for event %q", event.JalaliYear, event.ID)
|
|
}
|
|
canonicalYears[canonicalYearKey] = struct{}{}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateDeclaredCoverage verifies that each year advertised by an import has
|
|
// at least one dated event. JalaliYear is explicit review metadata: it avoids
|
|
// guessing calendar boundaries from Gregorian dates during catalog approval.
|
|
func ValidateDeclaredCoverage(events []Event, startJalaliYear, endJalaliYear int) error {
|
|
if startJalaliYear <= 0 || endJalaliYear < startJalaliYear {
|
|
return errors.New("event catalog: invalid declared Jalali coverage")
|
|
}
|
|
covered := make(map[int]struct{}, endJalaliYear-startJalaliYear+1)
|
|
for _, event := range events {
|
|
if event.JalaliYear < startJalaliYear || event.JalaliYear > endJalaliYear {
|
|
return fmt.Errorf("event catalog: event %q lacks valid Jalali year within declared coverage", event.ID)
|
|
}
|
|
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 {
|
|
return fmt.Errorf("event catalog: event %q end date: %w", event.ID, err)
|
|
}
|
|
startJalaliYear := gregorianToJalaliYear(start)
|
|
endJalaliYear := gregorianToJalaliYear(end)
|
|
belongsToOccurrenceYear := startJalaliYear == event.JalaliYear || endJalaliYear == event.JalaliYear
|
|
preparesImmediateNextYear := event.Variant == "CAMPAIGN_WINDOW" && endJalaliYear+1 == event.JalaliYear && gregorianToJalaliYear(end.AddDate(0, 0, 1)) == event.JalaliYear
|
|
if !belongsToOccurrenceYear && !preparesImmediateNextYear {
|
|
return fmt.Errorf("event catalog: event %q Jalali year is inconsistent with its Gregorian date", event.ID)
|
|
}
|
|
covered[event.JalaliYear] = struct{}{}
|
|
}
|
|
for year := startJalaliYear; year <= endJalaliYear; year++ {
|
|
if _, exists := covered[year]; !exists {
|
|
return fmt.Errorf("event catalog: declared coverage has no event in Jalali year %d", year)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func hasYearSegment(value string) bool {
|
|
digits := 0
|
|
for _, character := range value {
|
|
if character >= '0' && character <= '9' {
|
|
digits++
|
|
if digits >= 4 {
|
|
return true
|
|
}
|
|
} else {
|
|
digits = 0
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func gregorianToJalaliYear(date time.Time) int {
|
|
jy := date.Year() - 621
|
|
firstFarvardin := gregorianDayNumber(date.Year(), 3, jalaliMarchDay(jy))
|
|
if gregorianDayNumber(date.Year(), int(date.Month()), date.Day()) < firstFarvardin {
|
|
jy--
|
|
}
|
|
return jy
|
|
}
|
|
|
|
func jalaliMarchDay(jy int) int {
|
|
gy := jy + 621
|
|
leapJ := -14
|
|
previousBreak := jalaliBreaks[0]
|
|
jump := 0
|
|
for index := 1; index < len(jalaliBreaks); index++ {
|
|
currentBreak := jalaliBreaks[index]
|
|
jump = currentBreak - previousBreak
|
|
if jy < currentBreak {
|
|
break
|
|
}
|
|
leapJ += jump/33*8 + (jump%33)/4
|
|
previousBreak = currentBreak
|
|
}
|
|
n := jy - previousBreak
|
|
leapJ += n/33*8 + (n%33+3)/4
|
|
if jump%33 == 4 && jump-n == 4 {
|
|
leapJ++
|
|
}
|
|
leapG := gy/4 - ((gy/100+1)*3)/4 - 150
|
|
return 20 + leapJ - leapG
|
|
}
|
|
|
|
func gregorianDayNumber(gy, gm, gd int) int {
|
|
dayNumber := ((gy+(gm-8)/6+100100)*1461)/4 + (153*((gm+9)%12)+2)/5 + gd - 34840408
|
|
return dayNumber - (((gy+100100+(gm-8)/6)/100)*3)/4 + 752
|
|
}
|
|
|
|
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
|
|
}
|