diff --git a/public/logo192.png b/public/logo192.png index 1918ff2..8aa41eb 100644 Binary files a/public/logo192.png and b/public/logo192.png differ diff --git a/public/logo512.png b/public/logo512.png deleted file mode 100644 index 7e7dc74..0000000 Binary files a/public/logo512.png and /dev/null differ diff --git a/script/config.go b/script/config.go index 8d70c92..9d62219 100644 --- a/script/config.go +++ b/script/config.go @@ -579,6 +579,11 @@ func GetAccountPrefix(account string) string { return nodes[0] } +func GetAccountName(account string) string { + nodes := strings.Split(account, ":") + return nodes[len(nodes)-1] +} + func GetAccountIconName(account string) string { nodes := strings.Split(account, ":") return strings.Join(nodes, "_") diff --git a/script/utils.go b/script/utils.go index ff80f54..c1ed18c 100644 --- a/script/utils.go +++ b/script/utils.go @@ -84,30 +84,3 @@ func getMaxDate(str_date1 string, str_date2 string) string { } return max_date } - -// 获取1-2个日期字符串中最小的日期值 -// 如果双参数均为空,则返回账簿开始记账日期 -//func getMinDate(str_date1 string, str_date2 string) string { -// //time_layout := "2006-01-02 15:04:05" -// var min_date string -// if str_date1 != "" && str_date2 == "" { -// // 只定义了第一个账户,取第一个账户的日期为准 -// min_date = str_date1 -// } else if str_date1 == "" && str_date2 != "" { -// // 只定义了第二个账户,取第二个账户的日期为准 -// min_date = str_date2 -// } else if str_date1 != "" && str_date2 != "" { -// // 重复定义的账户,取最早的时间 -// t1 := getTimeStamp(str_date1) -// t2 := getTimeStamp(str_date2) -// if t1 < t2 { -// min_date = str_date1 -// } else { -// min_date = str_date2 -// } -// } else if str_date1 == "" && str_date2 == "" { -// // 没有定义账户,取固定日期"1970-01-01" -// min_date = "1970-01-01" -// } -// return min_date -//} diff --git a/server.go b/server.go index 3a188f0..f68bcc8 100644 --- a/server.go +++ b/server.go @@ -75,6 +75,7 @@ func RegisterRouter(router *gin.Engine) { authorized.GET("/stats/account/percent", service.StatsAccountPercent) authorized.GET("/stats/account/trend", service.StatsAccountTrend) authorized.GET("/stats/account/balance", service.StatsAccountBalance) + authorized.GET("/stats/account/flow", service.StatsAccountSankey) authorized.GET("/stats/month/total", service.StatsMonthTotal) authorized.GET("/stats/month/calendar", service.StatsMonthCalendar) authorized.GET("/stats/commodity/price", service.StatsCommodityPrice) diff --git a/service/stats.go b/service/stats.go index 399ec57..b2e798e 100644 --- a/service/stats.go +++ b/service/stats.go @@ -3,7 +3,9 @@ package service import ( "encoding/json" "fmt" + "math" "sort" + "strconv" "strings" "time" @@ -241,6 +243,178 @@ func StatsAccountBalance(c *gin.Context) { OK(c, resultList) } +type AccountSankeyResult struct { + Nodes []AccountSankeyNode `json:"nodes"` + Links []AccountSankeyLink `json:"links"` +} + +type AccountSankeyNode struct { + Name string `json:"name"` +} +type AccountSankeyLink struct { + Source int `json:"source"` + Target int `json:"target"` + Value string `json:"value"` +} + +func NewAccountSankeyLink() *AccountSankeyLink { + return &AccountSankeyLink{ + Source: -1, + Target: -1, + Value: "", + } +} + +func StatsAccountSankey(c *gin.Context) { + ledgerConfig := script.GetLedgerConfigFromContext(c) + queryParams := script.GetQueryParams(c) + // 倒序查询 + queryParams.OrderBy = "date desc" + transactions := make([]Transaction, 0) + err := script.BQLQueryList(ledgerConfig, &queryParams, &transactions) + if err != nil { + InternalError(c, err.Error()) + return + } + + accountSankeyResult := AccountSankeyResult{} + // 构建 nodes 和 links + var nodes []AccountSankeyNode + + // 遍历 transactions 中按id进行分组 + if len(transactions) > 0 { + for _, transaction := range transactions { + // 如果nodes中不存在该节点,则添加 + accountName := script.GetAccountName(transaction.Account) + if !contains(nodes, accountName) { + nodes = append(nodes, AccountSankeyNode{Name: accountName}) + } + } + accountSankeyResult.Nodes = nodes + + transactionsMap := groupTransactionsByID(transactions) + // 声明 links + links := make([]AccountSankeyLink, 0) + // 遍历 transactionsMap + for _, transactions := range transactionsMap { + // 拼接成 links + sourceTransaction := Transaction{} + targetTransaction := Transaction{} + currentLinkNode := NewAccountSankeyLink() + // transactions 的最大长度 + maxCycle := len(transactions) * 2 + + for { + if len(transactions) == 0 || maxCycle == 0 { + break + } + transaction := transactions[0] + transactions = transactions[1:] + + accountName := script.GetAccountName(transaction.Account) + num, err := strconv.ParseFloat(transaction.Number, 64) + if err != nil { + continue + } + if currentLinkNode.Source == -1 && num < 0 { + if sourceTransaction.Account == "" { + sourceTransaction = transaction + } + currentLinkNode.Source = indexOf(nodes, accountName) + if currentLinkNode.Target == -1 { + currentLinkNode.Value = strconv.FormatFloat(num, 'f', 2, 64) + } else { + // 比较 link node value 和 num 大小 + value, _ := strconv.ParseFloat(currentLinkNode.Value, 64) + delta := value + num + if delta == 0 { + currentLinkNode.Value = strconv.FormatFloat(math.Abs(num), 'f', 2, 64) + } else if delta < 0 { // source > target + targetNumber, _ := strconv.ParseFloat(targetTransaction.Number, 64) + currentLinkNode.Value = strconv.FormatFloat(math.Abs(targetNumber), 'f', 2, 64) + sourceTransaction.Number = strconv.FormatFloat(delta, 'f', 2, 64) + transactions = append(transactions, sourceTransaction) + } else { // source < target + targetTransaction.Number = strconv.FormatFloat(delta, 'f', 2, 64) + transactions = append(transactions, targetTransaction) + } + // 完成一个 linkNode 的构建,重置判定条件 + sourceTransaction.Account = "" + targetTransaction.Account = "" + links = append(links, *currentLinkNode) + currentLinkNode = NewAccountSankeyLink() + } + } else if currentLinkNode.Target == -1 && num > 0 { + if targetTransaction.Account == "" { + targetTransaction = transaction + } + currentLinkNode.Target = indexOf(nodes, accountName) + if currentLinkNode.Source == -1 { + currentLinkNode.Value = strconv.FormatFloat(num, 'f', 2, 64) + } else { + value, _ := strconv.ParseFloat(currentLinkNode.Value, 64) + delta := value + num + if delta == 0 { + currentLinkNode.Value = strconv.FormatFloat(math.Abs(num), 'f', 2, 64) + } else if delta < 0 { // source > target + currentLinkNode.Value = strconv.FormatFloat(math.Abs(num), 'f', 2, 64) + sourceTransaction.Number = strconv.FormatFloat(delta, 'f', 2, 64) + transactions = append(transactions, sourceTransaction) + } else { // source < target + sourceNumber, _ := strconv.ParseFloat(sourceTransaction.Number, 64) + currentLinkNode.Value = strconv.FormatFloat(math.Abs(sourceNumber), 'f', 2, 64) + targetTransaction.Number = strconv.FormatFloat(delta, 'f', 2, 64) + transactions = append(transactions, targetTransaction) + } + // 完成一个 linkNode 的构建,重置判定条件 + sourceTransaction.Account = "" + targetTransaction.Account = "" + links = append(links, *currentLinkNode) + currentLinkNode = NewAccountSankeyLink() + } + } else { + // 将当前的 transaction 加入到队列末尾 + transactions = append(transactions, transaction) + } + maxCycle -= 1 + } + } + accountSankeyResult.Links = links + } + + OK(c, accountSankeyResult) +} + +func contains(nodes []AccountSankeyNode, str string) bool { + for _, s := range nodes { + if s.Name == str { + return true + } + } + return false +} + +func indexOf(nodes []AccountSankeyNode, str string) int { + idx := 0 + for _, s := range nodes { + if s.Name == str { + return idx + } + idx += 1 + } + return -1 +} + +func groupTransactionsByID(transactions []Transaction) map[string][]Transaction { + grouped := make(map[string][]Transaction) + + for _, transaction := range transactions { + grouped[transaction.Id] = append(grouped[transaction.Id], transaction) + } + + return grouped +} + type MonthTotalBQLResult struct { Year int Month int diff --git a/service/transactions.go b/service/transactions.go index 82d35b8..90f18bd 100644 --- a/service/transactions.go +++ b/service/transactions.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "strconv" "strings" "time" @@ -34,6 +35,20 @@ type Transaction struct { IsAnotherCurrency bool `json:"isAnotherCurrency,omitempty"` } +type TransactionSort []Transaction + +func (s TransactionSort) Len() int { + return len(s) +} +func (s TransactionSort) Swap(i, j int) { + s[i], s[j] = s[j], s[i] +} +func (s TransactionSort) Less(i, j int) bool { + a, _ := strconv.Atoi(s[i].Number) + b, _ := strconv.Atoi(s[j].Number) + return a <= b +} + func QueryTransactions(c *gin.Context) { ledgerConfig := script.GetLedgerConfigFromContext(c) queryParams := script.GetQueryParams(c)