beancount-gs/service/transactions.go

860 lines
23 KiB
Go

package service
import (
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"strconv"
"strings"
"time"
"github.com/beancount-gs/script"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
)
// DateRange 查询账本中交易记录的时间范围
type DateRange struct {
MinDate string `bql:"min(date)" json:"minDate"`
MaxDate string `bql:"max(date)" json:"maxDate"`
}
// YearMonthOption 年月选项
type YearMonthOption struct {
Year int `json:"year"`
Month int `json:"month"` // 0 表示全年
Count int `json:"count"` // 该月份的交易数量
}
type Transaction struct {
Id string `bql:"distinct id" json:"id"`
Account string `bql:"account" json:"account"`
Date string `bql:"date" json:"date"`
Payee string `bql:"payee" json:"payee"`
Narration string `bql:"narration" json:"desc"`
Number string `bql:"number" json:"number"`
Balance string `bql:"balance" json:"balance"`
Currency string `bql:"currency" json:"currency"`
CostDate string `bql:"cost_date" json:"costDate"`
CostPrice string `bql:"cost_number" json:"costPrice"`
CostCurrency string `bql:"cost_currency" json:"costCurrency"`
Price string `bql:"price" json:"price"`
Tags []string `bql:"tags" json:"tags"`
CurrencySymbol string `json:"currencySymbol,omitempty"`
CostCurrencySymbol string `json:"costCurrencySymbol,omitempty"`
IsAnotherCurrency bool `json:"isAnotherCurrency,omitempty"`
}
type TransactionForm struct {
ID string `form:"id" json:"id"`
Date string `form:"date" binding:"required" json:"date"`
Payee string `form:"payee" json:"payee,omitempty"`
Desc string `form:"desc" binding:"required" json:"desc"`
Narration string `form:"narration" json:"narration,omitempty"`
Tags []string `form:"tags" json:"tags,omitempty"`
DivideDateList []string `form:"divideDateList" json:"divideDateList,omitempty"`
Entries []TransactionEntryForm `form:"entries" json:"entries"`
RawText string `json:"rawText,omitempty"`
}
type TransactionEntryForm struct {
Account string `form:"account" binding:"required" json:"account"`
Number decimal.Decimal `form:"number" json:"number,omitempty"`
Currency string `form:"currency" json:"currency"`
Price decimal.Decimal `form:"price" json:"price,omitempty"`
PriceCurrency string `form:"priceCurrency" json:"priceCurrency,omitempty"`
IsAnotherCurrency bool `form:"isAnotherCurrency" json:"isAnotherCurrency,omitempty"`
}
type UpdateRawTextTransactionForm struct {
ID string `form:"id" binding:"required" json:"id"`
RawText string `form:"rawText" json:"rawText,omitempty" binding:"required"`
}
type TransactionQuery struct {
Year int `form:"year"`
Month int `form:"month"`
Account string `form:"account"`
Tag string `form:"tag"`
Type string `form:"type"`
Limit int `form:"limit"`
Offset int `form:"offset"`
}
type TransactionTemplate struct {
Id string `json:"id"`
Date string `form:"date" binding:"required" json:"date"`
TemplateName string `form:"templateName" binding:"required" json:"templateName"`
Payee string `form:"payee" json:"payee"`
Desc string `form:"desc" binding:"required" json:"desc"`
Entries []TransactionEntryForm `form:"entries" json:"entries"`
}
// ==================== 工具函数 ====================
// getTransactionDateRange 获取交易记录的时间范围
func getTransactionDateRange(ledgerConfig *script.Config) (*DateRange, error) {
var dateRange []DateRange
queryParams := script.QueryParams{
Where: false,
}
err := script.BQLQueryList(ledgerConfig, &queryParams, &dateRange)
if err != nil {
return nil, err
}
if len(dateRange) == 0 {
currentDate := time.Now().Format("2006-01-02")
return &DateRange{
MinDate: currentDate,
MaxDate: currentDate,
}, nil
}
return &dateRange[0], nil
}
func buildQuery(params map[string]string) string {
var buf strings.Builder
for k, v := range params {
if buf.Len() > 0 {
buf.WriteByte('&')
}
buf.WriteString(url.QueryEscape(k))
buf.WriteByte('=')
buf.WriteString(url.QueryEscape(v))
}
return buf.String()
}
func truncateString(s string, length int) string {
if len(s) <= length {
return s
}
return s[:length] + "..."
}
func safeString(s string, defaultValue string) string {
if s == "" {
return defaultValue
}
return s
}
func safeTags(tags []string) string {
if len(tags) == 0 {
return "[]"
}
return fmt.Sprintf("%v", tags)
}
func logTransactionMultiline(mail string, index int, tx *Transaction) {
script.LogDebugDetailed(mail, "TransactionDetails", `
交易[%d]详细信息:
├── ID: %s
├── 账户: %s
├── 日期: %s
├── 收款方: %s
├── 描述: %s
├── 金额: %s
├── 货币: %s
├── 余额: %s
├── 成本日期: %s
├── 成本价格: %s
├── 成本货币: %s
├── 价格: %s
└── 标签: %v`,
index,
safeString(tx.Id, "N/A"),
safeString(tx.Account, "N/A"),
safeString(tx.Date, "N/A"),
safeString(tx.Payee, "N/A"),
truncateString(tx.Narration, 40),
safeString(tx.Number, "N/A"),
safeString(tx.Currency, "N/A"),
safeString(tx.Balance, "N/A"),
safeString(tx.CostDate, "N/A"),
safeString(tx.CostPrice, "N/A"),
safeString(tx.CostCurrency, "N/A"),
safeString(tx.Price, "N/A"),
safeTags(tx.Tags))
}
func filterEmptyStrings(arr []string) []string {
var result []string
for _, str := range arr {
if script.CleanString(str) != "" {
result = append(result, str)
}
}
return result
}
func sum(entries []TransactionEntryForm, openingBalances string) decimal.Decimal {
sumVal := decimal.NewFromInt(0)
for _, entry := range entries {
if entry.Account == openingBalances {
return decimal.NewFromInt(0)
}
pVal, _ := entry.Price.Float64()
if pVal == 0 {
sumVal = entry.Number.Add(sumVal)
} else {
sumVal = entry.Number.Mul(entry.Price).Add(sumVal)
}
}
return sumVal
}
// ==================== 交易查询相关 ====================
func QueryTransactionDetailById(c *gin.Context) {
ledgerConfig := script.GetLedgerConfigFromContext(c)
queryParams := script.GetQueryParams(c)
if queryParams.ID == "" {
BadRequest(c, "参数 'id' 不能为空")
return
}
transactions := make([]Transaction, 0)
err := script.BQLQueryList(ledgerConfig, &queryParams, &transactions)
if err != nil {
BadRequest(c, err.Error())
return
}
if len(transactions) == 0 {
BadRequest(c, "未找到交易记录")
return
}
transactionForm := TransactionForm{
Entries: make([]TransactionEntryForm, 0),
}
for _, transaction := range transactions {
if transactionForm.ID == "" {
transactionForm.ID = transaction.Id
transactionForm.Date = transaction.Date
transactionForm.Payee = transaction.Payee
transactionForm.Desc = transaction.Narration
transactionForm.Narration = transaction.Narration
}
entry := TransactionEntryForm{Account: transaction.Account}
if transaction.Number != "" && transaction.Number != "0" {
entry.Number = decimal.RequireFromString(transaction.Number)
entry.Currency = transaction.Currency
entry.IsAnotherCurrency = transaction.IsAnotherCurrency
}
if transaction.CostPrice != "" && transaction.CostPrice != "0" {
entry.Price = decimal.RequireFromString(transaction.CostPrice)
entry.PriceCurrency = transaction.CostCurrency
}
transactionForm.Entries = append(transactionForm.Entries, entry)
}
OK(c, transactionForm)
}
func QueryTransactionRawTextById(c *gin.Context) {
ledgerConfig := script.GetLedgerConfigFromContext(c)
queryParams := script.GetQueryParams(c)
if queryParams.ID == "" {
BadRequest(c, "参数 'id' 不能为空")
return
}
result, err := script.BQLPrint(ledgerConfig, queryParams.ID)
if err != nil {
InternalError(c, err.Error())
return
}
OK(c, result)
}
func QueryTransactions(c *gin.Context) {
ledgerConfig := script.GetLedgerConfigFromContext(c)
// 参数预处理
queryValues := c.Request.URL.Query()
params := make(map[string]string)
for k, v := range queryValues {
if len(v) > 0 {
params[k] = v[0]
}
}
// 处理undefined参数
now := time.Now()
if params["year"] == "undefined" {
params["year"] = strconv.Itoa(now.Year())
}
if params["month"] == "undefined" {
params["month"] = strconv.Itoa(int(now.Month()))
}
c.Request.URL.RawQuery = buildQuery(params)
// 绑定查询参数
var transactionQuery TransactionQuery
if err := c.ShouldBindQuery(&transactionQuery); err != nil {
BadRequest(c, "无效的查询参数")
return
}
// 获取账本时间范围
dateRange, err := getTransactionDateRange(ledgerConfig)
if err != nil {
InternalError(c, "获取账本时间范围失败")
return
}
// 解析最小日期作为默认起始点
minDate, err := time.Parse("2006-01-02", dateRange.MinDate)
if err != nil {
InternalError(c, "解析账本最小日期失败")
return
}
// 设置默认年月
if transactionQuery.Year <= 0 {
transactionQuery.Year = minDate.Year()
}
if transactionQuery.Month <= 0 || transactionQuery.Month > 12 {
transactionQuery.Month = int(minDate.Month())
}
// 构建查询参数
queryParams := script.QueryParams{
FromYear: minDate.Year(),
FromMonth: int(minDate.Month()),
Year: transactionQuery.Year,
Month: transactionQuery.Month,
Account: transactionQuery.Account,
Tag: transactionQuery.Tag,
Where: true,
OrderBy: "date desc",
Limit: transactionQuery.Limit,
From: true, // 启用起始日期查询
DateRange: true, // 启用日期范围查询
}
// 设置日期范围
if transactionQuery.Year <= 0 {
queryParams.Year = now.Year()
queryParams.Month = int(now.Month())
}
// 设置科目匹配模式
if transactionQuery.Account != "" {
queryParams.StrictAccountMatch = true
}
// 记录完整的查询参数
script.LogDebugDetailed(ledgerConfig.Mail, "QueryTransactions-FinalParams",
"完整查询参数: %+v (账本时间范围: %s 至 %s)",
queryParams, dateRange.MinDate, dateRange.MaxDate)
// 设置合理的limit
if queryParams.Limit <= 0 || queryParams.Limit > 1000 {
queryParams.Limit = 100
}
// 执行查询
transactions := make([]Transaction, 0)
err = script.BQLQueryList(ledgerConfig, &queryParams, &transactions)
if err != nil {
InternalError(c, "查询交易记录失败")
return
}
// 处理交易记录
currencyMap := script.GetLedgerCurrencyMap(ledgerConfig.Id)
for i := range transactions {
if i < 3 {
logTransactionMultiline(ledgerConfig.Mail, i, &transactions[i])
}
_, ok := currencyMap[transactions[i].Currency]
if ok {
transactions[i].IsAnotherCurrency = transactions[i].Currency != ledgerConfig.OperatingCurrency
}
symbol := script.GetCommoditySymbol(ledgerConfig.Id, transactions[i].Currency)
transactions[i].CurrencySymbol = symbol
transactions[i].CostCurrencySymbol = symbol
if transactions[i].Price != "" {
transactions[i].Price = strings.Fields(transactions[i].Price)[0]
}
if transactions[i].Balance != "" {
transactions[i].Balance = strings.Fields(transactions[i].Balance)[0]
}
}
OK(c, transactions)
}
// ==================== 交易操作相关 ====================
func AddBatchTransactions(c *gin.Context) {
var addTransactionForms []TransactionForm
if err := c.ShouldBindJSON(&addTransactionForms); err != nil {
BadRequest(c, err.Error())
return
}
result := make([]string, 0)
ledgerConfig := script.GetLedgerConfigFromContext(c)
for _, form := range addTransactionForms {
err := saveTransaction(nil, form, ledgerConfig)
if err == nil {
result = append(result, form.Date+form.Payee+form.Desc)
}
}
OK(c, result)
}
func AddTransactions(c *gin.Context) {
ledgerConfig := script.GetLedgerConfigFromContext(c)
var addTransactionForm TransactionForm
if err := c.ShouldBindJSON(&addTransactionForm); err != nil {
BadRequest(c, err.Error())
return
}
var err error
divideCount := len(addTransactionForm.DivideDateList)
if divideCount <= 0 {
err = saveTransaction(c, addTransactionForm, ledgerConfig)
} else {
// 分期处理
for idx, entry := range addTransactionForm.Entries {
addTransactionForm.Entries[idx].Number = entry.Number.Div(decimal.NewFromInt(int64(divideCount))).Round(3)
}
for _, date := range addTransactionForm.DivideDateList {
addTransactionForm.Date = date
err = saveTransaction(c, addTransactionForm, ledgerConfig)
if err != nil {
break
}
}
}
if err != nil {
InternalError(c, err.Error())
return
}
OK(c, nil)
}
func saveTransaction(c *gin.Context, form TransactionForm, ledgerConfig *script.Config) error {
// 余额检查
sumVal := sum(form.Entries, ledgerConfig.OpeningBalances)
val, _ := decimal.NewFromString("0.1")
if sumVal.Abs().GreaterThan(val) {
if c != nil {
TransactionNotBalance(c)
}
return errors.New("交易不平衡")
}
// 构建交易文本
line := fmt.Sprintf("\r\n%s * \"%s\" \"%s\"", form.Date, form.Payee, form.Desc)
// 添加标签
for _, tag := range form.Tags {
line += "#" + tag + " "
}
currencyMap := script.GetLedgerCurrencyMap(ledgerConfig.Id)
zero := decimal.NewFromInt(0)
for _, entry := range form.Entries {
if entry.Account == ledgerConfig.OpeningBalances {
line += fmt.Sprintf("\r\n %s", entry.Account)
} else {
line += fmt.Sprintf("\r\n %s %s %s", entry.Account, entry.Number.Round(2).StringFixedBank(2), entry.Currency)
}
// 多币种处理
if entry.Currency != ledgerConfig.OperatingCurrency && entry.Account != ledgerConfig.OpeningBalances {
if entry.Price.LessThanOrEqual(zero) {
continue
}
currency, isCurrency := currencyMap[entry.Currency]
currencyPrice := entry.Price
if currencyPrice.Equal(zero) {
currencyPrice, _ = decimal.NewFromString(currency.Price)
}
if !isCurrency {
if entry.Number.GreaterThan(zero) {
line += fmt.Sprintf(" {%s %s, %s}", entry.Price, ledgerConfig.OperatingCurrency, form.Date)
} else {
line += fmt.Sprintf(" {} @ %s %s", entry.Price, ledgerConfig.OperatingCurrency)
}
} else {
line += fmt.Sprintf(" {%s %s}", currencyPrice, ledgerConfig.OperatingCurrency)
}
// 更新价格文件
priceLine := fmt.Sprintf("%s price %s %s %s", form.Date, entry.Currency, entry.Price, ledgerConfig.OperatingCurrency)
if err := script.AppendFileInNewLine(script.GetLedgerPriceFilePath(ledgerConfig.DataPath), priceLine); err != nil {
return errors.New("internal error")
}
if isCurrency {
if err := script.LoadLedgerCurrencyMap(ledgerConfig); err != nil {
return errors.New("internal error")
}
}
}
}
// 确定文件路径
month, err := time.Parse("2006-01-02", form.Date)
if err != nil {
return errors.New("internal error")
}
monthStr := month.Format("2006-01")
if err := CreateMonthBeanFileIfNotExist(ledgerConfig.DataPath, monthStr); err != nil {
return err
}
beanFilePath := script.GetLedgerMonthFilePath(ledgerConfig.DataPath, monthStr)
if form.ID != "" {
// 更新交易
return updateTransaction(ledgerConfig, form, beanFilePath, line)
} else {
// 新增交易
return script.AppendFileInNewLine(beanFilePath, line)
}
}
func updateTransaction(ledgerConfig *script.Config, form TransactionForm, beanFilePath, newContent string) error {
result, err := script.BQLPrint(ledgerConfig, form.ID)
if err != nil {
return err
}
oldLines := filterEmptyStrings(strings.Split(result, "\n"))
startLine, endLine, err := script.FindConsecutiveMultilineTextInFile(beanFilePath, oldLines)
if err != nil {
return err
}
lines, err := script.RemoveLines(beanFilePath, startLine, endLine)
if err != nil {
return err
}
newLines := filterEmptyStrings(strings.Split(newContent, "\n"))
newLines = append(newLines, "")
lines, err = script.InsertLines(lines, startLine, newLines)
if err != nil {
return err
}
return script.WriteToFile(beanFilePath, lines)
}
// ==================== 其他功能 ====================
func UpdateTransactionRawTextById(c *gin.Context) {
var form UpdateRawTextTransactionForm
if err := c.ShouldBindJSON(&form); err != nil {
BadRequest(c, err.Error())
return
}
ledgerConfig := script.GetLedgerConfigFromContext(c)
beanFilePath, err := getBeanFilePathByTransactionId(form.ID, ledgerConfig)
if err != nil {
InternalError(c, err.Error())
return
}
result, err := script.BQLPrint(ledgerConfig, form.ID)
if err != nil {
InternalError(c, err.Error())
return
}
oldLines := filterEmptyStrings(strings.Split(result, "\n"))
startLine, endLine, err := script.FindConsecutiveMultilineTextInFile(beanFilePath, oldLines)
if err != nil {
InternalError(c, err.Error())
return
}
lines, err := script.RemoveLines(beanFilePath, startLine, endLine)
if err != nil {
InternalError(c, err.Error())
return
}
newLines := filterEmptyStrings(strings.Split(form.RawText, "\n"))
if len(newLines) > 0 {
lines, err = script.InsertLines(lines, startLine, newLines)
if err != nil {
InternalError(c, err.Error())
return
}
}
if err := script.WriteToFile(beanFilePath, lines); err != nil {
InternalError(c, err.Error())
return
}
OK(c, true)
}
func DeleteTransactionById(c *gin.Context) {
queryParams := script.GetQueryParams(c)
if queryParams.ID == "" {
BadRequest(c, "Param 'id' must not be blank.")
return
}
ledgerConfig := script.GetLedgerConfigFromContext(c)
beanFilePath, err := getBeanFilePathByTransactionId(queryParams.ID, ledgerConfig)
if err != nil {
InternalError(c, err.Error())
return
}
result, err := script.BQLPrint(ledgerConfig, queryParams.ID)
if err != nil {
InternalError(c, err.Error())
return
}
oldLines := filterEmptyStrings(strings.Split(result, "\n"))
startLine, endLine, err := script.FindConsecutiveMultilineTextInFile(beanFilePath, oldLines)
if err != nil {
InternalError(c, err.Error())
return
}
lines, err := script.RemoveLines(beanFilePath, startLine, endLine)
if err != nil {
InternalError(c, err.Error())
return
}
if err := script.WriteToFile(beanFilePath, lines); err != nil {
InternalError(c, err.Error())
return
}
OK(c, true)
}
func getBeanFilePathByTransactionId(transactionId string, ledgerConfig *script.Config) (string, error) {
queryParams := script.QueryParams{ID: transactionId, Where: true}
transactions := make([]Transaction, 0)
err := script.BQLQueryList(ledgerConfig, &queryParams, &transactions)
if err != nil {
return "", err
}
if len(transactions) == 0 {
return "", errors.New("no transaction found")
}
month, err := script.GetMonth(transactions[0].Date)
if err != nil {
return "", err
}
return script.GetLedgerMonthFilePath(ledgerConfig.DataPath, month), nil
}
// ==================== 模板相关 ====================
func QueryTransactionTemplates(c *gin.Context) {
ledgerConfig := script.GetLedgerConfigFromContext(c)
filePath := script.GetLedgerTransactionsTemplateFilePath(ledgerConfig.DataPath)
templates, err := getLedgerTransactionTemplates(filePath)
if err != nil {
InternalError(c, err.Error())
return
}
OK(c, templates)
}
func AddTransactionTemplate(c *gin.Context) {
var template TransactionTemplate
if err := c.ShouldBindJSON(&template); err != nil {
BadRequest(c, err.Error())
return
}
ledgerConfig := script.GetLedgerConfigFromContext(c)
filePath := script.GetLedgerTransactionsTemplateFilePath(ledgerConfig.DataPath)
templates, err := getLedgerTransactionTemplates(filePath)
if err != nil {
InternalError(c, err.Error())
return
}
// 生成唯一ID
t := sha1.New()
io.WriteString(t, time.Now().String())
template.Id = hex.EncodeToString(t.Sum(nil))
templates = append(templates, template)
if err := writeLedgerTransactionTemplates(filePath, templates); err != nil {
InternalError(c, err.Error())
return
}
OK(c, template)
}
func DeleteTransactionTemplate(c *gin.Context) {
templateId := c.Query("id")
if templateId == "" {
BadRequest(c, "templateId is not blank")
return
}
ledgerConfig := script.GetLedgerConfigFromContext(c)
filePath := script.GetLedgerTransactionsTemplateFilePath(ledgerConfig.DataPath)
oldTemplates, err := getLedgerTransactionTemplates(filePath)
if err != nil {
InternalError(c, err.Error())
return
}
newTemplates := make([]TransactionTemplate, 0)
for _, template := range oldTemplates {
if template.Id != templateId {
newTemplates = append(newTemplates, template)
}
}
if err := writeLedgerTransactionTemplates(filePath, newTemplates); err != nil {
InternalError(c, err.Error())
return
}
OK(c, templateId)
}
func getLedgerTransactionTemplates(filePath string) ([]TransactionTemplate, error) {
result := make([]TransactionTemplate, 0)
if !script.FileIfExist(filePath) {
return result, nil
}
bytes, err := script.ReadFile(filePath)
if err != nil {
return nil, err
}
err = json.Unmarshal(bytes, &result)
return result, err
}
func writeLedgerTransactionTemplates(filePath string, templates []TransactionTemplate) error {
if !script.FileIfExist(filePath) {
if err := script.CreateFile(filePath); err != nil {
return err
}
}
bytes, err := json.Marshal(templates)
if err != nil {
return err
}
return script.WriteFile(filePath, string(bytes))
}
// ==================== 辅助查询 ====================
type transactionPayee struct {
Value string `bql:"distinct payee" json:"value"`
}
func QueryTransactionPayees(c *gin.Context) {
ledgerConfig := script.GetLedgerConfigFromContext(c)
payeeList := make([]transactionPayee, 0)
queryParams := script.QueryParams{
Where: false,
OrderBy: "date desc",
Limit: 100,
}
err := script.BQLQueryList(ledgerConfig, &queryParams, &payeeList)
if err != nil {
InternalError(c, err.Error())
return
}
result := make([]string, 0)
for _, payee := range payeeList {
if payee.Value != "" {
result = append(result, payee.Value)
}
}
OK(c, result)
}
func QueryAvailableYearMonths(c *gin.Context) {
ledgerConfig := script.GetLedgerConfigFromContext(c)
var yearMonths []struct {
Year int `bql:"year" json:"year"`
Month int `bql:"month" json:"month"`
Count int `bql:"count" json:"count"`
}
queryParams := script.QueryParams{
Where: false,
GroupBy: "year, month",
OrderBy: "year desc, month desc",
}
err := script.BQLQueryList(ledgerConfig, &queryParams, &yearMonths)
if err != nil {
InternalError(c, "查询可用年月失败")
return
}
result := make([]YearMonthOption, 0)
result = append(result, YearMonthOption{Year: 0, Month: 0, Count: -1})
for _, ym := range yearMonths {
result = append(result, YearMonthOption{
Year: ym.Year,
Month: ym.Month,
Count: ym.Count,
})
}
OK(c, result)
}