feat: expose complete Iranian reporting periods
This commit is contained in:
203
internal/analytics/periods.go
Normal file
203
internal/analytics/periods.go
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
package analytics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var jalaliBreaks = [...]int{-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178}
|
||||||
|
|
||||||
|
var jalaliMonthNames = [...]string{
|
||||||
|
"فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور",
|
||||||
|
"مهر", "آبان", "آذر", "دی", "بهمن", "اسفند",
|
||||||
|
}
|
||||||
|
|
||||||
|
var persianSeasonNames = [...]string{"بهار", "تابستان", "پاییز", "زمستان"}
|
||||||
|
|
||||||
|
type jalaliCalculation struct {
|
||||||
|
leap int
|
||||||
|
gy int
|
||||||
|
march int
|
||||||
|
}
|
||||||
|
|
||||||
|
func jalaliCalendar(jy int) jalaliCalculation {
|
||||||
|
gy := jy + 621
|
||||||
|
leapJ := -14
|
||||||
|
jp := jalaliBreaks[0]
|
||||||
|
jump := 0
|
||||||
|
for index := 1; index < len(jalaliBreaks); index++ {
|
||||||
|
jm := jalaliBreaks[index]
|
||||||
|
jump = jm - jp
|
||||||
|
if jy < jm {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
leapJ += jump/33*8 + (jump%33)/4
|
||||||
|
jp = jm
|
||||||
|
}
|
||||||
|
n := jy - jp
|
||||||
|
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
|
||||||
|
march := 20 + leapJ - leapG
|
||||||
|
if jump-n < 6 {
|
||||||
|
n = n - jump + (jump+4)/33*33
|
||||||
|
}
|
||||||
|
leap := ((n+1)%33 - 1) % 4
|
||||||
|
if leap == -1 {
|
||||||
|
leap = 4
|
||||||
|
}
|
||||||
|
return jalaliCalculation{leap: leap, gy: gy, march: march}
|
||||||
|
}
|
||||||
|
|
||||||
|
func gregorianDayNumber(gy, gm, gd int) int {
|
||||||
|
d := ((gy+(gm-8)/6+100100)*1461)/4 + (153*((gm+9)%12)+2)/5 + gd - 34840408
|
||||||
|
d = d - (((gy+100100+(gm-8)/6)/100)*3)/4 + 752
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func gregorianFromDayNumber(dayNumber int) (int, int, int) {
|
||||||
|
j := 4*dayNumber + 139361631
|
||||||
|
j = j + (((4*dayNumber+183187720)/146097)*3/4)*4 - 3908
|
||||||
|
i := ((j % 1461) / 4 * 5) + 308
|
||||||
|
gd := (i%153)/5 + 1
|
||||||
|
gm := (i/153)%12 + 1
|
||||||
|
gy := j/1461 - 100100 + (8-gm)/6
|
||||||
|
return gy, gm, gd
|
||||||
|
}
|
||||||
|
|
||||||
|
func jalaliDayNumber(jy, jm, jd int) int {
|
||||||
|
calculation := jalaliCalendar(jy)
|
||||||
|
return gregorianDayNumber(calculation.gy, 3, calculation.march) + (jm-1)*31 - (jm/7)*(jm-7) + jd - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func jalaliToGregorian(jy, jm, jd int) (int, int, int) {
|
||||||
|
return gregorianFromDayNumber(jalaliDayNumber(jy, jm, jd))
|
||||||
|
}
|
||||||
|
|
||||||
|
func gregorianToJalali(gy, gm, gd int) (int, int, int) {
|
||||||
|
jy := gy - 621
|
||||||
|
calculation := jalaliCalendar(jy)
|
||||||
|
dayNumber := gregorianDayNumber(gy, gm, gd)
|
||||||
|
firstFarvardin := gregorianDayNumber(gy, 3, calculation.march)
|
||||||
|
k := dayNumber - firstFarvardin
|
||||||
|
if k >= 0 {
|
||||||
|
if k <= 185 {
|
||||||
|
return jy, 1 + k/31, k%31 + 1
|
||||||
|
}
|
||||||
|
k -= 186
|
||||||
|
} else {
|
||||||
|
jy--
|
||||||
|
k += 179
|
||||||
|
if calculation.leap == 1 {
|
||||||
|
k++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return jy, 7 + k/30, k%30 + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func jalaliDate(jy, jm, jd int, location *time.Location) time.Time {
|
||||||
|
gy, gm, gd := jalaliToGregorian(jy, jm, jd)
|
||||||
|
return time.Date(gy, time.Month(gm), gd, 0, 0, 0, 0, location)
|
||||||
|
}
|
||||||
|
|
||||||
|
func previousJalaliMonth(jy, jm int) (int, int) {
|
||||||
|
jm--
|
||||||
|
if jm == 0 {
|
||||||
|
return jy - 1, 12
|
||||||
|
}
|
||||||
|
return jy, jm
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextJalaliMonth(jy, jm int) (int, int) {
|
||||||
|
jm++
|
||||||
|
if jm == 13 {
|
||||||
|
return jy + 1, 1
|
||||||
|
}
|
||||||
|
return jy, jm
|
||||||
|
}
|
||||||
|
|
||||||
|
func latestCompletePeriods(datasetStart, datasetMax time.Time, location *time.Location) []Period {
|
||||||
|
localStart := datasetStart.In(location)
|
||||||
|
localMax := datasetMax.In(location)
|
||||||
|
datasetStartDay := time.Date(localStart.Year(), localStart.Month(), localStart.Day(), 0, 0, 0, 0, location)
|
||||||
|
dayStart := time.Date(localMax.Year(), localMax.Month(), localMax.Day(), 0, 0, 0, 0, location)
|
||||||
|
datasetEnd := dayStart.AddDate(0, 0, 1)
|
||||||
|
|
||||||
|
weekEnd := datasetEnd
|
||||||
|
for weekEnd.Weekday() != time.Saturday {
|
||||||
|
weekEnd = weekEnd.AddDate(0, 0, -1)
|
||||||
|
}
|
||||||
|
weekStart := weekEnd.AddDate(0, 0, -7)
|
||||||
|
|
||||||
|
jy, jm, _ := gregorianToJalali(localMax.Year(), int(localMax.Month()), localMax.Day())
|
||||||
|
monthStart := jalaliDate(jy, jm, 1, location)
|
||||||
|
nextYear, nextMonth := nextJalaliMonth(jy, jm)
|
||||||
|
monthEnd := jalaliDate(nextYear, nextMonth, 1, location)
|
||||||
|
if monthEnd.After(datasetEnd) {
|
||||||
|
jy, jm = previousJalaliMonth(jy, jm)
|
||||||
|
monthStart = jalaliDate(jy, jm, 1, location)
|
||||||
|
nextYear, nextMonth = nextJalaliMonth(jy, jm)
|
||||||
|
monthEnd = jalaliDate(nextYear, nextMonth, 1, location)
|
||||||
|
}
|
||||||
|
|
||||||
|
seasonStartMonth := ((jm-1)/3)*3 + 1
|
||||||
|
seasonYear := jy
|
||||||
|
seasonStart := jalaliDate(seasonYear, seasonStartMonth, 1, location)
|
||||||
|
seasonEndYear, seasonEndMonth := seasonYear, seasonStartMonth+3
|
||||||
|
if seasonEndMonth > 12 {
|
||||||
|
seasonEndYear++
|
||||||
|
seasonEndMonth -= 12
|
||||||
|
}
|
||||||
|
seasonEnd := jalaliDate(seasonEndYear, seasonEndMonth, 1, location)
|
||||||
|
if seasonEnd.After(datasetEnd) {
|
||||||
|
seasonStartMonth -= 3
|
||||||
|
if seasonStartMonth < 1 {
|
||||||
|
seasonYear--
|
||||||
|
seasonStartMonth += 12
|
||||||
|
}
|
||||||
|
seasonStart = jalaliDate(seasonYear, seasonStartMonth, 1, location)
|
||||||
|
seasonEndYear, seasonEndMonth = seasonYear, seasonStartMonth+3
|
||||||
|
if seasonEndMonth > 12 {
|
||||||
|
seasonEndYear++
|
||||||
|
seasonEndMonth -= 12
|
||||||
|
}
|
||||||
|
seasonEnd = jalaliDate(seasonEndYear, seasonEndMonth, 1, location)
|
||||||
|
}
|
||||||
|
|
||||||
|
dayJY, dayJM, dayJD := gregorianToJalali(dayStart.Year(), int(dayStart.Month()), dayStart.Day())
|
||||||
|
weekEndJY, weekEndJM, weekEndJD := gregorianToJalali(weekEnd.AddDate(0, 0, -1).Year(), int(weekEnd.AddDate(0, 0, -1).Month()), weekEnd.AddDate(0, 0, -1).Day())
|
||||||
|
weekStartJY, weekStartJM, weekStartJD := gregorianToJalali(weekStart.Year(), int(weekStart.Month()), weekStart.Day())
|
||||||
|
|
||||||
|
periods := []Period{
|
||||||
|
newPeriod("d-"+dayStart.Format(time.DateOnly), "DAY", dayStart, datasetEnd,
|
||||||
|
fmt.Sprintf("روز %s %s %s", persianNumber(dayJD), jalaliMonthNames[dayJM-1], persianNumber(dayJY)), false),
|
||||||
|
newPeriod("w-"+weekStart.Format(time.DateOnly), "WEEK", weekStart, weekEnd,
|
||||||
|
fmt.Sprintf("هفته %s %s تا %s %s", persianNumber(weekStartJD), jalaliMonthNames[weekStartJM-1], persianNumber(weekEndJD), jalaliMonthNames[weekEndJM-1]), false),
|
||||||
|
newPeriod(fmt.Sprintf("jm-%04d-%02d", jy, jm), "JALALI_MONTH", monthStart, monthEnd,
|
||||||
|
fmt.Sprintf("%s %s", jalaliMonthNames[jm-1], persianNumber(jy)), true),
|
||||||
|
newPeriod(fmt.Sprintf("ps-%04d-%02d", seasonYear, seasonStartMonth), "PERSIAN_SEASON", seasonStart, seasonEnd,
|
||||||
|
fmt.Sprintf("%s %s", persianSeasonNames[(seasonStartMonth-1)/3], persianNumber(seasonYear)), false),
|
||||||
|
}
|
||||||
|
for index := range periods {
|
||||||
|
periods[index].IsComplete = !periods[index].LocalStartAt.Before(datasetStartDay)
|
||||||
|
}
|
||||||
|
_ = weekEndJY
|
||||||
|
_ = weekStartJY
|
||||||
|
return periods
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPeriod(id, kind string, localStart, localEnd time.Time, label string, isDefault bool) Period {
|
||||||
|
return Period{
|
||||||
|
PeriodID: id, PeriodType: kind, StartAt: localStart.UTC(), EndAt: localEnd.UTC(),
|
||||||
|
LocalStartAt: localStart, LocalEndAt: localEnd, Timezone: "Asia/Tehran",
|
||||||
|
JalaliLabelFA: label, IsComplete: true, IsDefault: isDefault,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func persianNumber(value int) string {
|
||||||
|
replacer := strings.NewReplacer("0", "۰", "1", "۱", "2", "۲", "3", "۳", "4", "۴", "5", "۵", "6", "۶", "7", "۷", "8", "۸", "9", "۹")
|
||||||
|
return replacer.Replace(fmt.Sprint(value))
|
||||||
|
}
|
||||||
76
internal/analytics/periods_test.go
Normal file
76
internal/analytics/periods_test.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package analytics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJalaliGregorianRoundTrip(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
jy, jm, jd int
|
||||||
|
gy, gm, gd int
|
||||||
|
}{
|
||||||
|
{name: "Nowruz 1405", jy: 1405, jm: 1, jd: 1, gy: 2026, gm: 3, gd: 21},
|
||||||
|
{name: "leap Esfand", jy: 1399, jm: 12, jd: 30, gy: 2021, gm: 3, gd: 20},
|
||||||
|
{name: "Tir boundary", jy: 1405, jm: 4, jd: 1, gy: 2026, gm: 6, gd: 22},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gy, gm, gd := jalaliToGregorian(tt.jy, tt.jm, tt.jd)
|
||||||
|
if gy != tt.gy || gm != tt.gm || gd != tt.gd {
|
||||||
|
t.Fatalf("jalaliToGregorian(%d,%d,%d) = %04d-%02d-%02d, want %04d-%02d-%02d", tt.jy, tt.jm, tt.jd, gy, gm, gd, tt.gy, tt.gm, tt.gd)
|
||||||
|
}
|
||||||
|
jy, jm, jd := gregorianToJalali(tt.gy, tt.gm, tt.gd)
|
||||||
|
if jy != tt.jy || jm != tt.jm || jd != tt.jd {
|
||||||
|
t.Fatalf("gregorianToJalali(%04d-%02d-%02d) = %d-%02d-%02d, want %d-%02d-%02d", tt.gy, tt.gm, tt.gd, jy, jm, jd, tt.jy, tt.jm, tt.jd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatestCompletePeriods(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
location, err := time.LoadLocation("Asia/Tehran")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
datasetStart := time.Date(2026, time.January, 1, 0, 0, 0, 0, location)
|
||||||
|
datasetMax := time.Date(2026, time.June, 30, 23, 59, 0, 0, location)
|
||||||
|
|
||||||
|
periods := latestCompletePeriods(datasetStart, datasetMax, location)
|
||||||
|
if len(periods) != 4 {
|
||||||
|
t.Fatalf("latestCompletePeriods() returned %d periods, want 4", len(periods))
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []struct {
|
||||||
|
id, kind, start, end string
|
||||||
|
isDefault bool
|
||||||
|
}{
|
||||||
|
{id: "d-2026-06-30", kind: "DAY", start: "2026-06-30", end: "2026-07-01"},
|
||||||
|
{id: "w-2026-06-20", kind: "WEEK", start: "2026-06-20", end: "2026-06-27"},
|
||||||
|
{id: "jm-1405-03", kind: "JALALI_MONTH", start: "2026-05-22", end: "2026-06-22", isDefault: true},
|
||||||
|
{id: "ps-1405-01", kind: "PERSIAN_SEASON", start: "2026-03-21", end: "2026-06-22"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, expected := range want {
|
||||||
|
period := periods[index]
|
||||||
|
if period.PeriodID != expected.id || period.PeriodType != expected.kind {
|
||||||
|
t.Errorf("period[%d] identity = %s/%s, want %s/%s", index, period.PeriodID, period.PeriodType, expected.id, expected.kind)
|
||||||
|
}
|
||||||
|
if got := period.LocalStartAt.Format(time.DateOnly); got != expected.start {
|
||||||
|
t.Errorf("period[%d] start = %s, want %s", index, got, expected.start)
|
||||||
|
}
|
||||||
|
if got := period.LocalEndAt.Format(time.DateOnly); got != expected.end {
|
||||||
|
t.Errorf("period[%d] end = %s, want %s", index, got, expected.end)
|
||||||
|
}
|
||||||
|
if period.IsDefault != expected.isDefault {
|
||||||
|
t.Errorf("period[%d] IsDefault = %v, want %v", index, period.IsDefault, expected.isDefault)
|
||||||
|
}
|
||||||
|
if period.StartAt.Location() != time.UTC || period.EndAt.Location() != time.UTC {
|
||||||
|
t.Errorf("period[%d] UTC boundaries do not use UTC", index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ func New(pool *pgxpool.Pool) *Service { return &Service{pool: pool, runs: map[st
|
|||||||
|
|
||||||
func (s *Service) Periods(ctx context.Context) ([]Period, error) {
|
func (s *Service) Periods(ctx context.Context) ([]Period, error) {
|
||||||
var start, end time.Time
|
var start, end time.Time
|
||||||
err := s.pool.QueryRow(ctx, `SELECT date_trunc('day',min(created_at)),date_trunc('day',max(created_at))+interval '1 day' FROM payment_sessions`).Scan(&start, &end)
|
err := s.pool.QueryRow(ctx, `SELECT min(created_at),max(created_at) FROM payment_sessions`).Scan(&start, &end)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("loading dataset period: %w", err)
|
return nil, fmt.Errorf("loading dataset period: %w", err)
|
||||||
}
|
}
|
||||||
@@ -59,10 +59,7 @@ func (s *Service) Periods(ctx context.Context) ([]Period, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("loading tehran timezone: %w", err)
|
return nil, fmt.Errorf("loading tehran timezone: %w", err)
|
||||||
}
|
}
|
||||||
period := Period{PeriodID: "dataset-all", PeriodType: "DAY", StartAt: start.UTC(), EndAt: end.UTC(),
|
return latestCompletePeriods(start, end, location), nil
|
||||||
LocalStartAt: start.In(location), LocalEndAt: end.In(location), Timezone: "Asia/Tehran",
|
|
||||||
JalaliLabelFA: "کل دادههای واردشده", IsComplete: true, IsDefault: true}
|
|
||||||
return []Period{period}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Start(ctx context.Context, merchantID int64, period Period) (Run, error) {
|
func (s *Service) Start(ctx context.Context, merchantID int64, period Period) (Run, error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user