add asset trends in statistics & analysis (#314)

This commit is contained in:
MaysWind
2025-11-09 22:51:46 +08:00
parent d3abb279e3
commit 4c8bb5a0b7
52 changed files with 1917 additions and 266 deletions
+66 -1
View File
@@ -340,7 +340,7 @@ func (a *TransactionsApi) TransactionReconciliationStatementHandler(c *core.WebC
minTransactionTime = utils.GetMinTransactionTimeFromUnixTime(reconciliationStatementRequest.StartTime)
}
transactionsWithAccountBalance, totalInflows, totalOutflows, openingBalance, closingBalance, err := a.transactions.GetAllTransactionsWithAccountBalanceByMaxTime(c, uid, pageCountForAccountStatement, maxTransactionTime, minTransactionTime, reconciliationStatementRequest.AccountId, account.Category)
transactionsWithAccountBalance, totalInflows, totalOutflows, openingBalance, closingBalance, err := a.transactions.GetAllTransactionsInOneAccountWithAccountBalanceByMaxTime(c, uid, pageCountForAccountStatement, maxTransactionTime, minTransactionTime, reconciliationStatementRequest.AccountId, account.Category)
if err != nil {
log.Errorf(c, "[transactions.TransactionReconciliationStatementHandler] failed to get transactions from \"%d\" to \"%d\" for user \"uid:%d\", because %s", reconciliationStatementRequest.StartTime, reconciliationStatementRequest.EndTime, uid, err.Error())
@@ -532,6 +532,71 @@ func (a *TransactionsApi) TransactionStatisticsTrendsHandler(c *core.WebContext)
return statisticTrendsResp, nil
}
// TransactionStatisticsAssetTrendsHandler returns transaction statistics asset trends of current user
func (a *TransactionsApi) TransactionStatisticsAssetTrendsHandler(c *core.WebContext) (any, *errs.Error) {
var statisticAssetTrendsReq models.TransactionStatisticAssetTrendsRequest
err := c.ShouldBindQuery(&statisticAssetTrendsReq)
if err != nil {
log.Warnf(c, "[transactions.TransactionStatisticsAssetTrendsHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
utcOffset, err := c.GetClientTimezoneOffset()
if err != nil {
log.Warnf(c, "[transactions.TransactionStatisticsAssetTrendsHandler] cannot get client timezone offset, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
uid := c.GetCurrentUid()
maxTransactionTime := int64(0)
if statisticAssetTrendsReq.EndTime > 0 {
maxTransactionTime = utils.GetMaxTransactionTimeFromUnixTime(statisticAssetTrendsReq.EndTime)
}
minTransactionTime := int64(0)
if statisticAssetTrendsReq.StartTime > 0 {
minTransactionTime = utils.GetMinTransactionTimeFromUnixTime(statisticAssetTrendsReq.StartTime)
}
accountDailyBalances, err := a.transactions.GetAllAccountsDailyOpeningAndClosingBalance(c, uid, maxTransactionTime, minTransactionTime, utcOffset)
if err != nil {
log.Errorf(c, "[transactions.TransactionStatisticsAssetTrendsHandler] failed to get transactions from \"%d\" to \"%d\" for user \"uid:%d\", because %s", statisticAssetTrendsReq.StartTime, statisticAssetTrendsReq.EndTime, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
statisticAssetTrendsResp := make(models.TransactionStatisticAssetTrendsResponseItemSlice, 0)
for yearMonthDay, dailyAccountBalances := range accountDailyBalances {
dailyStatisticResp := &models.TransactionStatisticAssetTrendsResponseItem{
Year: yearMonthDay / 10000,
Month: (yearMonthDay % 10000) / 100,
Day: yearMonthDay % 100,
Items: make([]*models.TransactionStatisticAssetTrendsResponseDataItem, len(dailyAccountBalances)),
}
for i := 0; i < len(dailyAccountBalances); i++ {
accountBalance := dailyAccountBalances[i]
dailyStatisticResp.Items[i] = &models.TransactionStatisticAssetTrendsResponseDataItem{
AccountId: accountBalance.AccountId,
AccountOpeningBalance: accountBalance.AccountOpeningBalance,
AccountClosingBalance: accountBalance.AccountClosingBalance,
}
}
statisticAssetTrendsResp = append(statisticAssetTrendsResp, dailyStatisticResp)
}
sort.Sort(statisticAssetTrendsResp)
return statisticAssetTrendsResp, nil
}
// TransactionAmountsHandler returns transaction amounts of current user
func (a *TransactionsApi) TransactionAmountsHandler(c *core.WebContext) (any, *errs.Error) {
var transactionAmountsReq models.TransactionAmountsRequest
+47
View File
@@ -275,6 +275,12 @@ type TransactionStatisticTrendsRequest struct {
UseTransactionTimezone bool `form:"use_transaction_timezone"`
}
// TransactionStatisticAssetTrendsRequest represents all parameters of transaction statistic asset trends request
type TransactionStatisticAssetTrendsRequest struct {
StartTime int64 `form:"start_time"`
EndTime int64 `form:"end_time"`
}
// TransactionAmountsRequest represents all parameters of transaction amounts request
type TransactionAmountsRequest struct {
Query string `form:"query"`
@@ -403,6 +409,21 @@ type TransactionStatisticTrendsResponseItem struct {
Items []*TransactionStatisticResponseItem `json:"items"`
}
// TransactionStatisticAssetTrendsResponseItem represents the data within each statistic interval
type TransactionStatisticAssetTrendsResponseItem struct {
Year int32 `json:"year"`
Month int32 `json:"month"`
Day int32 `json:"day"`
Items []*TransactionStatisticAssetTrendsResponseDataItem `json:"items"`
}
// TransactionStatisticAssetTrendsResponseDataItem represents an asset trends data item
type TransactionStatisticAssetTrendsResponseDataItem struct {
AccountId int64 `json:"accountId,string"`
AccountOpeningBalance int64 `json:"accountOpeningBalance"`
AccountClosingBalance int64 `json:"accountClosingBalance"`
}
// TransactionAmountsResponseItem represents an item of transaction amounts
type TransactionAmountsResponseItem struct {
StartTime int64 `json:"startTime"`
@@ -600,6 +621,32 @@ func (s TransactionStatisticTrendsResponseItemSlice) Less(i, j int) bool {
return s[i].Month < s[j].Month
}
// TransactionStatisticAssetTrendsResponseItemSlice represents the slice data structure of TransactionStatisticAssetTrendsResponseItem
type TransactionStatisticAssetTrendsResponseItemSlice []*TransactionStatisticAssetTrendsResponseItem
// Len returns the count of items
func (s TransactionStatisticAssetTrendsResponseItemSlice) Len() int {
return len(s)
}
// Swap swaps two items
func (s TransactionStatisticAssetTrendsResponseItemSlice) 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 TransactionStatisticAssetTrendsResponseItemSlice) Less(i, j int) bool {
if s[i].Year != s[j].Year {
return s[i].Year < s[j].Year
}
if s[i].Month != s[j].Month {
return s[i].Month < s[j].Month
}
return s[i].Day < s[j].Day
}
// TransactionAmountsResponseItemAmountInfoSlice represents the slice data structure of TransactionAmountsResponseItemAmountInfo
type TransactionAmountsResponseItemAmountInfoSlice []*TransactionAmountsResponseItemAmountInfo
+55
View File
@@ -164,6 +164,61 @@ func TestTransactionStatisticTrendsResponseItemSliceLess(t *testing.T) {
assert.Equal(t, int32(9), transactionTrendsSlice[4].Month)
}
func TestTransactionStatisticAssetTrendsResponseItemSliceLess(t *testing.T) {
var transactionTrendsSlice TransactionStatisticAssetTrendsResponseItemSlice
transactionTrendsSlice = append(transactionTrendsSlice, &TransactionStatisticAssetTrendsResponseItem{
Year: 2024,
Month: 9,
Day: 1,
})
transactionTrendsSlice = append(transactionTrendsSlice, &TransactionStatisticAssetTrendsResponseItem{
Year: 2024,
Month: 9,
Day: 2,
})
transactionTrendsSlice = append(transactionTrendsSlice, &TransactionStatisticAssetTrendsResponseItem{
Year: 2024,
Month: 10,
Day: 1,
})
transactionTrendsSlice = append(transactionTrendsSlice, &TransactionStatisticAssetTrendsResponseItem{
Year: 2022,
Month: 10,
Day: 1,
})
transactionTrendsSlice = append(transactionTrendsSlice, &TransactionStatisticAssetTrendsResponseItem{
Year: 2023,
Month: 1,
Day: 1,
})
transactionTrendsSlice = append(transactionTrendsSlice, &TransactionStatisticAssetTrendsResponseItem{
Year: 2024,
Month: 2,
Day: 2,
})
sort.Sort(transactionTrendsSlice)
assert.Equal(t, int32(2022), transactionTrendsSlice[0].Year)
assert.Equal(t, int32(10), transactionTrendsSlice[0].Month)
assert.Equal(t, int32(1), transactionTrendsSlice[0].Day)
assert.Equal(t, int32(2023), transactionTrendsSlice[1].Year)
assert.Equal(t, int32(1), transactionTrendsSlice[1].Month)
assert.Equal(t, int32(1), transactionTrendsSlice[1].Day)
assert.Equal(t, int32(2024), transactionTrendsSlice[2].Year)
assert.Equal(t, int32(2), transactionTrendsSlice[2].Month)
assert.Equal(t, int32(2), transactionTrendsSlice[2].Day)
assert.Equal(t, int32(2024), transactionTrendsSlice[3].Year)
assert.Equal(t, int32(9), transactionTrendsSlice[3].Month)
assert.Equal(t, int32(1), transactionTrendsSlice[3].Day)
assert.Equal(t, int32(2024), transactionTrendsSlice[4].Year)
assert.Equal(t, int32(9), transactionTrendsSlice[4].Month)
assert.Equal(t, int32(2), transactionTrendsSlice[4].Day)
assert.Equal(t, int32(2024), transactionTrendsSlice[5].Year)
assert.Equal(t, int32(10), transactionTrendsSlice[5].Month)
assert.Equal(t, int32(1), transactionTrendsSlice[5].Day)
}
func TestTransactionAmountsResponseItemAmountInfoSliceLess(t *testing.T) {
var amountInfoSlice TransactionAmountsResponseItemAmountInfoSlice
amountInfoSlice = append(amountInfoSlice, &TransactionAmountsResponseItemAmountInfo{
+2
View File
@@ -43,6 +43,8 @@ var ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES = map[string]UserApplicationClo
"statistics.defaultCategoricalChartDataRangeType": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
"statistics.defaultTrendChartType": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
"statistics.defaultTrendChartDataRangeType": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
"statistics.defaultAssetTrendsChartType": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
"statistics.defaultAssetTrendsChartDataRangeType": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
}
// UserApplicationCloudSetting represents user application cloud setting stored in database
+129 -3
View File
@@ -107,8 +107,8 @@ func (s *TransactionService) GetAllSpecifiedTransactions(c core.Context, uid int
return allTransactions, nil
}
// GetAllTransactionsWithAccountBalanceByMaxTime returns account statement within time range
func (s *TransactionService) GetAllTransactionsWithAccountBalanceByMaxTime(c core.Context, uid int64, pageCount int32, maxTransactionTime int64, minTransactionTime int64, accountId int64, accountCategory models.AccountCategory) ([]*models.TransactionWithAccountBalance, int64, int64, int64, int64, error) {
// GetAllTransactionsInOneAccountWithAccountBalanceByMaxTime returns account statement within time range
func (s *TransactionService) GetAllTransactionsInOneAccountWithAccountBalanceByMaxTime(c core.Context, uid int64, pageCount int32, maxTransactionTime int64, minTransactionTime int64, accountId int64, accountCategory models.AccountCategory) ([]*models.TransactionWithAccountBalance, int64, int64, int64, int64, error) {
if maxTransactionTime <= 0 {
maxTransactionTime = utils.GetMaxTransactionTimeFromUnixTime(time.Now().Unix())
}
@@ -158,7 +158,7 @@ func (s *TransactionService) GetAllTransactionsWithAccountBalanceByMaxTime(c cor
} else if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
accumulatedBalance = accumulatedBalance + transaction.Amount
} else {
log.Errorf(c, "[transactions.GetAllTransactionsWithAccountBalanceByMaxTime] trasaction type (%d) is invalid (id:%d)", transaction.TransactionId, transaction.Type)
log.Errorf(c, "[transactions.GetAllTransactionsInOneAccountWithAccountBalanceByMaxTime] trasaction type (%d) is invalid (id:%d)", transaction.TransactionId, transaction.Type)
return nil, 0, 0, 0, 0, errs.ErrTransactionTypeInvalid
}
@@ -197,6 +197,132 @@ func (s *TransactionService) GetAllTransactionsWithAccountBalanceByMaxTime(c cor
return allTransactionsAndAccountBalance, totalInflows, totalOutflows, openingBalance, accumulatedBalance, nil
}
// GetAllAccountsDailyOpeningAndClosingBalance returns daily opening and closing balance of all accounts within time range
func (s *TransactionService) GetAllAccountsDailyOpeningAndClosingBalance(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, utcOffset int16) (map[int32][]*models.TransactionWithAccountBalance, error) {
if maxTransactionTime <= 0 {
maxTransactionTime = utils.GetMaxTransactionTimeFromUnixTime(time.Now().Unix())
}
clientLocation := time.FixedZone("Client Timezone", int(utcOffset)*60)
var allTransactions []*models.Transaction
for maxTransactionTime > 0 {
transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, 0, 0, nil, nil, nil, false, models.TRANSACTION_TAG_FILTER_HAS_ANY, "", "", 1, pageCountForLoadTransactionAmounts, false, false)
if err != nil {
return nil, err
}
allTransactions = append(allTransactions, transactions...)
if len(transactions) < pageCountForLoadTransactionAmounts {
maxTransactionTime = 0
break
}
maxTransactionTime = transactions[len(transactions)-1].TransactionTime - 1
}
accountDailyLastBalances := make(map[string]*models.TransactionWithAccountBalance)
accountDailyBalances := make(map[int32][]*models.TransactionWithAccountBalance)
if len(allTransactions) < 1 {
return accountDailyBalances, nil
}
accumulatedBalances := make(map[int64]int64)
accumulatedBalancesBeforeStartTime := make(map[int64]int64)
for i := len(allTransactions) - 1; i >= 0; i-- {
transaction := allTransactions[i]
accumulatedBalance := accumulatedBalances[transaction.AccountId]
lastAccumulatedBalance := accumulatedBalances[transaction.AccountId]
if transaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE {
accumulatedBalance = accumulatedBalance + transaction.RelatedAccountAmount
} else if transaction.Type == models.TRANSACTION_DB_TYPE_INCOME {
accumulatedBalance = accumulatedBalance + transaction.Amount
} else if transaction.Type == models.TRANSACTION_DB_TYPE_EXPENSE {
accumulatedBalance = accumulatedBalance - transaction.Amount
} else if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
accumulatedBalance = accumulatedBalance - transaction.Amount
} else if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
accumulatedBalance = accumulatedBalance + transaction.Amount
} else {
log.Errorf(c, "[transactions.GetAllTransactionsWithAccountBalanceByMaxTime] trasaction type (%d) is invalid (id:%d)", transaction.TransactionId, transaction.Type)
return nil, errs.ErrTransactionTypeInvalid
}
accumulatedBalances[transaction.AccountId] = accumulatedBalance
if transaction.TransactionTime < minTransactionTime {
accumulatedBalancesBeforeStartTime[transaction.AccountId] = accumulatedBalance
continue
}
yearMonthDay := utils.FormatUnixTimeToNumericYearMonthDay(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime), clientLocation)
groupKey := fmt.Sprintf("%d_%d", yearMonthDay, transaction.AccountId)
dailyAccountBalance, exists := accountDailyLastBalances[groupKey]
if exists {
dailyAccountBalance.AccountClosingBalance = accumulatedBalance
} else {
dailyAccountBalance = &models.TransactionWithAccountBalance{
Transaction: &models.Transaction{
AccountId: transaction.AccountId,
},
AccountOpeningBalance: lastAccumulatedBalance,
AccountClosingBalance: accumulatedBalance,
}
accountDailyLastBalances[groupKey] = dailyAccountBalance
}
}
firstTransactionTime := allTransactions[len(allTransactions)-1].TransactionTime
if minTransactionTime > firstTransactionTime {
firstTransactionTime = minTransactionTime
}
firstYearMonthDay := utils.FormatUnixTimeToNumericYearMonthDay(utils.GetUnixTimeFromTransactionTime(firstTransactionTime), clientLocation)
// fill in the opening balance for accounts that do not have transactions on the first day
for accountId, accumulatedBalance := range accumulatedBalancesBeforeStartTime {
if accumulatedBalance == 0 {
continue
}
groupKey := fmt.Sprintf("%d_%d", firstYearMonthDay, accountId)
if _, exists := accountDailyLastBalances[groupKey]; exists {
continue
}
accountDailyLastBalances[groupKey] = &models.TransactionWithAccountBalance{
Transaction: &models.Transaction{
AccountId: accountId,
},
AccountOpeningBalance: accumulatedBalance,
AccountClosingBalance: accumulatedBalance,
}
}
for groupKey, transactionWithAccountBalance := range accountDailyLastBalances {
groupKeyParts := strings.Split(groupKey, "_")
yearMonthDay, _ := utils.StringToInt32(groupKeyParts[0])
dailyAccountBalances, exists := accountDailyBalances[yearMonthDay]
if !exists {
dailyAccountBalances = make([]*models.TransactionWithAccountBalance, 0)
}
dailyAccountBalances = append(dailyAccountBalances, transactionWithAccountBalance)
accountDailyBalances[yearMonthDay] = dailyAccountBalances
}
return accountDailyBalances, nil
}
// GetTransactionsByMaxTime returns transactions before given time
func (s *TransactionService) GetTransactionsByMaxTime(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagIds []int64, noTags bool, tagFilterType models.TransactionTagFilterType, amountFilter string, keyword string, page int32, count int32, needOneMoreItem bool, noDuplicated bool) ([]*models.Transaction, error) {
if uid <= 0 {
+11
View File
@@ -155,6 +155,17 @@ func FormatUnixTimeToNumericYearMonth(unixTime int64, timezone *time.Location) i
return int32(t.Year())*100 + int32(t.Month())
}
// FormatUnixTimeToNumericYearMonthDay returns numeric year, month and day of specified unix time
func FormatUnixTimeToNumericYearMonthDay(unixTime int64, timezone *time.Location) int32 {
t := parseFromUnixTime(unixTime)
if timezone != nil {
t = t.In(timezone)
}
return int32(t.Year())*10000 + int32(t.Month())*100 + int32(t.Day())
}
// 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)
+14
View File
@@ -133,6 +133,20 @@ func TestFormatUnixTimeToNumericYearMonth(t *testing.T) {
assert.Equal(t, expectedValue, actualValue)
}
func TestFormatUnixTimeToNumericYearMonthDay(t *testing.T) {
unixTime := int64(1617228083)
utcTimezone := time.FixedZone("Test Timezone", 0) // UTC
utc8Timezone := time.FixedZone("Test Timezone", 28800) // UTC+8
expectedValue := int32(20210331)
actualValue := FormatUnixTimeToNumericYearMonthDay(unixTime, utcTimezone)
assert.Equal(t, expectedValue, actualValue)
expectedValue = int32(20210401)
actualValue = FormatUnixTimeToNumericYearMonthDay(unixTime, utc8Timezone)
assert.Equal(t, expectedValue, actualValue)
}
func TestFormatUnixTimeToNumericLocalDateTime(t *testing.T) {
unixTime := int64(1617228083)
utcTimezone := time.FixedZone("Test Timezone", 0) // UTC