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
|
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)
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user