fix(dashboard): 修正销售抽奖趋势自定义日期统计

修复 sales_draw_trend 自定义日期按 UTC 解析导致的统计偏差,并统一使用半开区间处理日维度边界,补充上海时区与单日范围回归测试。
This commit is contained in:
Zuncle 2026-04-04 00:06:17 +08:00
parent 6284966d3c
commit c7a6e1e017
2 changed files with 128 additions and 32 deletions

View File

@ -761,11 +761,11 @@ func parseCustomDateRange(startS, endS string) (time.Time, time.Time, bool) {
return time.Time{}, time.Time{}, false return time.Time{}, time.Time{}, false
} }
st, err := time.Parse("2006-01-02", startS) st, err := time.ParseInLocation("2006-01-02", startS, time.Local)
if err != nil { if err != nil {
return time.Time{}, time.Time{}, false return time.Time{}, time.Time{}, false
} }
et, err := time.Parse("2006-01-02", endS) et, err := time.ParseInLocation("2006-01-02", endS, time.Local)
if err != nil { if err != nil {
return time.Time{}, time.Time{}, false return time.Time{}, time.Time{}, false
} }
@ -773,9 +773,9 @@ func parseCustomDateRange(startS, endS string) (time.Time, time.Time, bool) {
return time.Time{}, time.Time{}, false return time.Time{}, time.Time{}, false
} }
et = et.Add(24 * time.Hour).Add(-time.Second) et = et.Add(24 * time.Hour)
if et.Sub(st) > 365*24*time.Hour { if et.Sub(st) > 366*24*time.Hour {
et = st.Add(365 * 24 * time.Hour).Add(-time.Second) et = st.Add(366 * 24 * time.Hour)
} }
return st, et, true return st, et, true
} }
@ -907,9 +907,10 @@ func daysBetween(start, end time.Time) []time.Time {
} }
type bucket struct { type bucket struct {
Label string Label string
Start time.Time Start time.Time
End time.Time End time.Time
EndExclusive time.Time
} }
func normalizeGranularity(in string) string { func normalizeGranularity(in string) string {
@ -929,12 +930,17 @@ func normalizeGranularity(in string) string {
func buildBuckets(start, end time.Time, gran string) []bucket { func buildBuckets(start, end time.Time, gran string) []bucket {
if gran == "day" { if gran == "day" {
days := daysBetween(start, end) days := daysBetween(start, end.Add(-time.Second))
out := make([]bucket, 0, len(days)) out := make([]bucket, 0, len(days))
for _, d := range days { for _, d := range days {
ds := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location()) ds := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location())
de := ds.Add(24 * time.Hour).Add(-time.Second) de := ds.Add(24 * time.Hour)
out = append(out, bucket{Label: ds.Format("2006-01-02"), Start: ds, End: de}) out = append(out, bucket{
Label: ds.Format("2006-01-02"),
Start: ds,
End: de.Add(-time.Second),
EndExclusive: de,
})
} }
return out return out
} }
@ -944,15 +950,22 @@ func buildBuckets(start, end time.Time, gran string) []bucket {
s = s.Add(-24 * time.Hour) s = s.Add(-24 * time.Hour)
} }
out := []bucket{} out := []bucket{}
for cur := s; cur.Before(end) || cur.Equal(end); cur = cur.Add(7 * 24 * time.Hour) { for cur := s; cur.Before(end); cur = cur.Add(7 * 24 * time.Hour) {
ds := time.Date(cur.Year(), cur.Month(), cur.Day(), 0, 0, 0, 0, cur.Location()) ds := time.Date(cur.Year(), cur.Month(), cur.Day(), 0, 0, 0, 0, cur.Location())
de := ds.Add(7 * 24 * time.Hour).Add(-time.Second) de := ds.Add(7 * 24 * time.Hour)
endInclusive := de.Add(-time.Second)
label := ds.Format("2006-01-02") label := ds.Format("2006-01-02")
if de.After(end) { if de.After(end) {
de = end de = end
endInclusive = end
} }
out = append(out, bucket{Label: label, Start: ds, End: de}) out = append(out, bucket{
if de.Equal(end) { Label: label,
Start: ds,
End: endInclusive,
EndExclusive: de,
})
if !de.Before(end) {
break break
} }
} }
@ -960,35 +973,60 @@ func buildBuckets(start, end time.Time, gran string) []bucket {
} }
if gran == "hour" { if gran == "hour" {
out := []bucket{} out := []bucket{}
// 步长 1 小时 for cur := start; cur.Before(end); cur = cur.Add(time.Hour) {
for cur := start; !cur.After(end); cur = cur.Add(time.Hour) {
s := time.Date(cur.Year(), cur.Month(), cur.Day(), cur.Hour(), 0, 0, 0, cur.Location()) s := time.Date(cur.Year(), cur.Month(), cur.Day(), cur.Hour(), 0, 0, 0, cur.Location())
de := s.Add(time.Hour).Add(-time.Second) de := s.Add(time.Hour)
out = append(out, bucket{Label: s.Format("01-02 15h"), Start: s, End: de}) endInclusive := de.Add(-time.Second)
if de.After(end) {
de = end
endInclusive = end
}
out = append(out, bucket{
Label: s.Format("01-02 15h"),
Start: s,
End: endInclusive,
EndExclusive: de,
})
} }
return out return out
} }
if gran == "minute" { if gran == "minute" {
out := []bucket{} out := []bucket{}
// 步长 1 分钟 for cur := start; cur.Before(end); cur = cur.Add(time.Minute) {
for cur := start; !cur.After(end); cur = cur.Add(time.Minute) {
s := time.Date(cur.Year(), cur.Month(), cur.Day(), cur.Hour(), cur.Minute(), 0, 0, cur.Location()) s := time.Date(cur.Year(), cur.Month(), cur.Day(), cur.Hour(), cur.Minute(), 0, 0, cur.Location())
de := s.Add(time.Minute).Add(-time.Second) de := s.Add(time.Minute)
out = append(out, bucket{Label: s.Format("01-02 15:04"), Start: s, End: de}) endInclusive := de.Add(-time.Second)
if de.After(end) {
de = end
endInclusive = end
}
out = append(out, bucket{
Label: s.Format("01-02 15:04"),
Start: s,
End: endInclusive,
EndExclusive: de,
})
} }
return out return out
} }
s := time.Date(start.Year(), start.Month(), 1, 0, 0, 0, 0, start.Location()) s := time.Date(start.Year(), start.Month(), 1, 0, 0, 0, 0, start.Location())
out := []bucket{} out := []bucket{}
for cur := s; cur.Before(end) || cur.Equal(end); cur = cur.AddDate(0, 1, 0) { for cur := s; cur.Before(end); cur = cur.AddDate(0, 1, 0) {
ds := time.Date(cur.Year(), cur.Month(), 1, 0, 0, 0, 0, cur.Location()) ds := time.Date(cur.Year(), cur.Month(), 1, 0, 0, 0, 0, cur.Location())
de := ds.AddDate(0, 1, 0).Add(-time.Second) de := ds.AddDate(0, 1, 0)
endInclusive := de.Add(-time.Second)
label := ds.Format("2006-01") label := ds.Format("2006-01")
if de.After(end) { if de.After(end) {
de = end de = end
endInclusive = end
} }
out = append(out, bucket{Label: label, Start: ds, End: de}) out = append(out, bucket{
if de.Equal(end) { Label: label,
Start: ds,
End: endInclusive,
EndExclusive: de,
})
if !de.Before(end) {
break break
} }
} }
@ -1771,7 +1809,7 @@ func (h *handler) DashboardSalesDrawTrend() core.HandlerFunc {
Where("source_type IN ?", []int32{2, 3, 4}). Where("source_type IN ?", []int32{2, 3, 4}).
Where("(ext_order_id = '' OR ext_order_id IS NULL)"). Where("(ext_order_id = '' OR ext_order_id IS NULL)").
Where("paid_at >= ?", b.Start). Where("paid_at >= ?", b.Start).
Where("paid_at <= ?", b.End). Where("paid_at < ?", b.EndExclusive).
Select("COUNT(id) as orders, COALESCE(SUM(actual_amount + discount_amount), 0) as gmv, COALESCE(SUM(actual_amount), 0) as paid_amount"). Select("COUNT(id) as orders, COALESCE(SUM(actual_amount + discount_amount), 0) as gmv, COALESCE(SUM(actual_amount), 0) as paid_amount").
Scan(&orderStats) Scan(&orderStats)

View File

@ -7,6 +7,23 @@ import (
"time" "time"
) )
func useShanghaiLocal(t *testing.T) *time.Location {
t.Helper()
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatalf("load Asia/Shanghai: %v", err)
}
oldLocal := time.Local
time.Local = loc
t.Cleanup(func() {
time.Local = oldLocal
})
return loc
}
func TestPercentChange(t *testing.T) { func TestPercentChange(t *testing.T) {
if s := percentChange(0, 10); s != "+0%" { if s := percentChange(0, 10); s != "+0%" {
t.Fatalf("prev0") t.Fatalf("prev0")
@ -38,13 +55,16 @@ func TestDaysBetween(t *testing.T) {
} }
func TestParseSalesTrendRange_PrefersStartEnd(t *testing.T) { func TestParseSalesTrendRange_PrefersStartEnd(t *testing.T) {
loc := useShanghaiLocal(t)
now := time.Date(2026, 3, 3, 12, 0, 0, 0, time.UTC) now := time.Date(2026, 3, 3, 12, 0, 0, 0, time.UTC)
s, e := parseSalesTrendRange("week", "2026-01-01", "2026-01-10", now, nil) s, e := parseSalesTrendRange("week", "2026-01-01", "2026-01-10", now, nil)
if !s.Equal(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) { expectedStart := time.Date(2026, 1, 1, 0, 0, 0, 0, loc)
expectedEnd := time.Date(2026, 1, 11, 0, 0, 0, 0, loc)
if !s.Equal(expectedStart) {
t.Fatalf("unexpected start: %v", s) t.Fatalf("unexpected start: %v", s)
} }
if !e.Equal(time.Date(2026, 1, 10, 23, 59, 59, 0, time.UTC)) { if !e.Equal(expectedEnd) {
t.Fatalf("unexpected end: %v", e) t.Fatalf("unexpected end: %v", e)
} }
} }
@ -87,11 +107,12 @@ func TestParseSalesTrendRange_AliasAndAllFallback(t *testing.T) {
} }
func TestParseSalesTrendRange_CustomSpanCap(t *testing.T) { func TestParseSalesTrendRange_CustomSpanCap(t *testing.T) {
loc := useShanghaiLocal(t)
now := time.Date(2026, 3, 3, 12, 0, 0, 0, time.UTC) now := time.Date(2026, 3, 3, 12, 0, 0, 0, time.UTC)
s, e := parseSalesTrendRange("custom", "2025-01-01", "2026-12-31", now, nil) s, e := parseSalesTrendRange("custom", "2025-01-01", "2026-12-31", now, nil)
expectedStart := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) expectedStart := time.Date(2025, 1, 1, 0, 0, 0, 0, loc)
expectedEnd := expectedStart.Add(365 * 24 * time.Hour).Add(-time.Second) expectedEnd := expectedStart.Add(366 * 24 * time.Hour)
if !s.Equal(expectedStart) { if !s.Equal(expectedStart) {
t.Fatalf("unexpected custom start: %v", s) t.Fatalf("unexpected custom start: %v", s)
} }
@ -100,6 +121,21 @@ func TestParseSalesTrendRange_CustomSpanCap(t *testing.T) {
} }
} }
func TestParseSalesTrendRange_CustomSingleDayLocal(t *testing.T) {
loc := useShanghaiLocal(t)
now := time.Date(2026, 3, 3, 12, 0, 0, 0, time.UTC)
s, e := parseSalesTrendRange("custom", "2026-03-01", "2026-03-01", now, nil)
expectedStart := time.Date(2026, 3, 1, 0, 0, 0, 0, loc)
expectedEnd := time.Date(2026, 3, 2, 0, 0, 0, 0, loc)
if !s.Equal(expectedStart) {
t.Fatalf("unexpected custom single-day start: %v", s)
}
if !e.Equal(expectedEnd) {
t.Fatalf("unexpected custom single-day end: %v", e)
}
}
func TestShouldUseMonthlyGranularityForAll(t *testing.T) { func TestShouldUseMonthlyGranularityForAll(t *testing.T) {
start := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) start := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
endLong := start.Add(181 * 24 * time.Hour) endLong := start.Add(181 * 24 * time.Hour)
@ -119,6 +155,28 @@ func TestShouldUseMonthlyGranularityForAll(t *testing.T) {
} }
} }
func TestBuildBuckets_DayUsesHalfOpenInterval(t *testing.T) {
loc := useShanghaiLocal(t)
start := time.Date(2026, 3, 1, 0, 0, 0, 0, loc)
endExclusive := time.Date(2026, 3, 2, 0, 0, 0, 0, loc)
buckets := buildBuckets(start, endExclusive, "day")
if len(buckets) != 1 {
t.Fatalf("expected 1 bucket, got %d", len(buckets))
}
b := buckets[0]
if !b.Start.Equal(start) {
t.Fatalf("unexpected bucket start: %v", b.Start)
}
if !b.End.Equal(time.Date(2026, 3, 1, 23, 59, 59, 0, loc)) {
t.Fatalf("unexpected bucket inclusive end: %v", b.End)
}
if !b.EndExclusive.Equal(endExclusive) {
t.Fatalf("unexpected bucket exclusive end: %v", b.EndExclusive)
}
}
func TestTrendPointJSONIncludesPaidAmount(t *testing.T) { func TestTrendPointJSONIncludesPaidAmount(t *testing.T) {
point := trendPoint{ point := trendPoint{
Date: "2026-03-01", Date: "2026-03-01",