diff --git a/README.md b/README.md index dbfbc5c..180b8da 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,11 @@ flowchart LR ### Step 3 分析账单数据 +1. 执行 `awm chart` +2. 选择合并后的账单 `output_xxxxxxxxxx.csv` +3. 按 enter 键执行 +4. 输出 `charts.html` 为分析好的账单,使用浏览器打开即可查看 + ## LICENSE [MIT](./LICENSE) diff --git a/cmd/echart.go b/cmd/echart.go index 60f6a60..7e8f3e6 100644 --- a/cmd/echart.go +++ b/cmd/echart.go @@ -1,12 +1,17 @@ package cmd import ( + "os" + "sort" "strings" + "github.com/go-echarts/go-echarts/v2/charts" + "github.com/go-echarts/go-echarts/v2/opts" + "github.com/go-echarts/go-echarts/v2/types" "github.com/spf13/cobra" ) -var _ = &cobra.Command{ +var chartCmd = &cobra.Command{ Use: "chart", Short: "图表分析账单", Long: `使用 awm chart 分析合并后的账单`, @@ -15,15 +20,132 @@ var _ = &cobra.Command{ oPath = PromptSelectAnalysis() var b Bill if strings.Contains(oPath, ".csv") { - _, err := b.ReadMergeFile(oPath) + list, err := b.ReadMergeFile(oPath) if err != nil { return err } + Bar(list) } + return nil }, } func init() { - // rootCmd.AddCommand(chartCmd) + rootCmd.AddCommand(chartCmd) +} + +// generate random data for bar chart +func generateBarItems(list []*Account, pType int) []opts.BarData { + items := make([]opts.BarData, 0) + iTotal, oTotal := 0.0, 0.0 + for _, account := range list { + if account.IO == "收入" { + iTotal += account.Money * 100 + } + if account.IO == "支出" { + oTotal += account.Money * 100 + } + } + if pType == 1 { + items = append(items, opts.BarData{Name: "累计收入", Value: iTotal / 100}) + } + if pType == 2 { + items = append(items, opts.BarData{Name: "累计支出", Value: oTotal / 100}) + } + return items +} + +func generateLineItems(list []*Account, pType int) []opts.LineData { + sort.Sort(Accounts(list)) + items := make([]opts.LineData, 0) + goodsName := "" + for _, account := range list { + if account.TransFrom != "" { + goodsName = account.TransFrom + ":" + account.GoodsName + } else { + goodsName = account.TransType + } + switch pType { + case 1: + if account.IO == "收入" { + items = append(items, opts.LineData{Name: goodsName, Value: account.Money}) + } else { + items = append(items, opts.LineData{Name: "", Value: 0}) + } + case 2: + if account.IO == "支出" { + items = append(items, opts.LineData{Name: goodsName, Value: account.Money}) + } else { + items = append(items, opts.LineData{Name: "", Value: 0}) + } + } + } + + return items +} + +func Bar(list []*Account) { + // create a new bar instance + bar := charts.NewBar() + // set some global options like Title/Legend/ToolTip or anything else + bar.SetGlobalOptions( + charts.WithInitializationOpts(opts.Initialization{ + PageTitle: "账单分析", + Theme: types.ThemeMacarons}), + charts.WithLegendOpts(opts.Legend{ + Show: true, + }), + charts.WithTooltipOpts(opts.Tooltip{ + Show: true, + }), + charts.WithTitleOpts(opts.Title{ + Title: "收支分析", + Subtitle: "账单中收支项总和", + }), + ) + + var xAxis []string + sort.Sort(Accounts(list)) + for _, account := range list { + xAxis = append(xAxis, account.TransAt.Time.String()) + } + + bar.SetXAxis([]string{"累计"}). + AddSeries("收入", generateBarItems(list, 1)). + AddSeries("支出", generateBarItems(list, 2)) + + line := charts.NewLine() + var lineX []string + for _, account := range list { + lineX = append(lineX, account.TransAt.Format(layout)) + } + + line.SetGlobalOptions(charts.WithInitializationOpts(opts.Initialization{ + PageTitle: "账单分析", + Theme: types.ThemeMacarons}), + charts.WithLegendOpts(opts.Legend{ + Show: true, + }), + charts.WithDataZoomOpts(opts.DataZoom{ + Type: "slider", + }), + charts.WithTooltipOpts(opts.Tooltip{ + Show: true, + }), + charts.WithTitleOpts(opts.Title{ + Title: "收支详情分析", + Subtitle: "账单中收支项详情 by 交易时间", + }), + ) + + line.SetXAxis(lineX). + AddSeries("收入", generateLineItems(list, 1)). + AddSeries("支出", generateLineItems(list, 2)). + SetSeriesOptions(charts.WithLineChartOpts(opts.LineChart{Smooth: true})) + + // Where the magic happens + f, _ := os.Create("charts.html") + bar.Render(f) + line.Render(f) } diff --git a/cmd/parse.go b/cmd/parse.go index 2da0e5a..1051fb9 100644 --- a/cmd/parse.go +++ b/cmd/parse.go @@ -3,12 +3,15 @@ package cmd import ( "encoding/csv" "fmt" + "io" "io/ioutil" "os" "reflect" "strconv" "strings" + "time" + "github.com/gocarina/gocsv" "github.com/manifoldco/promptui" "golang.org/x/text/encoding/simplifiedchinese" "golang.org/x/text/transform" @@ -20,6 +23,34 @@ type Bill struct { wechat [][]string // 微信账单 } +type Account struct { + TransAt DateTime `csv:"交易时间"` + TransType string `csv:"交易类型"` + TransFrom string `csv:"交易对方"` + GoodsName string `csv:"商品"` + IO string `csv:"收/支"` + Money float64 `csv:"金额(元)"` + PayType string `csv:"支付方式"` + CurrentStatus string `csv:"当前状态"` + TransNo string `csv:"交易单号"` + BusinessNo string `csv:"商户单号"` + Remark string `csv:"备注"` +} + +type Accounts []*Account + +func (I Accounts) Len() int { + return len(I) +} + +func (I Accounts) Less(i, j int) bool { + return I[i].TransAt.Before(I[j].TransAt.Time) +} + +func (I Accounts) Swap(i, j int) { + I[i], I[j] = I[j], I[i] +} + func (b *Bill) ReadAliPay(path string) error { f, err := os.Open(path) if err != nil { @@ -69,7 +100,7 @@ func (b *Bill) ReadAliPay(path string) error { // 微信账单 // []string{"交易时间", "交易类型", "交易对方", "商品", "收/支", "金额(元)", "支付方式", "当前状态", "交易单号", "商户单号", "备注"} - tempV := []string{temp[2], temp[6], temp[7], temp[8], temp[10], "¥" + temp[9], "支付宝", temp[15], temp[0], temp[1], temp[14]} + tempV := []string{temp[2], temp[6], temp[7], temp[8], temp[10], temp[9], "支付宝", temp[15], temp[0], temp[1], temp[14]} // fmt.Printf("%#v\n", temp) b.aliPay = append(b.aliPay, tempV) } @@ -108,6 +139,7 @@ func (b *Bill) ReadWechatPay(path string) error { for _, v1 := range v { temp = append(temp, strings.Trim(strings.Trim(v1, "/"), " ")) } + temp[5] = strings.Trim(temp[5], "¥") // TODO: 从备注中提取出有服务费的项目,填写进入收支明细中。 b.wechat = append(b.wechat, temp) } @@ -122,10 +154,6 @@ func (b *Bill) WriteMergeFile(path string) error { fmt.Println(err) } defer destFile.Close() - _, err = destFile.WriteString("\xEF\xBB\xBF") - if err != nil { - return err - } // 写入一个UTF-8 BOM df := csv.NewWriter(destFile) b.GetTitle() @@ -154,33 +182,30 @@ func (b *Bill) WriteMergeFile(path string) error { } // ReadMergeFile 读取合并后的账单 -func (b *Bill) ReadMergeFile(path string) (data [][]string, err error) { +func (b *Bill) ReadMergeFile(path string) (accounts []*Account, err error) { f, err := os.Open(path) if err != nil { fmt.Println(err) return } defer f.Close() - reader := csv.NewReader(f) - reader.FieldsPerRecord = -1 - data, err = reader.ReadAll() - if err != nil { - fmt.Println(err) + gocsv.SetCSVReader(func(in io.Reader) gocsv.CSVReader { + r := csv.NewReader(in) + r.LazyQuotes = true + r.FieldsPerRecord = -1 + return r + }) + + if err = gocsv.UnmarshalFile(f, &accounts); err != nil { return } return } -// 支付宝账单 -// []string{"交易号", "商家订单号", "交易创建时间", "付款时间 ", "最近修改时间", "交易来源地", "类型", "交易对方", "商品名称", "金额(元)", "收/支", "交易状态 ", "服务费(元)", "成功退款(元)", "备注", "资金状态"} - -// 微信账单 -// []string{"交易时间", "交易类型", "交易对方", "商品", "收/支", "金额(元)", "支付方式", "当前状态", "交易单号", "商户单号", "备注"} - // GetTitle 转换 title func (b *Bill) GetTitle() { - titles := []string{"交易时间", "交易类型", "交易对方", "商品", "收/支", "金额(元)", "支付方式", "交易状态", "交易单号", "商户单号", "备注", "服务费(元)"} + titles := []string{"交易时间", "交易类型", "交易对方", "商品", "收/支", "金额(元)", "支付方式", "交易状态", "交易单号", "商户单号", "备注"} b.title = append(b.title, titles...) } @@ -245,3 +270,26 @@ func PromptSelectAnalysis() string { return result } + +type DateTime struct { + time.Time +} + +var layout = "2006-01-02 15:04:05" + +// MarshalCSV Convert the internal date as CSV string +func (date *DateTime) MarshalCSV() (string, error) { + return date.Format(layout), nil +} + +// String You could also use the standard Stringer interface +func (date *DateTime) String() string { + return date.String() // Redundant, just for example +} + +// UnmarshalCSV Convert the CSV string as internal date +func (date *DateTime) UnmarshalCSV(csv string) (err error) { + loc, _ := time.LoadLocation("Asia/Shanghai") + date.Time, err = time.ParseInLocation(layout, csv, loc) + return err +} diff --git a/go.mod b/go.mod index 6f22660..f07452e 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module github.com/yann0917/alipay-wechat-merge go 1.16 require ( + github.com/go-echarts/go-echarts/v2 v2.2.4 // indirect + github.com/gocarina/gocsv v0.0.0-20220310154401-d4df709ca055 // indirect github.com/manifoldco/promptui v0.9.0 github.com/spf13/cobra v1.4.0 golang.org/x/text v0.3.7 diff --git a/go.sum b/go.sum index 029e8b4..7c8395a 100644 --- a/go.sum +++ b/go.sum @@ -5,19 +5,34 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-echarts/go-echarts/v2 v2.2.4 h1:SKJpdyNIyD65XjbUZjzg6SwccTNXEgmh+PlaO23g2H0= +github.com/go-echarts/go-echarts/v2 v2.2.4/go.mod h1:6TOomEztzGDVDkOSCFBq3ed7xOYfbOqhaBzD0YV771A= +github.com/gocarina/gocsv v0.0.0-20220310154401-d4df709ca055 h1:UfcDMw41lSx3XM7UvD1i7Fsu3rMgD55OU5LYwLoR/Yk= +github.com/gocarina/gocsv v0.0.0-20220310154401-d4df709ca055/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.4.0 h1:y+wJpx64xcgO1V+RcnwW0LEHxTKRi2ZDPSBjWnrg88Q= github.com/spf13/cobra v1.4.0/go.mod h1:Wo4iy3BUC+X2Fybo0PDqwJIv3dNRiZLHQymsfxlB84g= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b h1:MQE+LT/ABUuuvEZ+YQAMSXindAdUh7slEmAkup74op4= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=