fix: expose complete dataset period catalog
This commit is contained in:
@@ -167,7 +167,7 @@ Every section uses a versioned typed contract and has independent `queued`, `cal
|
||||
|
||||
- Normalized PostgreSQL facts are authoritative; Redis is a 30-day disposable analytics cache.
|
||||
- Four sections calculate lazily and in bounded parallel, with partial authenticated SSE delivery.
|
||||
- Reporting covers day, Iranian week, Jalali month, and Persian season only.
|
||||
- Reporting covers every complete day, Iranian week, Jalali month, and Persian season inside the imported dataset's local-date coverage; arbitrary client-computed ranges remain disallowed.
|
||||
- Peer comparison uses same-category merchant-weighted medians and direction-aware percentiles with the approved suppression threshold.
|
||||
- Payment lifecycle, retry, timing, and real-fee definitions follow `METRIC-CATALOGUE.md` and ADR 0013.
|
||||
- Asynq `v0.26.0` is pinned and acknowledged as pre-1.0; jobs have bounded concurrency, timeouts, rate limits, and exponential-backoff retries.
|
||||
|
||||
@@ -79,7 +79,7 @@ Clients select one allowlisted `periodId` returned by `GET /analytics/periods`;
|
||||
- `timezone: Asia/Tehran` and `jalaliLabelFa`, ready for display;
|
||||
- `isComplete` and `isDefault` flags.
|
||||
|
||||
The default is the latest complete Jalali month containing source data. Weekly periods start Saturday at `00:00` and end at the following Saturday `00:00` in Tehran. The optional `periodType` query is allowlisted and filters the returned descriptors. The server rejects stale or fabricated period ids and invalid period types instead of accepting client-computed dates.
|
||||
The catalog contains every complete day, Saturday–Friday week, Jalali month, and Persian season inside the imported dataset's local-date coverage. Descriptors are grouped by type and ordered newest-first within each group. The default is the latest complete Jalali month containing source data. Weekly periods start Saturday at `00:00` and end at the following Saturday `00:00` in Tehran. The optional `periodType` query is allowlisted and filters the returned descriptors. The server rejects stale or fabricated period ids and invalid period types instead of accepting client-computed dates.
|
||||
|
||||
## Analytics-run contract
|
||||
|
||||
|
||||
@@ -136,9 +136,11 @@ paths:
|
||||
operationId: listAnalyticsPeriods
|
||||
summary: List approved reporting periods
|
||||
description: |
|
||||
Returns allowlisted periods with server-calculated UTC and Tehran-local
|
||||
half-open boundaries. Clients start runs with a returned `periodId` and
|
||||
never submit arbitrary timestamps.
|
||||
Returns every complete allowlisted day, Iranian week, Jalali month, and
|
||||
Persian season inside the imported dataset's local-date coverage, with
|
||||
server-calculated UTC and Tehran-local half-open boundaries. Periods are
|
||||
grouped by type and ordered newest-first inside each group. Clients start
|
||||
runs with a returned `periodId` and never submit arbitrary timestamps.
|
||||
parameters:
|
||||
- name: periodType
|
||||
in: query
|
||||
@@ -1090,6 +1092,7 @@ components:
|
||||
$ref: '#/components/schemas/PeriodId'
|
||||
periods:
|
||||
type: array
|
||||
description: Dataset-bounded complete periods, grouped by type and newest-first within each group.
|
||||
maxItems: 1000
|
||||
items:
|
||||
$ref: '#/components/schemas/ReportingPeriod'
|
||||
|
||||
@@ -119,76 +119,121 @@ func nextJalaliMonth(jy, jm int) (int, int) {
|
||||
return jy, jm
|
||||
}
|
||||
|
||||
func latestCompletePeriods(datasetStart, datasetMax time.Time, location *time.Location) []Period {
|
||||
func completePeriods(datasetStart, datasetMax time.Time, location *time.Location) []Period {
|
||||
if datasetMax.Before(datasetStart) {
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
datasetEnd := time.Date(localMax.Year(), localMax.Month(), localMax.Day(), 0, 0, 0, 0, location).AddDate(0, 0, 1)
|
||||
periods := make([]Period, 0)
|
||||
|
||||
for dayStart := datasetEnd.AddDate(0, 0, -1); !dayStart.Before(datasetStartDay); dayStart = dayStart.AddDate(0, 0, -1) {
|
||||
jy, jm, jd := gregorianToJalali(dayStart.Year(), int(dayStart.Month()), dayStart.Day())
|
||||
periods = append(periods, newPeriod(
|
||||
"d-"+dayStart.Format(time.DateOnly),
|
||||
"DAY",
|
||||
dayStart,
|
||||
dayStart.AddDate(0, 0, 1),
|
||||
fmt.Sprintf("روز %s %s %s", persianNumber(jd), jalaliMonthNames[jm-1], persianNumber(jy)),
|
||||
false,
|
||||
))
|
||||
}
|
||||
|
||||
weekEnd := datasetEnd
|
||||
for weekEnd.Weekday() != time.Saturday {
|
||||
weekEnd = weekEnd.AddDate(0, 0, -1)
|
||||
}
|
||||
weekStart := weekEnd.AddDate(0, 0, -7)
|
||||
for weekStart := weekEnd.AddDate(0, 0, -7); !weekStart.Before(datasetStartDay); weekStart = weekStart.AddDate(0, 0, -7) {
|
||||
weekEnd = weekStart.AddDate(0, 0, 7)
|
||||
weekStartJY, weekStartJM, weekStartJD := gregorianToJalali(weekStart.Year(), int(weekStart.Month()), weekStart.Day())
|
||||
weekLastDay := weekEnd.AddDate(0, 0, -1)
|
||||
weekEndJY, weekEndJM, weekEndJD := gregorianToJalali(weekLastDay.Year(), int(weekLastDay.Month()), weekLastDay.Day())
|
||||
periods = append(periods, newPeriod(
|
||||
"w-"+weekStart.Format(time.DateOnly),
|
||||
"WEEK",
|
||||
weekStart,
|
||||
weekEnd,
|
||||
fmt.Sprintf(
|
||||
"هفته %s %s %s تا %s %s %s",
|
||||
persianNumber(weekStartJD), jalaliMonthNames[weekStartJM-1], persianNumber(weekStartJY),
|
||||
persianNumber(weekEndJD), jalaliMonthNames[weekEndJM-1], persianNumber(weekEndJY),
|
||||
),
|
||||
false,
|
||||
))
|
||||
}
|
||||
|
||||
jy, jm, _ := gregorianToJalali(localMax.Year(), int(localMax.Month()), localMax.Day())
|
||||
monthStart := jalaliDate(jy, jm, 1, location)
|
||||
nextYear, nextMonth := nextJalaliMonth(jy, jm)
|
||||
latestDay := datasetEnd.AddDate(0, 0, -1)
|
||||
latestJY, latestJM, _ := gregorianToJalali(latestDay.Year(), int(latestDay.Month()), latestDay.Day())
|
||||
monthYear, month := latestJY, latestJM
|
||||
monthStart := jalaliDate(monthYear, month, 1, location)
|
||||
nextYear, nextMonth := nextJalaliMonth(monthYear, month)
|
||||
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)
|
||||
monthYear, month = previousJalaliMonth(monthYear, month)
|
||||
}
|
||||
for isDefault := true; ; isDefault = false {
|
||||
monthStart = jalaliDate(monthYear, month, 1, location)
|
||||
nextYear, nextMonth = nextJalaliMonth(monthYear, month)
|
||||
monthEnd = jalaliDate(nextYear, nextMonth, 1, location)
|
||||
if monthStart.Before(datasetStartDay) {
|
||||
break
|
||||
}
|
||||
periods = append(periods, newPeriod(
|
||||
fmt.Sprintf("jm-%04d-%02d", monthYear, month),
|
||||
"JALALI_MONTH",
|
||||
monthStart,
|
||||
monthEnd,
|
||||
fmt.Sprintf("%s %s", jalaliMonthNames[month-1], persianNumber(monthYear)),
|
||||
isDefault,
|
||||
))
|
||||
monthYear, month = previousJalaliMonth(monthYear, month)
|
||||
}
|
||||
|
||||
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)
|
||||
seasonYear := latestJY
|
||||
seasonStartMonth := ((latestJM - 1) / 3 * 3) + 1
|
||||
seasonStart, seasonEnd := seasonBoundaries(seasonYear, seasonStartMonth, location)
|
||||
if seasonEnd.After(datasetEnd) {
|
||||
seasonStartMonth -= 3
|
||||
if seasonStartMonth < 1 {
|
||||
seasonYear--
|
||||
seasonStartMonth += 12
|
||||
seasonYear, seasonStartMonth = previousPersianSeason(seasonYear, seasonStartMonth)
|
||||
}
|
||||
for {
|
||||
seasonStart, seasonEnd = seasonBoundaries(seasonYear, seasonStartMonth, location)
|
||||
if seasonStart.Before(datasetStartDay) {
|
||||
break
|
||||
}
|
||||
seasonStart = jalaliDate(seasonYear, seasonStartMonth, 1, location)
|
||||
seasonEndYear, seasonEndMonth = seasonYear, seasonStartMonth+3
|
||||
if seasonEndMonth > 12 {
|
||||
seasonEndYear++
|
||||
seasonEndMonth -= 12
|
||||
}
|
||||
seasonEnd = jalaliDate(seasonEndYear, seasonEndMonth, 1, location)
|
||||
periods = append(periods, newPeriod(
|
||||
fmt.Sprintf("ps-%04d-%02d", seasonYear, seasonStartMonth),
|
||||
"PERSIAN_SEASON",
|
||||
seasonStart,
|
||||
seasonEnd,
|
||||
fmt.Sprintf("%s %s", persianSeasonNames[(seasonStartMonth-1)/3], persianNumber(seasonYear)),
|
||||
false,
|
||||
))
|
||||
seasonYear, seasonStartMonth = previousPersianSeason(seasonYear, seasonStartMonth)
|
||||
}
|
||||
|
||||
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 seasonBoundaries(year, startMonth int, location *time.Location) (time.Time, time.Time) {
|
||||
endYear, endMonth := year, startMonth+3
|
||||
if endMonth > 12 {
|
||||
endYear++
|
||||
endMonth -= 12
|
||||
}
|
||||
return jalaliDate(year, startMonth, 1, location), jalaliDate(endYear, endMonth, 1, location)
|
||||
}
|
||||
|
||||
func previousPersianSeason(year, startMonth int) (int, int) {
|
||||
startMonth -= 3
|
||||
if startMonth < 1 {
|
||||
return year - 1, startMonth + 12
|
||||
}
|
||||
return year, startMonth
|
||||
}
|
||||
|
||||
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(),
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestJalaliGregorianRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestCompletePeriods(t *testing.T) {
|
||||
func TestCompletePeriods(t *testing.T) {
|
||||
t.Parallel()
|
||||
location, err := time.LoadLocation("Asia/Tehran")
|
||||
if err != nil {
|
||||
@@ -40,37 +40,57 @@ func TestLatestCompletePeriods(t *testing.T) {
|
||||
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))
|
||||
periods := completePeriods(datasetStart, datasetMax, location)
|
||||
if len(periods) != 212 {
|
||||
t.Fatalf("completePeriods() returned %d periods, want 212", len(periods))
|
||||
}
|
||||
|
||||
want := []struct {
|
||||
index int
|
||||
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"},
|
||||
{index: 0, id: "d-2026-06-30", kind: "DAY", start: "2026-06-30", end: "2026-07-01"},
|
||||
{index: 180, id: "d-2026-01-01", kind: "DAY", start: "2026-01-01", end: "2026-01-02"},
|
||||
{index: 181, id: "w-2026-06-20", kind: "WEEK", start: "2026-06-20", end: "2026-06-27"},
|
||||
{index: 205, id: "w-2026-01-03", kind: "WEEK", start: "2026-01-03", end: "2026-01-10"},
|
||||
{index: 206, id: "jm-1405-03", kind: "JALALI_MONTH", start: "2026-05-22", end: "2026-06-22", isDefault: true},
|
||||
{index: 210, id: "jm-1404-11", kind: "JALALI_MONTH", start: "2026-01-21", end: "2026-02-20"},
|
||||
{index: 211, id: "ps-1405-01", kind: "PERSIAN_SEASON", start: "2026-03-21", end: "2026-06-22"},
|
||||
}
|
||||
|
||||
for index, expected := range want {
|
||||
period := periods[index]
|
||||
for _, expected := range want {
|
||||
period := periods[expected.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)
|
||||
t.Errorf("period[%d] identity = %s/%s, want %s/%s", expected.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)
|
||||
t.Errorf("period[%d] start = %s, want %s", expected.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)
|
||||
t.Errorf("period[%d] end = %s, want %s", expected.index, got, expected.end)
|
||||
}
|
||||
if period.IsDefault != expected.isDefault {
|
||||
t.Errorf("period[%d] IsDefault = %v, want %v", index, period.IsDefault, expected.isDefault)
|
||||
t.Errorf("period[%d] IsDefault = %v, want %v", expected.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)
|
||||
t.Errorf("period[%d] UTC boundaries do not use UTC", expected.index)
|
||||
}
|
||||
}
|
||||
|
||||
defaultCount := 0
|
||||
for _, period := range periods {
|
||||
if !period.IsComplete {
|
||||
t.Errorf("period %s is not complete", period.PeriodID)
|
||||
}
|
||||
if period.LocalStartAt.Before(datasetStart) || period.LocalEndAt.After(datasetMax.Add(time.Minute)) {
|
||||
t.Errorf("period %s lies outside dataset coverage", period.PeriodID)
|
||||
}
|
||||
if period.IsDefault {
|
||||
defaultCount++
|
||||
}
|
||||
}
|
||||
if defaultCount != 1 {
|
||||
t.Fatalf("completePeriods() returned %d defaults, want 1", defaultCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ func (s *Service) Periods(ctx context.Context) ([]Period, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading tehran timezone: %w", err)
|
||||
}
|
||||
return latestCompletePeriods(start, end, location), nil
|
||||
return completePeriods(start, end, location), nil
|
||||
}
|
||||
|
||||
func (s *Service) Start(ctx context.Context, merchantID int64, period Period, terminalID string) (Run, error) {
|
||||
|
||||
@@ -213,7 +213,7 @@ None.
|
||||
|
||||
The user approved the merchant-dashboard redesign on 2026-08-20. The backend increment is additive and follows this dependency order:
|
||||
|
||||
1. Pure, tested Jalali boundary helpers and four latest complete period descriptors.
|
||||
1. Pure, tested Jalali boundary helpers and a dataset-bounded catalog of all complete supported period descriptors.
|
||||
2. Authenticated merchant-terminal listing and optional terminal scope on analytics runs.
|
||||
3. Bounded overview payment-history points for the selected scope.
|
||||
4. Transaction listing filters and one merchant-owned transaction detail resource.
|
||||
|
||||
@@ -549,7 +549,7 @@ Every task must satisfy its acceptance criteria and the standing Definition of D
|
||||
|
||||
## Approved redesign API increment — 2026-08-20
|
||||
|
||||
- [ ] Task 13: Return latest complete `DAY`, `WEEK`, `JALALI_MONTH`, and `PERSIAN_SEASON` periods with the Jalali month as default.
|
||||
- [ ] Task 13: Return every complete dataset-backed `DAY`, `WEEK`, `JALALI_MONTH`, and `PERSIAN_SEASON` period with the latest complete Jalali month as default.
|
||||
- Acceptance: pure boundary tests cover Nowruz, month rollover, leap Esfand, Iranian Saturday–Friday weeks, and dataset boundaries.
|
||||
- Verify: focused analytics tests and `go test ./...`.
|
||||
- [ ] Task 14: Add authenticated merchant terminal discovery and terminal-scoped analytics runs.
|
||||
|
||||
Reference in New Issue
Block a user