fix(dashboard): 修正销售抽奖趋势自定义日期统计
修复 sales_draw_trend 自定义日期按 UTC 解析导致的统计偏差,并统一使用半开区间处理日维度边界,补充上海时区与单日范围回归测试。
This commit is contained in:
parent
6284966d3c
commit
c7a6e1e017
@ -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)
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user