add trend analysis api

This commit is contained in:
MaysWind
2024-05-20 00:01:40 +08:00
parent 72619f3dad
commit 0884af038d
7 changed files with 267 additions and 24 deletions
+1
View File
@@ -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))
+58
View File
@@ -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
+45 -24
View File
@@ -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
+103
View File
@@ -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)
+34
View File
@@ -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)
+23
View File
@@ -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
+3
View File
@@ -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 = [];