From 0884af038d8c36fd26c527367ef2fd19327a7bef Mon Sep 17 00:00:00 2001 From: MaysWind Date: Mon, 20 May 2024 00:01:40 +0800 Subject: [PATCH] add trend analysis api --- cmd/webserver.go | 1 + pkg/api/transactions.go | 58 ++++++++++++++++++++ pkg/models/transaction.go | 69 +++++++++++++++-------- pkg/services/transactions.go | 103 +++++++++++++++++++++++++++++++++++ pkg/utils/datetimes.go | 34 ++++++++++++ pkg/utils/datetimes_test.go | 23 ++++++++ src/lib/services.js | 3 + 7 files changed, 267 insertions(+), 24 deletions(-) diff --git a/cmd/webserver.go b/cmd/webserver.go index eca68661..073b4196 100644 --- a/cmd/webserver.go +++ b/cmd/webserver.go @@ -274,6 +274,7 @@ func startWebServer(c *cli.Context) error { apiV1Route.GET("/transactions/list.json", bindApi(api.Transactions.TransactionListHandler)) apiV1Route.GET("/transactions/list/by_month.json", bindApi(api.Transactions.TransactionMonthListHandler)) apiV1Route.GET("/transactions/statistics.json", bindApi(api.Transactions.TransactionStatisticsHandler)) + apiV1Route.GET("/transactions/statistics/trends.json", bindApi(api.Transactions.TransactionStatisticsTrendsHandler)) apiV1Route.GET("/transactions/amounts.json", bindApi(api.Transactions.TransactionAmountsHandler)) apiV1Route.GET("/transactions/get.json", bindApi(api.Transactions.TransactionGetHandler)) apiV1Route.POST("/transactions/add.json", bindApi(api.Transactions.TransactionCreateHandler)) diff --git a/pkg/api/transactions.go b/pkg/api/transactions.go index f3c3315a..4d68ca53 100644 --- a/pkg/api/transactions.go +++ b/pkg/api/transactions.go @@ -272,6 +272,64 @@ func (a *TransactionsApi) TransactionStatisticsHandler(c *core.Context) (any, *e return statisticResp, nil } +// TransactionStatisticsTrendsHandler returns transaction statistics trends of current user +func (a *TransactionsApi) TransactionStatisticsTrendsHandler(c *core.Context) (any, *errs.Error) { + var statisticTrendsReq models.TransactionStatisticTrendsRequest + err := c.ShouldBindQuery(&statisticTrendsReq) + + if err != nil { + log.WarnfWithRequestId(c, "[transactions.TransactionStatisticsTrendsHandler] parse request failed, because %s", err.Error()) + return nil, errs.NewIncompleteOrIncorrectSubmissionError(err) + } + + utcOffset, err := c.GetClientTimezoneOffset() + + if err != nil { + log.WarnfWithRequestId(c, "[transactions.TransactionStatisticsTrendsHandler] cannot get client timezone offset, because %s", err.Error()) + return nil, errs.ErrClientTimezoneOffsetInvalid + } + + startYear, startMonth, endYear, endMonth, err := statisticTrendsReq.GetNumericYearMonthRange() + + if err != nil { + log.WarnfWithRequestId(c, "[transactions.TransactionStatisticsTrendsHandler] cannot parse year month, because %s", err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + uid := c.GetCurrentUid() + allMonthlyTotalAmounts, err := a.transactions.GetAccountsAndCategoriesMonthlyIncomeAndExpense(c, uid, startYear, startMonth, endYear, endMonth, utcOffset, statisticTrendsReq.UseTransactionTimezone) + + if err != nil { + log.ErrorfWithRequestId(c, "[transactions.TransactionStatisticsTrendsHandler] failed to get accounts and categories total income and expense for user \"uid:%d\", because %s", uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + statisticTrendsResp := make(models.TransactionStatisticTrendsItemSlice, 0, len(allMonthlyTotalAmounts)) + + for yearMonth, monthlyTotalAmounts := range allMonthlyTotalAmounts { + monthlyStatisticResp := &models.TransactionStatisticTrendsItem{ + Year: yearMonth / 100, + Month: yearMonth % 100, + Items: make([]*models.TransactionStatisticResponseItem, len(monthlyTotalAmounts)), + } + + for i := 0; i < len(monthlyTotalAmounts); i++ { + totalAmountItem := monthlyTotalAmounts[i] + monthlyStatisticResp.Items[i] = &models.TransactionStatisticResponseItem{ + CategoryId: totalAmountItem.CategoryId, + AccountId: totalAmountItem.AccountId, + TotalAmount: totalAmountItem.Amount, + } + } + + statisticTrendsResp = append(statisticTrendsResp, monthlyStatisticResp) + } + + sort.Sort(statisticTrendsResp) + + return statisticTrendsResp, nil +} + // TransactionAmountsHandler returns transaction amounts of current user func (a *TransactionsApi) TransactionAmountsHandler(c *core.Context) (any, *errs.Error) { var transactionAmountsReq models.TransactionAmountsRequest diff --git a/pkg/models/transaction.go b/pkg/models/transaction.go index 85a0644b..0f36fdac 100644 --- a/pkg/models/transaction.go +++ b/pkg/models/transaction.go @@ -1,9 +1,7 @@ package models import ( - "fmt" "strings" - "time" "github.com/mayswind/ezbookkeeping/pkg/errs" "github.com/mayswind/ezbookkeeping/pkg/utils" @@ -140,6 +138,12 @@ type TransactionStatisticRequest struct { UseTransactionTimezone bool `form:"use_transaction_timezone"` } +// TransactionStatisticTrendsRequest represents all parameters of transaction statistic trends request +type TransactionStatisticTrendsRequest struct { + YearMonthRangeRequest + UseTransactionTimezone bool `form:"use_transaction_timezone"` +} + // TransactionAmountsRequest represents all parameters of transaction amounts request type TransactionAmountsRequest struct { Query string `form:"query"` @@ -219,7 +223,7 @@ type TransactionInfoPageWrapperResponse2 struct { TotalCount int64 `json:"totalCount"` } -// TransactionStatisticResponse represents an item of transaction amounts +// TransactionStatisticResponse represents transaction statistic response type TransactionStatisticResponse struct { StartTime int64 `json:"startTime"` EndTime int64 `json:"endTime"` @@ -233,6 +237,13 @@ type TransactionStatisticResponseItem struct { TotalAmount int64 `json:"amount"` } +// TransactionStatisticTrendsItem represents the data within each statistic interval +type TransactionStatisticTrendsItem struct { + Year int32 `json:"year"` + Month int32 `json:"month"` + Items []*TransactionStatisticResponseItem `json:"items"` +} + // TransactionAmountsResponseItem represents an item of transaction amounts type TransactionAmountsResponseItem struct { StartTime int64 `json:"startTime"` @@ -372,33 +383,21 @@ func (t *TransactionAmountsRequest) GetTransactionAmountsRequestItems() ([]*Tran return requestItems, nil } -// GetStartTimeAndEndTime returns start unix time and end unix time by request parameter -func (t *YearMonthRangeRequest) GetStartTimeAndEndTime(utcOffset int16) (int64, int64, error) { - startUnixTime := int64(0) - endUnixTime := time.Now().Unix() +// GetNumericYearMonthRange returns numeric start year, start month, end year and end month +func (t *YearMonthRangeRequest) GetNumericYearMonthRange() (int32, int32, int32, int32, error) { + startYear, startMonth, err := utils.ParseNumericYearMonth(t.StartYearMonth) - if t.StartYearMonth != "" { - startTime, err := utils.ParseFromShortDateTime(fmt.Sprintf("%s-1 0:0:0", t.StartYearMonth), utcOffset) - - if err != nil { - return 0, 0, err - } - - startUnixTime = startTime.Unix() + if err != nil { + return 0, 0, 0, 0, err } - if t.EndYearMonth != "" { - endTime, err := utils.ParseFromShortDateTime(fmt.Sprintf("%s-1 0:0:0", t.EndYearMonth), utcOffset) + endYear, endMonth, err := utils.ParseNumericYearMonth(t.EndYearMonth) - if err != nil { - return 0, 0, err - } - - endTime = endTime.AddDate(0, 1, 0) - endUnixTime = endTime.Unix() - 1 + if err != nil { + return 0, 0, 0, 0, err } - return startUnixTime, endUnixTime, nil + return startYear, startMonth, endYear, endMonth, nil } // TransactionInfoResponseSlice represents the slice data structure of TransactionInfoResponse @@ -423,6 +422,28 @@ func (s TransactionInfoResponseSlice) Less(i, j int) bool { return s[i].Id > s[j].Id } +// TransactionStatisticTrendsItemSlice represents the slice data structure of TransactionStatisticTrendsItem +type TransactionStatisticTrendsItemSlice []*TransactionStatisticTrendsItem + +// Len returns the count of items +func (s TransactionStatisticTrendsItemSlice) Len() int { + return len(s) +} + +// Swap swaps two items +func (s TransactionStatisticTrendsItemSlice) Swap(i, j int) { + s[i], s[j] = s[j], s[i] +} + +// Less reports whether the first item is less than the second one +func (s TransactionStatisticTrendsItemSlice) Less(i, j int) bool { + if s[i].Year != s[j].Year { + return s[i].Year < s[j].Year + } + + return s[i].Month < s[j].Month +} + // TransactionAmountsResponseItemAmountInfoSlice represents the slice data structure of TransactionAmountsResponseItemAmountInfo type TransactionAmountsResponseItemAmountInfoSlice []*TransactionAmountsResponseItemAmountInfo diff --git a/pkg/services/transactions.go b/pkg/services/transactions.go index 22071f53..db9f14d6 100644 --- a/pkg/services/transactions.go +++ b/pkg/services/transactions.go @@ -1200,6 +1200,109 @@ func (s *TransactionService) GetAccountsAndCategoriesTotalIncomeAndExpense(c *co return transactionTotalAmounts, nil } +// GetAccountsAndCategoriesMonthlyIncomeAndExpense returns the every accounts monthly income and expense amount by specific date range +func (s *TransactionService) GetAccountsAndCategoriesMonthlyIncomeAndExpense(c *core.Context, uid int64, startYear int32, startMonth int32, endYear int32, endMonth int32, utcOffset int16, useTransactionTimezone bool) (map[int32][]*models.Transaction, error) { + if uid <= 0 { + return nil, errs.ErrUserIdInvalid + } + + clientLocation := time.FixedZone("Client Timezone", int(utcOffset)*60) + startTransactionTime, _, err := utils.GetTransactionTimeRangeByYearMonth(startYear, startMonth) + + if err != nil { + return nil, errs.ErrSystemError + } + + _, endTransactionTime, err := utils.GetTransactionTimeRangeByYearMonth(endYear, endMonth) + + if err != nil { + return nil, errs.ErrSystemError + } + + condition := "uid=? AND deleted=? AND (type=? OR type=?) AND transaction_time>=? AND transaction_time<=?" + conditionParams := make([]any, 0, 4) + conditionParams = append(conditionParams, uid) + conditionParams = append(conditionParams, false) + conditionParams = append(conditionParams, models.TRANSACTION_DB_TYPE_INCOME) + conditionParams = append(conditionParams, models.TRANSACTION_DB_TYPE_EXPENSE) + + minTransactionTime := startTransactionTime + maxTransactionTime := endTransactionTime + var allTransactions []*models.Transaction + + for maxTransactionTime > 0 { + var transactions []*models.Transaction + + finalConditionParams := make([]any, 0, 6) + finalConditionParams = append(finalConditionParams, conditionParams...) + finalConditionParams = append(finalConditionParams, minTransactionTime) + finalConditionParams = append(finalConditionParams, maxTransactionTime) + + err := s.UserDataDB(uid).NewSession(c).Select("category_id, account_id, transaction_time, timezone_utc_offset, amount").Where(condition, finalConditionParams...).Limit(pageCountForLoadTransactionAmounts, 0).OrderBy("transaction_time desc").Find(&transactions) + + if err != nil { + return nil, err + } + + allTransactions = append(allTransactions, transactions...) + + if len(transactions) < pageCountForLoadTransactionAmounts { + maxTransactionTime = 0 + break + } + + maxTransactionTime = transactions[len(transactions)-1].TransactionTime - 1 + } + + startYearMonth := startYear*100 + startMonth + endYearMonth := endYear*100 + endMonth + transactionsMonthlyAmountsMap := make(map[string]*models.Transaction) + transactionsMonthlyAmounts := make(map[int32][]*models.Transaction) + + for i := 0; i < len(allTransactions); i++ { + transaction := allTransactions[i] + timeZone := clientLocation + + if useTransactionTimezone { + timeZone = time.FixedZone("Transaction Timezone", int(transaction.TimezoneUtcOffset)*60) + } + + yearMonth := utils.FormatUnixTimeToNumericYearMonth(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime), timeZone) + + if yearMonth < startYearMonth || yearMonth > endYearMonth { + continue + } + + groupKey := fmt.Sprintf("%d_%d_%d", yearMonth, transaction.CategoryId, transaction.AccountId) + transactionAmounts, exists := transactionsMonthlyAmountsMap[groupKey] + + if !exists { + transactionAmounts = &models.Transaction{ + CategoryId: transaction.CategoryId, + AccountId: transaction.AccountId, + } + transactionsMonthlyAmountsMap[groupKey] = transactionAmounts + } + + transactionAmounts.Amount += transaction.Amount + } + + for groupKey, transaction := range transactionsMonthlyAmountsMap { + groupKeyParts := strings.Split(groupKey, "_") + yearMonth, _ := utils.StringToInt32(groupKeyParts[0]) + monthlyAmounts, exists := transactionsMonthlyAmounts[yearMonth] + + if !exists { + monthlyAmounts = make([]*models.Transaction, 0, 0) + } + + monthlyAmounts = append(monthlyAmounts, transaction) + transactionsMonthlyAmounts[yearMonth] = monthlyAmounts + } + + return transactionsMonthlyAmounts, nil +} + // GetTransactionMapByList returns a transaction map by a list func (s *TransactionService) GetTransactionMapByList(transactions []*models.Transaction) map[int64]*models.Transaction { transactionMap := make(map[int64]*models.Transaction) diff --git a/pkg/utils/datetimes.go b/pkg/utils/datetimes.go index 40022d01..e9448313 100644 --- a/pkg/utils/datetimes.go +++ b/pkg/utils/datetimes.go @@ -17,6 +17,29 @@ const ( easternmostTimezoneUtcOffset = 840 // Pacific/Kiritimati (UTC+14:00) ) +// ParseNumericYearMonth returns numeric year and month from textual content +func ParseNumericYearMonth(yearMonth string) (int32, int32, error) { + yearMonthParts := strings.Split(yearMonth, "-") + + if len(yearMonthParts) != 2 { + return 0, 0, errs.ErrParameterInvalid + } + + year, err := StringToInt32(yearMonthParts[0]) + + if err != nil { + return 0, 0, err + } + + month, err := StringToInt32(yearMonthParts[1]) + + if err != nil { + return 0, 0, err + } + + return year, month, nil +} + // FormatUnixTimeToLongDateTimeInServerTimezone returns a textual representation of the unix time formatted by long date time format func FormatUnixTimeToLongDateTimeInServerTimezone(unixTime int64) string { return parseFromUnixTime(unixTime).Format(longDateTimeFormat) @@ -44,6 +67,17 @@ func FormatUnixTimeToYearMonth(unixTime int64, timezone *time.Location) string { return t.Format(yearMonthDateTimeFormat) } +// FormatUnixTimeToNumericYearMonth returns numeric year and month of specified unix time +func FormatUnixTimeToNumericYearMonth(unixTime int64, timezone *time.Location) int32 { + t := parseFromUnixTime(unixTime) + + if timezone != nil { + t = t.In(timezone) + } + + return int32(t.Year())*100 + int32(t.Month()) +} + // FormatUnixTimeToNumericLocalDateTime returns numeric year, month, day, hour, minute and second of specified unix time func FormatUnixTimeToNumericLocalDateTime(unixTime int64, timezone *time.Location) int64 { t := parseFromUnixTime(unixTime) diff --git a/pkg/utils/datetimes_test.go b/pkg/utils/datetimes_test.go index 37ac4557..ce5db4f2 100644 --- a/pkg/utils/datetimes_test.go +++ b/pkg/utils/datetimes_test.go @@ -7,6 +7,15 @@ import ( "github.com/stretchr/testify/assert" ) +func TestParseNumericYearMonth(t *testing.T) { + expectedYear := int32(2024) + expectedMonth := int32(3) + actualYear, actualMonth, err := ParseNumericYearMonth("2024-03") + assert.Equal(t, nil, err) + assert.Equal(t, expectedYear, actualYear) + assert.Equal(t, expectedMonth, actualMonth) +} + func TestFormatUnixTimeToLongDateTimeWithoutSecond(t *testing.T) { unixTime := int64(1617228083) utcTimezone := time.FixedZone("Test Timezone", 0) // UTC @@ -35,6 +44,20 @@ func TestFormatUnixTimeToYearMonth(t *testing.T) { assert.Equal(t, expectedValue, actualValue) } +func TestFormatUnixTimeToNumericYearMonth(t *testing.T) { + unixTime := int64(1617228083) + utcTimezone := time.FixedZone("Test Timezone", 0) // UTC + utc8Timezone := time.FixedZone("Test Timezone", 28800) // UTC+8 + + expectedValue := int32(202103) + actualValue := FormatUnixTimeToNumericYearMonth(unixTime, utcTimezone) + assert.Equal(t, expectedValue, actualValue) + + expectedValue = int32(202104) + actualValue = FormatUnixTimeToNumericYearMonth(unixTime, utc8Timezone) + assert.Equal(t, expectedValue, actualValue) +} + func TestFormatUnixTimeToNumericLocalDateTime(t *testing.T) { unixTime := int64(1617228083) utcTimezone := time.FixedZone("Test Timezone", 0) // UTC diff --git a/src/lib/services.js b/src/lib/services.js index 1276408b..185a5855 100644 --- a/src/lib/services.js +++ b/src/lib/services.js @@ -296,6 +296,9 @@ export default { return axios.get(`v1/transactions/statistics.json?use_transaction_timezone=${useTransactionTimezone}` + (queryParams.length ? '&' + queryParams.join('&') : '')); }, + getTransactionStatisticsTrends: ({ startTime, endTime, useTransactionTimezone, rangeType }) => { + return axios.get(`v1/transactions/statistics/trends.json?start_time=${startTime}&end_time=${endTime}&use_transaction_timezone=${useTransactionTimezone}&range_type=${rangeType}`); + }, getTransactionAmounts: ({ useTransactionTimezone, today, thisWeek, thisMonth, thisYear, lastMonth, monthBeforeLastMonth, monthBeforeLast2Months, monthBeforeLast3Months, monthBeforeLast4Months, monthBeforeLast5Months, monthBeforeLast6Months, monthBeforeLast7Months, monthBeforeLast8Months, monthBeforeLast9Months, monthBeforeLast10Months }) => { const queryParams = [];