fix: expose complete dataset period catalog

This commit is contained in:
2026-08-20 22:28:07 +03:30
parent d6de48fd15
commit 6523e90a03
8 changed files with 140 additions and 72 deletions

View File

@@ -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. - 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. - 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. - 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. - 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. - Asynq `v0.26.0` is pinned and acknowledged as pre-1.0; jobs have bounded concurrency, timeouts, rate limits, and exponential-backoff retries.

View File

@@ -79,7 +79,7 @@ Clients select one allowlisted `periodId` returned by `GET /analytics/periods`;
- `timezone: Asia/Tehran` and `jalaliLabelFa`, ready for display; - `timezone: Asia/Tehran` and `jalaliLabelFa`, ready for display;
- `isComplete` and `isDefault` flags. - `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 ## Analytics-run contract

View File

@@ -136,9 +136,11 @@ paths:
operationId: listAnalyticsPeriods operationId: listAnalyticsPeriods
summary: List approved reporting periods summary: List approved reporting periods
description: | description: |
Returns allowlisted periods with server-calculated UTC and Tehran-local Returns every complete allowlisted day, Iranian week, Jalali month, and
half-open boundaries. Clients start runs with a returned `periodId` and Persian season inside the imported dataset's local-date coverage, with
never submit arbitrary timestamps. 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: parameters:
- name: periodType - name: periodType
in: query in: query
@@ -1090,6 +1092,7 @@ components:
$ref: '#/components/schemas/PeriodId' $ref: '#/components/schemas/PeriodId'
periods: periods:
type: array type: array
description: Dataset-bounded complete periods, grouped by type and newest-first within each group.
maxItems: 1000 maxItems: 1000
items: items:
$ref: '#/components/schemas/ReportingPeriod' $ref: '#/components/schemas/ReportingPeriod'

View File

@@ -119,76 +119,121 @@ func nextJalaliMonth(jy, jm int) (int, int) {
return jy, jm 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) localStart := datasetStart.In(location)
localMax := datasetMax.In(location) localMax := datasetMax.In(location)
datasetStartDay := time.Date(localStart.Year(), localStart.Month(), localStart.Day(), 0, 0, 0, 0, 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 := time.Date(localMax.Year(), localMax.Month(), localMax.Day(), 0, 0, 0, 0, location).AddDate(0, 0, 1)
datasetEnd := dayStart.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 weekEnd := datasetEnd
for weekEnd.Weekday() != time.Saturday { for weekEnd.Weekday() != time.Saturday {
weekEnd = weekEnd.AddDate(0, 0, -1) 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()) latestDay := datasetEnd.AddDate(0, 0, -1)
monthStart := jalaliDate(jy, jm, 1, location) latestJY, latestJM, _ := gregorianToJalali(latestDay.Year(), int(latestDay.Month()), latestDay.Day())
nextYear, nextMonth := nextJalaliMonth(jy, jm) monthYear, month := latestJY, latestJM
monthStart := jalaliDate(monthYear, month, 1, location)
nextYear, nextMonth := nextJalaliMonth(monthYear, month)
monthEnd := jalaliDate(nextYear, nextMonth, 1, location) monthEnd := jalaliDate(nextYear, nextMonth, 1, location)
if monthEnd.After(datasetEnd) { if monthEnd.After(datasetEnd) {
jy, jm = previousJalaliMonth(jy, jm) monthYear, month = previousJalaliMonth(monthYear, month)
monthStart = jalaliDate(jy, jm, 1, location) }
nextYear, nextMonth = nextJalaliMonth(jy, jm) for isDefault := true; ; isDefault = false {
monthStart = jalaliDate(monthYear, month, 1, location)
nextYear, nextMonth = nextJalaliMonth(monthYear, month)
monthEnd = jalaliDate(nextYear, nextMonth, 1, location) 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 := latestJY
seasonYear := jy seasonStartMonth := ((latestJM - 1) / 3 * 3) + 1
seasonStart := jalaliDate(seasonYear, seasonStartMonth, 1, location) seasonStart, seasonEnd := seasonBoundaries(seasonYear, seasonStartMonth, location)
seasonEndYear, seasonEndMonth := seasonYear, seasonStartMonth+3
if seasonEndMonth > 12 {
seasonEndYear++
seasonEndMonth -= 12
}
seasonEnd := jalaliDate(seasonEndYear, seasonEndMonth, 1, location)
if seasonEnd.After(datasetEnd) { if seasonEnd.After(datasetEnd) {
seasonStartMonth -= 3 seasonYear, seasonStartMonth = previousPersianSeason(seasonYear, seasonStartMonth)
if seasonStartMonth < 1 {
seasonYear--
seasonStartMonth += 12
} }
seasonStart = jalaliDate(seasonYear, seasonStartMonth, 1, location) for {
seasonEndYear, seasonEndMonth = seasonYear, seasonStartMonth+3 seasonStart, seasonEnd = seasonBoundaries(seasonYear, seasonStartMonth, location)
if seasonEndMonth > 12 { if seasonStart.Before(datasetStartDay) {
seasonEndYear++ break
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 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 { func newPeriod(id, kind string, localStart, localEnd time.Time, label string, isDefault bool) Period {
return Period{ return Period{
PeriodID: id, PeriodType: kind, StartAt: localStart.UTC(), EndAt: localEnd.UTC(), PeriodID: id, PeriodType: kind, StartAt: localStart.UTC(), EndAt: localEnd.UTC(),

View File

@@ -31,7 +31,7 @@ func TestJalaliGregorianRoundTrip(t *testing.T) {
} }
} }
func TestLatestCompletePeriods(t *testing.T) { func TestCompletePeriods(t *testing.T) {
t.Parallel() t.Parallel()
location, err := time.LoadLocation("Asia/Tehran") location, err := time.LoadLocation("Asia/Tehran")
if err != nil { if err != nil {
@@ -40,37 +40,57 @@ func TestLatestCompletePeriods(t *testing.T) {
datasetStart := time.Date(2026, time.January, 1, 0, 0, 0, 0, location) datasetStart := time.Date(2026, time.January, 1, 0, 0, 0, 0, location)
datasetMax := time.Date(2026, time.June, 30, 23, 59, 0, 0, location) datasetMax := time.Date(2026, time.June, 30, 23, 59, 0, 0, location)
periods := latestCompletePeriods(datasetStart, datasetMax, location) periods := completePeriods(datasetStart, datasetMax, location)
if len(periods) != 4 { if len(periods) != 212 {
t.Fatalf("latestCompletePeriods() returned %d periods, want 4", len(periods)) t.Fatalf("completePeriods() returned %d periods, want 212", len(periods))
} }
want := []struct { want := []struct {
index int
id, kind, start, end string id, kind, start, end string
isDefault bool isDefault bool
}{ }{
{id: "d-2026-06-30", kind: "DAY", start: "2026-06-30", end: "2026-07-01"}, {index: 0, 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"}, {index: 180, id: "d-2026-01-01", kind: "DAY", start: "2026-01-01", end: "2026-01-02"},
{id: "jm-1405-03", kind: "JALALI_MONTH", start: "2026-05-22", end: "2026-06-22", isDefault: true}, {index: 181, id: "w-2026-06-20", kind: "WEEK", start: "2026-06-20", end: "2026-06-27"},
{id: "ps-1405-01", kind: "PERSIAN_SEASON", start: "2026-03-21", end: "2026-06-22"}, {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 { for _, expected := range want {
period := periods[index] period := periods[expected.index]
if period.PeriodID != expected.id || period.PeriodType != expected.kind { 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 { 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 { 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 { 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 { 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)
}
} }

View File

@@ -74,7 +74,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)
} }
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) { func (s *Service) Start(ctx context.Context, merchantID int64, period Period, terminalID string) (Run, error) {

View File

@@ -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: 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. 2. Authenticated merchant-terminal listing and optional terminal scope on analytics runs.
3. Bounded overview payment-history points for the selected scope. 3. Bounded overview payment-history points for the selected scope.
4. Transaction listing filters and one merchant-owned transaction detail resource. 4. Transaction listing filters and one merchant-owned transaction detail resource.

View File

@@ -549,7 +549,7 @@ Every task must satisfy its acceptance criteria and the standing Definition of D
## Approved redesign API increment — 2026-08-20 ## 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. - Acceptance: pure boundary tests cover Nowruz, month rollover, leap Esfand, Iranian Saturday–Friday weeks, and dataset boundaries.
- Verify: focused analytics tests and `go test ./...`. - Verify: focused analytics tests and `go test ./...`.
- [ ] Task 14: Add authenticated merchant terminal discovery and terminal-scoped analytics runs. - [ ] Task 14: Add authenticated merchant terminal discovery and terminal-scoped analytics runs.