beancount-gs/service/stats.go

2006 lines
60 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 提供账本统计相关的服务功能,
// 包括月份列表生成、金额计算等数据处理逻辑。
package service
import (
"encoding/json"
"fmt" // 格式化输出
// 提供基础日志功能
"regexp" // 正则表达式支持
"sort" // 数据排序功能
"strconv" // 字符串与基本类型的转换
"strings" // 字符串处理工具
"time" // 时间处理
"github.com/beancount-gs/script" // 项目自定义工具库
"github.com/gin-gonic/gin" // HTTP Web框架
"github.com/shopspring/decimal" // 高精度十进制数处理
)
// YearMonth 表示年份和月份的组合结构体
// 用于存储从数据库查询的日期字段拆分结果
//
// 字段说明:
// - Year : 年份字符串通过BQL的 `year(date)` 函数提取
// - Month : 月份字符串通过BQL的 `month(date)` 函数提取
//
// 标签说明:
// - bql : 定义数据库查询时的字段映射关系
// - json : 定义JSON序列化时的字段名称
type YearMonth struct {
Year string `bql:"distinct year(date)" json:"year"`
Month string `bql:"month(date)" json:"month"`
}
// MonthsList 返回账本中所有交易记录的月份列表格式YYYY-MM
//
// 该函数处理流程:
// 1. 从数据库查询去重的年份和月份
// 2. 对原始数据进行清洗和格式化
// 3. 返回标准化后的月份数组
//
// 参数:
// - c *gin.Context : Gin 框架的请求上下文,包含:
// - 账本配置(通过中间件注入)
// - 查询参数(分页/过滤等)
//
// 返回值:
// - 通过 Gin 上下文返回 JSON 响应:
// - 成功HTTP 200 格式 {"data": ["2023-01", "2023-02"]}
// - 失败HTTP 500 格式 {"error": "..."}
//
// 数据清洗规则:
// - 自动去除年份/月份字段前后空格
// - 处理年份和月份合并存储的情况(如 "2023 01"
// - 月份数字标准化为两位数("1" → "01"
// - 保留无法转换的原始格式(如 "2023-Q1"
//
// 调试模式:
// - 启用调试模式时打印完整处理流程
// - 使用 script.IsDebugMode() 判断
func MonthsList(c *gin.Context) {
// 获取调试模式参数
debugMode := script.IsDebugMode()
script.LogSystemDebugDetailed("RequestDebug", "=== DEBUG MODE START ===\nRequest URL: %s", c.Request.URL.String())
// 从上下文获取账本配置和查询参数
ledgerConfig := script.GetLedgerConfigFromContext(c)
queryParams := script.GetQueryParams(c)
queryParams.OrderBy = "year desc, month desc" // 固定排序规则
script.LogSystemDebugDetailed("QueryParams", "QueryParams: %+v", queryParams)
// 执行数据库查询
yearMonthList := make([]YearMonth, 0)
err := script.BQLQueryList(ledgerConfig, &queryParams, &yearMonthList)
if err != nil {
script.LogError(ledgerConfig.Mail, fmt.Sprintf("BQL查询失败: %v", err))
InternalError(c, err.Error())
return
}
script.LogSystemDebugDetailed("QueryResult", "Total records found: %d", len(yearMonthList))
// 处理每个年份月份组合
months := make([]string, 0, len(yearMonthList))
for i, yearMonth := range yearMonthList {
// 控制调试日志输出频率
shouldLog := debugMode && (i < 3 || i%100 == 99 || i == len(yearMonthList)-1)
if shouldLog {
switch {
case i < 3:
script.LogDebugDetailed(ledgerConfig.Mail, "First3Items",
"Processing item [%d]: Year='%s', Month='%s'",
i, yearMonth.Year, yearMonth.Month)
case i%100 == 99:
script.LogDebugDetailed(ledgerConfig.Mail, "Every100Items",
"Processing item [%d]: Year='%s', Month='%s'",
i, yearMonth.Year, yearMonth.Month)
case i == len(yearMonthList)-1:
script.LogDebugDetailed(ledgerConfig.Mail, "LastItem",
"Processing item [%d]: Year='%s', Month='%s'",
i, yearMonth.Year, yearMonth.Month)
}
}
// 基础数据清洗
year := strings.TrimSpace(yearMonth.Year)
month := strings.TrimSpace(yearMonth.Month)
/* 特殊场景处理:当月份为空时,尝试从年份字段解析
BQL 可能返回格式:
- 正常情况: Year="2023", Month="1"
- 合并情况: Year="2023 1", Month=""
*/
if month == "" && strings.Contains(year, " ") {
parts := strings.Fields(year)
if len(parts) >= 2 {
year = parts[0]
month = parts[1]
if shouldLog {
fmt.Printf(" Split year field: original='%s', year='%s', month='%s'\n",
yearMonth.Year, year, month)
}
}
}
// 月份数字标准化处理
monthNum, err := strconv.Atoi(month)
if err != nil {
// 首次转换失败后尝试二次清理(去除可能的多余字符)
cleanedMonth := strings.TrimSpace(month)
// 尝试提取数字部分(如果有的话)
if cleanedMonth != month && shouldLog {
fmt.Printf(" Cleaned month: '%s' -> '%s'\n", month, cleanedMonth)
}
monthNum, err = strconv.Atoi(cleanedMonth)
if err != nil {
// 最终仍转换失败则保留原始格式
result := fmt.Sprintf("%s-%s", year, cleanedMonth)
months = append(months, result)
if shouldLog {
fmt.Printf(" Atoi failed for Month '%s': %v\n", cleanedMonth, err)
fmt.Printf(" Result: '%s'\n", result)
}
continue
}
// 如果二次清理成功,使用清理后的值
month = cleanedMonth
}
// 成功转换后格式化为标准字符串
result := fmt.Sprintf("%s-%02d", year, monthNum)
months = append(months, result)
if shouldLog {
fmt.Printf(" Converted Month '%s' to number: %d\n", month, monthNum)
fmt.Printf(" Result: '%s'\n", result)
}
}
// 调试输出最终结果摘要
if debugMode {
fmt.Printf("Final months array length: %d\n", len(months))
if len(months) > 0 {
// 只显示前5个和后5个结果作为示例
fmt.Printf("First 5 results: %v\n", months[:min(5, len(months))])
if len(months) > 10 {
fmt.Printf("Last 5 results: %v\n", months[len(months)-5:])
}
}
fmt.Printf("=== DEBUG MODE END ===\n")
}
// 返回成功响应
OK(c, months)
}
// 辅助函数,获取最小值
func min(a, b int) int {
if a < b {
return a
}
return b
}
// StatsResult 表示统计查询结果的单个条目
//
// 字段说明:
// - Account: 账户类型名称Beancount标准账户类型
// - 示例值: "Income", "Assets", "Expenses" 等
// - Amount: 金额字符串,包含数值和货币单位
// - 示例值: "-5420.36 CNY", "1000.00 USD"
//
// JSON标签:
// - 保证字段在序列化时使用小写字母
type StatsResult struct {
Account string `json:"account"` // 账户类型,如 "Income", "Assets" 等
Amount string `json:"amount"` // 金额,如 "-5420.36 CNY" 等
}
type StatsTotalQuery struct {
Year int `form:"year"`
Month int `form:"month"`
// 可以根据需要添加其他参数
Account string `form:"account"`
Tag string `form:"tag"`
}
// StatsTotalQueryResult 用于解析StatsTotal查询的中间结果
type StatsTotalQueryResult struct {
Account string `json:"account"`
Amount string `json:"amount"` // 格式如: "-5420.36 CNY"
}
// StatsTotal 计算并返回账本中各主要账户类型的总额统计
//
// 功能说明:
// 1. 查询所有一级账户(Income/Assets/Expenses等)的汇总金额
// 2. 自动转换金额到账本运营货币(OperatingCurrency)
// 3. 返回标准化格式的统计结果
//
// 参数:
// - c *gin.Context: Gin请求上下文包含:
// - 账本配置(通过中间件注入)
// - 查询参数(分页/过滤等)
//
// 返回值:
// - 通过Gin上下文返回JSON响应:
// - 成功: HTTP 200 {"Assets":"10000.00","Income":"5000.00",...}
// - 失败: HTTP 500 {"error":"..."}
//
// 数据处理流程:
// 1. 构建BQL查询语句获取原始数据
// 2. 过滤非标准账户类型(root account)
// 3. 解析金额字符串(处理"-100.00 CNY"格式)
// 4. 验证货币单位一致性
// 5. 四舍五入保留2位小数
//
// 调试模式:
// - 通过script.IsDebugMode()激活
// - 记录完整处理流程到日志
// - 包含: 查询参数、SQL语句、中间结果等
func StatsTotal(c *gin.Context) {
// 获取账本配置
ledgerConfig := script.GetLedgerConfigFromContext(c)
script.LogDebugDetailed(ledgerConfig.Mail, "StatsTotal",
"[INFO]: 获取账本配置: %+v", ledgerConfig)
// 绑定查询参数
var statsTotalQuery StatsTotalQuery
if err := c.ShouldBindQuery(&statsTotalQuery); err != nil {
script.LogDebugDetailed(ledgerConfig.Mail, "StatsTotal", "参数绑定失败: %v", err)
BadRequest(c, err.Error())
return
}
script.LogDebugDetailed(ledgerConfig.Mail, "StatsTotal", "绑定查询参数: %+v", statsTotalQuery)
// 设置查询参数
queryParams := script.QueryParams{
Year: statsTotalQuery.Year,
Month: statsTotalQuery.Month,
Where: true,
// GroupBy 不需要设置,因为分隔符字段会导致 GROUP BY 错误
}
script.LogDebugDetailed(ledgerConfig.Mail, "StatsTotal", "构建查询参数: %+v", queryParams)
// 构建BQL查询语句 - 使用自定义分隔符参考StatsMonthCalendar
selectBql := fmt.Sprintf(
"SELECT '\\\\', root(account, 1), '\\\\', sum(convert(value(position), '%s')) AS amount, '\\\\' ",
ledgerConfig.OperatingCurrency,
)
script.LogDebugDetailed(ledgerConfig.Mail, "StatsTotal", "生成BQL语句:\n%s", selectBql)
// 准备结果列表 - 使用与StatsMonthCalendar类似的结构体
queryResultList := make([]StatsTotalQueryResult, 0)
script.LogDebugDetailed(ledgerConfig.Mail, "StatsTotal",
"准备执行查询, 结果指针类型: %T", &queryResultList)
// 执行BQL查询
err := script.BQLQueryListByCustomSelect(ledgerConfig, selectBql, &queryParams, &queryResultList)
if err != nil {
script.LogErrorDetailed(ledgerConfig.Mail, "StatsTotal", "BQL查询失败: %v", err)
InternalError(c, err.Error())
return
}
script.LogDebugDetailed(ledgerConfig.Mail, "StatsTotal",
"查询执行成功, 获取结果数量: %d", len(queryResultList))
// 处理查询结果 - 参考StatsMonthCalendar的处理方式
result := make(map[string]string)
processedCount := 0
skippedCount := 0
for i, queryRes := range queryResultList {
// 调试:打印原始结果结构
script.LogDebugDetailed(ledgerConfig.Mail, "StatsTotal",
"原始结果[%d]: 完整结构=%+v", i, queryRes)
if queryRes.Amount != "" {
// 使用与StatsMonthCalendar相同的解析逻辑
fields := strings.Fields(queryRes.Amount)
if len(fields) >= 2 {
account := strings.TrimSpace(queryRes.Account)
amountStr := fields[0]
currency := fields[1]
// 检查账户类型是否为主要账户类型
validAccounts := []string{"Income", "Assets", "Liabilities", "Expenses", "Equity"}
isValidAccount := false
for _, validAccount := range validAccounts {
if account == validAccount {
isValidAccount = true
break
}
}
if isValidAccount {
// 解析金额
amount, err := decimal.NewFromString(amountStr)
if err != nil {
script.LogDebugDetailed(ledgerConfig.Mail, "StatsTotal",
"结果[%d] 金额解析失败: AmountStr=%s, Error=%v",
i, amountStr, err)
skippedCount++
continue
}
// 验证货币是否匹配
if currency != ledgerConfig.OperatingCurrency {
script.LogDebugDetailed(ledgerConfig.Mail, "StatsTotal",
"结果[%d] 货币不匹配: 预期=%s, 实际=%s",
i, ledgerConfig.OperatingCurrency, currency)
}
result[account] = amount.Round(2).String()
processedCount++
script.LogDebugDetailed(ledgerConfig.Mail, "StatsTotal",
"处理结果[%d]: Account=%s, Amount=%s, Currency=%s",
i, account, amountStr, currency)
} else {
script.LogDebugDetailed(ledgerConfig.Mail, "StatsTotal",
"结果[%d] 跳过非根账户: Account=%s", i, account)
skippedCount++
}
} else {
script.LogWarn(ledgerConfig.Mail, "StatsTotal",
"结果格式异常[%d]: Amount=%s", i, queryRes.Amount)
skippedCount++
}
}
}
script.LogDebugDetailed(ledgerConfig.Mail, "StatsTotal",
"结果处理完成: 成功处理=%d, 跳过=%d, 最终结果条目=%d",
processedCount, skippedCount, len(result))
// 记录最终结果
for account, amount := range result {
script.LogDebugDetailed(ledgerConfig.Mail, "StatsTotal",
"最终结果 - Account=%s, Amount=%s", account, amount)
}
OK(c, result)
}
// StatsQuery 定义统计查询的请求参数结构
//
// 字段说明:
// - Prefix : 账户前缀过滤条件(如 "Assets"
// - 对应URL参数: ?prefix=Assets
// - Year : 年份过滤0表示不限制
// - 示例: 2023
// - Month : 月份过滤1-120表示不限制
// - Level : 账户层级深度1表示一级账户
// - Type : 特殊查询类型标识
// - 可选值: "income", "expense" 等
//
// 标签说明:
// - form : 定义Gin框架的URL参数绑定名称
type StatsQuery struct {
Prefix string `form:"prefix"`
Year int `form:"year"`
Month int `form:"month"`
Level int `form:"level"`
Type string `form:"type"`
}
// AccountPercentQueryResult 表示账户百分比查询的原始结果
//
// 注意:
// - 该结构体用于接收BQL查询的原始数据
// - 字段无JSON标签仅用于中间处理
//
// 字段说明:
// - Account : 完整账户路径(如 "Assets:Bank"
// - Position : 金额表达式字符串(如 "100.00 CNY"
type AccountPercentQueryResult struct {
Account string
Position string
}
// AccountPercentResult 表示最终返回的账户百分比数据
//
// 字段说明:
// - Account : 账户名称(可能被截断到指定层级)
// - Amount : 十进制精确金额
// - 使用 decimal.Decimal 避免浮点精度问题
// - OperatingCurrency : 金额对应的货币单位
// - 保证与账本配置的运营货币一致
//
// JSON标签:
// - 字段名使用camelCase风格
// - 金额始终序列化为字符串格式
type AccountPercentResult struct {
Account string `json:"account"`
Amount decimal.Decimal `json:"amount"`
OperatingCurrency string `json:"operatingCurrency"`
}
// StatsAccountPercent 计算账户金额占比统计
//
// 功能说明:
// - 根据请求参数过滤账户数据
// - 计算各账户在指定条件下的金额汇总
// - 返回标准化格式的账户金额列表
//
// 请求参数:
// - 通过StatsQuery结构体绑定URL参数:
// - prefix: 账户前缀过滤
// - year/month: 时间范围过滤
// - level: 账户层级控制(1表示只显示一级账户)
//
// 处理流程:
// 1. 绑定查询参数并验证
// 2. 构建BQL查询语句(自动转换到运营货币)
// 3. 执行查询获取原始数据
// 4. 处理账户层级(当level=1时转换账户显示格式)
// 5. 聚合重复账户的金额
//
// 返回值:
// - 成功: HTTP 200 格式示例:
// [{
// "account": "Assets:Bank",
// "amount": "1000.00",
// "operatingCurrency": "CNY"
// }]
// - 失败: 4xx/5xx错误响应
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, // 启用WHERE子句
}
// 构建BQL查询语句(自动货币转换)
bql := fmt.Sprintf("SELECT '\\', account, '\\', sum(convert(value(position), '%s')), '\\'",
ledgerConfig.OperatingCurrency)
// 执行BQL查询
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 != "" {
// 解析金额字符串(如"100.00 CNY")
fields := strings.Fields(queryRes.Position)
// 处理账户显示格式
account := queryRes.Account
if statsQuery.Level == 1 {
// 一级账户转换为"类型:名称"格式
accountType := script.GetAccountType(ledgerConfig.Id, queryRes.Account)
account = accountType.Key + ":" + accountType.Name
}
// 转换金额为decimal类型
amount, err := decimal.NewFromString(fields[0])
if err == nil {
result = append(result, AccountPercentResult{
Account: account,
Amount: amount,
OperatingCurrency: fields[1],
})
}
}
}
// 返回聚合后的结果
OK(c, aggregateAccountPercentList(result))
}
// aggregateAccountPercentList 聚合重复账户的金额
//
// 功能说明:
// - 合并相同账户名的金额记录
// - 使用map实现高效去重和汇总
//
// 参数:
// - result: 原始账户金额列表
//
// 返回值:
// - 去重后的账户列表,金额为同名账户的累加和
//
// 性能说明:
// - 时间复杂度: O(n)
// - 空间复杂度: O(n)
func aggregateAccountPercentList(result []AccountPercentResult) []AccountPercentResult {
// 使用map实现账户聚合
nodeMap := make(map[string]AccountPercentResult)
for _, account := range result {
acc := account.Account
if exist, found := nodeMap[acc]; found {
// 账户已存在时累加金额
exist.Amount = exist.Amount.Add(account.Amount)
nodeMap[acc] = exist
} else {
// 新账户直接存入map
nodeMap[acc] = account
}
}
// 将map转换为切片
aggregateResult := make([]AccountPercentResult, 0)
for _, value := range nodeMap {
aggregateResult = append(aggregateResult, value)
}
return aggregateResult
}
// AccountTrendResult 表示账户趋势数据的单个记录
//
// 字段说明:
// - Date: 日期字符串,格式根据查询类型变化:
// - "day": "2023-01-15"
// - "month": "2023-01"
// - "year": "2023"
// - Amount: 金额数值使用json.Number保证精度
// - OperatingCurrency: 金额对应的货币单位
//
// JSON序列化:
// - 所有字段使用camelCase命名
// - 金额始终序列化为字符串格式
type AccountTrendResult struct {
Date string `json:"date"`
Amount json.Number `json:"amount"`
OperatingCurrency string `json:"operatingCurrency"`
}
// StatsAccountTrend 获取账户金额趋势数据
//
// 功能说明:
// - 支持按日/月/年/累计四种统计维度
// - 自动处理多币种情况,优先匹配运营货币
// - 返回标准化格式的趋势数据
//
// 请求参数:
// - type: 统计类型,必须为以下值之一:
// - "day": 按日统计
// - "month": 按月统计
// - "year": 按年统计
// - "sum": 累计统计
// - 其他参数继承自StatsQuery
//
// 处理流程:
// 1. 参数绑定和验证
// 2. 根据统计类型构建不同的BQL查询
// 3. 执行查询并处理多币种情况
// 4. 格式化日期和金额数据
// 5. 返回标准化结果
//
// 返回值:
// - 成功: HTTP 200 格式示例:
// [{
// "date": "2023-01",
// "amount": "1500.00",
// "operatingCurrency": "CNY"
// }]
// - 失败: 4xx/5xx错误响应
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, // 启用WHERE条件
}
// 根据统计类型构建不同的BQL查询
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
}
// 执行BQL查询
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.Amount, ",")
var selectedCommodity string
/* 多币种选择策略:
1. 优先选择包含运营货币的币种
2. 找不到则使用第一个可用币种
3. 无数据则跳过该记录
*/
if len(commodities) > 1 {
// 多币种情况
for _, commodity := range commodities {
if strings.Contains(strings.TrimSpace(commodity), ledgerConfig.OperatingCurrency) {
selectedCommodity = strings.TrimSpace(commodity)
break
}
}
// 默认选择第一个币种
if selectedCommodity == "" && len(commodities) > 0 {
selectedCommodity = strings.TrimSpace(commodities[0])
}
} else if len(commodities) == 1 {
// 单币种直接使用
selectedCommodity = strings.TrimSpace(commodities[0])
} else {
// 无金额数据跳过
continue
}
// 解析金额和货币
amount, currency, err := parseAmountAndCurrency(selectedCommodity)
if err != nil {
script.LogError(ledgerConfig.Mail,
fmt.Sprintf("无法解析金额: %s, error: %v", selectedCommodity, err))
continue
}
// 特殊处理月份格式
var date = stats.Account
if statsQuery.Type == "month" {
yearMonth := strings.Split(date, "-")
if len(yearMonth) >= 2 {
date = fmt.Sprintf("%s-%s",
strings.TrimSpace(yearMonth[0]),
strings.TrimSpace(yearMonth[1]))
}
}
// 构建最终结果
result = append(result, AccountTrendResult{
Date: date,
Amount: json.Number(amount.Round(2).String()),
OperatingCurrency: currency,
})
}
// 返回处理后的结果
OK(c, result)
}
// parseAmountAndCurrency 解析金额和货币字符串
//
// 功能说明:
// - 从"100.00 CNY"格式的字符串中分离金额和货币单位
// - 采用两级解析策略:
// 1. 优先使用高性能的字符串分割
// 2. 失败时使用更健壮的正则表达式
//
// 参数:
// - amountStr: 待解析的金额字符串,格式为"[数值][空格][货币]"
//
// 返回值:
// - decimal.Decimal: 解析出的十进制金额
// - string: 货币代码(如"CNY")
// - error: 解析错误信息
//
// 示例:
// - 输入 "-100.50 USD" → 返回 (-100.50, "USD", nil)
// - 输入 "invalid" → 返回 (0, "", error)
func parseAmountAndCurrency(amountStr string) (decimal.Decimal, string, error) {
// 先尝试简单的字符串分割(性能更好)
parts := strings.Fields(strings.TrimSpace(amountStr))
if len(parts) >= 2 {
// 检查第一部分是否是数字
if amount, err := decimal.NewFromString(parts[0]); err == nil {
// 第二部分应该是货币
return amount, parts[1], nil
}
}
// 如果简单方法失败,使用正则表达式(更健壮)
re := regexp.MustCompile(`([-\d.]+)\s+(\w+)`)
matches := re.FindStringSubmatch(amountStr)
if len(matches) >= 3 {
amount, err := decimal.NewFromString(matches[1])
if err != nil {
return decimal.Zero, "", err
}
return amount, matches[2], nil
}
return decimal.Zero, "", fmt.Errorf("无法解析金额字符串: %s", amountStr)
}
// AccountBalanceBQLResult 表示账户余额BQL查询的原始结果
//
// 注意:
// - 用于接收数据库原始查询结果
// - bql标签定义数据库字段映射
//
// 字段说明:
// - Year: 交易年份
// - Month: 交易月份(1-12)
// - Day: 交易日期(1-31)
// - Balance: 余额字符串(如"100.00 CNY")
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"`
}
// AccountBalanceResult 表示最终返回的账户余额数据
//
// 字段说明:
// - Date: 日期字符串(格式"YYYY-MM-DD")
// - Amount: 精确到小数点后2位的金额
// - OperatingCurrency: 货币代码(与账本配置一致)
type AccountBalanceResult struct {
Date string `json:"date"`
Amount json.Number `json:"amount"`
OperatingCurrency string `json:"operatingCurrency"`
}
// StatsAccountBalance 获取账户每日余额数据
//
// 功能说明:
// - 查询指定账户的每日最后余额
// - 自动转换到运营货币
// - 返回标准化格式的余额历史
//
// 请求参数:
// - prefix: 账户前缀过滤
// - year/month: 时间范围过滤
//
// 返回值:
// - 成功: HTTP 200 格式示例:
// [{
// "date": "2023-01-01",
// "amount": "1000.00",
// "operatingCurrency": "CNY"
// }]
// - 失败: 4xx/5xx错误响应
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, // 启用WHERE条件
}
// 执行BQL查询(获取每日最后余额)
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: fmt.Sprintf("%s-%s-%s", bqlResult.Year, bqlResult.Month, bqlResult.Day),
Amount: json.Number(amount.Round(2).String()),
OperatingCurrency: fields[1],
})
}
}
OK(c, resultList)
}
// AccountSankeyResult 表示桑基图数据格式
//
// 数据结构说明:
// - 符合标准桑基图数据格式
// - 用于展示账户间资金流动
//
// 字段说明:
// - Nodes: 节点列表(账户)
// - Links: 连接线列表(资金流向)
type AccountSankeyResult struct {
Nodes []AccountSankeyNode `json:"nodes"`
Links []AccountSankeyLink `json:"links"`
}
// AccountSankeyNode 表示桑基图中的单个节点
//
// 字段说明:
// - Name: 账户名称(显示文本)
type AccountSankeyNode struct {
Name string `json:"name"`
}
// AccountSankeyLink 表示桑基图中的资金流向
//
// 字段说明:
// - Source: 源节点索引(对应Nodes数组下标)
// - Target: 目标节点索引
// - Value: 流转金额(使用decimal保证精度)
type AccountSankeyLink struct {
Source int `json:"source"`
Target int `json:"target"`
Value decimal.Decimal `json:"value"`
}
// NewAccountSankeyLink 创建桑基图连接线默认实例
//
// 返回值:
// - 初始化Source/Target为-1(表示未连接状态)
func NewAccountSankeyLink() *AccountSankeyLink {
return &AccountSankeyLink{
Source: -1,
Target: -1,
}
}
// TransactionAccountPositionBQLResult 交易账户位置BQL查询结果
//
// 注意:
// - 用于接收原始BQL查询数据
// - 不直接暴露给API
//
// 字段说明:
// - Id: 交易ID
// - Account: 账户路径
// - Position: 金额字符串(如"100.00 CNY")
type TransactionAccountPositionBQLResult struct {
Id string
Account string
Position string
}
// TransactionAccountPosition 交易账户位置处理结果
//
// 字段说明:
// - Id: 交易ID
// - Account: 完整账户路径
// - AccountName: 显示用账户名(可能被截断)
// - Value: 精确金额
// - OperatingCurrency: 货币单位
type TransactionAccountPosition struct {
Id string
Account string
AccountName string
Value decimal.Decimal
OperatingCurrency string
}
// StatsAccountSankey 生成账户资金流向桑基图数据
//
// 功能说明:
// - 分析指定账户或全部账户的资金流动情况
// - 支持按时间范围和账户层级过滤
// - 返回符合桑基图要求的数据结构
//
// 处理流程:
// 1. 如果指定了账户前缀(Prefix):
// - 先查询该账户涉及的所有交易ID
// - 转换为ID列表查询条件
// 2. 查询满足条件的交易数据:
// - 自动转换金额到运营货币
// - 按level参数处理账户显示名称
// 3. 构建桑基图节点和连接线数据
//
// 请求参数:
// - prefix: 账户前缀过滤(为空则分析全部账户)
// - year/month: 时间范围过滤
// - level: 账户层级(1表示只显示一级账户)
//
// 返回值:
// - 成功: HTTP 200 桑基图数据结构
// {
// "nodes": [{"name":"账户1"},...],
// "links": [{"source":0,"target":1,"value":100},...]
// }
// - 失败: 4xx/5xx错误响应
func StatsAccountSankey(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,
}
statsQueryResultList := make([]TransactionAccountPositionBQLResult, 0)
var bql string
// 账户不为空,则查询时间范围内所有涉及该账户的交易记录
if statsQuery.Prefix != "" {
bql = "SELECT '\\', id, '\\'"
err := script.BQLQueryListByCustomSelect(ledgerConfig, bql, &queryParams, &statsQueryResultList)
if err != nil {
InternalError(c, err.Error())
return
}
// 清空 account 查询条件,改为使用 ID 查询包含该账户所有交易记录
queryParams.AccountLike = ""
queryParams.IDList = "|"
if len(statsQueryResultList) != 0 {
idSet := make(map[string]bool)
for _, bqlResult := range statsQueryResultList {
idSet[bqlResult.Id] = true
}
idList := make([]string, 0, len(idSet))
for id := range idSet {
idList = append(idList, id)
}
queryParams.IDList = strings.Join(idList, "|")
}
}
// 查询全部account的交易数据
bql = fmt.Sprintf("SELECT '\\', id, '\\', account, '\\', sum(convert(value(position), '%s')), '\\'", ledgerConfig.OperatingCurrency)
statsQueryResultList = make([]TransactionAccountPositionBQLResult, 0)
err := script.BQLQueryListByCustomSelect(ledgerConfig, bql, &queryParams, &statsQueryResultList)
if err != nil {
InternalError(c, err.Error())
return
}
result := make([]Transaction, 0)
for _, queryRes := range statsQueryResultList {
if queryRes.Position != "" {
fields := strings.Fields(queryRes.Position)
account := queryRes.Account
if statsQuery.Level == 1 {
accountType := script.GetAccountType(ledgerConfig.Id, account)
account = accountType.Key + ":" + accountType.Name
}
result = append(result, Transaction{
Id: queryRes.Id,
Account: account,
Number: fields[0],
Currency: fields[1],
})
}
}
OK(c, buildSankeyResult(result))
}
// buildSankeyResult 构建桑基图数据结构
//
// 功能说明:
// - 将交易数据转换为桑基图所需的节点和连接线结构
// - 处理资金流动方向(正负值表示流向)
// - 自动处理循环引用和重复连接
//
// 处理流程:
// 1. 收集所有唯一账户作为节点
// 2. 按交易ID分组处理资金流动:
// - 负值金额作为流出(source)
// - 正值金额作为流入(target)
// 3. 特殊处理:
// - 合并相同节点间的多条连接
// - 检测并打破循环引用
// - 过滤自循环节点(source=target)
//
// 参数:
// - transactions: 交易记录列表,需包含:
// - Account: 账户名称
// - Number: 金额字符串(带正负号)
// - Id: 交易ID(用于分组)
//
// 返回值:
// - AccountSankeyResult: 完整的桑基图数据结构
// - Nodes: 账户节点列表
// - Links: 资金流向连接列表
//
// 注意事项:
// - 使用decimal处理金额保证精度
// - 循环引用会添加中间节点打破循环
// - 最大迭代次数限制防止死循环
func buildSankeyResult(transactions []Transaction) AccountSankeyResult {
accountSankeyResult := AccountSankeyResult{}
accountSankeyResult.Nodes = make([]AccountSankeyNode, 0)
accountSankeyResult.Links = make([]AccountSankeyLink, 0)
// 构建 nodes 和 links
var nodes []AccountSankeyNode
// 遍历 transactions 中按id进行分组
if len(transactions) > 0 {
for _, transaction := range transactions {
// 如果nodes中不存在该节点则添加
account := transaction.Account
if !contains(nodes, account) {
nodes = append(nodes, AccountSankeyNode{Name: account})
}
}
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:]
account := transaction.Account
num, err := decimal.NewFromString(transaction.Number)
if err != nil {
continue
}
if currentLinkNode.Source == -1 && num.IsNegative() {
if sourceTransaction.Account == "" {
sourceTransaction = transaction
}
currentLinkNode.Source = indexOf(nodes, account)
if currentLinkNode.Target == -1 {
currentLinkNode.Value = num
} else {
// 比较 link node value 和 num 大小
delta := currentLinkNode.Value.Add(num)
if delta.IsZero() {
currentLinkNode.Value = num.Abs()
} else if delta.IsNegative() { // source > target
targetNumber, _ := decimal.NewFromString(targetTransaction.Number)
currentLinkNode.Value = targetNumber.Abs()
sourceTransaction.Number = delta.String()
transactions = append(transactions, sourceTransaction)
} else { // source < target
targetTransaction.Number = delta.String()
transactions = append(transactions, targetTransaction)
}
// 完成一个 linkNode 的构建,重置判定条件
sourceTransaction.Account = ""
targetTransaction.Account = ""
links = append(links, *currentLinkNode)
currentLinkNode = NewAccountSankeyLink()
}
} else if currentLinkNode.Target == -1 && num.IsPositive() {
if targetTransaction.Account == "" {
targetTransaction = transaction
}
currentLinkNode.Target = indexOf(nodes, account)
if currentLinkNode.Source == -1 {
currentLinkNode.Value = num
} else {
delta := currentLinkNode.Value.Add(num)
if delta.IsZero() {
currentLinkNode.Value = num.Abs()
} else if delta.IsNegative() { // source > target
currentLinkNode.Value = num.Abs()
sourceTransaction.Number = delta.String()
transactions = append(transactions, sourceTransaction)
} else { // source < target
sourceNumber, _ := decimal.NewFromString(sourceTransaction.Number)
currentLinkNode.Value = sourceNumber.Abs()
targetTransaction.Number = delta.String()
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
// 同样source和target的link进行归并
accountSankeyResult.Links = aggregateLinkNodes(accountSankeyResult.Links)
//// source/target相反的link进行合并
//accountSankeyResult.Nodes = nodes
// 处理桑基图的link循环指向的问题
if hasCycle(accountSankeyResult.Links) {
newNodes, newLinks := breakCycleAndAddNode(accountSankeyResult.Nodes, accountSankeyResult.Links)
accountSankeyResult.Nodes = newNodes
accountSankeyResult.Links = newLinks
}
}
// 过滤 source 和 target 相同的节点
return accountSankeyResult
}
// hasCycle 检测桑基图连接中是否存在循环引用
//
// 参数:
// - links: 桑基图连接线列表
//
// 返回值:
// - bool: true表示存在循环引用false表示无循环
//
// 实现说明:
//
// 使用深度优先搜索(DFS)算法检测有向图中的环
func hasCycle(links []AccountSankeyLink) bool {
visited := make(map[int]bool)
recStack := make(map[int]bool)
var dfs func(node int) bool
dfs = func(node int) bool {
if recStack[node] {
return true // 找到循环
}
if visited[node] {
return false // 已访问过,不再检查
}
visited[node] = true
recStack[node] = true
// 检查所有 links看是否有从当前节点指向其他节点
for _, link := range links {
if link.Source == node {
if dfs(link.Target) {
return true
}
}
}
recStack[node] = false // 当前节点的 DFS 结束
return false
}
// 遍历所有节点
for _, link := range links {
if dfs(link.Source) {
return true // 发现循环
}
}
return false // 没有循环
}
// breakCycleAndAddNode 打破桑基图中的循环引用
//
// 处理逻辑:
// 1. 检测到循环时创建新节点
// 2. 将循环连接重定向到新节点
// 3. 新节点命名为原节点名+"1"
//
// 参数:
// - nodes: 原始节点列表
// - links: 原始连接线列表
//
// 返回值:
// - []AccountSankeyNode: 处理后的节点列表(可能包含新节点)
// - []AccountSankeyLink: 处理后的连接线列表
func breakCycleAndAddNode(nodes []AccountSankeyNode, links []AccountSankeyLink) ([]AccountSankeyNode, []AccountSankeyLink) {
visited := make(map[int]bool)
recStack := make(map[int]bool)
newNodeCount := 0 // 计数新节点
var dfs func(node int) bool
newNodes := make(map[int]int) // 记录新节点的映射
dfs = func(node int) bool {
if recStack[node] {
return true // 找到循环
}
if visited[node] {
return false // 已访问过,不再检查
}
visited[node] = true
recStack[node] = true
// 遍历所有 links看是否有从当前节点指向其他节点
for _, link := range links {
if link.Source == node {
if dfs(link.Target) {
// 检测到循环,创建新节点
originalNode := nodes[node]
newNode := AccountSankeyNode{
Name: originalNode.Name + "1", // 新节点名称
}
// 将新节点添加到 nodes 列表中
nodes = append(nodes, newNode)
newNodeIndex := len(nodes) - 1
newNodes[node] = newNodeIndex // 记录原节点到新节点的映射
// 更新当前节点的所有链接,将 target 指向新节点
for i := range links {
if links[i].Source == node {
links[i].Target = newNodeIndex
}
}
newNodeCount++ // 增加新节点计数
}
}
}
recStack[node] = false // 当前节点的 DFS 结束
return false
}
// 遍历所有节点,检测循环
for _, link := range links {
if !visited[link.Source] {
dfs(link.Source) // 如果未访问过,则调用 DFS
}
}
return nodes, links
}
// contains 检查节点列表中是否包含指定名称的节点
//
// 参数:
// - nodes: 节点列表
// - str: 要查找的节点名称
//
// 返回值:
// - bool: true表示包含false表示不包含
func contains(nodes []AccountSankeyNode, str string) bool {
for _, s := range nodes {
if s.Name == str {
return true
}
}
return false
}
// indexOf 查找节点在列表中的索引位置
//
// 参数:
// - nodes: 节点列表
// - str: 要查找的节点名称
//
// 返回值:
// - int: 节点索引(未找到返回-1)
func indexOf(nodes []AccountSankeyNode, str string) int {
idx := 0
for _, s := range nodes {
if s.Name == str {
return idx
}
idx += 1
}
return -1
}
// groupTransactionsByID 按交易ID分组交易记录
//
// 参数:
// - transactions: 交易记录列表
//
// 返回值:
// - map[string][]Transaction: 按ID分组的交易记录映射
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
}
// aggregateLinkNodes 聚合桑基图连接线
//
// 处理逻辑:
// 1. 合并相同方向的连接(值相加)
// 2. 抵消相反方向的连接(值相减)
// 3. 过滤自循环连接(source=target)
//
// 参数:
// - links: 原始连接线列表
//
// 返回值:
// - []AccountSankeyLink: 聚合后的连接线列表
func aggregateLinkNodes(links []AccountSankeyLink) []AccountSankeyLink {
// 创建一个映射来存储连接
nodeMap := make(map[string]decimal.Decimal)
for _, link := range links {
if link.Source == link.Target {
fmt.Printf("%v-%v-%v", link.Source, link.Target, link.Value)
continue
}
key := fmt.Sprintf("%d-%d", link.Source, link.Target)
reverseKey := fmt.Sprintf("%d-%d", link.Target, link.Source)
if existingValue, found := nodeMap[key]; found {
// 如果已存在相同方向,累加 value
nodeMap[key] = existingValue.Add(link.Value)
} else if existingValue, found := nodeMap[reverseKey]; found {
// 如果存在相反方向,确定最终的 source 和 target
totalValue := existingValue.Sub(link.Value)
if totalValue.IsPositive() {
nodeMap[reverseKey] = totalValue
} else if totalValue.IsZero() {
delete(nodeMap, reverseKey)
} else {
delete(nodeMap, reverseKey)
nodeMap[key] = totalValue.Abs()
}
} else {
// 否则直接插入新的 value
nodeMap[key] = link.Value
}
}
// 将结果转换为 slice
result := make([]AccountSankeyLink, 0)
for key, value := range nodeMap {
var parts = strings.Split(key, "-")
source, _ := strconv.Atoi(parts[0])
target, _ := strconv.Atoi(parts[1])
result = append(result, AccountSankeyLink{Source: source, Target: target, Value: value})
}
return result
}
// MonthTotalBQLResult 表示月度统计查询的原始结果结构体
//
// 字段说明:
// - Year : 年份数值(如2023)
// - Month : 月份数值(1-12)
// - Value : 金额字符串(格式:"100.00 CNY")
//
// 注意:
// - 用于接收BQL查询的原始数据
// - 不直接暴露给API接口
type MonthTotalBQLResult struct {
Year int
Month int
Value string
}
// MonthTotal 表示最终返回的月度统计数据
//
// 字段说明:
// - Type : 统计类型("收入"/"支出"/"结余")
// - Month : 年月字符串(格式:"YYYY-MM")
// - Amount : 精确金额(使用json.Number保证精度)
// - OperatingCurrency : 运营货币代码(如"CNY")
//
// JSON标签:
// - 所有字段使用小写命名
// - 金额始终序列化为字符串格式
type MonthTotal struct {
Type string `json:"type"`
Month string `json:"month"`
Amount json.Number `json:"amount"`
OperatingCurrency string `json:"operatingCurrency"`
}
// MonthTotalSort 实现sort.Interface用于MonthTotal切片排序
//
// 功能说明:
// - 按月份升序排列
// - 支持跨年排序(如2022-12, 2023-01)
//
// 实现方法:
// - Len() : 获取切片长度
// - Swap() : 交换元素位置
// - Less() : 比较时间先后
type MonthTotalSort []MonthTotal
// MonthTotalSort 实现 sort.Interface 接口用于 MonthTotal 切片排序
//
// 功能说明:
// - 提供按月份升序排列的能力
// - 支持跨年排序(如 2022-12, 2023-01)
//
// 实现方法:
// - Len(): 返回切片长度
// - Swap(i, j int): 交换两个元素的位置
// - Less(i, j int): 比较两个元素的月份先后
//
// 排序规则:
// - 使用 time.Parse 解析 "2006-1" 格式的月份字符串
// - 比较实际时间先后顺序
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)
}
// StatsMonthTotal 获取月度收支统计数据
//
// 功能说明:
// - 查询指定时间范围内的收入和支出数据
// - 自动计算每月结余
// - 返回按月份排序的完整统计数据
//
// 处理流程:
// 1. 查询收入数据(Income 账户)
// - 使用 neg() 将支出转为正数
// 2. 查询支出数据(Expenses 账户)
// 3. 合并收入和支出数据
// 4. 计算每月结余(收入-支出)
// 5. 按月份排序后返回
//
// 参数:
// - c *gin.Context: Gin 请求上下文
// - 自动获取账本配置
//
// 返回值:
// - HTTP 200: 成功返回排序后的月度统计数据
// - HTTP 500: 查询失败返回错误信息
//
// 数据结构:
// - 每月包含三条记录:
// 1. 收入记录(Type="收入")
// 2. 支出记录(Type="支出")
// 3. 结余记录(Type="结余")
//
// 货币处理:
// - 自动转换到账本运营货币(OperatingCurrency)
// - 金额保留2位小数
func StatsMonthTotal(c *gin.Context) {
// 获取账本配置
ledgerConfig := script.GetLedgerConfigFromContext(c)
// 准备查询参数
monthSet := make(map[string]bool) // 记录存在的月份
queryParams := script.QueryParams{
AccountLike: "Income", // 收入账户
Where: true,
OrderBy: "year, month", // 按年月排序
}
// 1. 查询收入数据
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
}
// 2. 查询支出数据
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
}
// 3. 合并数据并计算结余
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 {
// 无收入数据时填充0值
monthIncome = MonthTotal{
Type: "收入",
Month: month,
Amount: "0",
OperatingCurrency: ledgerConfig.OperatingCurrency,
}
}
// 处理支出数据
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 {
// 无支出数据时填充0值
monthExpenses = MonthTotal{
Type: "支出",
Month: month,
Amount: "0",
OperatingCurrency: ledgerConfig.OperatingCurrency,
}
}
// 添加收入、支出记录
monthTotalResult = append(monthTotalResult, monthIncome, monthExpenses)
// 计算并添加结余记录
monthTotalResult = append(monthTotalResult, MonthTotal{
Type: "结余",
Month: month,
Amount: json.Number(monthIncomeAmount.Sub(monthExpensesAmount).Round(2).String()),
OperatingCurrency: ledgerConfig.OperatingCurrency,
})
}
// 4. 按月份排序
sort.Sort(MonthTotalSort(monthTotalResult))
// 返回结果
OK(c, monthTotalResult)
}
// StatsMonthQuery 定义月度日历查询参数结构
//
// 字段说明:
// - Year: 查询年份(如2023)
// - Month: 查询月份(1-12)
//
// 标签说明:
// - form: 定义Gin框架的URL参数绑定名称
// - 示例: /api/stats/calendar?year=2023&month=7
type StatsMonthQuery struct {
Year int `form:"year"`
Month int `form:"month"`
}
// StatsCalendarQueryResult 表示日历查询的原始结果
//
// 注意:
// - 用于接收BQL查询的原始数据
// - 不直接暴露给API接口
//
// 字段说明:
// - Date: 交易日期(格式"YYYY-MM-DD")
// - Account: 一级账户名称(如"Assets:Bank")
// - Position: 金额字符串(格式"100.00 CNY")
type StatsCalendarQueryResult struct {
Date string
Account string
Position string
}
// StatsCalendarResult 表示最终返回的日历统计数据
//
// 字段说明:
// - Date: 交易日期(格式"YYYY-MM-DD")
// - Account: 一级账户名称(如"Assets:Bank")
// - Amount: 精确金额(使用json.Number保证精度)
// - Currency: 货币代码(如"CNY")
// - CurrencySymbol: 货币符号(如"¥")
//
// JSON标签:
// - 所有字段使用camelCase命名
// - 金额始终序列化为字符串格式
type StatsCalendarResult struct {
Date string `json:"date"`
Account string `json:"account"`
Amount json.Number `json:"amount"`
Currency string `json:"currency"`
CurrencySymbol string `json:"currencySymbol"`
}
// StatsMonthCalendar 获取指定月份的日历统计数据
//
// 功能说明:
// - 查询指定年月的每日交易数据
// - 按一级账户分类汇总金额
// - 返回包含货币符号的格式化数据
//
// 请求参数:
// - year: 查询年份(如2023)
// - month: 查询月份(1-12)
// - 通过StatsMonthQuery结构体绑定URL参数
//
// 处理流程:
// 1. 绑定并验证查询参数
// 2. 构建BQL查询语句:
// - 按日期和一级账户分组
// - 自动转换金额到运营货币
// 3. 执行查询并处理结果:
// - 解析金额和货币信息
// - 添加货币符号
//
// 返回值:
// - 成功: HTTP 200 返回[]StatsCalendarResult, 结构如下:
// [{
// "date": "2023-07-01",
// "account": "Assets:Bank",
// "amount": "1000.00",
// "currency": "CNY",
// "currencySymbol": "¥"
// }]
// - 失败:
// - HTTP 400: 参数绑定错误
// - HTTP 500: 查询执行错误
//
// 数据结构说明:
// - 每条记录包含日期、账户、金额和货币信息
// - 金额使用json.Number保证精度
// - 货币符号通过script.GetCommoditySymbol获取
func StatsMonthCalendar(c *gin.Context) {
// 获取账本配置
ledgerConfig := script.GetLedgerConfigFromContext(c)
script.LogDebugDetailed(ledgerConfig.Mail, "StatsMonthCalendar",
"[INFO]: 获取账本配置: %+v", ledgerConfig)
// 绑定查询参数
var statsMonthQuery StatsMonthQuery
if err := c.ShouldBindQuery(&statsMonthQuery); err != nil {
script.LogDebugDetailed(ledgerConfig.Mail, "StatsMonthCalendar", "参数绑定失败: %v", err)
BadRequest(c, err.Error())
return
}
script.LogDebugDetailed(ledgerConfig.Mail, "StatsMonthCalendar", "绑定查询参数: %+v", statsMonthQuery)
// 设置查询参数
queryParams := script.QueryParams{
Year: statsMonthQuery.Year,
Month: statsMonthQuery.Month,
Where: true,
// GroupBy: "date, root(account, 1)",
}
script.LogDebugDetailed(ledgerConfig.Mail, "StatsMonthCalendar", "构建查询参数: %+v", queryParams)
// 构建BQL查询语句
bql := fmt.Sprintf(
"SELECT '\\', date, '\\', root(account, 1), '\\', sum(convert(value(position), '%s')) AS amount, '\\' ",
ledgerConfig.OperatingCurrency,
)
// bql := fmt.Sprintf(
// "SELECT date, root(account, 1), sum(convert(value(position), '%s')) AS amount ",
// ledgerConfig.OperatingCurrency,
// )
script.LogDebugDetailed(ledgerConfig.Mail, "StatsMonthCalendar", "生成BQL语句:\n%s", bql)
// 执行查询
statsCalendarQueryResult := make([]StatsCalendarQueryResult, 0)
script.LogDebugDetailed(ledgerConfig.Mail, "StatsMonthCalendar",
"准备执行查询, 结果指针类型: %T", &statsCalendarQueryResult)
err := script.BQLQueryListByCustomSelect(ledgerConfig, bql, &queryParams, &statsCalendarQueryResult)
if err != nil {
script.LogError(ledgerConfig.Mail,
fmt.Sprintf("StatsMonthCalendar - 查询执行失败: %v", err))
InternalError(c, err.Error())
return
}
script.LogDebugDetailed(ledgerConfig.Mail, "StatsMonthCalendar",
"查询执行成功, 获取结果数量: %d", len(statsCalendarQueryResult))
// 处理查询结果
resultList := make([]StatsCalendarResult, 0)
for i, queryRes := range statsCalendarQueryResult {
if queryRes.Position != "" {
fields := strings.Fields(queryRes.Position)
if len(fields) >= 2 {
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]),
})
script.LogDebugDetailed(ledgerConfig.Mail, "StatsMonthCalendar",
"处理结果[%d]: Date=%s, Account=%s, Amount=%s, Currency=%s",
i,
queryRes.Date,
queryRes.Account,
fields[0],
fields[1])
} else {
script.LogWarn(ledgerConfig.Mail, "StatsMonthCalendar",
"结果格式异常[%d]: Position=%s", i, queryRes.Position)
}
}
}
script.LogDebugDetailed(ledgerConfig.Mail, "StatsMonthCalendar",
"返回结果数量: %d", len(resultList))
OK(c, resultList)
}
// StatsPayeeQueryResult 表示收款人统计查询的原始结果
//
// 注意:
// - 用于接收BQL查询的原始数据
// - 不直接暴露给API接口
//
// 字段说明:
// - Payee: 收款人名称
// - Count: 交易次数
// - Position: 金额字符串(格式:"100.00 CNY")
type StatsPayeeQueryResult struct {
Payee string
Count int32
Position string
}
// StatsPayeeResult 表示最终返回的收款人统计数据
//
// 字段说明:
// - Payee: 收款人名称
// - Currency: 货币代码(如"CNY")
// - Value: 统计值(交易次数或金额)
//
// JSON标签:
// - 所有字段使用camelCase命名
// - 金额始终序列化为字符串格式
type StatsPayeeResult struct {
Payee string `json:"payee"`
Currency string `json:"operatingCurrency"`
Value json.Number `json:"value"`
}
// StatsPayeeResultSort 实现sort.Interface用于StatsPayeeResult切片排序
//
// 功能说明:
// - 按Value值升序排列
// - 支持按交易次数或金额排序
//
// 实现方法:
// - Len(): 返回切片长度
// - Swap(i, j int): 交换两个元素的位置
// - Less(i, j int): 比较两个元素的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
}
// StatsPayee 获取收款人统计数据
//
// 功能说明:
// - 查询指定条件下的收款人交易数据
// - 支持按交易次数或金额统计
// - 返回排序后的收款人统计结果
//
// 请求参数:
// - prefix: 账户前缀过滤
// - year/month: 时间范围过滤
// - type: 统计类型(cot=交易次数, avg=平均金额, 其他=总金额)
// - 通过StatsQuery结构体绑定URL参数
//
// 处理流程:
// 1. 绑定并验证查询参数
// 2. 构建BQL查询语句:
// - 按收款人分组统计
// - 自动转换金额到运营货币
// 3. 执行查询并处理结果:
// - 根据type参数计算不同统计值
// - 过滤无效数据(如金额为0的交易)
// - 排序结果
//
// 返回值:
// - 成功: HTTP 200 返回[]StatsPayeeResult, 结构如下:
// [{
// "payee": "收款人A",
// "operatingCurrency": "CNY",
// "value": "1000.00"
// }]
// - 失败:
// - HTTP 400: 参数绑定错误
// - HTTP 500: 查询执行错误
//
// 数据结构说明:
// - 每条记录包含收款人名称、货币和统计值
// - 统计值可能是交易次数、平均金额或总金额
// - 使用json.Number保证精度
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查询语句
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的交易)
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)
}
// StatsCommodityPrice 获取商品价格数据
//
// 功能说明:
// - 查询账本中所有商品的最新价格
// - 返回标准化格式的价格数据
//
// 参数:
// - c *gin.Context: Gin请求上下文
// - 自动获取账本配置
//
// 返回值:
// - HTTP 200: 成功返回商品价格数据
// - 数据结构由script.BeanReportAllPrices决定
//
// 注意事项:
// - 直接调用底层script包的BeanReportAllPrices函数
// - 返回数据格式与底层实现保持一致
func StatsCommodityPrice(c *gin.Context) {
// 获取账本配置并查询价格数据
OK(c, script.BeanReportAllPrices(script.GetLedgerConfigFromContext(c)))
}