From 1d5a326a1a379f58b9c10f243d6ca965e9d2a165 Mon Sep 17 00:00:00 2001 From: BaoXuebin Date: Thu, 24 Oct 2024 23:26:07 +0800 Subject: [PATCH] add: update and delete transaction api --- script/bql.go | 17 ++++ script/file.go | 121 +++++++++++++++++++++++ script/utils.go | 31 ++++++ server.go | 3 + service/transactions.go | 210 +++++++++++++++++++++++++++++++++++----- 5 files changed, 359 insertions(+), 23 deletions(-) diff --git a/script/bql.go b/script/bql.go index e02ab46..9656f54 100644 --- a/script/bql.go +++ b/script/bql.go @@ -61,6 +61,10 @@ func GetQueryParams(c *gin.Context) QueryParams { queryParams.Limit = 100 hasWhere = true } + if c.Query("id") != "" { + queryParams.ID = c.Query("id") + hasWhere = true + } queryParams.Where = hasWhere if c.Query("path") != "" { queryParams.Path = c.Query("path") @@ -81,6 +85,19 @@ func GetQueryParams(c *gin.Context) QueryParams { // return nil //} +func BQLPrint(ledgerConfig *Config, transactionId string) (string, error) { + // PRINT FROM id = 'xxx' + output, err := queryByBQL(ledgerConfig, "PRINT FROM id = '"+transactionId+"'") + if err != nil { + return "", err + } + utf8, err := ConvertGBKToUTF8(output) + if err != nil { + return "", err + } + return utf8, nil +} + func BQLQueryList(ledgerConfig *Config, queryParams *QueryParams, queryResultPtr interface{}) error { assertQueryResultIsPointer(queryResultPtr) output, err := bqlRawQuery(ledgerConfig, "", queryParams, queryResultPtr) diff --git a/script/file.go b/script/file.go index 727b2a8..b8f6b5f 100644 --- a/script/file.go +++ b/script/file.go @@ -2,6 +2,7 @@ package script import ( "bufio" + "fmt" "io/ioutil" "os" "path/filepath" @@ -207,3 +208,123 @@ func MkDir(dirPath string) error { LogSystemInfo("Success mkdir " + dirPath) return nil } + +// FindConsecutiveMultilineTextInFile 查找文件中连续多行文本片段的开始和结束行号 +func FindConsecutiveMultilineTextInFile(filePath string, multilineLines []string) (startLine, endLine int, err error) { + for i := range multilineLines { + multilineLines[i] = cleanString(multilineLines[i]) + } + + file, err := os.Open(filePath) + if err != nil { + return -1, -1, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + startLine = -1 + endLine = -1 + lineNumber := 0 + matchIndex := 0 + + for scanner.Scan() { + lineNumber++ + // 清理文件中的当前行 + lineText := cleanString(scanner.Text()) + + // 检查当前行是否匹配多行文本片段的当前行 + if lineText == multilineLines[matchIndex] { + if startLine == -1 { + startLine = lineNumber // 记录起始行号 + } + matchIndex++ + // 如果所有行都匹配完成,记录结束行号并退出循环 + if matchIndex == len(multilineLines) { + endLine = lineNumber + break + } + } else { + // 如果匹配失败,重置匹配索引和起始行号 + matchIndex = 0 + startLine = -1 + } + } + + if err := scanner.Err(); err != nil { + return -1, -1, err + } + + // 如果未找到完整的多行文本片段,则返回 -1 + if startLine == -1 || endLine == -1 { + return -1, -1, fmt.Errorf("未找到连续的多行文本片段") + } + + LogSystemInfo("Success find content in file " + filePath + " line range: " + string(rune(startLine)) + "," + string(rune(endLine))) + return startLine, endLine, nil +} + +// cleanString 去除字符串中的首尾空白和中间的所有空格字符 +func cleanString(str string) string { + return strings.ReplaceAll(strings.TrimSpace(str), " ", "") +} + +// 删除指定行范围的内容 +func RemoveLines(filePath string, startLineNo, endLineNo int) ([]string, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer file.Close() + + // 读取文件的每一行 + var lines []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + // 检查行号的有效性 + if startLineNo < 1 || endLineNo > len(lines) || startLineNo > endLineNo { + return nil, fmt.Errorf("行号范围无效") + } + + // 删除从 startLineNo 到 endLineNo 的行(下标从 0 开始) + modifiedLines := append(lines[:startLineNo-1], lines[endLineNo:]...) + return modifiedLines, nil +} + +// 在指定行号插入多行文本 +func InsertLines(lines []string, startLineNo int, newLines []string) ([]string, error) { + // 检查插入位置的有效性 + if startLineNo < 1 || startLineNo > len(lines)+1 { + return nil, fmt.Errorf("插入行号无效") + } + + // 在指定位置插入新的内容 + modifiedLines := append(lines[:startLineNo-1], append(newLines, lines[startLineNo-1:]...)...) + return modifiedLines, nil +} + +// 写回文件 +func WriteToFile(filePath string, lines []string) error { + file, err := os.Create(filePath) + if err != nil { + return err + } + defer file.Close() + + // 将修改后的内容写回文件 + writer := bufio.NewWriter(file) + for _, line := range lines { + _, err := writer.WriteString(line + "\n") + if err != nil { + return err + } + } + LogSystemInfo("Success write content in file " + filePath) + return writer.Flush() +} diff --git a/script/utils.go b/script/utils.go index c1ed18c..943ba54 100644 --- a/script/utils.go +++ b/script/utils.go @@ -2,6 +2,9 @@ package script import ( "bytes" + "golang.org/x/text/encoding/simplifiedchinese" + "golang.org/x/text/transform" + "io/ioutil" "math/rand" "net" "os/exec" @@ -84,3 +87,31 @@ func getMaxDate(str_date1 string, str_date2 string) string { } return max_date } + +// ConvertGBKToUTF8 将 GBK 编码的字符串转换为 UTF-8 编码 +func ConvertGBKToUTF8(gbkStr string) (string, error) { + if !isWindows() { + return gbkStr, nil + } + // 创建一个 GBK 到 UTF-8 的转换器 + reader := transform.NewReader(bytes.NewReader([]byte(gbkStr)), simplifiedchinese.GBK.NewDecoder()) + + // 将转换后的内容读出为 UTF-8 字符串 + utf8Bytes, err := ioutil.ReadAll(reader) + if err != nil { + return "", err + } + + return string(utf8Bytes), nil +} + +func GetMonth(date string) (string, error) { + // 解析日期字符串 + parsedDate, err := time.Parse("2006-01-02", date) + if err != nil { + return "", err + } + // 格式化日期为 "YYYY-MM" 格式 + formattedDate := parsedDate.Format("2006-01") + return formattedDate, nil +} diff --git a/server.go b/server.go index f68bcc8..af88ad0 100644 --- a/server.go +++ b/server.go @@ -79,8 +79,11 @@ func RegisterRouter(router *gin.Engine) { authorized.GET("/stats/month/total", service.StatsMonthTotal) authorized.GET("/stats/month/calendar", service.StatsMonthCalendar) authorized.GET("/stats/commodity/price", service.StatsCommodityPrice) + authorized.GET("/transaction/detail", service.QueryTransactionDetailById) + authorized.GET("/transaction/raw", service.QueryTransactionRawTextById) authorized.GET("/transaction", service.QueryTransactions) authorized.POST("/transaction", service.AddTransactions) + authorized.DELETE("/transaction", service.DeleteTransactionById) authorized.POST("/transaction/batch", service.AddBatchTransactions) authorized.GET("/transaction/payee", service.QueryTransactionPayees) authorized.GET("/transaction/template", service.QueryTransactionTemplates) diff --git a/service/transactions.go b/service/transactions.go index 90f18bd..b10263c 100644 --- a/service/transactions.go +++ b/service/transactions.go @@ -6,14 +6,13 @@ import ( "encoding/json" "errors" "fmt" + "github.com/beancount-gs/script" + "github.com/gin-gonic/gin" + "github.com/shopspring/decimal" "io" "strconv" "strings" "time" - - "github.com/beancount-gs/script" - "github.com/gin-gonic/gin" - "github.com/shopspring/decimal" ) type Transaction struct { @@ -35,6 +34,13 @@ type Transaction struct { IsAnotherCurrency bool `json:"isAnotherCurrency,omitempty"` } +type RawTransaction struct { + RawText string `json:"text"` + StartLineNo int `json:"startLineNo"` + EndLineNo int `json:"endLineNo"` + FilePath string `json:"filePath,omitempty"` +} + type TransactionSort []Transaction func (s TransactionSort) Len() int { @@ -49,6 +55,64 @@ func (s TransactionSort) Less(i, j int) bool { return a <= b } +func QueryTransactionDetailById(c *gin.Context) { + queryParams := script.GetQueryParams(c) + if queryParams.ID == "" { + BadRequest(c, "Param 'id' must not be blank.") + return + } + ledgerConfig := script.GetLedgerConfigFromContext(c) + transactions := make([]Transaction, 0) + err := script.BQLQueryList(ledgerConfig, &queryParams, &transactions) + if err != nil { + BadRequest(c, err.Error()) + return + } + if len(transactions) == 0 { + BadRequest(c, "No transaction found.") + } + + transactionForm := 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 + } + transactionEntryForm := TransactionEntryForm{ + Account: transaction.Account, + } + if transaction.Number != "" && transaction.Number != "0" { + transactionEntryForm.Number = decimal.RequireFromString(transaction.Number) + transactionEntryForm.Currency = transaction.Currency + transactionEntryForm.IsAnotherCurrency = transaction.IsAnotherCurrency + } + if transaction.CostPrice != "" && transaction.CostPrice != "0" { + transactionEntryForm.Price = decimal.RequireFromString(transaction.CostPrice) + transactionEntryForm.PriceCurrency = transaction.CostCurrency + } + transactionForm.Entries = append(transactionForm.Entries, transactionEntryForm) + } + OK(c, transactionForm) +} + +func QueryTransactionRawTextById(c *gin.Context) { + queryParams := script.GetQueryParams(c) + if queryParams.ID == "" { + BadRequest(c, "Param 'id' must not be blank.") + } + ledgerConfig := script.GetLedgerConfigFromContext(c) + 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) queryParams := script.GetQueryParams(c) @@ -83,16 +147,19 @@ func QueryTransactions(c *gin.Context) { OK(c, transactions) } -type AddTransactionForm struct { - Date string `form:"date" binding:"required"` - Payee string `form:"payee"` - Desc string `form:"desc" binding:"required"` - Tags []string `form:"tags"` - DivideDateList []string `form:"divideDateList"` - Entries []AddTransactionEntryForm `form:"entries"` +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"` + Raw RawTransaction `json:"raw,omitempty"` } -type AddTransactionEntryForm struct { +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"` @@ -101,7 +168,7 @@ type AddTransactionEntryForm struct { IsAnotherCurrency bool `form:"isAnotherCurrency" json:"isAnotherCurrency,omitempty"` } -func sum(entries []AddTransactionEntryForm, openingBalances string) decimal.Decimal { +func sum(entries []TransactionEntryForm, openingBalances string) decimal.Decimal { sumVal := decimal.NewFromInt(0) for _, entry := range entries { if entry.Account == openingBalances { @@ -118,7 +185,7 @@ func sum(entries []AddTransactionEntryForm, openingBalances string) decimal.Deci } func AddBatchTransactions(c *gin.Context) { - var addTransactionForms []AddTransactionForm + var addTransactionForms []TransactionForm if err := c.ShouldBindJSON(&addTransactionForms); err != nil { BadRequest(c, err.Error()) return @@ -137,7 +204,7 @@ func AddBatchTransactions(c *gin.Context) { } func AddTransactions(c *gin.Context) { - var addTransactionForm AddTransactionForm + var addTransactionForm TransactionForm if err := c.ShouldBindJSON(&addTransactionForm); err != nil { BadRequest(c, err.Error()) return @@ -169,7 +236,7 @@ func AddTransactions(c *gin.Context) { OK(c, nil) } -func saveTransaction(c *gin.Context, addTransactionForm AddTransactionForm, ledgerConfig *script.Config) error { +func saveTransaction(c *gin.Context, addTransactionForm TransactionForm, ledgerConfig *script.Config) error { // 账户是否平衡 sumVal := sum(addTransactionForm.Entries, ledgerConfig.OpeningBalances) val, _ := decimal.NewFromString("0.1") @@ -269,7 +336,40 @@ func saveTransaction(c *gin.Context, addTransactionForm AddTransactionForm, ledg return err } - err = script.AppendFileInNewLine(script.GetLedgerMonthFilePath(ledgerConfig.DataPath, monthStr), line) + beanFilePath := script.GetLedgerMonthFilePath(ledgerConfig.DataPath, monthStr) + if addTransactionForm.ID != "" { // 更新交易 + result, e := script.BQLPrint(ledgerConfig, addTransactionForm.ID) + if e != nil { + InternalError(c, e.Error()) + return errors.New(e.Error()) + } + // 使用 \r\t 分割多行文本片段,并清理每一行的空白 + oldLines := filterEmptyStrings(strings.Split(result, "\r\n")) + startLine, endLine, e := script.FindConsecutiveMultilineTextInFile(beanFilePath, oldLines) + if e != nil { + InternalError(c, e.Error()) + return errors.New(e.Error()) + } + lines, e := script.RemoveLines(beanFilePath, startLine, endLine) + if e != nil { + InternalError(c, e.Error()) + return errors.New(e.Error()) + } + newLines := filterEmptyStrings(strings.Split(line, "\r\n")) + newLines = append(newLines, "") + lines, e = script.InsertLines(lines, startLine, newLines) + if e != nil { + InternalError(c, e.Error()) + return errors.New(e.Error()) + } + e = script.WriteToFile(beanFilePath, lines) + if e != nil { + InternalError(c, e.Error()) + return errors.New(e.Error()) + } + } else { // 新增交易 + err = script.AppendFileInNewLine(beanFilePath, line) + } if err != nil { if c != nil { InternalError(c, err.Error()) @@ -279,6 +379,70 @@ func saveTransaction(c *gin.Context, addTransactionForm AddTransactionForm, ledg return nil } +// 过滤字符串数组中的空字符串 +func filterEmptyStrings(arr []string) []string { + // 创建一个新切片来存储非空字符串 + var result []string + for _, str := range arr { + if str != "" { // 检查字符串是否为空 + result = append(result, str) + } + } + return result +} + +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) + transactions := make([]Transaction, 0) + err := script.BQLQueryList(ledgerConfig, &queryParams, &transactions) + if err != nil { + BadRequest(c, err.Error()) + return + } + + if len(transactions) == 0 { + InternalError(c, "No transaction found.") + return + } + + month, err := script.GetMonth(transactions[0].Date) + if err != nil { + InternalError(c, err.Error()) + return + } + // 交易记录所在文件位置 + beanFilePath := script.GetLedgerMonthFilePath(ledgerConfig.DataPath, month) + result, e := script.BQLPrint(ledgerConfig, queryParams.ID) + if e != nil { + InternalError(c, e.Error()) + return + } + + oldLines := filterEmptyStrings(strings.Split(result, "\r\n")) + startLine, endLine, err := script.FindConsecutiveMultilineTextInFile(beanFilePath, oldLines) + if err != nil { + InternalError(c, err.Error()) + return + } + lines, e := script.RemoveLines(beanFilePath, startLine, endLine) + if e != nil { + InternalError(c, err.Error()) + return + } + err = script.WriteToFile(beanFilePath, lines) + if err != nil { + InternalError(c, err.Error()) + return + } + + OK(c, true) +} + type transactionPayee struct { Value string `bql:"distinct payee" json:"value"` } @@ -302,12 +466,12 @@ func QueryTransactionPayees(c *gin.Context) { } 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 []AddTransactionEntryForm `form:"entries" json:"entries"` + 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"` } func QueryTransactionTemplates(c *gin.Context) {