code refactor

This commit is contained in:
MaysWind
2024-10-10 23:53:11 +08:00
parent dc6420ccb0
commit 7bc9a0357e
10 changed files with 564 additions and 366 deletions
@@ -9,17 +9,19 @@ type alipayAppTransactionDataCsvImporter struct {
var (
AlipayAppTransactionDataCsvImporter = &alipayAppTransactionDataCsvImporter{
alipayTransactionDataCsvImporter{
fileHeaderLine: "------------------------------------------------------------------------------------",
dataHeaderStartContent: "支付宝(中国)网络技术有限公司 电子客户回单",
timeColumnName: "交易时间",
categoryColumnName: "交易分类",
targetNameColumnName: "交易对方",
productNameColumnName: "商品说明",
amountColumnName: "金额",
typeColumnName: "收/支",
relatedAccountColumnName: "收/付款方式",
statusColumnName: "交易状态",
descriptionColumnName: "备注",
fileHeaderLine: "------------------------------------------------------------------------------------",
dataHeaderStartContent: "支付宝(中国)网络技术有限公司 电子客户回单",
originalColumnNames: alipayTransactionColumnNames{
timeColumnName: "交易时间",
categoryColumnName: "交易分类",
targetNameColumnName: "交易对方",
productNameColumnName: "商品说明",
amountColumnName: "金额",
typeColumnName: "收/",
relatedAccountColumnName: "收/付款方式",
statusColumnName: "交易状态",
descriptionColumnName: "备注",
},
},
}
)
@@ -2,36 +2,15 @@ package alipay
import (
"bytes"
"encoding/csv"
"fmt"
"io"
"strings"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/locales"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
const alipayTransactionDataStatusSuccessName = "交易成功"
const alipayTransactionDataStatusPaymentSuccessName = "支付成功"
const alipayTransactionDataStatusRepaymentSuccessName = "还款成功"
const alipayTransactionDataStatusClosedName = "交易关闭"
const alipayTransactionDataStatusRefundSuccessName = "退款成功"
const alipayTransactionDataStatusTaxRefundSuccessName = "退税成功"
const alipayTransactionDataProductNameRechargePrefix = "充值-"
const alipayTransactionDataProductNameCashWithdrawalPrefix = "提现-"
const alipayTransactionDataProductNameTransferInText = "转入"
const alipayTransactionDataProductNameTransferOutText = "转出"
const alipayTransactionDataProductNameRepaymentText = "还款"
var alipayTransactionTypeNameMapping = map[models.TransactionType]string{
models.TRANSACTION_TYPE_INCOME: "收入",
models.TRANSACTION_TYPE_EXPENSE: "支出",
@@ -40,334 +19,34 @@ var alipayTransactionTypeNameMapping = map[models.TransactionType]string{
// alipayTransactionDataCsvImporter defines the structure of alipay csv importer for transaction data
type alipayTransactionDataCsvImporter struct {
fileHeaderLine string
dataHeaderStartContent string
dataBottomEndLineRune rune
timeColumnName string
categoryColumnName string
targetNameColumnName string
productNameColumnName string
amountColumnName string
typeColumnName string
relatedAccountColumnName string
statusColumnName string
descriptionColumnName string
fileHeaderLine string
dataHeaderStartContent string
dataBottomEndLineRune rune
originalColumnNames alipayTransactionColumnNames
}
// ParseImportedData returns the imported data by parsing the alipay transaction csv data
func (c *alipayTransactionDataCsvImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]*models.TransactionCategory, incomeCategoryMap map[string]*models.TransactionCategory, transferCategoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
enc := simplifiedchinese.GB18030
reader := transform.NewReader(bytes.NewReader(data), enc.NewDecoder())
allLines, err := c.parseAllLinesFromCsvData(ctx, reader)
dataTable, err := createNewAlipayTransactionPlainTextDataTable(
ctx,
reader,
c.fileHeaderLine,
c.dataHeaderStartContent,
c.dataBottomEndLineRune,
c.originalColumnNames,
)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
if len(allLines) <= 1 {
log.Errorf(ctx, "[alipayTransactionDataCsvImporter.ParseImportedData] cannot parse import data for user \"uid:%d\", because data table row count is less 1", user.Uid)
return nil, nil, nil, nil, nil, nil, errs.ErrNotFoundTransactionDataInFile
}
headerLineItems := allLines[0]
headerItemMap := make(map[string]int)
for i := 0; i < len(headerLineItems); i++ {
headerItemMap[headerLineItems[i]] = i
}
timeColumnIdx, timeColumnExists := headerItemMap[c.timeColumnName]
categoryColumnIdx, categoryColumnExists := headerItemMap[c.categoryColumnName]
targetNameColumnIdx, targetNameColumnExists := headerItemMap[c.targetNameColumnName]
productNameColumnIdx, productNameColumnExists := headerItemMap[c.productNameColumnName]
amountColumnIdx, amountColumnExists := headerItemMap[c.amountColumnName]
typeColumnIdx, typeColumnExists := headerItemMap[c.typeColumnName]
relatedAccountColumnIdx, relatedAccountColumnExists := headerItemMap[c.relatedAccountColumnName]
statusColumnIdx, statusColumnExists := headerItemMap[c.statusColumnName]
descriptionColumnIdx, descriptionColumnExists := headerItemMap[c.descriptionColumnName]
if !timeColumnExists || !amountColumnExists || !typeColumnExists || !statusColumnExists {
log.Errorf(ctx, "[alipayTransactionDataCsvImporter.ParseImportedData] cannot parse import data for user \"uid:%d\", because missing essential columns in header row", user.Uid)
return nil, nil, nil, nil, nil, nil, errs.ErrMissingRequiredFieldInHeaderRow
}
newColumns := make([]datatable.DataTableColumn, 0, 7)
newColumns = append(newColumns, datatable.DATA_TABLE_TRANSACTION_TYPE)
newColumns = append(newColumns, datatable.DATA_TABLE_TRANSACTION_TIME)
newColumns = append(newColumns, datatable.DATA_TABLE_SUB_CATEGORY)
newColumns = append(newColumns, datatable.DATA_TABLE_ACCOUNT_NAME)
newColumns = append(newColumns, datatable.DATA_TABLE_AMOUNT)
newColumns = append(newColumns, datatable.DATA_TABLE_RELATED_ACCOUNT_NAME)
newColumns = append(newColumns, datatable.DATA_TABLE_DESCRIPTION)
dataTable := datatable.CreateNewWritableDataTable(newColumns)
for i := 1; i < len(allLines); i++ {
items := allLines[i]
if len(items) < len(headerLineItems) {
log.Errorf(ctx, "[alipayTransactionDataCsvImporter.ParseImportedData] cannot parse row \"index:%d\" for user \"uid:%d\", because may missing some columns (column count %d in data row is less than header column count %d)", i, user.Uid, len(items), len(headerLineItems))
return nil, nil, nil, nil, nil, nil, errs.ErrFewerFieldsInDataRowThanInHeaderRow
}
if items[typeColumnIdx] != alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] &&
items[typeColumnIdx] != alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] &&
items[typeColumnIdx] != alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER] {
log.Warnf(ctx, "[alipayTransactionDataCsvImporter.ParseImportedData] skip parsing transaction in row \"index:%d\" for user \"uid:%d\", because type is \"%s\"", i, user.Uid, items[typeColumnIdx])
continue
}
if items[statusColumnIdx] != alipayTransactionDataStatusSuccessName &&
items[statusColumnIdx] != alipayTransactionDataStatusPaymentSuccessName &&
items[statusColumnIdx] != alipayTransactionDataStatusRepaymentSuccessName &&
items[statusColumnIdx] != alipayTransactionDataStatusClosedName &&
items[statusColumnIdx] != alipayTransactionDataStatusRefundSuccessName &&
items[statusColumnIdx] != alipayTransactionDataStatusTaxRefundSuccessName {
log.Warnf(ctx, "[alipayTransactionDataCsvImporter.ParseImportedData] skip parsing transaction in row \"index:%d\" for user \"uid:%d\", because status is \"%s\"", i, user.Uid, items[statusColumnIdx])
continue
}
data, errMsg := c.parseTransactionData(ctx,
user,
items,
timeColumnIdx,
timeColumnExists,
categoryColumnIdx,
categoryColumnExists,
targetNameColumnIdx,
targetNameColumnExists,
productNameColumnIdx,
productNameColumnExists,
amountColumnIdx,
amountColumnExists,
typeColumnIdx,
typeColumnExists,
relatedAccountColumnIdx,
relatedAccountColumnExists,
statusColumnIdx,
statusColumnExists,
descriptionColumnIdx,
descriptionColumnExists,
)
if data == nil {
log.Warnf(ctx, "[alipayTransactionDataCsvImporter.ParseImportedData] skip parsing transaction in row \"index:%d\" for user \"uid:%d\", because %s", i, user.Uid, errMsg)
continue
}
dataTable.Add(data)
}
dataTableImporter := datatable.CreateNewSimpleImporterFromWritableDataTable(
dataTable,
dataTableImporter := datatable.CreateNewSimpleImporter(
dataTable.GetDataColumnMapping(),
alipayTransactionTypeNameMapping,
)
return dataTableImporter.ParseImportedData(ctx, user, dataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
func (c *alipayTransactionDataCsvImporter) parseAllLinesFromCsvData(ctx core.Context, reader io.Reader) ([][]string, error) {
csvReader := csv.NewReader(reader)
csvReader.FieldsPerRecord = -1
allLines := make([][]string, 0)
hasFileHeader := false
foundContentBeforeDataHeaderLine := false
for {
items, err := csvReader.Read()
if err == io.EOF {
break
}
if err != nil {
log.Errorf(ctx, "[alipayTransactionDataCsvImporter.parseAllLinesFromCsvData] cannot parse alipay csv data, because %s", err.Error())
return nil, errs.ErrInvalidCSVFile
}
if !hasFileHeader {
if len(items) <= 0 {
continue
} else if strings.Index(items[0], c.fileHeaderLine) == 0 {
hasFileHeader = true
continue
} else {
log.Warnf(ctx, "[alipayTransactionDataCsvImporter.parseAllLinesFromCsvData] read unexpected line before read file header, line content is %s", strings.Join(items, ","))
}
}
if !foundContentBeforeDataHeaderLine {
if len(items) <= 0 {
continue
} else if strings.Index(items[0], c.dataHeaderStartContent) >= 0 {
foundContentBeforeDataHeaderLine = true
continue
} else {
continue
}
}
if foundContentBeforeDataHeaderLine {
if len(items) <= 0 {
continue
} else if len(items) == 1 && c.dataBottomEndLineRune > 0 && utils.ContainsOnlyOneRune(items[0], c.dataBottomEndLineRune) {
break
}
for i := 0; i < len(items); i++ {
items[i] = strings.Trim(items[i], " ")
}
allLines = append(allLines, items)
}
}
if !hasFileHeader || !foundContentBeforeDataHeaderLine {
return nil, errs.ErrInvalidFileHeader
}
return allLines, nil
}
func (c *alipayTransactionDataCsvImporter) parseTransactionData(
ctx core.Context,
user *models.User,
items []string,
timeColumnIdx int,
timeColumnExists bool,
categoryColumnIdx int,
categoryColumnExists bool,
targetNameColumnIdx int,
targetNameColumnExists bool,
productNameColumnIdx int,
productNameColumnExists bool,
amountColumnIdx int,
amountColumnExists bool,
typeColumnIdx int,
typeColumnExists bool,
relatedAccountColumnIdx int,
relatedAccountColumnExists bool,
statusColumnIdx int,
statusColumnExists bool,
descriptionColumnIdx int,
descriptionColumnExists bool,
) (map[datatable.DataTableColumn]string, string) {
data := make(map[datatable.DataTableColumn]string, 11)
if timeColumnExists && timeColumnIdx < len(items) {
data[datatable.DATA_TABLE_TRANSACTION_TIME] = items[timeColumnIdx]
}
if categoryColumnExists && categoryColumnIdx < len(items) {
data[datatable.DATA_TABLE_SUB_CATEGORY] = items[categoryColumnIdx]
} else {
data[datatable.DATA_TABLE_SUB_CATEGORY] = ""
}
if amountColumnExists && amountColumnIdx < len(items) {
data[datatable.DATA_TABLE_AMOUNT] = items[amountColumnIdx]
}
if descriptionColumnExists && descriptionColumnIdx < len(items) && items[descriptionColumnIdx] != "" {
data[datatable.DATA_TABLE_DESCRIPTION] = items[descriptionColumnIdx]
} else if productNameColumnExists && productNameColumnIdx < len(items) && items[productNameColumnIdx] != "" {
data[datatable.DATA_TABLE_DESCRIPTION] = items[productNameColumnIdx]
} else {
data[datatable.DATA_TABLE_DESCRIPTION] = ""
}
relatedAccountName := ""
if relatedAccountColumnExists && relatedAccountColumnIdx < len(items) {
relatedAccountName = items[relatedAccountColumnIdx]
}
statusName := ""
if statusColumnExists && statusColumnIdx < len(items) {
statusName = items[statusColumnIdx]
}
locale := user.Language
if locale == "" {
locale = ctx.GetClientLocale()
}
localeTextItems := locales.GetLocaleTextItems(locale)
if typeColumnExists && typeColumnIdx < len(items) {
data[datatable.DATA_TABLE_TRANSACTION_TYPE] = items[typeColumnIdx]
if items[typeColumnIdx] == alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] {
if statusName == alipayTransactionDataStatusClosedName {
return nil, fmt.Sprintf("income transaction is closed")
}
if statusName == alipayTransactionDataStatusSuccessName {
data[datatable.DATA_TABLE_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.Alipay
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
} else {
data[datatable.DATA_TABLE_ACCOUNT_NAME] = ""
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
}
} else if items[typeColumnIdx] == alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER] {
if statusName == alipayTransactionDataStatusClosedName {
return nil, fmt.Sprintf("non-income/expense transaction is closed")
}
targetName := ""
productName := ""
if targetNameColumnExists && targetNameColumnIdx < len(items) {
targetName = items[targetNameColumnIdx]
}
if productNameColumnExists && productNameColumnIdx < len(items) {
productName = items[productNameColumnIdx]
}
if statusName == alipayTransactionDataStatusRefundSuccessName {
data[datatable.DATA_TABLE_TRANSACTION_TYPE] = alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME]
data[datatable.DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
} else {
if strings.Index(productName, alipayTransactionDataProductNameRechargePrefix) == 0 { // transfer to alipay wallet
data[datatable.DATA_TABLE_ACCOUNT_NAME] = ""
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.Alipay
} else if strings.Index(productName, alipayTransactionDataProductNameCashWithdrawalPrefix) == 0 { // transfer from alipay wallet
data[datatable.DATA_TABLE_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.Alipay
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
} else if strings.Index(productName, alipayTransactionDataProductNameTransferInText) >= 0 { // transfer in
data[datatable.DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
} else if strings.Index(productName, alipayTransactionDataProductNameTransferOutText) >= 0 { // transfer out
data[datatable.DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
} else if strings.Index(productName, alipayTransactionDataProductNameRepaymentText) >= 0 { // repayment
data[datatable.DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
} else {
return nil, fmt.Sprintf("product name (\"%s\") is unknown", productName)
}
}
} else {
data[datatable.DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
}
}
if data[datatable.DATA_TABLE_TRANSACTION_TYPE] == alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] && statusName != "" {
if statusName == alipayTransactionDataStatusRefundSuccessName || statusName == alipayTransactionDataStatusTaxRefundSuccessName {
amount, err := utils.ParseAmount(data[datatable.DATA_TABLE_AMOUNT])
if err == nil {
data[datatable.DATA_TABLE_TRANSACTION_TYPE] = alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE]
data[datatable.DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
}
}
}
return data, ""
}
@@ -0,0 +1,466 @@
package alipay
import (
"encoding/csv"
"fmt"
"io"
"strings"
"time"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/locales"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
const alipayTransactionDataStatusSuccessName = "交易成功"
const alipayTransactionDataStatusPaymentSuccessName = "支付成功"
const alipayTransactionDataStatusRepaymentSuccessName = "还款成功"
const alipayTransactionDataStatusClosedName = "交易关闭"
const alipayTransactionDataStatusRefundSuccessName = "退款成功"
const alipayTransactionDataStatusTaxRefundSuccessName = "退税成功"
const alipayTransactionDataProductNameRechargePrefix = "充值-"
const alipayTransactionDataProductNameCashWithdrawalPrefix = "提现-"
const alipayTransactionDataProductNameTransferInText = "转入"
const alipayTransactionDataProductNameTransferOutText = "转出"
const alipayTransactionDataProductNameRepaymentText = "还款"
var alipayTransactionSupportedColumns = []datatable.DataTableColumn{
datatable.DATA_TABLE_TRANSACTION_TYPE,
datatable.DATA_TABLE_TRANSACTION_TIME,
datatable.DATA_TABLE_SUB_CATEGORY,
datatable.DATA_TABLE_ACCOUNT_NAME,
datatable.DATA_TABLE_AMOUNT,
datatable.DATA_TABLE_RELATED_ACCOUNT_NAME,
datatable.DATA_TABLE_DESCRIPTION,
}
// alipayTransactionColumnNames defines the structure of alipay transaction plain text header names
type alipayTransactionColumnNames struct {
timeColumnName string
categoryColumnName string
targetNameColumnName string
productNameColumnName string
amountColumnName string
typeColumnName string
relatedAccountColumnName string
statusColumnName string
descriptionColumnName string
}
// alipayTransactionPlainTextDataTable defines the structure of alipay transaction plain text data table
type alipayTransactionPlainTextDataTable struct {
allOriginalLines [][]string
originalHeaderLineColumnNames []string
originalTimeColumnIndex int
originalCategoryColumnIndex int
originalTargetNameColumnIndex int
originalProductNameColumnIndex int
originalAmountColumnIndex int
originalTypeColumnIndex int
originalRelatedAccountColumnIndex int
originalStatusColumnIndex int
originalDescriptionColumnIndex int
}
// alipayTransactionPlainTextDataRow defines the structure of alipay transaction plain text data row
type alipayTransactionPlainTextDataRow struct {
dataTable *alipayTransactionPlainTextDataTable
isValid bool
originalItems []string
finalItems map[datatable.DataTableColumn]string
}
// alipayTransactionPlainTextDataRowIterator defines the structure of alipay transaction plain text data row iterator
type alipayTransactionPlainTextDataRowIterator struct {
dataTable *alipayTransactionPlainTextDataTable
currentIndex int
}
// DataRowCount returns the total count of data row
func (t *alipayTransactionPlainTextDataTable) DataRowCount() int {
if len(t.allOriginalLines) < 1 {
return 0
}
return len(t.allOriginalLines) - 1
}
// GetDataColumnMapping returns data column map for data importer
func (t *alipayTransactionPlainTextDataTable) GetDataColumnMapping() map[datatable.DataTableColumn]string {
dataColumnMapping := make(map[datatable.DataTableColumn]string, len(alipayTransactionSupportedColumns))
for i := 0; i < len(alipayTransactionSupportedColumns); i++ {
column := alipayTransactionSupportedColumns[i]
dataColumnMapping[column] = utils.IntToString(int(column))
}
return dataColumnMapping
}
// HeaderLineColumnNames returns the header column name list
func (t *alipayTransactionPlainTextDataTable) HeaderLineColumnNames() []string {
columnIndexes := make([]string, len(alipayTransactionSupportedColumns))
for i := 0; i < len(alipayTransactionSupportedColumns); i++ {
columnIndexes[i] = utils.IntToString(int(alipayTransactionSupportedColumns[i]))
}
return columnIndexes
}
// DataRowIterator returns the iterator of data row
func (t *alipayTransactionPlainTextDataTable) DataRowIterator() datatable.ImportedDataRowIterator {
return &alipayTransactionPlainTextDataRowIterator{
dataTable: t,
currentIndex: 0,
}
}
// IsValid returns whether this row contains valid data for importing
func (r *alipayTransactionPlainTextDataRow) IsValid() bool {
return r.isValid
}
// ColumnCount returns the total count of column in this data row
func (r *alipayTransactionPlainTextDataRow) ColumnCount() int {
return len(r.finalItems)
}
// GetData returns the data in the specified column index
func (r *alipayTransactionPlainTextDataRow) GetData(columnIndex int) string {
if columnIndex >= len(alipayTransactionSupportedColumns) {
return ""
}
dataColumn := alipayTransactionSupportedColumns[columnIndex]
return r.finalItems[dataColumn]
}
// GetTime returns the time in the specified column index
func (r *alipayTransactionPlainTextDataRow) GetTime(columnIndex int, timezoneOffset int16) (time.Time, error) {
return utils.ParseFromLongDateTime(r.GetData(columnIndex), timezoneOffset)
}
// GetTimezoneOffset returns the time zone offset in the specified column index
func (r *alipayTransactionPlainTextDataRow) GetTimezoneOffset(columnIndex int) (*time.Location, error) {
return nil, errs.ErrNotSupported
}
// HasNext returns whether the iterator does not reach the end
func (t *alipayTransactionPlainTextDataRowIterator) HasNext() bool {
return t.currentIndex+1 < len(t.dataTable.allOriginalLines)
}
// Next returns the next imported data row
func (t *alipayTransactionPlainTextDataRowIterator) Next(ctx core.Context, user *models.User) datatable.ImportedDataRow {
if t.currentIndex+1 >= len(t.dataTable.allOriginalLines) {
return nil
}
t.currentIndex++
rowItems := t.dataTable.allOriginalLines[t.currentIndex]
isValid := true
if t.dataTable.originalTypeColumnIndex >= 0 &&
rowItems[t.dataTable.originalTypeColumnIndex] != alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] &&
rowItems[t.dataTable.originalTypeColumnIndex] != alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] &&
rowItems[t.dataTable.originalTypeColumnIndex] != alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER] {
log.Warnf(ctx, "[alipay_transaction_data_plain_text_data_table.Next] skip parsing transaction in row \"index:%d\", because type is \"%s\"", t.currentIndex, rowItems[t.dataTable.originalTypeColumnIndex])
isValid = false
}
if t.dataTable.originalStatusColumnIndex >= 0 &&
rowItems[t.dataTable.originalStatusColumnIndex] != alipayTransactionDataStatusSuccessName &&
rowItems[t.dataTable.originalStatusColumnIndex] != alipayTransactionDataStatusPaymentSuccessName &&
rowItems[t.dataTable.originalStatusColumnIndex] != alipayTransactionDataStatusRepaymentSuccessName &&
rowItems[t.dataTable.originalStatusColumnIndex] != alipayTransactionDataStatusClosedName &&
rowItems[t.dataTable.originalStatusColumnIndex] != alipayTransactionDataStatusRefundSuccessName &&
rowItems[t.dataTable.originalStatusColumnIndex] != alipayTransactionDataStatusTaxRefundSuccessName {
log.Warnf(ctx, "[alipay_transaction_data_plain_text_data_table.Next] skip parsing transaction in row \"index:%d\", because status is \"%s\"", t.currentIndex, rowItems[t.dataTable.originalStatusColumnIndex])
isValid = false
}
var finalItems map[datatable.DataTableColumn]string
var errMsg string
if isValid {
finalItems, errMsg = t.dataTable.parseTransactionData(ctx, user, rowItems)
if finalItems == nil {
log.Warnf(ctx, "[alipay_transaction_data_plain_text_data_table.Next] skip parsing transaction in row \"index:%d\", because %s", t.currentIndex, errMsg)
isValid = false
}
}
return &alipayTransactionPlainTextDataRow{
dataTable: t.dataTable,
isValid: isValid,
originalItems: rowItems,
finalItems: finalItems,
}
}
func (t *alipayTransactionPlainTextDataTable) parseTransactionData(ctx core.Context, user *models.User, items []string) (map[datatable.DataTableColumn]string, string) {
data := make(map[datatable.DataTableColumn]string, 7)
if t.originalTimeColumnIndex >= 0 && t.originalTimeColumnIndex < len(items) {
data[datatable.DATA_TABLE_TRANSACTION_TIME] = items[t.originalTimeColumnIndex]
}
if t.originalCategoryColumnIndex >= 0 && t.originalCategoryColumnIndex < len(items) {
data[datatable.DATA_TABLE_SUB_CATEGORY] = items[t.originalCategoryColumnIndex]
} else {
data[datatable.DATA_TABLE_SUB_CATEGORY] = ""
}
if t.originalAmountColumnIndex >= 0 && t.originalAmountColumnIndex < len(items) {
data[datatable.DATA_TABLE_AMOUNT] = items[t.originalAmountColumnIndex]
}
if t.originalDescriptionColumnIndex >= 0 && t.originalDescriptionColumnIndex < len(items) && items[t.originalDescriptionColumnIndex] != "" {
data[datatable.DATA_TABLE_DESCRIPTION] = items[t.originalDescriptionColumnIndex]
} else if t.originalProductNameColumnIndex >= 0 && t.originalProductNameColumnIndex < len(items) && items[t.originalProductNameColumnIndex] != "" {
data[datatable.DATA_TABLE_DESCRIPTION] = items[t.originalProductNameColumnIndex]
} else {
data[datatable.DATA_TABLE_DESCRIPTION] = ""
}
relatedAccountName := ""
if t.originalRelatedAccountColumnIndex >= 0 && t.originalRelatedAccountColumnIndex < len(items) {
relatedAccountName = items[t.originalRelatedAccountColumnIndex]
}
statusName := ""
if t.originalStatusColumnIndex >= 0 && t.originalStatusColumnIndex < len(items) {
statusName = items[t.originalStatusColumnIndex]
}
locale := user.Language
if locale == "" {
locale = ctx.GetClientLocale()
}
localeTextItems := locales.GetLocaleTextItems(locale)
if t.originalTypeColumnIndex >= 0 && t.originalTypeColumnIndex < len(items) {
data[datatable.DATA_TABLE_TRANSACTION_TYPE] = items[t.originalTypeColumnIndex]
if items[t.originalTypeColumnIndex] == alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] {
if statusName == alipayTransactionDataStatusClosedName {
return nil, fmt.Sprintf("income transaction is closed")
}
if statusName == alipayTransactionDataStatusSuccessName {
data[datatable.DATA_TABLE_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.Alipay
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
} else {
data[datatable.DATA_TABLE_ACCOUNT_NAME] = ""
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
}
} else if items[t.originalTypeColumnIndex] == alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER] {
if statusName == alipayTransactionDataStatusClosedName {
return nil, fmt.Sprintf("non-income/expense transaction is closed")
}
targetName := ""
productName := ""
if t.originalTargetNameColumnIndex >= 0 && t.originalTargetNameColumnIndex < len(items) {
targetName = items[t.originalTargetNameColumnIndex]
}
if t.originalProductNameColumnIndex >= 0 && t.originalProductNameColumnIndex < len(items) {
productName = items[t.originalProductNameColumnIndex]
}
if statusName == alipayTransactionDataStatusRefundSuccessName {
data[datatable.DATA_TABLE_TRANSACTION_TYPE] = alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME]
data[datatable.DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
} else {
if strings.Index(productName, alipayTransactionDataProductNameRechargePrefix) == 0 { // transfer to alipay wallet
data[datatable.DATA_TABLE_ACCOUNT_NAME] = ""
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.Alipay
} else if strings.Index(productName, alipayTransactionDataProductNameCashWithdrawalPrefix) == 0 { // transfer from alipay wallet
data[datatable.DATA_TABLE_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.Alipay
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
} else if strings.Index(productName, alipayTransactionDataProductNameTransferInText) >= 0 { // transfer in
data[datatable.DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
} else if strings.Index(productName, alipayTransactionDataProductNameTransferOutText) >= 0 { // transfer out
data[datatable.DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
} else if strings.Index(productName, alipayTransactionDataProductNameRepaymentText) >= 0 { // repayment
data[datatable.DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
} else {
return nil, fmt.Sprintf("product name (\"%s\") is unknown", productName)
}
}
} else {
data[datatable.DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
}
}
if data[datatable.DATA_TABLE_TRANSACTION_TYPE] == alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] && statusName != "" {
if statusName == alipayTransactionDataStatusRefundSuccessName || statusName == alipayTransactionDataStatusTaxRefundSuccessName {
amount, err := utils.ParseAmount(data[datatable.DATA_TABLE_AMOUNT])
if err == nil {
data[datatable.DATA_TABLE_TRANSACTION_TYPE] = alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE]
data[datatable.DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
}
}
}
return data, ""
}
func createNewAlipayTransactionPlainTextDataTable(ctx core.Context, reader io.Reader, fileHeaderLine string, dataHeaderStartContent string, dataBottomEndLineRune rune, originalColumnNames alipayTransactionColumnNames) (*alipayTransactionPlainTextDataTable, error) {
allOriginalLines, err := parseAllLinesFromAlipayTransactionPlainText(ctx, reader, fileHeaderLine, dataHeaderStartContent, dataBottomEndLineRune)
if err != nil {
return nil, err
}
if len(allOriginalLines) < 2 {
log.Errorf(ctx, "[alipay_transaction_data_plain_text_data_table.createNewAlipayTransactionPlainTextDataTable] cannot parse import data, because data table row count is less 1")
return nil, errs.ErrNotFoundTransactionDataInFile
}
originalHeaderItems := allOriginalLines[0]
originalHeaderItemMap := make(map[string]int)
for i := 0; i < len(originalHeaderItems); i++ {
originalHeaderItemMap[originalHeaderItems[i]] = i
}
timeColumnIdx, timeColumnExists := originalHeaderItemMap[originalColumnNames.timeColumnName]
categoryColumnIdx, categoryColumnExists := originalHeaderItemMap[originalColumnNames.categoryColumnName]
targetNameColumnIdx, targetNameColumnExists := originalHeaderItemMap[originalColumnNames.targetNameColumnName]
productNameColumnIdx, productNameColumnExists := originalHeaderItemMap[originalColumnNames.productNameColumnName]
amountColumnIdx, amountColumnExists := originalHeaderItemMap[originalColumnNames.amountColumnName]
typeColumnIdx, typeColumnExists := originalHeaderItemMap[originalColumnNames.typeColumnName]
relatedAccountColumnIdx, relatedAccountColumnExists := originalHeaderItemMap[originalColumnNames.relatedAccountColumnName]
statusColumnIdx, statusColumnExists := originalHeaderItemMap[originalColumnNames.statusColumnName]
descriptionColumnIdx, descriptionColumnExists := originalHeaderItemMap[originalColumnNames.descriptionColumnName]
if !timeColumnExists || !amountColumnExists || !typeColumnExists || !statusColumnExists {
log.Errorf(ctx, "[alipay_transaction_data_plain_text_data_table.createNewAlipayTransactionPlainTextDataTable] cannot parse alipay csv data, because missing essential columns in header row")
return nil, errs.ErrMissingRequiredFieldInHeaderRow
}
if originalColumnNames.categoryColumnName == "" || !categoryColumnExists {
categoryColumnIdx = -1
}
if originalColumnNames.targetNameColumnName == "" || !targetNameColumnExists {
targetNameColumnIdx = -1
}
if originalColumnNames.productNameColumnName == "" || !productNameColumnExists {
productNameColumnIdx = -1
}
if originalColumnNames.relatedAccountColumnName == "" || !relatedAccountColumnExists {
relatedAccountColumnIdx = -1
}
if originalColumnNames.descriptionColumnName == "" || !descriptionColumnExists {
descriptionColumnIdx = -1
}
return &alipayTransactionPlainTextDataTable{
allOriginalLines: allOriginalLines,
originalHeaderLineColumnNames: originalHeaderItems,
originalTimeColumnIndex: timeColumnIdx,
originalCategoryColumnIndex: categoryColumnIdx,
originalTargetNameColumnIndex: targetNameColumnIdx,
originalProductNameColumnIndex: productNameColumnIdx,
originalAmountColumnIndex: amountColumnIdx,
originalTypeColumnIndex: typeColumnIdx,
originalRelatedAccountColumnIndex: relatedAccountColumnIdx,
originalStatusColumnIndex: statusColumnIdx,
originalDescriptionColumnIndex: descriptionColumnIdx,
}, nil
}
func parseAllLinesFromAlipayTransactionPlainText(ctx core.Context, reader io.Reader, fileHeaderLine string, dataHeaderStartContent string, dataBottomEndLineRune rune) ([][]string, error) {
csvReader := csv.NewReader(reader)
csvReader.FieldsPerRecord = -1
allOriginalLines := make([][]string, 0)
hasFileHeader := false
foundContentBeforeDataHeaderLine := false
for {
items, err := csvReader.Read()
if err == io.EOF {
break
}
if err != nil {
log.Errorf(ctx, "[alipay_transaction_data_plain_text_data_table.parseAllLinesFromAlipayTransactionPlainText] cannot parse alipay csv data, because %s", err.Error())
return nil, errs.ErrInvalidCSVFile
}
if !hasFileHeader {
if len(items) <= 0 {
continue
} else if strings.Index(items[0], fileHeaderLine) == 0 {
hasFileHeader = true
continue
} else {
log.Warnf(ctx, "[alipay_transaction_data_plain_text_data_table.parseAllLinesFromAlipayTransactionPlainText] read unexpected line before read file header, line content is %s", strings.Join(items, ","))
}
}
if !foundContentBeforeDataHeaderLine {
if len(items) <= 0 {
continue
} else if strings.Index(items[0], dataHeaderStartContent) >= 0 {
foundContentBeforeDataHeaderLine = true
continue
} else {
continue
}
}
if foundContentBeforeDataHeaderLine {
if len(items) <= 0 {
continue
} else if len(items) == 1 && dataBottomEndLineRune > 0 && utils.ContainsOnlyOneRune(items[0], dataBottomEndLineRune) {
break
}
for i := 0; i < len(items); i++ {
items[i] = strings.Trim(items[i], " ")
}
if len(allOriginalLines) > 0 && len(items) < len(allOriginalLines[0]) {
log.Errorf(ctx, "[alipay_transaction_data_plain_text_data_table.parseAllLinesFromAlipayTransactionPlainText] cannot parse row \"index:%d\", because may missing some columns (column count %d in data row is less than header column count %d)", len(allOriginalLines), len(items), len(allOriginalLines[0]))
return nil, errs.ErrFewerFieldsInDataRowThanInHeaderRow
}
allOriginalLines = append(allOriginalLines, items)
}
}
if !hasFileHeader || !foundContentBeforeDataHeaderLine {
return nil, errs.ErrInvalidFileHeader
}
return allOriginalLines, nil
}
@@ -9,18 +9,20 @@ type alipayWebTransactionDataCsvImporter struct {
var (
AlipayWebTransactionDataCsvImporter = &alipayWebTransactionDataCsvImporter{
alipayTransactionDataCsvImporter{
fileHeaderLine: "支付宝交易记录明细查询",
dataHeaderStartContent: "交易记录明细列表",
dataBottomEndLineRune: '-',
timeColumnName: "交易创建时间",
categoryColumnName: "",
targetNameColumnName: "交易对方",
productNameColumnName: "商品名称",
amountColumnName: "金额(元)",
typeColumnName: "收/支",
relatedAccountColumnName: "",
statusColumnName: "交易状态",
descriptionColumnName: "备注",
fileHeaderLine: "支付宝交易记录明细查询",
dataHeaderStartContent: "交易记录明细列表",
dataBottomEndLineRune: '-',
originalColumnNames: alipayTransactionColumnNames{
timeColumnName: "交易创建时间",
categoryColumnName: "",
targetNameColumnName: "交易对方",
productNameColumnName: "商品名称",
amountColumnName: "金额(元)",
typeColumnName: "收/支",
relatedAccountColumnName: "",
statusColumnName: "交易状态",
descriptionColumnName: "备注",
},
},
}
)
+10 -2
View File
@@ -1,6 +1,11 @@
package datatable
import "time"
import (
"time"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// ImportedDataTable defines the structure of imported data table
type ImportedDataTable interface {
@@ -16,6 +21,9 @@ type ImportedDataTable interface {
// ImportedDataRow defines the structure of imported data row
type ImportedDataRow interface {
// IsValid returns whether this row contains valid data for importing
IsValid() bool
// ColumnCount returns the total count of column in this data row
ColumnCount() int
@@ -35,7 +43,7 @@ type ImportedDataRowIterator interface {
HasNext() bool
// Next returns the next imported data row
Next() ImportedDataRow
Next(ctx core.Context, user *models.User) ImportedDataRow
}
// DataTableBuilder defines the structure of data table builder
@@ -75,6 +75,14 @@ func CreateNewImporter(dataColumnMapping map[DataTableColumn]string, transaction
}
}
// CreateNewSimpleImporter returns a new data table transaction data importer according to the specified arguments
func CreateNewSimpleImporter(dataColumnMapping map[DataTableColumn]string, transactionTypeMapping map[models.TransactionType]string) *DataTableTransactionDataImporter {
return &DataTableTransactionDataImporter{
dataColumnMapping: dataColumnMapping,
transactionTypeMapping: transactionTypeMapping,
}
}
// CreateNewSimpleImporterWithPostProcessFunc returns a new data table transaction data importer according to the specified arguments
func CreateNewSimpleImporterWithPostProcessFunc(dataColumnMapping map[DataTableColumn]string, transactionTypeMapping map[models.TransactionType]string, postProcessFunc DataTableTransactionDataImporterPostProcessFunc) *DataTableTransactionDataImporter {
return &DataTableTransactionDataImporter{
@@ -311,7 +319,12 @@ func (c *DataTableTransactionDataImporter) ParseImportedData(ctx core.Context, u
for dataRowIterator.HasNext() {
dataRowIndex++
dataRow := dataRowIterator.Next()
dataRow := dataRowIterator.Next(ctx, user)
if !dataRow.IsValid() {
continue
}
columnCount := dataRow.ColumnCount()
if columnCount < 1 || (columnCount == 1 && dataRow.GetData(0) == "") {
@@ -577,6 +590,11 @@ func (c *DataTableTransactionDataImporter) ParseImportedData(ctx core.Context, u
allNewTransactions = append(allNewTransactions, transaction)
}
if len(allNewTransactions) < 1 {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] no transaction data parsed for \"uid:%d\"", user.Uid)
return nil, nil, nil, nil, nil, nil, errs.ErrNotFoundTransactionDataInFile
}
sort.Sort(allNewTransactions)
return allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, nil
@@ -3,6 +3,8 @@ package datatable
import (
"time"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
@@ -89,6 +91,11 @@ func (t *WritableDataTable) DataRowIterator() ImportedDataRowIterator {
}
}
// IsValid returns whether this row contains valid data for importing
func (r *WritableDataRow) IsValid() bool {
return true
}
// ColumnCount returns the total count of column in this data row
func (r *WritableDataRow) ColumnCount() int {
return len(r.rowData)
@@ -121,7 +128,7 @@ func (t *WritableDataRowIterator) HasNext() bool {
}
// Next returns the next imported data row
func (t *WritableDataRowIterator) Next() ImportedDataRow {
func (t *WritableDataRowIterator) Next(ctx core.Context, user *models.User) ImportedDataRow {
if t.nextIndex >= len(t.dataTable.allData) {
return nil
}
@@ -6,6 +6,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
@@ -176,7 +178,7 @@ func TestWritableDataTableDataRowIterator(t *testing.T) {
iterator := writableDataTable.DataRowIterator()
for iterator.HasNext() {
dataRow := iterator.Next()
dataRow := iterator.Next(core.NewNullContext(), &models.User{})
actualTransactionTime, err := dataRow.GetTime(0, utils.GetTimezoneOffsetMinutes(time.Local))
assert.Nil(t, err)
@@ -6,7 +6,9 @@ import (
"time"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
@@ -61,6 +63,11 @@ func (t *ezBookKeepingTransactionPlainTextDataTable) DataRowIterator() datatable
}
}
// IsValid returns whether this row contains valid data for importing
func (r *ezBookKeepingTransactionPlainTextDataRow) IsValid() bool {
return true
}
// ColumnCount returns the total count of column in this data row
func (r *ezBookKeepingTransactionPlainTextDataRow) ColumnCount() int {
return len(r.allItems)
@@ -91,7 +98,7 @@ func (t *ezBookKeepingTransactionPlainTextDataRowIterator) HasNext() bool {
}
// Next returns the next imported data row
func (t *ezBookKeepingTransactionPlainTextDataRowIterator) Next() datatable.ImportedDataRow {
func (t *ezBookKeepingTransactionPlainTextDataRowIterator) Next(ctx core.Context, user *models.User) datatable.ImportedDataRow {
if t.currentIndex+1 >= len(t.dataTable.allLines) {
return nil
}
@@ -7,7 +7,9 @@ import (
"github.com/shakinm/xlsReader/xls"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
@@ -62,6 +64,11 @@ func (t *feideeMymoneyTransactionExcelFileDataTable) DataRowIterator() datatable
}
}
// IsValid returns whether this row contains valid data for importing
func (r *feideeMymoneyTransactionExcelFileDataRow) IsValid() bool {
return true
}
// ColumnCount returns the total count of column in this data row
func (r *feideeMymoneyTransactionExcelFileDataRow) ColumnCount() int {
row, err := r.sheet.GetRow(r.rowIndex)
@@ -142,7 +149,7 @@ func (t *feideeMymoneyTransactionExcelFileDataRowIterator) HasNext() bool {
}
// Next returns the next imported data row
func (t *feideeMymoneyTransactionExcelFileDataRowIterator) Next() datatable.ImportedDataRow {
func (t *feideeMymoneyTransactionExcelFileDataRowIterator) Next(ctx core.Context, user *models.User) datatable.ImportedDataRow {
allSheets := t.dataTable.workbook.GetSheets()
currentRowIndexInTable := t.currentRowIndexInTable