add trend analysis api
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user