package server import ( "encoding/csv" "encoding/json" "fmt" "io" "log" "net/http" "strconv" "strings" "time" "odoo-go/pkg/orm" ) // handleBankStatementImport imports bank statement lines from CSV data. // Accepts JSON body with: journal_id, csv_data, column_mapping, has_header. // After import, optionally triggers auto-matching against open invoices. // Mirrors: odoo/addons/account/wizard/account_bank_statement_import.py func (s *Server) handleBankStatementImport(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req JSONRPCRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { s.writeJSONRPC(w, nil, nil, &RPCError{Code: -32700, Message: "Parse error"}) return } var params struct { JournalID int64 `json:"journal_id"` CSVData string `json:"csv_data"` HasHeader bool `json:"has_header"` ColumnMapping bankColumnMapping `json:"column_mapping"` AutoMatch bool `json:"auto_match"` } if err := json.Unmarshal(req.Params, ¶ms); err != nil { s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "Invalid params"}) return } if params.JournalID == 0 || params.CSVData == "" { s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "journal_id and csv_data are required"}) return } uid := int64(1) companyID := int64(1) if sess := GetSession(r); sess != nil { uid = sess.UID companyID = sess.CompanyID } env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{ Pool: s.pool, UID: uid, CompanyID: companyID, }) if err != nil { s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32603, Message: "Internal error"}) return } defer env.Close() // Parse CSV reader := csv.NewReader(strings.NewReader(params.CSVData)) reader.LazyQuotes = true reader.TrimLeadingSpace = true // Try semicolon separator (common in European bank exports) reader.Comma = detectDelimiter(params.CSVData) var allRows [][]string for { row, err := reader.Read() if err == io.EOF { break } if err != nil { s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: fmt.Sprintf("CSV parse error: %v", err)}) return } allRows = append(allRows, row) } dataRows := allRows if params.HasHeader && len(allRows) > 1 { dataRows = allRows[1:] } // Create a bank statement header statementRS := env.Model("account.bank.statement") stmt, err := statementRS.Create(orm.Values{ "name": fmt.Sprintf("Import %s", time.Now().Format("2006-01-02 15:04")), "journal_id": params.JournalID, "company_id": companyID, "date": time.Now().Format("2006-01-02"), }) if err != nil { s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32603, Message: fmt.Sprintf("Create statement: %v", err)}) return } stmtID := stmt.ID() // Default column mapping cm := params.ColumnMapping if cm.Date < 0 { cm.Date = 0 } if cm.Amount < 0 { cm.Amount = 1 } if cm.Label < 0 { cm.Label = 2 } // Import lines lineRS := env.Model("account.bank.statement.line") var importedIDs []int64 var errors []importError for rowIdx, row := range dataRows { // Parse date dateStr := safeCol(row, cm.Date) date := parseFlexDate(dateStr) if date == "" { date = time.Now().Format("2006-01-02") } // Parse amount amountStr := safeCol(row, cm.Amount) amount := parseAmount(amountStr) if amount == 0 { continue // skip zero-amount rows } // Parse label/reference label := safeCol(row, cm.Label) if label == "" { label = fmt.Sprintf("Line %d", rowIdx+1) } // Parse optional columns partnerName := safeCol(row, cm.PartnerName) accountNumber := safeCol(row, cm.AccountNumber) vals := orm.Values{ "statement_id": stmtID, "journal_id": params.JournalID, "company_id": companyID, "date": date, "amount": amount, "payment_ref": label, "partner_name": partnerName, "account_number": accountNumber, "sequence": rowIdx + 1, } rec, err := lineRS.Create(vals) if err != nil { errors = append(errors, importError{Row: rowIdx + 1, Message: err.Error()}) log.Printf("bank_import: row %d error: %v", rowIdx+1, err) continue } importedIDs = append(importedIDs, rec.ID()) } // Auto-match against open invoices matchCount := 0 if params.AutoMatch && len(importedIDs) > 0 { stLineModel := orm.Registry.Get("account.bank.statement.line") if stLineModel != nil { if matchMethod, ok := stLineModel.Methods["button_match"]; ok { matchRS := env.Model("account.bank.statement.line").Browse(importedIDs...) if _, err := matchMethod(matchRS); err != nil { log.Printf("bank_import: auto-match error: %v", err) } } } // Count how many were matched env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM account_bank_statement_line WHERE id = ANY($1) AND is_reconciled = true`, importedIDs).Scan(&matchCount) } if err := env.Commit(); err != nil { s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32603, Message: fmt.Sprintf("Commit: %v", err)}) return } s.writeJSONRPC(w, req.ID, map[string]interface{}{ "statement_id": stmtID, "imported": len(importedIDs), "matched": matchCount, "errors": errors, }, nil) } // bankColumnMapping maps CSV columns to bank statement fields. type bankColumnMapping struct { Date int `json:"date"` // column index for date Amount int `json:"amount"` // column index for amount Label int `json:"label"` // column index for label/reference PartnerName int `json:"partner_name"` // column index for partner name (-1 = skip) AccountNumber int `json:"account_number"` // column index for account number (-1 = skip) } // detectDelimiter guesses the CSV delimiter (comma, semicolon, or tab). func detectDelimiter(data string) rune { firstLine := data if idx := strings.IndexByte(data, '\n'); idx > 0 { firstLine = data[:idx] } semicolons := strings.Count(firstLine, ";") commas := strings.Count(firstLine, ",") tabs := strings.Count(firstLine, "\t") if semicolons > commas && semicolons > tabs { return ';' } if tabs > commas { return '\t' } return ',' } // safeCol returns the value at index i, or "" if out of bounds. func safeCol(row []string, i int) string { if i < 0 || i >= len(row) { return "" } return strings.TrimSpace(row[i]) } // parseFlexDate tries multiple date formats and returns YYYY-MM-DD. func parseFlexDate(s string) string { s = strings.TrimSpace(s) if s == "" { return "" } formats := []string{ "2006-01-02", "02.01.2006", // DD.MM.YYYY (common in EU) "01/02/2006", // MM/DD/YYYY "02/01/2006", // DD/MM/YYYY "2006/01/02", "Jan 2, 2006", "2 Jan 2006", "02-01-2006", "01-02-2006", time.RFC3339, } for _, f := range formats { if t, err := time.Parse(f, s); err == nil { return t.Format("2006-01-02") } } return "" } // parseAmount parses a monetary amount string, handling comma/dot decimals and negative formats. func parseAmount(s string) float64 { s = strings.TrimSpace(s) if s == "" { return 0 } // Remove currency symbols and whitespace s = strings.NewReplacer("€", "", "$", "", "£", "", " ", "", "\u00a0", "").Replace(s) // Handle European format: 1.234,56 → 1234.56 if strings.Contains(s, ",") && strings.Contains(s, ".") { if strings.LastIndex(s, ",") > strings.LastIndex(s, ".") { // comma is decimal: 1.234,56 s = strings.ReplaceAll(s, ".", "") s = strings.ReplaceAll(s, ",", ".") } else { // dot is decimal: 1,234.56 s = strings.ReplaceAll(s, ",", "") } } else if strings.Contains(s, ",") { // Only comma: assume decimal separator s = strings.ReplaceAll(s, ",", ".") } v, err := strconv.ParseFloat(s, 64) if err != nil { return 0 } return v }