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

View File

@ -7,6 +7,23 @@ import (
"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) {
if s := percentChange(0, 10); s != "+0%" {
t.Fatalf("prev0")
@ -38,13 +55,16 @@ func TestDaysBetween(t *testing.T) {
}
func TestParseSalesTrendRange_PrefersStartEnd(t *testing.T) {
loc := useShanghaiLocal(t)
now := time.Date(2026, 3, 3, 12, 0, 0, 0, time.UTC)
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)
}
if !e.Equal(time.Date(2026, 1, 10, 23, 59, 59, 0, time.UTC)) {
if !e.Equal(expectedEnd) {
t.Fatalf("unexpected end: %v", e)
}
}
@ -87,11 +107,12 @@ func TestParseSalesTrendRange_AliasAndAllFallback(t *testing.T) {
}
func TestParseSalesTrendRange_CustomSpanCap(t *testing.T) {
loc := useShanghaiLocal(t)
now := time.Date(2026, 3, 3, 12, 0, 0, 0, time.UTC)
s, e := parseSalesTrendRange("custom", "2025-01-01", "2026-12-31", now, nil)
expectedStart := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
expectedEnd := expectedStart.Add(365 * 24 * time.Hour).Add(-time.Second)
expectedStart := time.Date(2025, 1, 1, 0, 0, 0, 0, loc)
expectedEnd := expectedStart.Add(366 * 24 * time.Hour)
if !s.Equal(expectedStart) {
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) {
start := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
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) {
point := trendPoint{
Date: "2026-03-01",