beancount-gs/service/stats.go

660 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
"github.com/beancount-gs/script"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
)
type YearMonth struct {
Year string `bql:"distinct year(date)" json:"year"`
Month string `bql:"month(date)" json:"month"`
}
func MonthsList(c *gin.Context) {
ledgerConfig := script.GetLedgerConfigFromContext(c)
// 添加排序
queryParams := script.GetQueryParams(c)
queryParams.OrderBy = "year, month desc"
yearMonthList := make([]YearMonth, 0)
err := script.BQLQueryList(ledgerConfig, &queryParams, &yearMonthList)
if err != nil {
InternalError(c, err.Error())
return
}
months := make([]string, 0)
for _, yearMonth := range yearMonthList {
months = append(months, yearMonth.Year+"-"+yearMonth.Month)
}
OK(c, months)
}
type StatsResult struct {
Key string
Value string
}
func StatsTotal(c *gin.Context) {
ledgerConfig := script.GetLedgerConfigFromContext(c)
queryParams := script.GetQueryParams(c)
selectBql := fmt.Sprintf("SELECT '\\', root(account, 1), '\\', sum(convert(value(position), '%s')), '\\'", ledgerConfig.OperatingCurrency)
accountTypeTotalList := make([]StatsResult, 0)
err := script.BQLQueryListByCustomSelect(ledgerConfig, selectBql, &queryParams, &accountTypeTotalList)
if err != nil {
InternalError(c, err.Error())
return
}
result := make(map[string]string)
for _, total := range accountTypeTotalList {
fields := strings.Fields(total.Value)
if len(fields) > 1 {
result[total.Key] = fields[0]
}
}
OK(c, result)
}
type StatsQuery struct {
Prefix string `form:"prefix" binding:"required"`
Year int `form:"year"`
Month int `form:"month"`
Level int `form:"level"`
Type string `form:"type"`
}
type AccountPercentQueryResult struct {
Account string
Position string
}
type AccountPercentResult struct {
Account string `json:"account"`
Amount json.Number `json:"amount"`
OperatingCurrency string `json:"operatingCurrency"`
}
func StatsAccountPercent(c *gin.Context) {
ledgerConfig := script.GetLedgerConfigFromContext(c)
var statsQuery StatsQuery
if err := c.ShouldBindQuery(&statsQuery); err != nil {
BadRequest(c, err.Error())
return
}
queryParams := script.QueryParams{
AccountLike: statsQuery.Prefix,
Year: statsQuery.Year,
Month: statsQuery.Month,
Where: true,
}
var bql string
if statsQuery.Level != 0 {
prefixNodeLen := len(strings.Split(strings.Trim(statsQuery.Prefix, ":"), ":"))
bql = fmt.Sprintf("SELECT '\\', root(account, %d) as subAccount, '\\', sum(convert(value(position), '%s')), '\\'", statsQuery.Level+prefixNodeLen, ledgerConfig.OperatingCurrency)
} else {
bql = fmt.Sprintf("SELECT '\\', account, '\\', sum(convert(value(position), '%s')), '\\'", ledgerConfig.OperatingCurrency)
}
statsQueryResultList := make([]AccountPercentQueryResult, 0)
err := script.BQLQueryListByCustomSelect(ledgerConfig, bql, &queryParams, &statsQueryResultList)
if err != nil {
InternalError(c, err.Error())
return
}
result := make([]AccountPercentResult, 0)
for _, queryRes := range statsQueryResultList {
if queryRes.Position != "" {
fields := strings.Fields(queryRes.Position)
result = append(result, AccountPercentResult{Account: queryRes.Account, Amount: json.Number(fields[0]), OperatingCurrency: fields[1]})
}
}
OK(c, result)
}
type AccountTrendResult struct {
Date string `json:"date"`
Amount json.Number `json:"amount"`
OperatingCurrency string `json:"operatingCurrency"`
}
func StatsAccountTrend(c *gin.Context) {
ledgerConfig := script.GetLedgerConfigFromContext(c)
var statsQuery StatsQuery
if err := c.ShouldBindQuery(&statsQuery); err != nil {
BadRequest(c, err.Error())
return
}
queryParams := script.QueryParams{
AccountLike: statsQuery.Prefix,
Year: statsQuery.Year,
Month: statsQuery.Month,
Where: true,
}
var bql string
switch {
case statsQuery.Type == "day":
bql = fmt.Sprintf("SELECT '\\', date, '\\', sum(convert(value(position), '%s')), '\\'", ledgerConfig.OperatingCurrency)
case statsQuery.Type == "month":
bql = fmt.Sprintf("SELECT '\\', year, '-', month, '\\', sum(convert(value(position), '%s')), '\\'", ledgerConfig.OperatingCurrency)
case statsQuery.Type == "year":
bql = fmt.Sprintf("SELECT '\\', year, '\\', sum(convert(value(position), '%s')), '\\'", ledgerConfig.OperatingCurrency)
case statsQuery.Type == "sum":
bql = fmt.Sprintf("SELECT '\\', date, '\\', convert(balance, '%s'), '\\'", ledgerConfig.OperatingCurrency)
default:
OK(c, new([]string))
return
}
statsResultList := make([]StatsResult, 0)
err := script.BQLQueryListByCustomSelect(ledgerConfig, bql, &queryParams, &statsResultList)
if err != nil {
InternalError(c, err.Error())
return
}
result := make([]AccountTrendResult, 0)
for _, stats := range statsResultList {
commodities := strings.Split(stats.Value, ",")
// 多币种的处理方式:例如 75799.78 USD, 18500.00 IRAUSD, 176 VACHR
// 选择账本默认ledgerConfig.OperatingCurrency币种的值
var selectedCommodity = commodities[0]
for _, commodity := range commodities {
if strings.Contains(commodity, " "+ledgerConfig.OperatingCurrency) {
selectedCommodity = commodity
break
}
}
fields := strings.Fields(selectedCommodity)
amount, _ := decimal.NewFromString(fields[0])
var date = stats.Key
// 月格式化日期
if statsQuery.Type == "month" {
yearMonth := strings.Split(date, "-")
date = fmt.Sprintf("%s-%s", strings.Trim(yearMonth[0], " "), strings.Trim(yearMonth[1], " "))
}
result = append(result, AccountTrendResult{Date: date, Amount: json.Number(amount.Round(2).String()), OperatingCurrency: fields[1]})
}
OK(c, result)
}
type AccountBalanceBQLResult struct {
Year string `bql:"year" json:"year"`
Month string `bql:"month" json:"month"`
Day string `bql:"day" json:"day"`
Balance string `bql:"balance" json:"balance"`
}
type AccountBalanceResult struct {
Date string `json:"date"`
Amount json.Number `json:"amount"`
OperatingCurrency string `json:"operatingCurrency"`
}
func StatsAccountBalance(c *gin.Context) {
ledgerConfig := script.GetLedgerConfigFromContext(c)
var statsQuery StatsQuery
if err := c.ShouldBindQuery(&statsQuery); err != nil {
BadRequest(c, err.Error())
return
}
queryParams := script.QueryParams{
AccountLike: statsQuery.Prefix,
Year: statsQuery.Year,
Month: statsQuery.Month,
Where: true,
}
balResultList := make([]AccountBalanceBQLResult, 0)
bql := fmt.Sprintf("select '\\', year, '\\', month, '\\', day, '\\', last(convert(balance, '%s')), '\\'", ledgerConfig.OperatingCurrency)
err := script.BQLQueryListByCustomSelect(ledgerConfig, bql, &queryParams, &balResultList)
if err != nil {
InternalError(c, err.Error())
return
}
resultList := make([]AccountBalanceResult, 0)
for _, bqlResult := range balResultList {
if bqlResult.Balance != "" {
fields := strings.Fields(bqlResult.Balance)
amount, _ := decimal.NewFromString(fields[0])
resultList = append(resultList, AccountBalanceResult{
Date: bqlResult.Year + "-" + bqlResult.Month + "-" + bqlResult.Day,
Amount: json.Number(amount.Round(2).String()),
OperatingCurrency: fields[1],
})
}
}
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
Value string
}
type MonthTotal struct {
Type string `json:"type"`
Month string `json:"month"`
Amount json.Number `json:"amount"`
OperatingCurrency string `json:"operatingCurrency"`
}
type MonthTotalSort []MonthTotal
func (s MonthTotalSort) Len() int {
return len(s)
}
func (s MonthTotalSort) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
func (s MonthTotalSort) Less(i, j int) bool {
iYearMonth, _ := time.Parse("2006-1", s[i].Month)
jYearMonth, _ := time.Parse("2006-1", s[j].Month)
return iYearMonth.Before(jYearMonth)
}
func StatsMonthTotal(c *gin.Context) {
ledgerConfig := script.GetLedgerConfigFromContext(c)
monthSet := make(map[string]bool)
queryParams := script.QueryParams{
AccountLike: "Income",
Where: true,
OrderBy: "year, month",
}
// 按月查询收入
queryIncomeBql := fmt.Sprintf("select '\\', year, '\\', month, '\\', neg(sum(convert(value(position), '%s'))), '\\'", ledgerConfig.OperatingCurrency)
monthIncomeTotalResultList := make([]MonthTotalBQLResult, 0)
err := script.BQLQueryListByCustomSelect(ledgerConfig, queryIncomeBql, &queryParams, &monthIncomeTotalResultList)
if err != nil {
InternalError(c, err.Error())
return
}
monthIncomeMap := make(map[string]MonthTotalBQLResult)
for _, income := range monthIncomeTotalResultList {
month := fmt.Sprintf("%d-%d", income.Year, income.Month)
monthSet[month] = true
monthIncomeMap[month] = income
}
// 按月查询支出
queryParams.AccountLike = "Expenses"
queryExpensesBql := fmt.Sprintf("select '\\', year, '\\', month, '\\', sum(convert(value(position), '%s')), '\\'", ledgerConfig.OperatingCurrency)
monthExpensesTotalResultList := make([]MonthTotalBQLResult, 0)
err = script.BQLQueryListByCustomSelect(ledgerConfig, queryExpensesBql, &queryParams, &monthExpensesTotalResultList)
if err != nil {
InternalError(c, err.Error())
return
}
monthExpensesMap := make(map[string]MonthTotalBQLResult)
for _, expenses := range monthExpensesTotalResultList {
month := fmt.Sprintf("%d-%d", expenses.Year, expenses.Month)
monthSet[month] = true
monthExpensesMap[month] = expenses
}
monthTotalResult := make([]MonthTotal, 0)
// 合并结果
var monthIncome, monthExpenses MonthTotal
var monthIncomeAmount, monthExpensesAmount decimal.Decimal
for month := range monthSet {
if monthIncomeMap[month].Value != "" {
fields := strings.Fields(monthIncomeMap[month].Value)
amount, _ := decimal.NewFromString(fields[0])
monthIncomeAmount = amount
monthIncome = MonthTotal{Type: "收入", Month: month, Amount: json.Number(amount.Round(2).String()), OperatingCurrency: fields[1]}
} else {
monthIncome = MonthTotal{Type: "收入", Month: month, Amount: "0", OperatingCurrency: ledgerConfig.OperatingCurrency}
}
monthTotalResult = append(monthTotalResult, monthIncome)
if monthExpensesMap[month].Value != "" {
fields := strings.Fields(monthExpensesMap[month].Value)
amount, _ := decimal.NewFromString(fields[0])
monthExpensesAmount = amount
monthExpenses = MonthTotal{Type: "支出", Month: month, Amount: json.Number(amount.Round(2).String()), OperatingCurrency: fields[1]}
} else {
monthExpenses = MonthTotal{Type: "支出", Month: month, Amount: "0", OperatingCurrency: ledgerConfig.OperatingCurrency}
}
monthTotalResult = append(monthTotalResult, monthExpenses)
monthTotalResult = append(monthTotalResult, MonthTotal{Type: "结余", Month: month, Amount: json.Number(monthIncomeAmount.Sub(monthExpensesAmount).Round(2).String()), OperatingCurrency: ledgerConfig.OperatingCurrency})
}
sort.Sort(MonthTotalSort(monthTotalResult))
OK(c, monthTotalResult)
}
type StatsMonthQuery struct {
Year int `form:"year"`
Month int `form:"month"`
}
type StatsCalendarQueryResult struct {
Date string
Account string
Position string
}
type StatsCalendarResult struct {
Date string `json:"date"`
Account string `json:"account"`
Amount json.Number `json:"amount"`
Currency string `json:"currency"`
CurrencySymbol string `json:"currencySymbol"`
}
func StatsMonthCalendar(c *gin.Context) {
ledgerConfig := script.GetLedgerConfigFromContext(c)
var statsMonthQuery StatsMonthQuery
if err := c.ShouldBindQuery(&statsMonthQuery); err != nil {
BadRequest(c, err.Error())
return
}
queryParams := script.QueryParams{
Year: statsMonthQuery.Year,
Month: statsMonthQuery.Month,
Where: true,
}
bql := fmt.Sprintf("SELECT '\\', date, '\\', root(account, 1), '\\', sum(convert(value(position), '%s')), '\\'", ledgerConfig.OperatingCurrency)
statsCalendarQueryResult := make([]StatsCalendarQueryResult, 0)
err := script.BQLQueryListByCustomSelect(ledgerConfig, bql, &queryParams, &statsCalendarQueryResult)
if err != nil {
InternalError(c, err.Error())
return
}
resultList := make([]StatsCalendarResult, 0)
for _, queryRes := range statsCalendarQueryResult {
if queryRes.Position != "" {
fields := strings.Fields(queryRes.Position)
resultList = append(resultList,
StatsCalendarResult{
Date: queryRes.Date,
Account: queryRes.Account,
Amount: json.Number(fields[0]),
Currency: fields[1],
CurrencySymbol: script.GetCommoditySymbol(ledgerConfig.Id, fields[1]),
})
}
}
OK(c, resultList)
}
type StatsPayeeQueryResult struct {
Payee string
Count int32
Position string
}
type StatsPayeeResult struct {
Payee string `json:"payee"`
Currency string `json:"operatingCurrency"`
Value json.Number `json:"value"`
}
type StatsPayeeResultSort []StatsPayeeResult
func (s StatsPayeeResultSort) Len() int {
return len(s)
}
func (s StatsPayeeResultSort) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
func (s StatsPayeeResultSort) Less(i, j int) bool {
a, _ := s[i].Value.Float64()
b, _ := s[j].Value.Float64()
return a <= b
}
func StatsPayee(c *gin.Context) {
ledgerConfig := script.GetLedgerConfigFromContext(c)
var statsQuery StatsQuery
if err := c.ShouldBindQuery(&statsQuery); err != nil {
BadRequest(c, err.Error())
return
}
queryParams := script.QueryParams{
AccountLike: statsQuery.Prefix,
Year: statsQuery.Year,
Month: statsQuery.Month,
Where: true,
Currency: ledgerConfig.OperatingCurrency,
}
bql := fmt.Sprintf("SELECT '\\', payee, '\\', count(payee), '\\', sum(convert(value(position), '%s')), '\\'", ledgerConfig.OperatingCurrency)
statsPayeeQueryResultList := make([]StatsPayeeQueryResult, 0)
err := script.BQLQueryListByCustomSelect(ledgerConfig, bql, &queryParams, &statsPayeeQueryResultList)
if err != nil {
InternalError(c, err.Error())
return
}
result := make([]StatsPayeeResult, 0)
for _, l := range statsPayeeQueryResultList {
// 交易账户名称非空
if l.Payee != "" {
payee := StatsPayeeResult{
Payee: l.Payee,
Currency: ledgerConfig.OperatingCurrency,
}
//查询交易次数
if statsQuery.Type == "cot" {
payee.Value = json.Number(decimal.NewFromInt32(l.Count).String())
} else {
//查询交易金额,要过滤掉空白交易金额的科目,
// 比如 记账购买后又全额退款导致科目交易条目数>0但是累计金额=0
if l.Position != "" {
// 读取交易金额相关信息
fields := strings.Fields(l.Position)
// 交易金额
total, err := decimal.NewFromString(fields[0])
// 错误处理
if err != nil {
panic(err)
}
if statsQuery.Type == "avg" {
// 如果是查询平均交易金额
payee.Value = json.Number(total.Div(decimal.NewFromInt32(l.Count)).Round(2).String())
} else {
// 如果是查询总交易金额
payee.Value = json.Number(fields[0])
}
}
}
result = append(result, payee)
}
}
sort.Sort(StatsPayeeResultSort(result))
OK(c, result)
}
func StatsCommodityPrice(c *gin.Context) {
OK(c, script.BeanReportAllPrices(script.GetLedgerConfigFromContext(c)))
}